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
+40
View File
@@ -36,6 +36,16 @@ PluginComponent {
"y": 0.14, "y": 0.14,
"defaultVisible": true "defaultVisible": true
}, },
{
"id": "home",
"title": "Home",
"icon": "home",
"width": 420,
"height": 420,
"x": 0.56,
"y": 0.50,
"defaultVisible": false
},
{ {
"id": "system", "id": "system",
"title": "Performance", "title": "Performance",
@@ -175,12 +185,42 @@ PluginComponent {
return "SUCCESS"; 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 { function resetLayout() : string {
root.resetLayout(); root.resetLayout();
return "SUCCESS"; return "SUCCESS";
} }
} }
readonly property alias homeService: haService
GameBarHomeService {
id: haService
daemon: root
}
GameBarOverlay { GameBarOverlay {
id: overlay id: overlay
+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()
}
}
+276
View File
@@ -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()
}
}
+225
View File
@@ -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)
}
}
}
}
}
}
+89
View File
@@ -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 !== ""
}
}
+10
View File
@@ -104,6 +104,8 @@ FocusScope {
return audioWidgetComponent; return audioWidgetComponent;
case "apps": case "apps":
return appsWidgetComponent; return appsWidgetComponent;
case "home":
return homeWidgetComponent;
case "system": case "system":
return systemWidgetComponent; return systemWidgetComponent;
} }
@@ -124,6 +126,14 @@ FocusScope {
GameBarAppMixer {} GameBarAppMixer {}
} }
Component {
id: homeWidgetComponent
GameBarHomeWidget {
service: root.daemon ? root.daemon.homeService : null
}
}
Component { Component {
id: systemWidgetComponent id: systemWidgetComponent
+31
View File
@@ -55,6 +55,37 @@ PluginSettings {
rightIcon: "dark_mode" 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 { Item {
width: parent.width width: parent.width
height: resetColumn.implicitHeight height: resetColumn.implicitHeight
+27 -1
View File
@@ -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 | | **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 | | **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) | | **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 ### 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 toggle` | Toggle the overlay |
| `dms ipc call gameBar open` | Open the overlay | | `dms ipc call gameBar open` | Open the overlay |
| `dms ipc call gameBar close` | Close 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 | | `dms ipc call gameBar resetLayout` | Forget all saved widget positions |
## Settings ## Settings
@@ -25,6 +50,7 @@ An overlay for [DankMaterialShell](https://danklinux.com/docs/dankmaterialshell/
Settings → Plugins → Game Bar (gear icon): Settings → Plugins → Game Bar (gear icon):
- **Background dimming** — how dark the desktop behind the overlay gets (0–95%). - **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. - **Reset widget positions** — back to the default arrangement on every monitor.
## License ## License
+7 -5
View File
@@ -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", "id": "gameBar",
"name": "Game Bar", "name": "Game Bar",
"description": "Game Bar style overlay with quick actions and system monitoring", "description": "Game Bar style overlay with movable widgets for quick settings and controls",
"version": "1.0.0", "version": "2.0.0",
"license": "MIT", "license": "MIT",
"author": "keule", "author": "keule2",
"icon": "sports_esports", "icon": "sports_esports",
"type": "daemon", "type": "daemon",
"capabilities": [ "capabilities": [
@@ -17,6 +17,8 @@
"requires_dms": ">=1.5.0", "requires_dms": ">=1.5.0",
"permissions": [ "permissions": [
"settings_read", "settings_read",
"settings_write" "settings_write",
"process",
"network"
] ]
} }