From 7c4cd2e268e597c6e87f988bc795edb5f40c124f Mon Sep 17 00:00:00 2001 From: Konrad Kruk Date: Sat, 15 Aug 2026 11:44:21 +0200 Subject: [PATCH 1/2] Fix climate on/off controls --- Model.js | 32 ++++++++++++++++++++++++++++---- README.md | 2 +- RowModel.js | 1 + Service.qml | 5 ++++- bin/hass-bridge | 9 ++++++++- tests/test_bridge.py | 17 +++++++++++++++++ tests/test_model.js | 36 ++++++++++++++++++++++++++++++++++++ tests/test_row_model.js | 28 ++++++++++++++++++++++++++++ 8 files changed, 123 insertions(+), 7 deletions(-) diff --git a/Model.js b/Model.js index 9610b6c..298072a 100644 --- a/Model.js +++ b/Model.js @@ -48,6 +48,7 @@ function isAvailable(entity) { function isToggleable(entity) { return TOGGLEABLE_DOMAINS.indexOf(domain(entity)) !== -1 + || climateCanToggle(entity) } function isExpandable(entity) { @@ -56,6 +57,12 @@ function isExpandable(entity) { function isOn(entity) { var state = stateOf(entity) + // A climate entity's state is its HVAC mode, so every real mode except + // `off` means the device is on. It never reports the literal state `on`. + if (domain(entity) === "climate") { + return state !== "" && state !== "off" + && state !== "unavailable" && state !== "unknown" + } return state === "on" || state === "locked" || state === "open" } @@ -180,6 +187,8 @@ var COVER_STOP = 8 var CLIMATE_TARGET_TEMPERATURE = 1 var CLIMATE_TARGET_TEMPERATURE_RANGE = 2 +var CLIMATE_TURN_OFF = 128 +var CLIMATE_TURN_ON = 256 function featureBits(entity) { var value = attrs(entity).supported_features @@ -190,6 +199,12 @@ function hasFeature(bits, flag) { return (bits & flag) === flag } +function climateCanToggle(entity) { + if (domain(entity) !== "climate") return false + var required = stateOf(entity) === "off" ? CLIMATE_TURN_ON : CLIMATE_TURN_OFF + return hasFeature(featureBits(entity), required) +} + function capabilitiesFor(entity) { var dom = domain(entity) var a = attrs(entity) @@ -198,7 +213,7 @@ function capabilitiesFor(entity) { var available = !!entity && (activate || !isUnavailable(entity)) var result = { available: available, - toggle: available && TOGGLEABLE_DOMAINS.indexOf(dom) !== -1, + toggle: available && isToggleable(entity), lock: available && dom === "lock", activate: available && activate, brightness: available && supportsBrightness(entity), @@ -211,7 +226,8 @@ function capabilitiesFor(entity) { coverClose: false, climateTarget: false, climateRange: false, - expandable: false + expandable: false, + reserveExpandSlot: false } if (available && dom === "media_player") { @@ -237,6 +253,14 @@ function capabilitiesFor(entity) { || result.mediaPrevious || result.mediaPlayPause || result.mediaNext || result.mediaVolume || result.coverOpen || result.coverStop || result.coverClose || result.climateTarget || result.climateRange + // 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 + // usable, but its slot is already reserved. + result.reserveExpandSlot = result.expandable + || (!!entity && dom === "climate" + && (hasFeature(bits, CLIMATE_TARGET_TEMPERATURE) + || hasFeature(bits, CLIMATE_TARGET_TEMPERATURE_RANGE))) return result } @@ -386,7 +410,7 @@ function iconFor(entity) { // its semantics; otherwise homeassistant.toggle. function toggleCall(entity, currentlyOn) { var dom = domain(entity) - if (TOGGLEABLE_DOMAINS.indexOf(dom) !== -1) { + if (isToggleable(entity)) { return { domain: dom, service: currentlyOn ? "turn_off" : "turn_on" } } return { domain: "homeassistant", service: "toggle" } @@ -395,7 +419,7 @@ function toggleCall(entity, currentlyOn) { // "toggle" | "lock" (switch calling lock/unlock) | "activate" (one-shot) | "none". function controlKind(entity) { var dom = domain(entity) - if (TOGGLEABLE_DOMAINS.indexOf(dom) !== -1) return "toggle" + if (isToggleable(entity)) return "toggle" if (dom === "lock") return "lock" if (dom === "scene" || dom === "script") return "activate" return "none" diff --git a/README.md b/README.md index 5877fb5..0ca1485 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` | Target temperature, or a low/high band when the thermostat reports one | +| `climate` | On/off when advertised, plus a target temperature or low/high band | | `sensor`, `binary_sensor`, everything else | State display only | Cameras not yet. diff --git a/RowModel.js b/RowModel.js index 52b6eb2..1a38a0e 100644 --- a/RowModel.js +++ b/RowModel.js @@ -20,6 +20,7 @@ function project(entityId, entity, context, model) { pending: context.pending, control: control, expandable: caps.expandable, + reserveExpandSlot: caps.reserveExpandSlot, available: caps.available, areaId: areaId, areaName: (context.areaNames || {})[areaId] || "" diff --git a/Service.qml b/Service.qml index 291a20a..e311835 100644 --- a/Service.qml +++ b/Service.qml @@ -727,7 +727,10 @@ QtObject { function recomputeExpandable() { for (var i = 0; i < rows.count; i++) { - if (rows.get(i).expandable) { root.rowsHaveExpandable = true; return } + if (rows.get(i).reserveExpandSlot) { + root.rowsHaveExpandable = true + return + } } root.rowsHaveExpandable = false } diff --git a/bin/hass-bridge b/bin/hass-bridge index ce5c88d..ccfeb7a 100755 --- a/bin/hass-bridge +++ b/bin/hass-bridge @@ -242,7 +242,8 @@ def demo_initial_states(): "friendly_name": "Living Room Thermostat", "hvac_action": "heating", "current_temperature": 21.4, "temperature": 22.0, "target_temp_step": 0.5, "min_temp": 16.0, "max_temp": 30.0, - "supported_features": 1}), + # TARGET_TEMPERATURE | TURN_OFF | TURN_ON + "supported_features": 1 | 128 | 256}), entity("media_player.living_room_tv", "playing", { "friendly_name": "Living Room TV", "icon": "mdi:television", "device_class": "tv", "volume_level": 0.42, @@ -391,6 +392,12 @@ class DemoTransport: # Home Assistant drops brightness to null on a dark light, which # is exactly when brightness must not be read as "not dimmable". self._set_attrs(entity_id, {"brightness": None, "color_mode": None}) + elif pair == ("climate", "turn_on"): + self._set_state(entity_id, "heat") + self._set_attrs(entity_id, {"hvac_action": "idle"}) + elif pair == ("climate", "turn_off"): + self._set_state(entity_id, "off") + self._set_attrs(entity_id, {"hvac_action": "off"}) elif pair == ("homeassistant", "toggle"): current = (self._states[entity_id].get("state") or "off").lower() nxt = "off" if current in ("on", "playing", "open", "unlocked") else "on" diff --git a/tests/test_bridge.py b/tests/test_bridge.py index b126aa7..3572be8 100644 --- a/tests/test_bridge.py +++ b/tests/test_bridge.py @@ -637,6 +637,23 @@ def test_demo_needs_no_server(): states = bridge.wait_for(lambda e: e["ev"] == "states") check("serves the demo house", states is not None and len(states["entities"]) == 14) + climate_id = "climate.living_room_thermostat" + bridge.send({"op": "call_service", "domain": "climate", "service": "turn_off", + "entity_id": climate_id, "tag": "demo-climate-off"}) + climate_off = bridge.wait_for( + lambda e: e["ev"] == "state_changed" + and e["entity"]["entity_id"] == climate_id + and e["entity"]["state"] == "off") + check("turns off demo climate", climate_off is not None, climate_off) + + bridge.send({"op": "call_service", "domain": "climate", "service": "turn_on", + "entity_id": climate_id, "tag": "demo-climate-on"}) + climate_on = bridge.wait_for( + lambda e: e["ev"] == "state_changed" + and e["entity"]["entity_id"] == climate_id + and e["entity"]["state"] == "heat") + check("turns on demo climate", climate_on is not None, climate_on) + 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 62c984f..e3e3a98 100644 --- a/tests/test_model.js +++ b/tests/test_model.js @@ -58,6 +58,10 @@ section("on/off semantics", () => { eq("off", Model.isOn(entity("light.a", "off")), false); eq("closed is off", Model.isOn(entity("cover.a", "closed")), false); eq("unlocked is off", Model.isOn(entity("lock.a", "unlocked")), false); + eq("a climate HVAC mode is on", Model.isOn(entity("climate.a", "heat")), true); + eq("climate off is off", Model.isOn(entity("climate.a", "off")), false); + eq("unavailable climate is not on", + Model.isOn(entity("climate.a", "unavailable")), false); }); section("state text", () => { @@ -241,6 +245,12 @@ section("demo starter picks match the demo house", () => { section("control classification", () => { eq("light is a toggle", Model.controlKind(entity("light.a", "on")), "toggle"); eq("humidifier is a toggle", Model.controlKind(entity("humidifier.a", "on")), "toggle"); + eq("climate with turn-off support is a toggle", + Model.controlKind(entity("climate.a", "heat", { supported_features: 128 })), + "toggle"); + eq("climate without the required turn-off support is not a toggle", + Model.controlKind(entity("climate.a", "heat", { supported_features: 256 })), + "none"); eq("lock is its own kind", Model.controlKind(entity("lock.a", "locked")), "lock"); eq("scene is one-shot", Model.controlKind(entity("scene.a", "unknown")), "activate"); eq("script is one-shot", Model.controlKind(entity("script.a", "off")), "activate"); @@ -300,6 +310,26 @@ section("entity capabilities", () => { supported_features: 2 })); eq("a missing target range isn't invented", missingRange.climateRange, false); + eq("a missing live climate target still reserves stable row geometry", + missingRange.reserveExpandSlot, true); + eq("a missing live climate target does not enable empty controls", + missingRange.expandable, false); + + const climateOn = Model.capabilitiesFor(entity("climate.a", "heat", { + supported_features: 1 | 128 | 256, temperature: 22 + })); + eq("an active climate entity exposes turn off", climateOn.toggle, true); + + const climateOff = Model.capabilitiesFor(entity("climate.a", "off", { + supported_features: 1 | 128 | 256, temperature: 22 + })); + eq("an off climate entity exposes turn on", climateOff.toggle, true); + + const oneWayClimate = Model.capabilitiesFor(entity("climate.a", "off", { + supported_features: 128 + })); + eq("climate does not invent an unsupported turn-on action", + oneWayClimate.toggle, false); const unavailable = Model.capabilitiesFor(entity("cover.a", "unavailable", { supported_features: 1 | 2 | 8 @@ -314,6 +344,12 @@ section("service calls", () => { Model.toggleCall(entity("light.a", "on"), true), { domain: "light", service: "turn_off" }); eq("a light off turns on", Model.toggleCall(entity("light.a", "off"), false), { domain: "light", service: "turn_on" }); + eq("an active climate entity turns off through its own domain", + Model.toggleCall(entity("climate.a", "heat", { supported_features: 128 }), true), + { domain: "climate", service: "turn_off" }); + eq("an off climate entity turns on through its own domain", + Model.toggleCall(entity("climate.a", "off", { supported_features: 256 }), false), + { domain: "climate", service: "turn_on" }); // Anything outside the known list still gets a sensible attempt. eq("an unknown domain falls back", Model.toggleCall(entity("water_heater.a", "on"), true), diff --git a/tests/test_row_model.js b/tests/test_row_model.js index 4ec26f2..5c1ad14 100644 --- a/tests/test_row_model.js +++ b/tests/test_row_model.js @@ -45,6 +45,34 @@ eq("capabilities control expansion", row.expandable, true); eq("a cover has no primary toggle", row.control, "none"); eq("optimistic state reaches the row", [row.isOn, row.pending], [false, true]); +const climate = { + entity_id: "climate.hallway", + state: "heat", + attributes: { supported_features: 1 | 128 | 256, temperature: 22 } +}; +const climateRow = Rows.project(climate.entity_id, climate, { + name: "Hallway", icon: "T", isOn: true, pending: false, + temperatureUnit: "°C", entityArea: {}, areaNames: {} +}, Model); +eq("climate turn-on/off support projects a primary toggle", + climateRow.control, "toggle"); +eq("climate keeps its temperature expansion", climateRow.expandable, true); + +const climateWithoutTarget = { + entity_id: "climate.hallway", + state: "off", + attributes: { supported_features: 1 | 128 | 256, current_temperature: 21 } +}; +const offClimateRow = Rows.project(climateWithoutTarget.entity_id, + climateWithoutTarget, { + name: "Hallway", icon: "T", isOn: false, pending: false, + temperatureUnit: "°C", entityArea: {}, areaNames: {} + }, Model); +eq("off climate without a target keeps an expansion slot", + offClimateRow.reserveExpandSlot, true); +eq("off climate without a target does not expose an empty expander", + offClimateRow.expandable, false); + const missing = Rows.project("light.missing", null, { name: "light.missing", icon: "?", isOn: false, pending: false, temperatureUnit: "", entityArea: {}, areaNames: {} From dafdd517465c3e06bc4fc67d86067fd21dd0c767 Mon Sep 17 00:00:00 2001 From: Konrad Kruk Date: Sat, 15 Aug 2026 11:49:39 +0200 Subject: [PATCH 2/2] Bump version to 0.2.1 --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index dfb785f..56aef48 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "hass", "name": "Home Assistant", - "version": "0.2.0", + "version": "0.2.1", "author": "Konrad Kruk", "license": "MIT", "description": "View and control Home Assistant devices from the Omarchy bar.",