Files
houseplan-card/src/device-presentation.ts
T
2026-08-29 22:20:18 +03:00

850 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* One semantic projection for every device face.
*
* Interactive plan, static space card and the device editor preview consume
* this object. Renderers are deliberately dumb: they never choose another
* entity, reclassify a state or format a value on their own.
*/
import {
climateTempFor, humFor, isHumEntity, isTempEntity, lqiFor,
persistedExternalControls, resolvedDeviceStateEntities, resolvedLightSources, tempFor,
type ResolvedLightSource,
} from './devices';
import {
combineVisualSamples, entityVisualSample, entityVisualSamplesForDevice,
isApplianceLifecycleEntity, isDevicePowerSwitch,
type DeviceActivity, type DeviceAvailability, type DeviceVisualState, type EntityVisualSample,
} from './device-visual';
import {
hassValue, lightColorOf, lqiColor, normalizeDeviceDisplay, stateIcon, valueWithUnit,
type DeviceDisplayMode,
} from './logic';
import { resolveToggleIntent, toggleCoverEntity } from './device-toggle';
import type { DevItem } from './types';
import { safeStoredColor } from './color';
import {
resolveDeviceValueBadge, resolveValueSource, valueBadgeSourceKey,
type ResolvedValueBadge,
} from './device-value-badge';
import {
DEVICE_PULSE_DEFAULT_SCALE, resolveDevicePulse, type ResolvedDevicePulse,
} from './device-pulse';
import { isManualVirtualLightMarker } from './virtual-light-state';
import {
resolveDevicePresentationPolicy, resolvePresentationReason,
type BindingPresentationLifecycle, type PresentationReason, type ValueFallbackReason,
} from './device-presentation-policy';
export type { PresentationReason, ValueFallbackReason } from './device-presentation-policy';
export type PresentationSourceKind =
| 'cover' | 'light' | 'controls' | 'device_role' | 'primary' | 'none';
export type PresentationSourceRole =
| 'cover' | 'light' | 'control' | 'forced_light' | 'device_role'
| 'lifecycle' | 'power_gate' | 'primary' | 'critical';
export type DeviceLockState = 'locked' | 'unlocked' | null;
export type DeviceLqiBand = 'low' | 'mid' | 'high';
export type DeviceA11yState =
| 'neutral' | 'working' | 'open' | 'locked' | 'unlocked' | 'alarm' | 'unavailable';
export function markerLqiBand(lqi: number): DeviceLqiBand {
if (lqi <= 40) return 'low';
if (lqi < 180) return 'mid';
return 'high';
}
/** Colour is continuous; the separate band remains semantic/a11y metadata. */
export function markerLqiColor(lqi: number): string {
return lqiColor(lqi);
}
export function deviceA11yState(presentation: Pick<
ResolvedDevicePresentation, 'visual' | 'lockState' | 'display'
>): DeviceA11yState {
if (presentation.display === 'static_icon') return 'neutral';
if (presentation.visual.status === 'alarm') return 'alarm';
if (presentation.visual.availability === 'unavailable') return 'unavailable';
if (presentation.lockState) return presentation.lockState;
if (presentation.visual.status === 'working') return 'working';
if (presentation.visual.status === 'open') return 'open';
return 'neutral';
}
export interface ResolvedPresentationSource {
eid: string;
role: PresentationSourceRole;
name: string;
state: string;
stateText: string;
integrationDomain: string | null;
sample: EntityVisualSample;
}
export interface ResolvedValueSource {
kind: 'temperature' | 'humidity' | 'entity' | 'derived_lqi' | 'derived_marker_state';
eid: string;
attribute?: string;
text: string;
sourceKey: string;
}
export interface PresentationExplanation {
reason: PresentationReason;
notices: PresentationReason[];
}
export interface PresentationActivityRuntime {
sources: string;
flashTs: number;
flashKind: 'event' | 'transition' | null;
gen: number;
/** Absolute deadline avoids reviving a frozen CSS animation after tab resume. */
expiresAt?: number;
/** Alarm owns the timeline and baselines source changes while it is active. */
alarmActive?: boolean;
}
export interface ResolvePresentationOptions {
liveStates: boolean;
showTemperature: boolean;
showSignal: boolean;
/** User-hidden markers show their real design inside the editor preview. */
designPreview?: boolean;
activityRuntime?: PresentationActivityRuntime | null;
/** The preview needs real source facts even for a static face. Plan surfaces
* may skip that work because the static projection cannot consume it. */
sourceDetails?: boolean;
/** Whole-plan light graph; needed for passive sources driven from another marker. */
lightDevices?: readonly DevItem[];
/** Pre-resolved whole-plan graph. Render/activity passes must build it once. */
lightSources?: readonly ResolvedLightSource<DevItem>[];
/** Registry view must match the action resolver, including disabled rows. */
registryHass?: any;
now?: number;
reducedMotion?: boolean;
}
export interface ResolvedDevicePresentation {
binding: string;
sourceKind: PresentationSourceKind;
visualSources: ResolvedPresentationSource[];
criticalSources: ResolvedPresentationSource[];
valueSource: ResolvedValueSource | null;
sourceSignature: string;
/** Stable internal trace for the documented decision matrix. */
decisionIds: readonly string[];
visual: DeviceVisualState;
lockState: DeviceLockState;
display: DeviceDisplayMode;
icon: string;
valueText: string | null;
valueFullText: string | null;
fallbackReason: ValueFallbackReason | null;
activity: DeviceActivity;
activityGeneration: number;
pulse: ResolvedDevicePulse;
classes: string[];
tempText: string | null;
humText: string | null;
valueBadge: ResolvedValueBadge | null;
lqiText: string | null;
lqiColor: string | null;
lqiBand: DeviceLqiBand | null;
lightColor: string | null;
scale: number;
angle: number;
rippleScale: number;
rippleColor: string | null;
userHidden: boolean;
effectiveHidden: boolean;
haDisabled: boolean;
disabledReason: string | null;
orphaned: boolean;
vacuumLive: boolean;
explanation: PresentationExplanation;
}
export interface ResolvedPresentationSources {
sourceKind: PresentationSourceKind;
decisionIds: readonly string[];
visualSources: ResolvedPresentationSource[];
criticalSources: ResolvedPresentationSource[];
samples: EntityVisualSample[];
}
const ACTIVITY_WINDOW_MS = 3300;
const EMPTY_SOURCES: ResolvedPresentationSources = {
sourceKind: 'none', decisionIds: ['source.skipped_static_fast_path'],
visualSources: [], criticalSources: [], samples: [],
};
function sourceName(hass: any, eid: string): string {
const reg = hass?.entities?.[eid];
const st = hass?.states?.[eid];
return String(reg?.name || reg?.original_name || st?.attributes?.friendly_name || eid);
}
function sourceStateText(hass: any, eid: string): string {
const st = hass?.states?.[eid];
if (!st) return '';
if (typeof hass?.formatEntityState === 'function') {
try {
const text = hass.formatEntityState(st);
if (typeof text === 'string' && text) return text;
} catch { /* older/custom HA formatter: use raw state */ }
}
return String(st.state ?? '');
}
/**
* Availability of the physical controller itself (#251).
*
* HA exposes entity states rather than one device state. Controls are not
* controller evidence: a live battery/LQI/update sibling is, while an
* event-only `unknown` entity is not. An active physical device with no entity
* roster has no evidence that it is offline, so it remains available while
* its target status is resolved independently. A virtual controller has no
* physical HA binding which can go offline, so it is available by definition.
*/
export function controllerAvailability(hass: any, d: DevItem): DeviceAvailability {
if (d.virtual || d.bindingKind === 'virtual' || d.marker?.binding === 'virtual') {
return 'available';
}
const ownEntities = d.entities || [];
// #369(г): "no roster" and "roster fully disabled by the user" are different
// evidence. A truly entityless active device stays available (#318); a
// device whose registry entities were all deliberately disabled is the user
// opting out — it must not look alive.
const disabledRoster = d.bindingStatus?.kind === 'ha_disabled'
|| (d.bindingStatus?.allEntityIds?.length ?? 0) > 0;
const activeEntitylessDevice = ownEntities.length === 0
&& !disabledRoster
&& (d.bindingKind === 'device' || d.marker?.binding?.startsWith('device:'))
&& d.bindingStatus?.kind === 'active';
if (activeEntitylessDevice) return 'available';
const live = ownEntities.some((eid) => {
const state = String(hass?.states?.[eid]?.state ?? '').trim().toLowerCase();
return state !== '' && state !== 'unknown' && state !== 'unavailable';
});
return live ? 'available' : 'unavailable';
}
function sourceOf(
hass: any, eid: string, role: PresentationSourceRole, sample?: EntityVisualSample,
): ResolvedPresentationSource {
return {
eid,
role,
name: sourceName(hass, eid),
state: String(hass?.states?.[eid]?.state ?? ''),
stateText: sourceStateText(hass, eid),
integrationDomain: hass?.entities?.[eid]?.platform || null,
sample: sample || entityVisualSample(hass, eid),
};
}
/** Resolve the effective visual role once; every surface uses this graph. */
export function resolvePresentationSources(
hass: any, device: DevItem, lightDevices: readonly DevItem[] = [device],
planLightSources?: readonly ResolvedLightSource<DevItem>[],
registryHass: any = hass,
): ResolvedPresentationSources {
// User-hidden is a renderer concern. It must not erase the source graph used
// by the design preview. HA-disabled devices already carry no active entities.
const d = device.hidden && device.userHidden ? { ...device, hidden: false } : device;
let sourceKind: PresentationSourceKind = 'none';
let visualSources: ResolvedPresentationSource[] = [];
const decisionIds: string[] = [];
// A cover remains the device's visual state source even when tap_action is
// More info. Resolve the same exact cover role/capability path as the toggle
// adapter without changing the persisted action selected by the user.
const coverDevice = [...d.entities, ...(d.allEntities || [])]
.some((eid) => eid.startsWith('cover.'))
? { ...d, tapAction: 'cover' as const }
: d;
const cover = toggleCoverEntity(resolveToggleIntent({
hass,
devices: lightDevices,
device: coverDevice,
lightSources: planLightSources,
registryHass,
}));
// A target owns Glow and room statistics, while a controller still needs to
// present the aggregate state of what it controls. Resolve the controller
// locally for ordinary entity refs, then project marker:* targets from the
// plan-wide graph. Filtering the global graph only by source owner would
// make a wall switch lose its yellow working state as soon as its target was
// represented by a separate plan marker.
const localLights = resolvedLightSources(hass, [d]);
const persistedControls = persistedExternalControls(
d.marker?.binding, d.marker?.controls ?? d.controls, d.entities,
);
const markerRefs = new Set(persistedControls.filter((ref) => ref.startsWith('marker:')));
// The global graph is needed only for incoming links to an Always marker or
// for outgoing marker:* aliases. Ordinary devices stay on the O(1) local
// path instead of resolving the complete plan once per rendered marker.
const needsGlobalGraph = d.marker?.is_light === true || markerRefs.size > 0;
const globalLights = needsGlobalGraph
? planLightSources || resolvedLightSources(hass, lightDevices)
: localLights;
// Keep controller projections local, but take sources owned by this marker
// from the plan-wide graph. This matters for passive sources: their `on`
// state is derived from incoming marker:* links and cannot be known from an
// isolated marker. Fall back to the local owned source for design previews
// whose temporary marker is not present in `lightDevices`.
const globalOwned = globalLights.filter((source) =>
source.device.id === d.id && source.via !== 'controls'
);
const localOwned = localLights.filter((source) => source.via !== 'controls');
const ownedLights = globalOwned.length ? globalOwned : localOwned;
// In #107's exact manual mode, outgoing controls remain saved and active in
// the plan graph, but they do not own this marker's face. Its icon/status is
// the same canonical manual source used by Glow, room fill and statistics.
const manualVirtualFace = isManualVirtualLightMarker(d.marker);
const lights = manualVirtualFace ? [...ownedLights] : [
...localLights.filter((source) => source.via === 'controls'),
...ownedLights,
];
if (markerRefs.size) {
for (const source of globalLights) {
if (!markerRefs.has(source.key)) continue;
const aliasesExisting = source.stateEids.length > 0 && lights.some((existing) =>
existing.stateEids.some((eid) => source.stateEids.includes(eid)),
);
if (!aliasesExisting && !lights.some((existing) => existing.key === source.key)) {
lights.push({ ...source, via: 'controls', castsGlow: false });
}
}
}
// A cover-only device still gets cover visuals even when its click action is
// More info. On a mixed device, however, an unrelated cover capability must
// not hijack a primary/owned light unless the legacy explicit cover action
// (or the primary entity itself) says that the marker represents the cover.
const resolvedDeviceRole = resolvedDeviceStateEntities(registryHass, d.entities);
const coverOwnsFace = !!cover && (
d.tapAction === 'cover'
|| d.primary?.startsWith('cover.')
|| resolvedDeviceRole.some((eid) => eid.startsWith('cover.'))
);
if (cover && !coverOwnsFace) decisionIds.push('source.cover_capability_bypassed');
if (coverOwnsFace) {
sourceKind = 'cover';
decisionIds.push('source.cover');
visualSources = [sourceOf(hass, cover, 'cover')];
} else if (lights.length) {
sourceKind = !manualVirtualFace && lights.some((source) => source.via === 'controls')
? 'controls' : 'light';
if (manualVirtualFace) decisionIds.push('source.manual_virtual_light');
else decisionIds.push(sourceKind === 'controls' ? 'source.controls' : 'source.owned_light');
if (sourceKind === 'controls' && (d.virtual || d.bindingKind === 'virtual')) {
decisionIds.push('source.virtual_controller');
}
visualSources = lights.map((source) => {
const role = source.via === 'controls' ? 'control'
: source.via === 'forced' ? 'forced_light' : 'light';
if (!source.passive) return sourceOf(hass, source.eid, role);
const state = source.on ? 'on' : 'off';
return {
eid: source.key,
role,
name: device.name,
state,
stateText: state,
integrationDomain: null,
sample: {
eid: source.key, state, availability: 'available' as const,
status: source.on ? 'working' as const : 'neutral' as const,
activity: 'none' as const, edge: 'none' as const,
},
};
});
} else {
// Registry metadata is authoritative when present, but a few integrations
// (and early HA startup snapshots) expose live states before the entity
// registry arrives. Keep the historical whole-device role in that case
// instead of silently downgrading the same entity to a generic primary.
const resolvedIds = resolvedDeviceStateEntities(registryHass, d.entities);
const ids = resolvedIds.length
? resolvedIds
: d.entities.filter((eid) => !!hass?.states?.[eid]);
if (ids.length) {
sourceKind = 'device_role';
decisionIds.push('source.device_role');
// Registry metadata chooses the role, while the live hass snapshot owns
// its state. Keep those authorities together for composite lifecycle
// classification even during a frozen/partial registry projection.
const visualHass = registryHass === hass
? hass : { ...registryHass, states: hass?.states || {} };
const samples = entityVisualSamplesForDevice(visualHass, ids, d.entities);
const hasLifecycle = ids.some((eid) => isApplianceLifecycleEntity(registryHass, eid));
visualSources = samples.map((sample) => {
const role: PresentationSourceRole = hasLifecycle
? isDevicePowerSwitch(registryHass, sample.eid) ? 'power_gate'
: isApplianceLifecycleEntity(registryHass, sample.eid) ? 'lifecycle' : 'device_role'
: 'device_role';
return sourceOf(hass, sample.eid, role, sample);
});
} else if (d.primary) {
sourceKind = 'primary';
decisionIds.push('source.primary_fallback');
visualSources = [sourceOf(hass, d.primary, 'primary')];
} else {
decisionIds.push('source.none');
}
}
if (persistedControls.length > 0 && sourceKind !== 'controls'
&& sourceKind !== 'light' && sourceKind !== 'cover') {
decisionIds.push('source.filtered_saved_controls');
}
const criticalSources: ResolvedPresentationSource[] = [];
for (const eid of d.entities || []) {
const sample = entityVisualSample(hass, eid);
if (sample.status !== 'alarm' || visualSources.some((source) => source.eid === eid)) continue;
criticalSources.push(sourceOf(hass, eid, 'critical', sample));
}
if (criticalSources.length) decisionIds.push('source.critical_sibling');
return {
sourceKind,
decisionIds,
visualSources,
criticalSources,
samples: [...visualSources, ...criticalSources].map((source) => source.sample),
};
}
function firstClimateTemperature(hass: any, d: DevItem): { eid: string; text: string } | null {
if (d.marker?.use_climate_temp !== true) return null;
for (const eid of d.entities) {
if (!eid.startsWith('climate.')) continue;
const n = Number(hass?.states?.[eid]?.attributes?.current_temperature);
if (!Number.isFinite(n)) continue;
// A derived plan reading is deliberately compact and shares the same
// one-decimal contract as the satellite temperature badge.
return { eid, text: `${Math.round(n * 10) / 10}°` };
}
return null;
}
function firstTemperature(hass: any, d: DevItem): { eid: string; text: string } | null {
if (d.icon !== 'mdi:thermometer' && d.icon !== 'mdi:air-filter') return null;
for (const eid of d.entities) {
if (!isTempEntity(hass, eid)) continue;
const n = Number(hass?.states?.[eid]?.state);
if (!Number.isFinite(n)) continue;
return { eid, text: `${Math.round(n * 10) / 10}°` };
}
return null;
}
function firstHumidity(hass: any, d: DevItem): { eid: string; text: string } | null {
if (!d.primary || !isHumEntity(hass, d.primary)) return null;
const n = Number(hass?.states?.[d.primary]?.state);
if (!Number.isFinite(n)) return null;
return { eid: d.primary, text: `${Math.round(n)}%` };
}
function validStateValue(hass: any, eid: string): { text: string; fallback: ValueFallbackReason | null } {
const state = hass?.states?.[eid];
if (!state || state.state == null || String(state.state).trim() === '') {
return { text: '', fallback: 'value_no_state' };
}
const raw = state.state;
if (!['string', 'number', 'boolean'].includes(typeof raw)) {
return { text: '', fallback: 'value_non_scalar' };
}
const normalized = String(raw).trim().toLowerCase();
if (normalized === 'unknown' || normalized === 'unavailable') {
return { text: '', fallback: 'value_no_state' };
}
const formatted = hassValue(hass, eid);
if (!formatted) return { text: '', fallback: 'value_no_state' };
const unit = String(state.attributes?.unit_of_measurement || '');
return { text: valueWithUnit(formatted, unit), fallback: null };
}
function resolveValue(
hass: any,
d: DevItem,
sources: ResolvedPresentationSources,
showTemperature: boolean,
markerStates: readonly { ref: string; on: boolean; name: string }[] = [],
): {
source: ResolvedValueSource | null;
text: string | null;
fullText: string | null;
fallback: ValueFallbackReason | null;
explicit: boolean;
} {
if (d.virtual) {
return { source: null, text: null, fullText: null, fallback: 'value_virtual', explicit: false };
}
const explicitSource = d.marker?.value_source;
if (explicitSource) {
const resolved = resolveValueSource(hass, d, explicitSource, markerStates);
const kind: ResolvedValueSource['kind'] = explicitSource.kind === 'derived_lqi'
? 'derived_lqi'
: explicitSource.kind === 'derived_marker_state'
? 'derived_marker_state'
: explicitSource.kind === 'entity_attribute' && explicitSource.attribute.includes('temperature')
? 'temperature'
: explicitSource.kind === 'entity_attribute' && explicitSource.attribute.includes('humidity')
? 'humidity' : 'entity';
const eid = explicitSource.kind === 'entity_state' || explicitSource.kind === 'entity_attribute'
? explicitSource.entity_id
: explicitSource.kind === 'derived_marker_state' ? explicitSource.ref : 'derived:lqi';
const attribute = explicitSource.kind === 'entity_attribute'
? explicitSource.attribute : undefined;
return {
source: {
kind, eid, attribute, text: resolved.text,
sourceKey: valueBadgeSourceKey(explicitSource),
},
text: resolved.text,
fullText: resolved.fullText,
fallback: resolved.failure === 'non_scalar' ? 'value_non_scalar'
: resolved.failure === 'missing' ? 'value_no_state' : null,
explicit: true,
};
}
if (showTemperature) {
const climate = firstClimateTemperature(hass, d);
if (climate) {
return {
source: {
kind: 'temperature', eid: climate.eid, attribute: 'current_temperature', text: climate.text,
sourceKey: `attr:${climate.eid}:current_temperature`,
},
text: climate.text,
fullText: climate.text,
fallback: null,
explicit: false,
};
}
const temp = firstTemperature(hass, d);
if (temp) {
return {
source: { kind: 'temperature', eid: temp.eid, text: temp.text, sourceKey: `state:${temp.eid}` },
text: temp.text, fullText: temp.text, fallback: null, explicit: false,
};
}
const hum = firstHumidity(hass, d);
if (hum) {
return {
source: { kind: 'humidity', eid: hum.eid, text: hum.text, sourceKey: `state:${hum.eid}` },
text: hum.text, fullText: hum.text, fallback: null, explicit: false,
};
}
}
// Passive marker sources deliberately use a `marker:*` identity. They are
// presentation graph nodes, never HA entity ids. For a value face keep the
// device's ordinary functional role as the value source instead of probing
// `hass.states['marker:*']` or turning a sensor into an empty value.
const realVisualSources = sources.visualSources.filter((source) => !source.eid.startsWith('marker:'));
let valueEids = realVisualSources.map((source) => source.eid);
const powerGate = realVisualSources.find((source) => source.role === 'power_gate');
// Before #164 a composite appliance value face displayed its selected Power
// entity. The added lifecycle source must not turn that existing value into
// an ambiguous fallback.
if (powerGate) valueEids = [powerGate.eid];
if (!valueEids.length && sources.visualSources.some((source) => source.eid.startsWith('marker:'))) {
valueEids = resolvedDeviceStateEntities(hass, d.entities);
if (!valueEids.length && d.primary && hass?.states?.[d.primary]) valueEids = [d.primary];
}
valueEids = [...new Set(valueEids)];
if (valueEids.length !== 1) {
return {
source: null,
text: null,
fullText: null,
fallback: valueEids.length ? 'value_ambiguous_sources' : 'value_no_state',
explicit: false,
};
}
const eid = valueEids[0];
const value = validStateValue(hass, eid);
if (value.fallback) {
return { source: null, text: null, fullText: null, fallback: value.fallback, explicit: false };
}
return {
source: { kind: 'entity', eid, text: value.text, sourceKey: `state:${eid}` },
text: value.text, fullText: value.text, fallback: null, explicit: false,
};
}
function signatureOf(
d: DevItem,
sources: ResolvedPresentationSources,
valueSource: ResolvedValueSource | null,
): string {
const binding = d.marker?.binding
|| (d.bindingKind && d.bindingRef ? `${d.bindingKind}:${d.bindingRef}` : d.virtual ? 'virtual' : '');
const rows = [
...sources.visualSources.map((source) => `${source.role}:${source.eid}`),
...sources.criticalSources.map((source) => `critical:${source.eid}`),
].sort();
const value = valueSource
? `${valueSource.kind}:${valueSource.sourceKey}:${valueSource.eid}:${valueSource.attribute || ''}` : 'none';
return [binding, sources.sourceKind, ...rows, `value:${value}`].join('\n');
}
/** Formal signature used to decide whether a witnessed event window survives. */
export function presentationSourceSignature(
hass: any,
d: DevItem,
showTemperature = true,
resolved?: ResolvedPresentationSources,
): string {
const sources = resolved || resolvePresentationSources(hass, d);
const value = resolveValue(hass, d, sources, showTemperature);
return signatureOf(d, sources, value.source);
}
/** Activity runtime identity deliberately excludes the optional value badge
* source: changing a displayed value must not restart or cancel a pulse. */
export function activitySourceSignature(
hass: any,
d: DevItem,
resolved?: ResolvedPresentationSources,
): string {
const sources = resolved || resolvePresentationSources(hass, d);
return signatureOf(d, sources, null).replace(/\nvalue:none$/, '');
}
export function presentationClasses(presentation: Pick<
ResolvedDevicePresentation,
'visual' | 'lockState' | 'activity' | 'display' | 'effectiveHidden' | 'activityGeneration' | 'pulse'
>): string[] {
if (presentation.effectiveHidden) return [];
if (presentation.display === 'static_icon') return ['static-icon'];
const classes: string[] = [];
const { visual } = presentation;
if (presentation.pulse.kind === 'alarm') classes.push('alarm');
else if (visual.availability === 'unavailable') classes.push('unavail');
else if (visual.status === 'working') classes.push('on');
else if (visual.status === 'open') classes.push('open');
if (presentation.lockState) classes.push(`lock-${presentation.lockState}`);
// Keep the semantic class names as styling/debug hooks during the beta;
// they no longer select a separate renderer or animation implementation.
if (presentation.pulse.reason !== 'none' && presentation.pulse.reason !== 'alarm') {
classes.push('activity-' + presentation.pulse.reason);
}
if (presentation.pulse.generation % 2 === 0) {
classes.push('pulse-gen2');
if (presentation.pulse.kind === 'short') classes.push('activity-gen2');
}
return classes;
}
/** Create the complete renderer-ready face projection. */
export function resolveDevicePresentation(
hass: any,
d: DevItem,
options: ResolvePresentationOptions,
): ResolvedDevicePresentation {
const display = normalizeDeviceDisplay(d.marker?.display);
const staticIcon = display === 'static_icon';
const sources = staticIcon && options.sourceDetails === false
? EMPTY_SOURCES : resolvePresentationSources(
hass, d, options.lightDevices || [d], options.lightSources,
options.registryHass || hass,
);
const needsMarkerState = d.marker?.value_source?.kind === 'derived_marker_state'
|| d.marker?.value_badge?.source?.kind === 'derived_marker_state';
const markerStateGraph = options.lightSources
|| (needsMarkerState ? resolvedLightSources(hass, options.lightDevices || [d]) : []);
const markerStates = markerStateGraph
.filter((source) => source.key.startsWith('marker:'))
.map((source) => ({ ref: source.key, on: source.on, name: source.device.name }));
const status = d.bindingStatus;
const haDisabled = status?.kind === 'ha_disabled';
const orphaned = status?.kind === 'orphaned';
const lifecycle: BindingPresentationLifecycle = haDisabled ? 'ha_disabled'
: orphaned ? 'orphaned' : 'active';
const userHidden = d.userHidden === true || d.marker?.hidden === true;
const combined = combineVisualSamples(sources.samples);
const lockSource = sources.visualSources.find((source) => source.eid.startsWith('lock.'));
const lockState: DeviceLockState = lockSource
? ['unlocked', 'open'].includes(lockSource.state.toLowerCase())
? 'unlocked'
: lockSource.state.toLowerCase() === 'locked' ? 'locked' : null
: null;
// A controller mirrors target work, not target connectivity. Its faded
// state describes only the bound controller; Glow/fill/statistics continue
// to consume the unchanged target graph.
// A configured controller remains a controller when every saved target is
// currently filtered out by a plan tombstone. In that case the source graph
// is deliberately empty/device-led, but falling back to the event primary
// would undo #251 and can produce the impossible `unavail` + live LQI face.
// Manual/owned light and cover faces keep their dedicated availability.
const configuredController = !isManualVirtualLightMarker(d.marker)
&& persistedExternalControls(
d.marker?.binding, d.marker?.controls ?? d.controls, d.entities,
).length > 0;
const controllerFace = sources.sourceKind === 'controls'
|| (configuredController && sources.sourceKind !== 'light' && sources.sourceKind !== 'cover');
const value = staticIcon && 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);
const activitySignature = activitySourceSignature(hass, d, sources);
const rt = options.activityRuntime;
const now = options.now ?? Date.now();
const shortExpiresAt = rt?.expiresAt || (rt?.flashTs ? rt.flashTs + ACTIVITY_WINDOW_MS : 0);
const shortActivity = rt?.sources === activitySignature && rt.flashTs && rt.flashKind
&& shortExpiresAt > now ? rt.flashKind : null;
const policy = resolveDevicePresentationPolicy({
bindingLifecycle: lifecycle,
userHidden,
designPreview: options.designPreview === true,
display,
liveStates: options.liveStates,
sourceVisual: combined,
controllerFace,
// Preserve the ordinary-marker O(1) fast path: controller evidence is
// scanned only when this marker actually projects external controls.
controllerAvailability: controllerFace ? controllerAvailability(hass, d) : 'available',
shortActivity,
valueAvailable: value.text != null,
valueFallback: value.fallback,
vacuumLiveRequested: d.marker?.vacuum?.live === true,
});
const { effectiveHidden, visual } = policy;
const activity = display === 'icon_ripple' && !effectiveHidden
&& options.liveStates && visual.status !== 'alarm' ? visual.activity : 'none';
const temp = policy.metrics && options.showTemperature
? (d.marker?.use_climate_temp === true ? climateTempFor(hass, d.entities)
: (d.icon === 'mdi:thermometer' || d.icon === 'mdi:air-filter') ? tempFor(hass, d.entities) : null)
: null;
// Legacy inline humidity belongs only to a humidity-led marker. A random
// diagnostic humidity sibling must not turn a composite device into a
// humidity marker; users can still select that sibling explicitly through
// the configurable value badge.
const hum = policy.metrics && options.showTemperature
&& !!d.primary && isHumEntity(hass, d.primary)
? humFor(hass, d.entities) : null;
const lqi = policy.metrics && options.showSignal && !d.virtual ? lqiFor(hass, d.entities) : null;
const valueBadge = resolveDeviceValueBadge(hass, d, {
showTemperature: options.showTemperature,
showSignal: options.showSignal,
display,
effectiveHidden,
markerStates,
});
const realVisualSource = sources.visualSources.find((source) => !source.eid.startsWith('marker:'));
const actEid = sources.sourceKind === 'cover'
? realVisualSource?.eid
: d.primary || realVisualSource?.eid;
const state = actEid ? hass?.states?.[actEid] : null;
const icon = policy.dynamicIcon
? stateIcon(d.icon, actEid?.split('.')[0], state?.attributes?.device_class, state?.state, !!d.marker?.icon)
: d.icon;
const lightColor = policy.liveColor
? sources.visualSources
.filter((source) => !source.eid.startsWith('marker:'))
.map((source) => lightColorOf(hass?.states?.[source.eid]))
.find((color): color is string => !!color) || null
: null;
const scale = Number(d.marker?.size) > 0 ? Number(d.marker!.size) : 1;
const angle = Number(d.marker?.angle) || 0;
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 pulse = resolveDevicePulse({
display,
visual,
semanticActivity: combined.activity,
shortReason: rt?.sources === activitySignature ? rt?.flashKind : null,
shortGeneration: rt?.gen,
shortExpiresAt: rt?.sources === activitySignature ? shortExpiresAt : null,
now,
liveStates: options.liveStates,
effectiveHidden,
bindingUnavailable: policy.bindingUnavailable,
reducedMotion: options.reducedMotion,
color: rippleColor,
diameterScale: rippleScale,
});
const valueText = policy.face === 'value' ? value.text : null;
const disabledReason = status?.kind === 'ha_disabled' ? status.reason : null;
const reason = resolvePresentationReason({
lifecycle,
display,
liveStates: options.liveStates,
sourceKind: sources.sourceKind,
primaryDomain: (d.primary || '').split('.')[0],
visual,
activity,
});
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');
const powerSource = sources.visualSources.some((source) =>
source.role === 'power_gate' || isDevicePowerSwitch(hass, source.eid),
);
const uncategorisedSwitches = d.entities.filter((eid) =>
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'
&& combined.status !== 'alarm' && combined.activity !== 'none') {
notices.push('activity_display_disabled');
}
const presentation: ResolvedDevicePresentation = {
binding: d.marker?.binding
|| (d.bindingKind && d.bindingRef ? `${d.bindingKind}:${d.bindingRef}` : d.virtual ? 'virtual' : ''),
sourceKind: sources.sourceKind,
visualSources: sources.visualSources,
criticalSources: sources.criticalSources,
valueSource: value.source,
sourceSignature,
decisionIds: [
...sources.decisionIds,
...policy.decisionIds,
...(valueBadge ? ['diagnostics.value_badge'] : []),
...(lqi == null ? [] : [`diagnostics.lqi_${markerLqiBand(lqi)}`]),
`pulse.${pulse.kind}_${pulse.reason}`,
],
visual,
lockState,
display,
icon,
valueText,
valueFullText: policy.face === 'value' ? value.fullText : null,
fallbackReason: display === 'value' ? value.fallback : null,
activity,
activityGeneration: rt?.gen || 1,
pulse,
classes: [],
tempText: temp == null ? null : String(temp),
humText: hum == null ? null : String(hum),
valueBadge,
lqiText: lqi == null || valueBadge?.isLqi || value.source?.kind === 'derived_lqi'
? null : String(lqi),
lqiColor: lqi == null ? null : markerLqiColor(lqi),
lqiBand: lqi == null ? null : markerLqiBand(lqi),
lightColor,
scale,
angle,
rippleScale,
rippleColor,
userHidden,
effectiveHidden,
haDisabled,
disabledReason,
orphaned,
vacuumLive: policy.vacuumLive,
explanation: { reason, notices },
};
return { ...presentation, classes: presentationClasses(presentation) };
}