Add support for 'water_heater' service

This commit is contained in:
Adrian Jagielak 2025-07-25 01:03:19 +02:00
parent 58e4ea228c
commit ae10c2fb5f
No known key found for this signature in database
GPG Key ID: 0818CF7AF6C62BFB
6 changed files with 402 additions and 29 deletions

View File

@ -8,6 +8,7 @@
- Changed the default 'sensor_lumin' unit from 'Lux' to 'lx'. - Changed the default 'sensor_lumin' unit from 'Lux' to 'lx'.
- Added support for 'indicator_ctrl' service (identify devices). - 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 '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) # 0.1.0 (24.07.2025)

View File

@ -120,7 +120,7 @@ todo: links to the .ts service implementations below
| siren_ctrl | | | | siren_ctrl | | |
| thermostat | [Thermostat](https://www.futurehome.io/en_no/shop/thermostat-w) | ✅ | | thermostat | [Thermostat](https://www.futurehome.io/en_no/shop/thermostat-w) | ✅ |
| user_code | | | | user_code | | |
| water_heater | | | | water_heater | | |
## Services that are deprecated, unused, or removed in newer versions of the system. ## Services that are deprecated, unused, or removed in newer versions of the system.

View File

@ -54,6 +54,7 @@ import { sensor_watpressure__components } from '../services/sensor_watpressure';
import { sensor_wattemp__components } from '../services/sensor_wattemp'; import { sensor_wattemp__components } from '../services/sensor_wattemp';
import { sensor_weight__components } from '../services/sensor_weight'; import { sensor_weight__components } from '../services/sensor_weight';
import { thermostat__components } from '../services/thermostat'; import { thermostat__components } from '../services/thermostat';
import { water_heater__components } from '../services/water_heater';
import { abbreviateHaMqttKeys } from './abbreviate_ha_mqtt_keys'; import { abbreviateHaMqttKeys } from './abbreviate_ha_mqtt_keys';
import { ha } from './globals'; import { ha } from './globals';
import { HaMqttComponent } from './mqtt_components/_component'; import { HaMqttComponent } from './mqtt_components/_component';
@ -209,6 +210,7 @@ const serviceHandlers: {
sensor_wattemp: sensor_wattemp__components, sensor_wattemp: sensor_wattemp__components,
sensor_weight: sensor_weight__components, sensor_weight: sensor_weight__components,
thermostat: thermostat__components, thermostat: thermostat__components,
water_heater: water_heater__components,
}; };
export function haPublishDevice(parameters: { export function haPublishDevice(parameters: {

View File

@ -14,6 +14,11 @@ import { ha } from './globals';
{ {
"name": "presence", "name": "presence",
"values": [ "values": [
{
"ts": "2025-07-22 16:21:31 +0200",
"val": true,
"val_t": "bool"
},
{ {
"ts": "2025-07-22 16:21:30 +0200", "ts": "2025-07-22 16:21:30 +0200",
"val": false, "val": false,
@ -52,18 +57,85 @@ import { ha } from './globals';
} }
], ],
"name": "battery" "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 topic: homeassistant/device/futurehome_123456_1/state
{ {
"/rt:dev/rn:zigbee/ad:1/sv:sensor_presence/ad:1_1": { "/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": { "/rt:dev/rn:zigbee/ad:1/sv:battery/ad:1_1": {
"lvl": 1, "lvl": 1,
@ -71,6 +143,28 @@ topic: homeassistant/device/futurehome_123456_1/state
"event": "low_battery", "event": "low_battery",
"status": "deactiv" "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<string, Record<string, any>> // payload (addr → { attr → value }) Record<string, Record<string, any>> // 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<string, any> = {};
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 * Publishes the full state of a Futurehome device to Home Assistant and
* stores a copy in the private cache above. * stores a copy in the private cache above.
@ -102,8 +238,10 @@ export function haUpdateState(parameters: {
const serviceState: Record<string, any> = {}; const serviceState: Record<string, any> = {};
for (const attr of service.attributes || []) { for (const attr of service.attributes || []) {
const value = attr.values?.[0]?.val; const processedValue = processAttributeValues(attr.values || []);
serviceState[attr.name] = value; if (processedValue !== undefined) {
serviceState[attr.name] = processedValue;
}
} }
haState[service.addr] = serviceState; haState[service.addr] = serviceState;
@ -123,13 +261,13 @@ export function haUpdateState(parameters: {
* *
* @param topic Full FIMP event topic, e.g. * @param topic Full FIMP event topic, e.g.
* "pt:j1/mt:evt/rt:dev/rn:zigbee/ad:1/sv:sensor_temp/ad:3_1" * "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 * @param attrName Attribute name to store the reading to
* *
* The prefix "pt:j1/mt:evt" is removed before matching so that the remainder * 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. * exactly matches the address keys stored in the cached HA payloads.
*/ */
export function haUpdateStateSensorReport(parameters: { export function haUpdateStateValueReport(parameters: {
topic: string; topic: string;
value: any; value: any;
attrName: string; attrName: string;
@ -140,8 +278,39 @@ export function haUpdateStateSensorReport(parameters: {
for (const [stateTopic, payload] of Object.entries(haStateCache)) { for (const [stateTopic, payload] of Object.entries(haStateCache)) {
if (!payload[sensorAddr]) continue; if (!payload[sensorAddr]) continue;
// Update the reading inplace // 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; payload[sensorAddr][parameters.attrName] = parameters.value;
}
log.debug( log.debug(
`Publishing updated sensor value for "${sensorAddr}" to "${stateTopic}"`, `Publishing updated sensor value for "${sensorAddr}" to "${stateTopic}"`,

View File

@ -3,7 +3,7 @@ import { log } from './logger';
import { FimpResponse, sendFimpMsg, setFimp } from './fimp/fimp'; import { FimpResponse, sendFimpMsg, setFimp } from './fimp/fimp';
import { haCommandHandlers, setHa, setHaCommandHandlers } from './ha/globals'; import { haCommandHandlers, setHa, setHaCommandHandlers } from './ha/globals';
import { CommandHandlers, haPublishDevice } from './ha/publish_device'; 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 { VinculumPd7Device } from './fimp/vinculum_pd7_device';
import { haUpdateAvailability } from './ha/update_availability'; import { haUpdateAvailability } from './ha/update_availability';
import { delay } from './utils'; import { delay } from './utils';
@ -229,25 +229,6 @@ import { delay } from './utils';
break; 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': { case 'evt.network.all_nodes_report': {
const devicesAvailability = msg.val; const devicesAvailability = msg.val;
if (!devicesAvailability) { if (!devicesAvailability) {
@ -259,6 +240,17 @@ import { delay } from './utils';
} }
break; break;
} }
default: {
// Handle any event that matches the pattern: evt.<something>.report
if (/^evt\..+\.report$/.test(msg.type ?? '')) {
haUpdateStateValueReport({
topic,
value: msg.val,
attrName: msg.type!.split('.')[1],
});
}
}
} }
} catch (e) { } catch (e) {
log.warn('Bad FIMP JSON', e, topic, buf); log.warn('Bad FIMP JSON', e, topic, buf);

View File

@ -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<string, HaMqttComponent> = {};
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,
};
}