Skip to content

Commit 89412c1

Browse files
authored
fix(homebridge): surface terminal stream health instead of staying silently stale (#116)
* fix(homebridge): surface terminal stream health instead of staying silently stale Handle stream_error and terminal auth_failure from the account SSE stream: correct the disconnect log (it no longer claims a reconnect is coming, since after two consecutive auth failures the SDK stops for good), mark every contact sensor that supports a HomeKit fault state (doors, TPMS, grid outage, storm watch active) as faulted on terminal failure, and clear it on the next successful connect. Core service types without an honest fault characteristic (Lock, Switch, Thermostat, Battery) are left alone rather than forcing an out-of-spec characteristic onto them. * fix(homebridge): clear stream fault per-service on real data, not on bare connect TeslemetryStream emits "connect" before consuming any SSE event, so clearing StatusFault there showed last-known (possibly stale/default) sensor state as healthy again with no proof fresh data had actually arrived. Each faulted service now clears its own StatusFault only when it receives a real reading, matching the existing "unknown/faulted until a real payload lands" pattern already used for TPMS/grid-outage/storm-watch. DoorService didn't touch StatusFault on data at all; it now does. * fix(homebridge): fault lazily-created Wall Connector sensors on terminal stream failure WallConnectorService doesn't extend BaseEnergyService (its per-DIN sensors are created lazily), so it had no setStreamFault() and EnergyAccessory's duck-typed fault fan-out silently skipped it - already-discovered Wall Connector sensors kept their last cable/fault state forever instead of reporting GENERAL_FAULT like the other stream-backed contact sensors. Adds setStreamFault(), clearing each DIN's fault only when a fresh live_status reading for that DIN actually arrives. * fix(homebridge): clear StatusFault generically wherever subscribeSignal delivers real data RearDefrostService is a ContactSensor driven entirely through the generic subscribeSignal() path, which only ever updated the mapped characteristic - unlike Door/TPMS/GridOutage/StormWatch, nothing cleared its StatusFault, so once a terminal auth failure faulted it, it would stay GENERAL_FAULT forever even after receiving current data. Clearing the fault inside subscribeSignal itself (a no-op for service types without StatusFault) fixes this for RearDefrost and any future ContactSensor service that uses the generic path.
1 parent 023be46 commit 89412c1

17 files changed

Lines changed: 418 additions & 6 deletions

File tree

.changeset/hb-stream-health.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+
Handle `stream_error` and terminal `auth_failure` from the account stream: a stream disconnect no longer logs a blanket "will attempt to reconnect" (it may be terminal), and two consecutive auth failures now mark every contact sensor that supports a HomeKit fault state (doors, TPMS, grid outage, storm watch active) as faulted instead of leaving them on their last cached value forever. A later reconnect clears the fault. See the README's Streaming Connection Issues section for recovery steps.

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ pnpm link --global n8n-nodes-teslemetry
149149

150150
**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.
151151

152+
**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+
152154
### 5. `iobroker.teslemetry` - ioBroker Adapter
153155

154156
**Location**: `packages/iobroker.teslemetry/`

packages/homebridge-teslemetry/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,11 @@ Each energy site appears as its own set of accessories:
146146
3. Restart Homebridge
147147
4. Check firewall settings (SSE requires persistent connections)
148148

149+
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.
153+
149154
### Accessories Not Responding
150155

151156
1. Check if vehicle is asleep (may take a moment to wake)

packages/homebridge-teslemetry/src/energy-services/base.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,28 @@ export abstract class BaseEnergyService {
142142
});
143143
}
144144

