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