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
32 changes: 28 additions & 4 deletions Model.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ function isAvailable(entity) {

function isToggleable(entity) {
return TOGGLEABLE_DOMAINS.indexOf(domain(entity)) !== -1
|| climateCanToggle(entity)
}

function isExpandable(entity) {
Expand All @@ -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"
}

Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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),
Expand All @@ -211,7 +226,8 @@ function capabilitiesFor(entity) {
coverClose: false,
climateTarget: false,
climateRange: false,
expandable: false
expandable: false,
reserveExpandSlot: false
}

if (available && dom === "media_player") {
Expand All @@ -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
}

Expand Down Expand Up @@ -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" }
Expand All @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions RowModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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] || ""
Expand Down
5 changes: 4 additions & 1 deletion Service.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
9 changes: 8 additions & 1 deletion bin/hass-bridge
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
17 changes: 17 additions & 0 deletions tests/test_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
36 changes: 36 additions & 0 deletions tests/test_model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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
Expand All @@ -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),
Expand Down
28 changes: 28 additions & 0 deletions tests/test_row_model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {}
Expand Down
Loading