From 51693d05c31f881993fed29ba814b796cc5e7db2 Mon Sep 17 00:00:00 2001 From: Adrian Jagielak Date: Fri, 25 Jul 2025 01:53:16 +0200 Subject: [PATCH] Update 'water_heater' service implementation --- futurehome/src/services/thermostat.ts | 2 + futurehome/src/services/water_heater.ts | 303 ++++++++++++------------ 2 files changed, 149 insertions(+), 156 deletions(-) diff --git a/futurehome/src/services/thermostat.ts b/futurehome/src/services/thermostat.ts index e9eb140..1f29101 100644 --- a/futurehome/src/services/thermostat.ts +++ b/futurehome/src/services/thermostat.ts @@ -131,10 +131,12 @@ export function thermostat__components( // Add current temperature from paired sensor_temp service const sensorTempAddr = replaceSvcInAddr(svc.addr, 'sensor_temp'); + climateComponent.current_temperature_topic = stateTopic; climateComponent.current_temperature_template = `{{ value_json['${sensorTempAddr}'].sensor | default(0) }}`; // Add action/state reporting if supported if (supStates.length > 0) { + climateComponent.action_topic = stateTopic; climateComponent.action_template = `{% set state = value_json['${svc.addr}'].state | default('idle') %}{{ {'idle': 'idle', 'heat': 'heating', 'cool': 'cooling', 'fan': 'fan'}.get(state, 'off') }}`; } diff --git a/futurehome/src/services/water_heater.ts b/futurehome/src/services/water_heater.ts index c83cc82..6ed9cad 100644 --- a/futurehome/src/services/water_heater.ts +++ b/futurehome/src/services/water_heater.ts @@ -1,17 +1,15 @@ import { sendFimpMsg } from '../fimp/fimp'; +import { replaceSvcInAddr } from '../fimp/helpers'; 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'; +import { haGetCachedState } from '../ha/update_state'; export function water_heater__components( topicPrefix: string, @@ -21,186 +19,179 @@ export function water_heater__components( 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']; + // Extract supported properties + const supModes = svc.props?.sup_modes || []; + const supSetpoints = svc.props?.sup_setpoints || []; + const supStates = svc.props?.sup_states || []; 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 + // Determine temperature range 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; + minTemp = supRange.min; + maxTemp = supRange.max; + } else if (supRanges) { + // Find the broadest range from all setpoint ranges + for (const setpoint of supSetpoints) { + const range = supRanges[setpoint]; + if (range) { + if (range.min < minTemp) minTemp = range.min; + if (range.max > maxTemp) maxTemp = range.max; } - - 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 }}`, + // Main water heater component + const stateTopic = `${topicPrefix}/state`; + const modeCommandTopic = `${topicPrefix}${svc.addr}/mode/command`; + const tempCommandTopic = `${topicPrefix}${svc.addr}/temperature/command`; + + // Map FIMP modes to appropriate Home Assistant water heater modes + const mapFimpToHaMode = (fimpMode: string): string => { + const modeMap: Record = { + off: 'off', + normal: 'heat_pump', + boost: 'high_demand', + eco: 'eco', }; - - 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 %}`, + return modeMap[fimpMode] || fimpMode; }; - components[`${svc.addr}_current_setpoint`] = currentSetpointSensorComponent; + // Map Home Assistant modes back to FIMP modes + const mapHaToFimpMode = (haMode: string): string => { + const reverseMap: Record = { + off: 'off', + heat_pump: 'normal', + high_demand: 'boost', + eco: 'eco', + }; - // 5. Power Control Switch (maps to mode on/off) - const powerCommandTopic = `${topicPrefix}${svc.addr}/power_command`; + // If we have the exact mode in supported modes, use it + if (supModes.includes(haMode)) { + return haMode; + } - const switchComponent: SwitchComponent = { - unique_id: `${svc.addr}_power`, - platform: 'switch', - name: 'power', - command_topic: powerCommandTopic, + // Otherwise try to find a suitable FIMP mode + const fimpMode = reverseMap[haMode]; + if (fimpMode && supModes.includes(fimpMode)) { + return fimpMode; + } + + return haMode; // Fallback to original + }; + + // Get supported HA modes + const haModes = supModes + .map(mapFimpToHaMode) + .filter( + (mode: string, index: number, arr: string[]) => + arr.indexOf(mode) === index, + ); + + // Water heater component configuration + const waterHeaterComponent: HaMqttComponent = { + unique_id: svc.addr, + platform: 'water_heater', + modes: haModes.length > 0 ? haModes : ['off', 'heat_pump'], + mode_command_topic: modeCommandTopic, + mode_state_topic: stateTopic, + mode_state_template: `{{ value_json['${svc.addr}'].mode | default('off') }}`, optimistic: false, - value_template: `{{ 'ON' if value_json['${svc.addr}'].mode != 'off' else 'OFF' }}`, - payload_on: 'ON', - payload_off: 'OFF', + min_temp: minTemp, + max_temp: maxTemp, + temperature_unit: 'C', + precision: supStep >= 1 ? 1 : 0.1, }; - components[`${svc.addr}_power`] = switchComponent; + // Add current temperature from paired sensor_wattemp service + const sensorWattempAddr = replaceSvcInAddr(svc.addr, 'sensor_wattemp'); + waterHeaterComponent.current_temperature_topic = stateTopic; + waterHeaterComponent.current_temperature_template = `{{ value_json['${sensorWattempAddr}'].sensor | default(0) }}`; - commandHandlers[powerCommandTopic] = async (payload: string) => { - let targetMode: string; + // Add operation state if supported + if (supStates.length > 0) { + // Map FIMP states to Home Assistant water heater states + waterHeaterComponent.value_template = `{% set state = value_json['${svc.addr}'].state | default('idle') %}{{ {'idle': 'idle', 'heat': 'heating'}.get(state, 'idle') }}`; + } - 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; + // Handle temperature control based on available setpoints + if (supSetpoints.length > 0) { + waterHeaterComponent.temperature_command_topic = tempCommandTopic; + waterHeaterComponent.temperature_state_topic = stateTopic; + + // Create template to get current temperature setpoint based on mode + // Priority: current mode setpoint > normal setpoint > first available setpoint + const setpointTemplate = `{% set mode = value_json['${svc.addr}'].mode | default('normal') %}{% set setpoint = value_json['${svc.addr}'].setpoint %}{% if setpoint[mode] %}{{ setpoint[mode].temp | default(${minTemp}) }}{% elif setpoint['normal'] %}{{ setpoint['normal'].temp | default(${minTemp}) }}{% elif setpoint %}{{ setpoint['${supSetpoints[0]}'].temp | default(${minTemp}) }}{% else %}{{ ${minTemp} }}{% endif %}`; + + waterHeaterComponent.temperature_state_template = setpointTemplate; + } + + components[svc.addr] = waterHeaterComponent; + + // Command handlers + + // Mode command handler + commandHandlers[modeCommandTopic] = async (payload: string) => { + const fimpMode = mapHaToFimpMode(payload); + + if (!supModes.includes(fimpMode)) { + return; } - if (supportedModes.includes(targetMode)) { + await sendFimpMsg({ + address: svc.addr, + service: 'water_heater', + cmd: 'cmd.mode.set', + val_t: 'string', + val: fimpMode, + }); + }; + + // Temperature command handler + if (waterHeaterComponent.temperature_command_topic) { + commandHandlers[tempCommandTopic] = async (payload: string) => { + const temp = parseFloat(payload); + if (Number.isNaN(temp)) { + return; + } + + // Validate temperature is within supported range + if (temp < minTemp || temp > maxTemp) { + return; + } + + // Get current mode to determine which setpoint to set + const currentState = haGetCachedState({ + topic: `${topicPrefix}/state`, + })?.[svc.addr]; + const currentMode = currentState?.mode || 'normal'; + + // Use the current mode as setpoint type if it's in sup_setpoints + // Otherwise use 'normal' as default, or first available setpoint + let setpointType = 'normal'; + if (supSetpoints.includes(currentMode)) { + setpointType = currentMode; + } else if (!supSetpoints.includes('normal') && supSetpoints.length > 0) { + setpointType = supSetpoints[0]; + } + await sendFimpMsg({ address: svc.addr, service: 'water_heater', - cmd: 'cmd.mode.set', - val_t: 'string', - val: targetMode, + cmd: 'cmd.setpoint.set', + val_t: 'object', + val: { + type: setpointType, + temp: temp, + unit: 'C', + }, }); - } - }; + }; + } return { components,