feat: added home assistant widget
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user