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 298072a..d859dca 100644 --- a/Model.js +++ b/Model.js @@ -187,6 +187,9 @@ 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 @@ -226,6 +229,10 @@ function capabilitiesFor(entity) { coverClose: false, climateTarget: false, climateRange: false, + climateHvacMode: false, + climateFanMode: false, + climatePresetMode: false, + climateSwingMode: false, expandable: false, reserveExpandSlot: false } @@ -247,12 +254,21 @@ function capabilitiesFor(entity) { && typeof a.target_temp_high === "number" result.climateTarget = hasFeature(bits, CLIMATE_TARGET_TEMPERATURE) && typeof a.temperature === "number" - } + result.climateHvacMode = hasClimateModeOption(entity, "hvac_modes") + result.climateFanMode = hasFeature(bits, CLIMATE_FAN_MODE) + && 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 || result.mediaPrevious || result.mediaPlayPause || result.mediaNext || 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 @@ -343,6 +359,132 @@ 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 climateModeOptions(entity, optionsAttribute) { + var declared = attrs(entity)[optionsAttribute] + 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 +} + +// 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) +} + +// 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) { + return mode === "heat_cool" ? "Heat/Cool" : humanizeMode(mode) +} + +function climateFanModeLabel(mode) { + return humanizeMode(mode) +} + +function climateHvacModeData(entity, 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) { + return climateModeOptions(entity, "fan_modes") +} + +function climateFanMode(entity) { + return climateAttributeMode(entity, "fan_mode") +} + +function climateFanModeData(entity, 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") +} + + // ---------------------------------------------------------------- icons // Material Design Icons, as in Home Assistant's own `mdi:` hints. Codepoints 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 0ca1485..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 a target temperature or low/high band | +| `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 e311835..4a05c66 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( @@ -621,6 +630,34 @@ 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 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 ccfeb7a..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): @@ -240,10 +248,15 @@ 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"], + "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 | TURN_OFF | TURN_ON - "supported_features": 1 | 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, @@ -430,11 +443,33 @@ 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 in DEMO_CLIMATE_ATTRIBUTE_MODE_SERVICES: + if not self._set_demo_climate_attribute_mode(msg_id, entity_id, pair, data): + return 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 78b256c..574acf3 100644 --- a/controls/ClimateControls.qml +++ b/controls/ClimateControls.qml @@ -3,8 +3,8 @@ 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 @@ -30,6 +30,41 @@ 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) : "" + 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 @@ -81,6 +116,7 @@ Item { width: parent.width spacing: Style.spacing.xl + // ---------- single setpoint ---------- Column { visible: control.capabilities.climateTarget && !control.ranged @@ -155,5 +191,109 @@ Item { onMoved: function(value) { control.localHigh = value } onReleased: function(value) { control.commitRange(false, value) } } + + Row { + visible: control.capabilities.climateFanMode + || control.capabilities.climateHvacMode + width: parent.width + 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)) + } + } + } + + Row { + visible: control.capabilities.climatePresetMode + || control.capabilities.climateSwingMode + width: parent.width + 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 3572be8..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) @@ -654,6 +654,44 @@ 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"}) + 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": "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"}) changed = bridge.wait_for( @@ -668,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 e3e3a98..07f76d7 100644 --- a/tests/test_model.js +++ b/tests/test_model.js @@ -188,6 +188,76 @@ section("temperature", () => { supported_features: 2, target_temp_low: 18, target_temp_high: 24, 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"), {}); + 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("crossed target bounds are normalized", Model.climateTemperatureData(rangeEntity, undefined, 27, 16, "°C"), { target_temp_low: 16, target_temp_high: 27 }); @@ -267,6 +337,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",