Update 'thermostat' service implementation

This commit is contained in:
Adrian Jagielak 2025-07-25 01:31:34 +02:00
parent bceccba572
commit dbc8b1d800
No known key found for this signature in database
GPG Key ID: 0818CF7AF6C62BFB
3 changed files with 238 additions and 83 deletions

View File

@ -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)

View File

@ -36,3 +36,7 @@ export function adapterServiceFromServiceAddress(
return adapterName;
}
export function replaceSvcInAddr(addr: string, newService: string): string {
return addr.replace(/\/sv:[^/]+/, `/sv:${newService}`);
}

View File

@ -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<string, HaMqttComponent> = {};
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<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
// 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<string, string> = {
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;
// 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<string, string> = {
off: 'off',
heat: 'heat',
cool: 'cool',
auto: 'auto',
fan_only: 'fan_only',
dry: 'dry',
};
// 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!,
address: svc.addr,
service: 'thermostat',
cmd: 'cmd.mode.set',
val_t: 'string',
val: payload,
val: fimpMode,
});
},
};
[tempCmdTopic]: async (payload: string) => {
const t = parseFloat(payload);
if (Number.isNaN(t)) return;
// 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',
},
});
},
};
return {
components: { [svc.addr]: climate },
commandHandlers: handlers,
};
}
// 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,
commandHandlers,
};
}