Skip to content

Commit 1e74fb9

Browse files
committed
feat: per-network HA Discovery health sensor
HA Discovery can fail silently per-network (e.g. 401 Network not found on startup, the v1.8.1 fix recovers via retry but ultimately gives up after 8 attempts). Users had no surface in HA itself for whether discovery was healthy; the only signal was a log warning. Anyone hitting the startup race had to trawl the add-on logs to know what was wrong. Add a per-network "Discovery (Network N)" diagnostic sensor with three states: discovering, ok, paused. Published via HA MQTT Discovery under the existing cgateweb Bridge device so it groups with other diagnostics (Bridge Ready, MQTT Connected, etc.). Config payload published once; state publishes are de-duplicated by previous value. State transitions: - queueTreeRequest() -> discovering - handleTreeEnd() success -> ok - _handleTreeRequestFailure -> paused (only when retry budget exhausts) Bumps to 1.8.4.
1 parent d29c3d1 commit 1e74fb9

6 files changed

Lines changed: 207 additions & 4 deletions

File tree

homeassistant-addon/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ All notable changes to the C-Gate Web Bridge Home Assistant add-on will be docum
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.8.4] - 2026-05-04
9+
10+
### Added
11+
- **Per-network discovery health sensor**: HA Discovery now publishes a "Discovery (Network N)" diagnostic sensor for each configured network, with three states — `discovering` (request in flight or retry pending), `ok` (last TreeXML succeeded), or `paused` (retry budget exhausted from the v1.8.1 startup-race protection). The sensor lives under the existing cgateweb Bridge device in HA, so users can see at a glance whether auto-discovery is healthy without trawling the add-on logs. State publishes are de-duplicated; the HA Discovery config payload is published once per network.
12+
813
## [1.8.3] - 2026-05-04
914

1015
### Refactor