145+
/**
146+
* Reflect terminal stream health as StatusFault. Only sensor-type HomeKit
147+
* services (e.g. ContactSensor) declare StatusFault as optional; forcing it
148+
* onto a service type that doesn't would add an out-of-spec characteristic,
149+
* so those are left untouched rather than given a misleading fault signal.
150+
*/
151+
setStreamFault(faulted: boolean): void {
152+
this.applyStreamFault(this.service, faulted);
153+
}
154+
155+
protected applyStreamFault(service: Service, faulted: boolean): void {
156+
const { StatusFault } = this.platform.Characteristic;
157+
// testCharacteristic() only reports characteristics already added, not
158+
// ones the service type merely permits - check the declared optional
159+
// list instead so a not-yet-added StatusFault still gets recognized.
160+
const supportsStatusFault = service.optionalCharacteristics.some(
161+
(characteristic) => characteristic.UUID === StatusFault.UUID,
162+
);
163+
if (!supportsStatusFault) return;
164+
service.updateCharacteristic(StatusFault, faulted ? StatusFault.GENERAL_FAULT : StatusFault.NO_FAULT);
165+
}
166+
145167
/**
146168
* Cleanup all subscriptions
147169
* Should be called when the service is being removed

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,29 @@ export class WallConnectorService {
6969
ContactSensorState,
7070
isConnected ? ContactSensorState.CONTACT_DETECTED : ContactSensorState.CONTACT_NOT_DETECTED,
7171
);
72+
73+
// A real reading just arrived for this DIN - clear any fault a prior
74+
// terminal stream failure left set (see setStreamFault()).
75+
this.applyStreamFault(services, false);
76+
}
77+
78+
/**
79+
* Reflect terminal stream health. Only DINs already discovered (i.e. with
80+
* services created) can be marked - a DIN never seen has no sensors to
81+
* fault yet, matching the same lazy-creation contract as everything else
82+
* here.
83+
*/
84+
setStreamFault(faulted: boolean): void {
85+
for (const services of this.connectors.values()) {
86+
this.applyStreamFault(services, faulted);
87+
}
88+
}
89+
90+
private applyStreamFault(services: ConnectorServices, faulted: boolean): void {
91+
const { StatusFault } = this.platform.Characteristic;
92+
const value = faulted ? StatusFault.GENERAL_FAULT : StatusFault.NO_FAULT;
93+
services.fault.updateCharacteristic(StatusFault, value);
94+
services.connected.updateCharacteristic(StatusFault, value);
7295
}
7396

7497
private createConnectorServices(din: string): ConnectorServices {

packages/homebridge-teslemetry/src/energy.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,19 @@ export class EnergyAccessory {
153153
this.platform.log.debug(`Started stream listeners for ${this.site.name}`);
154154
}
155155

156+
/**
157+
* 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).
160+
*/
161+
setStreamFault(faulted: boolean): void {
162+
for (const service of this.services) {
163+
if ("setStreamFault" in service) {
164+
service.setStreamFault(faulted);
165+
}
166+
}
167+
}
168+
156169
/**
157170
* Cleanup all services and stop polling/streaming
158171
*/

packages/homebridge-teslemetry/src/platform.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ export class TeslemetryPlatform implements DynamicPlatformPlugin {
3333
private teslemetry?: Teslemetry;
3434
private products?: Products;
3535

36+
// True once the stream has stopped permanently (two consecutive auth
37+
// failures); cleared only by a subsequent "connect", since the SDK never
38+
// reconnects on its own from this state.
39+
private streamFaulted = false;
40+
3641
// Accessory management
3742
private readonly accessories: PlatformAccessory[] = [];
3843
private readonly vehicleAccessories: Map<string, VehicleAccessory> = new Map();
@@ -152,15 +157,60 @@ export class TeslemetryPlatform implements DynamicPlatformPlugin {
152157
return;
153158
}
154159

160+
// "connect" fires as soon as the SSE handshake completes, before any
161+
// event is consumed - clearing StatusFault here would show last-known
162+
// (possibly stale/default) sensor state as healthy again with no proof
163+
// fresh data has actually arrived. Each faulted service instead clears
164+
// its own StatusFault the next time it receives a real payload (the
165+
// same "unknown/faulted until a real reading lands" convention already
166+
// used for TPMS/grid-outage/storm-watch at startup).
155167
this.teslemetry.sse.on("connect", () => {
156-
this.log.info("✓ Streaming API connected");
168+
if (this.streamFaulted) {
169+
this.log.info("✓ Streaming API reconnected");
170+
this.streamFaulted = false;
171+
} else {
172+
this.log.info("✓ Streaming API connected");
173+
}
157174
});
158175

176+
// A disconnect on its own doesn't say whether the SDK will retry or has
177+
// stopped for good - that distinction only arrives via stream_error/
178+
// auth_failure below, so this log makes no promise either way.
159179
this.teslemetry.sse.on("disconnect", () => {
160-
this.log.warn("✗ Streaming API disconnected - will attempt to reconnect");
180+
this.log.warn("✗ Streaming API disconnected");
181+
});
182+
183+
this.teslemetry.sse.on("stream_error", ({ error, status, retries }) => {
184+
const message = error instanceof Error ? error.message : String(error);
185+
this.log.warn(
186+
`Streaming API error (status ${status ?? "unknown"}, attempt ${retries}): ${message}`,
187+
);
188+
});
189+
190+
this.teslemetry.sse.on("auth_failure", (error) => {
191+
this.log.error(
192+
"Streaming API authentication failed twice in a row and has stopped permanently - " +
193+
"characteristics will no longer update. Fix the access token in this plugin's " +
194+
"config and restart Homebridge to resume streaming.",
195+
error,
196+
);
197+
this.streamFaulted = true;
198+
this.markStreamFault(true);
161199
});
162200
}
163201

202+
/**
203+
* Reflect terminal stream health on every registered vehicle/energy site.
204+
*/
205+
private markStreamFault(faulted: boolean): void {
206+
for (const vehicleAccessory of this.vehicleAccessories.values()) {
207+
vehicleAccessory.setStreamFault(faulted);
208+
}
209+
for (const energyAccessory of this.energyAccessories.values()) {
210+
energyAccessory.setStreamFault(faulted);
211+
}
212+
}
213+
164214
/**
165215
* Register a vehicle as a HomeKit accessory
166216
*/

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@ export abstract class BaseService {
9999
characteristic,
100100
mappedValue as Nullable<CharacteristicValue>,
101101
);
102+
// A real reading just arrived - clear any fault a prior terminal
103+
// stream failure left set (see setStreamFault()). A no-op for
104+
// service types that don't support StatusFault.
105+
this.applyStreamFault(this.service, false);
102106
} catch (error) {
103107
this.platform.log.error(
104108
`Error updating characteristic for signal ${signal}:`,
@@ -158,6 +162,30 @@ export abstract class BaseService {
158162
});
159163
}
160164

