Skip to content

Commit a551143

Browse files
committed
fix: emit labels-changed from save() so Web UI edits reach HA Discovery
User reported: editing labels in the Web UI and clicking Save did NOT update device names in Home Assistant. Root cause traced to LabelLoader: - save() wrote the file + updated internal state, but never emitted labels-changed - The only emit site was _onFileChanged (the fs.watch callback) at labelLoader.js:315 - The watcher correctly suppresses its own callback for SELF_WRITE_GRACE_MS (1000ms) after each save() to prevent re-processing our own write (labelLoader.js:184) - Net effect: for in-process saves (PUT /api/labels, PATCH /api/labels, POST /api/labels/import) the event was silently swallowed - The bridgeInitializationService listener that calls haDiscovery.updateLabels + trigger() therefore never ran, and HA never saw the updated discovery configs save() now emits labels-changed directly with the full getLabelData() payload. The watcher's grace period is still correct - it prevents the file-event path from double-firing. Same fix automatically covers areas, type_overrides, entity_ids, and exclude - they all flow through the single save() codepath, and getLabelData() includes all five sections. Audit of other label-lifecycle paths found no further bugs: only one labels-changed subscriber (the bridge), only two emit sites (save and the watcher callback), no other code paths mutate internal Maps without going through save() or _onFileChanged. Tests: new regression for save()-emits-once, new test confirming areas and the other sections are in the emit payload. The pre-existing "should not emit when file is written by save() within grace period" test was codifying the bug; rewritten to assert exactly-once instead. 1256/1256 pass.
1 parent 44bba3c commit a551143

5 files changed

Lines changed: 74 additions & 8 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.9.3] - 2026-05-27
9+
10+
### Fixed
11+
12+
- **Editing labels (or areas / type overrides / entity IDs / exclusions) via the Web UI did not update Home Assistant device names**. `LabelLoader.save()` wrote the file and updated internal state but never emitted `labels-changed` directly. The only emit site was the `fs.watch` callback, which is gated by a 1000ms self-write grace period to prevent double-processing - so for in-process saves (PUT/PATCH `/api/labels`, POST `/api/labels/import`), the event was silently suppressed. The downstream listener that re-triggers HA Discovery therefore never fired, and HA never saw the updated entity configs.
13+
- `save()` now emits `labels-changed` directly with the full `getLabelData()` payload (labels, areas, typeOverrides, entityIds, exclude). The file-watcher path still correctly suppresses the resulting fs event within the grace window, so there's no double-fire.
14+
- The same fix automatically covers areas, type overrides, entity IDs, and the exclude list - all flow through the single `save()` codepath.
15+
816
## [1.9.2] - 2026-05-26
917

1018
### Changed

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

src/labelLoader.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,12 @@ class LabelLoader extends EventEmitter {
159159
}
160160

161161
this.logger.info(`Saved ${this._labels.size} labels to ${this.filePath}`);
162+
163+
// Notify in-process listeners (HA Discovery re-trigger, the Web UI
164+
// SSE event stream) directly. The file-watcher path will see the
165+
// same write but suppress it via SELF_WRITE_GRACE_MS - that grace
166+
// period prevents a double-fire here, it doesn't replace this emit.
167+
this.emit('labels-changed', this.getLabelData());
162168
}
163169

