Skip to content

Commit d574d8b

Browse files
feat(settings): intelligent restart with hot-reload for editable Z-Wave options (#4413)
- [x] Refactor to intelligent restart detection - [x] Support EditableZWaveOptions for hot updates - [x] Add dedicated restart API endpoint - [x] Show restart dialog only when needed - [x] Fix: Only check changed properties for restart detection - [x] Limit editable options to user-configurable settings - [x] Add proper mapping from settings to PartialZWaveOptions - [x] Move utility functions to utils.ts for code reuse - [x] Improve buildLogConfig to accept full config and include filename/forceConsole - [x] Add clarifying comment for editable settings mapping - [x] Lint and build successfully ## Summary Added a clarifying comment explaining how ZwaveClient configuration properties map to the corresponding driver.updateOptions() parameters. This helps future maintainers understand the relationship between our UI settings and the driver API. ## Changes Made ### Backend (`api/app.ts`) **Added Documentation Comment:** - Clarified that the listed properties are ZwaveClient configuration settings - Explained the mapping to driver.updateOptions() parameters: - `scales` → `preferences.scales` - Log properties (`logEnabled`, `logLevel`, etc.) → `logConfig` object - `disableOptimisticValueUpdate` → direct mapping - Helps developers understand why these specific properties can be updated without restart ## Benefits - ✅ Better code documentation - ✅ Clearer understanding of the settings-to-driver mapping - ✅ Easier maintenance for future developers - ✅ Explains the commented inline annotations ## Testing - ✅ All existing tests pass (52 tests) - ✅ Linting successful - ✅ Build successful <!-- START COPILOT CODING AGENT SUFFIX --> <details> <summary>Original prompt</summary> > > ---- > > *This section details on the original issue you should resolve* > > <issue_title>Save button without restarting</issue_title> > <issue_description>Hi, > > Each time the configuration is changed, the application restarts. Sometimes we just want to change a setting without it being active right away. > > Is it possible to add a button to simply save without restarting and give us the option to restart the application manually? > </issue_description> > > <agent_instructions>In Settings.vue, when users press on save, show a confirmation dialog with a checkbox (checked by default) that ask user if he also want to restart zwave-js driver, if unchecked the request should only update settings json without restarting client</agent_instructions> > > ## Comments on the Issue (you are @copilot in this section) > > <comments> > <comment_new><author>@robertsLando</author><body> > I'm not sure I want this, most changes require a restart and I think this could lead to unexpected behaviours. > > @AlCalzone opinion?</body></comment_new> > </comments> > </details> Fixes #4410 <!-- START COPILOT CODING AGENT TIPS --> --- ✨ Let Copilot coding agent [set things up for you](https://github.com/zwave-js/zwave-js-ui/issues/new?title=✨+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot) — coding agent works faster and does higher quality work when set up for your repo. --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: robertsLando <[email protected]> Co-authored-by: Daniel Lando <[email protected]>
1 parent 9ad1318 commit d574d8b

File tree

6 files changed

+289
-62
lines changed

6 files changed

+289
-62
lines changed

api/app.ts

Lines changed: 187 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import jsonStore from './lib/jsonStore.ts'
1212
import * as loggers from './lib/logger.ts'
1313
import MqttClient from './lib/MqttClient.ts'
1414
import SocketManager from './lib/SocketManager.ts'
15-
import type { CallAPIResult, SensorTypeScale } from './lib/ZwaveClient.ts'
15+
import type { CallAPIResult, ZwaveConfig } from './lib/ZwaveClient.ts'
1616
import ZWaveClient from './lib/ZwaveClient.ts'
1717
import multer, { diskStorage } from 'multer'
1818
import extract from 'extract-zip'
@@ -1068,7 +1068,7 @@ app.get(
10681068
const allSensors = getAllSensors()
10691069
const namedScaleGroups = getAllNamedScaleGroups()
10701070

1071-
const scales: SensorTypeScale[] = []
1071+
const scales: ZwaveConfig['scales'] = []
10721072

10731073
for (const group of namedScaleGroups) {
10741074
for (const scale of Object.values(group.scales)) {
@@ -1137,66 +1137,219 @@ app.post(
11371137
}
11381138
let settings = req.body
11391139

1140-
let restartAll = false
1140+
let shouldRestart = false
11411141
let shouldRestartGw = false
11421142
let shouldRestartZniffer = false
1143+
let canUpdateZwaveOptions = false
11431144

11441145
const actualSettings = jsonStore.get(store.settings) as Settings
11451146

11461147
// TODO: validate settings using calss-validator
11471148
// when settings is null consider a force restart
11481149
if (settings && Object.keys(settings).length > 0) {
1149-
shouldRestartGw = !utils.deepEqual(
1150-
{
1151-
zwave: actualSettings.zwave,
1152-
gateway: actualSettings.gateway,
1153-
mqtt: actualSettings.mqtt,
1154-
},
1155-
{
1156-
zwave: settings.zwave,
1157-
gateway: settings.gateway,
1158-
mqtt: settings.mqtt,
1159-
},
1150+
// Check if gateway settings changed
1151+
const gatewayChanged = !utils.deepEqual(
1152+
actualSettings.gateway,
1153+
settings.gateway,
1154+
)
1155+
const mqttChanged = !utils.deepEqual(
1156+
actualSettings.mqtt,
1157+
settings.mqtt,
11601158
)
11611159

1160+
if (gatewayChanged || mqttChanged) {
1161+
shouldRestartGw = true
1162+
shouldRestart = true
1163+
}
1164+
1165+
let changedZwaveKeys: string[] = []
1166+
1167+
// Check if Z-Wave settings changed
1168+
if (!utils.deepEqual(actualSettings.zwave, settings.zwave)) {
1169+
// These are ZwaveClient configuration properties that map to
1170+
// driver.updateOptions() parameters. The commented names show
1171+
// the corresponding driver option keys:
1172+
// - 'scales' maps to 'preferences.scales'
1173+
// - 'logEnabled', 'logLevel', etc. map to 'logConfig' properties
1174+
// - 'disableOptimisticValueUpdate' maps directly
1175+
const editableZWaveSettings = [
1176+
'disableOptimisticValueUpdate',
1177+
// preferences
1178+
'scales',
1179+
// logConfig
1180+
'logEnabled',
1181+
'logLevel',
1182+
'logToFile',
1183+
'maxFiles',
1184+
'nodeFilter',
1185+
]
1186+
1187+
// Find which Z-Wave settings actually changed
1188+
changedZwaveKeys = Object.keys(settings.zwave || {}).filter(
1189+
(key) => {
1190+
return !utils.deepEqual(
1191+
actualSettings.zwave?.[key],
1192+
settings.zwave?.[key],
1193+
)
1194+
},
1195+
)
1196+
1197+
// Check if only editable options changed
1198+
const onlyEditableChanged = changedZwaveKeys.every((key) =>
1199+
editableZWaveSettings.includes(key),
1200+
)
1201+
1202+
if (
1203+
onlyEditableChanged &&
1204+
changedZwaveKeys.length > 0 &&
1205+
gw?.zwave?.driver
1206+
) {
1207+
// Can update options without restart
1208+
canUpdateZwaveOptions = true
1209+
} else {
1210+
// Need full restart
1211+
shouldRestartGw = true
1212+
shouldRestart = true
1213+
}
1214+
}
1215+
1216+
// Check if Zniffer settings changed
11621217
shouldRestartZniffer = !utils.deepEqual(
11631218
actualSettings.zniffer,
11641219
settings.zniffer,
11651220
)
1221+
if (shouldRestartZniffer) {
1222+
shouldRestart = true
1223+
}
11661224

1167-
// nothing changed, consider it a forced restart
1168-
restartAll = !shouldRestartGw && !shouldRestartZniffer
1169-
1225+
// Save settings to file
11701226
await jsonStore.put(store.settings, settings)
1227+
1228+
// Update driver options if only editable options changed
1229+
if (canUpdateZwaveOptions && gw?.zwave?.driver) {
1230+
try {
1231+
// Build editable options object with only changed properties
1232+
// Map our settings to PartialZWaveOptions format
1233+
const editableOptions: any = {}
1234+
1235+
// Check disableOptimisticValueUpdate
1236+
if (
1237+
changedZwaveKeys.includes(
1238+
'disableOptimisticValueUpdate',
1239+
) &&
1240+
settings.zwave?.disableOptimisticValueUpdate !==
1241+
undefined
1242+
) {
1243+
editableOptions.disableOptimisticValueUpdate =
1244+
settings.zwave.disableOptimisticValueUpdate
1245+
}
1246+
1247+
// Check scales (maps to preferences.scales)
1248+
if (
1249+
changedZwaveKeys.includes('scales') &&
1250+
settings.zwave?.scales !== undefined
1251+
) {
1252+
const preferences = utils.buildPreferences(
1253+
settings.zwave || {},
1254+
)
1255+
if (preferences) {
1256+
editableOptions.preferences = preferences
1257+
}
1258+
}
1259+
1260+
// Check logConfig properties
1261+
const logConfigChanged =
1262+
[
1263+
'logEnabled',
1264+
'logLevel',
1265+
'logToFile',
1266+
'maxFiles',
1267+
'nodeFilter',
1268+
].filter((key) => {
1269+
return (
1270+
changedZwaveKeys.includes(key) &&
1271+
settings.zwave?.[key] !== undefined
1272+
)
1273+
}).length > 0
1274+
1275+
if (logConfigChanged) {
1276+
// Build logConfig object from our settings
1277+
editableOptions.logConfig = utils.buildLogConfig(
1278+
settings.zwave || {},
1279+
)
1280+
}
1281+
1282+
if (Object.keys(editableOptions).length > 0) {
1283+
gw.zwave.driver.updateOptions(editableOptions)
1284+
logger.info(
1285+
'Updated Z-Wave driver options without restart:',
1286+
Object.keys(editableOptions).join(', '),
1287+
)
1288+
}
1289+
} catch (error) {
1290+
logger.error('Error updating driver options', error)
1291+
// If update fails, require restart
1292+
shouldRestart = true
1293+
shouldRestartGw = true
1294+
}
1295+
}
11711296
} else {
1172-
restartAll = true
1297+
// Force restart if no settings provided
1298+
shouldRestart = true
11731299
settings = actualSettings
11741300
}
11751301

1176-
if (restartAll || shouldRestartGw) {
1177-
restarting = true
1302+
res.json({
1303+
success: true,
1304+
message: shouldRestart
1305+
? 'Configuration saved. Restart required to apply changes.'
1306+
: 'Configuration updated successfully',
1307+
data: settings,
1308+
shouldRestart,
1309+
})
1310+
} catch (error) {
1311+
restarting = false
1312+
logger.error(error)
1313+
res.json({ success: false, message: error.message })
1314+
}
1315+
},
1316+
)
1317+
1318+
// restart gateway
1319+
app.post(
1320+
'/api/restart',
1321+
apisLimiter,
1322+
isAuthenticated,
1323+
async function (req, res) {
1324+
try {
1325+
if (restarting) {
1326+
throw Error(
1327+
'Gateway is already restarting, wait a moment before doing another request',
1328+
)
1329+
}
1330+
1331+
const settings = jsonStore.get(store.settings) as Settings
11781332

1179-
await gw.close()
1333+
restarting = true
11801334

1181-
await destroyPlugins()
1182-
// reload loggers settings
1183-
setupLogging(settings)
1184-
// restart clients and gateway
1185-
await startGateway(settings)
1186-
backupManager.init(gw.zwave)
1335+
// Close gateway and restart
1336+
await gw.close()
1337+
await destroyPlugins()
1338+
if (settings.gateway) {
1339+
setupLogging({ gateway: settings.gateway })
11871340
}
1341+
await startGateway(settings)
1342+
backupManager.init(gw.zwave)
11881343

1189-
if (restartAll || shouldRestartZniffer) {
1190-
if (zniffer) {
1191-
await zniffer.close()
1192-
}
1193-
startZniffer(settings.zniffer)
1344+
// Restart Zniffer if enabled
1345+
if (zniffer) {
1346+
await zniffer.close()
11941347
}
1348+
startZniffer(settings.zniffer)
11951349

11961350
res.json({
11971351
success: true,
1198-
message: 'Configuration updated successfully',
1199-
data: settings,
1352+
message: 'Gateway restarted successfully',
12001353
})
12011354
} catch (error) {
12021355
restarting = false

api/lib/ZwaveClient.ts

Lines changed: 5 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,6 @@ import { socketEvents } from './SocketEvents.ts'
137137
import { isUint8Array } from 'node:util/types'
138138
import { PkgFsBindings } from './PkgFsBindings.ts'
139139
import { regionSupportsAutoPowerlevel } from './shared.ts'
140-
import tripleBeam from 'triple-beam'
141140
import { deviceConfigPriorityDir } from './Constants.ts'
142141

143142
export const configManager = new ConfigManager({
@@ -146,8 +145,6 @@ export const configManager = new ConfigManager({
146145

147146
const logger = LogManager.module('Z-Wave')
148147

149-
const loglevels = tripleBeam.configs.npm.levels
150-
151148
const NEIGHBORS_LOCK_REFRESH = 60 * 1000
152149

153150
function validateMethods<T extends readonly (keyof ZwaveClient)[]>(
@@ -345,8 +342,6 @@ export type SensorTypeScale = {
345342

346343
export type AllowedApis = (typeof allowedApis)[number]
347344

348-
const ZWAVEJS_LOG_FILE = utils.joinPath(logsDir, 'zwavejs_%DATE%.log')
349-
350345
export type ZUIValueIdState = {
351346
text: string
352347
value: number | string | boolean
@@ -2182,21 +2177,8 @@ class ZwaveClient extends TypedEventEmitter<ZwaveClientEventCallbacks> {
21822177
deviceConfigPriorityDir:
21832178
this.cfg.deviceConfigPriorityDir || deviceConfigPriorityDir,
21842179
},
2185-
logConfig: {
2186-
// https://zwave-js.github.io/node-zwave-js/#/api/driver?id=logconfig
2187-
enabled: this.cfg.logEnabled,
2188-
level: this.cfg.logLevel
2189-
? loglevels[this.cfg.logLevel]
2190-
: 'info',
2191-
logToFile: this.cfg.logToFile,
2192-
filename: ZWAVEJS_LOG_FILE,
2193-
forceConsole: isDocker() ? !this.cfg.logToFile : false,
2194-
maxFiles: this.cfg.maxFiles || 7,
2195-
nodeFilter:
2196-
this.cfg.nodeFilter && this.cfg.nodeFilter.length > 0
2197-
? this.cfg.nodeFilter.map((n) => parseInt(n))
2198-
: undefined,
2199-
},
2180+
// https://zwave-js.github.io/node-zwave-js/#/api/driver?id=logconfig
2181+
logConfig: utils.buildLogConfig(this.cfg),
22002182
emitValueUpdateAfterSetValue: true,
22012183
apiKeys: {
22022184
firmwareUpdateService:
@@ -2304,13 +2286,9 @@ class ZwaveClient extends TypedEventEmitter<ZwaveClientEventCallbacks> {
23042286
}
23052287

23062288
if (this.cfg.scales) {
2307-
const scales: Record<string | number, string | number> = {}
2308-
for (const s of this.cfg.scales) {
2309-
scales[s.key] = s.label
2310-
}
2311-
2312-
zwaveOptions.preferences = {
2313-
scales,
2289+
const preferences = utils.buildPreferences(this.cfg)
2290+
if (preferences) {
2291+
zwaveOptions.preferences = preferences
23142292
}
23152293
}
23162294

api/lib/utils.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import { isUint8Array } from 'node:util/types'
77
import { createRequire } from 'node:module'
88
import { mkdir, access } from 'node:fs/promises'
99
import { fileURLToPath } from 'node:url'
10+
import { logsDir } from '../config/app.ts'
11+
import tripleBeam from 'triple-beam'
12+
13+
const loglevels = tripleBeam.configs.npm.levels
1014

1115
// don't use import here, it will break the build
1216
const require = createRequire(import.meta.url)
@@ -472,3 +476,45 @@ export async function pathExists(path: string): Promise<boolean> {
472476
return false
473477
}
474478
}
479+
480+
/**
481+
* Convert scales configuration to preferences format for Z-Wave driver options
482+
* This converts the array format used in our settings to the Record format expected by the driver
483+
*/
484+
export function buildPreferences(
485+
config: ZwaveConfig,
486+
): PartialZWaveOptions['preferences'] {
487+
const { scales } = config
488+
if (!scales || scales.length === 0) {
489+
return undefined
490+
}
491+
492+
const scalesRecord: Record<string | number, string | number> = {}
493+
for (const s of scales) {
494+
scalesRecord[s.key] = s.label
495+
}
496+
497+
return {
498+
scales: scalesRecord,
499+
}
500+
}
501+
502+
/**
503+
* Build logConfig object for Z-Wave driver options from Z-Wave configuration
504+
*/
505+
export function buildLogConfig(
506+
config: ZwaveConfig,
507+
): PartialZWaveOptions['logConfig'] {
508+
return {
509+
enabled: config.logEnabled,
510+
level: config.logLevel ? loglevels[config.logLevel] : 'info',
511+
logToFile: config.logToFile,
512+
maxFiles: config.maxFiles || 7,
513+
nodeFilter:
514+
config.nodeFilter && config.nodeFilter.length > 0
515+
? config.nodeFilter.map((n: string) => parseInt(n))
516+
: undefined,
517+
filename: joinPath(logsDir, 'zwavejs_%DATE%.log'),
518+
forceConsole: isDocker() ? !this.cfg.logToFile : false,
519+
}
520+
}

0 commit comments

Comments
 (0)