feat: added home assistant widget

This commit is contained in:
2026-09-21 17:48:09 +02:00
parent 62e760f126
commit e0a27e59b4
9 changed files with 1006 additions and 6 deletions
+301
View File
@@ -0,0 +1,301 @@
import QtQuick
import qs.Common
import qs.Widgets
Rectangle {
id: row
property var service: null
property string entityId: ""
readonly property var entity: service ? service.entityState(entityId) : null
readonly property string domain: service ? service.domainOf(entityId) : ""
readonly property var attributes: (entity && entity.attributes) ? entity.attributes : ({})
readonly property string state: entity ? String(entity.state) : "unavailable"
// Scenes/scripts/buttons report a timestamp or "unknown" rather than a state,
// so they must not be greyed out as unavailable.
readonly property bool available: entity !== null && (isButton || (state !== "unavailable" && state !== "unknown"))
readonly property bool isOn: state === "on" || state === "playing" || state === "open" || state === "home"
readonly property bool isToggleable: ["light", "switch", "fan", "input_boolean", "siren", "humidifier", "automation"].indexOf(domain) !== -1
readonly property bool isMediaPlayer: domain === "media_player"
readonly property bool isCover: domain === "cover"
readonly property bool isButton: ["scene", "script", "button", "input_button"].indexOf(domain) !== -1
// number / input_number expose min, max and step and are set with set_value.
readonly property bool isNumber: domain === "number" || domain === "input_number"
readonly property real numberMin: attributes.min !== undefined ? attributes.min : 0
readonly property real numberMax: attributes.max !== undefined ? attributes.max : 100
readonly property real numberStep: (attributes.step !== undefined && attributes.step > 0) ? attributes.step : 1
readonly property real numberValue: {
const parsed = parseFloat(state);
return isNaN(parsed) ? numberMin : parsed;
}
// DankSlider works in whole numbers, so only drive it directly when the
// entity's own range is integral; otherwise map it onto 0-100.
readonly property bool numberDirect: isNumber && Number.isInteger(numberMin) && Number.isInteger(numberMax) && Number.isInteger(numberStep)
readonly property string numberUnit: attributes.unit_of_measurement || ""
function numberFromSlider(sliderValue) {
if (row.numberDirect)
return sliderValue;
const span = row.numberMax - row.numberMin;
const raw = row.numberMin + (sliderValue / 100) * span;
const stepped = row.numberMin + Math.round((raw - row.numberMin) / row.numberStep) * row.numberStep;
return Math.min(row.numberMax, Math.max(row.numberMin, Math.round(stepped * 1000) / 1000));
}
readonly property bool hasBrightness: domain === "light" && isOn && attributes.brightness !== undefined && attributes.brightness !== null
readonly property bool hasVolume: isMediaPlayer && attributes.volume_level !== undefined && attributes.volume_level !== null
readonly property bool hasSlider: hasBrightness || hasVolume || (isNumber && available)
readonly property int sliderPercent: {
if (hasBrightness)
return Math.round((attributes.brightness / 255) * 100);
if (hasVolume)
return Math.round(attributes.volume_level * 100);
if (isNumber) {
if (numberDirect)
return Math.round(numberValue);
const span = numberMax - numberMin;
return span > 0 ? Math.round(((numberValue - numberMin) / span) * 100) : 0;
}
return 0;
}
readonly property string icon: {
switch (domain) {
case "light":
return "lightbulb";
case "switch":
return "power_settings_new";
case "fan":
return "mode_fan";
case "media_player":
return "speaker";
case "cover":
return "blinds";
case "scene":
return "auto_awesome";
case "script":
return "play_arrow";
case "button":
case "input_button":
return "radio_button_checked";
case "climate":
return "thermostat";
case "number":
case "input_number":
return "tune";
case "lock":
return "lock";
case "binary_sensor":
return "sensors";
case "sensor":
return "monitoring";
}
return "home";
}
readonly property string detail: {
if (!available)
return "Unavailable";
if (row.isButton) {
switch (domain) {
case "scene":
return "Scene";
case "script":
return "Script";
}
return "Button";
}
if (row.isMediaPlayer) {
const media = attributes.media_title || "";
return media !== "" ? state + " · " + media : state;
}
if (domain === "sensor" || domain === "binary_sensor")
return state + (attributes.unit_of_measurement ? " " + attributes.unit_of_measurement : "");
if (domain === "climate" && attributes.current_temperature !== undefined)
return state + " · " + attributes.current_temperature + "°";
if (row.isNumber)
return row.numberValue + (row.numberUnit !== "" ? " " + row.numberUnit : "");
if (row.hasBrightness)
return row.sliderPercent + "%";
return state;
}
function primaryAction() {
if (!service || !available)
return;
if (row.isButton)
service.press(entityId);
else if (row.isToggleable || row.isMediaPlayer)
service.toggle(entityId);
}
height: hasSlider ? 74 : 48
radius: Theme.cornerRadius
color: row.isOn ? Theme.primarySelected : Theme.withAlpha(Theme.surfaceLight, Theme.popupTransparency)
border.color: row.isOn ? Theme.primary : Theme.outlineLight
border.width: row.isOn ? 1 : Theme.layerOutlineWidth
opacity: available ? 1 : 0.55
DankIcon {
id: entityIcon
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.top: parent.top
anchors.topMargin: 12
name: row.icon
size: Theme.iconSize - 6
color: row.isOn ? Theme.primary : Theme.surfaceText
}
Column {
id: labels
anchors.left: entityIcon.right
anchors.leftMargin: Theme.spacingS
anchors.right: controls.left
anchors.rightMargin: Theme.spacingS
anchors.top: parent.top
anchors.topMargin: 8
spacing: 0
StyledText {
width: parent.width
text: row.service ? row.service.friendlyName(row.entityId) : row.entityId
font.pixelSize: Theme.fontSizeMedium
font.weight: row.isOn ? Font.Medium : Font.Normal
color: Theme.surfaceText
elide: Text.ElideRight
}
StyledText {
width: parent.width
text: row.detail
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
elide: Text.ElideRight
}
}
Row {
id: controls
anchors.right: parent.right
anchors.rightMargin: Theme.spacingS
anchors.top: parent.top
anchors.topMargin: 6
spacing: Theme.spacingXS
DankActionButton {
visible: row.isCover
iconName: "keyboard_arrow_up"
buttonSize: 30
iconSize: 18
iconColor: Theme.surfaceText
onClicked: row.service.cover(row.entityId, "open_cover")
}
DankActionButton {
visible: row.isCover
iconName: "stop"
buttonSize: 30
iconSize: 16
iconColor: Theme.surfaceText
onClicked: row.service.cover(row.entityId, "stop_cover")
}
DankActionButton {
visible: row.isCover
iconName: "keyboard_arrow_down"
buttonSize: 30
iconSize: 18
iconColor: Theme.surfaceText
onClicked: row.service.cover(row.entityId, "close_cover")
}
DankActionButton {
visible: row.isMediaPlayer
iconName: row.attributes.is_volume_muted === true ? "volume_off" : "volume_up"
buttonSize: 30
iconSize: 17
iconColor: row.attributes.is_volume_muted === true ? Theme.error : Theme.surfaceText
onClicked: row.service.setMuted(row.entityId, row.attributes.is_volume_muted !== true)
}
DankActionButton {
visible: row.isMediaPlayer
iconName: row.state === "playing" ? "pause" : "play_arrow"
buttonSize: 30
iconSize: 18
iconColor: Theme.surfaceText
onClicked: row.service.callService("media_player", "media_play_pause", {
"entity_id": row.entityId
})
}
DankToggle {
visible: row.isToggleable
anchors.verticalCenter: parent.verticalCenter
checked: row.isOn
enabled: row.available
onToggled: isChecked => row.service.toggle(row.entityId)
}
DankActionButton {
visible: row.isButton
iconName: "play_arrow"
buttonSize: 30
iconSize: 18
iconColor: Theme.primary
onClicked: row.service.press(row.entityId)
}
}
DankSlider {
id: slider
visible: row.hasSlider
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.leftMargin: Theme.spacingM
anchors.rightMargin: Theme.spacingM
anchors.bottomMargin: -4
minimum: row.numberDirect ? Math.round(row.numberMin) : 0
maximum: row.numberDirect ? Math.round(row.numberMax) : 100
step: row.numberDirect ? Math.round(row.numberStep) : 1
showValue: false
enabled: row.available
onSliderValueChanged: newValue => {
if (row.hasBrightness)
row.service.setBrightness(row.entityId, newValue);
else if (row.hasVolume)
row.service.setVolume(row.entityId, newValue);
else if (row.isNumber)
row.service.setNumber(row.entityId, row.numberFromSlider(newValue));
}
Component.onCompleted: slider.value = row.sliderPercent
}
// Keep the slider in step with Home Assistant without fighting the user's drag.
onSliderPercentChanged: {
if (!slider.isDragging)
slider.value = row.sliderPercent;
}
MouseArea {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: row.hasSlider ? slider.top : parent.bottom
anchors.right: controls.left
enabled: row.available && (row.isToggleable || row.isMediaPlayer || row.isButton)
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: row.primaryAction()
}
}