164170
/**

tests/labelLoader.test.js

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,53 @@ describe('LabelLoader', () => {
122122
const loader = new LabelLoader(null);
123123
expect(() => loader.save({})).toThrow('No label file path configured');
124124
});
125+
126+
it('emits labels-changed synchronously after save so in-process callers (web UI) trigger HA Discovery refresh', () => {
127+
// Regression for the bug where editing labels via the Web UI did
128+
// NOT update Home Assistant device names. save() wrote the file
129+
// and updated internal state but never emitted labels-changed -
130+
// the watcher's self-write grace period then suppressed the
131+
// file-event-driven emit, so the bridge's HA Discovery
132+
// re-trigger listener never fired.
133+
const loader = new LabelLoader(labelFile);
134+
const events = [];
135+
loader.on('labels-changed', (labelData) => events.push(labelData));
136+
137+
loader.save({ '254/56/10': 'Kitchen Bench', '254/56/11': 'Hallway' });
138+
139+
expect(events.length).toBe(1);
140+
expect(events[0].labels.get('254/56/10')).toBe('Kitchen Bench');
141+
expect(events[0].labels.get('254/56/11')).toBe('Hallway');
142+
});
143+
144+
it('labels-changed payload carries areas / type_overrides / entity_ids / exclude in addition to labels', () => {
145+
// The bridge's listener pipes getLabelData() into HaDiscovery.updateLabels,
146+
// which consumes all five sections. If save() ever stops including any of
147+
// them in the emit payload, HA entity area assignments / type overrides /
148+
// explicit unique-id-suffixes / exclusion lists silently stop applying on
149+
// edit. Lock the full contract here.
150+
const loader = new LabelLoader(labelFile);
151+
const events = [];
152+
loader.on('labels-changed', (labelData) => events.push(labelData));
153+
154+
loader.save({
155+
version: 1,
156+
labels: { '254/56/10': 'Kitchen' },
157+
type_overrides: { '254/203/15': 'cover' },
158+
entity_ids: { '254/56/10': 'kitchen_main' },
159+
exclude: ['254/56/250'],
160+
areas: { '254/56/10': 'Kitchen', '254/56/11': 'Hallway' }
161+
});
162+
163+
expect(events.length).toBe(1);
164+
const payload = events[0];
165+
expect(payload.labels.get('254/56/10')).toBe('Kitchen');
166+
expect(payload.typeOverrides.get('254/203/15')).toBe('cover');
167+
expect(payload.entityIds.get('254/56/10')).toBe('kitchen_main');
168+
expect(payload.exclude.has('254/56/250')).toBe(true);
169+
expect(payload.areas.get('254/56/10')).toBe('Kitchen');
170+
expect(payload.areas.get('254/56/11')).toBe('Hallway');
171+
});
125172
});
126173

127174
describe('getLabelsObject', () => {
@@ -390,7 +437,11 @@ describe('LabelLoader', () => {
390437
}, 100);
391438
}, 5000);
392439

393-
it('should not emit when file is written by save() within grace period', (done) => {
440+
it('save() emits labels-changed exactly once even with the watcher running (no double-fire)', (done) => {
441+
// The watcher's SELF_WRITE_GRACE_MS exists to prevent a SECOND
442+
// labels-changed firing from the fs.watch callback after our own
443+
// write. The FIRST emit comes from save() directly so the in-
444+
// process listeners (HA Discovery refresh, web UI SSE) wake up.
394445
const data = { version: 1, labels: { '254/56/10': 'Init' } };
395446
fs.writeFileSync(labelFile, JSON.stringify(data));
396447

@@ -401,16 +452,17 @@ describe('LabelLoader', () => {
401452
const handler = jest.fn();
402453
loader.on('labels-changed', handler);
403454

404-
// save() sets _lastSaveTime; fs.watch events within 1s grace are suppressed
405455
loader.save({ '254/56/10': 'Via Save' });
406456

407-
// Wait longer than debounce (500ms) but within the grace period (1000ms)
457+
// Wait longer than debounce (500ms) and longer than grace (1000ms)
458+
// to confirm only the direct emit fired, not the file-watcher path.
408459
setTimeout(() => {
409-
expect(handler).not.toHaveBeenCalled();
460+
expect(handler).toHaveBeenCalledTimes(1);
461+
expect(handler.mock.calls[0][0].labels.get('254/56/10')).toBe('Via Save');
410462
loader.unwatch();
411463
done();
412-
}, 800);
413-
}, 3000);
464+
}, 1500);
465+
}, 5000);
414466

415467
it('should do nothing when no file path is configured', () => {
416468
const loader = new LabelLoader(null);

0 commit comments

Comments
 (0)