diff --git a/README.md b/README.md index 64fc07c..6ef0a05 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,12 @@ Add Home Assistant to your plasma desktop. Base | Required package --|-- Debian, Ubuntu | qml-module-qtwebsockets - Arch, Fedora | qt5-websockets + Arch | qt5-websockets + Fedora | qt5-qtwebsockets-devel openSUSE | libQt5WebSockets5 + NixOS | libsForQt5.qt5.qtwebsockets - Please remember to restart plasma or re-login after installing the dependency. + **Please remember to restart plasma or re-login after installing the dependency.** ## Installation diff --git a/package/contents/ui/WsClient.qml b/package/contents/ui/Client.qml similarity index 58% rename from package/contents/ui/WsClient.qml rename to package/contents/ui/Client.qml index 7fee0ea..3123065 100644 --- a/package/contents/ui/WsClient.qml +++ b/package/contents/ui/Client.qml @@ -4,56 +4,80 @@ import QtWebSockets 1.0 import "components" BaseObject { - property string apiUrl property string baseUrl property string token property var subscribeState: ws.subscribeState - property var unsubscribe: ws.unsubscribe property var callService: ws.callService property var getServices: ws.getServices property var getStates: ws.getStates - - signal ready() - signal stateChanged(var state) + property string errorString: "" + readonly property bool configured: ws.url && token + + onBaseUrlChanged: ws.url = baseUrl.replace('http', 'ws') + "/api/websocket" + onConfiguredChanged: ws.active = configured - onBaseUrlChanged: apiUrl = baseUrl.replace('http', 'ws') + "/api/websocket" - onTokenChanged: ws.active = apiUrl && token + Connections { + target: ws + onError: errorString = msg + onEstablished: errorString = "" + } + + readonly property QtObject ready: QtObject { + function connect (fn) { + if (ws.ready) fn() + ws.established.connect(fn) + } + function disconnect (fn) { + ws.established.disconnect(fn) + } + } Timer { id: pingPongTimer interval: 30000 - running: ws.active + running: ws.status repeat: true property bool waiting onTriggered: { - if (waiting) { + if (waiting || !ws.open) { ws.reconnect() } else { ws.ping() } waiting = !waiting } + function reset() { + waiting = false + restart() + } } WebSocket { id: ws - url: apiUrl + property bool ready: false property int messageCounter: 0 + property var subscriptions: new Map() property var promises: new Map() + readonly property bool open: status === WebSocket.Open + signal established + signal error(string msg) + + onOpenChanged: ready = false + onReadyChanged: ready && established() onTextMessageReceived: { + pingPongTimer.reset() const msg = JSON.parse(message) switch (msg.type) { case 'auth_required': auth(token); break; - case 'auth_ok': ready(); break; - case 'auth_invalid': console.error(msg.message); break; - case 'event': stateChanged(msg.event); break; + case 'auth_ok': ready = true; break; + case 'auth_invalid': error(msg.message); break; + case 'event': notifyStateUpdate(msg); break; case 'result': handleResult(msg); break; - case 'pong': pingPongTimer.waiting = false } } - onErrorStringChanged: errorString && console.error(errorString) + onErrorStringChanged: errorString && error(errorString) function reconnect() { active = false @@ -75,12 +99,19 @@ BaseObject { send({"type": "auth", "access_token": token}) } - function subscribeState(entities) { - if (!entities) return - return subscribe({ + function notifyStateUpdate(msg) { + const callback = subscriptions.get(msg.id) + return callback && callback(msg.event.variables.trigger.to_state) + } + + function subscribeState(entities, callback) { + if (!callback) return + const subscription = subscribe({ "platform": "state", "entity_id": entities }) + subscriptions.set(subscription, callback) + return () => unsubscribe(subscription) } function subscribe(trigger) { @@ -88,7 +119,9 @@ BaseObject { } function unsubscribe(subscription) { - return command({"type": "unsubscribe_events", subscription}) + if(!subscriptions.has(subscription)) return + return commandAsync({"type": "unsubscribe_events", subscription}) + .then(() => subscriptions.delete(subscription)) } function callService({ domain, service, data, target } = {}) { @@ -127,5 +160,11 @@ BaseObject { sendTextMessage(JSON.stringify(message)) return messageCounter++ } + + function unsubscribeAll() { + Array.from(subscriptions.keys()).forEach(unsubscribe) + } + + Component.onDestruction: unsubscribeAll() } } \ No newline at end of file diff --git a/package/contents/ui/ClientFactory.qml b/package/contents/ui/ClientFactory.qml new file mode 100644 index 0000000..bee70f9 --- /dev/null +++ b/package/contents/ui/ClientFactory.qml @@ -0,0 +1,43 @@ +pragma Singleton + +import QtQuick 2.0 + +import "components" + +BaseObject { + readonly property var _instances: new Map() + readonly property Component clientComponent: Qt.createComponent("Client.qml") + readonly property bool error: clientComponent.status === Component.Error + readonly property var errorString: clientComponent.errorString + + Secrets { + id: secrets + readonly property var init: new Promise(resolve => onReady.connect(resolve)) + function getToken(url) { + return init.then(() => get(url)) + } + } + + function getClient(consumer, baseUrl) { + if (!(consumer instanceof QtObject) || !baseUrl) return + let instance = _findInstance(baseUrl) + if (!instance) { + instance = _createClient(baseUrl) + } + if (!_instances.has(consumer)) { + consumer.Component.destruction.connect(() => _instances.delete(consumer)) + } + _instances.set(consumer, instance) + return instance + } + + function _createClient(baseUrl) { + const client = clientComponent.createObject(null, { baseUrl }) + secrets.getToken(baseUrl).then(t => client.token = t) + return client + } + + function _findInstance(url) { + return Array.from(_instances.values()).find(i => i.baseUrl === url) + } +} \ No newline at end of file diff --git a/package/contents/ui/ConfigGeneral.qml b/package/contents/ui/ConfigGeneral.qml index b948f65..e44147e 100644 --- a/package/contents/ui/ConfigGeneral.qml +++ b/package/contents/ui/ConfigGeneral.qml @@ -3,26 +3,49 @@ import QtQuick.Controls 2.0 import QtQuick.Layouts 1.0 import org.kde.kirigami 2.4 as Kirigami -import "." Kirigami.FormLayout { - property alias cfg_url: url.text + property string cfg_url property alias cfg_flat: flat.checked signal configurationChanged - onCfg_urlChanged: Secrets.entryKey = cfg_url + Secrets { + id: secrets + property string token + onReady: { + restore(cfg_url) + list().then(urls => (url.model = urls)) + } + + function restore(entryKey) { + if (!entryKey) { + return this.token = "" + } + get(entryKey) + .then(t => this.token = t) + .catch(() => this.token = "") + } + } Item { Kirigami.FormData.isSection: true Kirigami.FormData.label: i18n("API") } - TextField { + ComboBox { id: url - onEditingFinished: Secrets.entryKey = url.text - placeholderText: "http://homeassistant.local:8123" + editable: true + onModelChanged: currentIndex = indexOfValue(cfg_url) + onFocusChanged: !focus && onValueChanged(editText) + onActivated: onValueChanged(editText) Kirigami.FormData.label: i18n("Home Assistant URL") + Layout.fillWidth: true + + function onValueChanged(value) { + cfg_url = value + secrets.restore(editText) + } } Label { @@ -31,8 +54,8 @@ Kirigami.FormLayout { TextField { id: token - text: Secrets.token - onTextChanged: text !== Secrets.token && configurationChanged() + text: secrets.token + onTextChanged: text !== secrets.token && configurationChanged() Kirigami.FormData.label: i18n("Token") } @@ -41,9 +64,9 @@ Kirigami.FormLayout { } Label { - text: `${url.text}/profile` + text: `${url.editText}/profile` onLinkActivated: Qt.openUrlExternally(link) - visible: url.text + visible: url.editText } Item { @@ -57,6 +80,6 @@ Kirigami.FormLayout { } function saveConfig() { - Secrets.token = token.text + secrets.set(url.editText, token.text) } } \ No newline at end of file diff --git a/package/contents/ui/ConfigItems.qml b/package/contents/ui/ConfigItems.qml index 0184ffd..5453ad4 100644 --- a/package/contents/ui/ConfigItems.qml +++ b/package/contents/ui/ConfigItems.qml @@ -1,4 +1,4 @@ -import QtQuick 2.4 +import QtQuick 2.0 import QtQuick.Controls 2.5 import QtQuick.Layouts 1.0 @@ -12,15 +12,28 @@ ColumnLayout { property var items: JSON.parse(cfg_items) property var services: ({}) property var entities: ({}) + property bool busy: true + property Client ha - WsClient { - id: ha - baseUrl: plasmoid.configuration.url - token: Secrets.token - onReady: { - ha.getStates().then(s => entities = arrayToObject(s, 'entity_id')) - ha.getServices().then(s => services = s) - } + Component.onCompleted: { + ha = ClientFactory.getClient(this, plasmoid.configuration.url) + ha.ready.connect(fetchData) + } + + function fetchData() { + return Promise.all([ha.getStates(), ha.getServices()]) + .then(([e, s]) => { + entities = arrayToObject(e, 'entity_id') + services = s + busy = false + }).catch(() => busy = false) + } + + Kirigami.InlineMessage { + Layout.fillWidth: true + text: ha && ha.errorString + visible: !!text + type: Kirigami.MessageType.Error } ScrollView { @@ -33,12 +46,18 @@ ColumnLayout { model: Object.keys(entities).length ? items : [] delegate: listItem spacing: Kirigami.Units.mediumSpacing + + BusyIndicator { + anchors.centerIn: parent + visible: busy + } } } Button { icon.name: 'list-add' text: i18n("Add") + enabled: !busy onClicked: openDialog(new Model.ConfigEntity()) Layout.fillWidth: true } diff --git a/package/contents/ui/FullRepresentation.qml b/package/contents/ui/FullRepresentation.qml index 225c994..6ddbb69 100644 --- a/package/contents/ui/FullRepresentation.qml +++ b/package/contents/ui/FullRepresentation.qml @@ -6,6 +6,8 @@ import org.kde.plasma.core 2.1 as PlasmaCore import org.kde.plasma.extras 2.0 as PlasmaExtras import org.kde.plasma.components 3.0 as PlasmaComponents3 +import "components" + PlasmaExtras.Representation { readonly property var appletInterface: plasmoid.self @@ -47,4 +49,30 @@ PlasmaExtras.Representation { visible: plasmoid.busy anchors.centerIn: parent } + + StatusIndicator { + icon: "data-error" + size: PlasmaCore.Units.iconSizes.small + message: ha && ha.errorString + anchors { + bottom: parent.bottom + right: parent.right + } + } + + Loader { + width: parent.width + anchors.centerIn: parent + active: ClientFactory.error + sourceComponent: PlasmaExtras.PlaceholderMessage { + text: i18n("Failed to create WebSocket client") + explanation: ClientFactory.errorString().split(/\:\d+\s/)[1] + iconName: "error" + helpfulAction: Action { + icon.name: "link" + text: i18n("Show requirements") + onTriggered: Qt.openUrlExternally(`${plasmoid.metaData.website}/tree/v${plasmoid.metaData.version}#requirements`) + } + } + } } diff --git a/package/contents/ui/Secrets.qml b/package/contents/ui/Secrets.qml index c290373..e59ee55 100644 --- a/package/contents/ui/Secrets.qml +++ b/package/contents/ui/Secrets.qml @@ -1,23 +1,7 @@ -pragma Singleton - import QtQuick 2.0 import "../lib/secrets" Secrets { appId: "HomeAssistant" - property string token - property string entryKey - - onReady: { - restore() - onEntryKeyChanged.connect(restore) - onTokenChanged.connect(() => set(entryKey, token)) - } - - function restore() { - if (entryKey) { - get(entryKey).then(t => token = t) - } - } } \ No newline at end of file diff --git a/package/contents/ui/components/StatusIndicator.qml b/package/contents/ui/components/StatusIndicator.qml new file mode 100644 index 0000000..5e6db37 --- /dev/null +++ b/package/contents/ui/components/StatusIndicator.qml @@ -0,0 +1,21 @@ +import QtQuick 2.0 +import QtQuick.Controls 2.5 + +import org.kde.plasma.core 2.1 as PlasmaCore + +PlasmaCore.IconItem { + property var icon + property int size + property string message + + width: size + source: icon + visible: icon && message + + MouseArea { + anchors.fill: parent + hoverEnabled: true + ToolTip.visible: containsMouse + ToolTip.text: message + } +} \ No newline at end of file diff --git a/package/contents/ui/components/qmldir b/package/contents/ui/components/qmldir deleted file mode 100644 index 52f4ba1..0000000 --- a/package/contents/ui/components/qmldir +++ /dev/null @@ -1 +0,0 @@ -BaseObject 1.0 BaseObject.qml \ No newline at end of file diff --git a/package/contents/ui/main.qml b/package/contents/ui/main.qml index 545330c..b54f2cf 100644 --- a/package/contents/ui/main.qml +++ b/package/contents/ui/main.qml @@ -11,8 +11,8 @@ Item { Plasmoid.compactRepresentation: CompactRepresentation {} Plasmoid.fullRepresentation: FullRepresentation {} Plasmoid.backgroundHints: PlasmaCore.Types.StandardBackground | PlasmaCore.Types.ConfigurableBackground - Plasmoid.configurationRequired: !url || !ha.token || !items.length - Plasmoid.busy: !plasmoid.configurationRequired && !initialized + Plasmoid.configurationRequired: !ClientFactory.error && (!url || !ha || !ha.token || !items.length) + Plasmoid.busy: !ClientFactory.error && !plasmoid.configurationRequired && !initialized Plasmoid.switchHeight: PlasmaCore.Units.iconSizes.enormous / 2 Plasmoid.switchWidth: PlasmaCore.Units.iconSizes.enormous @@ -20,33 +20,32 @@ Item { readonly property string cfgItems: plasmoid.configuration.items property ListModel itemModel: ListModel {} property var items: [] - property int subscription: 0 property bool initialized: false - - onUrlChanged: url && (Secrets.entryKey = url) - onCfgItemsChanged: items = JSON.parse(cfgItems) + property QtObject ha + property var cancelSubscription - WsClient { - id: ha - baseUrl: url - token: Secrets.token - onReady: { - subscribe() - onItemsChanged.connect(subscribe) - } - onStateChanged: updateState(state) - } + onCfgItemsChanged: items = JSON.parse(cfgItems) + onUrlChanged: url && initClient(url) Notifications { id: notifications } - function updateState(event) { - if (!event || !event.variables) return - const trigger = event.variables.trigger - const itemIdx = items.findIndex(i => i.entity_id === trigger.entity_id) + function initClient(url) { + if (ha) { + unsubscribe() + ha.ready.disconnect(subscribe) + onItemsChanged.disconnect(subscribe) + } + ha = ClientFactory.getClient(this, url) + ha.ready.connect(subscribe) + onItemsChanged.connect(subscribe) + } + + function updateState(state) { + const itemIdx = items.findIndex(i => i.entity_id === state.entity_id) const configItem = items[itemIdx] - const newItem = new Model.Entity(configItem, trigger.to_state) + const newItem = new Model.Entity(configItem, state) const oldValue = itemModel.get(itemIdx).value itemModel.set(itemIdx, newItem) if (configItem.notify && oldValue !== newItem.value) { @@ -63,18 +62,16 @@ Item { initialized = true } - function unsubscribe() { - if (!subscription) return - ha.unsubscribe(subscription) - subscription = 0 - } - - function subscribe() { + function subscribe() { unsubscribe() if (!items.length) return const entities = items.map(i => i.entity_id) ha.getStates(entities).then(initState) - subscription = ha.subscribeState(entities) + cancelSubscription = ha.subscribeState(entities, updateState) + } + + function unsubscribe() { + cancelSubscription = typeof cancelSubscription === 'function' && cancelSubscription() } Component.onCompleted: { diff --git a/package/contents/ui/mdi/mdi.svgz b/package/contents/ui/mdi/mdi.svgz index c10e3dd..df32e43 100644 Binary files a/package/contents/ui/mdi/mdi.svgz and b/package/contents/ui/mdi/mdi.svgz differ diff --git a/package/contents/ui/qmldir b/package/contents/ui/qmldir index 0bb5269..679325c 100644 --- a/package/contents/ui/qmldir +++ b/package/contents/ui/qmldir @@ -1 +1 @@ -singleton Secrets 1.0 Secrets.qml \ No newline at end of file +singleton ClientFactory 1.0 ClientFactory.qml \ No newline at end of file