feat: added home assistant widget
This commit is contained in:
@@ -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 <entity_id>";
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 !== ""
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <token>" 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 <entity_id>` | 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
|
||||
|
||||
+7
-5
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user