mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
850 lines
36 KiB
TypeScript
850 lines
36 KiB
TypeScript
/**
|
||
* 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) };
|
||
}
|