diff --git a/GameBarDaemon.qml b/GameBarDaemon.qml index 4eb1f15..35abf20 100644 --- a/GameBarDaemon.qml +++ b/GameBarDaemon.qml @@ -36,6 +36,16 @@ PluginComponent { "y": 0.14, "defaultVisible": true }, + { + "id": "home", + "title": "Home", + "icon": "home", + "width": 420, + "height": 420, + "x": 0.56, + "y": 0.50, + "defaultVisible": false + }, { "id": "system", "title": "Performance", @@ -175,12 +185,42 @@ PluginComponent { return "SUCCESS"; } + function homeToggle(entityId: string) : string { + if (!entityId) + return "USAGE: homeToggle "; + + const domain = haService.domainOf(entityId); + if (["scene", "script", "button", "input_button"].indexOf(domain) !== -1) + haService.press(entityId); + else + haService.toggle(entityId); + return "SUCCESS"; + } + + function homeStatus() : string { + const picked = Array.isArray(root.pluginData.haEntities) ? root.pluginData.haEntities.length : 0; + return "configured=" + haService.configured + " url=" + (haService.baseUrl || "(none)") + " tokenChars=" + haService.token.length + " http=" + haService.lastStatus + " entities=" + haService.entityList.length + " picked=" + picked + " error=" + (haService.lastError || "(none)"); + } + + function homeRefresh() : string { + haService.refresh(); + return "SUCCESS"; + } + function resetLayout() : string { root.resetLayout(); return "SUCCESS"; } } + readonly property alias homeService: haService + + GameBarHomeService { + id: haService + + daemon: root + } + GameBarOverlay { id: overlay diff --git a/GameBarHomeRow.qml b/GameBarHomeRow.qml new file mode 100644 index 0000000..e9a044d --- /dev/null +++ b/GameBarHomeRow.qml @@ -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() + } +} diff --git a/GameBarHomeService.qml b/GameBarHomeService.qml new file mode 100644 index 0000000..5ab9633 --- /dev/null +++ b/GameBarHomeService.qml @@ -0,0 +1,276 @@ +import QtQuick +import Quickshell +import Quickshell.Io + +Item { + id: service + + property var daemon: null + + readonly property var pluginData: (daemon && daemon.pluginData) ? daemon.pluginData : ({}) + readonly property string baseUrl: String(pluginData.haUrl || "").trim().replace(/\/+$/, "") + readonly property string token: String(pluginData.haToken || "").trim() + readonly property int pollInterval: Math.max(2, pluginData.haPollInterval || 5) * 1000 + readonly property bool configured: baseUrl !== "" && token !== "" + + // entity_id -> state object, plus a sorted array for pickers. + property var entities: ({}) + property var entityList: [] + property string lastError: "" + property bool loading: false + property bool everLoaded: false + property int lastStatus: 0 + + // Polling only runs while something is actually showing HA data. + property int consumers: 0 + + signal refreshed + signal callFailed(string message) + + function addConsumer() { + service.consumers = service.consumers + 1; + if (service.consumers === 1) + service.refresh(); + } + + function removeConsumer() { + service.consumers = Math.max(0, service.consumers - 1); + } + + function entityState(entityId) { + return service.entities[entityId] || null; + } + + function friendlyName(entityId) { + const state = service.entityState(entityId); + if (state && state.attributes && state.attributes.friendly_name) + return state.attributes.friendly_name; + + return entityId; + } + + function domainOf(entityId) { + const parts = String(entityId || "").split("."); + return parts.length > 1 ? parts[0] : ""; + } + + // A changed URL or token invalidates whatever the last attempt reported. + onTokenChanged: service.lastError = "" + onBaseUrlChanged: service.lastError = "" + + // The token is handed to curl through the environment, so it never shows up + // in the process arguments (and there is no stdin race to lose it to). + readonly property string curlGetScript: 'exec curl -sS --fail-with-body --connect-timeout 4 --max-time 10 -w "\n%{http_code}" -H "Authorization: Bearer $GAMEBAR_HA_TOKEN" -H "Content-Type: application/json" "$1"' + readonly property string curlPostScript: 'exec curl -sS --fail-with-body --connect-timeout 4 --max-time 10 -H "Authorization: Bearer $GAMEBAR_HA_TOKEN" -H "Content-Type: application/json" -X POST -d "$2" "$1"' + + function refresh() { + if (!service.configured) { + service.lastError = "Not configured"; + return; + } + if (service.loading) + return; + + service.loading = true; + statesProc.environment = { + "GAMEBAR_HA_TOKEN": service.token + }; + statesProc.command = ["sh", "-c", service.curlGetScript, "gameBar", service.baseUrl + "/api/states"]; + statesProc.running = true; + } + + function callService(domain, method, data) { + if (!service.configured) + return; + + service.callQueue.push({ + "url": service.baseUrl + "/api/services/" + domain + "/" + method, + "body": JSON.stringify(data || {}) + }); + service.drainQueue(); + } + + property var callQueue: [] + + function drainQueue() { + if (callProc.running || service.callQueue.length === 0) + return; + + const next = service.callQueue.shift(); + callProc.environment = { + "GAMEBAR_HA_TOKEN": service.token + }; + callProc.command = ["sh", "-c", service.curlPostScript, "gameBar", next.url, next.body]; + callProc.running = true; + } + + function toggle(entityId) { + const domain = service.domainOf(entityId); + const toggleDomains = ["light", "switch", "fan", "input_boolean", "siren", "humidifier", "media_player", "automation"]; + const target = toggleDomains.indexOf(domain) !== -1 ? domain : "homeassistant"; + service.callService(target, "toggle", { + "entity_id": entityId + }); + service.scheduleRefresh(); + } + + function setBrightness(entityId, percent) { + service.callService("light", "turn_on", { + "entity_id": entityId, + "brightness_pct": Math.round(percent) + }); + service.scheduleRefresh(); + } + + function setVolume(entityId, percent) { + service.callService("media_player", "volume_set", { + "entity_id": entityId, + "volume_level": Math.max(0, Math.min(1, percent / 100)) + }); + service.scheduleRefresh(); + } + + function setMuted(entityId, muted) { + service.callService("media_player", "volume_mute", { + "entity_id": entityId, + "is_volume_muted": muted === true + }); + service.scheduleRefresh(); + } + + function setNumber(entityId, value) { + service.callService(service.domainOf(entityId), "set_value", { + "entity_id": entityId, + "value": value + }); + service.scheduleRefresh(); + } + + function press(entityId) { + const domain = service.domainOf(entityId); + if (domain === "scene" || domain === "script") + service.callService(domain, "turn_on", { + "entity_id": entityId + }); + else + service.callService(domain, "press", { + "entity_id": entityId + }); + service.scheduleRefresh(); + } + + function cover(entityId, action) { + service.callService("cover", action, { + "entity_id": entityId + }); + service.scheduleRefresh(); + } + + function scheduleRefresh() { + followUpTimer.restart(); + } + + Process { + id: statesProc + + running: false + + stdout: StdioCollector { + onStreamFinished: { + service.loading = false; + let raw = String(text || "").trim(); + if (raw === "") + return; + + // curl -w appends the HTTP status on its own line. + const split = raw.lastIndexOf("\n"); + if (split !== -1) { + const tail = raw.substring(split + 1).trim(); + if (/^[0-9]{3}$/.test(tail)) { + service.lastStatus = parseInt(tail, 10); + raw = raw.substring(0, split).trim(); + } + } + + if (service.lastStatus === 401) { + service.lastError = "401 Unauthorized - check the access token"; + return; + } + + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + service.lastError = parsed.message || "Unexpected response"; + return; + } + + const map = {}; + for (const item of parsed) + map[item.entity_id] = item; + + service.entities = map; + service.entityList = parsed.slice().sort((a, b) => a.entity_id.localeCompare(b.entity_id)); + service.lastError = ""; + service.everLoaded = true; + service.refreshed(); + } catch (e) { + service.lastError = "Invalid response from Home Assistant"; + } + } + } + + stderr: StdioCollector { + onStreamFinished: { + const raw = String(text || "").trim(); + if (raw !== "") + service.lastError = raw.split("\n")[0]; + } + } + + onExited: exitCode => { + service.loading = false; + if (exitCode !== 0 && service.lastError === "") + service.lastError = "Request failed (curl " + exitCode + ")"; + } + } + + Process { + id: callProc + + running: false + + stderr: StdioCollector { + onStreamFinished: { + const raw = String(text || "").trim(); + if (raw !== "") + service.callFailed(raw.split("\n")[0]); + } + } + + onExited: exitCode => { + if (exitCode !== 0) + service.callFailed("Service call failed (curl " + exitCode + ")"); + + service.drainQueue(); + } + } + + Timer { + id: pollTimer + + interval: service.pollInterval + repeat: true + running: service.consumers > 0 && service.configured + onTriggered: service.refresh() + } + + // Home Assistant needs a moment to apply a service call before it shows up + // in /api/states. + Timer { + id: followUpTimer + + interval: 400 + repeat: false + onTriggered: service.refresh() + } +} diff --git a/GameBarHomeSettings.qml b/GameBarHomeSettings.qml new file mode 100644 index 0000000..fd88a82 --- /dev/null +++ b/GameBarHomeSettings.qml @@ -0,0 +1,225 @@ +import QtQuick +import Quickshell +import qs.Common +import qs.Services +import qs.Widgets + +Item { + id: haSettings + + property var settings: null + + readonly property var daemon: (typeof PluginService !== "undefined" && PluginService.pluginInstances) ? PluginService.pluginInstances["gameBar"] : null + readonly property var service: daemon ? daemon.homeService : null + readonly property var entityList: service ? service.entityList : [] + + property string filterText: "" + + // Read straight from the daemon's (reactive) plugin data. Caching this in a + // local list loses the saved picks, because PluginSettings.loadValue() still + // returns the default while pluginService is being injected - and the next + // pick would then save that empty list over the stored one. + readonly property var selectedIds: { + const data = service ? service.pluginData : null; + const stored = data ? data.haEntities : null; + if (Array.isArray(stored)) + return stored; + + const fallback = settings ? settings.loadValue("haEntities", []) : []; + return Array.isArray(fallback) ? fallback : []; + } + + readonly property var filteredEntities: { + const query = filterText.trim().toLowerCase(); + const list = haSettings.entityList; + if (query === "") + return list; + + return list.filter(entity => { + const name = (entity.attributes && entity.attributes.friendly_name) ? entity.attributes.friendly_name : ""; + return entity.entity_id.toLowerCase().includes(query) || name.toLowerCase().includes(query); + }); + } + + readonly property string statusText: { + if (!service) + return "Plugin daemon not running"; + if (!service.configured) + return "Enter a URL and token, then refresh"; + if (service.loading) + return "Contacting Home Assistant…"; + if (service.lastError !== "") + return service.lastError; + if (service.everLoaded) + return entityList.length + " entities · " + selectedIds.length + " picked"; + return "Not loaded yet"; + } + + function isSelected(entityId) { + return haSettings.selectedIds.indexOf(entityId) !== -1; + } + + function setSelected(entityId, selected) { + const next = haSettings.selectedIds.slice(); + const index = next.indexOf(entityId); + if (selected && index === -1) + next.push(entityId); + else if (!selected && index !== -1) + next.splice(index, 1); + + if (settings) + settings.saveValue("haEntities", next); + } + + implicitHeight: column.implicitHeight + height: implicitHeight + + Component.onCompleted: { + if (service && service.configured && !service.everLoaded) + service.refresh(); + } + + Column { + id: column + + anchors.left: parent.left + anchors.right: parent.right + spacing: Theme.spacingS + + StyledText { + text: "Home Assistant" + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + } + + StyledText { + width: parent.width + text: "Pick the entities you want in the Home widget. The access token is stored in plugin_settings.json." + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + } + + Row { + width: parent.width + spacing: Theme.spacingS + + StyledText { + anchors.verticalCenter: parent.verticalCenter + width: Math.round(parent.width * 0.3) + text: "Status" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + + StyledText { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - Math.round(parent.width * 0.3) - refreshButton.width - parent.spacing * 2 + text: haSettings.statusText + font.pixelSize: Theme.fontSizeSmall + color: (haSettings.service && haSettings.service.lastError !== "") ? Theme.error : Theme.surfaceText + elide: Text.ElideRight + } + + DankButton { + id: refreshButton + + text: "Refresh" + iconName: "refresh" + enabled: haSettings.service !== null && haSettings.service.configured + onClicked: haSettings.service.refresh() + } + } + + DankTextField { + width: parent.width + placeholderText: "Filter entities (e.g. light, kitchen)" + leftIconName: "search" + showClearButton: true + text: haSettings.filterText + onTextChanged: haSettings.filterText = text + visible: haSettings.entityList.length > 0 + } + + Rectangle { + width: parent.width + height: 280 + radius: Theme.cornerRadius + color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) + border.color: Theme.outlineMedium + border.width: Theme.layerOutlineWidth + visible: haSettings.entityList.length > 0 + + DankListView { + id: entityListView + + anchors.fill: parent + anchors.margins: Theme.spacingS + clip: true + spacing: 2 + model: haSettings.filteredEntities + + delegate: Rectangle { + id: entityRow + + required property var modelData + + readonly property bool picked: haSettings.isSelected(modelData.entity_id) + + width: entityListView.width + height: 44 + radius: Theme.cornerRadius + color: picked ? Theme.primarySelected : (rowArea.containsMouse ? Theme.surfaceHover : "transparent") + + Column { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingS + anchors.right: pickToggle.left + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + StyledText { + width: parent.width + text: (entityRow.modelData.attributes && entityRow.modelData.attributes.friendly_name) ? entityRow.modelData.attributes.friendly_name : entityRow.modelData.entity_id + font.pixelSize: Theme.fontSizeSmall + font.weight: entityRow.picked ? Font.Medium : Font.Normal + color: Theme.surfaceText + elide: Text.ElideRight + } + + StyledText { + width: parent.width + text: entityRow.modelData.entity_id + " · " + entityRow.modelData.state + font.pixelSize: Theme.fontSizeSmall - 1 + color: Theme.surfaceVariantText + elide: Text.ElideRight + } + } + + DankToggle { + id: pickToggle + + anchors.right: parent.right + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + hideText: true + checked: entityRow.picked + onToggled: isChecked => haSettings.setSelected(entityRow.modelData.entity_id, isChecked) + } + + MouseArea { + id: rowArea + + anchors.fill: parent + anchors.rightMargin: pickToggle.width + Theme.spacingM + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: haSettings.setSelected(entityRow.modelData.entity_id, !entityRow.picked) + } + } + } + } + } +} diff --git a/GameBarHomeWidget.qml b/GameBarHomeWidget.qml new file mode 100644 index 0000000..69af45a --- /dev/null +++ b/GameBarHomeWidget.qml @@ -0,0 +1,89 @@ +import QtQuick +import Quickshell +import qs.Common +import qs.Widgets + +Item { + id: widget + + property var service: null + + readonly property var selected: { + const stored = (service && service.pluginData) ? service.pluginData.haEntities : null; + return Array.isArray(stored) ? stored : []; + } + readonly property bool configured: service ? service.configured : false + + Component.onCompleted: { + if (service) + service.addConsumer(); + } + + Component.onDestruction: { + if (service) + service.removeConsumer(); + } + + DankFlickable { + id: list + + anchors.fill: parent + visible: widget.configured && widget.selected.length > 0 + clip: true + contentHeight: entityColumn.height + contentWidth: width + + Column { + id: entityColumn + + width: list.width + spacing: Theme.spacingXS + + Repeater { + model: widget.selected + + delegate: GameBarHomeRow { + required property var modelData + + width: entityColumn.width + service: widget.service + entityId: modelData + } + } + } + } + + Column { + anchors.centerIn: parent + width: parent.width - Theme.spacingL + spacing: Theme.spacingS + visible: !list.visible + + DankIcon { + anchors.horizontalCenter: parent.horizontalCenter + name: widget.configured ? "playlist_add" : "home" + size: Theme.iconSizeLarge + color: Theme.surfaceVariantText + } + + StyledText { + width: parent.width + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + text: widget.configured ? "No entities picked yet.\nChoose them in Settings > Plugins > Game Bar." : "Home Assistant is not set up.\nAdd your URL and token in Settings > Plugins > Game Bar." + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + } + + StyledText { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + text: widget.service ? widget.service.lastError : "" + font.pixelSize: Theme.fontSizeSmall + color: Theme.error + elide: Text.ElideRight + visible: text !== "" + } +} diff --git a/GameBarPanel.qml b/GameBarPanel.qml index 1fdaee1..ec52908 100644 --- a/GameBarPanel.qml +++ b/GameBarPanel.qml @@ -104,6 +104,8 @@ FocusScope { return audioWidgetComponent; case "apps": return appsWidgetComponent; + case "home": + return homeWidgetComponent; case "system": return systemWidgetComponent; } @@ -124,6 +126,14 @@ FocusScope { GameBarAppMixer {} } + Component { + id: homeWidgetComponent + + GameBarHomeWidget { + service: root.daemon ? root.daemon.homeService : null + } + } + Component { id: systemWidgetComponent diff --git a/GameBarSettings.qml b/GameBarSettings.qml index 7552892..be7fde6 100644 --- a/GameBarSettings.qml +++ b/GameBarSettings.qml @@ -55,6 +55,37 @@ PluginSettings { rightIcon: "dark_mode" } + StringSetting { + settingKey: "haUrl" + label: "Home Assistant URL" + description: "e.g. http://homeassistant.local:8123" + placeholder: "http://homeassistant.local:8123" + } + + StringSetting { + settingKey: "haToken" + label: "Long-lived access token" + description: "Home Assistant profile page, bottom of Security tab" + placeholder: "eyJhbGciOi..." + } + + SliderSetting { + settingKey: "haPollInterval" + label: "Home Assistant refresh interval" + description: "How often entity states are polled while the Home widget is open" + defaultValue: 5 + minimum: 2 + maximum: 30 + unit: "s" + leftIcon: "speed" + rightIcon: "schedule" + } + + GameBarHomeSettings { + width: parent.width + settings: settings + } + Item { width: parent.width height: resetColumn.implicitHeight diff --git a/README.md b/README.md index d4dafad..fe6c39e 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,28 @@ An overlay for [DankMaterialShell](https://danklinux.com/docs/dankmaterialshell/ | **Audio** | Master output volume + mute, output device switching, microphone volume + mute, input device switching | | **Apps** | Per-application volume sliders with individual mute, app icon and what is playing | | **Performance** | CPU (usage, temperature), memory (used / total), network (rx/tx), disk (root usage, read/write) | +| **Home** | Home Assistant entities you pick: lights (toggle + brightness), numbers (slider over the entity's own min/max/step), switches/fans, media players (volume, mute, play/pause), covers, scenes and scripts, plus read-only sensors | + +## Home Assistant + +Settings → Plugins → Game Bar: + +1. **Home Assistant URL**, e.g. `http://homeassistant.local:8123` +2. **Long-lived access token** — HA profile page → *Security* → *Create token*. + It is stored in `plugin_settings.json`, which is world-readable by default + (`chmod 600` it if that matters to you). +3. **Refresh**, then tick the entities you want in the Home widget. + +State is polled (default every 5 s) only while the Home widget is open. The token is handed to +`curl` through the process environment, so it never shows up in the process list. + +If a refresh fails, `dms ipc call gameBar homeStatus` shows what the plugin actually sent and got +back. A `401` means Home Assistant rejected the token; compare with + +```bash +curl -H "Authorization: Bearer " https://your-ha/api/ +``` +Note that editing `plugin_settings.json` by hand does not reach a running shell. ### IPC commands @@ -17,7 +39,10 @@ An overlay for [DankMaterialShell](https://danklinux.com/docs/dankmaterialshell/ | `dms ipc call gameBar toggle` | Toggle the overlay | | `dms ipc call gameBar open` | Open the overlay | | `dms ipc call gameBar close` | Close the overlay | -| `dms ipc call gameBar widget audio\|apps\|system` | Show/hide one widget | +| `dms ipc call gameBar widget audio\|apps\|home\|system` | Show/hide one widget | +| `dms ipc call gameBar homeToggle ` | Toggle an entity (activates a scene/script/button) | +| `dms ipc call gameBar homeRefresh` | Re-poll Home Assistant now | +| `dms ipc call gameBar homeStatus` | Print URL, token length, HTTP status, entity/picked counts and last error | | `dms ipc call gameBar resetLayout` | Forget all saved widget positions | ## Settings @@ -25,6 +50,7 @@ An overlay for [DankMaterialShell](https://danklinux.com/docs/dankmaterialshell/ Settings → Plugins → Game Bar (gear icon): - **Background dimming** — how dark the desktop behind the overlay gets (0–95%). +- **Home Assistant** — URL, access token, refresh interval, and the entity picker. - **Reset widget positions** — back to the default arrangement on every monitor. ## License diff --git a/plugin.json b/plugin.json index 5468e9b..51b92bc 100644 --- a/plugin.json +++ b/plugin.json @@ -1,11 +1,11 @@ { - "$schema": "https://github.com/AvengeMedia/DankMaterialShell/raw/refs/heads/master/PLUGINS/plugin-schema.json", + "$schema": "https://raw.githubusercontent.com/AvengeMedia/DankMaterialShell/master/quickshell/PLUGINS/plugin-schema.json", "id": "gameBar", "name": "Game Bar", - "description": "Game Bar style overlay with quick actions and system monitoring", - "version": "1.0.0", + "description": "Game Bar style overlay with movable widgets for quick settings and controls", + "version": "2.0.0", "license": "MIT", - "author": "keule", + "author": "keule2", "icon": "sports_esports", "type": "daemon", "capabilities": [ @@ -17,6 +17,8 @@ "requires_dms": ">=1.5.0", "permissions": [ "settings_read", - "settings_write" + "settings_write", + "process", + "network" ] }