feat: режим «Значение + статичный значок» (#588)

Пятый вариант «Отображение»: маркер показывает выбранное значение и при этом
никогда не меняет цвет — ни по состоянию, ни по тревоге, ни по недоступности,
ни под цвет RGB-света, ни по активности.

Четыре прежних режима задавали одним выбором две независимые вещи: что
нарисовано внутри маркера и красится ли он состоянием. Поэтому вместо
сравнения с одним токеном появились два производных предиката рядом с
`normalizeDeviceDisplay` — `displayWantsValue` и `displayIsNeutral`, — и их
спрашивают политика, слой пульсации, внешний бейдж, редактор и три ветки
живого слоя пылесоса.

Две ловушки, из-за которых режим не сводится к одной строке в словаре:

- быстрый путь «статичному маркеру источники не нужны» (`sourceDetails: false`)
  — это основной путь рендера плана, карточки пространства и PDF. Он оставлен
  только режиму без значения: иначе число пропало бы именно на плане и
  осталось в предпросмотре редактора;
- три ветки живого пылесоса сравнивают режим строкой и не читают политику,
  поэтому зелёная политика их не гарантирует. Свидетель держит две
  конфигурации карты: сопоставленная доказывает puck и след, несопоставленная
  — бейдж маршрута (при совпадающей калибровке маршрут `ready`, и бейджа не
  было бы ни в одном режиме). Бейдж проверяется при наполненном буфере
  позиций, иначе его отсутствие объяснялось бы первой веткой.

Потолок initial View перецентрирован 291_700 → 292_400 без изменения общего
бюджета: измеренный факт 291 346 Б оставлял под прежним центром 354 Б —
внутри шумовой полосы метрики.

Эталон: в `device-icon-state-table-{light,dark}` включённая RGB-лампа
переведена в новый режим. Кадры обязаны разойтись; приёмка — отдельным
коммитом класса D с полного линуксового артефакта Validate.

Issue: #588
User-Visible: yes
This commit is contained in:
Claude
2026-09-18 13:40:57 +03:00
parent 03aaad3e2b
commit 99e1765d22
75 changed files with 1324 additions and 765 deletions
+13 -10
View File
@@ -1,5 +1,5 @@
import type { DeviceAvailability, DeviceVisualState } from './device-visual';
import type { DeviceDisplayMode } from './logic';
import { displayIsNeutral, displayWantsValue, type DeviceDisplayMode } from './logic';
export type PresentationReason =
| 'neutral'
@@ -23,6 +23,7 @@ export type PresentationReason =
| 'composite_power_source'
| 'activity_display_disabled'
| 'static_icon'
| 'value_static_icon'
| 'ha_disabled'
| 'orphaned';
@@ -94,7 +95,8 @@ export function resolveDevicePresentationPolicy(
decisions.push('lifecycle.active');
}
const staticIcon = input.display === 'static_icon';
const neutralFace = displayIsNeutral(input.display);
const wantsValue = displayWantsValue(input.display);
let visual = input.controllerFace
? { ...input.sourceVisual, availability: input.controllerAvailability }
: input.sourceVisual;
@@ -107,7 +109,7 @@ export function resolveDevicePresentationPolicy(
if (effectiveHidden) {
visual = NEUTRAL_VISUAL;
decisions.push('face.hidden');
} else if (staticIcon) {
} else if (neutralFace) {
visual = NEUTRAL_VISUAL;
decisions.push('face.static');
} else if (input.sourceVisual.status !== 'alarm' && !input.liveStates) {
@@ -117,7 +119,7 @@ export function resolveDevicePresentationPolicy(
decisions.push('face.dynamic');
}
if (!staticIcon && !effectiveHidden && input.liveStates
if (!neutralFace && !effectiveHidden && input.liveStates
&& input.sourceVisual.status !== 'alarm' && input.shortActivity) {
visual = { ...visual, activity: input.shortActivity };
decisions.push(`activity.short_${input.shortActivity}`);
@@ -129,17 +131,17 @@ export function resolveDevicePresentationPolicy(
else if (visual.status === 'open') decisions.push('status.open');
else decisions.push('status.neutral');
const face: PresentationFace = input.display === 'value'
const face: PresentationFace = wantsValue
&& !effectiveHidden && input.valueAvailable ? 'value' : 'icon';
decisions.push(face === 'value' ? 'content.value' : input.display === 'value'
decisions.push(face === 'value' ? 'content.value' : wantsValue
? 'content.value_fallback_icon' : 'content.icon');
if (input.display === 'value' && !input.valueAvailable && input.valueFallback) {
if (wantsValue && !input.valueAvailable && input.valueFallback) {
decisions.push(`content.${input.valueFallback}`);
}
const dynamicIcon = input.liveStates && !staticIcon && !effectiveHidden;
const metrics = !staticIcon && !effectiveHidden;
const pulseEligible = !effectiveHidden && !bindingUnavailable && !staticIcon
const dynamicIcon = input.liveStates && !neutralFace && !effectiveHidden;
const metrics = !neutralFace && !effectiveHidden;
const pulseEligible = !effectiveHidden && !bindingUnavailable && !neutralFace
&& visual.availability === 'available'
&& (visual.status === 'alarm' || (input.liveStates && input.display === 'icon_ripple'));
const vacuumLive = metrics && input.vacuumLiveRequested;
@@ -177,6 +179,7 @@ export function resolvePresentationReason(input: PresentationReasonInput): Prese
if (input.lifecycle === 'ha_disabled') return 'ha_disabled';
if (input.lifecycle === 'orphaned') return 'orphaned';
if (input.display === 'static_icon') return 'static_icon';
if (input.display === 'value_static_icon') return 'value_static_icon';
if (input.visual.status === 'alarm') return 'alarm';
if (!input.liveStates) return 'live_states_disabled';
if (input.visual.availability === 'unavailable') return 'unavailable';
+18 -9
View File
@@ -16,6 +16,7 @@ import {
type DeviceActivity, type DeviceAvailability, type DeviceVisualState, type EntityVisualSample,
} from './device-visual';
import {
displayIsNeutral, displayWantsValue,
hassValue, lightColorOf, lqiColor, normalizeDeviceDisplay, stateIcon, valueWithUnit,
type DeviceDisplayMode,
} from './logic';
@@ -86,7 +87,7 @@ export function deviceAccessibleLabel(
export function deviceA11yState(presentation: Pick<
ResolvedDevicePresentation, 'visual' | 'lockState' | 'display'
>): DeviceA11yState {
if (presentation.display === 'static_icon') return 'neutral';
if (displayIsNeutral(presentation.display)) return 'neutral';
if (presentation.visual.status === 'alarm') return 'alarm';
if (presentation.visual.availability === 'unavailable') return 'unavailable';
if (presentation.lockState) return presentation.lockState;
@@ -645,7 +646,7 @@ export function presentationClasses(presentation: Pick<
'visual' | 'lockState' | 'activity' | 'display' | 'effectiveHidden' | 'activityGeneration' | 'pulse'
>): string[] {
if (presentation.effectiveHidden) return [];
if (presentation.display === 'static_icon') return ['static-icon'];
if (displayIsNeutral(presentation.display)) return ['static-icon'];
const classes: string[] = [];
const { visual } = presentation;
if (presentation.pulse.kind === 'alarm') classes.push('alarm');
@@ -672,8 +673,16 @@ export function resolveDevicePresentation(
options: ResolvePresentationOptions,
): ResolvedDevicePresentation {
const display = normalizeDeviceDisplay(d.marker?.display);
const staticIcon = display === 'static_icon';
const sources = staticIcon && options.sourceDetails === false
// Two independent questions, and conflating them is the trap this mode was
// built to avoid (#588). `neutralFace` answers "does anything colour the
// marker"; `sourceless` answers "can we skip resolving sources at all", and
// only the value-free static mode can. `sourceDetails: false` is the main
// plan render path, so answering the second question with the first would
// drop the value exactly where the user looks and keep it in the editor
// preview, where sources are always resolved.
const neutralFace = displayIsNeutral(display);
const sourceless = display === 'static_icon';
const sources = sourceless && options.sourceDetails === false
? EMPTY_SOURCES : resolvePresentationSources(
hass, d, options.lightDevices || [d], options.lightSources,
options.registryHass || hass,
@@ -712,7 +721,7 @@ export function resolveDevicePresentation(
).length > 0;
const controllerFace = sources.sourceKind === 'controls'
|| (configuredController && sources.sourceKind !== 'light' && sources.sourceKind !== 'cover');
const value = staticIcon && options.sourceDetails === false
const value = sourceless && options.sourceDetails === false
? { source: null, text: null, fullText: null, fallback: null, explicit: false }
: resolveValue(hass, d, sources, options.showTemperature, markerStates);
const sourceSignature = signatureOf(d, sources, value.source);
@@ -780,7 +789,7 @@ export function resolveDevicePresentation(
const rippleScale = Number(d.marker?.ripple_size) > 0
? Number(d.marker!.ripple_size) : DEVICE_PULSE_DEFAULT_SCALE;
const configuredRippleColor = safeStoredColor(d.marker?.ripple_color, null);
const rippleColor = staticIcon ? null : configuredRippleColor || lightColor || null;
const rippleColor = neutralFace ? null : configuredRippleColor || lightColor || null;
const pulse = resolveDevicePulse({
display,
visual,
@@ -809,7 +818,7 @@ export function resolveDevicePresentation(
});
const notices: PresentationReason[] = [];
if (options.designPreview && userHidden) notices.push('hidden_design_preview');
if (!staticIcon && d.marker?.vacuum?.live === true) notices.push('vacuum_live_plan_only');
if (!neutralFace && d.marker?.vacuum?.live === true) notices.push('vacuum_live_plan_only');
const powerSource = sources.visualSources.some((source) =>
source.role === 'power_gate' || isDevicePowerSwitch(hass, source.eid),
);
@@ -817,7 +826,7 @@ export function resolveDevicePresentation(
eid.startsWith('switch.') && !hass?.entities?.[eid]?.entity_category,
).length;
if (powerSource && uncategorisedSwitches > 1) notices.push('composite_power_source');
if (display !== 'icon_ripple' && display !== 'static_icon'
if (display !== 'icon_ripple' && !neutralFace
&& combined.status !== 'alarm' && combined.activity !== 'none') {
notices.push('activity_display_disabled');
}
@@ -843,7 +852,7 @@ export function resolveDevicePresentation(
icon,
valueText,
valueFullText: policy.face === 'value' ? value.fullText : null,
fallbackReason: display === 'value' ? value.fallback : null,
fallbackReason: displayWantsValue(display) ? value.fallback : null,
activity,
activityGeneration: rt?.gen || 1,
pulse,
+2 -2
View File
@@ -1,5 +1,5 @@
import type { DeviceActivity, DeviceVisualState } from './device-visual';
import type { DeviceDisplayMode } from './logic';
import { displayIsNeutral, type DeviceDisplayMode } from './logic';
export type DevicePulseKind = 'none' | 'alarm' | 'short' | 'continuous';
export type DevicePulseReason = 'none' | 'alarm' | 'event' | 'presence' | 'transition' | 'running';
@@ -79,7 +79,7 @@ export function resolveDevicePulse(options: ResolveDevicePulseOptions): Resolved
? Math.max(1, Number(options.diameterScale)) : DEVICE_PULSE_DEFAULT_SCALE;
const color = options.color || semanticPulseColor(visual, semanticActivity);
if (effectiveHidden || bindingUnavailable || visual.availability === 'unavailable'
|| display === 'static_icon') {
|| displayIsNeutral(display)) {
return noPulse(generation, color, diameterScale);
}
+4 -4
View File
@@ -2,7 +2,7 @@ import {
climateTempFor, humFor, isHumEntity, isTempEntity, lqiFor, resolvedLightSources, tempFor,
} from './devices';
import { isRegistryEntryEnabled } from './ha-binding-status';
import { hassValue, valueWithUnit } from './logic';
import { displayIsNeutral, displayWantsValue, hassValue, valueWithUnit, type DeviceDisplayMode } from './logic';
import type {
DevItem, MarkerValueBadge, ValueBadgePosition, ValueBadgeSource,
} from './types';
@@ -47,7 +47,7 @@ export interface ValueBadgeCandidate {
export interface ResolveValueBadgeOptions {
showTemperature: boolean;
showSignal: boolean;
display: string;
display: DeviceDisplayMode;
effectiveHidden: boolean;
/** Canonical marker:* states already projected by presentation/light graph. */
markerStates?: readonly { ref: string; on: boolean; name: string }[];
@@ -276,7 +276,7 @@ export function resolveDeviceValueBadge(
hass: any, d: DevItem, options: ResolveValueBadgeOptions,
): ResolvedValueBadge | null {
const configured = d.marker?.value_badge != null;
if (options.effectiveHidden || options.display === 'static_icon') return null;
if (options.effectiveHidden || displayIsNeutral(options.display)) return null;
const stored = d.marker?.value_badge;
if (configured) {
if (!stored?.enabled) return null;
@@ -302,7 +302,7 @@ export function resolveDeviceValueBadge(
...resolveValueSource(hass, d, stored.source, options.markerStates),
};
}
if (!options.showTemperature || options.display === 'value') return null;
if (!options.showTemperature || displayWantsValue(options.display)) return null;
const source = legacySource(hass, d);
if (!source) return null;
let legacyText: string | null = null;
+9 -12
View File
@@ -45,7 +45,7 @@ import {
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
referencedContentUrls,
DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_UI_MODES, ROOM_FILL_MODES,
normalizeDeviceDisplay, isAlarmCapable, type DeviceDisplayMode,
normalizeDeviceDisplay, isAlarmCapable, displayIsNeutral, type DeviceDisplayMode,
liveText, liveTextReference, liveTextToken, hassValue, valueWithUnit, decorTextScale, decorTextLines,
DECOR_TEXT_BASE,
} from './logic';
@@ -450,16 +450,13 @@ type ResizePreview = { space: string; sp: any };
type ResizeWallUnion = ReturnType<typeof wallBodiesUnionPath>;
type ResizeWallArtifact = ReturnType<typeof wallBodiesGeometry>;
const DISPLAY_LABEL_KEYS: Record<DeviceDisplayMode, I18nKey> = {
badge: 'display.badge',
icon_ripple: 'display.icon_ripple',
value: 'display.value',
static_icon: 'display.static_icon',
badge: 'display.badge', icon_ripple: 'display.icon_ripple', value: 'display.value',
static_icon: 'display.static_icon', value_static_icon: 'display.value_static_icon',
};
const DISPLAY_HINT_KEYS: Record<DeviceDisplayMode, I18nKey> = {
badge: 'marker.display_hint_badge',
icon_ripple: 'marker.display_hint_icon_ripple',
value: 'marker.display_hint_value',
static_icon: 'marker.display_hint_static_icon',
badge: 'marker.display_hint_badge', icon_ripple: 'marker.display_hint_icon_ripple',
value: 'marker.display_hint_value', static_icon: 'marker.display_hint_static_icon',
value_static_icon: 'marker.display_hint_value_static_icon',
};
/** Keeps every previously valid scale at the maximum 20 cm grid scale lossless. */
const DECOR_TEXT_CM_MAX = 2000;
@@ -12195,7 +12192,7 @@ export class HouseplanCard extends LitElement {
if (!this.hass) return;
for (const d of this._devices) {
if (d.hidden || !this._isVacDev(d)) continue;
if (normalizeDeviceDisplay(d.marker?.display) === 'static_icon') {
if (displayIsNeutral(normalizeDeviceDisplay(d.marker?.display))) {
this._vacRt.delete(d.id);
continue;
}
@@ -12411,7 +12408,7 @@ export class HouseplanCard extends LitElement {
const trails: TemplateResult[] = [];
for (const d of devs) {
if (d.hidden || !this._isVacDev(d)) continue;
if (normalizeDeviceDisplay(d.marker?.display) === 'static_icon') continue;
if (displayIsNeutral(normalizeDeviceDisplay(d.marker?.display))) continue;
const fact = this._renderDeviceSnapshot?.facts.get(`vacuum:${d.id}`) as any;
const src = fact?.source ?? this._vacSource(d, this._renderPlanHass);
if (!src) continue;
@@ -12590,7 +12587,7 @@ export class HouseplanCard extends LitElement {
*/
private _vacRouteBadge(d: DevItem): TemplateResult | typeof nothing {
if (!this._isVacDev(d) || d.hidden) return nothing;
if (normalizeDeviceDisplay(d.marker?.display) === 'static_icon') return nothing;
if (displayIsNeutral(normalizeDeviceDisplay(d.marker?.display))) return nothing;
const fact = this._renderDeviceSnapshot?.facts.get(`vacuum:${d.id}`) as
{ resolution?: VacuumRouteResolution } | undefined;
const reason = routeWarningKey(fact?.resolution, this._vacRt.get(d.id)?.moving ?? false);
+11 -14
View File
@@ -49,7 +49,7 @@ import {
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
referencedContentUrls,
DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_UI_MODES, ROOM_FILL_MODES,
normalizeDeviceDisplay, isAlarmCapable, type DeviceDisplayMode,
normalizeDeviceDisplay, isAlarmCapable, displayIsNeutral, displayWantsValue, type DeviceDisplayMode,
liveText, liveTextReference, liveTextToken, hassValue, valueWithUnit, decorTextScale, decorTextLines,
DECOR_TEXT_BASE,
} from './logic';
@@ -414,16 +414,13 @@ type ResizePreview = { space: string; sp: any };
type ResizeWallUnion = ReturnType<typeof wallBodiesUnionPath>;
type ResizeWallArtifact = ReturnType<typeof wallBodiesGeometry>;
const DISPLAY_LABEL_KEYS: Record<DeviceDisplayMode, I18nKey> = {
badge: 'display.badge',
icon_ripple: 'display.icon_ripple',
value: 'display.value',
static_icon: 'display.static_icon',
badge: 'display.badge', icon_ripple: 'display.icon_ripple', value: 'display.value',
static_icon: 'display.static_icon', value_static_icon: 'display.value_static_icon',
};
const DISPLAY_HINT_KEYS: Record<DeviceDisplayMode, I18nKey> = {
badge: 'marker.display_hint_badge',
icon_ripple: 'marker.display_hint_icon_ripple',
value: 'marker.display_hint_value',
static_icon: 'marker.display_hint_static_icon',
badge: 'marker.display_hint_badge', icon_ripple: 'marker.display_hint_icon_ripple',
value: 'marker.display_hint_value', static_icon: 'marker.display_hint_static_icon',
value_static_icon: 'marker.display_hint_value_static_icon',
};
/** Keeps every previously valid scale at the maximum 20 cm grid scale lossless. */
const DECOR_TEXT_CM_MAX = 2000;
@@ -13394,7 +13391,7 @@ public _renderMarkerDialog(): TemplateResult {
</option>`)}
</select>
<p class="muted">${this.host._t(DISPLAY_HINT_KEYS[d.display])}</p>
${d.display === 'value' ? html`<div class="markerhelpfield markervaluesource">
${displayWantsValue(d.display) ? html`<div class="markerhelpfield markervaluesource">
<div class="markerhelplabel">
<label for="marker-value-source">${this.host._t('marker.value_source')}</label>
${this._help('marker.value_source.help')}
@@ -13427,7 +13424,7 @@ public _renderMarkerDialog(): TemplateResult {
<ha-icon icon="mdi:alert-outline"></ha-icon>${this.host._t('marker.value_source_missing_hint')}
</p>` : nothing}
</div>` : nothing}
${d.display === 'static_icon' && this.host._bindingHasAlarm(d.binding)
${displayIsNeutral(d.display) && this.host._bindingHasAlarm(d.binding)
? html`<div class="habindingbanner" role="note">
<ha-icon icon="mdi:alert-outline"></ha-icon>
<span>${this.host._t('marker.static_alarm_warning')}</span>
@@ -13444,10 +13441,10 @@ public _renderMarkerDialog(): TemplateResult {
valueBadgeSource: source,
valueBadgeTouched: true,
};
}, d.display === 'static_icon' || (!badgeCandidates.length && !d.valueBadgeSource))}
}, displayIsNeutral(d.display) || (!badgeCandidates.length && !d.valueBadgeSource))}
<span>${this.host._t('marker.value_badge_enabled')}</span>
</label>
${d.display === 'static_icon'
${displayIsNeutral(d.display)
? html`<p class="muted markerlightdisabled" role="note">
<ha-icon icon="mdi:information-outline"></ha-icon>${this.host._t('marker.value_badge_static')}
</p>`
@@ -13483,7 +13480,7 @@ public _renderMarkerDialog(): TemplateResult {
${badgeSourceMissing ? html`<p class="muted markerlightwarning" role="status">
<ha-icon icon="mdi:alert-outline"></ha-icon>${this.host._t('marker.value_badge_missing_hint')}
</p>` : nothing}
${d.display === 'value' && badgeSourceKey === innerValueSourceKey
${displayWantsValue(d.display) && badgeSourceKey === innerValueSourceKey
? html`<p class="muted markerlightwarning" role="note">
<ha-icon icon="mdi:information-outline"></ha-icon>${this.host._t('marker.value_badge_duplicate')}
</p>` : nothing}
+3
View File
@@ -184,10 +184,12 @@
"display.badge": "Symbol + Zustand",
"display.icon_ripple": "Symbol + Zustand und Aktivität",
"display.static_icon": "Immer statisches Symbol",
"display.value_static_icon": "Wert + statisches Symbol",
"marker.display_hint_badge": "Das Symbol und die dynamische Platte zeigen den Gerätezustand ohne den üblichen Aktivitätspuls. Rote Alarme bleiben sichtbar.",
"marker.display_hint_icon_ripple": "Das Symbol, die dynamische Platte und der Puls zeigen einen kurzen Puls für Ereignisse und einen kontinuierlichen Puls für Arbeit, Bewegung oder Anwesenheit. Rote Alarme haben eine separate Priorität.",
"marker.display_hint_value": "Ein ausgewählter oder automatisch ermittelter Home Assistant-Wert ersetzt das Symbol, während die Platte weiterhin den Zustand anzeigt. Rote Alarme bleiben sichtbar.",
"marker.display_hint_static_icon": "Die themenabhängige Hülle und das Symbol bleiben immer gleich. Zustand, Aktivität, Nichtverfügbarkeit und Alarme verändern nicht das Erscheinungsbild.",
"marker.display_hint_value_static_icon": "Der Marker zeigt den gewählten Wert, während die Hülle themenabhängig bleibt. Zustand, Aktivität, Nichtverfügbarkeit und Alarme ändern die Markerfarbe nicht.",
"marker.static_alarm_warning": "Dieses Gerät kann Alarme melden. Die statische Anzeige verbirgt die visuelle Alarmanzeige des Markers.",
"marker.preview.title": "Anzeigevorschau",
"marker.preview.actual": "Jetzt",
@@ -236,6 +238,7 @@
"marker.preview.reason.ha_disabled": "Die Home Assistant-Verbindung ist deaktiviert und wird im Plan ausgeblendet",
"marker.preview.reason.orphaned": "Die gespeicherte Home Assistant-Verbindung konnte nicht mehr gefunden werden",
"marker.preview.reason.static_icon": "Statischer Modus: Der Gerätestatus ändert das Symbol nicht",
"marker.preview.reason.value_static_icon": "Statischer Modus mit Wert: Der Gerätestatus ändert die Markerfarbe nicht",
"marker.activity_color": "Aktivitätspulsfarbe",
"marker.ripple_size": "Aktivitätspulsgröße",
"marker.activity_alarm_note": "Farbe und Größe betreffen nur gewöhnliche Aktivität und ändern keine roten Alarme.",
+3
View File
@@ -184,10 +184,12 @@
"display.badge": "Icon + state",
"display.icon_ripple": "Icon + state and activity",
"display.static_icon": "Always static icon",
"display.value_static_icon": "Value + static icon",
"marker.display_hint_badge": "The icon and dynamic plate show device state without the ordinary activity pulse. Red alarms remain visible.",
"marker.display_hint_icon_ripple": "The icon, dynamic plate and pulse show a short pulse for events and a continuous pulse for work, motion or presence. Red alarms have separate priority.",
"marker.display_hint_value": "A selected or automatically resolved Home Assistant value replaces the icon while the plate continues to show state. Red alarms remain visible.",
"marker.display_hint_static_icon": "The theme-aware shell and icon always stay the same. State, activity, unavailability and alarms do not change the face.",
"marker.display_hint_value_static_icon": "The marker shows the selected value while the shell stays theme-aware. State, activity, unavailability and alarms do not change the marker colour.",
"marker.static_alarm_warning": "This device can report alarms. Static display hides the marker's visual alarm indication.",
"marker.preview.title": "Display preview",
"marker.preview.actual": "Now",
@@ -236,6 +238,7 @@
"marker.preview.reason.ha_disabled": "The Home Assistant binding is disabled and will be hidden on the plan",
"marker.preview.reason.orphaned": "The saved Home Assistant binding can no longer be found",
"marker.preview.reason.static_icon": "Static mode: device state does not change the icon",
"marker.preview.reason.value_static_icon": "Static mode with a value: device state does not change the marker colour",
"marker.activity_color": "Activity pulse color",
"marker.ripple_size": "Activity pulse size",
"marker.activity_alarm_note": "Color and size affect ordinary activity only and do not change red alarms.",
+3
View File
@@ -184,10 +184,12 @@
"display.badge": "Icône + état",
"display.icon_ripple": "Icône + état et activité",
"display.static_icon": "Icône toujours statique",
"display.value_static_icon": "Valeur + icône statique",
"marker.display_hint_badge": "L’icône et le fond dynamique affichent l’état de l’appareil sans la pulsation d’activité habituelle. Les alarmes rouges restent visibles.",
"marker.display_hint_icon_ripple": "L’icône, le fond dynamique et la pulsation affichent une brève animation pour les événements et une animation continue pour le fonctionnement, le mouvement ou la présence. Les alarmes rouges restent prioritaires.",
"marker.display_hint_value": "Une valeur Home Assistant sélectionnée ou résolue automatiquement remplace l’icône tandis que le fond continue d’indiquer l’état. Les alarmes rouges restent visibles.",
"marker.display_hint_static_icon": "Le contour adapté au thème et l’icône restent toujours identiques. L’état, l’activité, l’indisponibilité et les alarmes ne modifient pas l’apparence.",
"marker.display_hint_value_static_icon": "Le marqueur affiche la valeur choisie tandis que le contour reste adapté au thème. L’état, l’activité, l’indisponibilité et les alarmes ne modifient pas la couleur du marqueur.",
"marker.static_alarm_warning": "Cet appareil peut signaler des alarmes. L’affichage statique masque l’indication visuelle d’alarme du marqueur.",
"marker.preview.title": "Aperçu de l’affichage",
"marker.preview.actual": "Actuel",
@@ -236,6 +238,7 @@
"marker.preview.reason.ha_disabled": "L’association Home Assistant est désactivée et sera masquée sur le plan",
"marker.preview.reason.orphaned": "L’association Home Assistant enregistrée est introuvable",
"marker.preview.reason.static_icon": "Mode statique : l’état de l’appareil ne modifie pas l’icône",
"marker.preview.reason.value_static_icon": "Mode statique avec valeur : l’état de l’appareil ne modifie pas la couleur du marqueur",
"marker.activity_color": "Couleur de la pulsation d’activité",
"marker.ripple_size": "Taille de la pulsation d’activité",
"marker.activity_alarm_note": "La couleur et la taille n’affectent que l’activité normale et ne modifient pas les alarmes rouges.",
+3
View File
@@ -184,10 +184,12 @@
"display.badge": "Значок + состояние",
"display.icon_ripple": "Значок + состояние и активность",
"display.static_icon": "Всегда статичный значок",
"display.value_static_icon": "Значение + статичный значок",
"marker.display_hint_badge": "Значок и динамическая подложка показывают состояние устройства без обычной пульсации активности. Красная тревога сохраняется.",
"marker.display_hint_icon_ripple": "Значок, динамическая подложка и пульсация: короткая — для событий, постоянная — для работы, движения или присутствия. Красная тревога имеет отдельный приоритет.",
"marker.display_hint_value": "Значок заменяется выбранным или автоматически найденным значением Home Assistant; подложка продолжает показывать состояние. Красная тревога сохраняется.",
"marker.display_hint_static_icon": "Подложка в цветах темы и значок всегда остаются одинаковыми. Состояния, активность, недоступность и тревоги не меняют отображение.",
"marker.display_hint_value_static_icon": "Маркер показывает выбранное значение, а подложка остаётся в цветах темы. Состояния, активность, недоступность и тревоги не меняют цвет маркера.",
"marker.static_alarm_warning": "Это устройство может сообщать о тревогах. Статичный режим скрывает визуальную тревогу маркера.",
"marker.preview.title": "Предпросмотр отображения",
"marker.preview.actual": "Сейчас",
@@ -236,6 +238,7 @@
"marker.preview.reason.ha_disabled": "Привязка отключена в Home Assistant, поэтому устройство будет скрыто на плане",
"marker.preview.reason.orphaned": "Сохранённая привязка Home Assistant больше не найдена",
"marker.preview.reason.static_icon": "Статичный режим: состояние устройства не меняет значок",
"marker.preview.reason.value_static_icon": "Статичный режим со значением: состояние устройства не меняет цвет маркера",
"marker.activity_color": "Цвет пульсации активности",
"marker.ripple_size": "Размер пульсации активности",
"marker.activity_alarm_note": "Цвет и размер относятся только к обычной активности и не влияют на красную тревогу.",
+14 -1
View File
@@ -895,9 +895,22 @@ export function safeUrl(url: string | null | undefined): string | null {
*/
/** UI presentations. `ripple` remains a backend/read compatibility value only
* and is normalised to `icon_ripple` by the shared normalizer. */
export const DISPLAY_MODES = ['badge', 'icon_ripple', 'value', 'static_icon'] as const;
export const DISPLAY_MODES = ['badge', 'icon_ripple', 'value', 'static_icon', 'value_static_icon'] as const;
export type DeviceDisplayMode = typeof DISPLAY_MODES[number];
/* The four original modes tied two independent things together: what is drawn
* inside the marker (icon or value) and whether the marker takes colour from
* state. `value_static_icon` (#588) is the missing fourth combination, so the
* two questions are asked separately from here on. Every renderer, the pulse
* layer, the badge layer and the live vacuum branches ask these two predicates
* instead of comparing the token, which is what kept the modes consistent. */
/** The marker draws the resolved value instead of its icon when one exists. */
export const displayWantsValue = (display: DeviceDisplayMode): boolean =>
display === 'value' || display === 'value_static_icon';
/** State, alarm, availability, live colour and activity never touch the face. */
export const displayIsNeutral = (display: DeviceDisplayMode): boolean =>
display === 'static_icon' || display === 'value_static_icon';
/** One read boundary for persisted/legacy display values. Unknown manual data
* safely falls back to the default dynamic badge; the backend still rejects it
* on write. Keep every renderer and editor on this normalizer. */
+1 -1
View File
@@ -196,7 +196,7 @@ export interface Marker {
* only use. */
room_id?: string | null;
/** `ripple` is legacy read compatibility; UI maps it to icon_ripple. */
display?: 'badge' | 'ripple' | 'icon_ripple' | 'value' | 'static_icon' | null; // how the device is drawn
display?: 'badge' | 'ripple' | 'icon_ripple' | 'value' | 'static_icon' | 'value_static_icon' | null; // how the device is drawn
ripple_color?: string | null;
ripple_size?: number | null; // max ring diameter, in icon diameters (default 1.5)
size?: number | null; // icon size multiplier (default 1)