18.1 KiB · text History cc97953
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
    }
}