mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 03:38:47 +00:00
902 lines
33 KiB
TypeScript
902 lines
33 KiB
TypeScript
/**
|
|
* One authority for the marker action «Toggle state» (issue #94).
|
|
*
|
|
* Home Assistant has entity states, not a device state. This module resolves
|
|
* the user's persisted intent into an exact, current service call without
|
|
* silently falling back to an info card or an arbitrary sibling entity.
|
|
*/
|
|
import {
|
|
forcedLightEntityOf,
|
|
ownControllableEntities,
|
|
incomingLightControls,
|
|
persistedExternalControls,
|
|
resolvedDeviceStateEntities,
|
|
resolvedLightSources,
|
|
type ResolvedLightSource,
|
|
type IncomingLightControl,
|
|
} from './devices';
|
|
import { COVER_GUARDED_CLASSES, isControllable, type DeviceTapAction } from './logic';
|
|
import type { DevItem } from './types';
|
|
import {
|
|
isManualVirtualLightMarker,
|
|
virtualLightIsOn,
|
|
type VirtualLightSnapshot,
|
|
} from './virtual-light-state';
|
|
|
|
export type ToggleOrigin = 'explicit-toggle' | 'default-light' | 'legacy-cover';
|
|
export type ToggleSemantics = 'power' | 'group-power' | 'cover' | 'valve';
|
|
export type ToggleNextEffect = 'turn-on' | 'turn-off' | 'open' | 'close' | 'stop' | 'toggle';
|
|
export type ToggleNoneReason =
|
|
| 'no-binding'
|
|
| 'no-actionable-entity'
|
|
| 'configured-targets-missing'
|
|
| 'ha-disabled'
|
|
| 'unavailable'
|
|
| 'unsupported'
|
|
| 'secure';
|
|
export type ToggleSkipReason = 'missing' | 'ha-disabled' | 'unavailable' | 'unsupported' | 'secure';
|
|
|
|
export type ToggleTargetVia =
|
|
| 'binding'
|
|
| 'device-role'
|
|
| 'control-entity'
|
|
| 'control-marker-driver'
|
|
| 'virtual-light';
|
|
|
|
export interface ResolvedToggleTarget {
|
|
entityId: string;
|
|
name: string;
|
|
state: string;
|
|
via: ToggleTargetVia;
|
|
}
|
|
|
|
export interface SkippedToggleTarget {
|
|
ref: string;
|
|
entityId: string | null;
|
|
name: string | null;
|
|
reason: ToggleSkipReason;
|
|
}
|
|
|
|
export interface ToggleCommand {
|
|
domain: string;
|
|
service: string;
|
|
data: { entity_id: string | string[] };
|
|
}
|
|
|
|
export type ToggleOperation =
|
|
| { kind: 'ha-service'; command: ToggleCommand }
|
|
| { kind: 'virtual-light'; markerId: string };
|
|
|
|
export interface ResolvedToggleIntent {
|
|
origin: ToggleOrigin;
|
|
kind: 'single' | 'group' | 'none';
|
|
semantics: ToggleSemantics | null;
|
|
/** Exactly the entities included in `command`, in deterministic order. */
|
|
targets: ResolvedToggleTarget[];
|
|
skippedTargets: SkippedToggleTarget[];
|
|
noneReason: ToggleNoneReason | null;
|
|
nextEffect: ToggleNextEffect | null;
|
|
command: ToggleCommand | null;
|
|
/** Non-HA operational target. HA intents continue to expose `command`. */
|
|
operation?: ToggleOperation | null;
|
|
}
|
|
|
|
export interface ResolveToggleOptions {
|
|
hass: any;
|
|
/** Full registry projection, used only to explain disabled/missing targets. */
|
|
registryHass?: any;
|
|
devices: readonly DevItem[];
|
|
device: DevItem;
|
|
/** Reuse a plan-wide light graph when the caller already has one. */
|
|
lightSources?: readonly ResolvedLightSource<DevItem>[];
|
|
virtualLights?: VirtualLightSnapshot | null;
|
|
}
|
|
|
|
type PowerService = 'turn_on' | 'turn_off' | 'toggle';
|
|
|
|
interface PowerToggleAdapter {
|
|
/** States which mean disabled. Every other known state is active/on. */
|
|
offStates: readonly string[];
|
|
/** Whether an indeterminate state may use the domain/generic toggle. */
|
|
unknownUsesToggle: boolean;
|
|
/**
|
|
* Per-entity capability bits required by Home Assistant for each command.
|
|
* An absent entry means that the entity domain itself guarantees the basic
|
|
* turn-on/turn-off contract (for example light, switch and fan).
|
|
*/
|
|
featureMasks?: Partial<Record<PowerService, number>>;
|
|
}
|
|
|
|
const BASIC_POWER_ADAPTER: PowerToggleAdapter = {
|
|
offStates: ['off'],
|
|
unknownUsesToggle: true,
|
|
};
|
|
|
|
/**
|
|
* Explicit domain adapters are the capability allow-list for issue #94.
|
|
* `hass.services` is only a second, runtime guard: a service registered for a
|
|
* domain says nothing about whether one particular entity implements it.
|
|
*/
|
|
const POWER_ADAPTERS: Readonly<Record<string, PowerToggleAdapter>> = {
|
|
light: BASIC_POWER_ADAPTER,
|
|
switch: BASIC_POWER_ADAPTER,
|
|
fan: BASIC_POWER_ADAPTER,
|
|
humidifier: BASIC_POWER_ADAPTER,
|
|
input_boolean: BASIC_POWER_ADAPTER,
|
|
automation: BASIC_POWER_ADAPTER,
|
|
remote: BASIC_POWER_ADAPTER,
|
|
group: BASIC_POWER_ADAPTER,
|
|
climate: {
|
|
...BASIC_POWER_ADAPTER,
|
|
// Home Assistant ClimateEntityFeature.TURN_OFF / TURN_ON.
|
|
featureMasks: { turn_on: 256, turn_off: 128, toggle: 128 | 256 },
|
|
},
|
|
media_player: {
|
|
...BASIC_POWER_ADAPTER,
|
|
// Home Assistant MediaPlayerEntityFeature.TURN_ON / TURN_OFF.
|
|
featureMasks: { turn_on: 128, turn_off: 256, toggle: 128 | 256 },
|
|
},
|
|
siren: {
|
|
...BASIC_POWER_ADAPTER,
|
|
// Home Assistant SirenEntityFeature.TURN_ON / TURN_OFF.
|
|
featureMasks: { turn_on: 1, turn_off: 2, toggle: 1 | 2 },
|
|
},
|
|
vacuum: {
|
|
...BASIC_POWER_ADAPTER,
|
|
// Deprecated but still supported legacy VacuumEntityFeature bits. Modern
|
|
// StateVacuumEntity instances deliberately do not advertise these and are
|
|
// therefore not treated as a binary power toggle.
|
|
featureMasks: { turn_on: 1, turn_off: 2, toggle: 1 | 2 },
|
|
},
|
|
water_heater: {
|
|
...BASIC_POWER_ADAPTER,
|
|
// Home Assistant WaterHeaterEntityFeature.ON_OFF.
|
|
featureMasks: { turn_on: 8, turn_off: 8, toggle: 8 },
|
|
},
|
|
camera: {
|
|
...BASIC_POWER_ADAPTER,
|
|
// Home Assistant CameraEntityFeature.ON_OFF.
|
|
featureMasks: { turn_on: 1, turn_off: 1, toggle: 1 },
|
|
},
|
|
};
|
|
|
|
const FEATURE_OPEN = 1;
|
|
const FEATURE_CLOSE = 2;
|
|
const FEATURE_STOP = 8;
|
|
|
|
function domainOf(entityId: string): string {
|
|
return entityId.slice(0, entityId.indexOf('.'));
|
|
}
|
|
|
|
function serviceExists(hass: any, domain: string, service: string): boolean {
|
|
if (!hass?.services || typeof hass.services !== 'object') return false;
|
|
const services = hass.services?.[domain];
|
|
return !!services && Object.prototype.hasOwnProperty.call(services, service);
|
|
}
|
|
|
|
function directionalService(
|
|
hass: any, domain: string, service: 'turn_on' | 'turn_off' | 'toggle',
|
|
): { domain: string; service: string } | null {
|
|
if (serviceExists(hass, domain, service)) return { domain, service };
|
|
if (serviceExists(hass, 'homeassistant', service)) return { domain: 'homeassistant', service };
|
|
return null;
|
|
}
|
|
|
|
function entityRegistry(registryHass: any, entityId: string): any {
|
|
return registryHass?.entities?.[entityId] || null;
|
|
}
|
|
|
|
function entityName(hass: any, registryHass: any, entityId: string): string {
|
|
const state = hass?.states?.[entityId];
|
|
const registry = entityRegistry(registryHass, entityId);
|
|
return state?.attributes?.friendly_name || registry?.name || registry?.original_name || entityId;
|
|
}
|
|
|
|
function registryDisabled(registryHass: any, entityId: string): boolean {
|
|
const entity = entityRegistry(registryHass, entityId);
|
|
if (entity?.disabled_by != null) return true;
|
|
const device = entity?.device_id ? registryHass?.devices?.[entity.device_id] : null;
|
|
return device?.disabled_by != null;
|
|
}
|
|
|
|
function supportedFeature(state: any, mask: number): boolean {
|
|
const raw = state?.attributes?.supported_features;
|
|
if (raw == null || raw === '') return false;
|
|
const features = Number(raw);
|
|
return Number.isFinite(features) && (features & mask) === mask;
|
|
}
|
|
|
|
function secureEntity(hass: any, registryHass: any, entityId: string): boolean {
|
|
const domain = domainOf(entityId);
|
|
if (domain === 'lock' || domain === 'alarm_control_panel') return true;
|
|
if (domain !== 'cover') return false;
|
|
const state = hass?.states?.[entityId];
|
|
const registry = entityRegistry(registryHass, entityId);
|
|
const deviceClass = String(
|
|
state?.attributes?.device_class || registry?.device_class || registry?.original_device_class || '',
|
|
);
|
|
return COVER_GUARDED_CLASSES.has(deviceClass);
|
|
}
|
|
|
|
function skipped(
|
|
hass: any, registryHass: any, ref: string, entityId: string | null, reason: ToggleSkipReason,
|
|
): SkippedToggleTarget {
|
|
return {
|
|
ref,
|
|
entityId,
|
|
name: entityId ? entityName(hass, registryHass, entityId) : null,
|
|
reason,
|
|
};
|
|
}
|
|
|
|
interface SingleResolution {
|
|
target: ResolvedToggleTarget | null;
|
|
skipped: SkippedToggleTarget | null;
|
|
semantics: ToggleSemantics | null;
|
|
nextEffect: ToggleNextEffect | null;
|
|
command: ToggleCommand | null;
|
|
}
|
|
|
|
function unsupportedSingle(
|
|
hass: any, registryHass: any, ref: string, entityId: string, reason: ToggleSkipReason,
|
|
semantics: ToggleSemantics | null = null,
|
|
): SingleResolution {
|
|
return {
|
|
target: null,
|
|
skipped: skipped(hass, registryHass, ref, entityId, reason),
|
|
semantics,
|
|
nextEffect: null,
|
|
command: null,
|
|
};
|
|
}
|
|
|
|
function resolvePowerEntity(
|
|
hass: any, registryHass: any, entityId: string, via: ToggleTargetVia, ref = entityId,
|
|
): SingleResolution {
|
|
const domain = domainOf(entityId);
|
|
const stateObject = hass?.states?.[entityId];
|
|
if (registryDisabled(registryHass, entityId)) {
|
|
return unsupportedSingle(hass, registryHass, ref, entityId, 'ha-disabled', 'power');
|
|
}
|
|
if (secureEntity(hass, registryHass, entityId)) {
|
|
return unsupportedSingle(hass, registryHass, ref, entityId, 'secure');
|
|
}
|
|
if (!stateObject) return unsupportedSingle(hass, registryHass, ref, entityId, 'missing', 'power');
|
|
if (stateObject.state === 'unavailable') {
|
|
return unsupportedSingle(hass, registryHass, ref, entityId, 'unavailable', 'power');
|
|
}
|
|
const adapter = POWER_ADAPTERS[domain];
|
|
if (!adapter) {
|
|
return unsupportedSingle(hass, registryHass, ref, entityId, 'unsupported');
|
|
}
|
|
|
|
let nextEffect: ToggleNextEffect;
|
|
let requested: PowerService;
|
|
if (stateObject.state === 'unknown' || stateObject.state === '') {
|
|
if (!adapter.unknownUsesToggle) {
|
|
return unsupportedSingle(hass, registryHass, ref, entityId, 'unsupported', 'power');
|
|
}
|
|
nextEffect = 'toggle';
|
|
requested = 'toggle';
|
|
} else if (adapter.offStates.includes(String(stateObject.state))) {
|
|
nextEffect = 'turn-on';
|
|
requested = 'turn_on';
|
|
} else {
|
|
nextEffect = 'turn-off';
|
|
requested = 'turn_off';
|
|
}
|
|
|
|
const featureMask = adapter.featureMasks?.[requested];
|
|
if (featureMask && !supportedFeature(stateObject, featureMask)) {
|
|
return unsupportedSingle(hass, registryHass, ref, entityId, 'unsupported', 'power');
|
|
}
|
|
const service = directionalService(hass, domain, requested);
|
|
if (!service) return unsupportedSingle(hass, registryHass, ref, entityId, 'unsupported', 'power');
|
|
const target: ResolvedToggleTarget = {
|
|
entityId,
|
|
name: entityName(hass, registryHass, entityId),
|
|
state: String(stateObject.state || ''),
|
|
via,
|
|
};
|
|
return {
|
|
target,
|
|
skipped: null,
|
|
semantics: 'power',
|
|
nextEffect,
|
|
command: { ...service, data: { entity_id: entityId } },
|
|
};
|
|
}
|
|
|
|
function coverLikeService(
|
|
hass: any,
|
|
domain: 'cover' | 'valve',
|
|
stateObject: any,
|
|
): { service: string; effect: ToggleNextEffect } | null {
|
|
const state = String(stateObject?.state || '');
|
|
const candidate = state === 'closed'
|
|
? { service: `open_${domain}`, effect: 'open' as const, feature: FEATURE_OPEN }
|
|
: state === 'open'
|
|
? { service: `close_${domain}`, effect: 'close' as const, feature: FEATURE_CLOSE }
|
|
: state === 'opening' || state === 'closing'
|
|
? { service: `stop_${domain}`, effect: 'stop' as const, feature: FEATURE_STOP }
|
|
: null;
|
|
if (candidate && supportedFeature(stateObject, candidate.feature)
|
|
&& serviceExists(hass, domain, candidate.service)) {
|
|
return candidate;
|
|
}
|
|
// A cover/valve toggle is only a safe substitute when the entity advertises
|
|
// both directions. A domain-level `toggle` service is not proof that this
|
|
// particular entity can perform the missing direction.
|
|
const supportsBothDirections = supportedFeature(stateObject, FEATURE_OPEN | FEATURE_CLOSE);
|
|
return supportsBothDirections && serviceExists(hass, domain, 'toggle')
|
|
? { service: 'toggle', effect: 'toggle' }
|
|
: null;
|
|
}
|
|
|
|
function resolveEntity(
|
|
hass: any, registryHass: any, entityId: string, via: ToggleTargetVia, ref = entityId,
|
|
): SingleResolution {
|
|
const domain = domainOf(entityId);
|
|
if (domain !== 'cover' && domain !== 'valve') {
|
|
return resolvePowerEntity(hass, registryHass, entityId, via, ref);
|
|
}
|
|
if (registryDisabled(registryHass, entityId)) {
|
|
return unsupportedSingle(hass, registryHass, ref, entityId, 'ha-disabled', domain);
|
|
}
|
|
if (secureEntity(hass, registryHass, entityId)) {
|
|
return unsupportedSingle(hass, registryHass, ref, entityId, 'secure', domain);
|
|
}
|
|
const stateObject = hass?.states?.[entityId];
|
|
if (!stateObject) return unsupportedSingle(hass, registryHass, ref, entityId, 'missing', domain);
|
|
if (stateObject.state === 'unavailable') {
|
|
return unsupportedSingle(hass, registryHass, ref, entityId, 'unavailable', domain);
|
|
}
|
|
const service = coverLikeService(hass, domain, stateObject);
|
|
if (!service) return unsupportedSingle(hass, registryHass, ref, entityId, 'unsupported', domain);
|
|
const target: ResolvedToggleTarget = {
|
|
entityId,
|
|
name: entityName(hass, registryHass, entityId),
|
|
state: String(stateObject.state || ''),
|
|
via,
|
|
};
|
|
return {
|
|
target,
|
|
skipped: null,
|
|
semantics: domain,
|
|
nextEffect: service.effect,
|
|
command: { domain, service: service.service, data: { entity_id: entityId } },
|
|
};
|
|
}
|
|
|
|
function ownRoleCandidates(device: DevItem, registryHass: any): string[] {
|
|
if (device.bindingKind === 'entity' && device.bindingRef) {
|
|
// The binding remains exact unless the user explicitly selected the
|
|
// marker's light driver. That persisted override is shared by rendering,
|
|
// Glow and tap handling, so the face must never represent light.lamp while
|
|
// a tap silently toggles the original relay instead.
|
|
const explicitLight = device.marker?.light_entity
|
|
? forcedLightEntityOf(device)
|
|
: null;
|
|
return [...new Set([explicitLight, device.bindingRef].filter(
|
|
(eid): eid is string => !!eid,
|
|
))];
|
|
}
|
|
const candidates = device.entities.length ? device.entities : device.allEntities || [];
|
|
const leading = (device.marker?.light_entity
|
|
|| (device.primary && isControllable(device.primary)))
|
|
? forcedLightEntityOf(device)
|
|
: null;
|
|
return [...new Set([
|
|
leading,
|
|
...resolvedDeviceStateEntities(registryHass, candidates),
|
|
].filter((eid): eid is string => !!eid))];
|
|
}
|
|
|
|
/** Own light/switch choices exposed by the Toggle selector. An entity binding
|
|
* is already an exact user choice and must not silently expand to registry
|
|
* siblings; a device binding keeps the established deterministic order. */
|
|
export function toggleEntityCandidates(device: DevItem): string[] {
|
|
if (device.bindingKind === 'entity') {
|
|
return device.bindingRef && isControllable(device.bindingRef) ? [device.bindingRef] : [];
|
|
}
|
|
if (device.bindingKind === 'virtual') return [];
|
|
return ownControllableEntities(device);
|
|
}
|
|
|
|
/** A stale explicit choice remains persisted but is never sent to a service. */
|
|
function selectedToggleEntity(device: DevItem): string | null {
|
|
const selected = device.marker?.toggle_entity;
|
|
return selected && toggleEntityCandidates(device).includes(selected) ? selected : null;
|
|
}
|
|
|
|
/**
|
|
* Resolve the first supported member of the already-selected functional role.
|
|
* Missing/unavailable/secure members remain the explained target and never
|
|
* retarget. Capability-unsupported peers may be skipped only within that same
|
|
* role; `resolvedDeviceStateEntities` has already excluded random sibling
|
|
* config switches from a stronger cover/climate/media role.
|
|
*/
|
|
function resolveOwnEntity(
|
|
hass: any, registryHass: any, device: DevItem,
|
|
): SingleResolution | null {
|
|
const role = ownRoleCandidates(device, registryHass);
|
|
if (!role.length) return null;
|
|
const via: ToggleTargetVia = device.bindingKind === 'entity' ? 'binding' : 'device-role';
|
|
if (via === 'binding') return resolveEntity(hass, registryHass, role[0], via);
|
|
|
|
let firstUnsupported: SingleResolution | null = null;
|
|
let firstDisabled: SingleResolution | null = null;
|
|
for (const entityId of role) {
|
|
const result = resolveEntity(hass, registryHass, entityId, via);
|
|
if (result.command) return result;
|
|
const reason = result.skipped?.reason;
|
|
if (reason === 'missing' || reason === 'unavailable' || reason === 'secure') return result;
|
|
if (reason === 'ha-disabled') {
|
|
firstDisabled ||= result;
|
|
continue;
|
|
}
|
|
firstUnsupported ||= result;
|
|
}
|
|
return firstUnsupported || firstDisabled;
|
|
}
|
|
|
|
function reasonForSingle(result: SingleResolution): ToggleNoneReason {
|
|
const reason = result.skipped?.reason;
|
|
if (reason === 'missing') return 'unavailable';
|
|
return reason || 'unsupported';
|
|
}
|
|
|
|
function emptyIntent(origin: ToggleOrigin, reason: ToggleNoneReason): ResolvedToggleIntent {
|
|
return {
|
|
origin,
|
|
kind: 'none',
|
|
semantics: null,
|
|
targets: [],
|
|
skippedTargets: [],
|
|
noneReason: reason,
|
|
nextEffect: null,
|
|
command: null,
|
|
};
|
|
}
|
|
|
|
/** Effective action shown by the UI. Legacy cover is projected, not rewritten. */
|
|
export function projectedTapAction(
|
|
persisted: string | null | undefined,
|
|
defaultDomain: string | null | undefined,
|
|
): DeviceTapAction {
|
|
if (persisted === 'cover' || persisted === 'toggle') return 'toggle';
|
|
if (persisted === 'more-info' || persisted === 'run'
|
|
|| persisted === 'info' || persisted === 'none') return persisted;
|
|
// Only actual absence activates the light default. An unknown persisted
|
|
// token is invalid data, not an absent choice: fail closed to the local card
|
|
// so the UI projection and `toggleOriginOf()` cannot disagree.
|
|
return persisted == null || persisted === ''
|
|
? (defaultDomain === 'light' ? 'toggle' : 'info')
|
|
: 'info';
|
|
}
|
|
|
|
export function toggleOriginOf(device: DevItem): ToggleOrigin | null {
|
|
if (device.tapAction === 'toggle') return 'explicit-toggle';
|
|
if (device.tapAction === 'cover') return 'legacy-cover';
|
|
return !device.tapAction && device.primary?.startsWith('light.') ? 'default-light' : null;
|
|
}
|
|
|
|
function singleIntent(
|
|
origin: ToggleOrigin,
|
|
result: SingleResolution,
|
|
): ResolvedToggleIntent {
|
|
return {
|
|
origin,
|
|
kind: 'single',
|
|
semantics: result.semantics,
|
|
targets: result.target ? [result.target] : [],
|
|
skippedTargets: result.skipped ? [result.skipped] : [],
|
|
noneReason: result.command ? null : reasonForSingle(result),
|
|
nextEffect: result.nextEffect,
|
|
command: result.command,
|
|
};
|
|
}
|
|
|
|
interface GroupEntityRef {
|
|
entityId: string;
|
|
via: ToggleTargetVia;
|
|
ref: string;
|
|
}
|
|
|
|
function resolveGroupEntities(
|
|
hass: any,
|
|
registryHass: any,
|
|
entries: readonly GroupEntityRef[],
|
|
initialSkipped: readonly SkippedToggleTarget[] = [],
|
|
): ResolvedToggleIntent {
|
|
const byEntity = new Map<string, ResolvedToggleTarget>();
|
|
const skippedTargets: SkippedToggleTarget[] = [];
|
|
const skippedKeys = new Set<string>();
|
|
const addSkipped = (target: SkippedToggleTarget): void => {
|
|
const key = `${target.ref}\n${target.entityId || ''}\n${target.reason}`;
|
|
if (skippedKeys.has(key)) return;
|
|
skippedKeys.add(key);
|
|
skippedTargets.push(target);
|
|
};
|
|
for (const target of initialSkipped) addSkipped(target);
|
|
for (const entry of entries) {
|
|
const result = resolveEntity(hass, registryHass, entry.entityId, entry.via, entry.ref);
|
|
if (result.target) {
|
|
byEntity.set(entry.entityId, byEntity.get(entry.entityId) || result.target);
|
|
} else if (result.skipped) addSkipped(result.skipped);
|
|
}
|
|
|
|
const targets = [...byEntity.values()];
|
|
if (!targets.length) {
|
|
return {
|
|
origin: 'explicit-toggle',
|
|
kind: 'group',
|
|
semantics: 'group-power',
|
|
targets,
|
|
skippedTargets,
|
|
noneReason: skippedTargets.length && skippedTargets.every((target) => target.reason === 'secure')
|
|
? 'secure' : 'configured-targets-missing',
|
|
nextEffect: null,
|
|
command: null,
|
|
};
|
|
}
|
|
const service = targets.some((target) => target.state === 'on') ? 'turn_off' : 'turn_on';
|
|
const commandService = directionalService(hass, 'homeassistant', service);
|
|
if (!commandService) {
|
|
return {
|
|
origin: 'explicit-toggle', kind: 'group', semantics: 'group-power', targets: [],
|
|
skippedTargets: [
|
|
...skippedTargets,
|
|
...targets.map((target) => skipped(
|
|
hass, registryHass, target.entityId, target.entityId, 'unsupported',
|
|
)),
|
|
],
|
|
noneReason: 'unsupported', nextEffect: null, command: null,
|
|
};
|
|
}
|
|
return {
|
|
origin: 'explicit-toggle',
|
|
kind: 'group',
|
|
semantics: 'group-power',
|
|
targets,
|
|
skippedTargets,
|
|
noneReason: null,
|
|
nextEffect: service === 'turn_on' ? 'turn-on' : 'turn-off',
|
|
command: {
|
|
...commandService,
|
|
data: { entity_id: targets.map((target) => target.entityId) },
|
|
},
|
|
};
|
|
}
|
|
|
|
function stableDeviceId(device: DevItem): string {
|
|
return String(device.marker?.id || device.id || '');
|
|
}
|
|
|
|
function resolveIncomingControllers(
|
|
options: ResolveToggleOptions,
|
|
incoming: IncomingLightControl<DevItem>,
|
|
): ResolvedToggleIntent {
|
|
const registryHass = options.registryHass || options.hass;
|
|
return resolveGroupEntities(
|
|
options.hass,
|
|
registryHass,
|
|
incoming.driverEids.map((entityId) => ({
|
|
entityId,
|
|
via: 'control-marker-driver' as const,
|
|
ref: `marker:${incoming.markerId}`,
|
|
})),
|
|
);
|
|
}
|
|
|
|
function resolveControls(
|
|
options: ResolveToggleOptions, selectedOwnEntity: string | null = null,
|
|
): ResolvedToggleIntent {
|
|
const { hass, device, devices } = options;
|
|
const registryHass = options.registryHass || hass;
|
|
const refs = persistedExternalControls(
|
|
device.marker?.binding, device.marker?.controls ?? device.controls, device.entities,
|
|
);
|
|
const sources = options.lightSources
|
|
|| resolvedLightSources(hass, devices, null, options.virtualLights);
|
|
const markerSources = new Map<string, ResolvedLightSource<DevItem>[]>();
|
|
for (const source of sources) {
|
|
if (!source.key.startsWith('marker:')) continue;
|
|
const list = markerSources.get(source.key) || [];
|
|
list.push(source);
|
|
markerSources.set(source.key, list);
|
|
}
|
|
const incomingByMarker = incomingLightControls(devices);
|
|
const markerDevices = new Map<string, DevItem>();
|
|
for (const item of devices) {
|
|
const markerId = String(item.marker?.id || item.id || '');
|
|
if (markerId) markerDevices.set(markerId, item);
|
|
}
|
|
const entries: GroupEntityRef[] = [];
|
|
const skippedTargets: SkippedToggleTarget[] = [];
|
|
|
|
if (selectedOwnEntity) {
|
|
entries.push({
|
|
entityId: selectedOwnEntity,
|
|
via: device.bindingKind === 'entity' ? 'binding' : 'device-role',
|
|
ref: selectedOwnEntity,
|
|
});
|
|
}
|
|
|
|
for (const ref of refs) {
|
|
if (!ref.startsWith('marker:')) {
|
|
if (!isControllable(ref)) {
|
|
skippedTargets.push(skipped(
|
|
hass, registryHass, ref, ref.includes('.') ? ref : null, 'unsupported',
|
|
));
|
|
} else entries.push({ entityId: ref, via: 'control-entity', ref });
|
|
continue;
|
|
}
|
|
const linked = markerSources.get(ref) || [];
|
|
if (!linked.length) {
|
|
const target = markerDevices.get(ref.slice('marker:'.length));
|
|
if (target?.bindingStatus?.kind === 'ha_disabled') {
|
|
const entityId = target.bindingStatus.allEntityIds[0] || null;
|
|
skippedTargets.push({
|
|
ref,
|
|
entityId,
|
|
name: target.name || (entityId ? entityName(hass, registryHass, entityId) : null),
|
|
reason: 'ha-disabled',
|
|
});
|
|
} else {
|
|
skippedTargets.push(skipped(hass, registryHass, ref, null, 'missing'));
|
|
}
|
|
continue;
|
|
}
|
|
const serviceEntities = [...new Set(linked.flatMap((source) => source.serviceEids))];
|
|
if (serviceEntities.length) {
|
|
for (const entityId of serviceEntities) {
|
|
entries.push({ entityId, via: 'control-entity', ref });
|
|
}
|
|
continue;
|
|
}
|
|
const passive = linked.some((source) => source.passive);
|
|
const targetId = ref.slice('marker:'.length);
|
|
const controllerId = stableDeviceId(device);
|
|
const controller = incomingByMarker.get(targetId)?.controllers.find(
|
|
(candidate) => stableDeviceId(candidate.device) === controllerId,
|
|
);
|
|
if (passive && controller?.driverEids.length) {
|
|
for (const entityId of controller.driverEids) {
|
|
entries.push({ entityId, via: 'control-marker-driver', ref });
|
|
}
|
|
} else {
|
|
skippedTargets.push(skipped(
|
|
hass, registryHass, ref, null, passive ? 'missing' : 'unsupported',
|
|
));
|
|
}
|
|
}
|
|
return resolveGroupEntities(hass, registryHass, entries, skippedTargets);
|
|
}
|
|
|
|
/** Resolve the current exact target and command for one toggle-origin marker. */
|
|
export function resolveToggleIntent(options: ResolveToggleOptions): ResolvedToggleIntent | null {
|
|
const { hass, device } = options;
|
|
const registryHass = options.registryHass || hass;
|
|
const origin = toggleOriginOf(device);
|
|
if (!origin) return null;
|
|
|
|
// This operational target intentionally precedes `controls`: controls stay
|
|
// persisted losslessly, but while the exact manual mode is active a tap
|
|
// changes the marker's own server state and never calls an HA service.
|
|
const marker = device.marker;
|
|
if (origin === 'explicit-toggle' && isManualVirtualLightMarker(marker)) {
|
|
const markerId = String(marker?.id || device.id || '');
|
|
const incoming = incomingLightControls(options.devices).get(markerId);
|
|
if (incoming) return resolveIncomingControllers(options, incoming);
|
|
const on = virtualLightIsOn(marker, options.virtualLights);
|
|
return {
|
|
origin,
|
|
kind: 'single',
|
|
semantics: 'power',
|
|
targets: [{
|
|
entityId: '',
|
|
name: device.name,
|
|
state: on ? 'on' : 'off',
|
|
via: 'virtual-light',
|
|
}],
|
|
skippedTargets: [],
|
|
noneReason: null,
|
|
nextEffect: on ? 'turn-off' : 'turn-on',
|
|
command: null,
|
|
operation: { kind: 'virtual-light', markerId: marker!.id! },
|
|
};
|
|
}
|
|
|
|
if (origin === 'explicit-toggle') {
|
|
const refs = persistedExternalControls(
|
|
device.marker?.binding, device.marker?.controls ?? device.controls, device.entities,
|
|
);
|
|
if (refs.length) return resolveControls(options, selectedToggleEntity(device));
|
|
}
|
|
|
|
if (origin === 'legacy-cover') {
|
|
// Legacy cover is an explicit historical target choice. Keep its identity
|
|
// even if HA later disables that cover while other active siblings remain.
|
|
// An active cover still wins: `allEntities` may contain an older disabled
|
|
// peer before it, and registry order must not downgrade a working target.
|
|
const entityId = device.entities.find((eid) => eid.startsWith('cover.'))
|
|
|| device.allEntities?.find((eid) => eid.startsWith('cover.'));
|
|
return entityId
|
|
? singleIntent(origin, resolveEntity(hass, registryHass, entityId, 'device-role'))
|
|
: emptyIntent(origin, 'no-actionable-entity');
|
|
}
|
|
|
|
const selected = selectedToggleEntity(device);
|
|
const own = selected
|
|
? resolveEntity(
|
|
hass,
|
|
registryHass,
|
|
selected,
|
|
device.bindingKind === 'entity' ? 'binding' : 'device-role',
|
|
)
|
|
: resolveOwnEntity(hass, registryHass, device);
|
|
if (!own) {
|
|
return emptyIntent(origin, device.virtual || device.bindingKind === 'virtual'
|
|
? 'no-actionable-entity' : 'no-binding');
|
|
}
|
|
return singleIntent(origin, own);
|
|
}
|
|
|
|
/** Entity which owns cover presentation, even while temporarily unavailable. */
|
|
export function toggleCoverEntity(intent: ResolvedToggleIntent | null): string | null {
|
|
if (intent?.semantics !== 'cover') return null;
|
|
return intent.targets[0]?.entityId || intent.skippedTargets[0]?.entityId || null;
|
|
}
|
|
|
|
export function toggleCommandEntityIds(command: ToggleCommand | null): string[] {
|
|
if (!command) return [];
|
|
const ids = Array.isArray(command.data.entity_id) ? command.data.entity_id : [command.data.entity_id];
|
|
return [...new Set(ids)].sort();
|
|
}
|
|
|
|
export function sameToggleCommandTargets(a: ToggleCommand | null, b: ToggleCommand | null): boolean {
|
|
const left = toggleCommandEntityIds(a);
|
|
const right = toggleCommandEntityIds(b);
|
|
return left.length === right.length && left.every((entityId, index) => entityId === right[index]);
|
|
}
|
|
|
|
export function toggleOperation(intent: ResolvedToggleIntent | null): ToggleOperation | null {
|
|
if (!intent) return null;
|
|
if (intent.operation) return intent.operation;
|
|
return intent.command ? { kind: 'ha-service', command: intent.command } : null;
|
|
}
|
|
|
|
const TARGET_UNAVAILABLE_NOTICE_REASONS = new Set<ToggleSkipReason>([
|
|
'missing', 'ha-disabled', 'unavailable',
|
|
]);
|
|
|
|
/**
|
|
* Names for the #251 explanatory no-op toast.
|
|
*
|
|
* Only configured control groups qualify. Unsupported targets keep their
|
|
* existing explained/quiet path; secure targets may coexist with unavailable
|
|
* ones but are never named or made actionable by this projection.
|
|
*/
|
|
export function unavailableToggleTargetNames(
|
|
intent: ResolvedToggleIntent | null,
|
|
): string[] {
|
|
if (!intent || intent.kind !== 'group' || toggleOperation(intent)
|
|
|| intent.noneReason !== 'configured-targets-missing') return [];
|
|
if (!intent.skippedTargets.length || intent.skippedTargets.some((target) =>
|
|
!TARGET_UNAVAILABLE_NOTICE_REASONS.has(target.reason) && target.reason !== 'secure'
|
|
)) return [];
|
|
|
|
const names: string[] = [];
|
|
const seen = new Set<string>();
|
|
for (const target of intent.skippedTargets) {
|
|
if (!TARGET_UNAVAILABLE_NOTICE_REASONS.has(target.reason)) continue;
|
|
const name = String(target.name || target.entityId || target.ref || '').trim();
|
|
if (!name || seen.has(name)) continue;
|
|
seen.add(name);
|
|
names.push(name);
|
|
}
|
|
return names;
|
|
}
|
|
|
|
export interface ToggleConfirmationFormatter {
|
|
/** Localized/formatted current state for one executable target. */
|
|
state: (target: ResolvedToggleTarget) => string;
|
|
current: (state: string) => string;
|
|
expected: (state: string) => string;
|
|
groupCurrent: (on: number, total: number) => string;
|
|
groupAllOn: () => string;
|
|
groupAllOff: () => string;
|
|
unavailable: (count: number) => string;
|
|
effect: (effect: Exclude<ToggleNextEffect, 'toggle'>) => string;
|
|
expectedByHa: () => string;
|
|
}
|
|
|
|
/**
|
|
* Accessible current/expected/skipped copy for an executable confirmation.
|
|
* Direction is read exclusively from `nextEffect`; this formatter never
|
|
* derives a command from a domain or from the current-state label.
|
|
*/
|
|
export function formatToggleConfirmation(
|
|
intent: ResolvedToggleIntent,
|
|
formatter: ToggleConfirmationFormatter,
|
|
): string[] {
|
|
if (!toggleOperation(intent) || !intent.nextEffect || !intent.targets.length) return [];
|
|
|
|
const isGroup = intent.kind === 'group';
|
|
const on = isGroup
|
|
? intent.targets.filter((target) => target.state === 'on').length
|
|
: 0;
|
|
const currentState = isGroup
|
|
? (on === 0 ? formatter.groupAllOff() : formatter.groupCurrent(on, intent.targets.length))
|
|
: formatter.state(intent.targets[0]);
|
|
|
|
let expectedState: string;
|
|
if (intent.nextEffect === 'toggle') {
|
|
expectedState = formatter.expectedByHa();
|
|
} else if (isGroup && intent.nextEffect === 'turn-on') {
|
|
expectedState = formatter.groupAllOn();
|
|
} else if (isGroup && intent.nextEffect === 'turn-off') {
|
|
expectedState = formatter.groupAllOff();
|
|
} else {
|
|
expectedState = formatter.effect(intent.nextEffect);
|
|
}
|
|
|
|
return [
|
|
formatter.current(currentState),
|
|
formatter.expected(expectedState),
|
|
...(intent.skippedTargets.length
|
|
? [formatter.unavailable(intent.skippedTargets.length)]
|
|
: []),
|
|
];
|
|
}
|
|
|
|
/** Stable confirmation identity; direction is deliberately re-resolved later. */
|
|
export function sameToggleOperationTargets(
|
|
a: ResolvedToggleIntent | null,
|
|
b: ResolvedToggleIntent | null,
|
|
): boolean {
|
|
const left = toggleOperation(a);
|
|
const right = toggleOperation(b);
|
|
if (!left || !right || left.kind !== right.kind) return false;
|
|
if (left.kind === 'virtual-light' && right.kind === 'virtual-light') {
|
|
return left.markerId === right.markerId;
|
|
}
|
|
return left.kind === 'ha-service' && right.kind === 'ha-service'
|
|
&& sameToggleCommandTargets(left.command, right.command);
|
|
}
|
|
|
|
export interface ToggleIntentFormatter {
|
|
single: (target: ResolvedToggleTarget | SkippedToggleTarget) => string;
|
|
group: (targets: readonly ResolvedToggleTarget[]) => string;
|
|
currentNext: (target: ResolvedToggleTarget, effect: ToggleNextEffect) => string;
|
|
groupCurrentNext: (targets: readonly ResolvedToggleTarget[], effect: ToggleNextEffect) => string;
|
|
skipped: (targets: readonly SkippedToggleTarget[]) => string;
|
|
none: (reason: ToggleNoneReason) => string;
|
|
}
|
|
|
|
/** Shared line selection for dialog hint and confirmation diagnostics. */
|
|
export function formatToggleIntent(
|
|
intent: ResolvedToggleIntent,
|
|
formatter: ToggleIntentFormatter,
|
|
): string[] {
|
|
const intended = intent.targets[0] || intent.skippedTargets[0];
|
|
const lines: string[] = [];
|
|
if (intent.kind === 'group') {
|
|
if (intent.targets.length) lines.push(formatter.group(intent.targets));
|
|
} else if (intended) lines.push(formatter.single(intended));
|
|
if (intent.nextEffect && intent.targets.length) {
|
|
lines.push(intent.kind === 'group'
|
|
? formatter.groupCurrentNext(intent.targets, intent.nextEffect)
|
|
: formatter.currentNext(intent.targets[0], intent.nextEffect));
|
|
}
|
|
if (intent.skippedTargets.length) lines.push(formatter.skipped(intent.skippedTargets));
|
|
if (!intent.command && intent.noneReason) lines.push(formatter.none(intent.noneReason));
|
|
return lines;
|
|
}
|
|
|
|
export function toggleIntentName(intent: ResolvedToggleIntent): string {
|
|
const targets = intent.targets.length ? intent.targets : intent.skippedTargets;
|
|
return targets.map((target) => target.name || target.entityId || target.ref).join(', ');
|
|
}
|