Skip to content

Commit 1941c8b

Browse files
committed
feat: event-driven HA Discovery refresh on Network created
C-Gate emits async system events on the command port (response code 742) when a network finishes loading, e.g.: 20260504-193110.569 742 //PROJECT/254 c2211b00-... Network created type=cni address=192.168.0.100:10001 Previously these lines were dropped — _parseCommandResponseLine split on the first hyphen, which landed in the timestamp, producing a non-3-digit "code" and a "Skipping non-response line" debug log. Worse: even after a timestamp strip, hyphens in payload UUIDs would derail the hyphen-first split and mis-parse codes mid-payload. Three coordinated changes: 1) commandResponseProcessor parser: strip a leading C-Gate timestamp prefix, then anchor the response code to positions 0-2 (rejecting anything where char 3 is a digit, which would mean the "code" was part of a longer number). The validity range is widened from 1xx-6xx to 1xx-9xx so async system events (7xx/8xx) and job lifecycle events (9xx) route correctly. Canonical "CCC-DATA"/"CCC DATA" lines parse identically. 2) commandResponseProcessor routing: case 742 ("Network created") is detected by message text and parsed for the network id, then forwarded to haDiscovery.handleNetworkCreated(). 3) HaDiscovery.handleNetworkCreated(): gates by the same scope rules as trigger() — if ha_discovery_networks is configured, only those networks refresh; otherwise any network through. Calls queueTreeRequest() which de-duplicates against pending requests and cancels any pending v1.8.1 retry timer, so a Network created event mid-backoff just short-circuits the wait. Net effect: the discovery delay on cold starts collapses from 2-30+ seconds (v1.8.1 retry backoff) to whatever C-Gate takes to finish loading the network and emit the 742 event — typically ~2s. Retry remains as defence in depth. Bumps to 1.8.5.
1 parent 1e74fb9 commit 1941c8b

8 files changed

Lines changed: 202 additions & 34 deletions

File tree

homeassistant-addon/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ 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.5] - 2026-05-04
9+
10+
### Added
11+
- **Event-driven HA Discovery refresh**: when a network finishes loading in C-Gate (async system event 742, "Network created"), HA Discovery now refreshes the network's tree the moment it becomes available, instead of waiting for the v1.8.1 retry backoff to fire. This eliminates the discovery delay on cold starts where C-Gate initialises a few seconds after opening its TCP port. The retry remains as belt-and-braces.
12+
13+
### Changed
14+
- **Command response parser hardening**: the parser now recognises C-Gate's timestamp-prefixed async event lines (e.g. `20260504-193110.569 742 //PROJECT/254 ... Network created ...`) by stripping the leading timestamp before parsing. The parser also pins response codes to positions 0-2 of the line, eliminating mis-parses caused by hyphens elsewhere in the payload (UUIDs, etc.). Validity range expanded from 1xx-6xx to 1xx-9xx so 7xx/8xx async events route correctly. Behaviour for canonical lines like `200-OK` is unchanged.
15+
816
## [1.8.4] - 2026-05-04
917

