mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 04:09:17 +00:00
Пятый вариант «Отображение»: маркер показывает выбранное значение и при этом
никогда не меняет цвет — ни по состоянию, ни по тревоге, ни по недоступности,
ни под цвет 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
198 lines
7.0 KiB
TypeScript
198 lines
7.0 KiB
TypeScript
import type { DeviceAvailability, DeviceVisualState } from './device-visual';
|
|
import { displayIsNeutral, displayWantsValue, type DeviceDisplayMode } from './logic';
|
|
|
|
export type PresentationReason =
|
|
| 'neutral'
|
|
| 'working'
|
|
| 'working_activity'
|
|
| 'open'
|
|
| 'cover_icon_state'
|
|
| 'presence'
|
|
| 'event'
|
|
| 'transition'
|
|
| 'media_neutral'
|
|
| 'unavailable'
|
|
| 'alarm'
|
|
| 'live_states_disabled'
|
|
| 'value_no_state'
|
|
| 'value_ambiguous_sources'
|
|
| 'value_non_scalar'
|
|
| 'value_virtual'
|
|
| 'vacuum_live_plan_only'
|
|
| 'hidden_design_preview'
|
|
| 'composite_power_source'
|
|
| 'activity_display_disabled'
|
|
| 'static_icon'
|
|
| 'value_static_icon'
|
|
| 'ha_disabled'
|
|
| 'orphaned';
|
|
|
|
export type BindingPresentationLifecycle =
|
|
| 'active' | 'ha_disabled' | 'orphaned';
|
|
|
|
export type PresentationFace = 'icon' | 'value';
|
|
export type ValueFallbackReason =
|
|
| 'value_no_state' | 'value_ambiguous_sources' | 'value_non_scalar' | 'value_virtual';
|
|
|
|
export interface DevicePresentationPolicyInput {
|
|
bindingLifecycle: BindingPresentationLifecycle;
|
|
userHidden: boolean;
|
|
designPreview: boolean;
|
|
display: DeviceDisplayMode;
|
|
liveStates: boolean;
|
|
sourceVisual: DeviceVisualState;
|
|
controllerFace: boolean;
|
|
controllerAvailability: DeviceAvailability;
|
|
shortActivity: 'event' | 'transition' | null;
|
|
valueAvailable: boolean;
|
|
valueFallback: ValueFallbackReason | null;
|
|
vacuumLiveRequested: boolean;
|
|
}
|
|
|
|
export interface DevicePresentationPolicyResult {
|
|
effectiveHidden: boolean;
|
|
bindingUnavailable: boolean;
|
|
visual: DeviceVisualState;
|
|
face: PresentationFace;
|
|
dynamicIcon: boolean;
|
|
metrics: boolean;
|
|
liveColor: boolean;
|
|
pulseEligible: boolean;
|
|
vacuumLive: boolean;
|
|
decisionIds: readonly string[];
|
|
}
|
|
|
|
const NEUTRAL_VISUAL: DeviceVisualState = {
|
|
availability: 'available', status: 'neutral', activity: 'none',
|
|
};
|
|
|
|
/**
|
|
* Ordered, side-effect-free policy for the final device face.
|
|
*
|
|
* HA/registry/light-graph discovery stays in device-presentation.ts. This
|
|
* function consumes only resolved facts, so every priority can be exercised
|
|
* without constructing a Home Assistant runtime or a renderer.
|
|
*/
|
|
export function resolveDevicePresentationPolicy(
|
|
input: DevicePresentationPolicyInput,
|
|
): DevicePresentationPolicyResult {
|
|
const decisions: string[] = [];
|
|
const bindingUnavailable = input.bindingLifecycle === 'ha_disabled'
|
|
|| input.bindingLifecycle === 'orphaned';
|
|
|
|
let effectiveHidden = false;
|
|
if (input.bindingLifecycle === 'ha_disabled') {
|
|
effectiveHidden = true;
|
|
decisions.push('lifecycle.ha_disabled_hidden');
|
|
} else if (input.userHidden && !input.designPreview) {
|
|
effectiveHidden = true;
|
|
decisions.push('lifecycle.user_hidden');
|
|
} else if (input.userHidden) {
|
|
decisions.push('lifecycle.user_hidden_preview');
|
|
} else if (input.bindingLifecycle === 'orphaned') {
|
|
decisions.push('lifecycle.orphaned_diagnostic');
|
|
} else {
|
|
decisions.push('lifecycle.active');
|
|
}
|
|
|
|
const neutralFace = displayIsNeutral(input.display);
|
|
const wantsValue = displayWantsValue(input.display);
|
|
let visual = input.controllerFace
|
|
? { ...input.sourceVisual, availability: input.controllerAvailability }
|
|
: input.sourceVisual;
|
|
decisions.push(input.controllerFace
|
|
? input.controllerAvailability === 'available'
|
|
? 'availability.controller_available'
|
|
: 'availability.controller_unavailable'
|
|
: 'availability.source');
|
|
|
|
if (effectiveHidden) {
|
|
visual = NEUTRAL_VISUAL;
|
|
decisions.push('face.hidden');
|
|
} else if (neutralFace) {
|
|
visual = NEUTRAL_VISUAL;
|
|
decisions.push('face.static');
|
|
} else if (input.sourceVisual.status !== 'alarm' && !input.liveStates) {
|
|
visual = NEUTRAL_VISUAL;
|
|
decisions.push('face.live_states_disabled');
|
|
} else {
|
|
decisions.push('face.dynamic');
|
|
}
|
|
|
|
if (!neutralFace && !effectiveHidden && input.liveStates
|
|
&& input.sourceVisual.status !== 'alarm' && input.shortActivity) {
|
|
visual = { ...visual, activity: input.shortActivity };
|
|
decisions.push(`activity.short_${input.shortActivity}`);
|
|
}
|
|
|
|
if (visual.status === 'alarm') decisions.push('status.alarm');
|
|
else if (visual.availability === 'unavailable') decisions.push('status.unavailable');
|
|
else if (visual.status === 'working') decisions.push('status.working');
|
|
else if (visual.status === 'open') decisions.push('status.open');
|
|
else decisions.push('status.neutral');
|
|
|
|
const face: PresentationFace = wantsValue
|
|
&& !effectiveHidden && input.valueAvailable ? 'value' : 'icon';
|
|
decisions.push(face === 'value' ? 'content.value' : wantsValue
|
|
? 'content.value_fallback_icon' : 'content.icon');
|
|
if (wantsValue && !input.valueAvailable && input.valueFallback) {
|
|
decisions.push(`content.${input.valueFallback}`);
|
|
}
|
|
|
|
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;
|
|
decisions.push(dynamicIcon ? 'diagnostics.dynamic_icon' : 'diagnostics.base_icon');
|
|
decisions.push(metrics ? 'diagnostics.metrics_enabled' : 'diagnostics.metrics_suppressed');
|
|
decisions.push(pulseEligible ? 'activity.pulse_eligible' : 'activity.pulse_suppressed');
|
|
decisions.push(vacuumLive ? 'diagnostics.vacuum_live' : 'diagnostics.vacuum_static');
|
|
|
|
return {
|
|
effectiveHidden,
|
|
bindingUnavailable,
|
|
visual,
|
|
face,
|
|
dynamicIcon,
|
|
metrics,
|
|
liveColor: dynamicIcon,
|
|
pulseEligible,
|
|
vacuumLive,
|
|
decisionIds: decisions,
|
|
};
|
|
}
|
|
|
|
export interface PresentationReasonInput {
|
|
lifecycle: BindingPresentationLifecycle;
|
|
display: DeviceDisplayMode;
|
|
liveStates: boolean;
|
|
sourceKind: 'cover' | 'light' | 'controls' | 'device_role' | 'primary' | 'none';
|
|
primaryDomain: string;
|
|
visual: DeviceVisualState;
|
|
activity: 'none' | 'event' | 'presence' | 'transition' | 'running';
|
|
}
|
|
|
|
/** The single explanation priority, kept beside the face policy it describes. */
|
|
export function resolvePresentationReason(input: PresentationReasonInput): PresentationReason {
|
|
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';
|
|
if (input.sourceKind === 'cover') return 'cover_icon_state';
|
|
if (input.activity === 'presence') return 'presence';
|
|
if (input.activity === 'event') return 'event';
|
|
if (input.activity === 'transition') return 'transition';
|
|
if (input.visual.status === 'working') {
|
|
return input.display === 'icon_ripple' && input.activity !== 'none'
|
|
? 'working_activity' : 'working';
|
|
}
|
|
if (input.visual.status === 'open') return 'open';
|
|
if (input.primaryDomain === 'media_player') return 'media_neutral';
|
|
return 'neutral';
|
|
}
|