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() } }