diff --git a/EntityStore.js b/EntityStore.js index 3e69b5b..1f44574 100644 --- a/EntityStore.js +++ b/EntityStore.js @@ -78,6 +78,11 @@ function projectRegistries(areas, entities, devices) { } var mapping = {} + // Home Assistant keeps per-light favourite colours in the registry entry's + // options, not on the entity state, so they are picked up here rather than + // from the state snapshot. Kept raw: Model validates each entry against the + // light's own capabilities before anything is drawn or sent. + var favorites = {} var entityList = Array.isArray(entities) ? entities : [] for (var e = 0; e < entityList.length; e++) { var entry = entityList[e] @@ -86,8 +91,17 @@ function projectRegistries(areas, entities, devices) { var inheritedArea = safeKey(entry.device_id) ? deviceArea[entry.device_id] : "" var areaId = ownArea || inheritedArea || "" if (areaId) mapping[entry.entity_id] = areaId + + var options = entry.options + var lightOptions = options && typeof options === "object" + ? options.light : null + var saved = lightOptions && typeof lightOptions === "object" + ? lightOptions.favorite_colors : null + if (Array.isArray(saved)) { + favorites[entry.entity_id] = saved + } } - return { areaNames: names, entityArea: mapping } + return { areaNames: names, entityArea: mapping, favoriteColors: favorites } } function sortedIds(states, displayName) { diff --git a/Model.js b/Model.js index d859dca..b18833a 100644 --- a/Model.js +++ b/Model.js @@ -165,6 +165,479 @@ function brightnessPercent(entity) { return Math.min(Math.max(value / 255.0 * 100.0, 0), 100) } +// ---------------------------------------------------------- light colour + +// Every mode here accepts hs_color on the way in — Home Assistant converts to +// the one the light actually speaks — so one wheel drives all of them. +// `color_temp` and `white` produce white light only, and are separate. +var COLOR_MODES = ["hs", "xy", "rgb", "rgbw", "rgbww"] + +// Home Assistant's own fallbacks, for a light that publishes no limits. +var DEFAULT_MIN_KELVIN = 2000 +var DEFAULT_MAX_KELVIN = 6535 + +function clampNumber(value, low, high) { + return Math.min(Math.max(value, low), high) +} + +function colorModes(entity) { + var modes = attrs(entity).supported_color_modes + if (!Array.isArray(modes)) return [] + var out = [] + for (var i = 0; i < modes.length; i++) { + if (typeof modes[i] === "string") out.push(modes[i]) + } + return out +} + +// Capability comes from supported_color_modes, never from a currently non-null +// hs_color: a colour light that is off reports no colour at all, which is +// exactly when someone wants the picker. +function supportsColor(entity) { + if (domain(entity) !== "light") return false + var modes = colorModes(entity) + for (var i = 0; i < modes.length; i++) { + if (COLOR_MODES.indexOf(modes[i]) !== -1) return true + } + return false +} + +function supportsColorTemp(entity) { + if (domain(entity) !== "light") return false + return colorModes(entity).indexOf("color_temp") !== -1 +} + +// { hue: 0-360, saturation: 0-100 }, or null when the light reports no colour. +// Home Assistant publishes hs_color for every colour light whatever its native +// mode — including one in colour-temperature mode, where it derives a hue from +// the temperature — so this one attribute covers xy and rgb lights too. +function hsColor(entity) { + var value = attrs(entity).hs_color + if (!Array.isArray(value) || value.length < 2) return null + var hue = Number(value[0]) + var saturation = Number(value[1]) + if (!isFinite(hue) || !isFinite(saturation)) return null + return { + hue: clampNumber(hue, 0, 360), + saturation: clampNumber(saturation, 0, 100) + } +} + +// Instances before 2022.11 publish mireds instead of kelvin. The ends swap in +// the conversion: the largest mired is the warmest light, so it becomes the +// *minimum* kelvin. +function kelvinRange(entity) { + var a = attrs(entity) + var min = typeof a.min_color_temp_kelvin === "number" + ? a.min_color_temp_kelvin + : (typeof a.max_mireds === "number" && a.max_mireds > 0 + ? Math.round(1000000 / a.max_mireds) : DEFAULT_MIN_KELVIN) + var max = typeof a.max_color_temp_kelvin === "number" + ? a.max_color_temp_kelvin + : (typeof a.min_mireds === "number" && a.min_mireds > 0 + ? Math.round(1000000 / a.min_mireds) : DEFAULT_MAX_KELVIN) + if (!isFinite(min) || !isFinite(max) || min >= max) { + return { min: DEFAULT_MIN_KELVIN, max: DEFAULT_MAX_KELVIN } + } + return { min: min, max: max } +} + +function colorTempKelvin(entity) { + var a = attrs(entity) + if (typeof a.color_temp_kelvin === "number" && isFinite(a.color_temp_kelvin)) { + return a.color_temp_kelvin + } + if (typeof a.color_temp === "number" && a.color_temp > 0) { + return Math.round(1000000 / a.color_temp) + } + return -1 +} + +// True while the light renders white from its colour-temperature channel +// rather than a hue. Only decides which control reads as the live one; both +// stay usable. +function isColorTempActive(entity) { + return cleaned(attrs(entity).color_mode) === "color_temp" +} + +// Home Assistant treats hue as a half-open range: 360 is rejected, and it is +// the same colour as 0 anyway. Round before wrapping, or 359.999 rounds up +// into the value the wrap exists to avoid. +function lightColorData(hue, saturation) { + if (typeof hue !== "number" || !isFinite(hue)) return null + if (typeof saturation !== "number" || !isFinite(saturation)) return null + var rounded = Math.round(hue * 100) / 100 + return { + hs_color: [ + ((rounded % 360) + 360) % 360, + Math.round(clampNumber(saturation, 0, 100) * 100) / 100 + ] + } +} + +function lightColorTempData(entity, kelvin) { + if (typeof kelvin !== "number" || !isFinite(kelvin)) return null + var range = kelvinRange(entity) + return { color_temp_kelvin: Math.round(clampNumber(kelvin, range.min, range.max)) } +} + +// ------------------------------------------------ optimistic reconciliation + +// A control shows the value someone picked until the entity reports it back. +// The tolerances are the cost of the round trip: brightness travels as a +// 0-255 byte, colour temperature as an integer mired, and a light answers in +// whatever colour space it speaks. + +function settledWithin(live, wanted, slack) { + if (typeof live !== "number" || !isFinite(live)) return false + if (typeof wanted !== "number" || !isFinite(wanted)) return false + return Math.abs(live - wanted) <= slack +} + +// Hue is circular: 358 and 2 are four degrees apart, not 356. +function hueGap(one, other) { + var gap = Math.abs(one - other) % 360 + return Math.min(gap, 360 - gap) +} + +function brightnessSettled(entity, percent) { + if (typeof percent !== "number" || !isFinite(percent)) return false + // Zero is a turn_off, and a light that is off publishes no brightness. + if (percent <= 0) return !isOn(entity) + return settledWithin(brightnessPercent(entity), percent, 0.5) +} + +function colorSettled(entity, hue, saturation) { + if (isColorTempActive(entity)) return false + var live = hsColor(entity) + if (!live) return false + if (!settledWithin(live.saturation, saturation, 2)) return false + // Hue survives the light's colour space in proportion to saturation, and at + // the centre of the wheel every angle is the same white. + return hueGap(live.hue, hue) <= Math.min(180, 500 / saturation) +} + +// Compared in mireds, which is what Home Assistant stores: the same kelvin +// tolerance would be four kelvin at the warm end and forty at the cold one. +function colorTempSettled(entity, kelvin) { + if (typeof kelvin !== "number" || !isFinite(kelvin) || kelvin <= 0) return false + if (!isColorTempActive(entity)) return false + var live = colorTempKelvin(entity) + if (live <= 0) return false + return settledWithin(1000000 / live, 1000000 / kelvin, 1) +} + +function volumeSettled(entity, level) { + return settledWithin(volumeLevel(entity), level, 0.01) +} + +// Rounded either side of `round`: float noise must not cost a whole step, nor +// reach the value that gets sent. +function gridPoint(base, step, value, round) { + var steps = Math.round((value - base) / step * 1e6) / 1e6 + return Math.round((base + round(steps) * step) * 1e6) / 1e6 +} + +// A bound comes from the device and need not sit on the grid, so a snapped +// value can land past it. +function snapToStep(value, base, step, min, max, round) { + if (typeof value !== "number" || !isFinite(value)) return value + var clamped = clampNumber(value, min, max) + if (typeof step !== "number" || !isFinite(step) || step <= 0 + || typeof base !== "number" || !isFinite(base)) { + return clamped + } + var snapped = gridPoint(base, step, clamped, round || Math.round) + if (snapped > max) snapped = gridPoint(base, step, max, Math.floor) + if (snapped < min) snapped = gridPoint(base, step, min, Math.ceil) + // Bounds narrower than a step hold no grid point; off the grid beats past a + // limit the device just reported. + if (snapped > max || snapped < min) return clamped + return snapped +} + +function temperatureSettled(entity, attribute, value, step) { + var slack = typeof step === "number" && isFinite(step) && step > 0 + ? step / 2 : 0.25 + return settledWithin(attrs(entity)[attribute], value, slack) +} + +// ------------------------------------------------------------ command tags + +// Identifies the call, not the entity. Reaches IPC and the bridge's logs, so +// it carries nothing but an entity id and a counter. +var CALL_TAG_PREFIX = "call:" + +function callTag(entityId, sequence) { + return CALL_TAG_PREFIX + String(entityId) + ":" + String(sequence) +} + +function isCallTag(tag) { + return String(tag || "").indexOf(CALL_TAG_PREFIX) === 0 +} + +function callTagMatches(pendingTag, failedTag) { + if (!pendingTag || !failedTag) return false + return String(pendingTag) === String(failedTag) +} + +// ------------------------------------------------------- colour conversion + +// Ported from the frontend's temperature2rgb: a temperature swatch has to be +// the colour the app draws, and a second approximation of the curve would not +// be. rgbToHs/hsToRgb/matchMaxScale/rgbw*ToRgb below come from the same place. +function temperatureToRgb(kelvin) { + var t = clampNumber(kelvin, 1000, 40000) / 100 + var red = t <= 66 + ? 255 + : clampNumber(329.698727446 * Math.pow(t - 60, -0.1332047592), 0, 255) + var green = t <= 66 + ? clampNumber(99.4708025861 * Math.log(t) - 161.1195681661, 0, 255) + : clampNumber(288.1221695283 * Math.pow(t - 60, -0.0755148492), 0, 255) + var blue = t >= 66 + ? 255 + : (t <= 19 + ? 0 + : clampNumber(138.5177312231 * Math.log(t - 10) - 305.0447927307, 0, 255)) + return [Math.round(red), Math.round(green), Math.round(blue)] +} + +function rgbToHs(rgb) { + var red = clampNumber(rgb[0], 0, 255) / 255 + var green = clampNumber(rgb[1], 0, 255) / 255 + var blue = clampNumber(rgb[2], 0, 255) / 255 + var high = Math.max(red, green, blue) + var low = Math.min(red, green, blue) + var delta = high - low + + var hue = 0 + if (delta > 0) { + if (high === red) hue = 60 * (((green - blue) / delta) % 6) + else if (high === green) hue = 60 * ((blue - red) / delta + 2) + else hue = 60 * ((red - green) / delta + 4) + } + if (hue < 0) hue += 360 + + return { + hue: Math.round(hue * 100) / 100, + saturation: Math.round((high === 0 ? 0 : delta / high) * 10000) / 100 + } +} + +function hsToRgb(hue, saturation) { + var h = (((hue % 360) + 360) % 360) / 60 + var s = clampNumber(saturation, 0, 100) / 100 + var chroma = s + var second = chroma * (1 - Math.abs((h % 2) - 1)) + var rgb = [0, 0, 0] + if (h < 1) rgb = [chroma, second, 0] + else if (h < 2) rgb = [second, chroma, 0] + else if (h < 3) rgb = [0, chroma, second] + else if (h < 4) rgb = [0, second, chroma] + else if (h < 5) rgb = [second, 0, chroma] + else rgb = [chroma, 0, second] + var offset = 1 - chroma + return [ + Math.round((rgb[0] + offset) * 255), + Math.round((rgb[1] + offset) * 255), + Math.round((rgb[2] + offset) * 255) + ] +} + +// Scales a converted colour so its brightest channel matches the input's, +// which is what keeps the two below from overflowing. +function matchMaxScale(inputs, outputs) { + var maxIn = Math.max.apply(null, inputs) + var maxOut = Math.max.apply(null, outputs) + var factor = maxOut === 0 ? 0 : maxIn / maxOut + var scaled = [] + for (var i = 0; i < outputs.length; i++) { + scaled.push(Math.round(outputs[i] * factor)) + } + return scaled +} + +function rgbwToRgb(rgbw) { + var white = rgbw[3] + return matchMaxScale(rgbw, + [rgbw[0] + white, rgbw[1] + white, rgbw[2] + white]) +} + +function rgbwwToRgb(rgbww, minKelvin, maxKelvin) { + var cold = rgbww[3] + var warm = rgbww[4] + var maxMireds = 1000000 / minKelvin + var minMireds = 1000000 / maxKelvin + var ratio = (cold + warm) === 0 ? 0.5 : warm / (cold + warm) + var mireds = minMireds + ratio * (maxMireds - minMireds) + var white = temperatureToRgb(1000000 / mireds) + var level = Math.max(cold, warm) / 255 + return matchMaxScale(rgbww, [ + rgbww[0] + white[0] * level, + rgbww[1] + white[1] * level, + rgbww[2] + white[2] * level + ]) +} + +function xyToRgb(xy) { + var x = xy[0] + var y = xy[1] + if (!(y > 0)) return [0, 0, 0] + var bigX = x / y + var bigZ = (1 - x - y) / y + var linear = [ + bigX * 3.2406 - 1.5372 - bigZ * 0.4986, + -bigX * 0.9689 + 1.8758 + bigZ * 0.0415, + bigX * 0.0557 - 0.2040 + bigZ * 1.0570 + ] + var peak = Math.max(linear[0], linear[1], linear[2]) + var out = [] + for (var i = 0; i < 3; i++) { + var c = clampNumber(peak > 1 ? linear[i] / peak : linear[i], 0, 1) + out.push(255 * (c <= 0.0031308 + ? 12.92 * c + : 1.055 * Math.pow(c, 1 / 2.4) - 0.055)) + } + return out +} + +// ------------------------------------------------------- favourite colours + +// Home Assistant keeps per-light favourites in the entity registry under +// options.light.favorite_colors, and computes a set for the many lights with +// none saved. Both are mirrored, so the panel offers the app's swatches +// rather than a private palette sitting next to one. +var COLOR_TEMP_COUNT = 4 +var DEFAULT_COLORED_COLORS = [ + [127, 172, 255], + [215, 150, 255], + [255, 158, 243], + [255, 110, 84] +] + +// The registry is server-controlled and unbounded; the app's own editor stops +// well short of this. +var MAX_FAVORITE_COLORS = 24 + +function numberArray(value, length) { + if (!Array.isArray(value) || value.length < length) return null + var out = [] + for (var i = 0; i < length; i++) { + var number = Number(value[i]) + if (!isFinite(number)) return null + out.push(number) + } + return out +} + +// A favourite is normalized into the same hue/saturation or kelvin the wheel +// and the warmth slider produce, never carried around as a raw registry +// object, so a saved favourite cannot become an arbitrary service call. +function hsFavorite(hue, saturation) { + return { + kind: "color", hue: hue, saturation: saturation, kelvin: -1, + rgb: hsToRgb(hue, saturation) + } +} + +function colorFavorite(rgb) { + var hs = rgbToHs(rgb) + return { + kind: "color", hue: hs.hue, saturation: hs.saturation, kelvin: -1, + rgb: [Math.round(clampNumber(rgb[0], 0, 255)), + Math.round(clampNumber(rgb[1], 0, 255)), + Math.round(clampNumber(rgb[2], 0, 255))] + } +} + +function colorTempFavorite(kelvin) { + return { + kind: "colorTemp", hue: -1, saturation: -1, kelvin: Math.round(kelvin), + rgb: temperatureToRgb(kelvin) + } +} + +function parseFavoriteColor(entity, raw) { + if (!raw || typeof raw !== "object") return null + var range = kelvinRange(entity) + + if (typeof raw.color_temp_kelvin === "number" + && isFinite(raw.color_temp_kelvin)) { + if (!supportsColorTemp(entity)) return null + return colorTempFavorite( + clampNumber(raw.color_temp_kelvin, range.min, range.max)) + } + if (!supportsColor(entity)) return null + + // Saved hue and saturation are authoritative; converting them to rgb and + // back would round a low-saturation favourite into a visibly different hue. + var hs = numberArray(raw.hs_color, 2) + if (hs) { + return hsFavorite(clampNumber(hs[0], 0, 360), clampNumber(hs[1], 0, 100)) + } + var rgb = numberArray(raw.rgb_color, 3) + if (rgb) return colorFavorite(rgb) + + var xy = numberArray(raw.xy_color, 2) + if (xy) return colorFavorite(xyToRgb(xy)) + + var rgbw = numberArray(raw.rgbw_color, 4) + if (rgbw) return colorFavorite(rgbwToRgb(rgbw)) + + var rgbww = numberArray(raw.rgbww_color, 5) + if (rgbww) return colorFavorite(rgbwwToRgb(rgbww, range.min, range.max)) + + return null +} + +// The frontend's computeDefaultFavoriteColors: colour temperatures stepped +// across the light's own range when it has one, otherwise the same steps +// rendered as colours, then four fixed picks. The 2000/6500 bounds of the +// colour-only branch are upstream's literals, not the defaults above. +function defaultFavoriteColors(entity) { + var out = [] + var hasTemp = supportsColorTemp(entity) + var hasColor = supportsColor(entity) + + if (hasTemp) { + var range = kelvinRange(entity) + var step = (range.max - range.min) / (COLOR_TEMP_COUNT - 1) + for (var i = 0; i < COLOR_TEMP_COUNT; i++) { + out.push(colorTempFavorite(Math.round(range.min + step * i))) + } + } else if (hasColor) { + var whiteStep = (6500 - 2000) / (COLOR_TEMP_COUNT - 1) + for (var w = 0; w < COLOR_TEMP_COUNT; w++) { + out.push(colorFavorite( + temperatureToRgb(Math.round(2000 + whiteStep * w)))) + } + } + + if (hasColor) { + for (var c = 0; c < DEFAULT_COLORED_COLORS.length; c++) { + out.push(colorFavorite(DEFAULT_COLORED_COLORS[c])) + } + } + return out +} + +// A list in the registry is a choice, whatever it holds: emptied, or filled +// with entries this light cannot render — temperatures kept from before it +// stopped advertising color_temp — it still means "not the defaults". Only an +// absent list is an unanswered question. Every entry is still validated, so a +// saved favourite can never become an arbitrary service call. +function favoriteColors(entity, saved) { + if (!entity || domain(entity) !== "light") return [] + if (!Array.isArray(saved)) return defaultFavoriteColors(entity) + var out = [] + var list = saved.slice(0, MAX_FAVORITE_COLORS) + for (var i = 0; i < list.length; i++) { + var parsed = parseFavoriteColor(entity, list[i]) + if (parsed) out.push(parsed) + } + return out +} + // ---------------------------------------------------------------- media function volumeLevel(entity) { @@ -220,6 +693,8 @@ function capabilitiesFor(entity) { lock: available && dom === "lock", activate: available && activate, brightness: available && supportsBrightness(entity), + color: available && supportsColor(entity), + colorTemp: available && supportsColorTemp(entity), mediaPrevious: false, mediaPlayPause: false, mediaNext: false, @@ -263,7 +738,7 @@ function capabilitiesFor(entity) { && hasClimateModeOption(entity, "swing_modes") } - result.expandable = result.brightness + result.expandable = result.brightness || result.color || result.colorTemp || result.mediaPrevious || result.mediaPlayPause || result.mediaNext || result.mediaVolume || result.coverOpen || result.coverStop || result.coverClose || result.climateTarget || result.climateRange diff --git a/README.md b/README.md index 3d423fd..a0c32dd 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ refreshes, `esc` closes, and `tab` moves to the next bar panel. | Domain | Control | |---|---| -| `light` | On/off, plus a brightness slider when the light is dimmable | +| `light` | On/off, plus a brightness slider when the light is dimmable, colour swatches with hue and saturation when it renders colour, and a warmth slider when it does colour temperature | | `switch`, `fan`, `input_boolean`, `humidifier` | On/off | | `lock` | Lock/unlock switch | | `scene`, `script` | Activate button | diff --git a/Service.qml b/Service.qml index 893e516..096d14f 100644 --- a/Service.qml +++ b/Service.qml @@ -518,8 +518,19 @@ QtObject { // Every call is tagged. An untagged one has its failure dropped on the floor // by the bridge, which is how a rejected scene or a refused cover used to // look exactly like a button that does nothing. + property int callSequence: 0 + function callTag(entityId) { - return "call:" + entityId + root.callSequence++ + return Model.callTag(entityId, root.callSequence) + } + + signal commandFailed(string tag) + + // Returns the tag to match a later failure against, or "" if nothing went out. + function callTagged(domain, service, entityId, data) { + var tag = root.callTag(entityId) + return root.callService(domain, service, entityId, data, tag) ? tag : "" } function setBrightness(entityId, percent) { @@ -527,11 +538,28 @@ QtObject { return root.rejectAction("This light does not support brightness control.") } if (percent <= 0) { - root.callService("light", "turn_off", entityId, {}, root.callTag(entityId)) - return + return root.callTagged("light", "turn_off", entityId, {}) + } + return root.callTagged("light", "turn_on", entityId, + { brightness_pct: Math.round(percent) }) + } + + function setLightColor(entityId, hue, saturation) { + if (!root.capabilities(entityId).color) { + return root.rejectAction("This light does not support colour control.") + } + var data = Model.lightColorData(hue, saturation) + if (!data) return root.rejectAction("Invalid colour value.") + return root.callTagged("light", "turn_on", entityId, data) + } + + function setLightColorTemp(entityId, kelvin) { + if (!root.capabilities(entityId).colorTemp) { + return root.rejectAction("This light does not support colour temperature.") } - root.callService("light", "turn_on", entityId, - { brightness_pct: Math.round(percent) }, root.callTag(entityId)) + var data = Model.lightColorTempData(root.states[entityId], kelvin) + if (!data) return root.rejectAction("Invalid colour temperature.") + return root.callTagged("light", "turn_on", entityId, data) } function setVolume(entityId, level) { @@ -539,32 +567,29 @@ QtObject { return root.rejectAction("This media player does not support volume control.") } var clamped = Math.max(0, Math.min(1, level)) - root.callService("media_player", "volume_set", entityId, - { volume_level: clamped }, root.callTag(entityId)) + return root.callTagged("media_player", "volume_set", entityId, + { volume_level: clamped }) } function mediaPlayPause(entityId) { if (!root.capabilities(entityId).mediaPlayPause) { return root.rejectAction("This media player does not support play/pause.") } - root.callService("media_player", "media_play_pause", entityId, {}, - root.callTag(entityId)) + return root.callTagged("media_player", "media_play_pause", entityId, {}) } function mediaNext(entityId) { if (!root.capabilities(entityId).mediaNext) { return root.rejectAction("This media player does not support next track.") } - root.callService("media_player", "media_next_track", entityId, {}, - root.callTag(entityId)) + return root.callTagged("media_player", "media_next_track", entityId, {}) } function mediaPrevious(entityId) { if (!root.capabilities(entityId).mediaPrevious) { return root.rejectAction("This media player does not support previous track.") } - root.callService("media_player", "media_previous_track", entityId, {}, - root.callTag(entityId)) + return root.callTagged("media_player", "media_previous_track", entityId, {}) } function coverAction(entityId, service) { @@ -574,7 +599,7 @@ QtObject { : service === "close_cover" ? caps.coverClose : false if (!supported) return root.rejectAction("This cover does not support that action.") - root.callService("cover", service, entityId, {}, root.callTag(entityId)) + return root.callTagged("cover", service, entityId, {}) } function setLock(entityId, locked) { @@ -612,7 +637,9 @@ QtObject { return root.rejectAction("Only scenes and scripts can be activated.") } var domain = Model.domainOf(entityId) - return root.callService(domain, "turn_on", entityId, {}, root.callTag(entityId)) + // A tag, not a bool: IPC and the row both read this for truth alone, and + // an unsent call still comes back falsy. + return root.callTagged(domain, "turn_on", entityId, {}) } function setClimateHvacMode(entityId, mode) { @@ -620,8 +647,7 @@ QtObject { 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)) + return root.callTagged("climate", "set_hvac_mode", entityId, data) } function setClimateTemperature(entityId, target, low, high) { @@ -631,8 +657,7 @@ QtObject { if (Object.keys(data).length === 0) { return root.rejectAction("This climate entity does not report a controllable target.") } - root.callService("climate", "set_temperature", entityId, data, - root.callTag(entityId)) + return root.callTagged("climate", "set_temperature", entityId, data) } function setClimateFanMode(entityId, mode) { @@ -640,8 +665,7 @@ QtObject { 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)) + return root.callTagged("climate", "set_fan_mode", entityId, data) } function setClimatePresetMode(entityId, mode) { @@ -649,8 +673,7 @@ QtObject { 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)) + return root.callTagged("climate", "set_preset_mode", entityId, data) } function setClimateSwingMode(entityId, mode) { @@ -658,8 +681,7 @@ QtObject { 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)) + return root.callTagged("climate", "set_swing_mode", entityId, data) } @@ -735,6 +757,8 @@ QtObject { var entityId = tag.slice("toggle:".length) root.clearPendingToggle(entityId) root.refreshRow(entityId) + } else if (Model.isCallTag(tag)) { + root.commandFailed(tag) } root.lastError = event.error || "Command failed." root.lastErrorKind = event.errorKind || "command" @@ -792,9 +816,15 @@ QtObject { event.areas, event.entities, event.devices) root.areaNames = projection.areaNames root.entityArea = projection.entityArea + root.savedFavoriteColors = projection.favoriteColors root.rebuildRows() } + // entity_id -> the light's saved favourite colours, straight from the + // registry. Absent for a light the user has never customized, which is when + // Model falls back to the defaults the Home Assistant app computes. + property var savedFavoriteColors: ({}) + // ------------------------------------------------------------ rows // Attributes the row model does not carry, for the expanded controls. diff --git a/bin/hass-bridge b/bin/hass-bridge index 289cd0f..7bd7e4d 100755 --- a/bin/hass-bridge +++ b/bin/hass-bridge @@ -240,10 +240,15 @@ def demo_initial_states(): # one: brightness support is advertised through supported_color_modes, # climate carries no unit of its own, and the HVAC mode is the state. # A demo that lies about this is how the real instance breaks. + # Colour and colour temperature on one light, brightness only on the + # kitchen pendant below, so the panel's capability gating is exercised + # in both directions without a real bulb. entity("light.living_room_lamp", "on", { "friendly_name": "Living Room Lamp", "icon": "mdi:lightbulb", - "brightness": 180, "color_mode": "brightness", - "supported_color_modes": ["brightness"]}), + "brightness": 180, "color_mode": "hs", + "hs_color": [28.0, 100.0], + "min_color_temp_kelvin": 2000, "max_color_temp_kelvin": 6535, + "supported_color_modes": ["color_temp", "hs"]}), entity("fan.living_room_ceiling_fan", "on", { "friendly_name": "Living Room Ceiling Fan", "icon": "mdi:fan"}), entity("climate.living_room_thermostat", "heat", { @@ -398,6 +403,22 @@ class DemoTransport: pct = max(0.0, min(100.0, float(data["brightness_pct"]))) self._set_attrs(entity_id, { "brightness": round(pct / 100.0 * 255.0)}) + # A real light reports back the mode it ended up in, and nulls + # color_temp_kelvin whenever that mode is not color_temp — which + # is how the panel knows warmth has no live value to show. It + # keeps hs_color either way, deriving one from the temperature. + if domain == "light": + color = data.get("hs_color") + if isinstance(color, list) and len(color) >= 2: + self._set_attrs(entity_id, { + "hs_color": [float(color[0]), float(color[1])], + "color_temp_kelvin": None, + "color_mode": "hs"}) + kelvin = data.get("color_temp_kelvin") + if isinstance(kelvin, (int, float)): + self._set_attrs(entity_id, { + "color_temp_kelvin": int(kelvin), + "color_mode": "color_temp"}) elif service == "turn_off" and domain in ( "light", "switch", "fan", "input_boolean", "humidifier"): self._set_state(entity_id, "off") diff --git a/controls/ClimateControls.qml b/controls/ClimateControls.qml index 574acf3..00291a6 100644 --- a/controls/ClimateControls.qml +++ b/controls/ClimateControls.qml @@ -72,29 +72,47 @@ Item { return typeof value === "number" ? value : fallback } - property real localTarget: -999 - property real localLow: -999 - property real localHigh: -999 - - readonly property real target: localTarget > -999 - ? localTarget : attr("temperature", range.min) - readonly property real low: localLow > -999 - ? localLow : attr("target_temp_low", range.min) - readonly property real high: localHigh > -999 - ? localHigh : attr("target_temp_high", range.max) + PendingValue { id: pendingTarget } + PendingValue { id: pendingLow } + PendingValue { id: pendingHigh } + + readonly property real target: pendingTarget.active + ? pendingTarget.value : attr("temperature", range.min) + readonly property real low: pendingLow.active + ? pendingLow.value : attr("target_temp_low", range.min) + readonly property real high: pendingHigh.active + ? pendingHigh.value : attr("target_temp_high", range.max) + + onEntityChanged: { + var settled = function(pending, attribute) { + return pending.active + && Model.temperatureSettled(control.entity, attribute, + pending.value, control.step) + } + if (settled(pendingTarget, "temperature")) pendingTarget.clear() + if (settled(pendingLow, "target_temp_low")) pendingLow.clear() + if (settled(pendingHigh, "target_temp_high")) pendingHigh.clear() + } function clamp(value) { return Math.max(range.min, Math.min(range.max, value)) } + function snap(value, round) { + return Model.snapToStep(value, range.min, control.step, + range.min, range.max, round) + } + function format(value) { return Model.formatTemp(value, control.unit) } function commitTarget(value) { - control.localTarget = -999 - control.hass.setClimateTemperature(control.entityId, control.clamp(value), - undefined, undefined) + var wanted = control.clamp(value) + var tag = control.hass.setClimateTemperature(control.entityId, wanted, + undefined, undefined) + if (tag) pendingTarget.commit(wanted, tag) + else pendingTarget.clear() } function commitRange(changedLow, value) { @@ -105,10 +123,23 @@ Item { var high = changedLow ? control.high : control.clamp(value) var normalizedLow = Math.min(low, high) var normalizedHigh = Math.max(low, high) - control.localLow = -999 - control.localHigh = -999 - control.hass.setClimateTemperature(control.entityId, undefined, - normalizedLow, normalizedHigh) + var tag = control.hass.setClimateTemperature(control.entityId, undefined, + normalizedLow, normalizedHigh) + // Only the changed end is committed: re-arming the other one's deadline + // would let an end the thermostat never accepted outlive every nudge to + // this one. + var pending = changedLow ? pendingLow : pendingHigh + if (tag) pending.commit(changedLow ? normalizedLow : normalizedHigh, tag) + else pending.clear() + } + + Connections { + target: control.hass + function onCommandFailed(tag) { + pendingTarget.rollback(tag) + pendingLow.rollback(tag) + pendingHigh.rollback(tag) + } } Column { @@ -133,9 +164,13 @@ Item { minimum: control.range.min maximum: control.range.max step: control.step + // The thermostat's own target_temp_step, so a drag is quantized to + // what it can actually hold. + snap: true - onMoved: function(value) { control.localTarget = value } + onMoved: function(value) { pendingTarget.hold(value) } onReleased: function(value) { control.commitTarget(value) } + onCanceled: pendingTarget.clear() } // Nudge buttons under the track, for a precise half-degree that is hard @@ -148,7 +183,8 @@ Item { tooltipText: "Cooler" foreground: control.fg fontFamily: control.family - onClicked: control.commitTarget(control.target - control.step) + onClicked: control.commitTarget( + control.snap(control.target - control.step, Math.ceil)) } PanelActionButton { @@ -156,7 +192,8 @@ Item { tooltipText: "Warmer" foreground: control.fg fontFamily: control.family - onClicked: control.commitTarget(control.target + control.step) + onClicked: control.commitTarget( + control.snap(control.target + control.step, Math.floor)) } } } @@ -172,9 +209,12 @@ Item { minimum: control.range.min maximum: Math.min(control.range.max, control.high) step: control.step + snap: true + stepBase: control.range.min - onMoved: function(value) { control.localLow = value } + onMoved: function(value) { pendingLow.hold(value) } onReleased: function(value) { control.commitRange(true, value) } + onCanceled: pendingLow.clear() } SliderRow { @@ -187,9 +227,13 @@ Item { minimum: Math.max(control.range.min, control.low) maximum: control.range.max step: control.step + snap: true + // This minimum rides the low end, so the default base would move the grid. + stepBase: control.range.min - onMoved: function(value) { control.localHigh = value } + onMoved: function(value) { pendingHigh.hold(value) } onReleased: function(value) { control.commitRange(false, value) } + onCanceled: pendingHigh.clear() } Row { diff --git a/controls/ColorControls.qml b/controls/ColorControls.qml new file mode 100644 index 0000000..e5792d3 --- /dev/null +++ b/controls/ColorControls.qml @@ -0,0 +1,229 @@ +import QtQuick +import qs.Ui +import qs.Commons +import "../Model.js" as Model + +// Colour for a colour-capable light: the light's favourite colours, then a +// hue/saturation wheel for anything not among them. Colour temperature is a +// separate channel on the light rather than a saturation of zero, so it keeps +// its own slider and is only built when the light advertises color_temp. +Column { + id: control + + required property var hass + required property string entityId + required property var caps + property var entity: null + property QtObject bar: null + + readonly property color fg: bar ? bar.foreground : Color.foreground + readonly property string family: bar ? bar.fontFamily : Style.font.family + + readonly property var liveColor: Model.hsColor(control.entity) + readonly property bool whiteActive: Model.isColorTempActive(control.entity) + + readonly property var favorites: Model.favoriteColors( + control.entity, control.hass.savedFavoriteColors[control.entityId]) + + // What the user picked, shown until the light reports it back. Binding + // straight to the entity snaps the knob out from under the cursor. + PendingValue { id: pendingColor } + PendingValue { id: pendingKelvin } + + readonly property real shownHue: + pendingColor.active ? pendingColor.value.hue : (liveColor ? liveColor.hue : 0) + // A light showing white has no hue position of its own, so the knob parks + // at the centre until something is picked. + readonly property real shownSaturation: + pendingColor.active ? pendingColor.value.saturation + : (whiteActive || !liveColor ? 0 : liveColor.saturation) + + readonly property var kelvinLimits: Model.kelvinRange(control.entity) + + // A light rendering a hue reports no colour temperature at all — Home + // Assistant nulls it while color_mode is anything but color_temp. + readonly property real liveKelvin: Model.colorTempKelvin(control.entity) + readonly property bool hasKelvin: liveKelvin >= 0 || pendingKelvin.active + + readonly property real shownKelvin: { + if (pendingKelvin.active) return pendingKelvin.value + if (liveKelvin >= 0) return liveKelvin + // Park mid-range rather than at an end, where a slider with no value + // behind it would read as a real setting of the coldest white. + return (control.kelvinLimits.min + control.kelvinLimits.max) / 2 + } + + // Which channel the swatches highlight against, ahead of the state change + // that will confirm it. + readonly property bool shownWhite: { + if (pendingKelvin.active && pendingColor.active) + return pendingKelvin.pickedAt > pendingColor.pickedAt + if (pendingKelvin.active) return true + if (pendingColor.active) return false + return whiteActive + } + + // Hand back to the light once it reports what was picked. A binding would + // loop here: clearing changes what it reads. + onEntityChanged: { + if (pendingColor.active + && Model.colorSettled(control.entity, pendingColor.value.hue, + pendingColor.value.saturation)) { + pendingColor.clear() + } + if (pendingKelvin.active + && Model.colorTempSettled(control.entity, pendingKelvin.value)) { + pendingKelvin.clear() + } + } + + // Wide enough to survive the rounding on the way to the light and back, and + // narrow enough that two neighbouring favourites never both light up. + function matchesFavorite(favorite) { + if (favorite.kind === "colorTemp") { + return control.shownWhite + && Math.abs(favorite.kelvin - control.shownKelvin) < 150 + } + return !control.shownWhite + && Model.hueGap(favorite.hue, control.shownHue) < 5 + && Math.abs(favorite.saturation - control.shownSaturation) < 8 + } + + function applyFavorite(favorite) { + if (favorite.kind === "colorTemp") { + pendingColor.clear() + var kelvinTag = control.hass.setLightColorTemp(control.entityId, + favorite.kelvin) + if (kelvinTag) pendingKelvin.commit(favorite.kelvin, kelvinTag) + else pendingKelvin.clear() + return + } + pendingKelvin.clear() + var colorTag = control.hass.setLightColor(control.entityId, + favorite.hue, favorite.saturation) + if (colorTag) { + pendingColor.commit({ hue: favorite.hue, saturation: favorite.saturation }, + colorTag) + } else { + pendingColor.clear() + } + } + + Connections { + target: control.hass + function onCommandFailed(tag) { + pendingColor.rollback(tag) + pendingKelvin.rollback(tag) + } + } + + visible: control.caps.color || control.caps.colorTemp + spacing: Style.spacing.lg + + PanelSectionHeader { + text: "COLOUR" + foreground: control.fg + fontFamily: control.family + } + + // Centred, and never wider than it is tall, so the wheel stays a circle + // whatever width the panel is given. Sized from the wheel's actual height, + // not its implicit one, since the panel width can cap it. The extra gap is + // for the circle: the column's own spacing reads as tight under a round + // edge sitting above a row of round swatches. + Item { + width: parent.width + visible: control.caps.color + implicitHeight: picker.height + Style.spacing.md + + ColorWheel { + id: picker + anchors.horizontalCenter: parent.horizontalCenter + width: Math.min(parent.width, implicitWidth) + height: width + knobBorder: control.bar ? control.bar.background : Color.background + + hue: control.shownHue + saturation: control.shownSaturation + + onMoved: function(hue, saturation) { + pendingColor.hold({ hue: hue, saturation: saturation }) + } + onReleased: function(hue, saturation) { + pendingKelvin.clear() + var tag = control.hass.setLightColor(control.entityId, hue, saturation) + if (tag) pendingColor.commit({ hue: hue, saturation: saturation }, tag) + else pendingColor.clear() + } + onCanceled: pendingColor.clear() + } + } + + Flow { + visible: control.favorites.length > 0 + width: parent.width + spacing: Style.spacing.md + + Repeater { + model: control.favorites + + delegate: Rectangle { + id: swatch + required property var modelData + + width: Style.space(22) + height: Style.space(22) + radius: width / 2 + color: Qt.rgba(swatch.modelData.rgb[0] / 255, + swatch.modelData.rgb[1] / 255, + swatch.modelData.rgb[2] / 255, 1) + border.width: Math.max(1, Style.space(2)) + border.color: control.matchesFavorite(swatch.modelData) + ? control.fg : "transparent" + scale: swatchMouse.containsMouse ? 1.15 : 1.0 + + Behavior on scale { + NumberAnimation { duration: 110; easing.type: Easing.OutCubic } + } + + MouseArea { + id: swatchMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: control.applyFavorite(swatch.modelData) + } + + PanelToolTip { + visible: swatchMouse.containsMouse + text: swatch.modelData.kind === "colorTemp" + ? swatch.modelData.kelvin + "K" + : Math.round(swatch.modelData.hue) + "°, " + + Math.round(swatch.modelData.saturation) + "%" + fontFamily: control.family + } + } + } + } + + SliderRow { + width: parent.width + visible: control.caps.colorTemp + bar: control.bar + label: "WARMTH" + valueText: control.hasKelvin ? Math.round(control.shownKelvin) + "K" : "—" + value: control.shownKelvin + minimum: control.kelvinLimits.min + maximum: control.kelvinLimits.max + step: 50 + + onMoved: function(value) { pendingKelvin.hold(value) } + onReleased: function(value) { + pendingColor.clear() + var tag = control.hass.setLightColorTemp(control.entityId, value) + if (tag) pendingKelvin.commit(value, tag) + else pendingKelvin.clear() + } + onCanceled: pendingKelvin.clear() + } +} diff --git a/controls/ColorWheel.qml b/controls/ColorWheel.qml new file mode 100644 index 0000000..ac1cdf0 --- /dev/null +++ b/controls/ColorWheel.qml @@ -0,0 +1,142 @@ +import QtQuick +import qs.Ui +import qs.Commons + +// Hue/saturation wheel: angle around the centre is hue, distance from it is +// saturation, as in the Home Assistant app. +// +// Canvas rather than a shader, because Qt 6 wants shaders precompiled to .qsb +// and this plugin cannot have a build step. Painted once per resize; after +// that only the knob moves. +Item { + id: wheel + + property real hue: 0 + property real saturation: 0 + property color knobBorder: Color.background + + // `moved` fires continuously while dragging, `released` once at the end; + // `canceled` means the grab was taken away and nothing was chosen. + signal moved(real hue, real saturation) + signal released(real hue, real saturation) + signal canceled() + + implicitWidth: Style.space(170) + implicitHeight: implicitWidth + + readonly property real radius: Math.min(width, height) / 2 + readonly property real knobSize: Math.max(Style.space(14), + Math.round(radius * 0.17)) + + Canvas { + id: face + anchors.fill: parent + renderStrategy: Canvas.Threaded + + onPaint: { + var ctx = getContext("2d") + var centreX = width / 2 + var centreY = height / 2 + var r = Math.min(centreX, centreY) + ctx.reset() + if (r <= 0) return + + // One wedge per degree, each drawn wider than its slice: abutting + // wedges leave hairline seams once antialiasing has had its say. + var overlap = 1.5 * Math.PI / 180 + for (var angle = 0; angle < 360; angle++) { + var start = angle * Math.PI / 180 + ctx.beginPath() + ctx.moveTo(centreX, centreY) + ctx.arc(centreX, centreY, r, start, start + overlap) + ctx.closePath() + ctx.fillStyle = Qt.hsva(angle / 360, 1, 1, 1) + ctx.fill() + } + + // Saturation falls off to white at the centre. + var wash = ctx.createRadialGradient(centreX, centreY, 0, centreX, centreY, r) + wash.addColorStop(0, Qt.rgba(1, 1, 1, 1)) + wash.addColorStop(1, Qt.rgba(1, 1, 1, 0)) + ctx.beginPath() + ctx.arc(centreX, centreY, r, 0, 2 * Math.PI) + ctx.closePath() + ctx.fillStyle = wash + ctx.fill() + } + } + + readonly property real knobDistance: (Math.min(100, Math.max(0, saturation)) / 100) + * wheel.radius + readonly property real knobAngle: wheel.hue * Math.PI / 180 + + Rectangle { + id: knob + width: wheel.knobSize + height: wheel.knobSize + radius: width / 2 + color: Qt.hsva(wheel.hue / 360, wheel.saturation / 100, 1, 1) + border.width: Math.max(2, Style.space(2)) + border.color: wheel.knobBorder + x: wheel.width / 2 + Math.cos(wheel.knobAngle) * wheel.knobDistance - width / 2 + y: wheel.height / 2 + Math.sin(wheel.knobAngle) * wheel.knobDistance - height / 2 + scale: pointer.containsMouse || pointer.pressed ? 1.2 : 1.0 + + Behavior on scale { + NumberAnimation { duration: 110; easing.type: Easing.OutCubic } + } + } + + MouseArea { + id: pointer + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + // The panel list is a Flickable; without this it takes the grab as soon as + // a drag strays off the horizontal and the knob is left behind. + preventStealing: true + + function distanceFrom(x, y) { + var dx = x - wheel.width / 2 + var dy = y - wheel.height / 2 + return Math.sqrt(dx * dx + dy * dy) + } + + // Screen coordinates run y-downwards, which makes an increasing atan2 + // sweep clockwise — the direction the app's wheel runs too. + function pick(x, y) { + var angle = Math.atan2(y - wheel.height / 2, x - wheel.width / 2) + * 180 / Math.PI + if (angle < 0) angle += 360 + return { + hue: angle, + saturation: Math.min(100, wheel.radius > 0 + ? distanceFrom(x, y) / wheel.radius * 100 : 0) + } + } + + // The MouseArea is square, so the corners are outside the wheel. A press + // there is not a colour; a drag that strays out keeps its angle and + // clamps, which is how a knob dragged past the rim should behave. + onPressed: function(mouse) { + if (distanceFrom(mouse.x, mouse.y) > wheel.radius) { + mouse.accepted = false + return + } + var picked = pick(mouse.x, mouse.y) + wheel.moved(picked.hue, picked.saturation) + } + onPositionChanged: function(mouse) { + if (!pressed) return + var picked = pick(mouse.x, mouse.y) + wheel.moved(picked.hue, picked.saturation) + } + onReleased: function(mouse) { + var picked = pick(mouse.x, mouse.y) + wheel.released(picked.hue, picked.saturation) + } + // Without this a stolen grab strands the caller's local value, freezing + // the knob at a colour the light is not showing. + onCanceled: wheel.canceled() + } +} diff --git a/controls/LightControls.qml b/controls/LightControls.qml index 7d3cfb1..f16da11 100644 --- a/controls/LightControls.qml +++ b/controls/LightControls.qml @@ -2,8 +2,10 @@ import QtQuick import qs.Commons import "../Model.js" as Model -// Brightness for a dimmable light. -Item { +// Brightness for a dimmable light, and colour for one that can render it. +// Each section is built only when the light advertises the capability, so an +// on/off-only bulb that somehow expands still shows nothing it cannot do. +Column { id: control required property var hass @@ -11,21 +13,37 @@ Item { property var entity: null property QtObject bar: null - implicitHeight: brightness.implicitHeight + readonly property var caps: Model.capabilitiesFor(control.entity) readonly property real level: { var value = entity ? Model.brightnessPercent(entity) : -1 return value < 0 ? 0 : value } - // The slider owns the number while dragging; binding to `level` would snap - // the knob back under the finger between state updates. - property real localValue: -1 - readonly property real shownValue: localValue >= 0 ? localValue : level + // Shown until the light reports it back; binding to `level` would snap the + // knob out from under the cursor. + PendingValue { id: pendingBrightness } + readonly property real shownValue: pendingBrightness.active + ? pendingBrightness.value : level + + onEntityChanged: { + if (pendingBrightness.active + && Model.brightnessSettled(control.entity, pendingBrightness.value)) { + pendingBrightness.clear() + } + } + + Connections { + target: control.hass + function onCommandFailed(tag) { pendingBrightness.rollback(tag) } + } + + spacing: Style.spacing.lg SliderRow { id: brightness width: parent.width + visible: control.caps.brightness bar: control.bar label: "BRIGHTNESS" valueText: Math.round(control.shownValue) + "%" @@ -34,12 +52,25 @@ Item { maximum: 100 step: 1 - onMoved: function(value) { control.localValue = value } + onMoved: function(value) { pendingBrightness.hold(value) } // On release only: a call per pixel floods Home Assistant and makes the // light stutter trying to follow. onReleased: function(value) { - control.localValue = -1 - control.hass.setBrightness(control.entityId, value) + // setBrightness sends whole percent; the value held on screen must match. + var percent = Math.round(value) + var tag = control.hass.setBrightness(control.entityId, percent) + if (tag) pendingBrightness.commit(percent, tag) + else pendingBrightness.clear() } + onCanceled: pendingBrightness.clear() + } + + ColorControls { + width: parent.width + hass: control.hass + entityId: control.entityId + entity: control.entity + caps: control.caps + bar: control.bar } } diff --git a/controls/MediaControls.qml b/controls/MediaControls.qml index a2ff6b1..49a9a34 100644 --- a/controls/MediaControls.qml +++ b/controls/MediaControls.qml @@ -25,8 +25,21 @@ Item { } readonly property bool hasVolume: capabilities.mediaVolume - property real localVolume: -1 - readonly property real shownVolume: localVolume >= 0 ? localVolume : level + PendingValue { id: pendingVolume } + readonly property real shownVolume: pendingVolume.active + ? pendingVolume.value : level + + onEntityChanged: { + if (pendingVolume.active + && Model.volumeSettled(control.entity, pendingVolume.value)) { + pendingVolume.clear() + } + } + + Connections { + target: control.hass + function onCommandFailed(tag) { pendingVolume.rollback(tag) } + } Column { id: column @@ -75,11 +88,13 @@ Item { maximum: 1 step: 0.05 - onMoved: function(value) { control.localVolume = value } + onMoved: function(value) { pendingVolume.hold(value) } onReleased: function(value) { - control.localVolume = -1 - control.hass.setVolume(control.entityId, value) + var tag = control.hass.setVolume(control.entityId, value) + if (tag) pendingVolume.commit(value, tag) + else pendingVolume.clear() } + onCanceled: pendingVolume.clear() } } } diff --git a/controls/PendingValue.qml b/controls/PendingValue.qml new file mode 100644 index 0000000..ff70dba --- /dev/null +++ b/controls/PendingValue.qml @@ -0,0 +1,50 @@ +import QtQuick +import "../Model.js" as Model + +// A value someone has chosen that Home Assistant has not confirmed yet. Held +// so a control never falls back to the stale entity value between the command +// going out and the state coming back; `holdMs` bounds the wait in case it +// never does. +QtObject { + id: pending + + // null means nothing pending; otherwise a number or an object. + property var value: null + readonly property bool active: value !== null + property int holdMs: 6500 + + // Lets a control holding two of these tell which one was touched last. + property real pickedAt: 0 + + property string tag: "" + + // Mid-gesture: nothing sent, so no deadline. + function hold(next) { + expiry.stop() + pending.tag = "" + pending.pickedAt = Date.now() + pending.value = next + } + + function commit(next, tag) { + pending.tag = String(tag || "") + pending.pickedAt = Date.now() + pending.value = next + expiry.restart() + } + + function clear() { + expiry.stop() + pending.tag = "" + pending.value = null + } + + function rollback(failedTag) { + if (Model.callTagMatches(pending.tag, failedTag)) pending.clear() + } + + property Timer expiry: Timer { + interval: pending.holdMs + onTriggered: pending.clear() + } +} diff --git a/controls/SliderRow.qml b/controls/SliderRow.qml index 209602d..05e4995 100644 --- a/controls/SliderRow.qml +++ b/controls/SliderRow.qml @@ -1,6 +1,7 @@ import QtQuick import qs.Ui import qs.Commons +import "../Model.js" as Model // Labelled slider in the shape the audio panel uses: a section header on the // left, the live value on the right, and the track on its own line below at @@ -16,12 +17,18 @@ Column { property real minimum: 0 property real maximum: 1 property real step: 0.05 + // Off by default: quantizing a drag is right only where the step is the + // device's own granularity. Where it is a UI nudge size it just makes the + // track coarse — a 5% volume step leaves twenty-one reachable positions. + property bool snap: false + property real stepBase: minimum readonly property color fg: bar ? bar.foreground : Color.foreground readonly property string family: bar ? bar.fontFamily : Style.font.family signal moved(real value) signal released(real value) + signal canceled() spacing: Style.spacing.sm @@ -52,16 +59,70 @@ Column { } } - PanelSlider { - id: slider + // The gesture is handled above PanelSlider rather than by it: the panel list + // is a Flickable, and a drag that wanders a few pixels off the horizontal is + // otherwise taken for a scroll, stealing the grab mid-drag. Only the area + // holding the grab can refuse that, and PanelSlider's is private. + Item { width: parent.width - bar: sliderRow.bar - minimum: sliderRow.minimum - maximum: sliderRow.maximum - step: sliderRow.step - value: sliderRow.value + implicitHeight: slider.implicitHeight + + PanelSlider { + id: slider + anchors.fill: parent + bar: sliderRow.bar + minimum: sliderRow.minimum + maximum: sliderRow.maximum + step: sliderRow.step + value: sliderRow.value + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + preventStealing: true - onMoved: function(value) { sliderRow.moved(value) } - onReleased: function(value) { sliderRow.released(value) } + // Step 0 is snapToStep's pass-through: an unsnapped slider is still clamped. + function settle(value) { + return Model.snapToStep(value, sliderRow.stepBase, + sliderRow.snap ? sliderRow.step : 0, + sliderRow.minimum, sliderRow.maximum) + } + + function valueAt(x) { + var span = Math.max(0.0001, sliderRow.maximum - sliderRow.minimum) + var fraction = width > 0 ? Math.max(0, Math.min(1, x / width)) : 0 + return settle(sliderRow.minimum + fraction * span) + } + + function track(x) { + var next = valueAt(x) + slider.liveValue = next + sliderRow.moved(next) + } + + onPressed: function(mouse) { + slider.dragging = true + track(mouse.x) + } + onPositionChanged: function(mouse) { + if (slider.dragging) track(mouse.x) + } + onReleased: function(mouse) { + if (!slider.dragging) return + slider.dragging = false + sliderRow.released(valueAt(mouse.x)) + } + onCanceled: { + slider.dragging = false + sliderRow.canceled() + } + onWheel: function(wheel) { + var delta = wheel.angleDelta.y > 0 ? sliderRow.step : -sliderRow.step + var next = settle(sliderRow.value + delta) + sliderRow.moved(next) + sliderRow.released(next) + } + } } } diff --git a/tests/fake_ha.py b/tests/fake_ha.py index 7f73b9c..eb123b5 100644 --- a/tests/fake_ha.py +++ b/tests/fake_ha.py @@ -255,7 +255,14 @@ def _handle(self, conn, msg, index): "result": [{"area_id": "a1", "name": "Area One"}]}) elif kind == "config/entity_registry/list": send_json(conn, {"id": msg_id, "type": "result", "success": True, - "result": [{"entity_id": "light.test", "device_id": "d1"}]}) + "result": [{ + "entity_id": "light.test", "device_id": "d1", + # Saved favourite colours ride along in the + # registry entry's options, which is the only + # place Home Assistant publishes them. + "options": {"light": {"favorite_colors": [ + {"color_temp_kelvin": 2700}, + {"rgb_color": [255, 110, 84]}]}}}]}) elif kind == "config/device_registry/list": send_json(conn, {"id": msg_id, "type": "result", "success": True, "result": [{"id": "d1", "area_id": "a1"}]}) diff --git a/tests/test_model.js b/tests/test_model.js index 07f76d7..73d28af 100644 --- a/tests/test_model.js +++ b/tests/test_model.js @@ -477,6 +477,509 @@ section("attribute redaction", () => { Model.redactAttributes(null), {}); }); +section("light colour capability", () => { + // A Philips Hue colour strip reports exactly this pair. + const strip = entity("light.strip", "on", + { supported_color_modes: ["color_temp", "xy"] }); + eq("an xy light supports colour", Model.supportsColor(strip), true); + eq("the same light supports colour temperature", + Model.supportsColorTemp(strip), true); + + eq("hs is a colour mode", Model.supportsColor(entity("light.a", "on", + { supported_color_modes: ["hs"] })), true); + eq("rgbww is a colour mode", Model.supportsColor(entity("light.a", "on", + { supported_color_modes: ["rgbww"] })), true); + + // color_temp and white produce white light only. Treating either as colour + // would put a hue slider on a tunable-white bulb that cannot act on it. + eq("colour temperature alone is not colour", + Model.supportsColor(entity("light.a", "on", + { supported_color_modes: ["color_temp"] })), false); + eq("white alone is not colour", Model.supportsColor(entity("light.a", "on", + { supported_color_modes: ["white"] })), false); + eq("a dimmable-only light has no colour", + Model.supportsColor(entity("light.a", "on", + { supported_color_modes: ["brightness"] })), false); + eq("colour is a light-only capability", + Model.supportsColor(entity("switch.a", "on", + { supported_color_modes: ["hs"] })), false); + + // The capability has to come from supported_color_modes, because a colour + // light that is off reports no hs_color at all — which is the moment the + // picker is wanted. + const off = entity("light.strip", "off", + { supported_color_modes: ["color_temp", "xy"], hs_color: null }); + eq("a light that is off still advertises colour", + Model.supportsColor(off), true); + eq("a light that is off reports no live colour", Model.hsColor(off), null); +}); + +section("light colour values", () => { + eq("hs_color is read as hue and saturation", + Model.hsColor(entity("light.a", "on", { hs_color: [28.5, 100] })), + { hue: 28.5, saturation: 100 }); + eq("out-of-range values are clamped rather than trusted", + Model.hsColor(entity("light.a", "on", { hs_color: [400, 140] })), + { hue: 360, saturation: 100 }); + eq("a malformed hs_color is no colour", + Model.hsColor(entity("light.a", "on", { hs_color: ["red"] })), null); + eq("a missing hs_color is no colour", + Model.hsColor(entity("light.a", "on")), null); + + eq("colour temperature mode is detected", + Model.isColorTempActive(entity("light.a", "on", + { color_mode: "color_temp" })), true); + eq("hue mode is not colour temperature mode", + Model.isColorTempActive(entity("light.a", "on", { color_mode: "hs" })), + false); +}); + +section("colour temperature range", () => { + eq("declared kelvin limits are used as-is", + Model.kelvinRange(entity("light.a", "on", + { min_color_temp_kelvin: 2202, max_color_temp_kelvin: 4000 })), + { min: 2202, max: 4000 }); + + // Pre-2022.11 instances publish mireds only, and the ends swap: the largest + // mired value is the warmest light and therefore the lowest kelvin. + eq("mireds are converted and the ends swapped", + Model.kelvinRange(entity("light.a", "on", + { min_mireds: 153, max_mireds: 500 })), + { min: 2000, max: 6536 }); + + eq("a light with no limits falls back to the Home Assistant defaults", + Model.kelvinRange(entity("light.a", "on")), { min: 2000, max: 6535 }); + eq("a nonsense range falls back rather than inverting the slider", + Model.kelvinRange(entity("light.a", "on", + { min_color_temp_kelvin: 5000, max_color_temp_kelvin: 2000 })), + { min: 2000, max: 6535 }); + + eq("kelvin is read directly when published", + Model.colorTempKelvin(entity("light.a", "on", + { color_temp_kelvin: 2700 })), 2700); + eq("mireds are converted to kelvin", + Model.colorTempKelvin(entity("light.a", "on", { color_temp: 370 })), 2703); + eq("no colour temperature is signalled with -1", + Model.colorTempKelvin(entity("light.a", "on")), -1); +}); + +section("colour service payloads", () => { + eq("a hue and saturation become hs_color", + Model.lightColorData(28, 100), { hs_color: [28, 100] }); + + // Home Assistant rejects a hue of exactly 360, which is the same colour as 0. + eq("360 degrees wraps to 0", Model.lightColorData(360, 80), + { hs_color: [0, 80] }); + // Rounding runs before the wrap, or a hue a hair under 360 rounds up into + // the value the wrap exists to avoid. The wheel emits continuous angles. + eq("a hue that rounds up to 360 still wraps", + Model.lightColorData(359.999, 80), { hs_color: [0, 80] }); + eq("a negative hue wraps forward", Model.lightColorData(-10, 80), + { hs_color: [350, 80] }); + eq("saturation is clamped", Model.lightColorData(10, 140), + { hs_color: [10, 100] }); + eq("a non-numeric hue produces no call", Model.lightColorData("red", 50), + null); + eq("a non-finite hue produces no call", Model.lightColorData(Infinity, 50), + null); + + const strip = entity("light.strip", "on", + { min_color_temp_kelvin: 2202, max_color_temp_kelvin: 4000 }); + eq("kelvin is clamped into the light's own range", + Model.lightColorTempData(strip, 6500), { color_temp_kelvin: 4000 }); + eq("kelvin below the range is clamped up", + Model.lightColorTempData(strip, 1000), { color_temp_kelvin: 2202 }); + eq("a non-numeric kelvin produces no call", + Model.lightColorTempData(strip, "warm"), null); +}); + +section("colour conversion", () => { + // Anchors from the frontend's own temperature2rgb curve. + eq("a warm temperature is orange", Model.temperatureToRgb(2000), + [255, 137, 14]); + eq("6500K is near white", Model.temperatureToRgb(6500), [255, 254, 250]); + eq("above 6600K the blue channel saturates", + Model.temperatureToRgb(10000)[2], 255); + + eq("pure red converts to hue 0", Model.rgbToHs([255, 0, 0]), + { hue: 0, saturation: 100 }); + eq("pure green converts to hue 120", Model.rgbToHs([0, 255, 0]), + { hue: 120, saturation: 100 }); + eq("white has no saturation", Model.rgbToHs([255, 255, 255]), + { hue: 0, saturation: 0 }); + eq("black has no saturation", Model.rgbToHs([0, 0, 0]), + { hue: 0, saturation: 0 }); + + eq("hue 0 renders red", Model.hsToRgb(0, 100), [255, 0, 0]); + eq("hue 240 renders blue", Model.hsToRgb(240, 100), [0, 0, 255]); + eq("no saturation renders white", Model.hsToRgb(200, 0), [255, 255, 255]); + + // The favourites round-trip through both directions, so they have to agree. + const roundTrip = Model.rgbToHs(Model.hsToRgb(210, 60)); + eq("hue survives a round trip", Math.round(roundTrip.hue), 210); + eq("saturation survives a round trip", Math.round(roundTrip.saturation), 60); + + // A white channel lifts the colour without letting it overflow past 255. + eq("rgbw folds the white channel in", Model.rgbwToRgb([255, 0, 0, 255]), + [255, 128, 128]); + + // rgbww carries a cold and a warm white; their ratio picks a temperature + // between the light's limits, which is then folded in like the rgbw white. + eq("an all-cold rgbww renders the top of the range", + Model.rgbwwToRgb([0, 0, 0, 255, 0], 2000, 6535), + Model.temperatureToRgb(6535)); + eq("an all-warm rgbww renders the bottom of it", + Model.rgbwwToRgb([0, 0, 0, 0, 255], 2000, 6535), + Model.temperatureToRgb(2000)); + // Even channels interpolate in mireds, not kelvin, so the midpoint is + // 3063K rather than 4267K. + eq("a balanced rgbww sits between the two", + Model.rgbwwToRgb([0, 0, 0, 255, 255], 2000, 6535), + [255, 179, 114]); + eq("rgbww with no white channels keeps the colour", + Model.rgbwwToRgb([255, 0, 0, 0, 0], 2000, 6535), [255, 0, 0]); +}); + +section("favourite colours", () => { + // A Philips Hue colour strip: colour and colour temperature. + const strip = entity("light.strip", "on", { + supported_color_modes: ["color_temp", "xy"], + min_color_temp_kelvin: 2000, max_color_temp_kelvin: 6535 + }); + + // With nothing saved, Home Assistant computes four colour temperatures + // stepped across the light's own range, then four fixed colours. The panel + // has to show the same eight, in the same order, as the app. + const defaults = Model.favoriteColors(strip, null); + eq("a light with no saved favourites gets eight", defaults.length, 8); + eq("the first four are colour temperatures", + defaults.slice(0, 4).map((f) => f.kind), + ["colorTemp", "colorTemp", "colorTemp", "colorTemp"]); + eq("they step across the light's own range", + defaults.slice(0, 4).map((f) => f.kelvin), [2000, 3512, 5023, 6535]); + eq("the last four are colours", + defaults.slice(4).map((f) => f.kind), + ["color", "color", "color", "color"]); + eq("and are the frontend's fixed picks", + defaults.slice(4).map((f) => f.rgb), + [[127, 172, 255], [215, 150, 255], [255, 158, 243], [255, 110, 84]]); + + // Without colour temperature the same four whites are sent as colours, + // because that is the only channel the light has to render them on. + const colorOnly = entity("light.c", "on", { supported_color_modes: ["hs"] }); + const colorDefaults = Model.favoriteColors(colorOnly, null); + eq("a colour-only light still gets eight", colorDefaults.length, 8); + eq("none of them are colour temperatures", + colorDefaults.every((f) => f.kind === "color"), true); + + // A tunable white gets the temperatures and nothing else — offering a + // colour it cannot render would send a call it has to reject. + const whiteOnly = entity("light.w", "on", + { supported_color_modes: ["color_temp"], + min_color_temp_kelvin: 2200, max_color_temp_kelvin: 4000 }); + const whiteDefaults = Model.favoriteColors(whiteOnly, null); + eq("a tunable white gets only temperatures", whiteDefaults.length, 4); + eq("bounded by its own range", + [whiteDefaults[0].kelvin, whiteDefaults[3].kelvin], [2200, 4000]); + + eq("a non-light has no favourites", + Model.favoriteColors(entity("switch.a", "on"), null), []); +}); + +section("saved favourite colours", () => { + const strip = entity("light.strip", "on", { + supported_color_modes: ["color_temp", "xy"], + min_color_temp_kelvin: 2000, max_color_temp_kelvin: 6535 + }); + + const saved = Model.favoriteColors(strip, [ + { color_temp_kelvin: 2700 }, + { rgb_color: [255, 110, 84] }, + { hs_color: [120, 100] } + ]); + eq("saved favourites replace the defaults", saved.length, 3); + eq("a saved temperature keeps its kelvin", saved[0].kelvin, 2700); + eq("a saved temperature carries a drawable swatch", saved[0].rgb, + Model.temperatureToRgb(2700)); + eq("a saved rgb becomes hue and saturation", + [Math.round(saved[1].hue), Math.round(saved[1].saturation)], [9, 67]); + const xy = Model.favoriteColors(strip, [{ xy_color: [0.7, 0.3] }]); + eq("a saved xy_color produces a swatch", xy.length, 1); + eq("a saved xy_color becomes hue and saturation", + [Math.round(xy[0].hue), Math.round(xy[0].saturation)], [0, 100]); + + eq("a saved hs_color survives as itself", + [Math.round(saved[2].hue), Math.round(saved[2].saturation)], [120, 100]); + + // Exactly, not approximately: converting to rgb and back would round a + // pale favourite through three bytes and shift its hue several degrees. + const pale = Model.favoriteColors(strip, [{ hs_color: [30, 2] }]); + eq("a pale saved hs_color keeps its exact hue", + [pale[0].hue, pale[0].saturation], [30, 2]); + eq("and still draws a swatch", pale[0].rgb, Model.hsToRgb(30, 2)); + + // The registry is server-controlled and unbounded, but the Repeater that + // draws these is not. + const many = []; + for (let i = 0; i < 200; i++) many.push({ rgb_color: [0, 0, 255] }); + eq("an absurd saved list is capped", + Model.favoriteColors(strip, many).length, 24); + + // Registry contents are server-controlled, so a malformed entry must be + // dropped rather than drawn or sent. + const messy = Model.favoriteColors(strip, [ + { rgb_color: ["red", 0, 0] }, { nonsense: true }, null, "blue", + { rgb_color: [0, 0, 255] } + ]); + eq("malformed favourites are dropped", messy.length, 1); + eq("the survivor is the valid one", messy[0].rgb, [0, 0, 255]); + + eq("an emptied saved list draws no swatches", + Model.favoriteColors(strip, []).length, 0); + eq("an unset saved list falls back to the defaults", + Model.favoriteColors(strip, null).length, 8); + + // A temperature favourite copied onto a light with no white channel would + // produce a call the light must reject. + const colorOnly = entity("light.c", "on", { supported_color_modes: ["hs"] }); + eq("a temperature favourite is dropped on a colour-only light", + Model.favoriteColors(colorOnly, [{ color_temp_kelvin: 2700 }]).length, 0); + + const whiteOnly = entity("light.w", "on", + { supported_color_modes: ["color_temp"] }); + eq("a colour favourite is dropped on a tunable white", + Model.favoriteColors(whiteOnly, [{ rgb_color: [255, 0, 0] }]).length, 0); + eq("an xy favourite is dropped on a tunable white", + Model.favoriteColors(whiteOnly, [{ xy_color: [0.7, 0.3] }]).length, 0); + + // The list is the answer even when none of it survives validation: a light + // that stopped advertising color_temp keeps whatever the user chose, minus + // the entries it can no longer render. Reinstating eight defaults would + // hand back picks that were replaced. + eq("a saved list nothing survives still means no defaults", + Model.favoriteColors(whiteOnly, [ + { rgb_color: [255, 0, 0] }, { hs_color: [120, 100] }, { nonsense: true } + ]).length, 0); + + // Clamping is the model's job, not the light's. + const clamped = Model.favoriteColors(strip, [{ color_temp_kelvin: 99000 }]); + eq("a saved temperature is clamped into range", clamped[0].kelvin, 6535); +}); + +section("colour capabilities and expansion", () => { + const strip = Model.capabilitiesFor(entity("light.strip", "on", + { supported_color_modes: ["color_temp", "xy"] })); + eq("a colour strip reports colour", strip.color, true); + eq("a colour strip reports colour temperature", strip.colorTemp, true); + eq("a colour strip is expandable", strip.expandable, true); + + const plain = Model.capabilitiesFor(entity("light.a", "on", + { supported_color_modes: ["onoff"] })); + eq("an on/off light has no colour", plain.color, false); + eq("an on/off light has no colour temperature", plain.colorTemp, false); + eq("an on/off light is not expandable", plain.expandable, false); + + // Unavailable entities must not offer controls that would send a command. + const gone = Model.capabilitiesFor(entity("light.strip", "unavailable", + { supported_color_modes: ["hs"] })); + eq("an unavailable light offers no colour control", gone.color, false); +}); + +section("optimistic reconciliation", () => { + const lit = (attributes) => entity("light.a", "on", attributes); + + eq("brightness settles on the byte it rounded to", + Model.brightnessSettled(lit({ brightness: 128 }), 50), true); + eq("a different brightness does not settle it", + Model.brightnessSettled(lit({ brightness: 128 }), 70), false); + eq("zero settles once the light is off", + Model.brightnessSettled(entity("light.a", "off"), 0), true); + eq("zero does not settle while the light is on", + Model.brightnessSettled(lit({ brightness: 128 }), 0), false); + // One slider step must not settle against the value the light still holds, + // or the knob snaps back to it. + eq("a brightness one step away does not settle it", + Model.brightnessSettled(lit({ brightness: 128 }), 51), false); + eq("a positive brightness never settles against an off light", + Model.brightnessSettled(entity("light.a", "off"), 50), false); + + eq("hue is measured the short way round the wheel", + Model.hueGap(350, 10), 20); + eq("hue gap is symmetric", Model.hueGap(10, 350), 20); + eq("opposite hues are half a circle apart", Model.hueGap(0, 180), 180); + eq("the same hue has no gap", Model.hueGap(210, 210), 0); + + eq("a colour settles on a near-enough hue", + Model.colorSettled(lit({ hs_color: [211, 60] }), 210, 60), true); + eq("hue wraps rather than reading as a full circle apart", + Model.colorSettled(lit({ hs_color: [359, 80] }), 0.5, 80), true); + eq("a different hue does not settle it", + Model.colorSettled(lit({ hs_color: [211, 60] }), 120, 60), false); + // White has no hue of its own, so any angle confirms it. + eq("an unsaturated pick ignores hue", + Model.colorSettled(lit({ hs_color: [30, 0] }), 210, 0), true); + eq("a light on its temperature channel is not showing a colour", + Model.colorSettled(lit({ hs_color: [211, 60], color_mode: "color_temp" }), + 210, 60), false); + // Near the centre of the wheel an xy round trip barely preserves hue, so the + // tolerance has to widen or the knob freezes until the pending expires. + eq("a barely saturated pick settles on any hue", + Model.colorSettled(lit({ hs_color: [45, 3] }), 200, 3), true); + eq("a saturated pick still needs the hue it asked for", + Model.colorSettled(lit({ hs_color: [216, 100] }), 210, 100), false); + eq("a colour never settles against a light with no colour", + Model.colorSettled(entity("light.a", "off"), 210, 60), false); + eq("a colour never settles against an unavailable light", + Model.colorSettled(entity("light.a", "unavailable"), 210, 60), false); + eq("a colour never settles against a missing entity", + Model.colorSettled(null, 210, 60), false); + + const white = (kelvin) => + lit({ color_mode: "color_temp", color_temp_kelvin: kelvin }); + eq("warmth settles through the mired rounding", + Model.colorTempSettled(white(4000), 4008), true); + eq("a warmth a slider step away does not settle it", + Model.colorTempSettled(white(4000), 4100), false); + // A slider step is only 1.17 mireds at 6500K, so the slack has to stay under + // it even though a step is 100 kelvin wide down at 4000K. + eq("the mired rounding is still absorbed at the cold end", + Model.colorTempSettled(white(6494), 6500), true); + eq("a warmth one step away at the cold end does not settle it", + Model.colorTempSettled(white(6500), 6550), false); + eq("a light showing a hue has no warmth to settle", + Model.colorTempSettled(lit({ hs_color: [211, 60] }), 4000), false); + eq("a nonsensical kelvin never settles", + Model.colorTempSettled(white(4000), 0), false); + eq("a negative kelvin never settles", + Model.colorTempSettled(white(4000), -4000), false); + eq("warmth never settles against an off light", + Model.colorTempSettled(entity("light.a", "off"), 4000), false); + eq("warmth never settles against an unavailable light", + Model.colorTempSettled(entity("light.a", "unavailable"), 4000), false); + eq("warmth never settles against a missing entity", + Model.colorTempSettled(null, 4000), false); + + eq("volume settles on the level it reports", + Model.volumeSettled(entity("media_player.a", "playing", + { volume_level: 0.35 }), 0.35), true); + eq("a different volume does not settle it", + Model.volumeSettled(entity("media_player.a", "playing", + { volume_level: 0.35 }), 0.5), false); + eq("volume never settles against an unavailable player", + Model.volumeSettled(entity("media_player.a", "unavailable"), 0.35), false); + eq("volume never settles against an off player", + Model.volumeSettled(entity("media_player.a", "off"), 0.35), false); + eq("volume never settles against a missing entity", + Model.volumeSettled(null, 0.35), false); + + const stat = entity("climate.a", "heat", { temperature: 21 }); + eq("a setpoint settles within half a step", + Model.temperatureSettled(stat, "temperature", 21.2, 0.5), true); + eq("a setpoint a step away does not settle", + Model.temperatureSettled(stat, "temperature", 21.5, 0.5), false); + eq("a missing attribute never settles", + Model.temperatureSettled(stat, "target_temp_low", 21, 0.5), false); + // A thermostat that reports no step falls back to a quarter degree. + eq("an absent step settles within a quarter degree", + Model.temperatureSettled(stat, "temperature", 21.2), true); + eq("an absent step rejects more than a quarter degree", + Model.temperatureSettled(stat, "temperature", 21.3), false); + eq("a zero step falls back to the same quarter degree", + Model.temperatureSettled(stat, "temperature", 21.2, 0), true); +}); + +section("command tags", () => { + eq("two calls to the same entity get different tags", + Model.callTag("light.a", 1) === Model.callTag("light.a", 2), false); + eq("the same call reads back as the same tag", + Model.callTag("light.a", 7), Model.callTag("light.a", 7)); + eq("a tag keeps the prefix the bridge needs to report a failure", + Model.callTag("light.a", 3).indexOf("call:"), 0); + eq("a tag carries nothing but an entity id and a counter", + /^call:[A-Za-z0-9_.]+:\d+$/.test(Model.callTag("light.a", 12)), true); + + eq("a call tag is recognised", Model.isCallTag(Model.callTag("light.a", 1)), + true); + eq("a toggle tag is not a call tag", Model.isCallTag("toggle:light.a"), false); + eq("an empty tag is not a call tag", Model.isCallTag(""), false); + eq("a missing tag is not a call tag", Model.isCallTag(null), false); + + const first = Model.callTag("light.a", 1); + const second = Model.callTag("light.a", 2); + eq("a failure matches the value its own call put on screen", + Model.callTagMatches(first, first), true); + eq("a failure of an older call does not match a newer value", + Model.callTagMatches(second, first), false); + eq("nor does a newer failure match an older value", + Model.callTagMatches(first, second), false); + eq("an unsent value matches nothing", Model.callTagMatches("", first), false); + eq("an untagged failure matches nothing", + Model.callTagMatches(first, ""), false); + eq("two untagged sides still do not match", + Model.callTagMatches("", ""), false); +}); + +section("step snapping", () => { + eq("a drag lands on the nearest step", + Model.snapToStep(21.2, 5, 0.5, 5, 35), 21); + eq("it rounds up past the halfway point", + Model.snapToStep(21.3, 5, 0.5, 5, 35), 21.5); + eq("an offset base moves the whole grid", + Model.snapToStep(21.2, 5.25, 0.5, 5, 35), 21.25); + eq("a value already on the grid is left alone", + Model.snapToStep(21.5, 5, 0.5, 5, 35), 21.5); + eq("the result stays inside the range", + Model.snapToStep(40, 5, 0.5, 5, 35), 35); + eq("and inside it at the bottom", + Model.snapToStep(-3, 5, 0.5, 5, 35), 5); + eq("a percentage snaps to whole numbers", + Model.snapToStep(63.7, 0, 1, 0, 100), 64); + eq("a coarse step still lands on the grid", + Model.snapToStep(4123, 2000, 50, 2000, 6500), 4100); + + // Band sliders bound themselves by the thermostat's own low and high. + eq("an upper bound off the grid snaps down into the range", + Model.snapToStep(40, 5, 0.5, 5, 21.3), 21); + eq("a lower bound off the grid snaps up into the range", + Model.snapToStep(3, 5, 0.5, 5.2, 30), 5.5); + eq("a value already inside and on the grid is untouched", + Model.snapToStep(21, 5, 0.5, 5.2, 21.3), 21); + + eq("a value just inside an off-grid maximum stays inside", + Model.snapToStep(20.28, 5, 0.5, 5, 20.3), 20); + eq("a value just inside an off-grid minimum stays inside", + Model.snapToStep(20.22, 5, 0.5, 20.2, 35), 20.5); + + // Off the grid beats past a limit the thermostat just reported. + eq("a range with no grid point in it keeps the clamped value", + Model.snapToStep(20.3, 5, 0.5, 20.2, 20.4), 20.3); + eq("and still clamps into that range", + Model.snapToStep(30, 5, 0.5, 20.2, 20.4), 20.4); + eq("the arithmetic does not leak float noise", + String(Model.snapToStep(21.4, 5, 0.5, 5, 35)), "21.5"); + eq("a missing step leaves the value alone", + Model.snapToStep(21.2, 5, 0, 5, 35), 21.2); + eq("a negative step leaves the value alone", + Model.snapToStep(21.2, 5, -0.5, 5, 35), 21.2); + eq("a non-finite base leaves the value alone", + Model.snapToStep(21.2, NaN, 0.5, 5, 35), 21.2); + eq("a non-numeric value passes straight through", + Model.snapToStep(undefined, 5, 0.5, 5, 35), undefined); + + eq("a downward nudge lands on the step below", + Model.snapToStep(20.5, 5, 0.5, 5, 35, Math.ceil), 20.5); + eq("an upward nudge lands on the step above", + Model.snapToStep(21.5, 5, 0.5, 5, 35, Math.floor), 21.5); + eq("a downward nudge from an off-grid target still moves one step", + Model.snapToStep(20, 4.5, 1, 4.5, 35, Math.ceil), 20.5); + eq("an upward nudge from an off-grid target still moves one step", + Model.snapToStep(22, 4.5, 1, 4.5, 35, Math.floor), 21.5); + eq("a directional mode still respects an off-grid maximum", + Model.snapToStep(40, 5, 0.5, 5, 21.3, Math.floor), 21); + eq("a directional mode still respects an off-grid minimum", + Model.snapToStep(3, 5, 0.5, 5.2, 30, Math.ceil), 5.5); +}); + console.log(); if (failures) { console.log(`FAILED: ${failures} of ${checks} checks`); diff --git a/tests/test_qml_style.py b/tests/test_qml_style.py index 16ca810..b8fd092 100644 --- a/tests/test_qml_style.py +++ b/tests/test_qml_style.py @@ -97,6 +97,17 @@ def check(condition, message): "%s:%d BorderSurface sets padding but nothing reads content*Inset" % (rel(path), line)) +print("only a device-declared step quantizes a drag") +for path in QML_FILES: + source = open(path, encoding="utf-8").read() + for line, block in blocks(source, "SliderRow"): + # `control.step` is the climate entity's own target_temp_step; every + # other slider steps by a UI nudge size. + device_step = re.search(r"step:\s*control\.step\b", block) is not None + check(("snap: true" in block) == device_step, + "%s:%d SliderRow snaps to a step the device never declared" + % (rel(path), line)) + print("PanelActionButton instances pass a font family") for path in QML_FILES: source = open(path, encoding="utf-8").read() diff --git a/tests/test_service_contract.py b/tests/test_service_contract.py index d8ab6bd..ec4c6b5 100644 --- a/tests/test_service_contract.py +++ b/tests/test_service_contract.py @@ -116,6 +116,36 @@ def main(): check("domain actions validate entity capabilities", service.count("root.capabilities(entityId)") >= 7 and "Model.capabilitiesFor(entity)" in service) + call_tag = function_block("callTag") + check("every call gets its own tag rather than one per entity", + "root.callSequence++" in call_tag + and "Model.callTag(entityId, root.callSequence)" in call_tag + and "property int callSequence" in service) + check("tags carry nothing beyond an entity id and a counter", + 'CALL_TAG_PREFIX + String(entityId) + ":" + String(sequence)' + in open(os.path.join(ROOT, "Model.js"), encoding="utf-8").read()) + check("a refused command is reported back with its tag", + "signal commandFailed(string tag)" in service + and "root.commandFailed(tag)" in function_block("handleResult")) + check("optimistic setters hand their tag to the caller", + function_block("callTagged").count("return") == 1 + and "? tag : \"\"" in function_block("callTagged") + and all("return root.callTagged(" in function_block(name) + for name in ("setBrightness", "setLightColor", + "setLightColorTemp", "setVolume", + "setClimateTemperature"))) + # The two that go round callTagged are the ones the pending toggle map owns. + check("every domain action leaves through the same tagged call", + all("return root.callTagged(" in function_block(name) + for name in ("mediaPlayPause", "mediaNext", "mediaPrevious", + "coverAction", "activateScene")) + and service.count("root.callService(") == 3 + and all("root.callService(" in function_block(name) + for name in ("callTagged", "toggleEntity", "setLock"))) + check("toggle rollback still runs through the pending toggle map", + '"toggle:" + entityId' in function_block("toggleEntity") + and "clearPendingToggle" in function_block("handleResult")) + check("selected tab persistence is debounced", "selectedTabSaveDebounce.restart()" in service) diff --git a/tests/test_store.js b/tests/test_store.js index 0d36369..1fde7e7 100644 --- a/tests/test_store.js +++ b/tests/test_store.js @@ -58,6 +58,28 @@ eq("area names are projected", registries.areaNames, { k: "Kitchen", h: "Hall" } eq("entity area wins over its device", registries.entityArea, { "light.a": "k", "sensor.b": "h" }); +// Favourite colours ride along in the registry entry's options, which is the +// only place Home Assistant publishes them — they are not on the entity state. +const withFavorites = Store.projectRegistries([], [ + { entity_id: "light.a", options: { light: { favorite_colors: [ + { color_temp_kelvin: 2700 }, { rgb_color: [255, 0, 0] }] } } }, + { entity_id: "light.b", options: { light: { favorite_colors: [] } } }, + { entity_id: "light.c", options: { conversation: { should_expose: true } } }, + { entity_id: "light.d" } +], []); +eq("saved favourites are carried through", + withFavorites.favoriteColors["light.a"], + [{ color_temp_kelvin: 2700 }, { rgb_color: [255, 0, 0] }]); +eq("a deliberately emptied list is carried as empty", + withFavorites.favoriteColors["light.b"], []); +eq("and is distinguishable from a light that was never customised", + "light.b" in withFavorites.favoriteColors + && !("light.d" in withFavorites.favoriteColors), true); +eq("an unrelated options namespace is ignored", + withFavorites.favoriteColors["light.c"], undefined); +eq("an entry with no options is ignored", + withFavorites.favoriteColors["light.d"], undefined); + eq("display names drive the stable index", Store.sortedIds(indexed, (id) => id === "sensor.b" ? "Alpha" : "Zulu"), ["sensor.b", "light.a"]);