From 5ab64128c63b14fea60c15a1da5a3d9acb537088 Mon Sep 17 00:00:00 2001 From: Adrian Jagielak Date: Wed, 23 Jul 2025 22:32:23 +0200 Subject: [PATCH] Add support for 'thermostat' service --- README.md | 2 +- futurehome/config.yaml | 2 +- futurehome/src/client.ts | 2 +- futurehome/src/ha/publish_device.ts | 30 +++++-- futurehome/src/ha/update_state.ts | 6 +- futurehome/src/services/thermostat.ts | 114 ++++++++++++++++++++++++++ 6 files changed, 147 insertions(+), 9 deletions(-) create mode 100644 futurehome/src/services/thermostat.ts diff --git a/README.md b/README.md index c4689d4..954cca6 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ todo periodical refresh of state | sensor_wind | | ✅ | | siren | | | | 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 | | | | virtual_meter_elec | | | | water_heater | | | diff --git a/futurehome/config.yaml b/futurehome/config.yaml index a012dcc..82bbb1c 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: "0.0.18" +version: "0.0.19" slug: futurehome description: Local Futurehome Smarthub integration url: "https://github.com/adrianjagielak/home-assistant-futurehome" diff --git a/futurehome/src/client.ts b/futurehome/src/client.ts index 05d6a1f..b88faad 100644 --- a/futurehome/src/client.ts +++ b/futurehome/src/client.ts @@ -24,7 +24,7 @@ function makeClient(url: string, port: number, username: string, password: strin }); } -type RetainedMessage = { topic: string; message: string }; +export type RetainedMessage = { topic: string; message: string }; async function waitForHARetainedMessages( client: IMqttClient, diff --git a/futurehome/src/ha/publish_device.ts b/futurehome/src/ha/publish_device.ts index f277078..e149f77 100644 --- a/futurehome/src/ha/publish_device.ts +++ b/futurehome/src/ha/publish_device.ts @@ -44,6 +44,7 @@ import { sensor_watflow__components } from "../services/sensor_watflow"; 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 { ha } from "./globals"; type HaDeviceConfig = { @@ -78,13 +79,13 @@ type HaDeviceConfig = { qos: number, } -export type HaComponent = SensorComponent | BinarySensorComponent | SwitchComponent | NumberComponent; +export type HaComponent = SensorComponent | BinarySensorComponent | SwitchComponent | NumberComponent | ClimateComponent; // Device class supported values: https://www.home-assistant.io/integrations/homeassistant/#device-class /// https://www.home-assistant.io/integrations/sensor.mqtt/ /// https://www.home-assistant.io/integrations/sensor/#device-class -type SensorComponent = { +export type SensorComponent = { unique_id: string; // platform p: 'sensor'; @@ -95,7 +96,7 @@ type SensorComponent = { /// https://www.home-assistant.io/integrations/binary_sensor.mqtt/ /// https://www.home-assistant.io/integrations/binary_sensor/#device-class -type BinarySensorComponent = { +export type BinarySensorComponent = { unique_id: string; // platform p: 'binary_sensor'; @@ -105,7 +106,7 @@ type BinarySensorComponent = { /// https://www.home-assistant.io/integrations/switch.mqtt/ /// https://www.home-assistant.io/integrations/switch/#device-class -type SwitchComponent = { +export type SwitchComponent = { unique_id: string; // platform p: 'switch'; @@ -116,7 +117,7 @@ type SwitchComponent = { /// https://www.home-assistant.io/integrations/number.mqtt/ /// https://www.home-assistant.io/integrations/number/#device-class -type NumberComponent = { +export type NumberComponent = { unique_id: string; // platform p: 'number'; @@ -128,6 +129,24 @@ type NumberComponent = { value_template: string; } +/// https://www.home-assistant.io/integrations/climate.mqtt/ +export type ClimateComponent = { + unique_id: string; + // platform + p: 'climate'; + modes: string[]; + mode_command_topic: string; + mode_state_topic: string; + mode_state_template: string; + temperature_command_topic: string; + temperature_state_topic: string; + temperature_state_template: string; + min_temp: number; + max_temp: number; + temp_step: number; + optimistic: boolean; +} + export type ServiceComponentsCreationResult = { components: { [key: string]: HaComponent }; commandHandlers?: CommandHandlers; @@ -181,6 +200,7 @@ const serviceHandlers: { sensor_watpressure: sensor_watpressure__components, sensor_wattemp: sensor_wattemp__components, sensor_weight: sensor_weight__components, + thermostat: thermostat__components, }; export function haPublishDevice(parameters: { hubId: string, vinculumDeviceData: VinculumPd7Device, deviceInclusionReport: InclusionReport | undefined }): { commandHandlers: CommandHandlers } { diff --git a/futurehome/src/ha/update_state.ts b/futurehome/src/ha/update_state.ts index 08281e6..5521c77 100644 --- a/futurehome/src/ha/update_state.ts +++ b/futurehome/src/ha/update_state.ts @@ -141,4 +141,8 @@ export function haUpdateStateSensorReport(parameters: { topic: string; value: an haStateCache[stateTopic] = payload; } -} \ No newline at end of file +} + +export function haGetCachedState(parameters: { topic: string }) { + return haStateCache[parameters.topic]; +} diff --git a/futurehome/src/services/thermostat.ts b/futurehome/src/services/thermostat.ts new file mode 100644 index 0000000..48847fb --- /dev/null +++ b/futurehome/src/services/thermostat.ts @@ -0,0 +1,114 @@ +// 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 { VinculumPd7Device, VinculumPd7Service } from "../fimp/vinculum_pd7_device"; +import { + ClimateComponent, + CommandHandlers, + ServiceComponentsCreationResult, +} from "../ha/publish_device"; +import { haGetCachedState } from "../ha/update_state"; + +export function thermostat__components( + topicPrefix: string, + _device: VinculumPd7Device, + svc: VinculumPd7Service +): ServiceComponentsCreationResult | undefined { + const supModes: string[] = svc.props?.sup_modes ?? []; + const supSetpoints: string[] = svc.props?.sup_setpoints ?? []; + + if (!supModes.length) return undefined; // nothing useful to expose + + 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 + const stateTopic = `${topicPrefix}/state`; + + // ───────────── 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, + p: "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: true, + }; + + // ───────────── 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, + }); + }, + + [tempCmdTopic]: async (payload: string) => { + const t = parseFloat(payload); + if (Number.isNaN(t)) return; + + await sendFimpMsg({ + 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, + unit: "C", + }, + }); + }, + }; + + return { + components: { [svc.addr]: climate }, + commandHandlers: handlers, + }; +}