Skip to content

Commit 9b081cc

Browse files
AlCalzoneCopilotCopilotrobertsLando
authored
feat: support embedding Z-Wave JS UI in other software (#4520)
This PR adds support for embedding Z-Wave JS UI in other software, e.g. a Home Assistant addon. In this mode, several settings are managed externally and will be unavailable in Z-Wave JS UI. ### Controller port Set using the env variable `ZWAVE_PORT`: <img width="680" height="497" alt="grafik" src="https://github.com/user-attachments/assets/48461390-4c2b-4771-95a2-af4d803cd43e" /> ### Security keys, RF and log configuration Set using the env variable `ZWAVE_EXTERNAL_SETTINGS` --------- Co-authored-by: Copilot <[email protected]> Co-authored-by: Copilot <[email protected]> Co-authored-by: AlCalzone <[email protected]> Co-authored-by: Daniel Lando <[email protected]>
1 parent ceabeae commit 9b081cc

9 files changed

Lines changed: 685 additions & 104 deletions

File tree

.env.app.example

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ STORE_DIR=''
77
# Used as secret for session. If not provided the default one is used
88
SESSION_SECRET="zwavejsisawesome"
99

10+
# Z-Wave controller port/address. When set, overrides the port configured in settings.json
11+
# and disables automatic discovery of serial ports. Examples: /dev/ttyUSB0, COM3, tcp://192.168.1.100:5555
12+
ZWAVE_PORT=''
13+
# Path to a JSON file with externally provided settings for the driver and Z-Wave JS Server.
14+
# When set, the settings in this file override those in settings.json and the UI controls for those settings are hidden.
15+
ZWAVE_EXTERNAL_SETTINGS=''
16+
1017
# S0 Key
1118
NETWORK_KEY=''
1219
HTTPS=''
@@ -16,6 +23,6 @@ USE_SECURE_COOKIE=''
1623
# https://zwave-js.github.io/node-zwave-js/#/usage/external-config?id=specifying-an-external-config-db-location
1724
ZWAVEJS_EXTERNAL_CONFIG=''
1825

19-
# Browser preferred locale/tz to use for date/time formatting
26+
# Browser preferred locale/tz to use for date/time formatting
2027
TZ=''
2128
LOCALE=''

api/app.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ import { createPlugin } from './lib/CustomPlugin.ts'
4343
import { inboundEvents, socketEvents } from './lib/SocketEvents.ts'
4444
import * as utils from './lib/utils.ts'
4545
import backupManager from './lib/BackupManager.ts'
46+
import {
47+
getExternallyManagedPaths,
48+
loadExternalSettings,
49+
mergeExternalSettings,
50+
} from './lib/externalSettings.ts'
4651
import {
4752
readFile,
4853
realpath,
@@ -186,6 +191,12 @@ export async function startServer(port: number | string, host?: string) {
186191

187192
const settings = jsonStore.get(store.settings)
188193

194+
// Merge external settings into zwave config (if external settings exist)
195+
if (loadExternalSettings()) {
196+
settings.zwave ??= {}
197+
mergeExternalSettings(settings.zwave as Record<string, unknown>)
198+
}
199+
189200
// as the really first thing setup loggers so all logs will go to file if specified in settings
190201
setupLogging(settings)
191202

@@ -1105,19 +1116,29 @@ app.get(
11051116

11061117
const settings = jsonStore.get(store.settings)
11071118

1119+
const managedExternally: string[] = []
1120+
if (process.env.ZWAVE_PORT) {
1121+
managedExternally.push('zwave.port')
1122+
managedExternally.push('zwave.enabled')
1123+
}
1124+
// Add paths from external settings file
1125+
managedExternally.push(...getExternallyManagedPaths())
1126+
11081127
const data = {
11091128
success: true,
11101129
settings,
11111130
devices: gw?.zwave?.devices ?? {},
11121131
serial_ports: [],
11131132
scales: scales,
11141133
sslDisabled: sslDisabled(),
1134+
managedExternally,
11151135
tz: process.env.TZ,
11161136
locale: process.env.LOCALE,
11171137
deprecationWarning: process.env.TAG_NAME === 'zwavejs2mqtt',
11181138
}
11191139

1120-
if (process.platform !== 'sunos') {
1140+
// Only enumerate serial ports if ZWAVE_PORT is not set via env var
1141+
if (process.platform !== 'sunos' && !process.env.ZWAVE_PORT) {
11211142
try {
11221143
data.serial_ports = await Driver.enumerateSerialPorts({
11231144
local: true,
@@ -1127,8 +1148,9 @@ app.get(
11271148
logger.error(error)
11281149
data.serial_ports = []
11291150
}
1130-
res.json(data)
1131-
} else res.json(data)
1151+
}
1152+
1153+
res.json(data)
11321154
},
11331155
)
11341156

api/lib/ZwaveClient.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
extractFirmware,
2323
} from '@zwave-js/core'
2424
import { createDefaultTransportFormat } from '@zwave-js/core/bindings/log/node'
25+
import { applyExternalDriverSettings } from './externalSettings.ts'
2526
import { JSONTransport } from '@zwave-js/log-transport-json'
2627
import type {
2728
AssociationAddress,
@@ -2162,6 +2163,12 @@ class ZwaveClient extends TypedEventEmitter<ZwaveClientEventCallbacks> {
21622163
* Method used to start Z-Wave connection using configuration `port`
21632164
*/
21642165
async connect() {
2166+
// When ZWAVE_PORT env var is set, force enable and override port
2167+
if (process.env.ZWAVE_PORT) {
2168+
this.cfg.enabled = true
2169+
this.cfg.port = process.env.ZWAVE_PORT
2170+
}
2171+
21652172
if (this.cfg.enabled === false) {
21662173
logger.info('Z-Wave driver DISABLED')
21672174
return
@@ -2327,6 +2334,10 @@ class ZwaveClient extends TypedEventEmitter<ZwaveClientEventCallbacks> {
23272334

23282335
utils.parseSecurityKeys(this.cfg, zwaveOptions)
23292336

2337+
// Apply driver-only external settings (storage, presets, logFilename, forceConsole).
2338+
// These are not in ZwaveConfig/settings.json, so they must be applied directly to driver options.
2339+
applyExternalDriverSettings(zwaveOptions)
2340+
23302341
const logTransport = new JSONTransport()
23312342
logTransport.format = createDefaultTransportFormat(true, false)
23322343

api/lib/externalSettings.ts

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import { readFileSync, existsSync } from 'node:fs'
2+
import type { PartialZWaveOptions } from 'zwave-js'
3+
import { driverPresets } from 'zwave-js'
4+
import { module } from './logger.ts'
5+
6+
const logger = module('ExternalSettings')
7+
8+
export interface ExternalZwaveSettings {
9+
// Logging (with UI configuration)
10+
logEnabled?: boolean
11+
logLevel?: string
12+
logToFile?: boolean
13+
maxFiles?: number
14+
15+
// Logging (without UI configuration)
16+
logFilename?: string
17+
forceConsole?: boolean
18+
19+
// RF settings
20+
rf?: {
21+
region?: number
22+
autoPowerlevels?: boolean
23+
}
24+
25+
// Storage
26+
storage?: {
27+
cacheDir?: string
28+
throttle?: 'normal' | 'slow' | 'fast'
29+
}
30+
31+
// Security keys
32+
securityKeys?: {
33+
S0_Legacy?: string
34+
S2_Unauthenticated?: string
35+
S2_Authenticated?: string
36+
S2_AccessControl?: string
37+
}
38+
securityKeysLongRange?: {
39+
S2_Authenticated?: string
40+
S2_AccessControl?: string
41+
}
42+
43+
// Features
44+
enableSoftReset?: boolean
45+
46+
// Z-Wave JS Server settings
47+
serverEnabled?: boolean
48+
serverPort?: number
49+
serverHost?: string
50+
serverServiceDiscoveryDisabled?: boolean
51+
52+
// Presets
53+
presets?: string[]
54+
}
55+
56+
let cachedSettings: ExternalZwaveSettings | null = null
57+
let settingsLoaded = false
58+
59+
export function loadExternalSettings(): ExternalZwaveSettings | null {
60+
if (settingsLoaded) return cachedSettings
61+
settingsLoaded = true
62+
63+
const filePath = process.env.ZWAVE_EXTERNAL_SETTINGS
64+
if (!filePath) return null
65+
66+
if (!existsSync(filePath)) {
67+
logger.warn(`External settings file not found: ${filePath}`)
68+
return null
69+
}
70+
71+
try {
72+
cachedSettings = JSON.parse(
73+
readFileSync(filePath, 'utf-8'),
74+
) as ExternalZwaveSettings
75+
logger.info(`Loaded external Z-Wave settings from: ${filePath}`)
76+
return cachedSettings
77+
} catch (error) {
78+
logger.error(
79+
`Failed to load external settings: ${(error as Error).message}`,
80+
)
81+
return null
82+
}
83+
}
84+
85+
export function getExternallyManagedPaths(): string[] {
86+
const settings = loadExternalSettings()
87+
if (!settings) return []
88+
89+
const paths: string[] = []
90+
91+
// Logging (logFilename and forceConsole are driver-only, no UI mapping)
92+
if (settings.logEnabled !== undefined) paths.push('zwave.logEnabled')
93+
if (settings.logLevel !== undefined) paths.push('zwave.logLevel')
94+
if (settings.logToFile !== undefined) paths.push('zwave.logToFile')
95+
if (settings.maxFiles !== undefined) paths.push('zwave.maxFiles')
96+
97+
// RF settings
98+
if (settings.rf?.region !== undefined) paths.push('zwave.rf.region')
99+
if (settings.rf?.autoPowerlevels !== undefined)
100+
paths.push('zwave.rf.autoPowerlevels')
101+
102+
// Security keys (check each specific key)
103+
if (settings.securityKeys?.S0_Legacy !== undefined)
104+
paths.push('zwave.securityKeys.S0_Legacy')
105+
if (settings.securityKeys?.S2_Unauthenticated !== undefined)
106+
paths.push('zwave.securityKeys.S2_Unauthenticated')
107+
if (settings.securityKeys?.S2_Authenticated !== undefined)
108+
paths.push('zwave.securityKeys.S2_Authenticated')
109+
if (settings.securityKeys?.S2_AccessControl !== undefined)
110+
paths.push('zwave.securityKeys.S2_AccessControl')
111+
if (settings.securityKeysLongRange?.S2_Authenticated !== undefined)
112+
paths.push('zwave.securityKeysLongRange.S2_Authenticated')
113+
if (settings.securityKeysLongRange?.S2_AccessControl !== undefined)
114+
paths.push('zwave.securityKeysLongRange.S2_AccessControl')
115+
116+
// Features
117+
if (settings.enableSoftReset !== undefined)
118+
paths.push('zwave.enableSoftReset')
119+
120+
// Home Assistant / Z-Wave JS Server settings
121+
if (settings.serverEnabled !== undefined) paths.push('zwave.serverEnabled')
122+
if (settings.serverPort !== undefined) paths.push('zwave.serverPort')
123+
if (settings.serverHost !== undefined) paths.push('zwave.serverHost')
124+
if (settings.serverServiceDiscoveryDisabled !== undefined)
125+
paths.push('zwave.serverServiceDiscoveryDisabled')
126+
127+
// Presets (driver-only, no UI mapping)
128+
129+
return paths
130+
}
131+
132+
export function applyExternalDriverSettings(
133+
zwaveOptions: PartialZWaveOptions,
134+
): void {
135+
const settings = loadExternalSettings()
136+
if (!settings) return
137+
138+
if (
139+
settings.logFilename !== undefined ||
140+
settings.forceConsole !== undefined
141+
) {
142+
zwaveOptions.logConfig = zwaveOptions.logConfig || {}
143+
if (settings.logFilename !== undefined)
144+
zwaveOptions.logConfig.filename = settings.logFilename
145+
if (settings.forceConsole !== undefined)
146+
zwaveOptions.logConfig.forceConsole = settings.forceConsole
147+
}
148+
149+
if (settings.storage) {
150+
zwaveOptions.storage = zwaveOptions.storage || {}
151+
if (settings.storage.cacheDir !== undefined)
152+
zwaveOptions.storage.cacheDir = settings.storage.cacheDir
153+
if (settings.storage.throttle !== undefined)
154+
zwaveOptions.storage.throttle = settings.storage.throttle
155+
}
156+
157+
if (settings.presets && settings.presets.length > 0) {
158+
for (const presetName of settings.presets) {
159+
const preset =
160+
driverPresets[presetName as keyof typeof driverPresets]
161+
if (preset) {
162+
Object.assign(zwaveOptions, preset)
163+
} else {
164+
logger.warn(`Unknown driver preset: ${presetName}`)
165+
}
166+
}
167+
}
168+
}
169+
170+
/**
171+
* Merge external settings into ZwaveConfig.
172+
* This should be called once in app.ts before passing settings to ZwaveClient.
173+
*/
174+
export function mergeExternalSettings(
175+
zwaveConfig: Record<string, unknown>,
176+
): void {
177+
const settings = loadExternalSettings()
178+
if (!settings) return
179+
180+
// Server settings
181+
if (settings.serverEnabled !== undefined)
182+
zwaveConfig.serverEnabled = settings.serverEnabled
183+
if (settings.serverPort !== undefined)
184+
zwaveConfig.serverPort = settings.serverPort
185+
if (settings.serverHost !== undefined)
186+
zwaveConfig.serverHost = settings.serverHost
187+
if (settings.serverServiceDiscoveryDisabled !== undefined)
188+
zwaveConfig.serverServiceDiscoveryDisabled =
189+
settings.serverServiceDiscoveryDisabled
190+
191+
// Logging settings
192+
if (settings.logEnabled !== undefined)
193+
zwaveConfig.logEnabled = settings.logEnabled
194+
if (settings.logLevel !== undefined)
195+
zwaveConfig.logLevel = settings.logLevel
196+
if (settings.logToFile !== undefined)
197+
zwaveConfig.logToFile = settings.logToFile
198+
if (settings.maxFiles !== undefined)
199+
zwaveConfig.maxFiles = settings.maxFiles
200+
201+
// RF settings
202+
if (settings.rf) {
203+
zwaveConfig.rf = zwaveConfig.rf || {}
204+
const rf = zwaveConfig.rf as Record<string, unknown>
205+
if (settings.rf.region !== undefined) rf.region = settings.rf.region
206+
if (settings.rf.autoPowerlevels !== undefined)
207+
rf.autoPowerlevels = settings.rf.autoPowerlevels
208+
}
209+
210+
// Security keys (stored as hex strings, converted to Buffers later by ZwaveClient)
211+
if (settings.securityKeys) {
212+
zwaveConfig.securityKeys = zwaveConfig.securityKeys || {}
213+
const keys = zwaveConfig.securityKeys as Record<string, string>
214+
for (const [key, value] of Object.entries(settings.securityKeys)) {
215+
if (value) keys[key] = value
216+
}
217+
}
218+
if (settings.securityKeysLongRange) {
219+
zwaveConfig.securityKeysLongRange =
220+
zwaveConfig.securityKeysLongRange || {}
221+
const keys = zwaveConfig.securityKeysLongRange as Record<string, string>
222+
for (const [key, value] of Object.entries(
223+
settings.securityKeysLongRange,
224+
)) {
225+
if (value) keys[key] = value
226+
}
227+
}
228+
229+
// Features
230+
if (settings.enableSoftReset !== undefined)
231+
zwaveConfig.enableSoftReset = settings.enableSoftReset
232+
}

api/lib/utils.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -426,9 +426,7 @@ function hasDockerCGroup() {
426426
let isDockerCached: boolean
427427

428428
export function isDocker(): boolean {
429-
// TODO: Use `??=` when targeting Node.js 16.
430429
isDockerCached ??= hasDockerEnv() || hasDockerCGroup()
431-
432430
return isDockerCached
433431
}
434432

0 commit comments

Comments
 (0)