From 32838d4eed874f04e6790b13a3e62c19637dea3a Mon Sep 17 00:00:00 2001 From: Chris ter Beke <1134120+ChrisTerBeke@users.noreply.github.com> Date: Sun, 19 Jan 2025 14:44:34 +0100 Subject: [PATCH] Refactor: do API calls from driver (#14) * Refactor entire app to reduce number of API calls * Validate * Remove settings.json * Add translations for app settings * Many fixes after testing --- app.json | 61 --------------- drivers/uponor/device.ts | 120 +++++++---------------------- drivers/uponor/driver.compose.json | 61 --------------- drivers/uponor/driver.ts | 90 ++++++++++++++-------- lib/UponorHTTPClient.ts | 88 +++++++++++---------- lib/constants.ts | 9 +++ locales/en.json | 6 ++ settings/index.html | 50 ++++++++++++ 8 files changed, 195 insertions(+), 290 deletions(-) create mode 100644 lib/constants.ts create mode 100644 settings/index.html diff --git a/app.json b/app.json index 279b280..d021e0c 100644 --- a/app.json +++ b/app.json @@ -77,67 +77,6 @@ "id": "add_devices", "template": "add_devices" } - ], - "settings": [ - { - "type": "group", - "label": { - "en": "IP address", - "nl": "IP addres" - }, - "children": [ - { - "id": "discovered_address", - "label": { - "en": "Auto-discovered address", - "nl": "Automatisch ontdekte adres" - }, - "type": "label", - "required": false, - "value": "" - }, - { - "id": "address", - "label": { - "en": "Override IP Address", - "nl": "IP-adres overschrijven" - }, - "type": "text", - "required": false, - "hint": { - "en": "Overrides the auto-discovered IP address of the device.", - "nl": "Overschrijft het automatisch ontdekte IP-adres van het apparaat." - } - } - ] - }, - { - "type": "group", - "label": { - "en": "Troubleshooting", - "nl": "Probleemoplossing" - }, - "children": [ - { - "id": "debugEnabled", - "type": "checkbox", - "label": { - "en": "Enable debug data", - "nl": "Schakel probleemverhelping in" - }, - "value": false - }, - { - "id": "apiData", - "type": "textarea", - "label": { - "en": "Last API response", - "nl": "Laatste API-reactie" - }, - "value": "{}" - } - ] - } ] } ], diff --git a/drivers/uponor/device.ts b/drivers/uponor/device.ts index 7a2a404..55df374 100644 --- a/drivers/uponor/device.ts +++ b/drivers/uponor/device.ts @@ -1,28 +1,14 @@ -import { isIPv4 } from 'net' import { Device, DiscoveryResultMAC } from 'homey' import { UponorHTTPClient } from '../../lib/UponorHTTPClient' - -const POLL_INTERVAL_MS = 1000 * 60 * 1 +import { UponorDriver } from './driver' +import { MEASURE_TEMPERATURE_CAPABILITY, TARGET_TEMPERATURE_CAPABILITY, POLL_INTERVAL_MS, INIT_TIMEOUT_MS } from '../../lib/constants' class UponorThermostatDevice extends Device { - private _syncInterval?: NodeJS.Timer - private _client?: UponorHTTPClient - async onInit(): Promise { - this._init() - } - - async onAdded(): Promise { - this._init() - } - - async onUninit(): Promise { - this._uninit() - } - - onDeleted(): void { - this.homey.clearInterval(this._syncInterval) + await this._syncCapabilities() + this.homey.setInterval(this._syncAttributes.bind(this), POLL_INTERVAL_MS) + this.homey.setTimeout(this._syncAttributes.bind(this), INIT_TIMEOUT_MS) } onDiscoveryResult(discoveryResult: DiscoveryResultMAC): boolean { @@ -30,106 +16,54 @@ class UponorThermostatDevice extends Device { } async onDiscoveryAvailable(discoveryResult: DiscoveryResultMAC): Promise { - await this._updateDiscoveredAddress(discoveryResult.address) - await this._updateAddress(discoveryResult.address, true) + await this._updateAddress(discoveryResult.address) } async onDiscoveryAddressChanged(discoveryResult: DiscoveryResultMAC): Promise { - await this._updateDiscoveredAddress(discoveryResult.address) - await this._updateAddress(discoveryResult.address, true) - } - - async onSettings({ newSettings }: { newSettings: { [key: string]: any } }): Promise { - const addressUpdated = await this._updateAddress(newSettings.address as string) - if (!addressUpdated) throw new Error(`Could not connect to Uponor controller on IP address ${newSettings.address}`) - } - - private _getAddress(): string | undefined { - const settingAddress = this.getSetting('address') - if (settingAddress && isIPv4(settingAddress)) return settingAddress - const storeAddress = this.getStoreValue('address') - if (storeAddress && isIPv4(storeAddress)) return storeAddress - return undefined - } - - private async _updateAddress(newAddress: string, persist = false): Promise { - if (newAddress.length === 0) { - newAddress = await this.getStoreValue('address') - } - - if (!isIPv4(newAddress)) { - return false - } - - if (persist) { - await this.setStoreValue('address', newAddress) - } - - if (!this._client) return false - const success = await this._client.updateAddress(newAddress) - return success - } - - private async _updateDiscoveredAddress(newAddress: string): Promise { - if (newAddress.length === 0) return - await this.setSettings({ 'discovered_address': newAddress }) + await this._updateAddress(discoveryResult.address) } - async _init(): Promise { - const address = this._getAddress() - if (!address) return this.setUnavailable('No IP address configured') - this._client = new UponorHTTPClient(address) - this._syncInterval = this.homey.setInterval(this._sync.bind(this), POLL_INTERVAL_MS) - this.homey.setTimeout(this._sync.bind(this), 2000) + private _getClient(): UponorHTTPClient { + const driver = this.driver as UponorDriver + return driver.getClient() } - async _uninit(): Promise { - this.homey.clearInterval(this._syncInterval) - this._syncInterval = undefined - this._client = undefined + private async _updateAddress(newAddress: string): Promise { + const driver = this.driver as UponorDriver + return await driver.setIpAddress(newAddress) } - private async _sync(): Promise { - await this._syncCapabilities() - await this._syncAttributes() + private async _syncCapabilities(): Promise { + await this._ensureCapability(MEASURE_TEMPERATURE_CAPABILITY) + await this._ensureCapability(TARGET_TEMPERATURE_CAPABILITY, this._setTargetTemperature.bind(this)) } - private async _syncCapabilities(): Promise { - this.registerCapabilityListener('target_temperature', this._setTargetTemperature.bind(this)) + private async _ensureCapability(capability: string, callback: Device.CapabilityCallback | undefined = undefined): Promise { + if (!this.hasCapability(capability)) await this.addCapability(capability) + if (callback) this.registerCapabilityListener(capability, callback) } private async _syncAttributes(): Promise { - if (!this._client) return this.setUnavailable('No Uponor client') - const canConnect = await this._client.testConnection() - if (!canConnect) return this.setUnavailable('Could not connect to Uponor controller on local network') - try { - await this._client.syncAttributes() + await this._getClient().syncAttributes() const { controllerID, thermostatID } = this.getData() - const data = this._client.getThermostat(controllerID, thermostatID) + const data = this._getClient().getThermostat(controllerID, thermostatID) if (!data) return this.setUnavailable('Could not find thermostat data') this.setAvailable() - this.setCapabilityValue('measure_temperature', data.temperature) - this.setCapabilityValue('target_temperature', data.setPoint) + this.setCapabilityValue(MEASURE_TEMPERATURE_CAPABILITY, data.temperature) + this.setCapabilityValue(TARGET_TEMPERATURE_CAPABILITY, data.setPoint) } catch (error) { + this.homey.error(error) this.setUnavailable('Could not fetch data from Uponor controller') } - - try { - const { debugEnabled } = this.getSettings() - if (!debugEnabled) return - const debug = await this._client.debug() - this.setSettings({ apiData: JSON.stringify(debug) }) - } catch (error) { } } - private async _setTargetTemperature(value: number): Promise { - if (!this._client) return this.setUnavailable('No Uponor client') + private async _setTargetTemperature(value: number, _opts: any): Promise { const { controllerID, thermostatID } = this.getData() - try { - await this._client.setTargetTemperature(controllerID, thermostatID, value) + await this._getClient().setTargetTemperature(controllerID, thermostatID, value) } catch (error) { + this.homey.error(error) this.setUnavailable('Could not send data to Uponor controller') } } diff --git a/drivers/uponor/driver.compose.json b/drivers/uponor/driver.compose.json index 4b6b2d3..df2c48d 100644 --- a/drivers/uponor/driver.compose.json +++ b/drivers/uponor/driver.compose.json @@ -40,66 +40,5 @@ "id": "add_devices", "template": "add_devices" } - ], - "settings": [ - { - "type": "group", - "label": { - "en": "IP address", - "nl": "IP addres" - }, - "children": [ - { - "id": "discovered_address", - "label": { - "en": "Auto-discovered address", - "nl": "Automatisch ontdekte adres" - }, - "type": "label", - "required": false, - "value": "" - }, - { - "id": "address", - "label": { - "en": "Override IP Address", - "nl": "IP-adres overschrijven" - }, - "type": "text", - "required": false, - "hint": { - "en": "Overrides the auto-discovered IP address of the device.", - "nl": "Overschrijft het automatisch ontdekte IP-adres van het apparaat." - } - } - ] - }, - { - "type": "group", - "label": { - "en": "Troubleshooting", - "nl": "Probleemoplossing" - }, - "children": [ - { - "id": "debugEnabled", - "type": "checkbox", - "label": { - "en": "Enable debug data", - "nl": "Schakel probleemverhelping in" - }, - "value": false - }, - { - "id": "apiData", - "type": "textarea", - "label": { - "en": "Last API response", - "nl": "Laatste API-reactie" - }, - "value": "{}" - } - ] - } ] } diff --git a/drivers/uponor/driver.ts b/drivers/uponor/driver.ts index 96c83f2..69397eb 100644 --- a/drivers/uponor/driver.ts +++ b/drivers/uponor/driver.ts @@ -1,51 +1,81 @@ import { Driver } from 'homey' import { Thermostat, UponorHTTPClient } from '../../lib/UponorHTTPClient' -import { PairSession } from 'homey/lib/Driver'; +import { PairSession } from 'homey/lib/Driver' +import { + IP_ADDRESS_SETTINGS_KEY, + CUSTOM_IP_ADDRESS_SETTINGS_KEY, + DEBUG_DEVICES_SETTINGS_KEY, + LIST_DEVICES_PAIR_KEY, + CUSTOM_IP_ADDRESS_PAIR_KEY, +} from '../../lib/constants' -class UponorDriver extends Driver { +export class UponorDriver extends Driver { - private _customIpAddress: string | undefined + private _client?: UponorHTTPClient - async onPair(session: PairSession): Promise { - - const driver = this + getClient(address: string | null = null): UponorHTTPClient { + address = address || this.getIpAddress() + if (!address) throw new Error('IP address not discovered or set during pairing') + if (!this._client) this._client = new UponorHTTPClient(address) + return this._client + } - session.setHandler('custom_ip_address', async function (address: string) { - driver._customIpAddress = address - }) + getIpAddress(): string { + const address = this.getCustomIpAddress() || this.homey.settings.get(IP_ADDRESS_SETTINGS_KEY) + if (!address) throw new Error('IP address not discovered or set during pairing') + return address + } - session.setHandler('list_devices', async function () { - const discoveryStrategy = driver.getDiscoveryStrategy() - const discoveryResults = discoveryStrategy.getDiscoveryResults() + async setIpAddress(address: string): Promise { + this.homey.settings.set(IP_ADDRESS_SETTINGS_KEY, address) + return await this.getClient(address).updateAddress(address) + } - for await (let discoveryResult of Object.values(discoveryResults)) { - return await driver._findDevices(discoveryResult.address, discoveryResult.id) - } + getCustomIpAddress(): string { + return this.homey.settings.get(CUSTOM_IP_ADDRESS_SETTINGS_KEY) + } - if (driver._customIpAddress) { - const time = new Date().getTime() - return await driver._findDevices(driver._customIpAddress, `custom_${time}`) - } + private async _setCustomIpAddress(address: string): Promise { + return this.homey.settings.set(CUSTOM_IP_ADDRESS_SETTINGS_KEY, address) + } - return [] - }) + async onPair(session: PairSession): Promise { + this.homey.settings.unset(CUSTOM_IP_ADDRESS_SETTINGS_KEY) + session.setHandler(CUSTOM_IP_ADDRESS_PAIR_KEY, this._setCustomIpAddress.bind(this)) + session.setHandler(LIST_DEVICES_PAIR_KEY, this._listDevices.bind(this)) } - private async _findDevices(ipAddress: string, systemID: string): Promise { - const client = new UponorHTTPClient(ipAddress) + private async _listDevices(): Promise { + // when a custom IP address is set, only return devices for that address + const custom_address = this.getCustomIpAddress() + if (custom_address) return await this._findDevices(custom_address, `custom_${new Date().getTime()}`) + + // otherwise discover devices on the network and use the first one found + const discoveryStrategy = this.getDiscoveryStrategy() + const discoveryResults = discoveryStrategy.getDiscoveryResults() + const controller = Object.values(discoveryResults).pop() + if (!controller) return [] + + this.setIpAddress(controller.address) + return await this._findDevices(controller.address, controller.id) + } + private async _findDevices(address: string, systemID: string): Promise { try { - const connected = await client.testConnection() - if (!connected) return [] - await client.syncAttributes() - const thermostats = Array.from(client.getThermostats().values()) - return thermostats.map(this._mapDevice.bind(this, ipAddress, systemID)) + const success = await this.getClient(address).updateAddress(address) + if (!success) throw new Error(`Could not connect to Uponor controller at IP address ${address}`) + await this.getClient().syncAttributes() + const debug = await this.getClient().debug() + this.homey.settings.set(DEBUG_DEVICES_SETTINGS_KEY, JSON.stringify(debug)) + const thermostats = Array.from(this.getClient().getThermostats().values()) + return thermostats.map(this._mapDevice.bind(this, address, systemID)) } catch (error) { + this.homey.error(error) return [] } } - private _mapDevice(ipAddress: string, systemID: string, thermostat: Thermostat): any { + private _mapDevice(address: string, systemID: string, thermostat: Thermostat): any { return { name: thermostat.name, data: { @@ -54,7 +84,7 @@ class UponorDriver extends Driver { thermostatID: thermostat.thermostatID, }, store: { - address: ipAddress, + address: address, } } } diff --git a/lib/UponorHTTPClient.ts b/lib/UponorHTTPClient.ts index 4759433..10938fe 100644 --- a/lib/UponorHTTPClient.ts +++ b/lib/UponorHTTPClient.ts @@ -1,3 +1,4 @@ +import { isIPv4 } from 'net' import fetch from 'node-fetch' export type Mode = 'auto' | 'heat' | 'cool' | 'off' @@ -27,6 +28,8 @@ type AttributesResponse = { export class UponorHTTPClient { private _url: string + private _lastSync: Date | undefined + private _rawAttributes: any private _attributes: Map = new Map() private _thermostats: Map = new Map() @@ -34,6 +37,12 @@ export class UponorHTTPClient { this._url = `http://${ip_address}/JNAP/` } + public async updateAddress(newAddress: string): Promise { + if (!isIPv4(newAddress)) return false + this._url = `http://${newAddress}/JNAP/` + return await this.testConnection() + } + public getAttributes(): Map { return this._attributes } @@ -52,59 +61,59 @@ export class UponorHTTPClient { } public async syncAttributes(): Promise { - this._attributes = await this._syncAttributes() - this._thermostats = this._syncThermostats() + const syncedRaw = await this._syncRawAttributes() + if (!syncedRaw) return console.error('Could not sync raw attributes') + const parsed = await this._parseAttributes() + if (!parsed) return console.error('Could not parse attributes') + await this._syncThermostats() } public async debug(): Promise { - try { - const request = await fetch(this._url, { - method: 'POST', - headers: { 'x-jnap-action': 'http://phyn.com/jnap/uponorsky/GetAttributes' }, - body: '{}', - }) - return await request.json() - } catch (error) { - return false - } + return this._rawAttributes } - public async updateAddress(newAddress: string): Promise { - this._url = `http://${newAddress}/JNAP/` - const success = await this.testConnection() - return success + public async testConnection(): Promise { + return this._syncRawAttributes(true) } - public async testConnection(): Promise { + public async setTargetTemperature(controllerID: number, thermostatID: number, value: number): Promise { + const fahrenheit = (value * 9 / 5) + 32 + const setPoint = round(fahrenheit * 10, 0).toString() + await this._setAttributes(new Map([[`C${controllerID}_T${thermostatID}_setpoint`, setPoint]])) + } + + // public async setMode(controllerID: number, thermostatID: number, value: Mode): Promise { + // // TODO: convert value to correct heat/cool/eco/holiday/comfort attributes + // // await this._setAttribute("", "") + // } + + private async _syncRawAttributes(force: boolean = false): Promise { + if (!force && this._lastSync && (new Date().getTime() - this._lastSync.getTime()) < 60000) { + return true + } + try { const request = await fetch(this._url, { method: 'POST', headers: { 'x-jnap-action': 'http://phyn.com/jnap/uponorsky/GetAttributes' }, - body: '{}', - timeout: 30000, + body: '{}' }) + this._lastSync = new Date() + this._rawAttributes = await request.json() return request.status == 200 } catch (error) { return false } } - private async _syncAttributes(): Promise> { - try { - const request = await fetch(this._url, { - method: 'POST', - headers: { 'x-jnap-action': 'http://phyn.com/jnap/uponorsky/GetAttributes' }, - body: '{}' - }) - const data: AttributesResponse = await request.json() as AttributesResponse - if (data.result != 'OK') return Promise.reject(data.result) - return new Map(data.output.vars.map(v => [v.waspVarName, v.waspVarValue])) - } catch (error) { - return Promise.reject(error) - } + private async _parseAttributes(): Promise { + const data = this._rawAttributes as AttributesResponse + if (data && data.result != 'OK') return false + this._attributes = new Map(data.output.vars.map(v => [v.waspVarName, v.waspVarValue])) + return true } - private _syncThermostats(): Map { + private async _syncThermostats(): Promise { const attributes = this.getAttributes() const thermostats: Map = new Map() @@ -129,18 +138,7 @@ export class UponorHTTPClient { }) }) - return thermostats - } - - public async setTargetTemperature(controllerID: number, thermostatID: number, value: number): Promise { - const fahrenheit = (value * 9 / 5) + 32 - const setPoint = round(fahrenheit * 10, 0).toString() - await this._setAttributes(new Map([[`C${controllerID}_T${thermostatID}_setpoint`, setPoint]])) - } - - public async setMode(controllerID: number, thermostatID: number, value: Mode): Promise { - // TODO: convert value to correct heat/cool/eco/holiday/comfort attributes - // await this._setAttribute("", "") + this._thermostats = thermostats } private async _setAttributes(attributes: Map): Promise { diff --git a/lib/constants.ts b/lib/constants.ts new file mode 100644 index 0000000..eb1480e --- /dev/null +++ b/lib/constants.ts @@ -0,0 +1,9 @@ +export const IP_ADDRESS_SETTINGS_KEY = 'ip_address' +export const CUSTOM_IP_ADDRESS_SETTINGS_KEY = 'custom_ip_address' +export const DEBUG_DEVICES_SETTINGS_KEY = 'debug_devices' +export const CUSTOM_IP_ADDRESS_PAIR_KEY = 'custom_ip_address' +export const LIST_DEVICES_PAIR_KEY = 'list_devices' +export const MEASURE_TEMPERATURE_CAPABILITY = 'measure_temperature' +export const TARGET_TEMPERATURE_CAPABILITY = 'target_temperature' +export const POLL_INTERVAL_MS = 1000 * 60 * 1 // 1 minute +export const INIT_TIMEOUT_MS = 1000 * 2 // 2 seconds diff --git a/locales/en.json b/locales/en.json index dabeba6..103b0a0 100644 --- a/locales/en.json +++ b/locales/en.json @@ -5,5 +5,11 @@ "description": "Homey scans for known Uponor devices in the local network. This does not always work depending on the network configuration. If you do not see your Uponor devices in the list, please try entering the IP address before continuing.", "ip_address": "IP address", "custom_ip_address_warning": "Automatic discovery is recommended to prevent issues when the IP address of the controller changes." + }, + "settings": { + "info_title": "Info", + "ip_address_label": "Discovered IP address", + "custom_ip_address_label": "Custom pairing IP address", + "debug_devices_label": "Debug data" } } \ No newline at end of file diff --git a/settings/index.html b/settings/index.html new file mode 100644 index 0000000..c57118b --- /dev/null +++ b/settings/index.html @@ -0,0 +1,50 @@ + + + + + + + +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +