From 205e1953b188a0e9e1f57ff8660edf0de46c1dd8 Mon Sep 17 00:00:00 2001 From: Manuel Seeger Date: Sun, 16 Aug 2026 12:43:58 +0200 Subject: [PATCH 1/5] feat(climate): add fan mode controls --- Model.js | 32 ++++++++++++++++++++++- README.md | 2 +- Service.qml | 10 ++++++++ bin/hass-bridge | 12 +++++++-- controls/ClimateControls.qml | 47 ++++++++++++++++++++++++++++++++++ tests/test_bridge.py | 10 ++++++++ tests/test_model.js | 18 +++++++++++++ tests/test_service_contract.py | 4 +++ 8 files changed, 131 insertions(+), 4 deletions(-) diff --git a/Model.js b/Model.js index 298072a..b079261 100644 --- a/Model.js +++ b/Model.js @@ -187,6 +187,7 @@ var COVER_STOP = 8 var CLIMATE_TARGET_TEMPERATURE = 1 var CLIMATE_TARGET_TEMPERATURE_RANGE = 2 +var CLIMATE_FAN_MODE = 8 var CLIMATE_TURN_OFF = 128 var CLIMATE_TURN_ON = 256 @@ -226,6 +227,7 @@ function capabilitiesFor(entity) { coverClose: false, climateTarget: false, climateRange: false, + climateFanMode: false, expandable: false, reserveExpandSlot: false } @@ -247,12 +249,15 @@ function capabilitiesFor(entity) { && typeof a.target_temp_high === "number" result.climateTarget = hasFeature(bits, CLIMATE_TARGET_TEMPERATURE) && typeof a.temperature === "number" - } + result.climateFanMode = hasFeature(bits, CLIMATE_FAN_MODE) + && climateFanModes(entity).length > 0 + } result.expandable = result.brightness || result.mediaPrevious || result.mediaPlayPause || result.mediaNext || result.mediaVolume || result.coverOpen || result.coverStop || result.coverClose || result.climateTarget || result.climateRange + || result.climateFanMode // Climate integrations commonly clear the live target while the device is // off. Keep the row geometry stable without pretending there is a target // value to edit: the chevron remains hidden/disabled until controls are @@ -343,6 +348,31 @@ function climateTemperatureData(entity, target, low, high, unitFallback) { return data } +// Climate integrations declare every permitted fan-mode token. Preserve tokens +// exactly because Home Assistant expects the selected value verbatim. +function climateFanModes(entity) { + var declared = attrs(entity).fan_modes + if (!Array.isArray(declared)) return [] + var modes = [] + for (var i = 0; i < declared.length; i++) { + if (typeof declared[i] !== "string" || !declared[i].trim()) continue + if (modes.indexOf(declared[i]) === -1) modes.push(declared[i]) + } + return modes +} + +function climateFanMode(entity) { + var mode = attrs(entity).fan_mode + return typeof mode === "string" ? mode : "" +} + +function climateFanModeData(entity, mode) { + var caps = capabilitiesFor(entity) + if (!caps.climateFanMode || typeof mode !== "string") return {} + return climateFanModes(entity).indexOf(mode) === -1 ? {} : { fan_mode: mode } +} + + // ---------------------------------------------------------------- icons // Material Design Icons, as in Home Assistant's own `mdi:` hints. Codepoints diff --git a/README.md b/README.md index 0ca1485..054b7e2 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ settings, `r` refreshes, `esc` closes, `tab` moves to the next bar panel. | `scene`, `script` | Activate button | | `media_player` | Previous / play-pause / next, volume slider | | `cover` | Open / stop / close | -| `climate` | On/off when advertised, plus a target temperature or low/high band | +| `climate` | On/off when advertised, plus a target temperature or low/high band and fan-mode selection when advertised | | `sensor`, `binary_sensor`, everything else | State display only | Cameras not yet. diff --git a/Service.qml b/Service.qml index e311835..f5c680a 100644 --- a/Service.qml +++ b/Service.qml @@ -621,6 +621,16 @@ QtObject { root.callTag(entityId)) } + function setClimateFanMode(entityId, mode) { + var data = Model.climateFanModeData(root.states[entityId], mode) + if (Object.keys(data).length === 0) { + return root.rejectAction("This climate entity does not report a controllable fan mode.") + } + return root.callService("climate", "set_fan_mode", entityId, data, + root.callTag(entityId)) + } + + function refresh() { root.send({ op: "refresh" }) } diff --git a/bin/hass-bridge b/bin/hass-bridge index ccfeb7a..159ea04 100755 --- a/bin/hass-bridge +++ b/bin/hass-bridge @@ -241,9 +241,10 @@ def demo_initial_states(): entity("climate.living_room_thermostat", "heat", { "friendly_name": "Living Room Thermostat", "hvac_action": "heating", "current_temperature": 21.4, "temperature": 22.0, + "fan_mode": "medium", "fan_modes": ["auto", "low", "medium", "high"], "target_temp_step": 0.5, "min_temp": 16.0, "max_temp": 30.0, - # TARGET_TEMPERATURE | TURN_OFF | TURN_ON - "supported_features": 1 | 128 | 256}), + # TARGET_TEMPERATURE | FAN_MODE | TURN_OFF | TURN_ON + "supported_features": 1 | 8 | 128 | 256}), entity("media_player.living_room_tv", "playing", { "friendly_name": "Living Room TV", "icon": "mdi:television", "device_class": "tv", "volume_level": 0.42, @@ -430,6 +431,13 @@ class DemoTransport: if key in data: attrs[key] = float(data[key]) self._set_attrs(entity_id, attrs) + elif pair == ("climate", "set_fan_mode"): + mode = data.get("fan_mode") + modes = self._states[entity_id]["attributes"].get("fan_modes") or [] + if not isinstance(mode, str) or mode not in modes: + self._fail(msg_id, "Invalid demo climate fan mode.") + return + self._set_attrs(entity_id, {"fan_mode": mode}) else: self._fail(msg_id, "demo backend does not implement %s.%s" % pair) return diff --git a/controls/ClimateControls.qml b/controls/ClimateControls.qml index 78b256c..593d828 100644 --- a/controls/ClimateControls.qml +++ b/controls/ClimateControls.qml @@ -1,4 +1,5 @@ import QtQuick +import QtQuick.Controls import qs.Ui import qs.Commons import "../Model.js" as Model @@ -30,6 +31,8 @@ Item { ? Model.temperatureRange(entity, instanceUnit) : ({ min: 5, max: 35 }) readonly property var capabilities: Model.capabilitiesFor(entity) readonly property bool ranged: capabilities.climateRange + readonly property var fanModes: entity ? Model.climateFanModes(entity) : [] + readonly property string fanMode: entity ? Model.climateFanMode(entity) : "" function attr(key, fallback) { if (!entity || !entity.attributes) return fallback @@ -155,5 +158,49 @@ Item { onMoved: function(value) { control.localHigh = value } onReleased: function(value) { control.commitRange(false, value) } } + + Column { + visible: control.capabilities.climateFanMode + width: parent.width + spacing: Style.spacing.sm + + Text { + textFormat: Text.PlainText + text: "FAN" + color: control.fg + font.family: control.family + font.pixelSize: Style.font.caption + font.weight: Font.Medium + } + + // ButtonGroup is a non-wrapping row. Keep every integration-provided + // mode reachable instead of letting a long list escape the panel. + ScrollView { + width: parent.width + implicitHeight: fanModeGroup.implicitHeight + clip: true + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + ScrollBar.vertical.policy: ScrollBar.AlwaysOff + + ButtonGroup { + id: fanModeGroup + focusable: false + foreground: control.fg + fontFamily: control.family + fontSize: Style.font.caption + options: control.fanModes.map(function(mode) { + return { value: mode, label: Model.capitalize(mode) } + }) + value: control.fanMode + onChanged: function(mode) { + // A state update also changes value. It is already authoritative, + // so only dispatch a user selection that differs from that state. + if (mode !== control.fanMode) { + control.hass.setClimateFanMode(control.entityId, mode) + } + } + } + } + } } } diff --git a/tests/test_bridge.py b/tests/test_bridge.py index 3572be8..0fb3d3f 100644 --- a/tests/test_bridge.py +++ b/tests/test_bridge.py @@ -654,6 +654,16 @@ def test_demo_needs_no_server(): and e["entity"]["state"] == "heat") check("turns on demo climate", climate_on is not None, climate_on) + bridge.send({"op": "call_service", "domain": "climate", "service": "set_fan_mode", + "entity_id": climate_id, "data": {"fan_mode": "high"}, + "tag": "demo-climate-fan"}) + fan_mode = bridge.wait_for( + lambda e: e["ev"] == "state_changed" + and e["entity"]["entity_id"] == climate_id + and e["entity"]["attributes"].get("fan_mode") == "high") + check("sets the advertised demo climate fan mode", fan_mode is not None, fan_mode) + + bridge.send({"op": "call_service", "domain": "cover", "service": "open_cover", "entity_id": "cover.garage_door", "tag": "demo-1"}) changed = bridge.wait_for( diff --git a/tests/test_model.js b/tests/test_model.js index e3e3a98..acb86d3 100644 --- a/tests/test_model.js +++ b/tests/test_model.js @@ -188,6 +188,24 @@ section("temperature", () => { supported_features: 2, target_temp_low: 18, target_temp_high: 24, min_temp: 10, max_temp: 30 }); + + const fanEntity = entity("climate.a", "cool", { + supported_features: 8, fan_mode: "medium", + fan_modes: ["auto", "low", "medium", "high"] + }); + eq("advertised climate fan modes are preserved", + Model.climateFanModes(fanEntity), ["auto", "low", "medium", "high"]); + eq("a climate fan mode is supported only with its feature and options", + Model.capabilitiesFor(fanEntity).climateFanMode, true); + eq("the selected advertised fan mode maps to the typed service payload", + Model.climateFanModeData(fanEntity, "high"), { fan_mode: "high" }); + eq("an undeclared fan mode is rejected", + Model.climateFanModeData(fanEntity, "turbo"), {}); + eq("fan options without the advertised feature are rejected", + Model.climateFanModeData(entity("climate.a", "cool", { + fan_modes: ["auto", "high"] + }), "high"), {}); + eq("crossed target bounds are normalized", Model.climateTemperatureData(rangeEntity, undefined, 27, 16, "°C"), { target_temp_low: 16, target_temp_high: 27 }); diff --git a/tests/test_service_contract.py b/tests/test_service_contract.py index d8ab6bd..dff038d 100644 --- a/tests/test_service_contract.py +++ b/tests/test_service_contract.py @@ -116,6 +116,10 @@ def main(): check("domain actions validate entity capabilities", service.count("root.capabilities(entityId)") >= 7 and "Model.capabilitiesFor(entity)" in service) + fan_mode = function_block("setClimateFanMode") + check("climate fan mode calls are capability-validated and typed", + "Model.climateFanModeData" in fan_mode + and 'root.callService("climate", "set_fan_mode"' in fan_mode) check("selected tab persistence is debounced", "selectedTabSaveDebounce.restart()" in service) From 1b3734e2dd97b3e05d13d5d7ea1c35889e537d1c Mon Sep 17 00:00:00 2001 From: Manuel Seeger Date: Sun, 16 Aug 2026 12:53:06 +0200 Subject: [PATCH 2/5] feat(climate): add HVAC mode controls --- Model.js | 28 ++++++++++++++++++++- README.md | 2 +- Service.qml | 9 +++++++ bin/hass-bridge | 9 +++++++ controls/ClimateControls.qml | 46 ++++++++++++++++++++++++++++++++++ tests/test_bridge.py | 10 ++++++++ tests/test_model.js | 19 ++++++++++++++ tests/test_service_contract.py | 4 +++ 8 files changed, 125 insertions(+), 2 deletions(-) diff --git a/Model.js b/Model.js index b079261..78bdcc9 100644 --- a/Model.js +++ b/Model.js @@ -227,6 +227,7 @@ function capabilitiesFor(entity) { coverClose: false, climateTarget: false, climateRange: false, + climateHvacMode: false, climateFanMode: false, expandable: false, reserveExpandSlot: false @@ -249,6 +250,7 @@ function capabilitiesFor(entity) { && typeof a.target_temp_high === "number" result.climateTarget = hasFeature(bits, CLIMATE_TARGET_TEMPERATURE) && typeof a.temperature === "number" + result.climateHvacMode = climateHvacModes(entity).length > 0 result.climateFanMode = hasFeature(bits, CLIMATE_FAN_MODE) && climateFanModes(entity).length > 0 @@ -257,7 +259,7 @@ function capabilitiesFor(entity) { || result.mediaPrevious || result.mediaPlayPause || result.mediaNext || result.mediaVolume || result.coverOpen || result.coverStop || result.coverClose || result.climateTarget || result.climateRange - || result.climateFanMode + || result.climateHvacMode || result.climateFanMode // Climate integrations commonly clear the live target while the device is // off. Keep the row geometry stable without pretending there is a target // value to edit: the chevron remains hidden/disabled until controls are @@ -348,6 +350,30 @@ function climateTemperatureData(entity, target, low, high, unitFallback) { return data } +// HVAC mode is the climate entity state. Unlike optional climate controls, +// Home Assistant does not assign it a supported-feature bit; the advertised +// `hvac_modes` list is the capability contract for climate.set_hvac_mode. +function climateHvacModes(entity) { + var declared = attrs(entity).hvac_modes + if (!Array.isArray(declared)) return [] + var modes = [] + for (var i = 0; i < declared.length; i++) { + if (typeof declared[i] !== "string" || !declared[i].trim()) continue + if (modes.indexOf(declared[i]) === -1) modes.push(declared[i]) + } + return modes +} + +function climateHvacMode(entity) { + return stateOf(entity) +} + +function climateHvacModeData(entity, mode) { + var caps = capabilitiesFor(entity) + if (!caps.climateHvacMode || typeof mode !== "string") return {} + return climateHvacModes(entity).indexOf(mode) === -1 ? {} : { hvac_mode: mode } +} + // Climate integrations declare every permitted fan-mode token. Preserve tokens // exactly because Home Assistant expects the selected value verbatim. function climateFanModes(entity) { diff --git a/README.md b/README.md index 054b7e2..83c4806 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ settings, `r` refreshes, `esc` closes, `tab` moves to the next bar panel. | `scene`, `script` | Activate button | | `media_player` | Previous / play-pause / next, volume slider | | `cover` | Open / stop / close | -| `climate` | On/off when advertised, plus a target temperature or low/high band and fan-mode selection when advertised | +| `climate` | On/off when advertised, plus HVAC-mode, fan-mode, and target-temperature or low/high-band selection when advertised | | `sensor`, `binary_sensor`, everything else | State display only | Cameras not yet. diff --git a/Service.qml b/Service.qml index f5c680a..45eb6f2 100644 --- a/Service.qml +++ b/Service.qml @@ -610,6 +610,15 @@ QtObject { return root.callService(domain, "turn_on", entityId, {}, root.callTag(entityId)) } + function setClimateHvacMode(entityId, mode) { + var data = Model.climateHvacModeData(root.states[entityId], mode) + if (Object.keys(data).length === 0) { + return root.rejectAction("This climate entity does not report a controllable HVAC mode.") + } + return root.callService("climate", "set_hvac_mode", entityId, data, + root.callTag(entityId)) + } + function setClimateTemperature(entityId, target, low, high) { var entity = root.states[entityId] var data = Model.climateTemperatureData( diff --git a/bin/hass-bridge b/bin/hass-bridge index 159ea04..a49ca2c 100755 --- a/bin/hass-bridge +++ b/bin/hass-bridge @@ -240,6 +240,7 @@ def demo_initial_states(): "friendly_name": "Living Room Ceiling Fan", "icon": "mdi:fan"}), entity("climate.living_room_thermostat", "heat", { "friendly_name": "Living Room Thermostat", "hvac_action": "heating", + "hvac_modes": ["off", "heat", "cool", "dry", "fan_only", "auto"], "current_temperature": 21.4, "temperature": 22.0, "fan_mode": "medium", "fan_modes": ["auto", "low", "medium", "high"], "target_temp_step": 0.5, "min_temp": 16.0, "max_temp": 30.0, @@ -431,6 +432,14 @@ class DemoTransport: if key in data: attrs[key] = float(data[key]) self._set_attrs(entity_id, attrs) + elif pair == ("climate", "set_hvac_mode"): + mode = data.get("hvac_mode") + modes = self._states[entity_id]["attributes"].get("hvac_modes") or [] + if not isinstance(mode, str) or mode not in modes: + self._fail(msg_id, "Invalid demo climate HVAC mode.") + return + self._set_state(entity_id, mode) + self._set_attrs(entity_id, {"hvac_action": "off" if mode == "off" else "idle"}) elif pair == ("climate", "set_fan_mode"): mode = data.get("fan_mode") modes = self._states[entity_id]["attributes"].get("fan_modes") or [] diff --git a/controls/ClimateControls.qml b/controls/ClimateControls.qml index 593d828..4e60b3d 100644 --- a/controls/ClimateControls.qml +++ b/controls/ClimateControls.qml @@ -31,6 +31,8 @@ Item { ? Model.temperatureRange(entity, instanceUnit) : ({ min: 5, max: 35 }) readonly property var capabilities: Model.capabilitiesFor(entity) readonly property bool ranged: capabilities.climateRange + readonly property var hvacModes: entity ? Model.climateHvacModes(entity) : [] + readonly property string hvacMode: entity ? Model.climateHvacMode(entity) : "" readonly property var fanModes: entity ? Model.climateFanModes(entity) : [] readonly property string fanMode: entity ? Model.climateFanMode(entity) : "" @@ -84,6 +86,50 @@ Item { width: parent.width spacing: Style.spacing.xl + Column { + visible: control.capabilities.climateHvacMode + width: parent.width + spacing: Style.spacing.sm + + Text { + textFormat: Text.PlainText + text: "MODE" + color: control.fg + font.family: control.family + font.pixelSize: Style.font.caption + font.weight: Font.Medium + } + + // HVAC modes are integration-defined. Keep the selector horizontal so + // every advertised mode remains reachable in a narrow panel. + ScrollView { + width: parent.width + implicitHeight: hvacModeGroup.implicitHeight + clip: true + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + ScrollBar.vertical.policy: ScrollBar.AlwaysOff + + ButtonGroup { + id: hvacModeGroup + focusable: false + foreground: control.fg + fontFamily: control.family + fontSize: Style.font.caption + options: control.hvacModes.map(function(mode) { + return { value: mode, label: Model.capitalize(mode) } + }) + value: control.hvacMode + onChanged: function(mode) { + // Incoming state is authoritative. Only a different user choice + // needs the typed service call. + if (mode !== control.hvacMode) { + control.hass.setClimateHvacMode(control.entityId, mode) + } + } + } + } + } + // ---------- single setpoint ---------- Column { visible: control.capabilities.climateTarget && !control.ranged diff --git a/tests/test_bridge.py b/tests/test_bridge.py index 0fb3d3f..57bbec9 100644 --- a/tests/test_bridge.py +++ b/tests/test_bridge.py @@ -654,6 +654,16 @@ def test_demo_needs_no_server(): and e["entity"]["state"] == "heat") check("turns on demo climate", climate_on is not None, climate_on) + bridge.send({"op": "call_service", "domain": "climate", "service": "set_hvac_mode", + "entity_id": climate_id, "data": {"hvac_mode": "cool"}, + "tag": "demo-climate-hvac"}) + hvac_mode = bridge.wait_for( + lambda e: e["ev"] == "state_changed" + and e["entity"]["entity_id"] == climate_id + and e["entity"]["state"] == "cool") + check("sets the advertised demo HVAC mode", hvac_mode is not None, hvac_mode) + + bridge.send({"op": "call_service", "domain": "climate", "service": "set_fan_mode", "entity_id": climate_id, "data": {"fan_mode": "high"}, "tag": "demo-climate-fan"}) diff --git a/tests/test_model.js b/tests/test_model.js index acb86d3..45fdc3d 100644 --- a/tests/test_model.js +++ b/tests/test_model.js @@ -189,6 +189,22 @@ section("temperature", () => { min_temp: 10, max_temp: 30 }); + const hvacEntity = entity("climate.a", "cool", { + hvac_modes: ["off", "heat", "cool", "dry", "fan_only", "cool", "", 1] + }); + eq("advertised climate HVAC modes are preserved and cleaned", + Model.climateHvacModes(hvacEntity), + ["off", "heat", "cool", "dry", "fan_only"]); + eq("the HVAC mode is the climate state", Model.climateHvacMode(hvacEntity), "cool"); + eq("a climate HVAC mode needs advertised options, not a feature bit", + Model.capabilitiesFor(hvacEntity).climateHvacMode, true); + eq("the selected advertised HVAC mode maps to the typed service payload", + Model.climateHvacModeData(hvacEntity, "dry"), { hvac_mode: "dry" }); + eq("an undeclared HVAC mode is rejected", + Model.climateHvacModeData(hvacEntity, "turbo"), {}); + eq("HVAC mode without advertised options is rejected", + Model.climateHvacModeData(entity("climate.a", "cool"), "heat"), {}); + const fanEntity = entity("climate.a", "cool", { supported_features: 8, fan_mode: "medium", fan_modes: ["auto", "low", "medium", "high"] @@ -285,6 +301,9 @@ section("control classification", () => { { supported_features: 1, temperature: 22 })), true); eq("climate without a target control does not expand", Model.isExpandable(entity("climate.a", "heat")), false); + eq("climate with advertised HVAC modes expands", + Model.isExpandable(entity("climate.a", "heat", + { hvac_modes: ["off", "heat", "cool"] })), true); eq("cover with open support expands", Model.isExpandable(entity("cover.a", "open", { supported_features: 1 })), true); eq("cover without advertised actions does not expand", diff --git a/tests/test_service_contract.py b/tests/test_service_contract.py index dff038d..0ac1697 100644 --- a/tests/test_service_contract.py +++ b/tests/test_service_contract.py @@ -116,6 +116,10 @@ def main(): check("domain actions validate entity capabilities", service.count("root.capabilities(entityId)") >= 7 and "Model.capabilitiesFor(entity)" in service) + hvac_mode = function_block("setClimateHvacMode") + check("climate HVAC mode calls are capability-validated and typed", + "Model.climateHvacModeData" in hvac_mode + and 'root.callService("climate", "set_hvac_mode"' in hvac_mode) fan_mode = function_block("setClimateFanMode") check("climate fan mode calls are capability-validated and typed", "Model.climateFanModeData" in fan_mode From 3bc258ae57fdf437077b7b21506315dfe0a65d96 Mon Sep 17 00:00:00 2001 From: Manuel Seeger Date: Sun, 16 Aug 2026 12:56:34 +0200 Subject: [PATCH 3/5] fix(climate): place HVAC modes last --- controls/ClimateControls.qml | 86 ++++++++++++++++++------------------ 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/controls/ClimateControls.qml b/controls/ClimateControls.qml index 4e60b3d..a4bf0f2 100644 --- a/controls/ClimateControls.qml +++ b/controls/ClimateControls.qml @@ -86,49 +86,6 @@ Item { width: parent.width spacing: Style.spacing.xl - Column { - visible: control.capabilities.climateHvacMode - width: parent.width - spacing: Style.spacing.sm - - Text { - textFormat: Text.PlainText - text: "MODE" - color: control.fg - font.family: control.family - font.pixelSize: Style.font.caption - font.weight: Font.Medium - } - - // HVAC modes are integration-defined. Keep the selector horizontal so - // every advertised mode remains reachable in a narrow panel. - ScrollView { - width: parent.width - implicitHeight: hvacModeGroup.implicitHeight - clip: true - ScrollBar.horizontal.policy: ScrollBar.AlwaysOff - ScrollBar.vertical.policy: ScrollBar.AlwaysOff - - ButtonGroup { - id: hvacModeGroup - focusable: false - foreground: control.fg - fontFamily: control.family - fontSize: Style.font.caption - options: control.hvacModes.map(function(mode) { - return { value: mode, label: Model.capitalize(mode) } - }) - value: control.hvacMode - onChanged: function(mode) { - // Incoming state is authoritative. Only a different user choice - // needs the typed service call. - if (mode !== control.hvacMode) { - control.hass.setClimateHvacMode(control.entityId, mode) - } - } - } - } - } // ---------- single setpoint ---------- Column { @@ -248,5 +205,48 @@ Item { } } } + Column { + visible: control.capabilities.climateHvacMode + width: parent.width + spacing: Style.spacing.sm + + Text { + textFormat: Text.PlainText + text: "MODE" + color: control.fg + font.family: control.family + font.pixelSize: Style.font.caption + font.weight: Font.Medium + } + + // HVAC modes are integration-defined. Keep the selector horizontal so + // every advertised mode remains reachable in a narrow panel. + ScrollView { + width: parent.width + implicitHeight: hvacModeGroup.implicitHeight + clip: true + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + ScrollBar.vertical.policy: ScrollBar.AlwaysOff + + ButtonGroup { + id: hvacModeGroup + focusable: false + foreground: control.fg + fontFamily: control.family + fontSize: Style.font.caption + options: control.hvacModes.map(function(mode) { + return { value: mode, label: Model.capitalize(mode) } + }) + value: control.hvacMode + onChanged: function(mode) { + // Incoming state is authoritative. Only a different user choice + // needs the typed service call. + if (mode !== control.hvacMode) { + control.hass.setClimateHvacMode(control.entityId, mode) + } + } + } + } + } } } From f8c868fe010491b8b2ae5cae6d2ac611a6d72c4f Mon Sep 17 00:00:00 2001 From: Manuel Seeger Date: Mon, 17 Aug 2026 18:41:06 +0200 Subject: [PATCH 4/5] fix(climate): use dropdowns for fan and HVAC modes --- Model.js | 38 +++++++++++++ controls/ClimateControls.qml | 102 +++++++++-------------------------- tests/test_model.js | 14 +++++ 3 files changed, 78 insertions(+), 76 deletions(-) diff --git a/Model.js b/Model.js index 78bdcc9..2c3e9cc 100644 --- a/Model.js +++ b/Model.js @@ -368,6 +368,44 @@ function climateHvacMode(entity) { return stateOf(entity) } +// Home Assistant mode tokens are protocol values, not ready-made UI copy. +// Preserve the token for service calls, but never render separators verbatim. +function humanizeMode(mode) { + var text = cleaned(mode).replace(/[_-]+/g, " ") + return capitalize(text) +} + +function climateHvacModeLabel(mode) { + switch (mode) { + case "off": return "Off" + case "heat": return "Heat" + case "cool": return "Cool" + case "heat_cool": return "Heat/Cool" + case "auto": return "Auto" + case "dry": return "Dry" + case "fan_only": return "Fan only" + default: return humanizeMode(mode) + } +} + +// These are Home Assistant's standard climate fan-mode values. Integrations +// may advertise more, so unknown values use the protocol-token fallback. +function climateFanModeLabel(mode) { + switch (mode) { + case "on": return "On" + case "off": return "Off" + case "auto": return "Auto" + case "low": return "Low" + case "medium": return "Medium" + case "high": return "High" + case "top": return "Top" + case "middle": return "Middle" + case "focus": return "Focus" + case "diffuse": return "Diffuse" + default: return humanizeMode(mode) + } +} + function climateHvacModeData(entity, mode) { var caps = capabilitiesFor(entity) if (!caps.climateHvacMode || typeof mode !== "string") return {} diff --git a/controls/ClimateControls.qml b/controls/ClimateControls.qml index a4bf0f2..b5a3340 100644 --- a/controls/ClimateControls.qml +++ b/controls/ClimateControls.qml @@ -162,89 +162,39 @@ Item { onReleased: function(value) { control.commitRange(false, value) } } - Column { + Dropdown { visible: control.capabilities.climateFanMode width: parent.width - spacing: Style.spacing.sm - - Text { - textFormat: Text.PlainText - text: "FAN" - color: control.fg - font.family: control.family - font.pixelSize: Style.font.caption - font.weight: Font.Medium - } - - // ButtonGroup is a non-wrapping row. Keep every integration-provided - // mode reachable instead of letting a long list escape the panel. - ScrollView { - width: parent.width - implicitHeight: fanModeGroup.implicitHeight - clip: true - ScrollBar.horizontal.policy: ScrollBar.AlwaysOff - ScrollBar.vertical.policy: ScrollBar.AlwaysOff - - ButtonGroup { - id: fanModeGroup - focusable: false - foreground: control.fg - fontFamily: control.family - fontSize: Style.font.caption - options: control.fanModes.map(function(mode) { - return { value: mode, label: Model.capitalize(mode) } - }) - value: control.fanMode - onChanged: function(mode) { - // A state update also changes value. It is already authoritative, - // so only dispatch a user selection that differs from that state. - if (mode !== control.fanMode) { - control.hass.setClimateFanMode(control.entityId, mode) - } - } + label: "FAN" + value: control.fanMode + foreground: control.fg + fontFamily: control.family + options: control.fanModes.map(function(mode) { + return { value: mode, label: Model.climateFanModeLabel(mode) } + }) + onChanged: function(mode) { + // A state update also changes value. It is already authoritative, + // so only dispatch a user selection that differs from that state. + if (mode !== control.fanMode) { + control.hass.setClimateFanMode(control.entityId, mode) } } } - Column { + Dropdown { visible: control.capabilities.climateHvacMode width: parent.width - spacing: Style.spacing.sm - - Text { - textFormat: Text.PlainText - text: "MODE" - color: control.fg - font.family: control.family - font.pixelSize: Style.font.caption - font.weight: Font.Medium - } - - // HVAC modes are integration-defined. Keep the selector horizontal so - // every advertised mode remains reachable in a narrow panel. - ScrollView { - width: parent.width - implicitHeight: hvacModeGroup.implicitHeight - clip: true - ScrollBar.horizontal.policy: ScrollBar.AlwaysOff - ScrollBar.vertical.policy: ScrollBar.AlwaysOff - - ButtonGroup { - id: hvacModeGroup - focusable: false - foreground: control.fg - fontFamily: control.family - fontSize: Style.font.caption - options: control.hvacModes.map(function(mode) { - return { value: mode, label: Model.capitalize(mode) } - }) - value: control.hvacMode - onChanged: function(mode) { - // Incoming state is authoritative. Only a different user choice - // needs the typed service call. - if (mode !== control.hvacMode) { - control.hass.setClimateHvacMode(control.entityId, mode) - } - } + label: "MODE" + value: control.hvacMode + foreground: control.fg + fontFamily: control.family + options: control.hvacModes.map(function(mode) { + return { value: mode, label: Model.climateHvacModeLabel(mode) } + }) + onChanged: function(mode) { + // Incoming state is authoritative. Only a different user choice + // needs the typed service call. + if (mode !== control.hvacMode) { + control.hass.setClimateHvacMode(control.entityId, mode) } } } diff --git a/tests/test_model.js b/tests/test_model.js index 45fdc3d..771ed31 100644 --- a/tests/test_model.js +++ b/tests/test_model.js @@ -204,6 +204,13 @@ section("temperature", () => { Model.climateHvacModeData(hvacEntity, "turbo"), {}); eq("HVAC mode without advertised options is rejected", Model.climateHvacModeData(entity("climate.a", "cool"), "heat"), {}); + eq("standard HVAC modes use readable labels", + ["off", "heat", "cool", "heat_cool", "auto", "dry", "fan_only"].map( + Model.climateHvacModeLabel), + ["Off", "Heat", "Cool", "Heat/Cool", "Auto", "Dry", "Fan only"]); + eq("custom HVAC mode separators are humanized", + Model.climateHvacModeLabel("eco_quiet-mode"), "Eco quiet mode"); + const fanEntity = entity("climate.a", "cool", { supported_features: 8, fan_mode: "medium", @@ -221,6 +228,13 @@ section("temperature", () => { Model.climateFanModeData(entity("climate.a", "cool", { fan_modes: ["auto", "high"] }), "high"), {}); + eq("standard fan modes use readable labels", + ["on", "off", "auto", "low", "medium", "high", "top", "middle", + "focus", "diffuse"].map(Model.climateFanModeLabel), + ["On", "Off", "Auto", "Low", "Medium", "High", "Top", "Middle", + "Focus", "Diffuse"]); + eq("custom fan mode separators are humanized", + Model.climateFanModeLabel("quiet_mode"), "Quiet mode"); eq("crossed target bounds are normalized", Model.climateTemperatureData(rangeEntity, undefined, 27, 16, "°C"), From 586acd436da14438331be9527d30db3cb523ec3b Mon Sep 17 00:00:00 2001 From: Manuel Seeger Date: Mon, 17 Aug 2026 21:36:31 +0200 Subject: [PATCH 5/5] feat(climate): add controls for preset, swing Makes all controls accessible by keyboard. --- EntityRow.qml | 15 +++ Model.js | 138 ++++++++++++++++++--------- Panel.qml | 82 +++++++++++++++- README.md | 10 +- Service.qml | 18 ++++ bin/hass-bridge | 34 +++++-- controls/ClimateControls.qml | 155 +++++++++++++++++++++++++------ controls/ClimateModeSelector.qml | 31 +++++++ tests/test_bridge.py | 26 +++++- tests/test_model.js | 82 ++++++++++------ tests/test_service_contract.py | 8 -- 11 files changed, 468 insertions(+), 131 deletions(-) create mode 100644 controls/ClimateModeSelector.qml diff --git a/EntityRow.qml b/EntityRow.qml index 30c1053..6cdf5c8 100644 --- a/EntityRow.qml +++ b/EntityRow.qml @@ -30,8 +30,19 @@ CursorSurface { signal expandToggled() signal cursorRequested() + signal expandedControlCursorRequested(int index) property bool expanded: false + property int expandedControlCursorIndex: -1 + readonly property int expandedControlCount: row.domain === "climate" + && expansion.item !== null ? expansion.item.selectorCount : 0 + readonly property bool expandedControlPopupOpen: row.domain === "climate" + && expansion.item !== null && expansion.item.popupOpen + + function activateExpandedControl(index) { + if (row.domain !== "climate" || expansion.item === null) return false + return expansion.item.activateSelector(index) + } readonly property color fg: bar ? bar.foreground : Color.foreground readonly property string family: bar ? bar.fontFamily : Style.font.family @@ -260,6 +271,10 @@ CursorSurface { id: climateControls ClimateControls { hass: row.hass; entityId: row.entityId; entity: row.entity; bar: row.bar + selectorCursorIndex: row.expandedControlCursorIndex + onSelectorCursorRequested: function(index) { + row.expandedControlCursorRequested(index) + } } } diff --git a/Model.js b/Model.js index 2c3e9cc..d859dca 100644 --- a/Model.js +++ b/Model.js @@ -188,6 +188,8 @@ var COVER_STOP = 8 var CLIMATE_TARGET_TEMPERATURE = 1 var CLIMATE_TARGET_TEMPERATURE_RANGE = 2 var CLIMATE_FAN_MODE = 8 +var CLIMATE_PRESET_MODE = 16 +var CLIMATE_SWING_MODE = 32 var CLIMATE_TURN_OFF = 128 var CLIMATE_TURN_ON = 256 @@ -229,6 +231,8 @@ function capabilitiesFor(entity) { climateRange: false, climateHvacMode: false, climateFanMode: false, + climatePresetMode: false, + climateSwingMode: false, expandable: false, reserveExpandSlot: false } @@ -250,9 +254,13 @@ function capabilitiesFor(entity) { && typeof a.target_temp_high === "number" result.climateTarget = hasFeature(bits, CLIMATE_TARGET_TEMPERATURE) && typeof a.temperature === "number" - result.climateHvacMode = climateHvacModes(entity).length > 0 + result.climateHvacMode = hasClimateModeOption(entity, "hvac_modes") result.climateFanMode = hasFeature(bits, CLIMATE_FAN_MODE) - && climateFanModes(entity).length > 0 + && hasClimateModeOption(entity, "fan_modes") + result.climatePresetMode = hasFeature(bits, CLIMATE_PRESET_MODE) + && hasClimateModeOption(entity, "preset_modes") + result.climateSwingMode = hasFeature(bits, CLIMATE_SWING_MODE) + && hasClimateModeOption(entity, "swing_modes") } result.expandable = result.brightness @@ -260,6 +268,7 @@ function capabilitiesFor(entity) { || result.mediaVolume || result.coverOpen || result.coverStop || result.coverClose || result.climateTarget || result.climateRange || result.climateHvacMode || result.climateFanMode + || result.climatePresetMode || result.climateSwingMode // Climate integrations commonly clear the live target while the device is // off. Keep the row geometry stable without pretending there is a target // value to edit: the chevron remains hidden/disabled until controls are @@ -353,8 +362,8 @@ function climateTemperatureData(entity, target, low, high, unitFallback) { // HVAC mode is the climate entity state. Unlike optional climate controls, // Home Assistant does not assign it a supported-feature bit; the advertised // `hvac_modes` list is the capability contract for climate.set_hvac_mode. -function climateHvacModes(entity) { - var declared = attrs(entity).hvac_modes +function climateModeOptions(entity, optionsAttribute) { + var declared = attrs(entity)[optionsAttribute] if (!Array.isArray(declared)) return [] var modes = [] for (var i = 0; i < declared.length; i++) { @@ -364,6 +373,46 @@ function climateHvacModes(entity) { return modes } +// Projection only needs the capability bit, not a new option list on every +// state update. Scan the advertised values directly. +function hasClimateModeOption(entity, optionsAttribute) { + var declared = attrs(entity)[optionsAttribute] + if (!Array.isArray(declared)) return false + for (var i = 0; i < declared.length; i++) { + if (typeof declared[i] === "string" && declared[i].trim()) return true + } + return false +} + +function climateModeDeclared(entity, optionsAttribute, mode) { + var declared = attrs(entity)[optionsAttribute] + if (!Array.isArray(declared) || typeof mode !== "string") return false + for (var i = 0; i < declared.length; i++) { + if (declared[i] === mode && mode.trim()) return true + } + return false +} + +function climateAttributeMode(entity, attributeName) { + var mode = attrs(entity)[attributeName] + return typeof mode === "string" ? mode : "" +} + +function climateModeData(entity, mode, featureFlag, optionsAttribute, payloadKey) { + if (domain(entity) !== "climate" || isUnavailable(entity) + || (featureFlag && !hasFeature(featureBits(entity), featureFlag)) + || !climateModeDeclared(entity, optionsAttribute, mode)) { + return {} + } + var data = {} + data[payloadKey] = mode + return data +} + +function climateHvacModes(entity) { + return climateModeOptions(entity, "hvac_modes") +} + function climateHvacMode(entity) { return stateOf(entity) } @@ -376,64 +425,63 @@ function humanizeMode(mode) { } function climateHvacModeLabel(mode) { - switch (mode) { - case "off": return "Off" - case "heat": return "Heat" - case "cool": return "Cool" - case "heat_cool": return "Heat/Cool" - case "auto": return "Auto" - case "dry": return "Dry" - case "fan_only": return "Fan only" - default: return humanizeMode(mode) - } + return mode === "heat_cool" ? "Heat/Cool" : humanizeMode(mode) } -// These are Home Assistant's standard climate fan-mode values. Integrations -// may advertise more, so unknown values use the protocol-token fallback. function climateFanModeLabel(mode) { - switch (mode) { - case "on": return "On" - case "off": return "Off" - case "auto": return "Auto" - case "low": return "Low" - case "medium": return "Medium" - case "high": return "High" - case "top": return "Top" - case "middle": return "Middle" - case "focus": return "Focus" - case "diffuse": return "Diffuse" - default: return humanizeMode(mode) - } + return humanizeMode(mode) } function climateHvacModeData(entity, mode) { - var caps = capabilitiesFor(entity) - if (!caps.climateHvacMode || typeof mode !== "string") return {} - return climateHvacModes(entity).indexOf(mode) === -1 ? {} : { hvac_mode: mode } + return climateModeData(entity, mode, 0, "hvac_modes", "hvac_mode") } // Climate integrations declare every permitted fan-mode token. Preserve tokens // exactly because Home Assistant expects the selected value verbatim. function climateFanModes(entity) { - var declared = attrs(entity).fan_modes - if (!Array.isArray(declared)) return [] - var modes = [] - for (var i = 0; i < declared.length; i++) { - if (typeof declared[i] !== "string" || !declared[i].trim()) continue - if (modes.indexOf(declared[i]) === -1) modes.push(declared[i]) - } - return modes + return climateModeOptions(entity, "fan_modes") } function climateFanMode(entity) { - var mode = attrs(entity).fan_mode - return typeof mode === "string" ? mode : "" + return climateAttributeMode(entity, "fan_mode") } function climateFanModeData(entity, mode) { - var caps = capabilitiesFor(entity) - if (!caps.climateFanMode || typeof mode !== "string") return {} - return climateFanModes(entity).indexOf(mode) === -1 ? {} : { fan_mode: mode } + return climateModeData(entity, mode, CLIMATE_FAN_MODE, "fan_modes", "fan_mode") +} + +function climatePresetModeLabel(mode) { + return humanizeMode(mode) +} + +function climatePresetModes(entity) { + return climateModeOptions(entity, "preset_modes") +} + +function climatePresetMode(entity) { + return climateAttributeMode(entity, "preset_mode") +} + +function climatePresetModeData(entity, mode) { + return climateModeData(entity, mode, CLIMATE_PRESET_MODE, + "preset_modes", "preset_mode") +} + +function climateSwingModeLabel(mode) { + return humanizeMode(mode) +} + +function climateSwingModes(entity) { + return climateModeOptions(entity, "swing_modes") +} + +function climateSwingMode(entity) { + return climateAttributeMode(entity, "swing_mode") +} + +function climateSwingModeData(entity, mode) { + return climateModeData(entity, mode, CLIMATE_SWING_MODE, + "swing_modes", "swing_mode") } diff --git a/Panel.qml b/Panel.qml index 10850ba..e7b0934 100644 --- a/Panel.qml +++ b/Panel.qml @@ -25,6 +25,7 @@ Panel { // Dormant until a key is pressed. property int cursorIndex: 0 property bool cursorActive: false + property int expandedControlCursorIndex: -1 readonly property int rowCount: serviceReady ? hass.rows.count : 0 readonly property bool hasDevices: serviceReady && hass.hasDevices @@ -34,11 +35,53 @@ Panel { expandedEntityId = "" cursorActive = false cursorIndex = 0 + expandedControlCursorIndex = -1 } function moveCursor(delta) { - if (rowCount === 0) return - cursorIndex = Math.max(0, Math.min(rowCount - 1, cursorIndex + delta)) + if (rowCount === 0 || delta === 0) return + var currentPosition = 0 + var total = 0 + for (var i = 0; i < rowCount; i++) { + var item = entityRepeater.itemAt(i) + var controls = item && item.expanded ? item.expandedControlCount : 0 + if (i === cursorIndex) { + var controlOffset = root.expandedControlCursorIndex >= 0 + && root.expandedControlCursorIndex < controls + ? root.expandedControlCursorIndex + 1 : 0 + currentPosition = total + controlOffset + } + total += 1 + controls + } + + var nextPosition = Math.max(0, Math.min(total - 1, currentPosition + delta)) + if (nextPosition === currentPosition) return + for (var rowIndex = 0; rowIndex < rowCount; rowIndex++) { + var row = entityRepeater.itemAt(rowIndex) + var rowControls = row && row.expanded ? row.expandedControlCount : 0 + if (nextPosition === 0) { + cursorIndex = rowIndex + expandedControlCursorIndex = -1 + return + } + if (nextPosition <= rowControls) { + cursorIndex = rowIndex + expandedControlCursorIndex = nextPosition - 1 + return + } + nextPosition -= 1 + rowControls + } + } + + function moveCursorH(delta) { + var item = currentRow() + if (!item || root.expandedControlCursorIndex < 0) { + root.switchTab(delta) + return + } + root.expandedControlCursorIndex = Math.max( + 0, Math.min(item.expandedControlCount - 1, + root.expandedControlCursorIndex + delta)) } function switchTab(delta) { @@ -50,6 +93,7 @@ Panel { var next = (current + delta + tabs.length) % tabs.length hass.setActiveTab(tabs[next].id) cursorIndex = 0 + expandedControlCursorIndex = -1 expandedEntityId = "" } @@ -58,10 +102,25 @@ Panel { if (cursorIndex < 0 || cursorIndex >= items) return null return entityRepeater.itemAt(cursorIndex) } + readonly property bool expandedControlPopupOpen: { + if (!root.expandedEntityId) return false + for (var i = 0; i < entityRepeater.count; i++) { + var item = entityRepeater.itemAt(i) + if (item && item.entityId === root.expandedEntityId) { + return item.expandedControlPopupOpen + } + } + return false + } function activateCursor() { var item = currentRow() - if (item) item.activate() + if (!item) return + if (root.expandedControlCursorIndex >= 0) { + item.activateExpandedControl(root.expandedControlCursorIndex) + } else { + item.activate() + } } // A separate plugin surface, so it goes through the shell. The popup closes @@ -76,6 +135,7 @@ Panel { var item = currentRow() if (!item || !item.expandable) return expandedEntityId = (expandedEntityId === item.entityId) ? "" : item.entityId + expandedControlCursorIndex = -1 } // Colour carries the state, so the button never changes width. @@ -211,13 +271,15 @@ Panel { PanelKeyCatcher { id: keyCatcher anchors.fill: parent + // An open dropdown owns j/k, arrows, Enter, and Escape. + blocked: root.expandedControlPopupOpen onCloseRequested: root.close() onTabRequested: function(direction) { root.switchPanel(direction) } onMoveRequested: function(dx, dy) { // The first key press only wakes the cursor. if (!root.cursorActive) { root.cursorActive = true; return } if (dy !== 0) root.moveCursor(dy) - else if (dx !== 0) root.switchTab(dx) + else if (dx !== 0) root.moveCursorH(dx) } onActivateRequested: if (root.cursorActive) root.activateCursor() onTextKey: function(key) { @@ -288,6 +350,7 @@ Panel { if (!root.serviceReady) return root.hass.setActiveTab(value) root.cursorIndex = 0 + root.expandedControlCursorIndex = -1 root.expandedEntityId = "" } } @@ -438,15 +501,26 @@ Panel { showIcon: root.serviceReady ? root.hass.showEntityIcons : true reserveExpandSlot: root.serviceReady ? root.hass.rowsHaveExpandable : false hasCursor: root.cursorActive && root.cursorIndex === index + && root.expandedControlCursorIndex < 0 expanded: root.expandedEntityId === entityId + expandedControlCursorIndex: root.cursorIndex === index + ? root.expandedControlCursorIndex : -1 onCursorRequested: { root.cursorActive = true root.cursorIndex = index + root.expandedControlCursorIndex = -1 } onExpandToggled: { // One at a time: this is a popup, not a dashboard. root.expandedEntityId = (root.expandedEntityId === entityId) ? "" : entityId + root.expandedControlCursorIndex = -1 + } + onExpandedControlCursorRequested: function(controlIndex) { + if (controlIndex < 0) return + root.cursorActive = true + root.cursorIndex = index + root.expandedControlCursorIndex = controlIndex } } } diff --git a/README.md b/README.md index 83c4806..3d423fd 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,11 @@ Quickshell plugin for **Omarchy 4**. Pick the devices and toggle lights, adjust ## Keyboard -With the panel open: `j`/`k` or arrows move, `←`/`→` switch area tabs, `enter` -turns the highlighted device on or off, `e` expands its controls, `s` opens -settings, `r` refreshes, `esc` closes, `tab` moves to the next bar panel. +With the panel open, `j`/`k` or `↑`/`↓` traverse device rows and their +expanded controls. Inside expanded controls, `←`/`→` move between selectors; +otherwise they switch area tabs. `enter` activates the selected row or opens +the selected dropdown. `e` expands its controls, `s` opens settings, `r` +refreshes, `esc` closes, and `tab` moves to the next bar panel. ## What you can control @@ -32,7 +34,7 @@ settings, `r` refreshes, `esc` closes, `tab` moves to the next bar panel. | `scene`, `script` | Activate button | | `media_player` | Previous / play-pause / next, volume slider | | `cover` | Open / stop / close | -| `climate` | On/off when advertised, plus HVAC-mode, fan-mode, and target-temperature or low/high-band selection when advertised | +| `climate` | On/off when advertised, plus HVAC, fan, preset, swing, and target-temperature or low/high-band controls when advertised | | `sensor`, `binary_sensor`, everything else | State display only | Cameras not yet. diff --git a/Service.qml b/Service.qml index 45eb6f2..4a05c66 100644 --- a/Service.qml +++ b/Service.qml @@ -639,6 +639,24 @@ QtObject { root.callTag(entityId)) } + function setClimatePresetMode(entityId, mode) { + var data = Model.climatePresetModeData(root.states[entityId], mode) + if (Object.keys(data).length === 0) { + return root.rejectAction("This climate entity does not report a controllable preset.") + } + return root.callService("climate", "set_preset_mode", entityId, data, + root.callTag(entityId)) + } + + function setClimateSwingMode(entityId, mode) { + var data = Model.climateSwingModeData(root.states[entityId], mode) + if (Object.keys(data).length === 0) { + return root.rejectAction("This climate entity does not report a controllable swing mode.") + } + return root.callService("climate", "set_swing_mode", entityId, data, + root.callTag(entityId)) + } + function refresh() { root.send({ op: "refresh" }) diff --git a/bin/hass-bridge b/bin/hass-bridge index a49ca2c..289cd0f 100755 --- a/bin/hass-bridge +++ b/bin/hass-bridge @@ -222,6 +222,14 @@ DEMO_PLAYLIST = [ ("Night Routine", "Loft Radio"), ] +# Fixed service contracts for demo-only climate attributes. Never derive a +# service name or payload key from incoming Home Assistant data. +DEMO_CLIMATE_ATTRIBUTE_MODE_SERVICES = { + ("climate", "set_fan_mode"): ("fan_mode", "fan_modes", "fan mode"), + ("climate", "set_preset_mode"): ("preset_mode", "preset_modes", "preset"), + ("climate", "set_swing_mode"): ("swing_mode", "swing_modes", "swing mode"), +} + def demo_initial_states(): def entity(entity_id, state, attributes): @@ -243,9 +251,12 @@ def demo_initial_states(): "hvac_modes": ["off", "heat", "cool", "dry", "fan_only", "auto"], "current_temperature": 21.4, "temperature": 22.0, "fan_mode": "medium", "fan_modes": ["auto", "low", "medium", "high"], + "preset_mode": "none", "preset_modes": ["none", "eco", "away", "boost"], + "swing_mode": "off", "swing_modes": ["off", "vertical", "horizontal", "both"], "target_temp_step": 0.5, "min_temp": 16.0, "max_temp": 30.0, - # TARGET_TEMPERATURE | FAN_MODE | TURN_OFF | TURN_ON - "supported_features": 1 | 8 | 128 | 256}), + # TARGET_TEMPERATURE | FAN_MODE | PRESET_MODE | SWING_MODE | + # TURN_OFF | TURN_ON + "supported_features": 1 | 8 | 16 | 32 | 128 | 256}), entity("media_player.living_room_tv", "playing", { "friendly_name": "Living Room TV", "icon": "mdi:television", "device_class": "tv", "volume_level": 0.42, @@ -440,18 +451,25 @@ class DemoTransport: return self._set_state(entity_id, mode) self._set_attrs(entity_id, {"hvac_action": "off" if mode == "off" else "idle"}) - elif pair == ("climate", "set_fan_mode"): - mode = data.get("fan_mode") - modes = self._states[entity_id]["attributes"].get("fan_modes") or [] - if not isinstance(mode, str) or mode not in modes: - self._fail(msg_id, "Invalid demo climate fan mode.") + elif pair in DEMO_CLIMATE_ATTRIBUTE_MODE_SERVICES: + if not self._set_demo_climate_attribute_mode(msg_id, entity_id, pair, data): return - self._set_attrs(entity_id, {"fan_mode": mode}) else: self._fail(msg_id, "demo backend does not implement %s.%s" % pair) return self._ok(msg_id, None) + def _set_demo_climate_attribute_mode(self, msg_id, entity_id, pair, data): + payload_key, options_attribute, error_noun = ( + DEMO_CLIMATE_ATTRIBUTE_MODE_SERVICES[pair]) + mode = data.get(payload_key) + modes = self._states[entity_id]["attributes"].get(options_attribute) or [] + if not isinstance(mode, str) or mode not in modes: + self._fail(msg_id, "Invalid demo climate %s." % error_noun) + return False + self._set_attrs(entity_id, {payload_key: mode}) + return True + def _advance_playlist(self, entity_id, step): self._playlist_index = (self._playlist_index + step) % len(DEMO_PLAYLIST) diff --git a/controls/ClimateControls.qml b/controls/ClimateControls.qml index b5a3340..574acf3 100644 --- a/controls/ClimateControls.qml +++ b/controls/ClimateControls.qml @@ -1,11 +1,10 @@ import QtQuick -import QtQuick.Controls import qs.Ui import qs.Commons import "../Model.js" as Model -// Target temperature for a climate entity. Two shapes: a single setpoint, or -// a low/high band when the thermostat reports one. +// Temperature and mode controls for a climate entity. Temperature can be a +// single setpoint or a low/high band. Item { id: control @@ -35,6 +34,37 @@ Item { readonly property string hvacMode: entity ? Model.climateHvacMode(entity) : "" readonly property var fanModes: entity ? Model.climateFanModes(entity) : [] readonly property string fanMode: entity ? Model.climateFanMode(entity) : "" + readonly property var presetModes: entity ? Model.climatePresetModes(entity) : [] + readonly property string presetMode: entity ? Model.climatePresetMode(entity) : "" + readonly property var swingModes: entity ? Model.climateSwingModes(entity) : [] + readonly property string swingMode: entity ? Model.climateSwingMode(entity) : "" + readonly property bool popupOpen: fanModeDropdown.popupOpen + || hvacModeDropdown.popupOpen || presetModeDropdown.popupOpen + || swingModeDropdown.popupOpen + property int selectorCursorIndex: -1 + readonly property var activeSelectors: { + var selectors = [ + fanModeDropdown, hvacModeDropdown, presetModeDropdown, swingModeDropdown + ] + var active = [] + for (var i = 0; i < selectors.length; i++) { + if (selectors[i].visible) active.push(selectors[i]) + } + return active + } + readonly property int selectorCount: activeSelectors.length + + signal selectorCursorRequested(int index) + + function selectorIndex(selector) { + return control.activeSelectors.indexOf(selector) + } + + function activateSelector(index) { + if (index < 0 || index >= control.activeSelectors.length) return false + control.activeSelectors[index].toggle() + return true + } function attr(key, fallback) { if (!entity || !entity.attributes) return fallback @@ -162,39 +192,106 @@ Item { onReleased: function(value) { control.commitRange(false, value) } } - Dropdown { + Row { visible: control.capabilities.climateFanMode + || control.capabilities.climateHvacMode width: parent.width - label: "FAN" - value: control.fanMode - foreground: control.fg - fontFamily: control.family - options: control.fanModes.map(function(mode) { - return { value: mode, label: Model.climateFanModeLabel(mode) } - }) - onChanged: function(mode) { - // A state update also changes value. It is already authoritative, - // so only dispatch a user selection that differs from that state. - if (mode !== control.fanMode) { + spacing: Style.spacing.md + + readonly property bool bothSelectors: control.capabilities.climateFanMode + && control.capabilities.climateHvacMode + + ClimateModeSelector { + id: fanModeDropdown + hasCursor: control.selectorCursorIndex + === control.selectorIndex(fanModeDropdown) + visible: control.capabilities.climateFanMode + width: parent.bothSelectors + ? (parent.width - parent.spacing) / 2 : parent.width + label: "FAN" + authoritativeValue: control.fanMode + modes: control.fanModes + modeLabel: function(mode) { return Model.climateFanModeLabel(mode) } + foreground: control.fg + fontFamily: control.family + onModeSelected: function(mode) { control.hass.setClimateFanMode(control.entityId, mode) } + onSelectorHovered: { + control.selectorCursorRequested(control.selectorIndex(fanModeDropdown)) + } + } + + ClimateModeSelector { + id: hvacModeDropdown + hasCursor: control.selectorCursorIndex + === control.selectorIndex(hvacModeDropdown) + visible: control.capabilities.climateHvacMode + width: parent.bothSelectors + ? (parent.width - parent.spacing) / 2 : parent.width + label: "MODE" + authoritativeValue: control.hvacMode + modes: control.hvacModes + modeLabel: function(mode) { return Model.climateHvacModeLabel(mode) } + foreground: control.fg + fontFamily: control.family + onModeSelected: function(mode) { + control.hass.setClimateHvacMode(control.entityId, mode) + } + onSelectorHovered: { + control.selectorCursorRequested(control.selectorIndex(hvacModeDropdown)) + } } } - Dropdown { - visible: control.capabilities.climateHvacMode + + Row { + visible: control.capabilities.climatePresetMode + || control.capabilities.climateSwingMode width: parent.width - label: "MODE" - value: control.hvacMode - foreground: control.fg - fontFamily: control.family - options: control.hvacModes.map(function(mode) { - return { value: mode, label: Model.climateHvacModeLabel(mode) } - }) - onChanged: function(mode) { - // Incoming state is authoritative. Only a different user choice - // needs the typed service call. - if (mode !== control.hvacMode) { - control.hass.setClimateHvacMode(control.entityId, mode) + spacing: Style.spacing.md + + readonly property bool bothSelectors: control.capabilities.climatePresetMode + && control.capabilities.climateSwingMode + + ClimateModeSelector { + id: presetModeDropdown + hasCursor: control.selectorCursorIndex + === control.selectorIndex(presetModeDropdown) + visible: control.capabilities.climatePresetMode + width: parent.bothSelectors + ? (parent.width - parent.spacing) / 2 : parent.width + label: "PRESET" + authoritativeValue: control.presetMode + modes: control.presetModes + modeLabel: function(mode) { return Model.climatePresetModeLabel(mode) } + foreground: control.fg + fontFamily: control.family + onModeSelected: function(mode) { + control.hass.setClimatePresetMode(control.entityId, mode) + } + onSelectorHovered: { + control.selectorCursorRequested(control.selectorIndex(presetModeDropdown)) + } + } + + ClimateModeSelector { + id: swingModeDropdown + hasCursor: control.selectorCursorIndex + === control.selectorIndex(swingModeDropdown) + visible: control.capabilities.climateSwingMode + width: parent.bothSelectors + ? (parent.width - parent.spacing) / 2 : parent.width + label: "SWING" + authoritativeValue: control.swingMode + modes: control.swingModes + modeLabel: function(mode) { return Model.climateSwingModeLabel(mode) } + foreground: control.fg + fontFamily: control.family + onModeSelected: function(mode) { + control.hass.setClimateSwingMode(control.entityId, mode) + } + onSelectorHovered: { + control.selectorCursorRequested(control.selectorIndex(swingModeDropdown)) } } } diff --git a/controls/ClimateModeSelector.qml b/controls/ClimateModeSelector.qml new file mode 100644 index 0000000..890e517 --- /dev/null +++ b/controls/ClimateModeSelector.qml @@ -0,0 +1,31 @@ +import QtQuick +import qs.Ui + +// Shared dropdown mechanics for one typed climate mode selector. Callers keep +// the service method explicit while this restores the authoritative value after +// Dropdown imperatively updates its value. +Dropdown { + id: selector + + required property string authoritativeValue + required property var modes + required property var modeLabel + + signal modeSelected(string mode) + signal selectorHovered() + + value: authoritativeValue + options: modes.map(function(mode) { + return { value: mode, label: modeLabel(mode) } + }) + + onChanged: function(mode) { + selector.value = Qt.binding(function() { + return selector.authoritativeValue + }) + if (mode !== selector.authoritativeValue) selector.modeSelected(mode) + } + onHovered: function(hovered) { + if (hovered) selector.selectorHovered() + } +} diff --git a/tests/test_bridge.py b/tests/test_bridge.py index 57bbec9..5541780 100644 --- a/tests/test_bridge.py +++ b/tests/test_bridge.py @@ -59,10 +59,10 @@ def snapshot(self): with self._lock: return list(self.events) - def wait_for(self, predicate, budget=10.0): + def wait_for(self, predicate, budget=10.0, after=0): end = time.time() + budget while time.time() < end: - for event in self.snapshot(): + for event in self.snapshot()[after:]: if predicate(event): return event time.sleep(0.05) @@ -673,6 +673,24 @@ def test_demo_needs_no_server(): and e["entity"]["attributes"].get("fan_mode") == "high") check("sets the advertised demo climate fan mode", fan_mode is not None, fan_mode) + bridge.send({"op": "call_service", "domain": "climate", "service": "set_preset_mode", + "entity_id": climate_id, "data": {"preset_mode": "away"}, + "tag": "demo-climate-preset"}) + preset_mode = bridge.wait_for( + lambda e: e["ev"] == "state_changed" + and e["entity"]["entity_id"] == climate_id + and e["entity"]["attributes"].get("preset_mode") == "away") + check("sets the advertised demo climate preset", preset_mode is not None, preset_mode) + + bridge.send({"op": "call_service", "domain": "climate", "service": "set_swing_mode", + "entity_id": climate_id, "data": {"swing_mode": "both"}, + "tag": "demo-climate-swing"}) + swing_mode = bridge.wait_for( + lambda e: e["ev"] == "state_changed" + and e["entity"]["entity_id"] == climate_id + and e["entity"]["attributes"].get("swing_mode") == "both") + check("sets the advertised demo climate swing mode", swing_mode is not None, swing_mode) + bridge.send({"op": "call_service", "domain": "cover", "service": "open_cover", "entity_id": "cover.garage_door", "tag": "demo-1"}) @@ -688,9 +706,11 @@ def test_demo_needs_no_server(): check("rejects an unknown demo service", rejected is not None and rejected.get("ok") is False, rejected) + drift_after = len(bridge.snapshot()) drift = bridge.wait_for( lambda e: e["ev"] == "state_changed" - and e["entity"]["entity_id"].startswith("climate."), budget=12) + and e["entity"]["entity_id"].startswith("climate."), + budget=12, after=drift_after) check("emits unprompted events", drift is not None) finally: bridge.stop() diff --git a/tests/test_model.js b/tests/test_model.js index 771ed31..07f76d7 100644 --- a/tests/test_model.js +++ b/tests/test_model.js @@ -204,37 +204,59 @@ section("temperature", () => { Model.climateHvacModeData(hvacEntity, "turbo"), {}); eq("HVAC mode without advertised options is rejected", Model.climateHvacModeData(entity("climate.a", "cool"), "heat"), {}); - eq("standard HVAC modes use readable labels", - ["off", "heat", "cool", "heat_cool", "auto", "dry", "fan_only"].map( - Model.climateHvacModeLabel), - ["Off", "Heat", "Cool", "Heat/Cool", "Auto", "Dry", "Fan only"]); - eq("custom HVAC mode separators are humanized", - Model.climateHvacModeLabel("eco_quiet-mode"), "Eco quiet mode"); - - - const fanEntity = entity("climate.a", "cool", { - supported_features: 8, fan_mode: "medium", - fan_modes: ["auto", "low", "medium", "high"] + eq("heat_cool has its conventional label", + Model.climateHvacModeLabel("heat_cool"), "Heat/Cool"); + eq("custom mode separators are humanized", + Model.climateFanModeLabel("eco_quiet-mode"), "Eco quiet mode"); + + const optionalModeCases = [ + { + name: "fan", capability: "climateFanMode", feature: 8, + optionsAttribute: "fan_modes", currentAttribute: "fan_mode", + selected: "high", options: ["auto", "high"], + modes: Model.climateFanModes, data: Model.climateFanModeData, + payload: { fan_mode: "high" } + }, + { + name: "preset", capability: "climatePresetMode", feature: 16, + optionsAttribute: "preset_modes", currentAttribute: "preset_mode", + selected: "away", options: ["none", "away"], + modes: Model.climatePresetModes, data: Model.climatePresetModeData, + payload: { preset_mode: "away" } + }, + { + name: "swing", capability: "climateSwingMode", feature: 32, + optionsAttribute: "swing_modes", currentAttribute: "swing_mode", + selected: "vertical", options: ["off", "vertical"], + modes: Model.climateSwingModes, data: Model.climateSwingModeData, + payload: { swing_mode: "vertical" } + } + ]; + + optionalModeCases.forEach((modeCase) => { + const attributes = { supported_features: modeCase.feature }; + attributes[modeCase.optionsAttribute] = modeCase.options; + attributes[modeCase.currentAttribute] = modeCase.options[0]; + const modeEntity = entity("climate.a", "cool", attributes); + + eq(`${modeCase.name} modes are preserved`, + modeCase.modes(modeEntity), modeCase.options); + eq(`${modeCase.name} mode needs its feature and options`, + Model.capabilitiesFor(modeEntity)[modeCase.capability], true); + eq(`${modeCase.name} mode is absent without its feature`, + Model.capabilitiesFor(entity("climate.a", "cool", { + [modeCase.optionsAttribute]: modeCase.options + }))[modeCase.capability], false); + eq(`${modeCase.name} mode is absent without advertised options`, + Model.capabilitiesFor(entity("climate.a", "cool", { + supported_features: modeCase.feature + }))[modeCase.capability], false); + eq(`${modeCase.name} payload has only its typed key`, + modeCase.data(modeEntity, modeCase.selected), modeCase.payload); + eq(`${modeCase.name} rejects an undeclared value`, + modeCase.data(modeEntity, "turbo"), {}); }); - eq("advertised climate fan modes are preserved", - Model.climateFanModes(fanEntity), ["auto", "low", "medium", "high"]); - eq("a climate fan mode is supported only with its feature and options", - Model.capabilitiesFor(fanEntity).climateFanMode, true); - eq("the selected advertised fan mode maps to the typed service payload", - Model.climateFanModeData(fanEntity, "high"), { fan_mode: "high" }); - eq("an undeclared fan mode is rejected", - Model.climateFanModeData(fanEntity, "turbo"), {}); - eq("fan options without the advertised feature are rejected", - Model.climateFanModeData(entity("climate.a", "cool", { - fan_modes: ["auto", "high"] - }), "high"), {}); - eq("standard fan modes use readable labels", - ["on", "off", "auto", "low", "medium", "high", "top", "middle", - "focus", "diffuse"].map(Model.climateFanModeLabel), - ["On", "Off", "Auto", "Low", "Medium", "High", "Top", "Middle", - "Focus", "Diffuse"]); - eq("custom fan mode separators are humanized", - Model.climateFanModeLabel("quiet_mode"), "Quiet mode"); + eq("crossed target bounds are normalized", Model.climateTemperatureData(rangeEntity, undefined, 27, 16, "°C"), diff --git a/tests/test_service_contract.py b/tests/test_service_contract.py index 0ac1697..d8ab6bd 100644 --- a/tests/test_service_contract.py +++ b/tests/test_service_contract.py @@ -116,14 +116,6 @@ def main(): check("domain actions validate entity capabilities", service.count("root.capabilities(entityId)") >= 7 and "Model.capabilitiesFor(entity)" in service) - hvac_mode = function_block("setClimateHvacMode") - check("climate HVAC mode calls are capability-validated and typed", - "Model.climateHvacModeData" in hvac_mode - and 'root.callService("climate", "set_hvac_mode"' in hvac_mode) - fan_mode = function_block("setClimateFanMode") - check("climate fan mode calls are capability-validated and typed", - "Model.climateFanModeData" in fan_mode - and 'root.callService("climate", "set_fan_mode"' in fan_mode) check("selected tab persistence is debounced", "selectedTabSaveDebounce.restart()" in service)