Skip to content

Commit

Permalink
Merge pull request #14 from korapp/dev
Browse files Browse the repository at this point in the history
Dev
  • Loading branch information
korapp authored Jan 7, 2024
2 parents d2d187b + e732d35 commit 214d90e
Show file tree
Hide file tree
Showing 12 changed files with 243 additions and 88 deletions.
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
77 changes: 58 additions & 19 deletions package/contents/ui/WsClient.qml → package/contents/ui/Client.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -75,20 +99,29 @@ 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) {
return command({"type": "subscribe_trigger", trigger})
}

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 } = {}) {
Expand Down Expand Up @@ -127,5 +160,11 @@ BaseObject {
sendTextMessage(JSON.stringify(message))
return messageCounter++
}

function unsubscribeAll() {
Array.from(subscriptions.keys()).forEach(unsubscribe)
}

Component.onDestruction: unsubscribeAll()
}
}
43 changes: 43 additions & 0 deletions package/contents/ui/ClientFactory.qml
Original file line number Diff line number Diff line change
@@ -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)
}
}
45 changes: 34 additions & 11 deletions package/contents/ui/ConfigGeneral.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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")
}

Expand All @@ -41,9 +64,9 @@ Kirigami.FormLayout {
}

Label {
text: `<a href="${url.text}/profile">${url.text}/profile</a>`
text: `<a href="${url.editText}/profile">${url.editText}/profile</a>`
onLinkActivated: Qt.openUrlExternally(link)
visible: url.text
visible: url.editText
}

Item {
Expand All @@ -57,6 +80,6 @@ Kirigami.FormLayout {
}

function saveConfig() {
Secrets.token = token.text
secrets.set(url.editText, token.text)
}
}
37 changes: 28 additions & 9 deletions package/contents/ui/ConfigItems.qml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import QtQuick 2.4
import QtQuick 2.0
import QtQuick.Controls 2.5
import QtQuick.Layouts 1.0

Expand All @@ -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 {
Expand All @@ -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
}
Expand Down
28 changes: 28 additions & 0 deletions package/contents/ui/FullRepresentation.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`)
}
}
}
}
Loading

0 comments on commit 214d90e

Please sign in to comment.