mirror of
https://github.com/adrianjagielak/home-assistant-futurehome.git
synced 2025-09-13 15:47:08 +00:00
Update 'thermostat' service implementation
This commit is contained in:
parent
bceccba572
commit
dbc8b1d800
@ -6,6 +6,7 @@
|
|||||||
- Added support for 'water_heater' service (devices such as water boiler or a water tank).
|
- Added support for 'water_heater' service (devices such as water boiler or a water tank).
|
||||||
- Updated demo mode data.
|
- Updated demo mode data.
|
||||||
- Added extracting device manufacturer name.
|
- Added extracting device manufacturer name.
|
||||||
|
- Updated 'thermostat' service implementation.
|
||||||
|
|
||||||
## 0.1.1 (24.07.2025)
|
## 0.1.1 (24.07.2025)
|
||||||
|
|
||||||
|
@ -36,3 +36,7 @@ export function adapterServiceFromServiceAddress(
|
|||||||
|
|
||||||
return adapterName;
|
return adapterName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function replaceSvcInAddr(addr: string, newService: string): string {
|
||||||
|
return addr.replace(/\/sv:[^/]+/, `/sv:${newService}`);
|
||||||
|
}
|
||||||
|
@ -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 { sendFimpMsg } from '../fimp/fimp';
|
||||||
|
import { replaceSvcInAddr } from '../fimp/helpers';
|
||||||
import {
|
import {
|
||||||
VinculumPd7Device,
|
VinculumPd7Device,
|
||||||
VinculumPd7Service,
|
VinculumPd7Service,
|
||||||
} from '../fimp/vinculum_pd7_device';
|
} from '../fimp/vinculum_pd7_device';
|
||||||
|
import { HaMqttComponent } from '../ha/mqtt_components/_component';
|
||||||
import { ClimateComponent } from '../ha/mqtt_components/climate';
|
import { ClimateComponent } from '../ha/mqtt_components/climate';
|
||||||
import {
|
import {
|
||||||
CommandHandlers,
|
CommandHandlers,
|
||||||
@ -23,97 +14,256 @@ import { haGetCachedState } from '../ha/update_state';
|
|||||||
|
|
||||||
export function thermostat__components(
|
export function thermostat__components(
|
||||||
topicPrefix: string,
|
topicPrefix: string,
|
||||||
_device: VinculumPd7Device,
|
device: VinculumPd7Device,
|
||||||
svc: VinculumPd7Service,
|
svc: VinculumPd7Service,
|
||||||
): ServiceComponentsCreationResult | undefined {
|
): ServiceComponentsCreationResult | undefined {
|
||||||
const supModes: string[] = svc.props?.sup_modes ?? [];
|
const components: Record<string, HaMqttComponent> = {};
|
||||||
const supSetpoints: string[] = svc.props?.sup_setpoints ?? [];
|
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';
|
// Main climate component
|
||||||
|
|
||||||
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`;
|
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 ─────────────
|
// Map FIMP modes to Home Assistant modes
|
||||||
const modeCmdTopic = `${topicPrefix}${svc.addr}/mode/command`;
|
const mapFimpToHaMode = (fimpMode: string): string => {
|
||||||
const tempCmdTopic = `${topicPrefix}${svc.addr}/temperature/command`;
|
const modeMap: Record<string, string> = {
|
||||||
|
off: 'off',
|
||||||
// ───────────── MQTT climate component ─────────────
|
heat: 'heat',
|
||||||
const climate: ClimateComponent = {
|
cool: 'cool',
|
||||||
unique_id: svc.addr,
|
auto: 'auto',
|
||||||
platform: 'climate',
|
auto_changeover: 'auto',
|
||||||
|
fan: 'fan_only',
|
||||||
// HVAC modes
|
fan_only: 'fan_only',
|
||||||
modes: supModes,
|
dry: 'dry',
|
||||||
mode_command_topic: modeCmdTopic,
|
dry_air: 'dry',
|
||||||
// Even though state topic is often optional as it's already defined by the device object this component is in, the 'climate' expects it
|
aux_heat: 'heat',
|
||||||
mode_state_topic: stateTopic,
|
energy_heat: 'heat',
|
||||||
mode_state_template: `{{ value_json['${svc.addr}'].mode }}`,
|
energy_cool: 'cool',
|
||||||
|
};
|
||||||
// Temperature
|
return modeMap[fimpMode] || fimpMode;
|
||||||
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,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ───────────── command handlers ─────────────
|
// Map Home Assistant modes back to FIMP modes
|
||||||
const handlers: CommandHandlers = {
|
const mapHaToFimpMode = (haMode: string): string => {
|
||||||
[modeCmdTopic]: async (payload: string) => {
|
// Find the first FIMP mode that maps to this HA mode
|
||||||
if (!supModes.includes(payload)) return;
|
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({
|
await sendFimpMsg({
|
||||||
address: svc.addr!,
|
address: svc.addr,
|
||||||
service: 'thermostat',
|
service: 'thermostat',
|
||||||
cmd: 'cmd.mode.set',
|
cmd: 'cmd.mode.set',
|
||||||
val_t: 'string',
|
val_t: 'string',
|
||||||
val: payload,
|
val: fimpMode,
|
||||||
});
|
});
|
||||||
},
|
};
|
||||||
|
|
||||||
[tempCmdTopic]: async (payload: string) => {
|
// Single temperature command handler
|
||||||
const t = parseFloat(payload);
|
if (climateComponent.temperature_command_topic) {
|
||||||
if (Number.isNaN(t)) return;
|
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({
|
await sendFimpMsg({
|
||||||
address: svc.addr!,
|
address: svc.addr,
|
||||||
service: 'thermostat',
|
service: 'thermostat',
|
||||||
cmd: 'cmd.setpoint.set',
|
cmd: 'cmd.setpoint.set',
|
||||||
val_t: 'str_map',
|
val_t: 'str_map',
|
||||||
val: {
|
val: {
|
||||||
type:
|
type: setpointType,
|
||||||
haGetCachedState({ topic: `${topicPrefix}/state` })?.[svc.addr]
|
temp: temp.toString(),
|
||||||
?.mode ?? defaultSpType,
|
|
||||||
temp: payload,
|
|
||||||
unit: 'C',
|
unit: 'C',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
return {
|
// High temperature command handler (for dual setpoint systems)
|
||||||
components: { [svc.addr]: climate },
|
if (climateComponent.temperature_high_command_topic) {
|
||||||
commandHandlers: handlers,
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user