diff --git a/futurehome/CHANGELOG.md b/futurehome/CHANGELOG.md index e705957..bc0fe43 100644 --- a/futurehome/CHANGELOG.md +++ b/futurehome/CHANGELOG.md @@ -1,5 +1,9 @@ +## 1.6.3 (01.07.2026) + +- Fixed Modeswitch/Modusbryter (and other scene controllers) staying stuck at `unknown`. Live `evt.scene.report` button presses are now delivered to the "Scene" entity even for services that never appear in the periodic state poll. + ## 1.6.2 (16.05.2026) - Fix invalid device_class + unit_of_measurement combinations for HA 2026.5 (#32). diff --git a/futurehome/config.yaml b/futurehome/config.yaml index 3e185b7..d46f340 100644 --- a/futurehome/config.yaml +++ b/futurehome/config.yaml @@ -1,6 +1,6 @@ # https://developers.home-assistant.io/docs/add-ons/configuration#add-on-config name: Futurehome -version: '1.6.2' +version: '1.6.3' slug: futurehome description: Local Futurehome Smarthub integration url: 'https://github.com/adrianjagielak/home-assistant-futurehome' diff --git a/futurehome/src/ha/publish_device.ts b/futurehome/src/ha/publish_device.ts index 4123ec8..aef4398 100644 --- a/futurehome/src/ha/publish_device.ts +++ b/futurehome/src/ha/publish_device.ts @@ -38,6 +38,7 @@ import { abbreviateHaMqttKeys } from './abbreviate_ha_mqtt_keys'; import { ha } from './globals'; import { HaDeviceConfig } from './ha_device_config'; import { HaMqttComponent } from './mqtt_components/_component'; +import { registerServiceStateTopic } from './update_state'; export type ServiceComponentsCreationResult = { components: { [key: string]: HaMqttComponent }; @@ -228,6 +229,12 @@ export function haPublishDevice(parameters: { if (!svc.enabled) { continue; } + + // Register the address→state-topic mapping so that live `evt.*.report` + // events for services that never appear in the periodic state poll (e.g. a + // Modeswitch/Modusbryter scene controller) can still be routed to this + // device's state topic instead of being dropped. + registerServiceStateTopic(svc.addr, `${topicPrefix}/state`); // Skip publishing services that are already represented by higher-level MQTT entities if ( !shouldPublishService( diff --git a/futurehome/src/ha/update_state.ts b/futurehome/src/ha/update_state.ts index 19abe9c..1a0eabb 100644 --- a/futurehome/src/ha/update_state.ts +++ b/futurehome/src/ha/update_state.ts @@ -175,6 +175,36 @@ const haStateCache: Record< Record> // payload (addr → { attr → value }) > = {}; +/** + * Maps a FIMP service address + * (e.g. "/rt:dev/rn:zw/ad:1/sv:scene_ctrl/ad:86_0") to the Home Assistant + * device state topic that should carry its value. + * + * This is required because some services never appear in the periodic Vinculum + * `state` poll and therefore never get an entry in `haStateCache`. The most + * prominent example is a momentary scene controller such as the Futurehome + * Modeswitch / Modusbryter (a Z-Wave device): it only ever emits + * `evt.scene.report` events when a button is pressed, and its `scene` + * attribute is not part of the polled state. + * + * Without this mapping those live events would be dropped in + * `haUpdateStateValueReport` (because the address is not present in any cached + * payload), leaving the "Scene" sensor stuck at "unknown" forever. + * + * The mapping is populated both from the device config (see + * `haPublishDevice`) and from every state poll below. + */ +const addrToStateTopic: Record = {}; + +/** + * Registers the Home Assistant device state topic that owns a given FIMP + * service address, so that live `evt.*.report` events for services missing + * from the periodic state poll can still be routed to the correct state topic. + */ +export function registerServiceStateTopic(addr: string, stateTopic: string) { + addrToStateTopic[addr] = stateTopic; +} + const attributeTypeKeyMap: Record = { alarm: 'event', meter: 'props.unit', @@ -271,12 +301,17 @@ export function haUpdateState(parameters: { }) { const stateTopic = `homeassistant/device/futurehome_${parameters.hubId}_${parameters.deviceState.id?.toString()}/state`; - const haState: Record> = {}; + // Start from the previously cached payload so that values learned from live + // `evt.*.report` events (e.g. a scene controller's last reported scene) are + // preserved across polls that do not include that service. The poll only + // ever refreshes/adds attributes, it never drops previously known ones. + const previous = haStateCache[stateTopic] ?? {}; + const haState: Record> = { ...previous }; for (const service of parameters.deviceState.services || []) { if (!service.addr) continue; - const serviceState: Record = {}; + const serviceState: Record = { ...(previous[service.addr] ?? {}) }; for (const attr of service.attributes || []) { const processedValue = processAttributeValues( @@ -289,6 +324,7 @@ export function haUpdateState(parameters: { } haState[service.addr] = serviceState; + registerServiceStateTopic(service.addr, stateTopic); } log.debug(`Publishing HA state "${stateTopic}"`); @@ -328,6 +364,20 @@ export function haUpdateStateValueReport(parameters: { const addr = parameters.topic.replace(/^pt:j1\/mt:evt/, ''); const typeKey = getTypeKey(parameters.attrName); + // Some services (most notably momentary scene controllers such as the + // Futurehome Modeswitch / Modusbryter) never appear in the periodic state + // poll, so their address has no entry in any cached payload yet. Seed one + // from the address→topic mapping registered from the device config, so the + // live event below is applied instead of being silently dropped. + if ( + !Object.values(haStateCache).some((payload) => payload[addr]) && + addrToStateTopic[addr] + ) { + const stateTopic = addrToStateTopic[addr]; + haStateCache[stateTopic] ??= {}; + haStateCache[stateTopic][addr] ??= {}; + } + for (const [stateTopic, payload] of Object.entries(haStateCache)) { if (!payload[addr]) continue;