homeassistant-addon/config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: "C-Gate Web Bridge"
2-
version: "1.8.3"
2+
version: "1.8.4"
33
slug: cgateweb
44
description: "Bridge between Clipsal C-Bus systems and MQTT/Home Assistant"
55
url: "https://github.com/dougrathbone/cgateweb"

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "cgateweb",
3-
"version": "1.8.3",
3+
"version": "1.8.4",
44
"description": "Node.js bridge connecting Clipsal C-Bus automation systems to MQTT for Home Assistant integration",
55
"keywords": [
66
"cbus",

src/constants.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,13 @@ const HA_MODEL_HVAC = 'HVAC Zone (Air Conditioning)';
9292
const HA_COMPONENT_EVENT = 'event';
9393
const HA_COMPONENT_SCENE = 'scene';
9494

95+
// HA Discovery health states surfaced as the per-network "Discovery" sensor.
96+
const DISCOVERY_STATE_DISCOVERING = 'discovering';
97+
const DISCOVERY_STATE_OK = 'ok';
98+
const DISCOVERY_STATE_PAUSED = 'paused';
99+
100+
const MQTT_TOPIC_SUFFIX_DISCOVERY_STATUS = 'discovery_status';
101+
95102
// HA Origin Info
96103
const HA_ORIGIN_NAME = 'cgateweb';
97104
const HA_ORIGIN_SW_VERSION = packageJson.version;
@@ -152,6 +159,7 @@ module.exports = {
152159
MQTT_TOPIC_SUFFIX_TILT,
153160
MQTT_TOPIC_SUFFIX_EVENT,
154161
MQTT_TOPIC_SUFFIX_TREE,
162+
MQTT_TOPIC_SUFFIX_DISCOVERY_STATUS,
155163
MQTT_TOPIC_SUFFIX_HVAC_CURRENT_TEMP,
156164
MQTT_TOPIC_SUFFIX_HVAC_SETPOINT,
157165
MQTT_TOPIC_SUFFIX_HVAC_MODE,
@@ -199,6 +207,9 @@ module.exports = {
199207
HA_MODEL_HVAC,
200208
HA_COMPONENT_EVENT,
201209
HA_COMPONENT_SCENE,
210+
DISCOVERY_STATE_DISCOVERING,
211+
DISCOVERY_STATE_OK,
212+
DISCOVERY_STATE_PAUSED,
202213
HA_ORIGIN_NAME,
203214
HA_ORIGIN_SW_VERSION,
204215
HA_ORIGIN_SUPPORT_URL,

src/haDiscovery.js

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,13 @@ const {
2525
MQTT_STATE_ON,
2626
MQTT_STATE_OFF,
2727
MQTT_COMMAND_STOP,
28+
MQTT_TOPIC_SUFFIX_DISCOVERY_STATUS,
29+
MQTT_TOPIC_STATUS,
2830
HA_COMPONENT_LIGHT,
2931
HA_COMPONENT_BUTTON,
3032
HA_COMPONENT_CLIMATE,
3133
HA_COMPONENT_SCENE,
34+
HA_COMPONENT_SENSOR,
3235
HA_DISCOVERY_SUFFIX,
3336
HA_DEVICE_VIA,
3437
HA_DEVICE_MANUFACTURER,
@@ -37,6 +40,9 @@ const {
3740
HA_ORIGIN_NAME,
3841
HA_ORIGIN_SW_VERSION,
3942
HA_ORIGIN_SUPPORT_URL,
43+
DISCOVERY_STATE_DISCOVERING,
44+
DISCOVERY_STATE_OK,
45+
DISCOVERY_STATE_PAUSED,
4046
CGATE_CMD_TREEXML,
4147
NEWLINE,
4248
entityIdFields
@@ -81,6 +87,13 @@ class HaDiscovery {
8187
this._treeRetryInitialDelayMs = 2000;
8288
this._treeRetryMaxDelayMs = 60000;
8389
this._treeRequestTimeoutMs = 8000;
90+
91+
// Tracks per-network HA Discovery health. Each entry holds the last
92+
// published state so we don't re-publish identical states. Networks for
93+
// which we've published an HA Discovery config are tracked separately
94+
// to avoid republishing the same config payload on every transition.
95+
this._networkDiscoveryStatus = new Map(); // networkId -> 'discovering' | 'ok' | 'paused'
96+
this._discoveryStatusConfigPublished = new Set();
8497
}
8598

8699
/**
@@ -163,6 +176,7 @@ class HaDiscovery {
163176
// attempts counter so backoff continues if this attempt also fails.
164177
this._clearTimer(state, 'retryHandle');
165178

179+
this._setDiscoveryStatus(normalizedNetwork, DISCOVERY_STATE_DISCOVERING);
166180
this.logger.info(`Requesting TreeXML for network ${normalizedNetwork}...`);
167181

168182
// Avoid duplicate pending entries when a retry races a late response.
@@ -243,6 +257,7 @@ class HaDiscovery {
243257
`Verify the network is configured and reachable in C-Gate, then restart the bridge or publish to cbus/write/${networkId}///gettree to retry.`
244258
);
245259
this._clearTreeState(networkId);
260+
this._setDiscoveryStatus(networkId, DISCOVERY_STATE_PAUSED);
246261
return;
247262
}
248263

@@ -273,6 +288,65 @@ class HaDiscovery {
273288
}
274289
}
275290

291+
/**
292+
* Publishes a per-network "Discovery (Network N)" diagnostic sensor to HA
293+
* via MQTT Discovery. Idempotent — only publishes the config payload once
294+
* per network for the lifetime of this instance.
295+
*/
296+
_publishDiscoveryStatusConfig(networkId) {
297+
if (!this.settings.ha_discovery_enabled) return;
298+
const networkKey = String(networkId);
299+
if (this._discoveryStatusConfigPublished.has(networkKey)) return;
300+
301+
const uniqueId = `cgateweb_discovery_${networkKey}`;
302+
const stateTopic = `${MQTT_TOPIC_PREFIX_READ}/${networkKey}///${MQTT_TOPIC_SUFFIX_DISCOVERY_STATUS}`;
303+
const configTopic = `${this.settings.ha_discovery_prefix}/${HA_COMPONENT_SENSOR}/${uniqueId}/${HA_DISCOVERY_SUFFIX}`;
304+
const payload = {
305+
name: `Discovery (Network ${networkKey})`,
306+
unique_id: uniqueId,
307+
...entityIdFields(HA_COMPONENT_SENSOR, uniqueId),
308+
state_topic: stateTopic,
309+
availability_topic: MQTT_TOPIC_STATUS,
310+
payload_available: 'Online',
311+
payload_not_available: 'Offline',
312+
entity_category: 'diagnostic',
313+
icon: 'mdi:radar',
314+
device: {
315+
identifiers: [HA_DEVICE_VIA],
316+
name: 'cgateweb Bridge',
317+
manufacturer: HA_DEVICE_MANUFACTURER,
318+
model: 'Bridge Diagnostics'
319+
},
320+
origin: {
321+
name: HA_ORIGIN_NAME,
322+
sw_version: HA_ORIGIN_SW_VERSION,
323+
support_url: HA_ORIGIN_SUPPORT_URL
324+
}
325+
};
326+
327+
this._publish(configTopic, JSON.stringify(payload), { retain: true, qos: 0 });
328+
this._discoveryStatusConfigPublished.add(networkKey);
329+
}
330+
331+
/**
332+
* Updates the per-network discovery status. Publishes the HA Discovery
333+
* config the first time a network is seen, then publishes the state to the
334+
* sensor's state topic. Skips republishes when the state hasn't changed.
335+
*/
336+
_setDiscoveryStatus(networkId, status) {
337+
if (!this.settings.ha_discovery_enabled) return;
338+
const networkKey = String(networkId);
339+
const previous = this._networkDiscoveryStatus.get(networkKey);
340+
if (previous === status) return;
341+
342+
this._publishDiscoveryStatusConfig(networkKey);
343+
this._networkDiscoveryStatus.set(networkKey, status);
344+
345+
const stateTopic = `${MQTT_TOPIC_PREFIX_READ}/${networkKey}///${MQTT_TOPIC_SUFFIX_DISCOVERY_STATUS}`;
346+
this._publish(stateTopic, status, { retain: true, qos: 0 });
347+
this.logger.debug(`Discovery status for network ${networkKey}: ${previous || 'init'} -> ${status}`);
348+
}
349+
276350
handleTreeStart(_statusData) {
277351
if (this.activeTreeSession && this.activeTreeSession.bufferParts.length > 0) {
278352
this.logger.warn(`Received a new TreeXML start before previous tree completed; dropping incomplete tree for network ${this.activeTreeSession.network}`);
@@ -349,16 +423,18 @@ class HaDiscovery {
349423
});
350424
} else {
351425
this.logger.info(`Parsed TreeXML for network ${networkForTree} (took ${duration}ms)`);
352-
426+
353427
// Publish standard tree topic
354428
this._publish(
355429
`${MQTT_TOPIC_PREFIX_READ}/${networkForTree}///tree`,
356430
JSON.stringify(result),
357431
{ retain: true, qos: 0 }
358432
);
359-
433+
360434
// Generate HA Discovery messages
361435
this._publishDiscoveryFromTree(networkForTree, result);
436+
437+
this._setDiscoveryStatus(networkForTree, DISCOVERY_STATE_OK);
362438
}
363439
});
364440
}

tests/haDiscovery.test.js

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -679,6 +679,117 @@ describe('HaDiscovery', () => {
679679
});
680680
});
681681

