Skip to content

Commit aeeaf5f

Browse files
authored
fix(homebridge): propagate terminal stream faults to Presence and cached Wall Connector sensors (#118)
Closes the two P2 gaps deferred from PR #116: PresenceService's OccupancySensor services now honestly fault via setStreamFault (HAP declares StatusFault optional on OccupancySensor, so this is not a misleading signal), and WallConnectorService now hydrates its per-DIN connectors map from Homebridge's persisted accessory cache at construction so already-restored sensors are fault-checked immediately, including a fault already raised before the accessory was constructed.
1 parent db758d0 commit aeeaf5f

11 files changed

Lines changed: 144 additions & 9 deletions

File tree

.changeset/hb-fault-followup.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"homebridge-teslemetry": patch
3+
---
4+
5+
Close two gaps left by the terminal stream health fault propagation: PresenceService's occupancy sensors now fault on a terminal stream failure and clear again once their signal reports a fresh value, and WallConnectorService's per-DIN sensors restored from Homebridge's accessory cache (before any `live_status` has arrived this run) are now hydrated and fault-checked immediately instead of being invisible to `setStreamFault()` until their first reading.

AGENTS.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,9 @@ pnpm link --global n8n-nodes-teslemetry
150150

151151
**Gotcha**: not every feature can be gated ahead of time (model/config are known synchronously; whether a given vehicle *reports* a signal at all often isn't). `PresenceService` (`vehicle-services/presence.ts`) and `WallConnectorService` (`energy-services/wall-connector.ts`) don't extend `BaseService`/`BaseEnergyService` and create zero HAP services at construction time - each sub-sensor (per presence field, per Wall Connector DIN) is created lazily the first time its signal/live_status entry actually arrives, since a field withheld by scope or simply absent hardware may never fire. Contrast this with `TpmsService`, `GridOutageService`, and `StormWatchActiveService`, which construct their `ContactSensor`s eagerly (virtually every vehicle/site has the underlying hardware) but hold `StatusFault` at `GENERAL_FAULT` until the first real payload lands, clearing it only then - never default a not-yet-received reading to "safe" by leaving a fresh `ContactSensorState` at its HAP default. Both new and existing contact-sensor mappings in this package (`DoorService`, the TPMS/grid/storm/wall-connector services above) follow one polarity convention: `CONTACT_DETECTED` = normal/quiescent (door closed, no fault, cable seated, grid up), `CONTACT_NOT_DETECTED` = triggered/abnormal (door open, fault active, cable unseated, grid down) - keep new contact sensors on this convention rather than picking polarity per-service.
152152

153-
**Gotcha**: `BaseService`/`BaseEnergyService.setStreamFault()` (called from `VehicleAccessory`/`EnergyAccessory`/`TeslemetryPlatform` to reflect terminal account-stream health) only marks a service's `StatusFault` when that HomeKit service type actually declares it as an optional characteristic (checked via `service.optionalCharacteristics`, not `service.testCharacteristic()` - the latter only reports characteristics already added, which excludes `StatusFault` on a service like `DoorService` that never touches it during normal operation). Only sensor-type services (`ContactSensor` and similar) declare `StatusFault` at all; core control services (`LockMechanism`, `Switch`, `Thermostat`, `Battery`) don't, and forcing it onto them via `getCharacteristic()` would silently add an out-of-spec characteristic with a HAP warning - `setStreamFault()` is a no-op for those rather than inventing a misleading fault signal. Services owning more than one HAP `Service` instance (`TpmsService`, `DoorService`) override `setStreamFault()` to loop over all of them, not just the primary `this.service`.
153+
**Gotcha**: `BaseService`/`BaseEnergyService.setStreamFault()` (called from `VehicleAccessory`/`EnergyAccessory`/`TeslemetryPlatform` to reflect terminal account-stream health) only marks a service's `StatusFault` when that HomeKit service type actually declares it as an optional characteristic (checked via `service.optionalCharacteristics`, not `service.testCharacteristic()` - the latter only reports characteristics already added, which excludes `StatusFault` on a service like `DoorService` that never touches it during normal operation). Only sensor-type services (`ContactSensor`, `OccupancySensor`, and similar) declare `StatusFault` at all; core control services (`LockMechanism`, `Switch`, `Thermostat`, `Battery`) don't, and forcing it onto them via `getCharacteristic()` would silently add an out-of-spec characteristic with a HAP warning - `setStreamFault()` is a no-op for those rather than inventing a misleading fault signal. Services owning more than one HAP `Service` instance (`TpmsService`, `DoorService`) override `setStreamFault()` to loop over all of them, not just the primary `this.service`. `PresenceService`/`WallConnectorService` don't extend `BaseService`/`BaseEnergyService` (see above) but hand-implement the same `setStreamFault()` contract over their own lazily-created sensor maps, clearing a sensor's fault the moment its own signal/DIN reading arrives - independent of any debounce applied to the reading's *value*.
154+
155+
**Gotcha**: `WallConnectorService`'s `connectors` map is otherwise only populated by `live_status`, so per-DIN sensors that already exist in Homebridge's persisted accessory cache from a prior run are invisible to `setStreamFault()` until this run's first `live_status` - its constructor hydrates `connectors` from any matching cached `ContactSensor` services up front (parsed off their `wall-connector-{fault,connected}-<din>` subtype) and immediately applies `TeslemetryPlatform.streamFault` (a public getter over the platform's private terminal-fault flag), so a fault already raised before this accessory was constructed still reaches them. Any other lazily-hydrated per-entity service map should follow the same hydrate-from-cache-at-construction pattern rather than assuming the live stream is the only source of a map's keys.
154156

155157
### 5. `iobroker.teslemetry` - ioBroker Adapter
156158

packages/homebridge-teslemetry/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,9 +147,9 @@ Each energy site appears as its own set of accessories:
147147
4. Check firewall settings (SSE requires persistent connections)
148148

149149
If the log shows streaming authentication failed twice in a row, the stream has stopped
150-
permanently (contact sensors that support a fault state, e.g. doors and TPMS, will show
151-
faulted). Fix the access token in your config and restart Homebridge - the plugin does not
152-
retry a dead token on its own.
150+
permanently (contact sensors and occupancy sensors that support a fault state, e.g. doors,
151+
TPMS, wall connectors, and presence, will show faulted). Fix the access token in your config
152+
and restart Homebridge - the plugin does not retry a dead token on its own.
153153

154154
### Accessories Not Responding
155155

packages/homebridge-teslemetry/src/energy-services/wall-connector.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,41 @@ export class WallConnectorService {
3131
private readonly accessory: PlatformAccessory,
3232
private readonly site: EnergyDetails,
3333
) {
34+
// Per-DIN services from a prior run persist in Homebridge's accessory
35+
// cache, but `connectors` was otherwise only ever populated by
36+
// live_status - hydrate it from any already-cached services up front so
37+
// a setStreamFault() call this run (including one that already happened
38+
// before this accessory was constructed) reaches them too.
39+
this.hydrateFromCache();
40+
if (this.platform.streamFault) {
41+
this.setStreamFault(true);
42+
}
43+
3444
const listener = (data: any) => this.handleLiveStatus(data);
3545
this.site.api.on("liveStatus", listener);
3646
this.cleanupFunctions.push(() => this.site.api.off("liveStatus", listener));
3747
}
3848

49+
private hydrateFromCache(): void {
50+
const faultPrefix = "wall-connector-fault-";
51+
const connectedPrefix = "wall-connector-connected-";
52+
53+
for (const service of this.accessory.services) {
54+
if (service.UUID !== this.platform.Service.ContactSensor.UUID) continue;
55+
const subtype = service.subtype;
56+
if (!subtype) continue;
57+
58+
const din = subtype.startsWith(faultPrefix)
59+
? subtype.slice(faultPrefix.length)
60+
: subtype.startsWith(connectedPrefix)
61+
? subtype.slice(connectedPrefix.length)
62+
: undefined;
63+
if (din === undefined || this.connectors.has(din)) continue;
64+
65+
this.connectors.set(din, this.createConnectorServices(din));
66+
}
67+
}
68+
3969
private handleLiveStatus(data: any): void {
4070
const connectors = data?.response?.wall_connectors;
4171
if (!Array.isArray(connectors)) return;

packages/homebridge-teslemetry/src/energy.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,7 @@ export class EnergyAccessory {
155155

156156
/**
157157
* Reflect terminal stream health across every service that has an honest
158-
* HomeKit fault characteristic (WallConnectorService's lazily-created
159-
* sensors don't implement this and are skipped).
158+
* HomeKit fault characteristic.
160159
*/
161160
setStreamFault(faulted: boolean): void {
162161
for (const service of this.services) {

packages/homebridge-teslemetry/src/platform.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ export class TeslemetryPlatform implements DynamicPlatformPlugin {
3838
// reconnects on its own from this state.
3939
private streamFaulted = false;
4040

41+
/** Current terminal-stream-fault state, so a service constructed after the
42+
* fault was raised (e.g. hydrating services from Homebridge's persisted
43+
* accessory cache) can apply it immediately instead of waiting for the
44+
* next markStreamFault() call that will never come until reconnect. */
45+
get streamFault(): boolean {
46+
return this.streamFaulted;
47+
}
48+
4149
// Accessory management
4250
private readonly accessories: PlatformAccessory[] = [];
4351
private readonly vehicleAccessories: Map<string, VehicleAccessory> = new Map();

packages/homebridge-teslemetry/src/vehicle-services/presence.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ export class PresenceService {
7575

7676
const service = this.services.get(definition.field)!;
7777

78+
// The signal just arrived, proving the stream is alive right now -
79+
// clear any fault independent of the debounced occupancy transition
80+
// below, which only governs when the *value* change is applied.
81+
this.applyStreamFault(service, false);
82+
7883
const existingTimer = this.pendingTimers.get(definition.field);
7984
if (existingTimer) clearTimeout(existingTimer);
8085

@@ -97,6 +102,7 @@ export class PresenceService {
97102

98103
service.setCharacteristic(this.platform.Characteristic.Name, displayName);
99104
this.applyOccupancy(service, initialValue);
105+
this.applyStreamFault(service, false);
100106
return service;
101107
}
102108

@@ -108,6 +114,25 @@ export class PresenceService {
108114
);
109115
}
110116

117+
/**
118+
* Reflect terminal stream health on every occupancy sensor created so far.
119+
* OccupancySensor declares StatusFault as an optional characteristic, so
120+
* this is an honest signal (same convention as the contact-sensor-backed
121+
* services) - a sensor not yet created (its field has never reported a
122+
* value) has nothing to fault and is simply skipped, the same as every
123+
* other lazily-created sensor in this package.
124+
*/
125+
setStreamFault(faulted: boolean): void {
126+
for (const service of this.services.values()) {
127+
this.applyStreamFault(service, faulted);
128+
}
129+
}
130+
131+
private applyStreamFault(service: Service, faulted: boolean): void {
132+
const { StatusFault } = this.platform.Characteristic;
133+
service.updateCharacteristic(StatusFault, faulted ? StatusFault.GENERAL_FAULT : StatusFault.NO_FAULT);
134+
}
135+
111136
private getDisplayName(serviceName: string): string {
112137
if (this.platform.config.prefixName !== false) {
113138
return `${this.vehicle.name} ${serviceName}`;

packages/homebridge-teslemetry/src/vehicle.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,7 @@ export class VehicleAccessory {
138138

139139
/**
140140
* Reflect terminal stream health across every service that has an honest
141-
* HomeKit fault characteristic (PresenceService's lazily-created sensors
142-
* don't implement this and are skipped).
141+
* HomeKit fault characteristic.
143142
*/
144143
setStreamFault(faulted: boolean): void {
145144
for (const service of this.services) {

packages/homebridge-teslemetry/test/fakePlatform.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ export function createFakeAccessory(displayName = "Test Vehicle"): PlatformAcces
1818
return accessory as unknown as PlatformAccessory;
1919
}
2020

21-
export function createFakePlatform(config: Record<string, unknown> = {}): {
21+
export function createFakePlatform(
22+
config: Record<string, unknown> = {},
23+
options: { streamFault?: boolean } = {},
24+
): {
2225
platform: TeslemetryPlatform;
2326
logs: LogEntry[];
2427
} {
@@ -31,6 +34,7 @@ export function createFakePlatform(config: Record<string, unknown> = {}): {
3134
Service,
3235
Characteristic,
3336
config,
37+
streamFault: options.streamFault ?? false,
3438
log: {
3539
info: log("info"),
3640
warn: log("warn"),

packages/homebridge-teslemetry/test/presence.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,30 @@ test("a non-boolean signal value is ignored without throwing", () => {
8585
assert.doesNotThrow(() => sse.emitSignal("LocatedAtHome", null));
8686
});
8787

88+
test("setStreamFault(true) faults every discovered sensor; a fresh signal for it clears it", () => {
89+
const { accessory, sse, presence } = setup();
90+
sse.emitSignal("LocatedAtHome", true);
91+
const home = accessory.getServiceById(Service.OccupancySensor, "presence-home")!;
92+
93+
presence.setStreamFault(true);
94+
assert.equal(
95+
home.getCharacteristic(Characteristic.StatusFault).value,
96+
Characteristic.StatusFault.GENERAL_FAULT,
97+
);
98+
99+
sse.emitSignal("LocatedAtHome", true);
100+
assert.equal(
101+
home.getCharacteristic(Characteristic.StatusFault).value,
102+
Characteristic.StatusFault.NO_FAULT,
103+
);
104+
});
105+
106+
test("setStreamFault(true) has nothing to fault before any signal has been discovered", () => {
107+
const { accessory, presence } = setup();
108+
assert.doesNotThrow(() => presence.setStreamFault(true));
109+
assert.equal(accessory.services.filter((s) => s.UUID === Service.OccupancySensor.UUID).length, 0);
110+
});
111+
88112
test("destroy() detaches signal subscriptions and clears pending debounce timers", (t) => {
89113
t.mock.timers.enable({ apis: ["setTimeout"] });
90114
const { sse, presence } = setup();

0 commit comments

Comments
 (0)