1018
### Added

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.4"
2+
version: "1.8.5"
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.4",
3+
"version": "1.8.5",
44
"description": "Node.js bridge connecting Clipsal C-Bus automation systems to MQTT for Home Assistant integration",
55
"keywords": [
66
"cbus",

src/commandResponseProcessor.js

Lines changed: 62 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ const {
44
CGATE_RESPONSE_OBJECT_STATUS,
55
CGATE_RESPONSE_TREE_START,
66
CGATE_RESPONSE_TREE_DATA,
7-
CGATE_RESPONSE_TREE_END
7+
CGATE_RESPONSE_TREE_END,
8+
CGATE_RESPONSE_SYSTEM_EVENT
89
} = require('./constants');
910

1011
/**
@@ -85,41 +86,45 @@ class CommandResponseProcessor {
8586
* @returns {Object|null} Parsed response with responseCode and statusData, or null if invalid
8687
*/
8788
_parseCommandResponseLine(line) {
88-
let responseCode = '';
89-
let statusData = '';
90-
const hyphenIndex = line.indexOf('-');
91-
92-
if (hyphenIndex > -1 && line.length > hyphenIndex + 1) {
93-
// C-Gate format: "200-OK" or "300-//PROJECT/254/56/1: level=255"
94-
responseCode = line.substring(0, hyphenIndex).trim();
95-
statusData = line.substring(hyphenIndex + 1).trim();
96-
} else {
97-
// Alternative format: "200 OK" (space-separated)
98-
const firstSpace = line.indexOf(' ');
99-
if (firstSpace === -1) {
100-
responseCode = line.trim();
101-
} else {
102-
responseCode = line.substring(0, firstSpace).trim();
103-
statusData = line.substring(firstSpace + 1).trim();
104-
}
89+
// Strip a leading C-Gate timestamp (e.g. "20260504-193110.569 ").
90+
// Asynchronous notifications enabled by EVENT ON arrive on the command
91+
// port with this prefix; without stripping it the hyphen-first split
92+
// below would land in the date instead of the response code.
93+
const stripped = (line || '').replace(/^\d{8}-\d{6}\.\d+\s+/, '');
94+
95+
// C-Gate response codes are exactly 3 digits at the start of the line,
96+
// followed by either '-' (e.g. "200-OK") or ' ' (e.g. "742 //PROJECT
97+
// /254 ... Network created ..."), or end-of-string. Pinning to
98+
// positions 0-2 avoids mis-parsing payloads with later hyphens (UUIDs).
99+
const trimmed = stripped.trim();
100+
const responseCode = trimmed.substring(0, 3);
101+
if (trimmed.length < 3 || !this._isValidResponseCode(responseCode)) {
102+
this.logger.debug(`Skipping non-response line: ${line}`);
103+
return null;
105104
}
106-
107-
if (!this._isValidResponseCode(responseCode)) {
108-
this.logger.debug(`Skipping non-response line: ${line}`);
109-
return null;
105+
const separator = trimmed.charAt(3);
106+
if (separator && separator !== '-' && separator !== ' ') {
107+
// Position 3 must be the start of the data section, not another
108+
// digit (which would mean the "code" is part of a 4+ digit number).
109+
this.logger.debug(`Skipping non-response line: ${line}`);
110+
return null;
110111
}
111-
112-
return { responseCode, statusData };
112+
// Strip the separator and any surrounding whitespace.
113+
const statusData = trimmed.substring(3).replace(/^\s*-?\s*/, '');
114+
return { responseCode, statusData: statusData.trim() };
113115
}
114116

115117
_isValidResponseCode(responseCode) {
116118
if (!responseCode || responseCode.length !== 3) {
117119
return false;
118120
}
121+
// C-Gate codes span 1xx-9xx: 1xx informational, 2xx success, 3xx
122+
// multi-line (tree), 4xx/5xx errors, 7xx/8xx async system events
123+
// (network created, connection events), 9xx job lifecycle.
119124
const c0 = responseCode.charCodeAt(0);
120125
const c1 = responseCode.charCodeAt(1);
121126
const c2 = responseCode.charCodeAt(2);
122-
return c0 >= 49 && c0 <= 54 && c1 >= 48 && c1 <= 57 && c2 >= 48 && c2 <= 57;
127+
return c0 >= 49 && c0 <= 57 && c1 >= 48 && c1 <= 57 && c2 >= 48 && c2 <= 57;
123128
}
124129

125130
/**
@@ -164,6 +169,9 @@ class CommandResponseProcessor {
164169
this._pendingTreeMessages.push({ code: CGATE_RESPONSE_TREE_END, data: statusData });
165170
}
166171
break;
172+
case CGATE_RESPONSE_SYSTEM_EVENT:
173+
this._processSystemEvent(statusData);
174+
break;
167175
default:
168176
if (responseCode.startsWith('4') || responseCode.startsWith('5')) {
169177
this._processCommandErrorResponse(responseCode, statusData);
@@ -175,9 +183,37 @@ class CommandResponseProcessor {
175183
}
176184
}
177185

186+
/**
187+
* Processes async system event lines (response code 742). C-Gate emits
188+
* these for tag/network changes; the case we care about is "Network
189+
* created", which signals that a configured network has finished loading
190+
* and is now queryable via TREEXML. Forwarding to HaDiscovery lets us
191+
* refresh discovery the moment a network becomes available, eliminating
192+
* the startup race that the v1.8.1 retry mechanism otherwise covers.
193+
*
194+
* Example payload:
195+
* "//12LESLIE/254 c2211b00-... Network created type=cni address=..."
196+
*/
197+
_processSystemEvent(statusData) {
198+
const data = statusData || '';
199+
if (!/Network created/.test(data)) {
200+
this.logger.debug(`C-Gate system event 742 (no action): ${data}`);
201+
return;
202+
}
203+
const match = data.match(/\/\/[^/]+\/(\d+)\b/);
204+
if (!match) {
205+
this.logger.debug(`C-Gate system event 742 (Network created, but no network id parsed): ${data}`);
206+
return;
207+
}
208+
const networkId = match[1];
209+
if (this._haDiscovery && typeof this._haDiscovery.handleNetworkCreated === 'function') {
210+
this._haDiscovery.handleNetworkCreated(networkId);
211+
}
212+
}
213+
178214
/**
179215
* Processes object status responses from C-Gate commands.
180-
*
216+
*
181217
* @param {string} statusData - Object status data from C-Gate
182218
*/
183219
_processCommandObjectStatus(statusData) {

src/constants.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const CGATE_RESPONSE_OBJECT_STATUS = '300'; // Device status response (e.g., "3
2424
const CGATE_RESPONSE_TREE_START = '343'; // Start of TREEXML response
2525
const CGATE_RESPONSE_TREE_END = '344'; // End of TREEXML response
2626
const CGATE_RESPONSE_TREE_DATA = '347'; // TREEXML data line
27+
const CGATE_RESPONSE_SYSTEM_EVENT = '742'; // Async object/network event (e.g. "Network created")
2728

2829
// === MQTT System ===
2930
const MQTT_TOPIC_PREFIX_CBUS = 'cbus';
@@ -148,6 +149,7 @@ module.exports = {
148149
CGATE_RESPONSE_TREE_START,
149150
CGATE_RESPONSE_TREE_END,
150151
CGATE_RESPONSE_TREE_DATA,
152+
CGATE_RESPONSE_SYSTEM_EVENT,
151153

152154
// MQTT System
153155
MQTT_TOPIC_PREFIX_CBUS,

src/haDiscovery.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,29 @@ class HaDiscovery {
168168
});
169169
}
170170

171+
/**
172+
* Triggers a discovery refresh for a network that has just become
173+
* available in C-Gate (driven by a "Network created" async event on the
174+
* command port). Gated by the same scope rules as `trigger()`: when
175+
* `ha_discovery_networks` is configured, only those networks refresh;
176+
* otherwise we let any network through (matching the auto-discovery path).
177+
*
178+
* Idempotent against the v1.8.1 retry: `queueTreeRequest` cancels any
179+
* pending retry and de-duplicates the pending queue, so a Network created
180+
* event mid-backoff just short-circuits the wait.
181+
*/
182+
handleNetworkCreated(networkId) {
183+
if (!this.settings.ha_discovery_enabled) return;
184+
const networkKey = String(networkId);
185+
const configured = this.settings.ha_discovery_networks || [];
186+
if (configured.length > 0 && !configured.map(String).includes(networkKey)) {
187+
this.logger.debug(`Network ${networkKey} created but not in ha_discovery_networks; skipping refresh`);
188+
return;
189+
}
190+
this.logger.info(`Network ${networkKey} created in C-Gate; refreshing HA Discovery`);
191+
this.queueTreeRequest(networkKey);
192+
}
193+
171194
queueTreeRequest(networkId) {
172195
const normalizedNetwork = String(networkId);
173196
const state = this._getOrCreateTreeState(normalizedNetwork);

tests/commandResponseProcessor.test.js

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ describe('CommandResponseProcessor', () => {
2222
mockHaDiscovery = {
2323
handleTreeStart: jest.fn(),
2424
handleTreeData: jest.fn(),
25-
handleTreeEnd: jest.fn()
25+
handleTreeEnd: jest.fn(),
26+
handleNetworkCreated: jest.fn()
2627
};
2728

2829
mockOnObjectStatus = jest.fn();
@@ -135,14 +136,24 @@ describe('CommandResponseProcessor', () => {
135136
);
136137
});
137138

138-
it('should skip C-Gate v3.6.0 timestamp-prefixed notifications at debug level', () => {
139+
it('should strip a leading C-Gate timestamp and parse the response code', () => {
139140
const result = processor._parseCommandResponseLine(
140141
'20251031-171409.874 803 cmd7 - Host:/127.0.0.1 opened command interface from port: 60052'
141142
);
142-
expect(result).toBeNull();
143-
expect(mockLogger.debug).toHaveBeenCalledWith(
144-
expect.stringContaining('Skipping non-response line:')
143+
expect(result).toEqual({
144+
responseCode: '803',
145+
statusData: 'cmd7 - Host:/127.0.0.1 opened command interface from port: 60052'
146+
});
147+
});
148+
149+
it('should not be fooled by hyphens in payload UUIDs', () => {
150+
const result = processor._parseCommandResponseLine(
151+
'20260504-193110.569 742 //PROJECT/254 c2211b00-28c1-103f-94b5-db702a32859b Network created type=cni address=192.168.0.100:10001'
145152
);
153+
expect(result).toEqual({
154+
responseCode: '742',
155+
statusData: '//PROJECT/254 c2211b00-28c1-103f-94b5-db702a32859b Network created type=cni address=192.168.0.100:10001'
156+
});
146157
});
147158
});
148159

@@ -169,10 +180,48 @@ describe('CommandResponseProcessor', () => {
169180

170181
it('should route tree end responses to HA discovery', () => {
171182
processor._processCommandResponse(CGATE_RESPONSE_TREE_END, 'tree end data');
172-
183+
173184
expect(mockHaDiscovery.handleTreeEnd).toHaveBeenCalledWith('tree end data');
174185
});
175186

187+
it('should forward Network created system events to HA discovery', () => {
188+
processor._processCommandResponse(
189+
'742',
190+
'//PROJECT/254 c2211b00-28c1-103f-94b5-db702a32859b Network created type=cni address=192.168.0.100:10001'
191+
);
192+
expect(mockHaDiscovery.handleNetworkCreated).toHaveBeenCalledWith('254');
193+
});
194+
195+
it('should ignore 742 events that are not Network created', () => {
196+
processor._processCommandResponse(
197+
'742',
198+
'//PROJECT - Tag information changed at tag address: //PROJECT/Installation oldtag: null newtag: null'
199+
);
200+
expect(mockHaDiscovery.handleNetworkCreated).not.toHaveBeenCalled();
201+
});
202+
203+
it('should ignore Network created events without a parseable network id', () => {
204+
processor._processCommandResponse('742', 'Network created (malformed line)');
205+
expect(mockHaDiscovery.handleNetworkCreated).not.toHaveBeenCalled();
206+
});
207+
208+
it('should not throw when haDiscovery lacks handleNetworkCreated', () => {
209+
processor.haDiscovery = { handleTreeStart: jest.fn() };
210+
expect(() => {
211+
processor._processCommandResponse(
212+
'742',
213+
'//PROJECT/254 uuid Network created type=cni'
214+
);
215+
}).not.toThrow();
216+
});
217+
218+
it('end-to-end: timestamped 742 line on processLine forwards to handleNetworkCreated', () => {
219+
processor.processLine(
220+
'20260504-193110.569 742 //12LESLIE/254 c2211b00-28c1-103f-94b5-db702a32859b Network created type=cni address=192.168.0.100:10001'
221+
);
222+
expect(mockHaDiscovery.handleNetworkCreated).toHaveBeenCalledWith('254');
223+
});
224+
176225
it('should route 4xx error responses', () => {
177226
const errorSpy = jest.spyOn(processor, '_processCommandErrorResponse');
178227

tests/haDiscovery.test.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -790,6 +790,56 @@ describe('HaDiscovery', () => {
790790
});
791791
});
792792

793+
describe('handleNetworkCreated (event-driven discovery)', () => {
794+
beforeEach(() => {
795+
jest.useFakeTimers();
796+
});
797+
798+
afterEach(() => {
799+
haDiscovery.stop();
800+
jest.useRealTimers();
801+
});
802+
803+
it('triggers a TreeXML for a configured network when it becomes available', () => {
804+
mockSettings.ha_discovery_networks = ['254'];
805+
haDiscovery.handleNetworkCreated('254');
806+
expect(mockSendCommandFn).toHaveBeenCalledWith(`${CGATE_CMD_TREEXML} 254${NEWLINE}`);
807+
});
808+
809+
it('skips networks not in ha_discovery_networks when the list is configured', () => {
810+
mockSettings.ha_discovery_networks = ['254'];
811+
haDiscovery.handleNetworkCreated('999');
812+
expect(mockSendCommandFn).not.toHaveBeenCalled();
813+
});
814+
815+
it('triggers for any network when ha_discovery_networks is empty', () => {
816+
mockSettings.ha_discovery_networks = [];
817+
haDiscovery.handleNetworkCreated('999');
818+
expect(mockSendCommandFn).toHaveBeenCalledWith(`${CGATE_CMD_TREEXML} 999${NEWLINE}`);
819+
});
820+
821+
it('does nothing when discovery is disabled', () => {
822+
mockSettings.ha_discovery_enabled = false;
823+
mockSettings.ha_discovery_networks = ['254'];
824+
haDiscovery.handleNetworkCreated('254');
825+
expect(mockSendCommandFn).not.toHaveBeenCalled();
826+
});
827+
828+
it('cancels a pending retry when an event arrives mid-backoff', () => {
829+
mockSettings.ha_discovery_networks = ['254'];
830+
haDiscovery.trigger();
831+
haDiscovery.handleCommandError('401', 'Bad object or device ID: Network not found');
832+
// Retry is scheduled for 2s. A Network created event arriving now
833+
// should send a fresh TREEXML and cancel the pending retry.
834+
mockSendCommandFn.mockClear();
835+
haDiscovery.handleNetworkCreated('254');
836+
expect(mockSendCommandFn).toHaveBeenCalledTimes(1);
837+
// Advance past the original retry's 2s backoff. No extra send.
838+
jest.advanceTimersByTime(2500);
839+
expect(mockSendCommandFn).toHaveBeenCalledTimes(1);
840+
});
841+
});
842+
793843
describe('Custom Label Override (three-tier priority)', () => {
794844
beforeEach(() => {
795845
jest.spyOn(require('xml2js'), 'parseString').mockImplementation((xml, _opts, callback) => {

0 commit comments

Comments
 (0)