From ae10c2fb5ff7c8bb916b3afcd2fe70e78a50d57a Mon Sep 17 00:00:00 2001 From: Adrian Jagielak Date: Fri, 25 Jul 2025 01:03:19 +0200 Subject: [PATCH] Add support for 'water_heater' service --- futurehome/CHANGELOG.md | 1 + futurehome/README.md | 2 +- futurehome/src/ha/publish_device.ts | 2 + futurehome/src/ha/update_state.ts | 185 ++++++++++++++++++++- futurehome/src/index.ts | 32 ++-- futurehome/src/services/water_heater.ts | 209 ++++++++++++++++++++++++ 6 files changed, 402 insertions(+), 29 deletions(-) create mode 100644 futurehome/src/services/water_heater.ts diff --git a/futurehome/CHANGELOG.md b/futurehome/CHANGELOG.md index fdd21b4..75c63b8 100644 --- a/futurehome/CHANGELOG.md +++ b/futurehome/CHANGELOG.md @@ -8,6 +8,7 @@ - Changed the default 'sensor_lumin' unit from 'Lux' to 'lx'. - Added support for 'indicator_ctrl' service (identify devices). - Added support for 'barrier_ctrl' service (devices like garage doors, barriers, and window shades). +- Added support for 'water_heater' service (devices such as water boiler or a water tank). # 0.1.0 (24.07.2025) diff --git a/futurehome/README.md b/futurehome/README.md index a92d623..41d9179 100644 --- a/futurehome/README.md +++ b/futurehome/README.md @@ -120,7 +120,7 @@ todo: links to the .ts service implementations below | siren_ctrl | | | | thermostat | [Thermostat](https://www.futurehome.io/en_no/shop/thermostat-w) | ✅ | | user_code | | | -| water_heater | | | +| water_heater | | ✅ | ## Services that are deprecated, unused, or removed in newer versions of the system. diff --git a/futurehome/src/ha/publish_device.ts b/futurehome/src/ha/publish_device.ts index 90f81dc..3a2adfa 100644 --- a/futurehome/src/ha/publish_device.ts +++ b/futurehome/src/ha/publish_device.ts @@ -54,6 +54,7 @@ import { sensor_watpressure__components } from '../services/sensor_watpressure'; import { sensor_wattemp__components } from '../services/sensor_wattemp'; import { sensor_weight__components } from '../services/sensor_weight'; import { thermostat__components } from '../services/thermostat'; +import { water_heater__components } from '../services/water_heater'; import { abbreviateHaMqttKeys } from './abbreviate_ha_mqtt_keys'; import { ha } from './globals'; import { HaMqttComponent } from './mqtt_components/_component'; @@ -209,6 +210,7 @@ const serviceHandlers: { sensor_wattemp: sensor_wattemp__components, sensor_weight: sensor_weight__components, thermostat: thermostat__components, + water_heater: water_heater__components, }; export function haPublishDevice(parameters: { diff --git a/futurehome/src/ha/update_state.ts b/futurehome/src/ha/update_state.ts index 28abcd7..187cfe6 100644 --- a/futurehome/src/ha/update_state.ts +++ b/futurehome/src/ha/update_state.ts @@ -14,6 +14,11 @@ import { ha } from './globals'; { "name": "presence", "values": [ + { + "ts": "2025-07-22 16:21:31 +0200", + "val": true, + "val_t": "bool" + }, { "ts": "2025-07-22 16:21:30 +0200", "val": false, @@ -52,18 +57,85 @@ import { ha } from './globals'; } ], "name": "battery" + }, + { + "addr": "/rt:dev/rn:hoiax/ad:1/sv:water_heater/ad:2", + "attributes": [ + { + "name": "state", + "values": [ + { + "ts": "2023-04-03 13:37:22 +0200", + "val": "idle", + "val_t": "string" + } + ] + }, + { + "name": "setpoint", + "values": [ + { + "ts": "2023-03-27 14:19:52 +0200", + "val": { + "temp": 49, + "type": "vacation", + "unit": "C" + }, + "val_t": "object" + }, + { + "ts": "2023-03-27 14:19:52 +0200", + "val": { + "temp": 60, + "type": "normal", + "unit": "C" + }, + "val_t": "object" + }, + { + "ts": "2023-12-21 09:44:28 +0100", + "val": { + "temp": 85.0, + "type": "boost", + "unit": "C" + }, + "val_t": "object" + }, + { + "ts": "2023-03-27 14:19:52 +0200", + "val": { + "temp": 60, + "type": "external", + "unit": "C" + }, + "val_t": "object" + } + ] + }, + { + "name": "mode", + "values": [ + { + "ts": "2023-04-05 16:08:43 +0200", + "val": "off", + "val_t": "string" + } + ] + } + ], + "name": "water_heater" } ] } ``` -Output (assuming hub ID 123456): +Saved state (assuming hub ID 123456): ``` topic: homeassistant/device/futurehome_123456_1/state { "/rt:dev/rn:zigbee/ad:1/sv:sensor_presence/ad:1_1": { - "presence": false + "presence": true }, "/rt:dev/rn:zigbee/ad:1/sv:battery/ad:1_1": { "lvl": 1, @@ -71,6 +143,28 @@ topic: homeassistant/device/futurehome_123456_1/state "event": "low_battery", "status": "deactiv" } + }, + "/rt:dev/rn:hoiax/ad:1/sv:water_heater/ad:2": { + "state": "idle", + "setpoint": { + "vacation": { + "temp": 49, + "unit": "C" + }, + "normal": { + "temp": 60, + "unit": "C" + }, + "boost": { + "temp": 85.0, + "unit": "C" + }, + "external": { + "temp": 60, + "unit": "C" + } + }, + "mode": "off" } } ``` @@ -81,6 +175,48 @@ const haStateCache: Record< Record> // payload (addr → { attr → value }) > = {}; +/** + * Helper function to process multiple values for an attribute, handling typed values + */ +function processAttributeValues(values: any[]): any { + if (!values || values.length === 0) { + return undefined; + } + + // Sort by timestamp to get the latest values first + const sortedValues = [...values].sort((a, b) => { + const tsA = new Date(a.ts).getTime(); + const tsB = new Date(b.ts).getTime(); + return tsB - tsA; // Latest first + }); + + // Check if any value has a 'type' property in its val object + const hasTypedValues = sortedValues.some( + (v) => v.val && typeof v.val === 'object' && v.val.type, + ); + + if (!hasTypedValues) { + // No typed values, return the latest value + return sortedValues[0].val; + } + + // Group by type, keeping only the latest value for each type + const typeMap: Record = {}; + + for (const value of sortedValues) { + if (value.val && typeof value.val === 'object' && value.val.type) { + const type = value.val.type; + if (!typeMap[type]) { + // Create a copy without the 'type' property + const { type: _, ...valueWithoutType } = value.val; + typeMap[type] = valueWithoutType; + } + } + } + + return typeMap; +} + /** * Publishes the full state of a Futurehome device to Home Assistant and * stores a copy in the private cache above. @@ -102,8 +238,10 @@ export function haUpdateState(parameters: { const serviceState: Record = {}; for (const attr of service.attributes || []) { - const value = attr.values?.[0]?.val; - serviceState[attr.name] = value; + const processedValue = processAttributeValues(attr.values || []); + if (processedValue !== undefined) { + serviceState[attr.name] = processedValue; + } } haState[service.addr] = serviceState; @@ -123,13 +261,13 @@ export function haUpdateState(parameters: { * * @param topic Full FIMP event topic, e.g. * "pt:j1/mt:evt/rt:dev/rn:zigbee/ad:1/sv:sensor_temp/ad:3_1" - * @param value The new sensor reading (number, boolean, string, …) + * @param value The new sensor reading (number, boolean, string, object with type, …) * @param attrName Attribute name to store the reading to * * The prefix "pt:j1/mt:evt" is removed before matching so that the remainder * exactly matches the address keys stored in the cached HA payloads. */ -export function haUpdateStateSensorReport(parameters: { +export function haUpdateStateValueReport(parameters: { topic: string; value: any; attrName: string; @@ -140,8 +278,39 @@ export function haUpdateStateSensorReport(parameters: { for (const [stateTopic, payload] of Object.entries(haStateCache)) { if (!payload[sensorAddr]) continue; - // Update the reading in‑place - payload[sensorAddr][parameters.attrName] = parameters.value; + // Check if the new value has a type property + if ( + parameters.value && + typeof parameters.value === 'object' && + parameters.value.type + ) { + // Handle typed value update + const type = parameters.value.type; + const { type: _, ...valueWithoutType } = parameters.value; + + // Get current attribute value + const currentAttrValue = payload[sensorAddr][parameters.attrName]; + + if ( + currentAttrValue && + typeof currentAttrValue === 'object' && + !Array.isArray(currentAttrValue) + ) { + // Current value is already a type map, update the specific type + payload[sensorAddr][parameters.attrName] = { + ...currentAttrValue, + [type]: valueWithoutType, + }; + } else { + // Current value is not a type map, convert it to one + payload[sensorAddr][parameters.attrName] = { + [type]: valueWithoutType, + }; + } + } else { + // Handle regular value update (non-typed) + payload[sensorAddr][parameters.attrName] = parameters.value; + } log.debug( `Publishing updated sensor value for "${sensorAddr}" to "${stateTopic}"`, diff --git a/futurehome/src/index.ts b/futurehome/src/index.ts index 10a0375..8a2a6a2 100644 --- a/futurehome/src/index.ts +++ b/futurehome/src/index.ts @@ -3,7 +3,7 @@ import { log } from './logger'; import { FimpResponse, sendFimpMsg, setFimp } from './fimp/fimp'; import { haCommandHandlers, setHa, setHaCommandHandlers } from './ha/globals'; import { CommandHandlers, haPublishDevice } from './ha/publish_device'; -import { haUpdateState, haUpdateStateSensorReport } from './ha/update_state'; +import { haUpdateState, haUpdateStateValueReport } from './ha/update_state'; import { VinculumPd7Device } from './fimp/vinculum_pd7_device'; import { haUpdateAvailability } from './ha/update_availability'; import { delay } from './utils'; @@ -229,25 +229,6 @@ import { delay } from './utils'; break; } - case 'evt.alarm.report': - case 'evt.binary.report': - case 'evt.color.report': - case 'evt.lvl.report': - case 'evt.mode.report': - case 'evt.open.report': - case 'evt.presence.report': - case 'evt.scene.report': - case 'evt.sensor.report': - case 'evt.setpoint.report': - case 'evt.state.report': { - haUpdateStateSensorReport({ - topic, - value: msg.val, - attrName: msg.type.split('.')[1], - }); - break; - } - case 'evt.network.all_nodes_report': { const devicesAvailability = msg.val; if (!devicesAvailability) { @@ -259,6 +240,17 @@ import { delay } from './utils'; } break; } + + default: { + // Handle any event that matches the pattern: evt..report + if (/^evt\..+\.report$/.test(msg.type ?? '')) { + haUpdateStateValueReport({ + topic, + value: msg.val, + attrName: msg.type!.split('.')[1], + }); + } + } } } catch (e) { log.warn('Bad FIMP JSON', e, topic, buf); diff --git a/futurehome/src/services/water_heater.ts b/futurehome/src/services/water_heater.ts new file mode 100644 index 0000000..c83cc82 --- /dev/null +++ b/futurehome/src/services/water_heater.ts @@ -0,0 +1,209 @@ +import { sendFimpMsg } from '../fimp/fimp'; +import { + VinculumPd7Device, + VinculumPd7Service, +} from '../fimp/vinculum_pd7_device'; +import { HaMqttComponent } from '../ha/mqtt_components/_component'; +import { SelectComponent } from '../ha/mqtt_components/select'; +import { NumberComponent } from '../ha/mqtt_components/number'; +import { SensorComponent } from '../ha/mqtt_components/sensor'; +import { + CommandHandlers, + ServiceComponentsCreationResult, +} from '../ha/publish_device'; +import { SwitchComponent } from '../ha/mqtt_components/switch'; + +export function water_heater__components( + topicPrefix: string, + device: VinculumPd7Device, + svc: VinculumPd7Service, +): ServiceComponentsCreationResult | undefined { + const components: Record = {}; + const commandHandlers: CommandHandlers = {}; + + // Extract supported modes, setpoints, and states from service properties + const supportedModes = svc.props?.sup_modes || [ + 'off', + 'normal', + 'boost', + 'eco', + ]; + const supportedSetpoints = svc.props?.sup_setpoints || []; + const supportedStates = svc.props?.sup_states || ['idle', 'heat']; + const supRange = svc.props?.sup_range; + const supRanges = svc.props?.sup_ranges; + const supStep = svc.props?.sup_step || 1.0; + + // Determine temperature range and unit + let minTemp = 0; // Default minimum + let maxTemp = 100; // Default maximum + const temperatureUnit = 'C'; // Default unit + + if (supRange) { + minTemp = supRange.min ?? minTemp; + maxTemp = supRange.max ?? maxTemp; + } + + // 1. Mode Control (Select Component) + if (supportedModes.length > 0) { + const modeCommandTopic = `${topicPrefix}${svc.addr}/mode_command`; + + const selectComponent: SelectComponent = { + unique_id: `${svc.addr}_mode`, + platform: 'select', + name: 'mode', + options: supportedModes, + command_topic: modeCommandTopic, + optimistic: false, + value_template: `{{ value_json['${svc.addr}'].mode }}`, + }; + + components[`${svc.addr}_mode`] = selectComponent; + + commandHandlers[modeCommandTopic] = async (payload: string) => { + if (!supportedModes.includes(payload)) { + return; + } + + await sendFimpMsg({ + address: svc.addr, + service: 'water_heater', + cmd: 'cmd.mode.set', + val_t: 'string', + val: payload, + }); + }; + } + + // 2. Setpoint Controls (Number Components) + if (supportedSetpoints.length > 0) { + for (const setpointType of supportedSetpoints) { + const setpointCommandTopic = `${topicPrefix}${svc.addr}/setpoint_${setpointType}_command`; + + // Determine range for this specific setpoint + let setpointMinTemp = minTemp; + let setpointMaxTemp = maxTemp; + + if (supRanges && supRanges[setpointType]) { + setpointMinTemp = supRanges[setpointType].min ?? minTemp; + setpointMaxTemp = supRanges[setpointType].max ?? maxTemp; + } + + const numberComponent: NumberComponent = { + unique_id: `${svc.addr}_setpoint_${setpointType}`, + platform: 'number', + entity_category: 'config', + name: `${setpointType} setpoint`, + min: setpointMinTemp, + max: setpointMaxTemp, + step: supStep, + unit_of_measurement: temperatureUnit === 'C' ? '°C' : '°F', + command_topic: setpointCommandTopic, + optimistic: false, + value_template: `{{ value_json['${svc.addr}'].setpoint.${setpointType}.temp if value_json['${svc.addr}'].setpoint.${setpointType} else 0 }}`, + }; + + components[`${svc.addr}_setpoint_${setpointType}`] = numberComponent; + + commandHandlers[setpointCommandTopic] = async (payload: string) => { + const temperature = parseFloat(payload); + if ( + Number.isNaN(temperature) || + temperature < setpointMinTemp || + temperature > setpointMaxTemp + ) { + return; + } + + await sendFimpMsg({ + address: svc.addr, + service: 'water_heater', + cmd: 'cmd.setpoint.set', + val_t: 'object', + val: { + type: setpointType, + temp: temperature, + unit: temperatureUnit, + }, + }); + }; + } + } + + // 3. Operational State Sensor + if (svc.intf?.includes('evt.state.report') && supportedStates.length > 0) { + const sensorComponent: SensorComponent = { + unique_id: `${svc.addr}_state`, + platform: 'sensor', + entity_category: 'diagnostic', + name: 'operational state', + value_template: `{{ value_json['${svc.addr}'].state }}`, + }; + + components[`${svc.addr}_state`] = sensorComponent; + } + + // 4. Current Setpoint Temperature Sensor (shows active setpoint value) + // This provides a way to see what temperature the water heater is currently targeting + const currentSetpointSensorComponent: SensorComponent = { + unique_id: `${svc.addr}_current_setpoint`, + platform: 'sensor', + entity_category: 'diagnostic', + name: 'current setpoint', + unit_of_measurement: temperatureUnit === 'C' ? '°C' : '°F', + device_class: 'temperature', + // Template to extract current setpoint based on active mode + value_template: `{% set mode = value_json['${svc.addr}'].mode %}{% set setpoints = value_json['${svc.addr}'].setpoint %}{% if setpoints and setpoints[mode] %}{{ setpoints[mode].temp }}{% else %}unknown{% endif %}`, + }; + + components[`${svc.addr}_current_setpoint`] = currentSetpointSensorComponent; + + // 5. Power Control Switch (maps to mode on/off) + const powerCommandTopic = `${topicPrefix}${svc.addr}/power_command`; + + const switchComponent: SwitchComponent = { + unique_id: `${svc.addr}_power`, + platform: 'switch', + name: 'power', + command_topic: powerCommandTopic, + optimistic: false, + value_template: `{{ 'ON' if value_json['${svc.addr}'].mode != 'off' else 'OFF' }}`, + payload_on: 'ON', + payload_off: 'OFF', + }; + + components[`${svc.addr}_power`] = switchComponent; + + commandHandlers[powerCommandTopic] = async (payload: string) => { + let targetMode: string; + + switch (payload) { + case 'ON': + // Turn on to normal mode if available, otherwise first non-off mode + targetMode = supportedModes.includes('normal') + ? 'normal' + : supportedModes.find((mode: any) => mode !== 'off') || 'normal'; + break; + case 'OFF': + targetMode = 'off'; + break; + default: + return; + } + + if (supportedModes.includes(targetMode)) { + await sendFimpMsg({ + address: svc.addr, + service: 'water_heater', + cmd: 'cmd.mode.set', + val_t: 'string', + val: targetMode, + }); + } + }; + + return { + components, + commandHandlers, + }; +}