Add support for 'thermostat' service

This commit is contained in:
Adrian Jagielak 2025-07-23 22:32:23 +02:00
parent 8af915cebc
commit 5ab64128c6
No known key found for this signature in database
GPG Key ID: 0818CF7AF6C62BFB
6 changed files with 147 additions and 9 deletions

View File

@ -127,7 +127,7 @@ todo periodical refresh of state
| sensor_wind | | ✅ | | sensor_wind | | ✅ |
| siren | | | | siren | | |
| 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 | | |
| virtual_meter_elec | | | | virtual_meter_elec | | |
| water_heater | | | | water_heater | | |

View File

@ -1,6 +1,6 @@
# https://developers.home-assistant.io/docs/add-ons/configuration#add-on-config # https://developers.home-assistant.io/docs/add-ons/configuration#add-on-config
name: Futurehome name: Futurehome
version: "0.0.18" version: "0.0.19"
slug: futurehome slug: futurehome
description: Local Futurehome Smarthub integration description: Local Futurehome Smarthub integration
url: "https://github.com/adrianjagielak/home-assistant-futurehome" url: "https://github.com/adrianjagielak/home-assistant-futurehome"

View File

@ -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( async function waitForHARetainedMessages(
client: IMqttClient, client: IMqttClient,

View File

@ -44,6 +44,7 @@ import { sensor_watflow__components } from "../services/sensor_watflow";
import { sensor_watpressure__components } from "../services/sensor_watpressure"; 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 { ha } from "./globals"; import { ha } from "./globals";
type HaDeviceConfig = { type HaDeviceConfig = {
@ -78,13 +79,13 @@ type HaDeviceConfig = {
qos: number, 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 // 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.mqtt/
/// https://www.home-assistant.io/integrations/sensor/#device-class /// https://www.home-assistant.io/integrations/sensor/#device-class
type SensorComponent = { export type SensorComponent = {
unique_id: string; unique_id: string;
// platform // platform
p: 'sensor'; 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.mqtt/
/// https://www.home-assistant.io/integrations/binary_sensor/#device-class /// https://www.home-assistant.io/integrations/binary_sensor/#device-class
type BinarySensorComponent = { export type BinarySensorComponent = {
unique_id: string; unique_id: string;
// platform // platform
p: 'binary_sensor'; p: 'binary_sensor';
@ -105,7 +106,7 @@ type BinarySensorComponent = {
/// https://www.home-assistant.io/integrations/switch.mqtt/ /// https://www.home-assistant.io/integrations/switch.mqtt/
/// https://www.home-assistant.io/integrations/switch/#device-class /// https://www.home-assistant.io/integrations/switch/#device-class
type SwitchComponent = { export type SwitchComponent = {
unique_id: string; unique_id: string;
// platform // platform
p: 'switch'; p: 'switch';
@ -116,7 +117,7 @@ type SwitchComponent = {
/// https://www.home-assistant.io/integrations/number.mqtt/ /// https://www.home-assistant.io/integrations/number.mqtt/
/// https://www.home-assistant.io/integrations/number/#device-class /// https://www.home-assistant.io/integrations/number/#device-class
type NumberComponent = { export type NumberComponent = {
unique_id: string; unique_id: string;
// platform // platform
p: 'number'; p: 'number';
@ -128,6 +129,24 @@ type NumberComponent = {
value_template: string; 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 = { export type ServiceComponentsCreationResult = {
components: { [key: string]: HaComponent }; components: { [key: string]: HaComponent };
commandHandlers?: CommandHandlers; commandHandlers?: CommandHandlers;
@ -181,6 +200,7 @@ const serviceHandlers: {
sensor_watpressure: sensor_watpressure__components, sensor_watpressure: sensor_watpressure__components,
sensor_wattemp: sensor_wattemp__components, sensor_wattemp: sensor_wattemp__components,
sensor_weight: sensor_weight__components, sensor_weight: sensor_weight__components,
thermostat: thermostat__components,
}; };
export function haPublishDevice(parameters: { hubId: string, vinculumDeviceData: VinculumPd7Device, deviceInclusionReport: InclusionReport | undefined }): { commandHandlers: CommandHandlers } { export function haPublishDevice(parameters: { hubId: string, vinculumDeviceData: VinculumPd7Device, deviceInclusionReport: InclusionReport | undefined }): { commandHandlers: CommandHandlers } {

View File

@ -142,3 +142,7 @@ export function haUpdateStateSensorReport(parameters: { topic: string; value: an
haStateCache[stateTopic] = payload; haStateCache[stateTopic] = payload;
} }
} }
export function haGetCachedState(parameters: { topic: string }) {
return haStateCache[parameters.topic];
}

View File

@ -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<string, { min?: number; max?: number }> =
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,
};
}