Initial implementation: CLI + KDE Plasma 6 widget for Elgato Key Light

- Python CLI (click): discover, status, on/off/toggle, brightness, temperature, info
- mDNS discovery via zeroconf for automatic light detection
- Shared config at ~/.config/elgato-cli/config.json
- KDE Plasma 6 plasmoid with system tray icon, power/brightness/temperature controls
- Plasmoid uses XMLHttpRequest directly to light API (no CLI dependency)
- Unit tests for API client with mocked HTTP responses
This commit is contained in:
2026-02-07 11:14:01 +01:00
commit ca36b15af1
14 changed files with 787 additions and 0 deletions
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<kcfg xmlns="http://www.kde.org/standards/kcfg/1.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.kde.org/standards/kcfg/1.0
http://www.kde.org/standards/kcfg/1.0/kcfg.xsd">
<kcfgfile name=""/>
<group name="General">
<entry name="lightHost" type="String">
<label>Elgato light IP address or hostname</label>
<default></default>
</entry>
<entry name="lightPort" type="Int">
<label>Elgato light port</label>
<default>9123</default>
</entry>
<entry name="pollInterval" type="Int">
<label>Polling interval in milliseconds</label>
<default>3000</default>
</entry>
</group>
</kcfg>
@@ -0,0 +1,37 @@
import QtQuick
import org.kde.plasma.core as PlasmaCore
import org.kde.kirigami as Kirigami
MouseArea {
id: compactRoot
property bool wasExpanded: false
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
Kirigami.Icon {
anchors.fill: parent
source: {
if (root.lightHost === "" || !root.lightReachable)
return "network-disconnect"
return root.lightOn ? "brightness-high" : "brightness-low"
}
active: compactRoot.containsMouse
}
hoverEnabled: true
onPressed: function(mouse) {
if (mouse.button === Qt.MiddleButton) {
root.toggleLight()
} else {
wasExpanded = root.expanded
}
}
onClicked: function(mouse) {
if (mouse.button === Qt.LeftButton) {
root.expanded = !wasExpanded
}
}
}
@@ -0,0 +1,123 @@
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.plasma.components as PC3
ColumnLayout {
id: fullRoot
Layout.preferredWidth: Kirigami.Units.gridUnit * 18
Layout.preferredHeight: Kirigami.Units.gridUnit * 10
Layout.minimumWidth: Kirigami.Units.gridUnit * 14
spacing: Kirigami.Units.smallSpacing
// Not configured state
PC3.Label {
Layout.alignment: Qt.AlignCenter
text: "Run 'elgato discover' or configure host in widget settings"
visible: root.lightHost === ""
wrapMode: Text.WordWrap
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
}
// Unreachable state
PC3.Label {
Layout.alignment: Qt.AlignCenter
text: "Light unreachable (" + root.lightHost + ")"
visible: root.lightHost !== "" && !root.lightReachable
wrapMode: Text.WordWrap
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
}
// Controls (only when reachable)
ColumnLayout {
visible: root.lightHost !== "" && root.lightReachable
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
Layout.margins: Kirigami.Units.smallSpacing
// Header: Power switch
RowLayout {
Layout.fillWidth: true
Kirigami.Icon {
source: "video-television"
Layout.preferredWidth: Kirigami.Units.iconSizes.medium
Layout.preferredHeight: Kirigami.Units.iconSizes.medium
}
PC3.Label {
text: "Elgato Key Light"
font.bold: true
Layout.fillWidth: true
}
QQC2.Switch {
checked: root.lightOn
onToggled: root.toggleLight()
}
}
Kirigami.Separator {
Layout.fillWidth: true
}
// Brightness slider
RowLayout {
Layout.fillWidth: true
Kirigami.Icon {
source: "brightness-high"
Layout.preferredWidth: Kirigami.Units.iconSizes.small
Layout.preferredHeight: Kirigami.Units.iconSizes.small
}
PC3.Slider {
id: brightnessSlider
Layout.fillWidth: true
from: 3
to: 100
stepSize: 1
value: root.lightBrightness
onMoved: root.setBrightness(Math.round(value))
}
PC3.Label {
text: Math.round(brightnessSlider.value) + "%"
Layout.preferredWidth: Kirigami.Units.gridUnit * 3
horizontalAlignment: Text.AlignRight
}
}
// Temperature slider
RowLayout {
Layout.fillWidth: true
Kirigami.Icon {
source: "color-management"
Layout.preferredWidth: Kirigami.Units.iconSizes.small
Layout.preferredHeight: Kirigami.Units.iconSizes.small
}
PC3.Slider {
id: temperatureSlider
Layout.fillWidth: true
from: 143
to: 344
stepSize: 1
value: root.lightTemperature
onMoved: root.setTemperature(Math.round(value))
}
PC3.Label {
text: Math.round(1000000 / temperatureSlider.value) + "K"
Layout.preferredWidth: Kirigami.Units.gridUnit * 3
horizontalAlignment: Text.AlignRight
}
}
}
}
@@ -0,0 +1,150 @@
import QtQuick
import org.kde.plasma.plasmoid
import org.kde.plasma.core as PlasmaCore
PlasmoidItem {
id: root
// State
property bool lightOn: false
property int lightBrightness: 50
property int lightTemperature: 200 // mired
property bool lightReachable: false
property string lightHost: ""
property int lightPort: 9123
// Resolve host: widget config > shared CLI config
function resolveHost() {
var cfgHost = Plasmoid.configuration.lightHost || ""
var cfgPort = Plasmoid.configuration.lightPort || 9123
if (cfgHost !== "") {
root.lightHost = cfgHost
root.lightPort = cfgPort
return
}
// Try reading shared CLI config
var xhr = new XMLHttpRequest()
var configPath = StandardPaths.writableLocation(StandardPaths.GenericConfigLocation) + "/../elgato-cli/config.json"
// Use home dir approach
try {
xhr.open("GET", "file://" + StandardPaths.writableLocation(StandardPaths.HomeLocation) + "/.config/elgato-cli/config.json", false)
xhr.send()
if (xhr.status === 200 || xhr.readyState === XMLHttpRequest.DONE) {
var config = JSON.parse(xhr.responseText)
if (config.host) {
root.lightHost = config.host
root.lightPort = config.port || 9123
}
}
} catch (e) {
console.log("Elgato: Could not read shared config:", e)
}
}
function baseUrl() {
return "http://" + root.lightHost + ":" + root.lightPort + "/elgato"
}
function fetchState() {
if (root.lightHost === "") return
var xhr = new XMLHttpRequest()
xhr.open("GET", baseUrl() + "/lights")
xhr.timeout = 3000
xhr.onreadystatechange = function() {
if (xhr.readyState !== XMLHttpRequest.DONE) return
if (xhr.status === 200) {
var data = JSON.parse(xhr.responseText)
var light = data.lights[0]
root.lightOn = light.on === 1
root.lightBrightness = light.brightness
root.lightTemperature = light.temperature
root.lightReachable = true
} else {
root.lightReachable = false
}
}
xhr.onerror = function() { root.lightReachable = false }
xhr.send()
}
function setLightState(payload) {
if (root.lightHost === "") return
var xhr = new XMLHttpRequest()
xhr.open("PUT", baseUrl() + "/lights")
xhr.setRequestHeader("Content-Type", "application/json")
xhr.onreadystatechange = function() {
if (xhr.readyState !== XMLHttpRequest.DONE) return
if (xhr.status === 200) {
var data = JSON.parse(xhr.responseText)
var light = data.lights[0]
root.lightOn = light.on === 1
root.lightBrightness = light.brightness
root.lightTemperature = light.temperature
root.lightReachable = true
} else {
root.lightReachable = false
}
}
xhr.onerror = function() { root.lightReachable = false }
xhr.send(JSON.stringify({numberOfLights: 1, lights: [payload]}))
}
function toggleLight() {
setLightState({
on: root.lightOn ? 0 : 1,
brightness: root.lightBrightness,
temperature: root.lightTemperature
})
}
function setBrightness(val) {
setLightState({
on: root.lightOn ? 1 : 0,
brightness: val,
temperature: root.lightTemperature
})
}
function setTemperature(val) {
setLightState({
on: root.lightOn ? 1 : 0,
brightness: root.lightBrightness,
temperature: val
})
}
// Polling timer
Timer {
id: pollTimer
interval: Plasmoid.configuration.pollInterval || 3000
running: true
repeat: true
triggeredOnStart: true
onTriggered: root.fetchState()
}
Component.onCompleted: {
resolveHost()
fetchState()
}
// Tray icon
compactRepresentation: CompactRepresentation {}
// Popup
fullRepresentation: FullRepresentation {}
// Prefer status area
Plasmoid.status: root.lightReachable ? PlasmaCore.Types.ActiveStatus : PlasmaCore.Types.PassiveStatus
preferredRepresentation: compactRepresentation
toolTipMainText: "Elgato Key Light"
toolTipSubText: {
if (root.lightHost === "") return "Not configured"
if (!root.lightReachable) return "Unreachable"
return (root.lightOn ? "On" : "Off") + " - " + root.lightBrightness + "% - " + Math.round(1000000 / root.lightTemperature) + "K"
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"KPlugin": {
"Id": "at.sub-net.elgato",
"Name": "Elgato Key Light",
"Description": "Control an Elgato Key Light from the system tray",
"Category": "Hardware",
"Icon": "video-television",
"Authors": [
{
"Name": "Tina Schellander",
"Email": "tina@sub-net.at"
}
],
"Version": "0.1.0",
"License": "MIT"
},
"X-Plasma-API": "declarativeappletscript",
"X-Plasma-API-Minimum-Version": "6.0",
"X-Plasma-Provides": [
"org.kde.plasma.systemtray"
],
"KPackageStructure": "Plasma/Applet"
}