682+
describe('Discovery health diagnostic sensor', () => {
683+
const findStateCall = (network) => mockPublishFn.mock.calls.find(
684+
c => c[0] === `cbus/read/${network}///discovery_status`
685+
);
686+
const findConfigCall = (network) => mockPublishFn.mock.calls.find(
687+
c => c[0] === `testhomeassistant/sensor/cgateweb_discovery_${network}/config`
688+
);
689+
690+
beforeEach(() => {
691+
jest.useFakeTimers();
692+
mockSettings.ha_discovery_networks = ['254'];
693+
});
694+
695+
afterEach(() => {
696+
haDiscovery.stop();
697+
jest.useRealTimers();
698+
});
699+
700+
it('publishes a HA Discovery config + discovering state on first request', () => {
701+
haDiscovery.trigger();
702+
703+
const config = findConfigCall('254');
704+
expect(config).toBeDefined();
705+
const payload = JSON.parse(config[1]);
706+
expect(payload.unique_id).toBe('cgateweb_discovery_254');
707+
expect(payload.state_topic).toBe('cbus/read/254///discovery_status');
708+
expect(payload.entity_category).toBe('diagnostic');
709+
expect(payload.device.identifiers).toContain('cgateweb_bridge');
710+
711+
const state = findStateCall('254');
712+
expect(state).toBeDefined();
713+
expect(state[1]).toBe('discovering');
714+
expect(state[2]).toEqual({ retain: true, qos: 0 });
715+
});
716+
717+
it('transitions to ok after a successful TreeXML', () => {
718+
jest.spyOn(require('xml2js'), 'parseString').mockImplementation((xml, _opts, cb) => cb(null, {}));
719+
haDiscovery.trigger();
720+
721+
haDiscovery.handleTreeStart('start');
722+
haDiscovery.handleTreeData('<xml/>');
723+
haDiscovery.handleTreeEnd('end');
724+
725+
const stateCalls = mockPublishFn.mock.calls.filter(
726+
c => c[0] === 'cbus/read/254///discovery_status'
727+
);
728+
const states = stateCalls.map(c => c[1]);
729+
expect(states).toEqual(['discovering', 'ok']);
730+
});
731+
732+
it('transitions to paused after retry limit is exhausted', () => {
733+
const errMsg = 'Bad object or device ID: Network not found';
734+
haDiscovery.trigger();
735+
for (let i = 1; i <= 8; i++) {
736+
haDiscovery.handleCommandError('401', errMsg);
737+
jest.runOnlyPendingTimers();
738+
}
739+
haDiscovery.handleCommandError('401', errMsg);
740+
741+
const stateCalls = mockPublishFn.mock.calls.filter(
742+
c => c[0] === 'cbus/read/254///discovery_status'
743+
);
744+
const states = stateCalls.map(c => c[1]);
745+
expect(states[states.length - 1]).toBe('paused');
746+
});
747+
748+
it('does not republish the config on every state transition', () => {
749+
jest.spyOn(require('xml2js'), 'parseString').mockImplementation((xml, _opts, cb) => cb(null, {}));
750+
haDiscovery.trigger();
751+
haDiscovery.handleTreeStart('start');
752+
haDiscovery.handleTreeEnd('end');
753+
haDiscovery.queueTreeRequest('254');
754+
755+
const configCalls = mockPublishFn.mock.calls.filter(
756+
c => c[0] === 'testhomeassistant/sensor/cgateweb_discovery_254/config'
757+
);
758+
expect(configCalls).toHaveLength(1);
759+
});
760+
761+
it('does not republish the same state twice in a row', () => {
762+
haDiscovery.trigger();
763+
// Calling queueTreeRequest again for the same network shouldn't
764+
// produce a duplicate "discovering" publish.
765+
haDiscovery.queueTreeRequest('254');
766+
767+
const stateCalls = mockPublishFn.mock.calls.filter(
768+
c => c[0] === 'cbus/read/254///discovery_status'
769+
);
770+
expect(stateCalls).toHaveLength(1);
771+
expect(stateCalls[0][1]).toBe('discovering');
772+
});
773+
774+
it('publishes a separate sensor per network', () => {
775+
mockSettings.ha_discovery_networks = ['254', '200'];
776+
haDiscovery.trigger();
777+
778+
expect(findConfigCall('254')).toBeDefined();
779+
expect(findConfigCall('200')).toBeDefined();
780+
expect(findStateCall('254')[1]).toBe('discovering');
781+
expect(findStateCall('200')[1]).toBe('discovering');
782+
});
783+
784+
it('skips publishing when HA discovery is disabled', () => {
785+
mockSettings.ha_discovery_enabled = false;
786+
haDiscovery._setDiscoveryStatus('254', 'discovering');
787+
788+
expect(findStateCall('254')).toBeUndefined();
789+
expect(findConfigCall('254')).toBeUndefined();
790+
});
791+
});
792+
682793
describe('Custom Label Override (three-tier priority)', () => {
683794
beforeEach(() => {
684795
jest.spyOn(require('xml2js'), 'parseString').mockImplementation((xml, _opts, callback) => {

0 commit comments

Comments
 (0)