165+
/**
166+
* Reflect terminal stream health as StatusFault. Only sensor-type HomeKit
167+
* services (e.g. ContactSensor) declare StatusFault as optional; forcing it
168+
* onto a service type that doesn't (Lock, Switch, Thermostat, Battery)
169+
* would add an out-of-spec characteristic, so those are left untouched
170+
* rather than given a misleading fault signal.
171+
*/
172+
setStreamFault(faulted: boolean): void {
173+
this.applyStreamFault(this.service, faulted);
174+
}
175+
176+
protected applyStreamFault(service: Service, faulted: boolean): void {
177+
const { StatusFault } = this.platform.Characteristic;
178+
// testCharacteristic() only reports characteristics already added, not
179+
// ones the service type merely permits - check the declared optional
180+
// list instead so a not-yet-added StatusFault (e.g. Door, which never
181+
// touches it during normal operation) still gets recognized.
182+
const supportsStatusFault = service.optionalCharacteristics.some(
183+
(characteristic) => characteristic.UUID === StatusFault.UUID,
184+
);
185+
if (!supportsStatusFault) return;
186+
service.updateCharacteristic(StatusFault, faulted ? StatusFault.GENERAL_FAULT : StatusFault.NO_FAULT);
187+
}
188+
161189
/**
162190
* Cleanup all subscriptions
163191
* Should be called when the service is being removed

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,19 @@ export class DoorService extends BaseService {
9595
? ContactSensorState.CONTACT_NOT_DETECTED // Door open
9696
: ContactSensorState.CONTACT_DETECTED, // Door closed
9797
);
98+
// A real reading just arrived for this door - clear any fault a
99+
// prior terminal stream failure left set (see setStreamFault()).
100+
this.applyStreamFault(service, false);
98101
}
99102
}
100103
}
101104

105+
setStreamFault(faulted: boolean): void {
106+
for (const service of this.doorServices.values()) {
107+
this.applyStreamFault(service, faulted);
108+
}
109+
}
110+
102111
/**
103112
* Override destroy to clean up all door services
104113
*/

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,13 @@ export class TpmsService extends BaseService {
112112
}
113113
}
114114

115+
setStreamFault(faulted: boolean): void {
116+
super.setStreamFault(faulted);
117+
for (const service of this.softWarningServices.values()) {
118+
this.applyStreamFault(service, faulted);
119+
}
120+
}
121+
115122
destroy(): void {
116123
super.destroy();
117124
this.softWarningServices.clear();

0 commit comments

Comments
 (0)