import QtQuick
import QtQuick.Layouts
import QtQuick.Controls as QQC2
import org.kde.plasma.plasmoid
import org.kde.plasma.core as PlasmaCore
import org.kde.plasma.components as PlasmaComponents3
import org.kde.kirigami as Kirigami
import "Lambent.js" as L
PlasmoidItem {
id: root
switchWidth: Kirigami.Units.gridUnit * 20
switchHeight: Kirigami.Units.gridUnit * 16
// In a panel always show the circles, however much room we're given;
// on the desktop let Plasma pick by size.
preferredRepresentation: Plasmoid.formFactor === PlasmaCore.Types.Horizontal
|| Plasmoid.formFactor === PlasmaCore.Types.Vertical
? compactRepresentation : null
Plasmoid.backgroundHints: PlasmaCore.Types.DefaultBackground | PlasmaCore.Types.ConfigurableBackground
// Each circle carries its own tooltip; the applet-wide one would just
// sit on top of them.
toolTipMainText: ""
toolTipSubText: ""
readonly property url logoSource: Qt.resolvedUrl("../icons/lambent.svg")
readonly property string baseUrl: L.normalizeBase(Plasmoid.configuration.serverUrl)
readonly property bool previewsWanted: Plasmoid.configuration.previews
readonly property bool wheelBrightness: Plasmoid.configuration.wheelBrightness
readonly property bool hideDark: Plasmoid.configuration.hideDark
readonly property string circlesJson: Plasmoid.configuration.circles
readonly property bool hasValidConfig: baseUrl !== ""
readonly property bool connected: phx.live
readonly property bool available: phx.available
// ── server state ────────────────────────────────────────────────────
// Pushes arrive many times a second (every machine and link publishes
// every 400ms, previews at up to 4Hz), so they're merged into these
// objects in place and `rev` is bumped at most every 200ms. Anything
// that reads them binds on `rev` too.
property var devices: ({}) // mac -> device
property var machines: ({}) // name -> machine
property var links: ({}) // name -> link
property var previews: ({}) // machine name -> [[r, g, b]]
property var seenAt: ({}) // "m:name" / "l:name" -> last push
property var globalClamp: ({ curr: L.FULL, tgt: L.FULL, rate: 0 })
property var placeClamps: ({}) // place -> {curr, tgt, rate}
property var ladder: [] // [{value, name}]
property var fades: [] // [{rate, name}]
property var library: ({}) // class -> {entry -> spec}
property int rev: 0
property bool dirty: false
property var circleDefs: ({}) // circle key -> {key, label, macs, place}
property string notice: ""
// ── view state ──────────────────────────────────────────────────────
property string focusKey: "" // "" = everything; else a circle key
property int currentTab: 0
readonly property var focusDef: focusKey !== "" ? (circleDefs[focusKey] || null) : null
ListModel { id: circlesModel } // {key, label}
ListModel { id: linksModel } // {key}
ListModel { id: machinesModel } // {key}
ListModel { id: devicesModel } // {key}
ListModel { id: placesModel } // {key}
readonly property ListModel circlesModelRef: circlesModel
readonly property ListModel linksModelRef: linksModel
readonly property ListModel machinesModelRef: machinesModel
readonly property ListModel devicesModelRef: devicesModel
readonly property ListModel placesModelRef: placesModel
PhoenixSocket {
id: phx
url: L.socketUrl(root.baseUrl)
onOpened: root.resetState()
onMessage: function(topic, event, payload) { root.onPush(topic, event, payload) }
Component.onCompleted: root.joinAll()
}
property alias socket: phx
function joinAll() {
phx.join("devices", {})
phx.join("machines", { previews: root.previewsWanted })
phx.join("links", {})
phx.join("brightness", {})
}
onPreviewsWantedChanged: {
previews = {}
phx.join("machines", { previews: previewsWanted })
phx.reconnect()
}
onCirclesJsonChanged: markDirty()
onHideDarkChanged: markDirty()
onFocusKeyChanged: markDirty()
// ── pushes ──────────────────────────────────────────────────────────
function resetState() {
devices = {}
machines = {}
links = {}
previews = {}
seenAt = {}
placeClamps = {}
markDirty()
}
function markDirty() {
dirty = true
}
Timer {
interval: 200
running: root.dirty
repeat: false
onTriggered: {
root.dirty = false
root.rebuild()
root.rev++
}
}
function onPush(topic, event, payload) {
var now = Date.now()
switch (topic + "/" + event) {
case "devices/devices":
// snapshot on join, deltas after: merge by mac
var incoming = payload.devices || {}
for (var mac in incoming) devices[mac] = incoming[mac]
break
case "devices/probe_result":
return
case "machines/machine":
if (!payload.name) return
machines[payload.name] = payload
seenAt["m:" + payload.name] = now
break
case "machines/machine_gone":
delete machines[payload.name]
delete previews[payload.name]
break
case "machines/preview":
if (payload.name) previews[payload.name] = payload.pixels || []
break
case "machines/library":
library = payload.library || {}
break
case "links/link":
if (!payload.name) return
links[payload.name] = payload
seenAt["l:" + payload.name] = now
break
case "brightness/brightness":
if (payload.global) globalClamp = payload.global
placeClamps = payload.places || {}
break
case "brightness/ladder":
ladder = payload.steps || []
fades = payload.fades || []
break
default:
return
}
markDirty()
}
// Links have no "gone" push and a quit machine may miss its own, so
// anything that hasn't published in a while (they publish every 400ms)
// has stopped.
Timer {
interval: 1000
running: phx.live
repeat: true
onTriggered: {
var now = Date.now()
var dropped = false
for (var k in root.seenAt) {
if (now - root.seenAt[k] < 3000) continue
var name = k.substring(2)
if (k.charAt(0) === "m") {
delete root.machines[name]
delete root.previews[name]
} else {
delete root.links[name]
}
delete root.seenAt[k]
dropped = true
}
if (dropped) root.markDirty()
}
}
// Wall-clock watchdog. Timers don't fire during suspend, so a tick far
// later than scheduled means the machine slept and the socket is
// suspect: dial again rather than wait out a heartbeat.
Timer {
property double lastTick: Date.now()
interval: 5000
running: root.hasValidConfig && root.available
repeat: true
onTriggered: {
var now = Date.now()
var gap = now - lastTick
lastTick = now
if (gap > interval * 6) {
console.log("Lambent: resumed after", Math.round(gap / 1000), "s, reconnecting")
phx.reconnect()
}
}
}
// ── derived: places, circles, filtered lists ────────────────────────
// every place a device claims, plus any carrying a clamp with no device
function allPlaces() {
var set = {}
for (var mac in devices) if (devices[mac].place) set[devices[mac].place] = true
for (var p in placeClamps) set[p] = true
return Object.keys(set).sort(function(a, b) { return a.localeCompare(b) })
}
function macsInPlace(place) {
var out = []
for (var mac in devices) if (devices[mac].place === place) out.push(mac)
return out
}
function parseCircles() {
try {
var parsed = JSON.parse(circlesJson || "[]")
return Array.isArray(parsed) ? parsed : []
} catch (e) {
return []
}
}
// [{key, label, macs, place}], in the order configured; nothing
// configured means one per place (or per device, before any has one).
function resolveCircles() {
var wanted = parseCircles()
var out = []
var i
function placeCircle(p) {
return { key: "place:" + p, label: p, macs: macsInPlace(p), place: p }
}
function deviceCircle(mac) {
var d = devices[mac]
return { key: "device:" + mac, label: L.deviceLabel(d, mac), macs: [mac], place: d ? (d.place || "") : "" }
}
if (wanted.length === 0) {
var places = allPlaces()
for (i = 0; i < places.length; i++) out.push(placeCircle(places[i]))
if (out.length === 0) {
var macs = Object.keys(devices).sort()
for (i = 0; i < macs.length; i++) out.push(deviceCircle(macs[i]))
}
} else {
for (i = 0; i < wanted.length; i++) {
var w = wanted[i]
if (w.kind === "place" && w.name) out.push(placeCircle(w.name))
else if (w.kind === "device" && w.mac) out.push(deviceCircle(w.mac))
}
}
if (hideDark) out = out.filter(function(c) { return linksOn(c.macs).length > 0 })
return out
}
// enabled links driving any of these devices
function linksOn(macs) {
var out = []
for (var name in links) {
var l = links[name]
if (l.enabled && macs.indexOf(l.target) !== -1) out.push(l)
}
return out
}
function placeClamp(place) {
return (place && placeClamps[place]) || null
}
// What a circle shows: whether anything is lit on it, the pixels of
// whatever is driving it, and how far the clamps scale those down.
function circleInfo(key) {
var def = circleDefs[key]
if (!def) return { lit: false, pixels: [], level: 1, walking: false, links: [] }
var on = linksOn(def.macs)
var pixels = []
var sources = {}
for (var i = 0; i < on.length; i++) {
if (sources[on[i].source]) continue
sources[on[i].source] = true
pixels = pixels.concat(previews[on[i].source] || [])
}
var pc = placeClamp(def.place)
var level = (globalClamp.curr / L.FULL) * (pc ? pc.curr / L.FULL : 1)
return {
lit: on.length > 0,
pixels: L.resample(pixels, 24),
level: level,
walking: globalClamp.curr !== globalClamp.tgt || (!!pc && pc.curr !== pc.tgt),
links: on
}
}
function circleTooltip(key) {
var def = circleDefs[key]
if (!def) return ""
var info = circleInfo(key)
var lines = []
var pc = placeClamp(def.place)
var bright = i18n("Global %1", L.rungName(globalClamp.tgt, ladder))
if (def.place) bright += " · " + i18n("%1 %2", def.place, pc ? L.rungName(pc.tgt, ladder) : i18n("full"))
lines.push(bright)
if (info.links.length === 0) {
lines.push("<i>" + i18n("Nothing linked") + "</i>")
} else {
for (var i = 0; i < info.links.length && i < 8; i++) {
var l = info.links[i]
lines.push("<b>" + l.source + "</b> → " + L.deviceLabel(devices[l.target], l.target)
+ (l.chan !== "all" ? " (" + L.chanLabel(l.chan) + ")" : ""))
}
}
if (def.macs.length > 1) lines.push("<small>" + i18np("%1 device", "%1 devices", def.macs.length) + "</small>")
if (wheelBrightness) lines.push("<small>" + i18n("Scroll to dim or brighten") + "</small>")
return lines.join("<br/>")
}
// Upsert `items` into a ListModel in order, keyed on `key`, touching
// only rows that changed so open delegates (and tooltips) survive.
function syncModel(model, items) {
for (var i = 0; i < items.length; i++) {
var item = items[i]
var found = -1
for (var j = i; j < model.count; j++) {
if (model.get(j).key === item.key) { found = j; break }
}
if (found === -1) {
model.insert(i, item)
continue
}
if (found !== i) model.move(found, i, 1)
var row = model.get(i)
for (var prop in item) {
if (row[prop] !== item[prop]) model.setProperty(i, prop, item[prop])
}
}
while (model.count > items.length) model.remove(model.count - 1)
}
function rebuild() {
var circles = resolveCircles()
var defs = {}
for (var i = 0; i < circles.length; i++) defs[circles[i].key] = circles[i]
circleDefs = defs
syncModel(circlesModel, circles.map(function(c) { return { key: c.key, label: c.label } }))
var def = focusKey !== "" ? defs[focusKey] : null
if (focusKey !== "" && !def) focusKey = ""
var macs = def ? def.macs : null
var linkNames = Object.keys(links).filter(function(n) {
return !macs || macs.indexOf(links[n].target) !== -1
}).sort()
syncModel(linksModel, linkNames.map(function(n) { return { key: n } }))
var machineNames = Object.keys(machines)
if (macs) {
var driving = {}
for (var k = 0; k < linkNames.length; k++) driving[links[linkNames[k]].source] = true
machineNames = machineNames.filter(function(n) { return driving[n] || driving[machines[n].id] })
}
machineNames.sort()
syncModel(machinesModel, machineNames.map(function(n) { return { key: n } }))
var deviceMacs = (macs || Object.keys(devices)).filter(function(m) { return !!devices[m] })
deviceMacs.sort(function(a, b) {
var pa = devices[a].place || "", pb = devices[b].place || ""
if (pa !== pb) return pa.localeCompare(pb)
return L.deviceLabel(devices[a], a).localeCompare(L.deviceLabel(devices[b], b))
})
syncModel(devicesModel, deviceMacs.map(function(m) { return { key: m } }))
var places = def ? (def.place ? [def.place] : []) : allPlaces()
syncModel(placesModel, places.map(function(p) { return { key: p } }))
}
// ── commands ────────────────────────────────────────────────────────
function command(topic, event, payload) {
phx.push(topic, event, payload, function(ok, response) {
if (ok) return
var why = response.reason || (response.errors ? JSON.stringify(response.errors) : i18n("rejected"))
showNotice(event + ": " + why)
})
}
function showNotice(text) {
notice = text
noticeTimer.restart()
}
Timer {
id: noticeTimer
interval: 6000
onTriggered: root.notice = ""
}
function machineCommand(cmd, name) { command("machines", cmd, { id: name }) }
function linkCommand(cmd, name) { command("links", cmd, { id: name }) }
function poke(mac) { command("devices", "poke", { id: mac, mode: "auto" }) }
function refreshDevices() { command("devices", "refresh", {}) }
function libraryAdd(cls, entry) { command("machines", "library_add", { cls: cls, entry: entry }) }
function createLink(source, mac) {
var label = L.deviceLabel(devices[mac], mac)
command("links", "create", { link: { name: source + "-" + label, source_id: source, target_id: mac, chan: "all" } })
}
// brightness clamps: an empty place is the global clamp
function clampPayload(place, rest) {
if (place) rest.place = place
return rest
}
function setClamp(place, value) { command("brightness", "set", clampPayload(place, { value: value })) }
function clampStep(place, up) { command("brightness", up ? "brighter" : "dimmer", clampPayload(place, {})) }
function clearClamp(place) { command("brightness", "clear", { place: place }) }
// ── panel interactions ──────────────────────────────────────────────
function openAll() {
if (root.expanded && focusKey === "") {
root.expanded = false
} else {
focusKey = ""
root.expanded = true
}
}
function openCircle(key) {
if (root.expanded && focusKey === key) {
root.expanded = false
} else {
focusKey = key
root.expanded = true
}
}
function statusText() {
if (!available) return phx.unavailableReason
if (!hasValidConfig) return i18n("Not configured")
switch (phx.connState) {
case "live": return i18n("Connected to %1", baseUrl)
case "connecting": return i18n("Connecting to %1…", baseUrl)
case "offline": return i18n("Can't reach %1", baseUrl) + (phx.lastError ? " — " + phx.lastError : "")
default: return ""
}
}
// ── representations ─────────────────────────────────────────────────
compactRepresentation: CircleStrip {
store: root
inPanel: true
}
fullRepresentation: FullView {
store: root
}
}
neiam
/lambent-ex-plasmoid
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
18.1 KiB · text
History
e43831b