From 1b0696562d0fb28e40030a30ee6467b3c39f161c Mon Sep 17 00:00:00 2001 From: Martin Man Date: Mon, 16 Dec 2024 15:35:51 +0100 Subject: [PATCH] feat: Allow custom per device MQTT subscription --- .../views/settings/AutoExpiryOptionList.tsx | 1 - src/client/views/settings/DeviceList.tsx | 22 ++++- src/client/views/settings/Discovery.tsx | 20 +++- .../views/settings/EditableDeviceList.tsx | 44 ++++++++- .../settings/MQTTSubscriptionsOptionList.tsx | 46 +++++++++ src/client/views/settings/Manual.tsx | 29 +++++- src/client/views/settings/VRM.tsx | 59 ++++++++++- src/server/loader.ts | 97 ++++++++++++++++--- src/shared/state.ts | 3 +- src/shared/types.ts | 48 +++++++++ 10 files changed, 348 insertions(+), 21 deletions(-) create mode 100644 src/client/views/settings/MQTTSubscriptionsOptionList.tsx diff --git a/src/client/views/settings/AutoExpiryOptionList.tsx b/src/client/views/settings/AutoExpiryOptionList.tsx index cb33a34..7c4af97 100644 --- a/src/client/views/settings/AutoExpiryOptionList.tsx +++ b/src/client/views/settings/AutoExpiryOptionList.tsx @@ -9,7 +9,6 @@ export interface AutoExpiryOptionListProps { portalId: string configuredExpiryTime?: number defaultExpiryDuration?: number - value?: number onSelectionDidChange: (_event: React.ChangeEvent, _index: number, _portalId: string) => void } diff --git a/src/client/views/settings/DeviceList.tsx b/src/client/views/settings/DeviceList.tsx index 7151507..73fc736 100644 --- a/src/client/views/settings/DeviceList.tsx +++ b/src/client/views/settings/DeviceList.tsx @@ -1,18 +1,29 @@ import React from "react" import { CFormCheck, CTable, CTableHead, CTableBody, CTableHeaderCell, CTableDataCell, CTableRow } from "@coreui/react" -import { AppDataCollectionExpiryConfig, AppUPNPConfig, AppVRMConfig } from "../../../shared/types" +import AppDeviceSubscriptionsConfig, { + AppDataCollectionExpiryConfig, + AppUPNPConfig, + AppVRMConfig, +} from "../../../shared/types" import { DiscoveredDevice } from "../../../shared/state" import { AutoExpiryOptionList } from "./AutoExpiryOptionList" +import { MQTTSubscriptionsOptionList } from "./MQTTSubscriptionsOptionList" interface DeviceListProps { hidden?: boolean settings: AppUPNPConfig | AppVRMConfig referenceTime: number expirySettings: AppDataCollectionExpiryConfig + mqttSubscriptionsSettings: AppDeviceSubscriptionsConfig onEnablePortalChange: React.ChangeEventHandler onEnableAllPortalsChange: React.ChangeEventHandler availablePortalIds: DiscoveredDevice[] defaultExpiryDuration?: number + onPortalMQTTSubscriptionsChange: ( + _event: React.ChangeEvent, + _index: number, + _portalId: string, + ) => void onPortalExpiryChange: (_event: React.ChangeEvent, _index: number, _portalId: string) => void } @@ -23,6 +34,7 @@ export function DeviceList(props: DeviceListProps) { Installation Name Portal ID + Subscription {props.defaultExpiryDuration && Auto Expire Data Collection} {element.name} {element.portalId} + + + {props.defaultExpiryDuration && ( , + _index: number, + portalId: string, + ) { + const clone = { ...temporaryConfig!! } + const value = String(event.target.value) as VenusMQTTTopic + if (value) { + clone.upnp.subscriptions[portalId] = [value] + } else { + delete clone.upnp.subscriptions[portalId] + } + setTemporaryConfig(clone) + setIsTemporaryConfigDirty(true) + } + function handlePortalExpiryChange(event: React.ChangeEvent, _index: number, portalId: string) { const clone = { ...temporaryConfig!! } const value = Number(event.target.value) @@ -134,11 +150,13 @@ function Discovery() { settings={temporaryConfig.upnp} referenceTime={referenceTime} expirySettings={temporaryConfig.upnp.expiry} + mqttSubscriptionsSettings={temporaryConfig.upnp.subscriptions} availablePortalIds={upnpDiscovered} onEnablePortalChange={handleEnablePortalChange} onEnableAllPortalsChange={handleEnableAllPortalsChange} defaultExpiryDuration={defaultExpiryDuration} onPortalExpiryChange={handlePortalExpiryChange} + onPortalMQTTSubscriptionsChange={handlePortalSubscriptionChange} /> diff --git a/src/client/views/settings/EditableDeviceList.tsx b/src/client/views/settings/EditableDeviceList.tsx index 139b4fc..0dd6c70 100644 --- a/src/client/views/settings/EditableDeviceList.tsx +++ b/src/client/views/settings/EditableDeviceList.tsx @@ -10,15 +10,21 @@ import { CFormInput, CButton, } from "@coreui/react" -import { AppDataCollectionExpiryConfig, AppDeviceConfig, AppInstallationConfig } from "../../../shared/types" +import AppDeviceSubscriptionsConfig, { + AppDataCollectionExpiryConfig, + AppDeviceConfig, + AppInstallationConfig, +} from "../../../shared/types" import { AutoExpiryOptionList } from "./AutoExpiryOptionList" import { DiscoveredDevice } from "../../../shared/state" +import { MQTTSubscriptionsOptionList } from "./MQTTSubscriptionsOptionList" interface EditableDeviceListProps { hidden?: boolean entries: AppDeviceConfig[] | AppInstallationConfig[] referenceTime: number expirySettings: (number | undefined)[] + mqttSubscriptionsSettings: AppDeviceSubscriptionsConfig onEntryValueChange: (_event: React.ChangeEvent, _index: number) => void onEnableEntryChange: (_event: React.ChangeEvent, _index: number) => void onEnableAllEntriesChange: (_event: React.ChangeEvent) => void @@ -27,6 +33,11 @@ interface EditableDeviceListProps { entryTitleText: string addEntryButtonText: string defaultExpiryDuration?: number + onPortalMQTTSubscriptionsChange: ( + _event: React.ChangeEvent, + _index: number, + _portalId: string, + ) => void onPortalExpiryChange: (_event: React.ChangeEvent, _index: number, _portalId: string) => void } @@ -37,6 +48,7 @@ export function EditableDeviceList(props: EditableDeviceListProps) { {props.entryTitleText} + Subscription {props.defaultExpiryDuration && Auto Expire Data Collection} props.onEntryValueChange(event, index)} /> + + + {props.defaultExpiryDuration && ( expiry[device.hostName ?? device.portalId]) } + +export function arraySubscriptionsToKeyed( + subscriptions: AppDeviceSubscriptionsConfig, + devices: AppDeviceConfig[] | AppInstallationConfig[], + existingSubscriptions: AppDeviceSubscriptionsConfig = {}, + discoveredDevices: DiscoveredDevice[] = [], +): AppDeviceSubscriptionsConfig { + const a = Object.fromEntries( + discoveredDevices.map((device) => [device.portalId, existingSubscriptions[device.portalId]]), + ) + // @ts-expect-error + const b = Object.fromEntries(devices.map((device, i) => [device.hostName ?? device.portalId, subscriptions[i]])) + return { ...a, ...b } +} + +export function keyedSubscriptionsToArray( + subscriptions: AppDeviceSubscriptionsConfig, + devices: AppDeviceConfig[] | AppInstallationConfig[], +): AppDeviceSubscriptionsConfig { + // @ts-expect-error + return devices.map((device) => subscriptions[device.hostName ?? device.portalId]) +} diff --git a/src/client/views/settings/MQTTSubscriptionsOptionList.tsx b/src/client/views/settings/MQTTSubscriptionsOptionList.tsx new file mode 100644 index 0000000..5eb59d9 --- /dev/null +++ b/src/client/views/settings/MQTTSubscriptionsOptionList.tsx @@ -0,0 +1,46 @@ +import React from "react" +import { CFormSelect } from "@coreui/react" +import { useEffect, useState } from "react" +import { VenusMQTTTopic, VenusMQTTTopics } from "../../../shared/types" + +export interface MQTTSubscriptionsOptionListProps { + index: number + portalId: string + configuredMQTTSubscriptions: VenusMQTTTopic[] + onSelectionDidChange: (_event: React.ChangeEvent, _index: number, _portalId: string) => void +} + +interface MQTTSubscriptionsListOption { + label: string + value: string +} + +interface MQTTSubscriptionsOptionListOptions { + default: string + options: MQTTSubscriptionsListOption[] +} + +function generateOptions(configuredMQTTSubscriptions: VenusMQTTTopic[]): MQTTSubscriptionsOptionListOptions { + let defaultValue = `/#` + if (configuredMQTTSubscriptions && configuredMQTTSubscriptions.length > 0) { + defaultValue = configuredMQTTSubscriptions[0] + } + const options = VenusMQTTTopics.map((x) => { + return { label: x, value: x } + }) + return { default: defaultValue, options: options } +} + +export function MQTTSubscriptionsOptionList(props: MQTTSubscriptionsOptionListProps) { + const [options, setOptions] = useState({ default: "", options: [] }) + useEffect(() => { + setOptions(generateOptions(props.configuredMQTTSubscriptions)) + }, [props.configuredMQTTSubscriptions]) + return ( + props.onSelectionDidChange(event, props.index, props.portalId)} + /> + ) +} diff --git a/src/client/views/settings/Manual.tsx b/src/client/views/settings/Manual.tsx index 919463d..ff78f8a 100644 --- a/src/client/views/settings/Manual.tsx +++ b/src/client/views/settings/Manual.tsx @@ -3,9 +3,15 @@ import { CCard, CCardBody, CCardHeader, CCardFooter, CForm, CButton, CFormCheck import { useGetConfig, usePutConfig } from "../../hooks/useAdminApi" import { useFormValidation, extractParameterNameAndValue } from "../../hooks/useFormValidation" -import { arrayExpiryToKeyed, EditableDeviceList, keyedExpiryToArray } from "./EditableDeviceList" +import { + arrayExpiryToKeyed, + arraySubscriptionsToKeyed, + EditableDeviceList, + keyedExpiryToArray, + keyedSubscriptionsToArray, +} from "./EditableDeviceList" import { useEffect, useState } from "react" -import { AppConfig } from "../../../shared/types" +import AppDeviceSubscriptionsConfig, { AppConfig, VenusMQTTTopic } from "../../../shared/types" import { WebSocketStatus } from "./WebsocketStatus" import { useSelector } from "react-redux" import { AppState } from "../../store" @@ -29,12 +35,14 @@ function Manual() { const [referenceTime, setReferenceTime] = useState(0) const [temporaryExpiry, setTemporaryExpiry] = useState<(number | undefined)[]>([]) + const [temporarySubscriptions, setTemporarySubscriptions] = useState({}) const [temporaryConfig, setTemporaryConfig] = useState() useEffect(() => { setReferenceTime(Date.now()) populateDefaultExpiry(config) setTemporaryConfig(config) setTemporaryExpiry(keyedExpiryToArray(config?.manual.expiry ?? {}, config?.manual.hosts ?? [])) + setTemporarySubscriptions(keyedSubscriptionsToArray(config?.manual.subscriptions ?? {}, config?.manual.hosts ?? [])) setIsTemporaryConfigDirty(false) }, [config]) @@ -111,6 +119,21 @@ function Manual() { setIsTemporaryConfigDirty(true) } + function handlePortalSubscriptionChange( + event: React.ChangeEvent, + index: number, + _portalId: string, + ) { + const clone = { ...temporaryConfig!! } + const value = String(event.target.value) as VenusMQTTTopic + const newSubscriptions = { ...temporarySubscriptions!! } + newSubscriptions[index] = [value] + clone.manual.subscriptions = arraySubscriptionsToKeyed(newSubscriptions, clone.manual.hosts) + setTemporarySubscriptions(newSubscriptions) + setTemporaryConfig(clone) + setIsTemporaryConfigDirty(true) + } + function handlePortalExpiryChange(event: React.ChangeEvent, index: number, _portalId: string) { const clone = { ...temporaryConfig!! } const value = Number(event.target.value) @@ -147,6 +170,7 @@ function Manual() { entries={temporaryConfig.manual.hosts} referenceTime={referenceTime} expirySettings={temporaryExpiry} + mqttSubscriptionsSettings={temporarySubscriptions} onEntryValueChange={handleHostNameChange} onEnableEntryChange={handleEnableHostChange} onEnableAllEntriesChange={handleEnableAllHostsChange} @@ -156,6 +180,7 @@ function Manual() { addEntryButtonText="Add Host" defaultExpiryDuration={defaultExpiryDuration} onPortalExpiryChange={handlePortalExpiryChange} + onPortalMQTTSubscriptionsChange={handlePortalSubscriptionChange} /> diff --git a/src/client/views/settings/VRM.tsx b/src/client/views/settings/VRM.tsx index 69ab66f..b6d6f5b 100644 --- a/src/client/views/settings/VRM.tsx +++ b/src/client/views/settings/VRM.tsx @@ -26,12 +26,23 @@ import { import { useGetConfig, usePutConfig, useVRMLogin, useVRMLogout, useVRMRefresh } from "../../hooks/useAdminApi" import { useFormValidation, extractParameterNameAndValue } from "../../hooks/useFormValidation" import { DeviceList } from "./DeviceList" -import { AppConfig, AppVRMConfig, AppVRMConfigKey } from "../../../shared/types" +import AppDeviceSubscriptionsConfig, { + AppConfig, + AppVRMConfig, + AppVRMConfigKey, + VenusMQTTTopic, +} from "../../../shared/types" import { AppState } from "../../store" import { VRMDeviceType, VRMLoginRequest } from "../../../shared/api" import { VRMStatus } from "../../../shared/state" import { WebSocketStatus } from "./WebsocketStatus" -import { arrayExpiryToKeyed, EditableDeviceList, keyedExpiryToArray } from "./EditableDeviceList" +import { + arrayExpiryToKeyed, + arraySubscriptionsToKeyed, + EditableDeviceList, + keyedExpiryToArray, + keyedSubscriptionsToArray, +} from "./EditableDeviceList" function VRM() { // auto load loader config on first page render @@ -60,12 +71,16 @@ function VRM() { const [referenceTime, setReferenceTime] = useState(0) const [temporaryExpiry, setTemporaryExpiry] = useState<(number | undefined)[]>([]) + const [temporarySubscriptions, setTemporarySubscriptions] = useState({}) const [temporaryConfig, setTemporaryConfig] = useState() useEffect(() => { setReferenceTime(Date.now()) populateDefaultExpiry(config) setTemporaryConfig(config) setTemporaryExpiry(keyedExpiryToArray(config?.vrm.expiry ?? {}, config?.vrm.manualPortalIds ?? [])) + setTemporarySubscriptions( + keyedSubscriptionsToArray(config?.vrm.subscriptions ?? {}, config?.vrm.manualPortalIds ?? []), + ) setIsTemporaryConfigDirty(false) }, [config, vrmDiscovered, defaultExpiryDuration]) @@ -259,6 +274,42 @@ function VRM() { setIsTemporaryConfigDirty(true) } + function handleDiscoveredPortalSubscriptionChange( + event: React.ChangeEvent, + _index: number, + portalId: string, + ) { + const clone = { ...temporaryConfig!! } + const value = String(event.target.value) as VenusMQTTTopic + if (value) { + clone.vrm.subscriptions[portalId] = [value] + } else { + delete clone.vrm.subscriptions[portalId] + } + setTemporaryConfig(clone) + setIsTemporaryConfigDirty(true) + } + + function handleConfiguredPortalSubscriptionChange( + event: React.ChangeEvent, + index: number, + _portalId: string, + ) { + const clone = { ...temporaryConfig!! } + const value = String(event.target.value) as VenusMQTTTopic + const newSubscriptions = { ...temporarySubscriptions!! } + newSubscriptions[index] = [value] + clone.vrm.subscriptions = arraySubscriptionsToKeyed( + newSubscriptions, + clone.vrm.manualPortalIds, + clone.vrm.subscriptions, + vrmDiscovered, + ) + setTemporarySubscriptions(newSubscriptions) + setTemporaryConfig(clone) + setIsTemporaryConfigDirty(true) + } + const [displayedDevices, setDisplayedDevices] = useState("discovered") const [showStatusPane, setShowStatusPane] = useState(false) @@ -333,11 +384,13 @@ function VRM() { settings={temporaryConfig.vrm} referenceTime={referenceTime} expirySettings={temporaryConfig.vrm.expiry} + mqttSubscriptionsSettings={temporaryConfig.vrm.subscriptions} availablePortalIds={vrmDiscovered} onEnablePortalChange={handleEnableDiscoveredPortalChange} onEnableAllPortalsChange={handleEnableAllDiscoveredPortalsChange} defaultExpiryDuration={defaultExpiryDuration} onPortalExpiryChange={handleDiscoveredPortalExpiryChange} + onPortalMQTTSubscriptionsChange={handleDiscoveredPortalSubscriptionChange} /> diff --git a/src/server/loader.ts b/src/server/loader.ts index e6ecbcb..8e2d58b 100644 --- a/src/server/loader.ts +++ b/src/server/loader.ts @@ -7,11 +7,15 @@ import { Server } from "./server" import { Logger } from "winston" import { ConfiguredDevice, DiscoveredDevice, LoaderStatistics } from "../shared/state.js" import ms from "ms" +import { VenusMQTTTopic } from "../shared/types.js" const collectStatsInterval = 5 const checkExpiryInterval = 60 const keepAliveInterval = 30 +const defaultVenusMQTTSubscriptions: VenusMQTTTopic[] = ["/#"] +const niceToHaveVenusMQTTSubscriptions: VenusMQTTTopic[] = ["/system/#", "/settings/#"] + export class Loader { server: Server logger: Logger @@ -131,7 +135,11 @@ export class Loader { }) // connect to Venus devices that are enabled and not expired remaining.forEach((portalId) => - this.initiateUpnpDeviceConnection(this.server.upnpDevices[portalId], config.expiry[portalId]), + this.initiateUpnpDeviceConnection( + this.server.upnpDevices[portalId], + config.subscriptions[portalId], + config.expiry[portalId], + ), ) // disable expired devices in the config file this.server.config.upnp.enabledPortalIds = remaining @@ -163,7 +171,9 @@ export class Loader { delete this.manualConnections[hostName] }) // connect to Venus devices that are enabled - remaining.forEach((hostName) => this.initiateHostnameDeviceConnection(hostName, config.expiry[hostName])) + remaining.forEach((hostName) => + this.initiateHostnameDeviceConnection(hostName, config.subscriptions[hostName], config.expiry[hostName]), + ) // disable expired devices in the config file expired.forEach((hostName) => { this.server.config.manual.hosts.forEach((host) => { @@ -198,7 +208,9 @@ export class Loader { delete this.vrmConnections[portalId] }) // connect to Venus devices that are enabled - remaining.forEach((portalId) => this.initiateVrmDeviceConnection(portalId, config.expiry[portalId])) + remaining.forEach((portalId) => + this.initiateVrmDeviceConnection(portalId, config.subscriptions[portalId], config.expiry[portalId]), + ) // disable expired devices in the config file expired.forEach((portalId) => { this.server.config.vrm.enabledPortalIds = this.server.config.vrm.enabledPortalIds.filter((x) => x !== portalId) @@ -211,39 +223,56 @@ export class Loader { this.isConfigFileDirty = this.isConfigFileDirty ? true : expired.length > 0 } - private async initiateUpnpDeviceConnection(d: DiscoveredDevice, expiry?: number) { + private async initiateUpnpDeviceConnection(d: DiscoveredDevice, subscriptions: VenusMQTTTopic[], expiry?: number) { if (d === undefined) return if (this.upnpConnections[d.portalId]) { this.upnpConnections[d.portalId].updateExpiry(expiry) + this.upnpConnections[d.portalId].updateSubscriptions(this.prepareVenusMQTTSubscriptions(subscriptions)) return } - const device: ConfiguredDevice = { type: "UPNP", address: d.address, portalId: d.portalId } + const device: ConfiguredDevice = { + type: "UPNP", + address: d.address, + portalId: d.portalId, + subscriptions: this.prepareVenusMQTTSubscriptions(subscriptions), + } this.logger.debug(`initiateUpnpDeviceConnection: ${JSON.stringify(device)}`) const mqttClient = new VenusMqttClient(this, device, expiry) this.upnpConnections[d.portalId] = mqttClient await mqttClient.start() } - private async initiateHostnameDeviceConnection(hostName: string, expiry?: number) { + private async initiateHostnameDeviceConnection(hostName: string, subscriptions: VenusMQTTTopic[], expiry?: number) { if (hostName === undefined) return if (this.manualConnections[hostName]) { this.manualConnections[hostName].updateExpiry(expiry) + this.manualConnections[hostName].updateSubscriptions(this.prepareVenusMQTTSubscriptions(subscriptions)) return } - const device: ConfiguredDevice = { type: "IP", address: hostName } + const device: ConfiguredDevice = { + type: "IP", + address: hostName, + subscriptions: this.prepareVenusMQTTSubscriptions(subscriptions), + } this.logger.debug(`initiateHostnameDeviceConnection: ${JSON.stringify(device)}`) const mqttClient = new VenusMqttClient(this, device, expiry) this.manualConnections[hostName] = mqttClient await mqttClient.start() } - private async initiateVrmDeviceConnection(portalId: string, expiry?: number) { + private async initiateVrmDeviceConnection(portalId: string, subscriptions: VenusMQTTTopic[], expiry?: number) { if (portalId === undefined) return if (this.vrmConnections[portalId]) { this.vrmConnections[portalId].updateExpiry(expiry) + this.vrmConnections[portalId].updateSubscriptions(this.prepareVenusMQTTSubscriptions(subscriptions)) return } - const device: ConfiguredDevice = { type: "VRM", portalId: portalId, address: this.calculateVrmBrokerURL(portalId) } + const device: ConfiguredDevice = { + type: "VRM", + portalId: portalId, + address: this.calculateVrmBrokerURL(portalId), + subscriptions: this.prepareVenusMQTTSubscriptions(subscriptions), + } this.logger.debug(`initiateVrmDeviceConnection: ${JSON.stringify(device)}`) const mqttClient = new VenusMqttClient(this, device, expiry, true) this.vrmConnections[portalId] = mqttClient @@ -258,6 +287,13 @@ export class Loader { } return `mqtt${sum % 128}.victronenergy.com` } + + private prepareVenusMQTTSubscriptions(subscriptions?: VenusMQTTTopic[]) { + if (subscriptions && subscriptions.filter((topic) => topic === "/#").length == 0) { + return [...subscriptions, ...niceToHaveVenusMQTTSubscriptions] + } + return defaultVenusMQTTSubscriptions + } } class VenusMqttClient { @@ -336,9 +372,13 @@ class VenusMqttClient { this.isDetectingPortalId = true } else { // we do know the portalId already (vrm + upnp connection) - this.logger.info("Subscribing to portalId %s", this.device.portalId) + this.logger.info("Using portalId %s", this.device.portalId) this.client.subscribe(`N/${this.device.portalId}/settings/0/Settings/SystemSetup/SystemName`) - this.client.subscribe(`N/${this.device.portalId}/#`) + for (const topic of this.device.subscriptions) { + const x = `N/${this.device.portalId}${topic}` + this.logger.info(`Subscribing to '${x}'`) + this.client.subscribe(x) + } this.client.publish(`R/${this.device.portalId}/settings/0/Settings/SystemSetup/SystemName`, "") this.client.publish(`R/${this.device.portalId}/system/0/Serial`, "") this.isDetectingPortalId = false @@ -414,7 +454,11 @@ class VenusMqttClient { if (this.isDetectingPortalId && measurement === "system/Serial") { this.logger.info("Detected portalId %s", json.value) this.client.subscribe(`N/${json.value}/settings/0/Settings/SystemSetup/SystemName`) - this.client.subscribe(`N/${json.value}/#`) + for (const sub of this.device.subscriptions) { + const x = `N/${json.value}${sub}` + this.logger.info(`Subscribing to '${x}'`) + this.client.subscribe(x) + } this.client.publish(`R/${json.value}/settings/0/Settings/SystemSetup/SystemName`, "") this.client.publish(`R/${json.value}/system/0/Serial`, "") this.isDetectingPortalId = false @@ -510,6 +554,35 @@ class VenusMqttClient { portalStats.lastMeasurement = new Date() } + updateSubscriptions(subscriptions: VenusMQTTTopic[]) { + const existingSubs = this.device.subscriptions + const newSubs = subscriptions + const diff = arrayDifference(existingSubs, newSubs) + if (existingSubs.length == newSubs.length && diff.length == 0) { + return + } + + const toSubscribe = arrayDifference(newSubs, existingSubs) + const toUnsubscribe = arrayDifference(existingSubs, newSubs) + const toKeep = arrayDifference(existingSubs, toUnsubscribe) + this.logger.info( + `updateSubscriptions, unsubscribe: ${JSON.stringify(toUnsubscribe)}, keep: ${JSON.stringify(toKeep)}, subscribe: ${JSON.stringify(toSubscribe)}`, + ) + this.device.subscriptions = [...toSubscribe, ...toKeep] + if (!this.isDetectingPortalId) { + for (const topic of toUnsubscribe) { + const x = `N/${this.device.portalId}${topic}` + this.logger.info(`Unsubscribing from '${x}'`) + this.client.unsubscribe(x) + } + for (const topic of toSubscribe) { + const x = `N/${this.device.portalId}${topic}` + this.logger.info(`Subscribing to '${x}'`) + this.client.subscribe(x) + } + } + } + updateExpiry(expiry?: number) { if (this.expiry === expiry) { return diff --git a/src/shared/state.ts b/src/shared/state.ts index 579e3ba..256667d 100644 --- a/src/shared/state.ts +++ b/src/shared/state.ts @@ -1,4 +1,4 @@ -import { AppConfig, AppUISettings, LogEntry } from "./types" +import { AppConfig, AppUISettings, LogEntry, VenusMQTTTopic } from "./types" export type WebSocketActions = "WEBSOCKET_OPEN" | "WEBSOCKET_CLOSE" | "WEBSOCKET_ERROR" export type DiscoveryActions = "UPNPDISCOVERY" | "VRMDISCOVERY" @@ -37,6 +37,7 @@ export type ConfiguredDevice = { portalId?: string name?: string address: string + subscriptions: VenusMQTTTopic[] } export interface AppStateUPNPDiscoveryAction extends AppStateBaseAction { diff --git a/src/shared/types.ts b/src/shared/types.ts index e38e831..08d5d87 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -17,6 +17,7 @@ export interface AppUPNPConfig { enabled: boolean enabledPortalIds: string[] expiry: AppDataCollectionExpiryConfig + subscriptions: AppDeviceSubscriptionsConfig } export type AppUPNPConfigKey = keyof AppUPNPConfig @@ -26,6 +27,7 @@ export interface AppVRMConfig { enabledPortalIds: string[] manualPortalIds: AppInstallationConfig[] expiry: AppDataCollectionExpiryConfig + subscriptions: AppDeviceSubscriptionsConfig hasToken: boolean } @@ -49,10 +51,53 @@ export interface AppManualConfig { enabled: boolean hosts: AppDeviceConfig[] expiry: AppDataCollectionExpiryConfig + subscriptions: AppDeviceSubscriptionsConfig } export type AppManualConfigKey = keyof AppManualConfig +// Taken from https://github.com/victronenergy/dbus_modbustcp/blob/master/attributes.csv +// Using: `cut -d',' -f 1 attributes.csv | sort | uniq | cut -d '.' -f 3` +// `/system/#` and `/settings/#` removed as we always subscribe to them +export const VenusMQTTTopics = [ + `/#`, + `/acload/#`, + `/acsystem/#`, + `/alternator/#`, + `/battery/#`, + `/charger/#`, + `/dcdc/#`, + `/dcgenset/#`, + `/dcload/#`, + `/dcsource/#`, + `/dcsystem/#`, + `/digitalinput/#`, + `/evcharger/#`, + `/fuelcell/#`, + `/generator/#`, + `/genset/#`, + `/gps/#`, + `/grid/#`, + `/hub4/#`, + `/inverter/#`, + `/meteo/#`, + `/motordrive/#`, + `/multi/#`, + `/pulsemeter/#`, + `/pump/#`, + `/pvinverter/#`, + `/solarcharger/#`, + `/tank/#`, + `/temperature/#`, + `/vebus/#`, +] as const + +export type VenusMQTTTopic = (typeof VenusMQTTTopics)[number] | "/system/#" | "/settings/#" + +export default interface AppDeviceSubscriptionsConfig { + [portalId: string]: VenusMQTTTopic[] // MQTT topics to subscribe to +} + export interface AppDataCollectionExpiryConfig { [portalId: string]: number | undefined // absolute time in millis when data collection will expire } @@ -111,11 +156,13 @@ const defaultAppConfigValues: AppConfig = { enabled: true, enabledPortalIds: [], expiry: {}, + subscriptions: {}, }, manual: { enabled: true, hosts: [], expiry: {}, + subscriptions: {}, }, vrm: { enabled: true, @@ -123,6 +170,7 @@ const defaultAppConfigValues: AppConfig = { manualPortalIds: [], hasToken: false, expiry: {}, + subscriptions: {}, }, influxdb: { protocol: "http",