13.9 KiB · text History e43831b
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.kcmutils as KCM

import "Lambent.js" as L

KCM.SimpleKCM {
    id: configGeneral

    property alias cfg_serverUrl: serverUrlField.text
    property string cfg_circles: "[]"

    // Connection test: a socket of our own, only dialled on request
    property bool isTesting: false
    property string testResult: ""
    property bool testSuccess: false
    property var devices: ({})
    property var places: []

    readonly property string base: L.normalizeBase(serverUrlField.text)

    readonly property var circles: {
        try {
            var parsed = JSON.parse(cfg_circles || "[]")
            return Array.isArray(parsed) ? parsed : []
        } catch (e) {
            return []
        }
    }

    readonly property var deviceMacs: Object.keys(devices).sort(function(a, b) {
        return L.deviceLabel(devices[a], a).localeCompare(L.deviceLabel(devices[b], b))
    })

    PhoenixSocket {
        id: probe
        url: ""

        onOpened: probe.join("devices", {})
        onMessage: function(topic, event, payload) {
            if (topic !== "devices" || event !== "devices") return
            var merged = Object.assign({}, configGeneral.devices, payload.devices || {})
            configGeneral.devices = merged
            var set = {}
            for (var mac in merged) if (merged[mac].place) set[merged[mac].place] = true
            configGeneral.places = Object.keys(set).sort()
            // MQTT devices land a beat after the snapshot; give them a moment
            settle.restart()
        }
        onConnStateChanged: {
            if (probe.connState === "offline" && configGeneral.isTesting) {
                configGeneral.finishTest(false, i18n("Could not reach %1", configGeneral.base)
                                         + (probe.lastError ? " — " + probe.lastError : ""))
            }
        }
    }

    Timer {
        id: settle
        interval: 800
        onTriggered: {
            var n = Object.keys(configGeneral.devices).length
            configGeneral.finishTest(true, i18n("Connected — %1 in %2",
                                                i18np("%1 device", "%1 devices", n),
                                                i18np("%1 place", "%1 places", configGeneral.places.length)))
        }
    }

    Timer {
        id: giveUp
        interval: 8000
        onTriggered: configGeneral.finishTest(false, probe.live ? i18n("Connected, but no devices were reported")
                                                                : i18n("Timed out reaching %1", configGeneral.base))
    }

    function testConnection() {
        if (!probe.available) {
            finishTest(false, probe.unavailableReason)
            return
        }
        if (base === "") {
            finishTest(false, i18n("Enter the address of your LambentEx box"))
            return
        }
        isTesting = true
        testResult = ""
        devices = {}
        places = []
        giveUp.restart()
        var url = L.socketUrl(base)
        if (probe.url === url) probe.reconnect()
        else probe.url = url
    }

    function finishTest(ok, message) {
        if (!isTesting) return
        isTesting = false
        giveUp.stop()
        settle.stop()
        testResult = message
        testSuccess = ok
        // keep the device list, drop the socket
        probe.url = ""
    }

    // ── circle list editing ─────────────────────────────────────────────

    function save(list) {
        cfg_circles = JSON.stringify(list)
    }

    function hasPlace(name) {
        return circles.some(function(c) { return c.kind === "place" && c.name === name })
    }

    function hasDevice(mac) {
        return circles.some(function(c) { return c.kind === "device" && c.mac === mac })
    }

    function addPlace(name) {
        if (!name || hasPlace(name)) return
        save(circles.concat([{ kind: "place", name: name }]))
    }

    function addDevice(mac) {
        if (!mac || hasDevice(mac)) return
        var d = devices[mac]
        // keep a label so the list still reads when the box is unreachable
        save(circles.concat([{ kind: "device", mac: mac, label: L.deviceLabel(d, mac) }]))
    }

    function removeAt(index) {
        var list = circles.slice()
        list.splice(index, 1)
        save(list)
    }

    function move(index, delta) {
        var target = index + delta
        if (target < 0 || target >= circles.length) return
        var list = circles.slice()
        var item = list.splice(index, 1)[0]
        list.splice(target, 0, item)
        save(list)
    }

    function addManual() {
        var text = manualField.text.trim()
        if (!text) return
        // a MAC is a device; anything else is a place name
        if (/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/i.test(text)) addDevice(text.toLowerCase())
        else addPlace(text)
        manualField.text = ""
    }

    Component.onCompleted: if (base !== "" && probe.available) Qt.callLater(testConnection)

    ColumnLayout {
        spacing: Kirigami.Units.largeSpacing

        // ── connection ──────────────────────────────────────────────────
        Kirigami.FormLayout {
            Layout.fillWidth: true

            QQC2.TextField {
                id: serverUrlField
                Kirigami.FormData.label: i18n("LambentEx address:")
                placeholderText: i18n("172.16.13.5 or http://lambent.local:8033")
                Layout.fillWidth: true
                onTextChanged: testResult = ""
                onAccepted: testConnection()
            }

            QQC2.Label {
                visible: configGeneral.base !== "" && configGeneral.base !== serverUrlField.text.trim()
                Layout.fillWidth: true
                font: Kirigami.Theme.smallFont
                opacity: 0.7
                text: i18n("Connects to %1", configGeneral.base)
            }

            RowLayout {
                Kirigami.FormData.label: i18n("Connection:")

                QQC2.Button {
                    text: i18n("Test Connection")
                    icon.name: "network-connect"
                    enabled: !isTesting && configGeneral.base !== ""
                    onClicked: testConnection()
                }

                QQC2.BusyIndicator {
                    visible: isTesting
                    Layout.preferredWidth: Kirigami.Units.iconSizes.small
                    Layout.preferredHeight: Kirigami.Units.iconSizes.small
                }
            }

            QQC2.Label {
                visible: testResult !== "" || !probe.available
                text: probe.available ? testResult : probe.unavailableReason
                color: testSuccess && probe.available ? Kirigami.Theme.positiveTextColor : Kirigami.Theme.negativeTextColor
                wrapMode: Text.WordWrap
                Layout.fillWidth: true
                font: Kirigami.Theme.smallFont
            }

            QQC2.Label {
                Layout.fillWidth: true
                wrapMode: Text.WordWrap
                font: Kirigami.Theme.smallFont
                color: Kirigami.Theme.disabledTextColor
                text: i18n("The pairing QR in the web UI (the ⬛ icon, top right of any page) shows the address. A bare host is fine: port 8033 and plain http are assumed. There's nothing to log into — the socket is open on the LAN, same as the web UI.")
            }
        }

        // ── circles ─────────────────────────────────────────────────────
        Kirigami.Heading {
            level: 3
            text: i18n("Circles in the panel")
        }

        QQC2.Label {
            visible: circles.length === 0
            Layout.fillWidth: true
            wrapMode: Text.WordWrap
            opacity: 0.7
            text: i18n("Nothing chosen: one circle per place, in alphabetical order (or one per device until any has a place). Add places or devices below to pick and order them yourself.")
        }

        Repeater {
            model: circles

            delegate: RowLayout {
                required property var modelData
                required property int index
                readonly property bool isPlace: modelData.kind === "place"

                Layout.fillWidth: true
                spacing: Kirigami.Units.smallSpacing

                Kirigami.Icon {
                    source: isPlace ? "go-home" : "lighttable"
                    Layout.preferredWidth: Kirigami.Units.iconSizes.small
                    Layout.preferredHeight: Kirigami.Units.iconSizes.small
                }

                QQC2.Label {
                    Layout.fillWidth: true
                    elide: Text.ElideRight
                    text: isPlace ? modelData.name
                          : (configGeneral.devices[modelData.mac] ? L.deviceLabel(configGeneral.devices[modelData.mac], modelData.mac)
                                                                  : (modelData.label || modelData.mac))
                            + "  (" + modelData.mac + ")"
                }

                QQC2.ToolButton {
                    icon.name: "go-up"
                    enabled: index > 0
                    onClicked: move(index, -1)
                }

                QQC2.ToolButton {
                    icon.name: "go-down"
                    enabled: index < circles.length - 1
                    onClicked: move(index, 1)
                }

                QQC2.ToolButton {
                    icon.name: "list-remove"
                    onClicked: removeAt(index)
                    QQC2.ToolTip.text: i18n("Remove this circle")
                    QQC2.ToolTip.visible: hovered
                }
            }
        }

        RowLayout {
            Layout.fillWidth: true

            QQC2.TextField {
                id: manualField
                Layout.fillWidth: true
                placeholderText: i18n("A place name, or a device's MAC address")
                onAccepted: addManual()
            }

            QQC2.Button {
                text: i18n("Add")
                icon.name: "list-add"
                enabled: manualField.text.trim() !== ""
                onClicked: addManual()
            }
        }

        // ── what the box knows about ────────────────────────────────────
        Kirigami.Heading {
            level: 3
            text: i18n("On the box")
        }

        QQC2.Label {
            visible: deviceMacs.length === 0
            Layout.fillWidth: true
            wrapMode: Text.WordWrap
            opacity: 0.7
            text: i18n("Test the connection to list places and devices.")
        }

        QQC2.Label {
            visible: places.length > 0
            text: i18n("Places")
            font.bold: true
        }

        Repeater {
            model: places

            delegate: RowLayout {
                required property var modelData
                Layout.fillWidth: true

                Kirigami.Icon {
                    source: "go-home"
                    Layout.preferredWidth: Kirigami.Units.iconSizes.small
                    Layout.preferredHeight: Kirigami.Units.iconSizes.small
                }

                QQC2.Label {
                    Layout.fillWidth: true
                    elide: Text.ElideRight
                    text: {
                        var n = 0
                        for (var mac in configGeneral.devices) if (configGeneral.devices[mac].place === modelData) n++
                        return modelData + "  " + i18np("(%1 device)", "(%1 devices)", n)
                    }
                }

                QQC2.Button {
                    text: hasPlace(modelData) ? i18n("Shown") : i18n("Add")
                    icon.name: hasPlace(modelData) ? "checkmark" : "list-add"
                    enabled: !hasPlace(modelData)
                    onClicked: addPlace(modelData)
                }
            }
        }

        QQC2.Label {
            visible: deviceMacs.length > 0
            text: i18n("Devices")
            font.bold: true
        }

        Repeater {
            model: deviceMacs

            delegate: RowLayout {
                required property var modelData
                readonly property var device: configGeneral.devices[modelData]
                Layout.fillWidth: true

                Kirigami.Icon {
                    source: "lighttable"
                    Layout.preferredWidth: Kirigami.Units.iconSizes.small
                    Layout.preferredHeight: Kirigami.Units.iconSizes.small
                }

                ColumnLayout {
                    Layout.fillWidth: true
                    spacing: 0

                    QQC2.Label {
                        Layout.fillWidth: true
                        elide: Text.ElideRight
                        text: L.deviceLabel(device, modelData) + (device && device.place ? " · " + device.place : "")
                    }

                    QQC2.Label {
                        Layout.fillWidth: true
                        elide: Text.ElideRight
                        font: Kirigami.Theme.smallFont
                        opacity: 0.7
                        text: [modelData, device ? device.ip || "" : "", device ? device.type || "" : ""]
                              .filter(function(s) { return s !== "" }).join(" · ")
                    }
                }

                QQC2.Button {
                    text: hasDevice(modelData) ? i18n("Shown") : i18n("Add")
                    icon.name: hasDevice(modelData) ? "checkmark" : "list-add"
                    enabled: !hasDevice(modelData)
                    onClicked: addDevice(modelData)
                }
            }
        }
    }
}