Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 17 additions & 9 deletions src/devices/bosch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -757,28 +757,36 @@ export const definitions: DefinitionWithExtend[] = [
model: "BTH-RM230Z",
vendor: "Bosch",
description: "Room thermostat II 230V",
options: [exposes.options.homeassistant_climate_modes()],
meta: {
overrideHaDiscoveryPayload: (payload) => {
overrideHaDiscoveryPayload: (payload, options) => {
if (payload.mode_command_topic?.endsWith("/system_mode")) {
const climateModes = options?.homeassistant_climate_modes;
const activeModes = climateModes === "cool" ? ["cool"] : climateModes === "heat_cool" ? ["heat", "cool"] : ["heat"];
const fallbackMode = activeModes[0];
const activeModesTemplate = `[${activeModes.map((mode) => `'${mode}'`).join(",")}]`;

payload.mode_command_topic = payload.mode_command_topic.substring(0, payload.mode_command_topic.lastIndexOf("/system_mode"));
payload.mode_command_template =
"{% set values = " +
`{ 'auto':'schedule','heat':'manual','cool':'manual','off':'pause'} %}` +
`{% if value == "heat" or value == "cool" %}` +
`{% set active_modes = ${activeModesTemplate} %}` +
`{% set values = {'auto':'schedule','off':'pause'} %}` +
"{% if value in active_modes %}" +
`{"operating_mode": "manual", "system_mode": "{{ value }}"}` +
"{% else %}" +
`{"operating_mode": "{{ values[value] if value in values.keys() else 'pause' }}"}` +
"{% endif %}";
payload.mode_state_template =
"{% set values = " +
`{'schedule':'auto','manual':'heat','pause':'off'} %}` +
`{% set active_modes = ${activeModesTemplate} %}` +
`{% set fallback_mode = '${fallbackMode}' %}` +
"{% set values = {'schedule':'auto','pause':'off'} %}" +
"{% set value = value_json.operating_mode %}" +
`{% if value == "manual" %}` +
"{{ value_json.system_mode }}" +
"{% set mode = value_json.system_mode %}" +
"{% if value == 'manual' %}" +
"{{ mode if mode in active_modes else fallback_mode }}" +
"{% else %}" +
`{{ values[value] if value in values.keys() else 'off' }}` +
"{% endif %}";
payload.modes = ["off", "heat", "cool", "auto"];
payload.modes = ["off", ...activeModes, "auto"];
}
},
},
Expand Down
4 changes: 4 additions & 0 deletions src/lib/exposes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -850,6 +850,10 @@ export const options = {
new Enum("thermostat_unit", access.SET, ["celsius", "fahrenheit"]).withDescription(
"Controls the temperature unit of the thermostat (default celsius).",
),
homeassistant_climate_modes: () =>
new Enum("homeassistant_climate_modes", access.SET, ["heat", "cool", "heat_cool"])
.withLabel("Home Assistant climate modes")
.withDescription("Controls which modes are exposed in Home Assistant climate discovery (default heat)."),
expose_pin: () =>
new Binary("expose_pin", access.SET, true, false)
.withLabel("Expose PIN")
Expand Down
51 changes: 50 additions & 1 deletion test/bosch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {describe, expect, it, vi} from "vitest";
import {findByDevice} from "../src/index";
import {boschThermostatExtend} from "../src/lib/bosch";
import {repInterval} from "../src/lib/constants";
import type {DefinitionExposesFunction} from "../src/lib/types";
import type {DefinitionExposesFunction, KeyValueAny} from "../src/lib/types";
import {mockDevice, reportingItem} from "./utils";

describe("Bosch thermostats", () => {
Expand Down Expand Up @@ -76,3 +76,52 @@ describe("Bosch thermostat relayState extension", () => {
expect(device.getEndpoint(1).configureReporting).not.toHaveBeenCalled();
});
});

describe("Bosch Room thermostat II 230V Home Assistant climate modes", () => {
const getDefinition = () =>
findByDevice(
mockDevice({
modelID: "RBSH-RTH0-ZB-EU",
manufacturerName: "BOSCH",
endpoints: [{ID: 1, inputClusters: ["hvacThermostat"]}],
}),
);

const buildPayload = async (options = {}) => {
const definition = await getDefinition();
const payload: KeyValueAny = {
mode_command_topic: "zigbee2mqtt/bad_thermostat/set/system_mode",
};

definition.meta?.overrideHaDiscoveryPayload?.(payload, options);

return {definition, payload};
};

it("defaults to heat-only Home Assistant climate discovery", async () => {
const {definition, payload} = await buildPayload();
const option = definition.options?.find((option) => option.name === "homeassistant_climate_modes");

expect(option?.label).toBe("Home Assistant climate modes");
expect(payload.mode_command_topic).toBe("zigbee2mqtt/bad_thermostat/set");
expect(payload.mode_command_template).toContain("{% set active_modes = ['heat'] %}");
expect(payload.mode_state_template).toContain("{% set fallback_mode = 'heat' %}");
expect(payload.modes).toStrictEqual(["off", "heat", "auto"]);
});

it("can expose cool-only Home Assistant climate discovery", async () => {
const {payload} = await buildPayload({homeassistant_climate_modes: "cool"});

expect(payload.mode_command_template).toContain("{% set active_modes = ['cool'] %}");
expect(payload.mode_state_template).toContain("{% set fallback_mode = 'cool' %}");
expect(payload.modes).toStrictEqual(["off", "cool", "auto"]);
});

it("can expose heat and cool Home Assistant climate discovery", async () => {
const {payload} = await buildPayload({homeassistant_climate_modes: "heat_cool"});

expect(payload.mode_command_template).toContain("{% set active_modes = ['heat','cool'] %}");
expect(payload.mode_state_template).toContain("{% set fallback_mode = 'heat' %}");
expect(payload.modes).toStrictEqual(["off", "heat", "cool", "auto"]);
});
});