diff --git a/futurehome/CHANGELOG.md b/futurehome/CHANGELOG.md index 272c04f..1f9d8f2 100644 --- a/futurehome/CHANGELOG.md +++ b/futurehome/CHANGELOG.md @@ -6,6 +6,7 @@ - Added support for 'water_heater' service (devices such as water boiler or a water tank). - Updated demo mode data. - Added extracting device manufacturer name. +- Updated 'thermostat' service implementation. ## 0.1.1 (24.07.2025) diff --git a/futurehome/src/fimp/helpers.ts b/futurehome/src/fimp/helpers.ts index b7f4431..ac2da5a 100644 --- a/futurehome/src/fimp/helpers.ts +++ b/futurehome/src/fimp/helpers.ts @@ -36,3 +36,7 @@ export function adapterServiceFromServiceAddress( return adapterName; } + +export function replaceSvcInAddr(addr: string, newService: string): string { + return addr.replace(/\/sv:[^/]+/, `/sv:${newService}`); +} diff --git a/futurehome/src/services/thermostat.ts b/futurehome/src/services/thermostat.ts index 8f62bdf..e9eb140 100644 --- a/futurehome/src/services/thermostat.ts +++ b/futurehome/src/services/thermostat.ts @@ -1,19 +1,10 @@ -// Maps a Futurehome “thermostat” service to one MQTT *climate* entity. -// ───────────────────────────────────────────────────────────────────────── -// FIMP ➞ HA state path used by the templates -// value_json[svc.addr].mode – current HVAC mode -// value_json[svc.addr].setpoint.temp – set-point temperature (string) -// -// HA ➞ FIMP commands -// mode_command_topic → cmd.mode.set -// temperature_command_topic → cmd.setpoint.set -// ───────────────────────────────────────────────────────────────────────── - 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 { ClimateComponent } from '../ha/mqtt_components/climate'; import { CommandHandlers, @@ -23,97 +14,256 @@ import { haGetCachedState } from '../ha/update_state'; export function thermostat__components( topicPrefix: string, - _device: VinculumPd7Device, + device: VinculumPd7Device, svc: VinculumPd7Service, ): ServiceComponentsCreationResult | undefined { - const supModes: string[] = svc.props?.sup_modes ?? []; - const supSetpoints: string[] = svc.props?.sup_setpoints ?? []; + const components: Record = {}; + const commandHandlers: CommandHandlers = {}; - if (!supModes.length) return undefined; // nothing useful to expose + // Extract supported properties + const supModes = svc.props?.sup_modes || []; + const supSetpoints = svc.props?.sup_setpoints || []; + const supStates = svc.props?.sup_states || []; + const supTemperatures = svc.props?.sup_temperatures || {}; + const supStep = svc.props?.sup_step || 0.5; - const defaultSpType = supSetpoints[0] ?? 'heat'; - - const ranges: Record = - svc.props?.sup_temperatures ?? {}; - const step: number = svc.props?.sup_step ?? 0.5; - - // Determine overall min/max temp from all advertised ranges - let minTemp = 1000; - let maxTemp = -1000; - for (const sp of supSetpoints) { - minTemp = Math.min(minTemp, ranges[sp]?.min ?? minTemp); - maxTemp = Math.max(maxTemp, ranges[sp]?.max ?? maxTemp); - } - if (minTemp === 1000) minTemp = 7; - if (maxTemp === -1000) maxTemp = 35; - - // Shared JSON blob + // Main climate component const stateTopic = `${topicPrefix}/state`; + const modeCommandTopic = `${topicPrefix}${svc.addr}/mode/command`; + const tempCommandTopic = `${topicPrefix}${svc.addr}/temperature/command`; + const tempHighCommandTopic = `${topicPrefix}${svc.addr}/temperature_high/command`; + const tempLowCommandTopic = `${topicPrefix}${svc.addr}/temperature_low/command`; - // ───────────── command topics ───────────── - const modeCmdTopic = `${topicPrefix}${svc.addr}/mode/command`; - const tempCmdTopic = `${topicPrefix}${svc.addr}/temperature/command`; - - // ───────────── MQTT climate component ───────────── - const climate: ClimateComponent = { - unique_id: svc.addr, - platform: 'climate', - - // HVAC modes - modes: supModes, - mode_command_topic: modeCmdTopic, - // Even though state topic is often optional as it's already defined by the device object this component is in, the 'climate' expects it - mode_state_topic: stateTopic, - mode_state_template: `{{ value_json['${svc.addr}'].mode }}`, - - // Temperature - temperature_command_topic: tempCmdTopic, - temperature_state_topic: stateTopic, - temperature_state_template: `{{ value_json['${svc.addr}'].setpoint.temp }}`, - - // Limits / resolution - min_temp: minTemp, - max_temp: maxTemp, - temp_step: step, - - optimistic: false, + // Map FIMP modes to Home Assistant modes + const mapFimpToHaMode = (fimpMode: string): string => { + const modeMap: Record = { + off: 'off', + heat: 'heat', + cool: 'cool', + auto: 'auto', + auto_changeover: 'auto', + fan: 'fan_only', + fan_only: 'fan_only', + dry: 'dry', + dry_air: 'dry', + aux_heat: 'heat', + energy_heat: 'heat', + energy_cool: 'cool', + }; + return modeMap[fimpMode] || fimpMode; }; - // ───────────── command handlers ───────────── - const handlers: CommandHandlers = { - [modeCmdTopic]: async (payload: string) => { - if (!supModes.includes(payload)) return; - await sendFimpMsg({ - address: svc.addr!, - service: 'thermostat', - cmd: 'cmd.mode.set', - val_t: 'string', - val: payload, - }); - }, + // Map Home Assistant modes back to FIMP modes + const mapHaToFimpMode = (haMode: string): string => { + // Find the first FIMP mode that maps to this HA mode + const reverseMap: Record = { + off: 'off', + heat: 'heat', + cool: 'cool', + auto: 'auto', + fan_only: 'fan_only', + dry: 'dry', + }; - [tempCmdTopic]: async (payload: string) => { - const t = parseFloat(payload); - if (Number.isNaN(t)) return; + // If we have the exact mode in supported modes, use it + if (supModes.includes(haMode)) { + return haMode; + } + + // Otherwise try to find a suitable FIMP mode + const fimpMode = reverseMap[haMode]; + if (fimpMode && supModes.includes(fimpMode)) { + return fimpMode; + } + + // Special cases for fan_only + if (haMode === 'fan_only') { + if (supModes.includes('fan')) return 'fan'; + if (supModes.includes('fan_only')) return 'fan_only'; + } + + // Special cases for auto + if (haMode === 'auto') { + if (supModes.includes('auto')) return 'auto'; + if (supModes.includes('auto_changeover')) return 'auto_changeover'; + } + + 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, + ); + + // Determine temperature ranges + let minTemp: number | undefined; + let maxTemp: number | undefined; + + // Find the broadest temperature range from all setpoints + for (const setpoint of supSetpoints) { + const range = supTemperatures[setpoint]; + if (range) { + if (minTemp === undefined || range.min < minTemp) { + minTemp = range.min; + } + if (maxTemp === undefined || range.max > maxTemp) { + maxTemp = range.max; + } + } + } + + const climateComponent: ClimateComponent = { + unique_id: svc.addr, + platform: 'climate', + modes: haModes, + mode_command_topic: modeCommandTopic, + // mode_state_topic seems to be required for mode state to be reported correctly in HA + mode_state_topic: stateTopic, + mode_state_template: `{{ value_json['${svc.addr}'].mode | default('off') }}`, + optimistic: false, + temp_step: supStep, + temperature_unit: 'C', + }; + + // Add current temperature from paired sensor_temp service + const sensorTempAddr = replaceSvcInAddr(svc.addr, 'sensor_temp'); + climateComponent.current_temperature_template = `{{ value_json['${sensorTempAddr}'].sensor | default(0) }}`; + + // Add action/state reporting if supported + if (supStates.length > 0) { + climateComponent.action_template = `{% set state = value_json['${svc.addr}'].state | default('idle') %}{{ {'idle': 'idle', 'heat': 'heating', 'cool': 'cooling', 'fan': 'fan'}.get(state, 'off') }}`; + } + + // Add temperature ranges if available + if (minTemp !== undefined) { + climateComponent.min_temp = minTemp; + } + if (maxTemp !== undefined) { + climateComponent.max_temp = maxTemp; + } + + // Handle different setpoint configurations + if (supSetpoints.includes('heat') && supSetpoints.includes('cool')) { + // Dual setpoint system (heat/cool) + climateComponent.temperature_high_command_topic = tempHighCommandTopic; + climateComponent.temperature_low_command_topic = tempLowCommandTopic; + climateComponent.temperature_high_state_topic = stateTopic; + climateComponent.temperature_low_state_topic = stateTopic; + climateComponent.temperature_high_state_template = `{{ value_json['${svc.addr}'].setpoint.cool.temp | default(25) }}`; + climateComponent.temperature_low_state_template = `{{ value_json['${svc.addr}'].setpoint.heat.temp | default(20) }}`; + } else if (supSetpoints.length > 0) { + // Single setpoint system + climateComponent.temperature_command_topic = tempCommandTopic; + climateComponent.temperature_state_topic = stateTopic; + // Use the first available setpoint or try to find the current mode's setpoint + const primarySetpoint = supSetpoints[0]; + climateComponent.temperature_state_template = `{% set mode = value_json['${svc.addr}'].mode | default('${primarySetpoint}') %}{% set setpoint = value_json['${svc.addr}'].setpoint %}{% if setpoint[mode] %}{{ setpoint[mode].temp | default(20) }}{% else %}{{ setpoint['${primarySetpoint}'].temp | default(20) }}{% endif %}`; + } + + components[svc.addr] = climateComponent; + + // Command handlers + + // Mode command handler + commandHandlers[modeCommandTopic] = async (payload: string) => { + const fimpMode = mapHaToFimpMode(payload); + + if (!supModes.includes(fimpMode)) { + return; + } + + await sendFimpMsg({ + address: svc.addr, + service: 'thermostat', + cmd: 'cmd.mode.set', + val_t: 'string', + val: fimpMode, + }); + }; + + // Single temperature command handler + if (climateComponent.temperature_command_topic) { + commandHandlers[tempCommandTopic] = async (payload: string) => { + const temp = parseFloat(payload); + if (Number.isNaN(temp)) { + return; + } + + // Get current mode to determine which setpoint to set + const currentState = haGetCachedState({ + topic: `${topicPrefix}/state`, + })?.[svc.addr]; + const currentMode = currentState?.mode || supSetpoints[0]; + + // Use the mode as setpoint type if it's in sup_setpoints, otherwise use the first available setpoint + const setpointType = supSetpoints.includes(currentMode) + ? currentMode + : supSetpoints[0]; await sendFimpMsg({ - address: svc.addr!, + address: svc.addr, service: 'thermostat', cmd: 'cmd.setpoint.set', val_t: 'str_map', val: { - type: - haGetCachedState({ topic: `${topicPrefix}/state` })?.[svc.addr] - ?.mode ?? defaultSpType, - temp: payload, + type: setpointType, + temp: temp.toString(), unit: 'C', }, }); - }, - }; + }; + } + + // High temperature command handler (for dual setpoint systems) + if (climateComponent.temperature_high_command_topic) { + commandHandlers[tempHighCommandTopic] = async (payload: string) => { + const temp = parseFloat(payload); + if (Number.isNaN(temp)) { + return; + } + + await sendFimpMsg({ + address: svc.addr, + service: 'thermostat', + cmd: 'cmd.setpoint.set', + val_t: 'str_map', + val: { + type: 'cool', + temp: temp.toString(), + unit: 'C', + }, + }); + }; + } + + // Low temperature command handler (for dual setpoint systems) + if (climateComponent.temperature_low_command_topic) { + commandHandlers[tempLowCommandTopic] = async (payload: string) => { + const temp = parseFloat(payload); + if (Number.isNaN(temp)) { + return; + } + + await sendFimpMsg({ + address: svc.addr, + service: 'thermostat', + cmd: 'cmd.setpoint.set', + val_t: 'str_map', + val: { + type: 'heat', + temp: temp.toString(), + unit: 'C', + }, + }); + }; + } return { - components: { [svc.addr]: climate }, - commandHandlers: handlers, + components, + commandHandlers, }; }