Skip to content

Commit d851f2b

Browse files
committed
Make Bosch RM230Z HA climate modes configurable
1 parent d5594b2 commit d851f2b

3 files changed

Lines changed: 74 additions & 9 deletions

File tree

src/devices/bosch.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -757,28 +757,36 @@ export const definitions: DefinitionWithExtend[] = [
757757
model: "BTH-RM230Z",
758758
vendor: "Bosch",
759759
description: "Room thermostat II 230V",
760+
options: [exposes.options.homeassistant_climate_modes()],
760761
meta: {
761-
overrideHaDiscoveryPayload: (payload) => {
762+
overrideHaDiscoveryPayload: (payload, options?: KeyValue) => {
762763
if (payload.mode_command_topic?.endsWith("/system_mode")) {
764+
const climateModes = options?.homeassistant_climate_modes;
765+
const activeModes = climateModes === "cool" ? ["cool"] : climateModes === "heat_cool" ? ["heat", "cool"] : ["heat"];
766+
const fallbackMode = activeModes[0];
767+
const activeModesTemplate = `[${activeModes.map((mode) => `'${mode}'`).join(",")}]`;
768+
763769
payload.mode_command_topic = payload.mode_command_topic.substring(0, payload.mode_command_topic.lastIndexOf("/system_mode"));
764770
payload.mode_command_template =
765-
"{% set values = " +
766-
`{ 'auto':'schedule','heat':'manual','cool':'manual','off':'pause'} %}` +
767-
`{% if value == "heat" or value == "cool" %}` +
771+
`{% set active_modes = ${activeModesTemplate} %}` +
772+
`{% set values = {'auto':'schedule','off':'pause'} %}` +
773+
"{% if value in active_modes %}" +
768774
`{"operating_mode": "manual", "system_mode": "{{ value }}"}` +
769775
"{% else %}" +
770776
`{"operating_mode": "{{ values[value] if value in values.keys() else 'pause' }}"}` +
771777
"{% endif %}";
772778
payload.mode_state_template =
773-
"{% set values = " +
774-
`{'schedule':'auto','manual':'heat','pause':'off'} %}` +
779+
`{% set active_modes = ${activeModesTemplate} %}` +
780+
`{% set fallback_mode = '${fallbackMode}' %}` +
781+
"{% set values = {'schedule':'auto','pause':'off'} %}" +
775782
"{% set value = value_json.operating_mode %}" +
776-
`{% if value == "manual" %}` +
777-
"{{ value_json.system_mode }}" +
783+
"{% set mode = value_json.system_mode %}" +
784+
"{% if value == 'manual' %}" +
785+
"{{ mode if mode in active_modes else fallback_mode }}" +
778786
"{% else %}" +
779787
`{{ values[value] if value in values.keys() else 'off' }}` +
780788
"{% endif %}";
781-
payload.modes = ["off", "heat", "cool", "auto"];
789+
payload.modes = ["off", ...activeModes, "auto"];
782790
}
783791
},
784792
},

src/lib/exposes.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -840,6 +840,10 @@ export const options = {
840840
new Enum("thermostat_unit", access.SET, ["celsius", "fahrenheit"]).withDescription(
841841
"Controls the temperature unit of the thermostat (default celsius).",
842842
),
843+
homeassistant_climate_modes: () =>
844+
new Enum("homeassistant_climate_modes", access.SET, ["heat", "cool", "heat_cool"])
845+
.withLabel("Home Assistant climate modes")
846+
.withDescription("Controls which modes are exposed in Home Assistant climate discovery (default heat)."),
843847
expose_pin: () =>
844848
new Binary("expose_pin", access.SET, true, false)
845849
.withLabel("Expose PIN")

test/bosch.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import {describe, expect, it} from "vitest";
2+
import {findByDevice} from "../src/index";
3+
import type {KeyValueAny} from "../src/lib/types";
4+
import {mockDevice} from "./utils";
5+
6+
describe("Bosch Room thermostat II 230V", () => {
7+
const getDefinition = () =>
8+
findByDevice(
9+
mockDevice({
10+
modelID: "RBSH-RTH0-ZB-EU",
11+
manufacturerName: "BOSCH",
12+
endpoints: [{ID: 1, inputClusters: ["hvacThermostat"]}],
13+
}),
14+
);
15+
16+
const buildPayload = async (options = {}) => {
17+
const definition = await getDefinition();
18+
const payload: KeyValueAny = {
19+
mode_command_topic: "zigbee2mqtt/bad_thermostat/set/system_mode",
20+
};
21+
22+
definition.meta?.overrideHaDiscoveryPayload?.(payload, options);
23+
24+
return {definition, payload};
25+
};
26+
27+
it("defaults to heat-only Home Assistant climate discovery", async () => {
28+
const {definition, payload} = await buildPayload();
29+
const option = definition.options?.find((option) => option.name === "homeassistant_climate_modes");
30+
31+
expect(option?.label).toBe("Home Assistant climate modes");
32+
expect(payload.mode_command_topic).toBe("zigbee2mqtt/bad_thermostat/set");
33+
expect(payload.mode_command_template).toContain("{% set active_modes = ['heat'] %}");
34+
expect(payload.mode_state_template).toContain("{% set fallback_mode = 'heat' %}");
35+
expect(payload.modes).toStrictEqual(["off", "heat", "auto"]);
36+
});
37+
38+
it("can expose cool-only Home Assistant climate discovery", async () => {
39+
const {payload} = await buildPayload({homeassistant_climate_modes: "cool"});
40+
41+
expect(payload.mode_command_template).toContain("{% set active_modes = ['cool'] %}");
42+
expect(payload.mode_state_template).toContain("{% set fallback_mode = 'cool' %}");
43+
expect(payload.modes).toStrictEqual(["off", "cool", "auto"]);
44+
});
45+
46+
it("can expose heat and cool Home Assistant climate discovery", async () => {
47+
const {payload} = await buildPayload({homeassistant_climate_modes: "heat_cool"});
48+
49+
expect(payload.mode_command_template).toContain("{% set active_modes = ['heat','cool'] %}");
50+
expect(payload.mode_state_template).toContain("{% set fallback_mode = 'heat' %}");
51+
expect(payload.modes).toStrictEqual(["off", "heat", "cool", "auto"]);
52+
});
53+
});

0 commit comments

Comments
 (0)