diff --git a/homeassistant-addon/CHANGELOG.md b/homeassistant-addon/CHANGELOG.md index 1acd307..9b379e6 100644 --- a/homeassistant-addon/CHANGELOG.md +++ b/homeassistant-addon/CHANGELOG.md @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 If this add-on saves you time, you can [buy me a coffee](https://buymeacoffee.com/dougrathbone). +## [1.34.10] - 2026-09-11 + +### Fixed + +- **The status page reports when it cannot bind.** C-Gate and MQTT stay up; Home Assistant diagnostics show the web UI is down. + +### Changed + +- Internal: patched js-yaml, HTTP request timeouts on the status page, schema-backed config range warnings, and a Security event verb table. + ## [1.34.9] - 2026-09-11 ### Security diff --git a/homeassistant-addon/config.yaml b/homeassistant-addon/config.yaml index e57e2f2..ca794dc 100644 --- a/homeassistant-addon/config.yaml +++ b/homeassistant-addon/config.yaml @@ -1,5 +1,5 @@ name: "C-Gate Web Bridge" -version: "1.34.9" +version: "1.34.10" slug: cgateweb description: "Bridge between Clipsal C-Bus systems and MQTT/Home Assistant" url: "https://github.com/dougrathbone/cgateweb" diff --git a/package-lock.json b/package-lock.json index d87220f..a3b0bca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cgateweb", - "version": "1.34.9", + "version": "1.34.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cgateweb", - "version": "1.34.9", + "version": "1.34.10", "license": "MIT", "dependencies": { "adm-zip": "^0.6.1", @@ -20,7 +20,7 @@ "eslint": "^10.9.1", "globals": "^17.12.0", "jest": "^30.5.1", - "js-yaml": "^4.3.0", + "js-yaml": "4.3.2", "typescript": "^7.0.2", "yaml": "^2.9.0" }, @@ -4596,9 +4596,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index ef18883..c3a8a2d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cgateweb", - "version": "1.34.9", + "version": "1.34.10", "description": "Node.js bridge connecting Clipsal C-Bus automation systems to MQTT for Home Assistant integration", "keywords": [ "cbus", @@ -43,10 +43,10 @@ "jest": "^30.5.1", "typescript": "^7.0.2", "yaml": "^2.9.0", - "js-yaml": "^4.3.0" + "js-yaml": "4.3.2" }, "overrides": { - "js-yaml": "^4.3.0", + "js-yaml": "4.3.2", "browserslist": "4.28.8", "@humanfs/node": "0.16.8" }, diff --git a/src/applicationDecoders/securityDecoder.js b/src/applicationDecoders/securityDecoder.js index 3b8089d..2a1abd6 100644 --- a/src/applicationDecoders/securityDecoder.js +++ b/src/applicationDecoders/securityDecoder.js @@ -273,6 +273,197 @@ function decodeStatusReport2({ network, application, params, verb }) { return { kind: 'status_report_2', network, application, zones, verb }; } +/** + * Zone-state verbs (sealed/unsealed/open/short). + * @private + */ +function decodeZoneKind({ network, application, zone, verb }) { + if (zone === null) return null; + return { kind: 'zone', network, application, zone, zoneState: ZONE_STATE_BY_VERB[verb], verb }; +} + +/** + * Echo of our own status_request commands on the event port — consumed + * quietly instead of logging as undecoded. + * @private + */ +function decodeStatusRequest({ network, application, params, verb }) { + const report = params.length > 0 ? parseInt(params[0], 10) : NaN; + return { kind: 'status_request', network, application, report: Number.isInteger(report) ? report : null, verb }; +} + +/** + * Echo of `security request_zone_name //PROJECT// `. + * @private + */ +function decodeRequestZoneName({ network, application, zone, params, verb }) { + const zoneParam = params.length > 0 ? parseInt(params[0], 10) : NaN; + return { + kind: 'zone_name_request_echo', + network, + application, + zone: Number.isInteger(zoneParam) ? String(zoneParam) : zone, + verb + }; +} + +/** + * Panel zone-name reply. Name is 11 bytes, space-padded in the spec; + * C-Gate's text form is inferred (no live capture). Empty names still + * consume the line so they do not warn-spam. + * @private + */ +function decodeZoneName({ network, application, zone, params, verb }) { + const name = params.join(' ').replace(/^["']|["']$/g, '').trim(); + if (zone === null) return { kind: 'zone_name', network, application, zone: null, name, verb }; + return { kind: 'zone_name', network, application, zone, name, verb }; +} + +/** + * Spec $90 password-entry codes 1–4. C-Gate verb inferred as password_entry; + * unknown extra tokens still consume the line. + * @private + */ +function decodePasswordEntry({ network, application, params, verb }) { + const code = params.length > 0 ? parseInt(params[0], 10) : NaN; + return { + kind: 'password_entry', + network, + application, + code: Number.isInteger(code) && code >= 1 && code <= 4 ? code : null, + verb + }; +} + +/** + * Echo of our own `security arm` commands. The echo carries no state worth + * acting on — the panel's own exit_delay_started/system_arm events follow — + * so it is recognised purely to keep it out of the undecoded log (#42). + * @private + */ +function decodeArmEcho({ network, application, params, verb }) { + return { kind: 'arm_command_echo', network, application, mode: params.length > 0 ? params[0] : null, verb }; +} + +/** + * Echo of `security emulate_keypad`. The argument is one character of the + * user's alarm PIN; every path that logs an undecoded line would have + * written it out verbatim (#51). Deliberately does not carry the key. + * @private + */ +function decodeKeypadEcho({ network, application, verb }) { + return { kind: 'keypad_command_echo', network, application, verb }; +} + +/** + * zone is '0' when the panel armed with nothing blocking, a zone number on + * panels that report readiness per zone, and null on panels that omit the + * segment entirely. + * @private + */ +function decodeArmReady({ network, application, zone, verb }) { + return { kind: 'arm_ready', network, application, zone, verb }; +} + +/** @private */ +function decodeArmNotReady({ network, application, zone, verb }) { + return { kind: 'arm_not_ready', network, application, zone, verb }; +} + +/** @private */ +function decodeExitDelay({ network, application, verb }) { + return { kind: 'exit_delay_started', network, application, verb }; +} + +/** + * The system is armed and a delay zone just opened: the siren follows + * unless it is disarmed in time (spec §5.5.1.4). Verb spelling inferred + * from exit_delay_started — see the header note. + * + * zone is the zone that started the delay when the panel names one, null + * when it does not; a literal 0 means "no particular zone" and is + * reported as null so nothing downstream addresses a zone 0 that does + * not exist. + * @private + */ +function decodeEntryDelay({ network, application, zone, verb }) { + return { + kind: 'entry_delay_started', network, application, + zone: zone === '0' ? null : zone, + verb + }; +} + +/** @private */ +function decodeSystemArm({ network, application, params, verb }) { + const mode = params.length > 0 ? parseInt(params[0], 10) : NaN; + const modeName = Object.prototype.hasOwnProperty.call(ARM_MODE_BY_CODE, mode) + ? ARM_MODE_BY_CODE[mode] + : null; + return { kind: 'system_arm', network, application, mode: Number.isInteger(mode) ? mode : null, modeName, verb }; +} + +/** @private */ +function decodeAlarm({ network, application, verb }) { + return { kind: verb, network, application, verb }; +} + +/** @private */ +function decodeZoneIsolated({ network, application, zone, verb }) { + return { kind: 'zone_isolated', network, application, zone, verb }; +} + +/** + * Panel-wide trouble (mains, battery, tamper, panic, phone line, arm + * failure, fire). Raise/clear sense is resolved here so downstream code + * never parses verbs. PANEL_TROUBLE_VERBS name their own sense; the rest + * carry it in a "_raised" / "_cleared" argument, where a bare + * verb with no argument means raised. + * @private + */ +function decodePanelTrouble({ network, application, params, verb }) { + const named = PANEL_TROUBLE_VERBS.get(verb); + const detailCondition = PANEL_TROUBLE_DETAIL_VERBS.get(verb); + if (!named && !detailCondition) return null; + const detail = params.length > 0 ? params.join(' ') : null; + return { + kind: 'panel_trouble', network, application, + condition: named ? named.condition : detailCondition, + active: named ? named.active : !(detail && detail.endsWith('_cleared')), + verb, + detail + }; +} + +const VERB_HANDLERS = Object.assign(Object.create(null), { + zone_sealed: decodeZoneKind, + zone_unsealed: decodeZoneKind, + zone_open: decodeZoneKind, + zone_short: decodeZoneKind, + status_report_1: decodeStatusReport1, + status_report_2: decodeStatusReport2, + status_request: decodeStatusRequest, + request_zone_name: decodeRequestZoneName, + zone_name: decodeZoneName, + password_entry: decodePasswordEntry, + arm: decodeArmEcho, + emulate_keypad: decodeKeypadEcho, + arm_ready: decodeArmReady, + arm_not_ready: decodeArmNotReady, + exit_delay_started: decodeExitDelay, + entry_delay_started: decodeEntryDelay, + system_arm: decodeSystemArm, + alarm_on: decodeAlarm, + alarm_off: decodeAlarm, + zone_isolated: decodeZoneIsolated +}); +for (const verb of PANEL_TROUBLE_VERBS.keys()) { + VERB_HANDLERS[verb] = decodePanelTrouble; +} +for (const verb of PANEL_TROUBLE_DETAIL_VERBS.keys()) { + VERB_HANDLERS[verb] = decodePanelTrouble; +} + /** * Decode a single C-Gate event line from the Security application. * @@ -307,151 +498,9 @@ function decodeLine(line) { if (!parsed) return null; const { network, application, zone } = parsed; - // 4. Dispatch by verb - const zoneState = ZONE_STATE_BY_VERB[verb]; - if (zoneState !== undefined) { - if (zone === null) return null; - return { kind: 'zone', network, application, zone, zoneState, verb }; - } - - if (verb === 'status_report_1') { - return decodeStatusReport1({ network, application, params, verb }); - } - - if (verb === 'status_report_2') { - return decodeStatusReport2({ network, application, params, verb }); - } - - // Our own status_request commands echo back on the event port - // ("security status_request //PROJECT// #sourceunit=0 - // OID= sessionId=cmd6 …") — recognise them so they are consumed quietly - // instead of logging as undecoded. - if (verb === 'status_request') { - const report = params.length > 0 ? parseInt(params[0], 10) : NaN; - return { kind: 'status_request', network, application, report: Number.isInteger(report) ? report : null, verb }; - } - - // Echo of `security request_zone_name //PROJECT// `. - if (verb === 'request_zone_name') { - const zoneParam = params.length > 0 ? parseInt(params[0], 10) : NaN; - return { - kind: 'zone_name_request_echo', - network, - application, - zone: Number.isInteger(zoneParam) ? String(zoneParam) : zone, - verb - }; - } - - // Panel zone-name reply. Name is 11 bytes, space-padded in the spec; - // C-Gate's text form is inferred (no live capture). Empty names still - // consume the line so they do not warn-spam. - if (verb === 'zone_name') { - const name = params.join(' ').replace(/^["']|["']$/g, '').trim(); - if (zone === null) return { kind: 'zone_name', network, application, zone: null, name, verb }; - return { kind: 'zone_name', network, application, zone, name, verb }; - } - - // Spec $90 password-entry codes 1–4. C-Gate verb inferred as - // password_entry; unknown extra tokens still consume the line. - if (verb === 'password_entry') { - const code = params.length > 0 ? parseInt(params[0], 10) : NaN; - return { - kind: 'password_entry', - network, - application, - code: Number.isInteger(code) && code >= 1 && code <= 4 ? code : null, - verb - }; - } - - // Same for our own `security arm` commands ("security arm //PROJECT/254/208 - // day #sourceunit=0 …"). The echo carries no state worth acting on — the - // panel's own exit_delay_started/system_arm events follow and drive the - // entity — so it is recognised purely to keep it out of the undecoded log - // (#42). - if (verb === 'arm') { - return { kind: 'arm_command_echo', network, application, mode: params.length > 0 ? params[0] : null, verb }; - } - - // Our own `security emulate_keypad` commands echo back the same way. Beyond - // the log noise, these must be recognised for a second reason: the argument - // is one character of the user's alarm PIN, and every path that logs an - // undecoded line would have written it out verbatim (#51). Deliberately does - // not carry the key. - if (verb === 'emulate_keypad') { - return { kind: 'keypad_command_echo', network, application, verb }; - } - - // System state verbs (decoded, logged and surfaced to Live Events; the - // panel condition sensors build on these — see securityPanelState). - if (verb === 'arm_ready') { - // zone is '0' when the panel armed with nothing blocking, a zone number - // on panels that report readiness per zone, and null on panels that - // omit the segment entirely. - return { kind: 'arm_ready', network, application, zone, verb }; - } - - if (verb === 'arm_not_ready') { - return { kind: 'arm_not_ready', network, application, zone, verb }; - } - - if (verb === 'exit_delay_started') { - return { kind: 'exit_delay_started', network, application, verb }; - } - - if (verb === 'entry_delay_started') { - // The system is armed and a delay zone just opened: the siren follows - // unless it is disarmed in time (spec §5.5.1.4). Verb spelling inferred - // from exit_delay_started — see the header note. - // - // zone is the zone that started the delay when the panel names one, - // null when it does not; a literal 0 means "no particular zone" and is - // reported as null so nothing downstream addresses a zone 0 that does - // not exist. - return { - kind: 'entry_delay_started', network, application, - zone: zone === '0' ? null : zone, - verb - }; - } - - if (verb === 'system_arm') { - const mode = params.length > 0 ? parseInt(params[0], 10) : NaN; - const modeName = Object.prototype.hasOwnProperty.call(ARM_MODE_BY_CODE, mode) - ? ARM_MODE_BY_CODE[mode] - : null; - return { kind: 'system_arm', network, application, mode: Number.isInteger(mode) ? mode : null, modeName, verb }; - } - - if (verb === 'alarm_on' || verb === 'alarm_off') { - return { kind: verb, network, application, verb }; - } - - if (verb === 'zone_isolated') { - return { kind: 'zone_isolated', network, application, zone, verb }; - } - - // Panel-wide trouble conditions (mains, battery, tamper, panic, phone line, - // arm failure, fire). These become diagnostic binary_sensors; the raise and - // clear senses are resolved here so downstream code never parses verbs. - const named = PANEL_TROUBLE_VERBS.get(verb); - const detailCondition = PANEL_TROUBLE_DETAIL_VERBS.get(verb); - if (named || detailCondition) { - // Verbs in PANEL_TROUBLE_VERBS name their own sense; the rest carry it - // in a "_raised" / "_cleared" argument, where a bare verb - // with no argument means raised. - const detail = params.length > 0 ? params.join(' ') : null; - return { - kind: 'panel_trouble', network, application, - condition: named ? named.condition : detailCondition, - active: named ? named.active : !(detail && detail.endsWith('_cleared')), - verb, - detail - }; - } - - return null; + const handler = VERB_HANDLERS[verb]; + if (!handler) return null; + return handler({ network, application, zone, params, verb }); } module.exports = { diff --git a/src/cgateWebBridge.js b/src/cgateWebBridge.js index f621bea..e9b999d 100644 --- a/src/cgateWebBridge.js +++ b/src/cgateWebBridge.js @@ -131,6 +131,10 @@ class CgateWebBridge { staleDeviceDetector; /** @type {*} */ initializationService; + /** @type {boolean} */ + _webListening; + /** @type {string|null} */ + _webListenError; /** * Creates a new CgateWebBridge instance. @@ -295,9 +299,20 @@ class CgateWebBridge { this._updateBridgeReadiness('startup-complete'); // Off the await chain so it never gates the critical startup path. - this.webServer.start().catch((err) => { - this.logger.warn(`Web server failed to start: ${err.message}`); - }); + this.webServer.start() + .then(() => { + this._webListening = true; + this._webListenError = null; + this._updateBridgeReadiness('web-listening'); + this.haBridgeDiagnostics.publishNow('web-listening'); + }) + .catch((err) => { + this._webListening = false; + this._webListenError = err.message; + this.logger.warn(`Web server failed to start: ${err.message}`); + this._updateBridgeReadiness('web-bind-failed'); + this.haBridgeDiagnostics.publishNow('web-bind-failed'); + }); // Fire-and-forget alongside the web server: learns the ingress base // path and applies it once known (GitHub #33). @@ -890,7 +905,11 @@ class CgateWebBridge { isShuttingDown: commandStats ? commandStats.isShuttingDown : false }, event: eventConnected, - eventReconnectAttempts: this.eventConnection?.reconnectAttempts || 0 + eventReconnectAttempts: this.eventConnection?.reconnectAttempts || 0, + web: { + listening: this._webListening === true, + error: this._webListenError || null + } }, metrics: { commandQueue: { diff --git a/src/cgateWebBridgeBuild.js b/src/cgateWebBridgeBuild.js index c22f243..da2e9d2 100644 --- a/src/cgateWebBridgeBuild.js +++ b/src/cgateWebBridgeBuild.js @@ -184,6 +184,12 @@ class _CgateWebBridgeBuild { /** @type {Map} */ this._networkSyncState = new Map(); + // Web listen state is independent of C-Gate/MQTT readiness: a bind + // failure must not mark the bridge unready, but it is reported on the + // status payload and HA diagnostics so an Ingress 502 is explainable. + this._webListening = false; + this._webListenError = null; + // Owns lifecycle state + readiness reason; emits 'readinessChanged' which // the bridge subscribes to (after haBridgeDiagnostics is built) to drive // the hello/cgateweb status publish and diagnostics refresh. @@ -353,6 +359,8 @@ class _CgateWebBridgeBuild { maxDashboardDevices: resolveSetting(this.settings, 'webDashboardMaxDevices'), maxSseConnections: resolveSetting(this.settings, 'web_max_sse_connections'), _sseKeepaliveMs: resolveSetting(this.settings, 'webSseKeepaliveMs'), + headersTimeoutMs: resolveSetting(this.settings, 'webHeadersTimeoutMs'), + requestTimeoutMs: resolveSetting(this.settings, 'webRequestTimeoutMs'), triggerAppId: resolveSetting(this.settings, 'ha_discovery_trigger_app_id'), getStatus: () => this._getBridgeStatus(), deviceStateManager: this.deviceStateManager, diff --git a/src/config/ConfigLoader.js b/src/config/ConfigLoader.js index 624e9bf..11d3719 100644 --- a/src/config/ConfigLoader.js +++ b/src/config/ConfigLoader.js @@ -2,7 +2,7 @@ const fs = require('fs'); const { Logger } = require('../logger'); const EnvironmentDetector = require('./EnvironmentDetector'); -const { listKnownConfigKeys, listSettingAliases, getSchemaEntry, resolveSetting } = require('./schema'); +const { listKnownConfigKeys, listSettingAliases, listSchemaEntries, getSchemaEntry, resolveSetting } = require('./schema'); const { DEFAULT_ADDON_LABEL_FILE, LEGACY_ADDON_LABEL_FILE, DEFAULT_ADDON_DATA_LABEL_FILE } = require('../constants'); const { isPortInRange, isValidCgateProjectName, isValidCgateUsername, isValidCgatePassword, normalizeOptionalSecret } = require('./validationRules'); const { applyAddonOptionMap } = require('./addonOptionMap'); @@ -586,25 +586,7 @@ class ConfigLoader { errors.push('C-Gate event port must be between 1 and 65535'); } - if (configToValidate.messageinterval && (configToValidate.messageinterval < 10 || configToValidate.messageinterval > 10000)) { - warnings.push('Message interval should be between 10 and 10000 milliseconds'); - } - - if (configToValidate.commandMinIntervalMs && (configToValidate.commandMinIntervalMs < 1 || configToValidate.commandMinIntervalMs > 1000)) { - warnings.push('commandMinIntervalMs should be between 1 and 1000 milliseconds'); - } - - if (configToValidate.eventPublishDedupWindowMs && (configToValidate.eventPublishDedupWindowMs < 0 || configToValidate.eventPublishDedupWindowMs > 60000)) { - warnings.push('eventPublishDedupWindowMs should be between 0 and 60000 milliseconds'); - } - - if (configToValidate.eventPublishDedupMaxEntries && configToValidate.eventPublishDedupMaxEntries < 100) { - warnings.push('eventPublishDedupMaxEntries should be at least 100'); - } - - if (configToValidate.topicCacheMaxEntries && configToValidate.topicCacheMaxEntries < 100) { - warnings.push('topicCacheMaxEntries should be at least 100'); - } + this._warnNumericRanges(configToValidate, warnings); // Validate C-Gate mode settings if (configToValidate.cgate_mode === 'managed') { @@ -632,6 +614,31 @@ class ConfigLoader { return true; } + /** + * Warn when a numeric setting sits outside the schema's warnMin/warnMax. + * Values are still accepted; this is an operator heads-up only. + * @private + */ + _warnNumericRanges(config, warnings) { + for (const entry of listSchemaEntries()) { + if (entry.warnMin === undefined && entry.warnMax === undefined) continue; + const value = config[entry.key]; + if (typeof value !== 'number' || !Number.isFinite(value)) continue; + const below = entry.warnMin !== undefined && value < entry.warnMin; + const above = entry.warnMax !== undefined && value > entry.warnMax; + if (!below && !above) continue; + const unitSuffix = entry.unit === 'ms' ? ' milliseconds' : ''; + if (entry.warnMin !== undefined && entry.warnMax !== undefined) { + const label = entry.key === 'messageinterval' ? 'Message interval' : entry.key; + warnings.push(`${label} should be between ${entry.warnMin} and ${entry.warnMax}${unitSuffix}`); + } else if (below) { + warnings.push(`${entry.key} should be at least ${entry.warnMin}`); + } else { + warnings.push(`${entry.key} should be at most ${entry.warnMax}`); + } + } + } + /** * Get current configuration */ diff --git a/src/config/schema.js b/src/config/schema.js index 6e9922f..4b78d01 100644 --- a/src/config/schema.js +++ b/src/config/schema.js @@ -31,6 +31,8 @@ * description - the plain-English explanation of what the setting does. * aliases - other names a standalone settings.js may use for this exact * setting. See ALIAS RULE below. + * warnMin - optional inclusive floor; ConfigLoader.validate warns below it. + * warnMax - optional inclusive ceiling; ConfigLoader.validate warns above it. * * ALIAS RULE * ---------- @@ -67,6 +69,8 @@ * @property {string[]} [values] * @property {string} [reason] * @property {string[]} [aliases] + * @property {number} [warnMin] + * @property {number} [warnMax] */ /** @@ -200,7 +204,9 @@ const SETTINGS_SCHEMA = { exposure: 'both', description: 'Minimum gap between outbound C-Gate commands, so bursts do not flood C-Gate.', // Milliseconds on both sides - the add-on option is not one of the *_sec ones. - aliases: ['message_interval'] + aliases: ['message_interval'], + warnMin: 10, + warnMax: 10000 }, commandMinIntervalMs: { key: 'commandMinIntervalMs', @@ -209,7 +215,9 @@ const SETTINGS_SCHEMA = { unit: 'ms', exposure: 'standalone', description: 'Floor on the adaptive command interval.', - reason: TUNING_ONLY_REASON + reason: TUNING_ONLY_REASON, + warnMin: 1, + warnMax: 1000 }, commandMinIntervalFloorMs: { key: 'commandMinIntervalFloorMs', @@ -498,7 +506,9 @@ const SETTINGS_SCHEMA = { unit: 'ms', exposure: 'standalone', description: 'Drop a publish if the identical payload went to the same topic within this window. 0 disables. Useful on noisy buses.', - reason: TUNING_ONLY_REASON + reason: TUNING_ONLY_REASON, + warnMin: 0, + warnMax: 60000 }, eventPublishDedupMaxEntries: { key: 'eventPublishDedupMaxEntries', @@ -507,7 +517,8 @@ const SETTINGS_SCHEMA = { unit: 'none', exposure: 'standalone', description: 'Cap on the publish dedup cache.', - reason: TUNING_ONLY_REASON + reason: TUNING_ONLY_REASON, + warnMin: 100 }, topicCacheMaxEntries: { key: 'topicCacheMaxEntries', @@ -516,7 +527,8 @@ const SETTINGS_SCHEMA = { unit: 'none', exposure: 'standalone', description: 'Cap on the computed-topic string cache.', - reason: TUNING_ONLY_REASON + reason: TUNING_ONLY_REASON, + warnMin: 100 }, eventPublishCoalesce: { key: 'eventPublishCoalesce', @@ -1112,6 +1124,24 @@ const SETTINGS_SCHEMA = { description: 'SSE comment keepalive interval so proxies don\'t idle-close the stream.', reason: TUNING_ONLY_REASON }, + webHeadersTimeoutMs: { + key: 'webHeadersTimeoutMs', + type: 'number', + default: 60000, + unit: 'ms', + exposure: 'standalone', + description: 'How long the web UI waits for request headers before closing the socket. Matches Node\'s default so behaviour is unchanged unless you raise it.', + reason: TUNING_ONLY_REASON + }, + webRequestTimeoutMs: { + key: 'webRequestTimeoutMs', + type: 'number', + default: 300000, + unit: 'ms', + exposure: 'standalone', + description: 'Inactivity timeout for a web UI request. Matches Node\'s default; SSE keepalives keep the event stream inside this window.', + reason: TUNING_ONLY_REASON + }, eventLogMaxEntries: { key: 'eventLogMaxEntries', type: 'number', diff --git a/src/haBridgeDiagnostics.js b/src/haBridgeDiagnostics.js index 50c6c59..cb8bbe0 100644 --- a/src/haBridgeDiagnostics.js +++ b/src/haBridgeDiagnostics.js @@ -77,7 +77,8 @@ class HaBridgeDiagnostics { { key: 'command_pool_healthy', component: HA_COMPONENT_SENSOR, name: 'Healthy Command Connections', icon: 'mdi:pool' }, { key: 'command_queue_depth', component: HA_COMPONENT_SENSOR, name: 'Command Queue Depth', icon: 'mdi:queue-first-in-last-out' }, { key: 'reconnect_indicator', component: HA_COMPONENT_SENSOR, name: 'Reconnect Indicator', icon: 'mdi:restart-alert' }, - { key: 'cgate_version', component: HA_COMPONENT_SENSOR, name: 'C-Gate Version', icon: 'mdi:tag-outline' } + { key: 'cgate_version', component: HA_COMPONENT_SENSOR, name: 'C-Gate Version', icon: 'mdi:tag-outline' }, + { key: 'web_listening', component: HA_COMPONENT_BINARY_SENSOR, name: 'Web UI Listening', icon: 'mdi:web' } ]; for (const entity of diagnostics) { @@ -133,7 +134,8 @@ class HaBridgeDiagnostics { command_pool_healthy: String(Number(commandPool.healthyConnections || 0)), command_queue_depth: String(Number(queueDepth)), reconnect_indicator: reconnectIndicator, - cgate_version: cgateVersion + cgate_version: cgateVersion, + web_listening: status.connections?.web?.listening ? 'ON' : 'OFF' }; for (const [key, value] of Object.entries(values)) { @@ -152,7 +154,8 @@ class HaBridgeDiagnostics { mqtt: !!status.connections?.mqtt, event: !!status.connections?.event, commandPoolHealthy: Number(commandPool.healthyConnections || 0), - commandPoolTotal: Number(commandPool.totalConnections || 0) + commandPoolTotal: Number(commandPool.totalConnections || 0), + webListening: !!status.connections?.web?.listening }, queue: { depth: Number(queue.depth || 0), diff --git a/src/webServer.js b/src/webServer.js index 15e04ba..69e00b0 100644 --- a/src/webServer.js +++ b/src/webServer.js @@ -128,6 +128,8 @@ class WebServer { * @param {number} [options.maxDashboardDevices] - Maximum device rows on GET /api/dashboard * @param {number} [options.maxSseConnections] - Maximum concurrent SSE connections * @param {number} [options._sseKeepaliveMs] - SSE keep-alive interval in ms (internal) + * @param {number} [options.headersTimeoutMs] - HTTP headers timeout in ms + * @param {number} [options.requestTimeoutMs] - HTTP request inactivity timeout in ms */ constructor(options = {}) { this.port = listenPort(options.port, resolveSetting({}, 'web_port')); @@ -161,6 +163,8 @@ class WebServer { this.haAreasCacheTtlMs = positiveNumber(options.haAreasCacheTtlMs, resolveSetting({}, 'web_ha_areas_cache_ttl_ms')); this.haApiTimeoutMs = positiveNumber(options.haApiTimeoutMs, resolveSetting({}, 'web_ha_api_timeout_ms')); this.maxDashboardDevices = positiveNumber(options.maxDashboardDevices, resolveSetting({}, 'webDashboardMaxDevices')); + this.headersTimeoutMs = positiveNumber(options.headersTimeoutMs, resolveSetting({}, 'webHeadersTimeoutMs')); + this.requestTimeoutMs = positiveNumber(options.requestTimeoutMs, resolveSetting({}, 'webRequestTimeoutMs')); this.logger = createLogger({ component: 'WebServer' }); this._server = null; @@ -244,6 +248,8 @@ class WebServer { start() { this._startPromise = new Promise((resolve, reject) => { this._server = http.createServer((req, res) => this._handleRequest(req, res)); + this._server.headersTimeout = this.headersTimeoutMs; + this._server.requestTimeout = this.requestTimeoutMs; this._server.on('error', (err) => { this.logger.error(`Web server error: ${err.message}`); diff --git a/tests/cgateWebBridge.test.js b/tests/cgateWebBridge.test.js index 12225d7..faad1c0 100644 --- a/tests/cgateWebBridge.test.js +++ b/tests/cgateWebBridge.test.js @@ -418,6 +418,23 @@ describe('CgateWebBridge', () => { expect(bridge.mqttManager.connected).toBe(true); expect(mockMqttClient.subscribe).toHaveBeenCalled(); }); + + it('records a web bind failure without failing start', async () => { + const failed = Promise.reject(new Error('EADDRINUSE')); + const swallowed = failed.catch(() => {}); + jest.spyOn(bridge.webServer, 'start').mockReturnValue(failed); + const publishSpy = jest.spyOn(bridge.haBridgeDiagnostics, 'publishNow'); + + await expect(bridge.start()).resolves.toBe(bridge); + await swallowed; + + const web = bridge._getBridgeStatus().connections.web; + expect(web.listening).toBe(false); + expect(web.error).toBe('EADDRINUSE'); + expect(bridge.bridgeReadiness.getLifecycleSnapshot().reason).toBe('web-bind-failed'); + expect(bridge._getBridgeStatus().ready).toBe(false); + expect(publishSpy).toHaveBeenCalledWith('web-bind-failed'); + }); }); describe('stop()', () => { @@ -1907,6 +1924,38 @@ describe('CgateWebBridge', () => { }); }); + describe('command error and HA discovery wiring', () => { + let wired; + beforeEach(() => { + wired = new CgateWebBridge({ ...defaultSettings, cbusip: '127.0.0.1' }); + }); + afterEach(() => jest.restoreAllMocks()); + + it('routes command errors to the initialization service', () => { + const spy = jest.spyOn(wired.initializationService, 'handleCommandError').mockImplementation(() => {}); + wired.commandResponseProcessor.onCommandError(401, '401 tree empty'); + expect(spy).toHaveBeenCalledWith(401, '401 tree empty'); + }); + + it('triggers HA discovery from the MQTT command router', () => { + wired.haDiscovery = { trigger: jest.fn(), queueTreeRequest: jest.fn() }; + wired.mqttCommandRouter.emit('haDiscoveryTrigger'); + expect(wired.haDiscovery.trigger).toHaveBeenCalledTimes(1); + }); + + it('queues a tree request from the MQTT command router', () => { + wired.haDiscovery = { trigger: jest.fn(), queueTreeRequest: jest.fn() }; + wired.mqttCommandRouter.emit('treeRequest', '254'); + expect(wired.haDiscovery.queueTreeRequest).toHaveBeenCalledWith('254'); + }); + + it('ignores discovery router events when haDiscovery is missing', () => { + wired.haDiscovery = null; + expect(() => wired.mqttCommandRouter.emit('haDiscoveryTrigger')).not.toThrow(); + expect(() => wired.mqttCommandRouter.emit('treeRequest', '254')).not.toThrow(); + }); + }); + // The coordinator is built inside _buildSubsystems, before the bridge // assigns this.initializationService. Passing the property by value there // captured undefined and every resync threw diff --git a/tests/config/configLoader.test.js b/tests/config/configLoader.test.js index 2682982..6683eac 100644 --- a/tests/config/configLoader.test.js +++ b/tests/config/configLoader.test.js @@ -1557,6 +1557,7 @@ describe('ConfigLoader', () => { }); test('should warn when messageinterval is below minimum', () => { + const warn = jest.spyOn(configLoader.logger, 'warn').mockImplementation(() => {}); const config = { cbusip: '127.0.0.1', mqtt: '127.0.0.1:1883', @@ -1565,9 +1566,11 @@ describe('ConfigLoader', () => { // Should not throw - just warn expect(() => configLoader.validate(config)).not.toThrow(); + expect(warn).toHaveBeenCalledWith('Message interval should be between 10 and 10000 milliseconds'); }); test('should warn when messageinterval is above maximum', () => { + const warn = jest.spyOn(configLoader.logger, 'warn').mockImplementation(() => {}); const config = { cbusip: '127.0.0.1', mqtt: '127.0.0.1:1883', @@ -1575,9 +1578,11 @@ describe('ConfigLoader', () => { }; expect(() => configLoader.validate(config)).not.toThrow(); + expect(warn).toHaveBeenCalledWith('Message interval should be between 10 and 10000 milliseconds'); }); test('should warn when commandMinIntervalMs is out of range', () => { + const warn = jest.spyOn(configLoader.logger, 'warn').mockImplementation(() => {}); const config = { cbusip: '127.0.0.1', mqtt: '127.0.0.1:1883', @@ -1585,9 +1590,11 @@ describe('ConfigLoader', () => { }; expect(() => configLoader.validate(config)).not.toThrow(); + expect(warn).toHaveBeenCalledWith('commandMinIntervalMs should be between 1 and 1000 milliseconds'); }); test('should warn when commandMinIntervalMs is above maximum', () => { + const warn = jest.spyOn(configLoader.logger, 'warn').mockImplementation(() => {}); const config = { cbusip: '127.0.0.1', mqtt: '127.0.0.1:1883', @@ -1595,9 +1602,11 @@ describe('ConfigLoader', () => { }; expect(() => configLoader.validate(config)).not.toThrow(); + expect(warn).toHaveBeenCalledWith('commandMinIntervalMs should be between 1 and 1000 milliseconds'); }); test('should warn when eventPublishDedupWindowMs is out of range', () => { + const warn = jest.spyOn(configLoader.logger, 'warn').mockImplementation(() => {}); const config = { cbusip: '127.0.0.1', mqtt: '127.0.0.1:1883', @@ -1605,9 +1614,11 @@ describe('ConfigLoader', () => { }; expect(() => configLoader.validate(config)).not.toThrow(); + expect(warn).toHaveBeenCalledWith('eventPublishDedupWindowMs should be between 0 and 60000 milliseconds'); }); test('should warn when eventPublishDedupMaxEntries is below minimum', () => { + const warn = jest.spyOn(configLoader.logger, 'warn').mockImplementation(() => {}); const config = { cbusip: '127.0.0.1', mqtt: '127.0.0.1:1883', @@ -1615,9 +1626,11 @@ describe('ConfigLoader', () => { }; expect(() => configLoader.validate(config)).not.toThrow(); + expect(warn).toHaveBeenCalledWith('eventPublishDedupMaxEntries should be at least 100'); }); test('should warn when topicCacheMaxEntries is below minimum', () => { + const warn = jest.spyOn(configLoader.logger, 'warn').mockImplementation(() => {}); const config = { cbusip: '127.0.0.1', mqtt: '127.0.0.1:1883', @@ -1625,6 +1638,7 @@ describe('ConfigLoader', () => { }; expect(() => configLoader.validate(config)).not.toThrow(); + expect(warn).toHaveBeenCalledWith('topicCacheMaxEntries should be at least 100'); }); }); }); diff --git a/tests/configSchema.test.js b/tests/configSchema.test.js index 389e77d..296abb1 100644 --- a/tests/configSchema.test.js +++ b/tests/configSchema.test.js @@ -104,6 +104,18 @@ describe('settings schema — self lint', () => { }).map((entry) => entry.key); expect(bad).toEqual([]); }); + + it('declares warnMin/warnMax as numbers with min not above max', () => { + const bad = ENTRIES.filter((entry) => { + const hasMin = entry.warnMin !== undefined; + const hasMax = entry.warnMax !== undefined; + if (!hasMin && !hasMax) return false; + if (hasMin && typeof entry.warnMin !== 'number') return true; + if (hasMax && typeof entry.warnMax !== 'number') return true; + return hasMin && hasMax && entry.warnMin > entry.warnMax; + }).map((entry) => entry.key); + expect(bad).toEqual([]); + }); }); describe('settings schema — unit suffix convention', () => { diff --git a/tests/defaultSettings.test.js b/tests/defaultSettings.test.js index bff31f0..7235cd2 100644 --- a/tests/defaultSettings.test.js +++ b/tests/defaultSettings.test.js @@ -11,14 +11,14 @@ describe('defaultSettings — frozen baseline', () => { expect(defaultSettings).toStrictEqual(defaultSettingsSnapshot); }); - it('exports the same 147 keys as the baseline', () => { + it('exports the same 150 keys as the baseline', () => { expect(Object.keys(defaultSettings).sort()) .toEqual(Object.keys(defaultSettingsSnapshot).sort()); // Bumping this count is only ever legitimate alongside a genuinely NEW // setting added to the fixture; a changed value for an existing key is // the thing this baseline exists to catch, and must never be "fixed" // in the fixture. - expect(Object.keys(defaultSettings)).toHaveLength(148); + expect(Object.keys(defaultSettings)).toHaveLength(150); }); it('returns a fresh object so consumers cannot mutate the schema defaults', () => { diff --git a/tests/fixtures/defaultSettings.snapshot.json b/tests/fixtures/defaultSettings.snapshot.json index 9d7ce19..72965ca 100644 --- a/tests/fixtures/defaultSettings.snapshot.json +++ b/tests/fixtures/defaultSettings.snapshot.json @@ -138,9 +138,11 @@ "stateResyncOnMqttReconnect": true, "supervisorMqttDetectTimeoutMs": 5000, "topicCacheMaxEntries": 5000, + "webHeadersTimeoutMs": 60000, "webMaxBodySizeBytes": 10485760, "webRateLimitWindowMs": 60000, - "webRateLimitMaxTrackedSources": 5000, + "webRateLimitMaxTrackedSources": 5000, + "webRequestTimeoutMs": 300000, "webSseKeepaliveMs": 15000, "web_active_device_window_ms": 86400000, "web_allow_unauthenticated_mutations": false, diff --git a/tests/haBridgeDiagnostics.test.js b/tests/haBridgeDiagnostics.test.js index 5bfc7a9..bc79fb3 100644 --- a/tests/haBridgeDiagnostics.test.js +++ b/tests/haBridgeDiagnostics.test.js @@ -20,7 +20,8 @@ describe('HaBridgeDiagnostics', () => { mqtt: true, event: true, commandPool: { healthyConnections: 3, pendingReconnects: 0 }, - eventReconnectAttempts: 1 + eventReconnectAttempts: 1, + web: { listening: true, error: null } }, metrics: { commandQueue: { depth: 4 } @@ -32,7 +33,7 @@ describe('HaBridgeDiagnostics', () => { test('publishes discovery and state on first publishNow call', () => { diagnostics.publishNow('test'); - expect(publishFn).toHaveBeenCalledTimes(17); // 8 discovery + 8 state + 1 consolidated stats + expect(publishFn).toHaveBeenCalledTimes(19); // 9 discovery + 9 state + 1 consolidated stats expect(publishFn).toHaveBeenCalledWith( 'homeassistant/binary_sensor/cgateweb_bridge_ready/config', expect.any(String), @@ -44,8 +45,8 @@ describe('HaBridgeDiagnostics', () => { { retain: true, qos: 0 } ); expect(publishFn).toHaveBeenCalledWith( - 'cbus/read/bridge/diagnostics/command_queue_depth/state', - '4', + 'cbus/read/bridge/diagnostics/web_listening/state', + 'ON', { retain: true, qos: 0 } ); }); @@ -56,7 +57,7 @@ describe('HaBridgeDiagnostics', () => { diagnostics.publishNow('second'); - expect(publishFn).toHaveBeenCalledTimes(9); // 8 state + 1 consolidated stats + expect(publishFn).toHaveBeenCalledTimes(10); // 9 state + 1 consolidated stats expect(publishFn).not.toHaveBeenCalledWith( expect.stringContaining('/config'), expect.any(String), @@ -70,7 +71,7 @@ describe('HaBridgeDiagnostics', () => { diagnostics.republishDiscovery(); - expect(publishFn).toHaveBeenCalledTimes(8); // 8 discovery configs only + expect(publishFn).toHaveBeenCalledTimes(9); // 9 discovery configs only expect(publishFn).toHaveBeenCalledWith( 'homeassistant/binary_sensor/cgateweb_bridge_ready/config', expect.any(String), @@ -151,7 +152,8 @@ describe('HaBridgeDiagnostics', () => { mqtt: true, event: true, commandPoolHealthy: 3, - commandPoolTotal: 0 + commandPoolTotal: 0, + webListening: true }); expect(stats.queue).toHaveProperty('depth', 4); expect(stats.publisher).toHaveProperty('published'); diff --git a/tests/webServer.test.js b/tests/webServer.test.js index 5c24a83..821abaf 100644 --- a/tests/webServer.test.js +++ b/tests/webServer.test.js @@ -1595,6 +1595,28 @@ describe('WebServer', () => { await expect(dupServer.start()).rejects.toThrow(); await badServer.close(); }); + + it('applies Node-default HTTP headers and request timeouts', () => { + expect(server._server.headersTimeout).toBe(60000); + expect(server._server.requestTimeout).toBe(300000); + }); + + it('applies configured HTTP timeouts', async () => { + const timed = new WebServer({ + port: 0, + labelLoader, + getStatus: () => ({}), + headersTimeoutMs: 15000, + requestTimeoutMs: 45000 + }); + await timed.start(); + try { + expect(timed._server.headersTimeout).toBe(15000); + expect(timed._server.requestTimeout).toBe(45000); + } finally { + await timed.close(); + } + }); }); describe('Request error handler (catch block)', () => {