homebridge-navilink 1.0.2 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.0.3](https://github.com/tbaur/homebridge-navilink/compare/v1.0.2...v1.0.3) (2026-09-20)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * fail fast REST sign-in during a sustained cloud outage ([#20](https://github.com/tbaur/homebridge-navilink/issues/20)) ([41f0a39](https://github.com/tbaur/homebridge-navilink/commit/41f0a3963d9333850144d243945b83b6d6ac725c))
9
+
3
10
  ## [1.0.2](https://github.com/tbaur/homebridge-navilink/compare/v1.0.1...v1.0.2) (2026-09-20)
4
11
 
5
12
 
package/README.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # homebridge-navilink
2
2
 
3
- [![Tests](https://github.com/tbaur/homebridge-navilink/actions/workflows/test.yml/badge.svg)](https://github.com/tbaur/homebridge-navilink/actions/workflows/test.yml)
4
- [![npm version](https://img.shields.io/npm/v/homebridge-navilink?style=flat-square)](https://www.npmjs.com/package/homebridge-navilink)
5
- [![npm downloads](https://img.shields.io/npm/dt/homebridge-navilink?label=downloads&style=flat-square)](https://www.npmjs.com/package/homebridge-navilink)
6
- [![Node.js](https://img.shields.io/badge/node-22%20%7C%7C%2024%20%7C%7C%2026-green)](https://nodejs.org)
7
- [![Homebridge](https://img.shields.io/badge/homebridge-2.x-purple)](https://homebridge.io)
8
- [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)
3
+ ![Tests](https://github.com/tbaur/homebridge-navilink/actions/workflows/test.yml/badge.svg)
4
+ ![npm version](https://img.shields.io/npm/v/homebridge-navilink?style=flat-square)
5
+ ![npm downloads](https://img.shields.io/npm/dt/homebridge-navilink?label=downloads&style=flat-square)
6
+ ![Node.js](https://img.shields.io/badge/node-22%20%7C%7C%2024%20%7C%7C%2026-green)
7
+ ![Homebridge](https://img.shields.io/badge/homebridge-2.x-purple)
8
+ ![License](https://img.shields.io/badge/license-Apache--2.0-blue)
9
9
 
10
10
  **Navien combi boilers and water heaters in Apple HomeKit.** Hot water and space-heating thermostats, recirculation, faults and temperature probes, with changes arriving as they happen rather than on a polling loop. Verified against an NCB-240E (firmware 4352).
11
11
 
@@ -13,6 +13,8 @@ Scheduling, weekly programmes and commissioning stay in the NaviLink app, which
13
13
 
14
14
  ## Features
15
15
 
16
+
17
+
16
18
  ### Per appliance
17
19
 
18
20
  - **Hot water thermostat:** the domestic hot water setpoint, reading the actual outlet temperature
@@ -22,6 +24,8 @@ Scheduling, weekly programmes and commissioning stay in the NaviLink app, which
22
24
  - **Fault sensor:** a contact sensor that opens and logs on an error code, so HomeKit can notify you
23
25
  - **Temperature sensors:** hot water in and out, heating flow and return, and the optional outdoor probe
24
26
 
27
+
28
+
25
29
  ### Reliability
26
30
 
27
31
  - **Push, not polling.** The gateway sends changes as they happen, so a setpoint changed at the wall controller or in the NaviLink app reaches HomeKit in about a second. The poll is only a backstop
@@ -30,6 +34,9 @@ Scheduling, weekly programmes and commissioning stay in the NaviLink app, which
30
34
  - **A connected socket is not a live appliance.** Readings carry the time they were taken, and a stale one becomes No Response instead
31
35
  - **State:** No Response until the appliance has actually been read, and not a value it cannot confirm
32
36
  - **Never loses your rooms:** a broken config disables the platform without unregistering anything
37
+ - **A REST circuit breaker** fails fast during a sustained cloud outage. The log is `Circuit breaker CLOSED -> OPEN`, then `HALF_OPEN` and `CLOSED` when it recovers. Firmware reads do not trip it
38
+
39
+
33
40
 
34
41
  ### Quality
35
42
 
@@ -42,6 +49,8 @@ Every accessory, field and log line is documented in [Detailed documentation](do
42
49
 
43
50
  ## Quick Start
44
51
 
52
+
53
+
45
54
  ### 1. Install
46
55
 
47
56
  **Homebridge UI** (recommended): Plugins → Search `homebridge-navilink` → Install
@@ -50,6 +59,8 @@ Every accessory, field and log line is documented in [Detailed documentation](do
50
59
  npm install -g homebridge-navilink
51
60
  ```
52
61
 
62
+
63
+
53
64
  ### 2. Prepare your appliance
54
65
 
55
66
  Your NaviLink gateway must be connected and showing as online in the NaviLink app. Nothing needs enabling: the plugin uses the same service the app does. If the app cannot see the appliance, neither can this.
@@ -94,12 +105,15 @@ Accessories appear in the Home app after restart, showing No Response for the fe
94
105
 
95
106
  Any Navien appliance that the NaviLink app controls. The cloud interface is the same for all of them; what differs is which capabilities an appliance reports.
96
107
 
108
+
97
109
  | Verified against | Notes |
98
110
  | ----------------- | ------------------------------------------------- |
99
111
  | **NCB-240E (NG)** | Combi: hot water and space heating, firmware 4352 |
100
112
 
113
+
101
114
  Other families are decoded from the same table the NaviLink app uses and are expected to work, but nobody has confirmed them here:
102
115
 
116
+
103
117
  | Family | Expected |
104
118
  | ------------------ | ------------------------------------------------------------------------------ |
105
119
  | **NPE, NPE2, NPN** | Tankless water heaters. Hot water only; the heating thermostat disables itself |
@@ -108,12 +122,14 @@ Other families are decoded from the same table the NaviLink app uses and are exp
108
122
  | **NFB, NFC** | Boilers with hot water and space heating |
109
123
  | **NVW** | Water heater with a tank |
110
124
 
125
+
111
126
  If yours is not listed, it will most likely work. Please open an issue with the output of `node scripts/capture-fixture.js --redact` either way, so the table can say so with confidence.
112
127
 
113
128
  ## Configuration Options
114
129
 
115
130
  Only one `NaviLink` platform block is supported. It can hold as many appliances as the account owns.
116
131
 
132
+
117
133
  | Option | Required to run | Description |
118
134
  | ----------------------------- | --------------- | ------------------------------------------------------------------------------- |
119
135
  | `name` | ✓ | Plugin instance name shown in Homebridge logs |
@@ -127,15 +143,17 @@ Only one `NaviLink` platform block is supported. It can hold as many appliances
127
143
  | `options.structuredLogs` | | With diagnostics, also emit a JSON line (default false) |
128
144
  | `options.accessoryPrefix` | | Prefix for every HomeKit name. Blank uses each appliance's name |
129
145
 
146
+
130
147
  Each entry in `devices[]` takes `id` and `name`, plus an optional `channel` and one flag per accessory: `dhw`, `heating`, `power`, `recirculation`, `fault`, `temperatureSensors` and `outdoorSensor`. The [detailed documentation](docs/README-DETAILED.md#devices-entries) describes each one.
131
148
 
132
149
  ## Not Working?
133
150
 
134
151
  1. **"NaviLink rejected the email address or password."** Sign in to the NaviLink app with the same credentials. The plugin has stopped trying on purpose, so fix the password and restart Homebridge
135
152
  2. **Everything shows No Response.** Check the log for `platform disabled`, which means the configuration could not be read
136
- 3. **The heating thermostat says it has no loop.** Your appliance reports no space-heating circuit. Turn the accessory off in the settings
137
- 4. **A recirculation switch that is not offered.** No pump is fitted, or it is not commissioned. Check the NaviLink app
153
+ 3. **The heating thermostat says it has no loop.** Your appliance reports no space-heating circuit
154
+ 4. **A recirculation switch that is not offered.** No pump is fitted, or it is not commissioned
138
155
  5. **`rate limited`.** The cloud rate-limits control. The plugin pauses and retries on the next press
156
+ 6. **`Circuit breaker CLOSED -> OPEN`.** Sign-in is paused; tiles stay No Response until the cloud recovers
139
157
 
140
158
  The [full troubleshooting list](docs/README-DETAILED.md#troubleshooting) covers more, including what Off on the hot water tile does once you allow power-off.
141
159
 
@@ -151,6 +169,8 @@ This plugin needs your NaviLink password, stores it in the Homebridge configurat
151
169
  - A NaviLink account with at least one gateway online
152
170
  - A working internet connection
153
171
 
172
+
173
+
154
174
  ## More Info
155
175
 
156
176
  - [Detailed documentation](docs/README-DETAILED.md)
@@ -160,6 +180,8 @@ This plugin needs your NaviLink password, stores it in the Homebridge configurat
160
180
  - [Report Issues](https://github.com/tbaur/homebridge-navilink/issues)
161
181
  - [Changelog](CHANGELOG.md)
162
182
 
183
+
184
+
163
185
  ## License
164
186
 
165
187
  Copyright 2026 tbaur
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Copyright (c) 2026 tbaur
3
+ *
4
+ * Licensed under the Apache License, Version 2.0
5
+ * See LICENSE file for full license text
6
+ *
7
+ * @fileoverview Circuit breaker for REST resilience.
8
+ *
9
+ * Prevents hammering the NaviLink REST cloud when it is down: after a
10
+ * threshold of service-health failures the breaker opens, later calls fail
11
+ * fast until a cooldown elapses, then a single half-open probe decides
12
+ * whether to close again or stay open.
13
+ *
14
+ * MQTT is a separate transport and is not gated here. A dead broker is the
15
+ * session reconnect loop and the `mqttDown` diagnostic, not this breaker.
16
+ */
17
+ /** Circuit breaker states. */
18
+ export declare enum CircuitState {
19
+ /** Normal operation — requests flow through. */
20
+ CLOSED = "CLOSED",
21
+ /** Circuit tripped — requests fail immediately. */
22
+ OPEN = "OPEN",
23
+ /** Testing whether the service recovered. */
24
+ HALF_OPEN = "HALF_OPEN"
25
+ }
26
+ /** Circuit breaker configuration. */
27
+ export interface CircuitBreakerConfig {
28
+ /** Number of failures within the window before opening the circuit. */
29
+ failureThreshold: number;
30
+ /** Time in ms before trying half-open after opening. */
31
+ resetTimeout: number;
32
+ /** Max concurrent probe requests allowed in half-open state. */
33
+ halfOpenMax: number;
34
+ /** Sliding window (ms) for counting failures. */
35
+ failureWindow?: number;
36
+ /**
37
+ * Invoked whenever the circuit transitions between states. Used for
38
+ * observability so operators can see when the breaker opens or recovers.
39
+ */
40
+ onStateChange?: (from: CircuitState, to: CircuitState) => void;
41
+ }
42
+ /**
43
+ * Default circuit breaker configuration.
44
+ *
45
+ * `halfOpenMax` is 1 on purpose: establish is sequential (sign-in, then
46
+ * list), and a single probe keeps recovery deterministic, matching the
47
+ * sibling plugins.
48
+ */
49
+ export declare const DEFAULT_CIRCUIT_BREAKER_CONFIG: CircuitBreakerConfig;
50
+ /** Circuit breaker status snapshot. */
51
+ export interface CircuitBreakerStatus {
52
+ state: CircuitState;
53
+ failures: number;
54
+ successes: number;
55
+ lastFailureTime: number | null;
56
+ halfOpenRequests: number;
57
+ isOpen: boolean;
58
+ remainingResetTime: number | null;
59
+ }
60
+ /**
61
+ * Circuit breaker for REST resilience.
62
+ * Prevents hammering NaviLink REST when it is returning sustained failures.
63
+ */
64
+ export declare class CircuitBreaker {
65
+ private readonly failureThreshold;
66
+ private readonly resetTimeout;
67
+ private readonly halfOpenMax;
68
+ private readonly failureWindow;
69
+ private readonly onStateChange?;
70
+ private _state;
71
+ private failures;
72
+ private successes;
73
+ private lastFailureTime;
74
+ private halfOpenRequests;
75
+ private failureTimestamps;
76
+ constructor(config?: Partial<CircuitBreakerConfig>);
77
+ /** Current circuit state. */
78
+ get state(): CircuitState;
79
+ /** True when the circuit is open (requests should fail fast). */
80
+ get isOpen(): boolean;
81
+ /**
82
+ * Transition to a new state, notifying observers only on an actual change.
83
+ */
84
+ private transitionTo;
85
+ /** Drop failure timestamps that fall outside the sliding window. */
86
+ private cleanupFailures;
87
+ /** Whether the circuit currently allows a request through. */
88
+ canRequest(): boolean;
89
+ /** Record a successful request. */
90
+ recordSuccess(): void;
91
+ /** Record a failed request that reflects service health. */
92
+ recordFailure(): void;
93
+ /** Track a half-open probe request against the concurrency cap. */
94
+ trackHalfOpenRequest(): void;
95
+ /** Reset the circuit breaker to closed state. */
96
+ reset(): void;
97
+ /** Current circuit breaker status. */
98
+ getStatus(): CircuitBreakerStatus;
99
+ /**
100
+ * Execute a function with circuit breaker protection.
101
+ *
102
+ * @param isFailure Optional predicate controlling which thrown errors count
103
+ * as service-health failures while CLOSED. Defaults to treating every
104
+ * rejection as a failure. REST uses a narrower predicate (connection and
105
+ * protocol errors). While HALF_OPEN, every terminal rejection still
106
+ * re-opens the breaker so the probe slot cannot wedge.
107
+ */
108
+ execute<T>(fn: () => Promise<T>, isFailure?: (error: unknown) => boolean): Promise<T>;
109
+ /** Create a wrapped version of a function with circuit breaker protection. */
110
+ wrap<T extends unknown[], R>(fn: (...args: T) => Promise<R>, isFailure?: (error: unknown) => boolean): (...args: T) => Promise<R>;
111
+ }
@@ -0,0 +1,221 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 tbaur
4
+ *
5
+ * Licensed under the Apache License, Version 2.0
6
+ * See LICENSE file for full license text
7
+ *
8
+ * @fileoverview Circuit breaker for REST resilience.
9
+ *
10
+ * Prevents hammering the NaviLink REST cloud when it is down: after a
11
+ * threshold of service-health failures the breaker opens, later calls fail
12
+ * fast until a cooldown elapses, then a single half-open probe decides
13
+ * whether to close again or stay open.
14
+ *
15
+ * MQTT is a separate transport and is not gated here. A dead broker is the
16
+ * session reconnect loop and the `mqttDown` diagnostic, not this breaker.
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.CircuitBreaker = exports.DEFAULT_CIRCUIT_BREAKER_CONFIG = exports.CircuitState = void 0;
20
+ const errors_1 = require("../utils/errors");
21
+ /** Circuit breaker states. */
22
+ var CircuitState;
23
+ (function (CircuitState) {
24
+ /** Normal operation — requests flow through. */
25
+ CircuitState["CLOSED"] = "CLOSED";
26
+ /** Circuit tripped — requests fail immediately. */
27
+ CircuitState["OPEN"] = "OPEN";
28
+ /** Testing whether the service recovered. */
29
+ CircuitState["HALF_OPEN"] = "HALF_OPEN";
30
+ })(CircuitState || (exports.CircuitState = CircuitState = {}));
31
+ /**
32
+ * Default circuit breaker configuration.
33
+ *
34
+ * `halfOpenMax` is 1 on purpose: establish is sequential (sign-in, then
35
+ * list), and a single probe keeps recovery deterministic, matching the
36
+ * sibling plugins.
37
+ */
38
+ exports.DEFAULT_CIRCUIT_BREAKER_CONFIG = {
39
+ failureThreshold: 5,
40
+ resetTimeout: 30_000,
41
+ halfOpenMax: 1,
42
+ failureWindow: 60_000,
43
+ };
44
+ /**
45
+ * Circuit breaker for REST resilience.
46
+ * Prevents hammering NaviLink REST when it is returning sustained failures.
47
+ */
48
+ class CircuitBreaker {
49
+ failureThreshold;
50
+ resetTimeout;
51
+ halfOpenMax;
52
+ failureWindow;
53
+ onStateChange;
54
+ _state = CircuitState.CLOSED;
55
+ failures = 0;
56
+ successes = 0;
57
+ lastFailureTime = null;
58
+ halfOpenRequests = 0;
59
+ failureTimestamps = [];
60
+ constructor(config = {}) {
61
+ const merged = { ...exports.DEFAULT_CIRCUIT_BREAKER_CONFIG, ...config };
62
+ this.failureThreshold = merged.failureThreshold;
63
+ this.resetTimeout = merged.resetTimeout;
64
+ // At least one probe must be allowed. With a cap of 0, canRequest() would
65
+ // promote OPEN -> HALF_OPEN and then refuse every probe (0 < 0), wedging the
66
+ // breaker shut permanently with no path back to CLOSED.
67
+ this.halfOpenMax = Math.max(1, merged.halfOpenMax);
68
+ this.failureWindow = merged.failureWindow ?? 60_000;
69
+ this.onStateChange = merged.onStateChange;
70
+ }
71
+ /** Current circuit state. */
72
+ get state() {
73
+ return this._state;
74
+ }
75
+ /** True when the circuit is open (requests should fail fast). */
76
+ get isOpen() {
77
+ return this._state === CircuitState.OPEN;
78
+ }
79
+ /**
80
+ * Transition to a new state, notifying observers only on an actual change.
81
+ */
82
+ transitionTo(next) {
83
+ if (this._state === next) {
84
+ return;
85
+ }
86
+ const previous = this._state;
87
+ this._state = next;
88
+ this.onStateChange?.(previous, next);
89
+ }
90
+ /** Drop failure timestamps that fall outside the sliding window. */
91
+ cleanupFailures() {
92
+ const cutoff = Date.now() - this.failureWindow;
93
+ this.failureTimestamps = this.failureTimestamps.filter((stamp) => stamp > cutoff);
94
+ this.failures = this.failureTimestamps.length;
95
+ }
96
+ /** Whether the circuit currently allows a request through. */
97
+ canRequest() {
98
+ if (this._state === CircuitState.CLOSED) {
99
+ return true;
100
+ }
101
+ if (this._state === CircuitState.OPEN) {
102
+ if (this.lastFailureTime && (Date.now() - this.lastFailureTime) >= this.resetTimeout) {
103
+ this.halfOpenRequests = 0;
104
+ this.successes = 0;
105
+ this.transitionTo(CircuitState.HALF_OPEN);
106
+ return true;
107
+ }
108
+ return false;
109
+ }
110
+ if (this._state === CircuitState.HALF_OPEN) {
111
+ return this.halfOpenRequests < this.halfOpenMax;
112
+ }
113
+ return false;
114
+ }
115
+ /** Record a successful request. */
116
+ recordSuccess() {
117
+ if (this._state === CircuitState.HALF_OPEN) {
118
+ this.successes += 1;
119
+ if (this.successes >= this.halfOpenMax) {
120
+ this.reset();
121
+ }
122
+ }
123
+ else if (this._state === CircuitState.OPEN) {
124
+ // A request that was already in flight when the breaker re-opened still
125
+ // proves the service is reachable — close immediately rather than waiting
126
+ // out the full cooldown.
127
+ this.reset();
128
+ }
129
+ else if (this._state === CircuitState.CLOSED) {
130
+ this.cleanupFailures();
131
+ }
132
+ }
133
+ /** Record a failed request that reflects service health. */
134
+ recordFailure() {
135
+ // Already open: ignore late failures from overlapping half-open probes so
136
+ // they cannot keep pushing lastFailureTime forward and extend the cooldown.
137
+ if (this._state === CircuitState.OPEN) {
138
+ return;
139
+ }
140
+ const now = Date.now();
141
+ this.lastFailureTime = now;
142
+ this.failureTimestamps.push(now);
143
+ if (this._state === CircuitState.HALF_OPEN) {
144
+ this.halfOpenRequests = 0;
145
+ this.successes = 0;
146
+ this.transitionTo(CircuitState.OPEN);
147
+ }
148
+ else if (this._state === CircuitState.CLOSED) {
149
+ this.cleanupFailures();
150
+ if (this.failures >= this.failureThreshold) {
151
+ this.transitionTo(CircuitState.OPEN);
152
+ }
153
+ }
154
+ }
155
+ /** Track a half-open probe request against the concurrency cap. */
156
+ trackHalfOpenRequest() {
157
+ if (this._state === CircuitState.HALF_OPEN) {
158
+ this.halfOpenRequests += 1;
159
+ }
160
+ }
161
+ /** Reset the circuit breaker to closed state. */
162
+ reset() {
163
+ this.failures = 0;
164
+ this.successes = 0;
165
+ this.lastFailureTime = null;
166
+ this.halfOpenRequests = 0;
167
+ this.failureTimestamps = [];
168
+ this.transitionTo(CircuitState.CLOSED);
169
+ }
170
+ /** Current circuit breaker status. */
171
+ getStatus() {
172
+ const now = Date.now();
173
+ let remainingResetTime = null;
174
+ if (this._state === CircuitState.OPEN && this.lastFailureTime) {
175
+ remainingResetTime = Math.max(0, this.resetTimeout - (now - this.lastFailureTime));
176
+ }
177
+ return {
178
+ state: this._state,
179
+ failures: this.failures,
180
+ successes: this.successes,
181
+ lastFailureTime: this.lastFailureTime,
182
+ halfOpenRequests: this.halfOpenRequests,
183
+ isOpen: this.isOpen,
184
+ remainingResetTime,
185
+ };
186
+ }
187
+ /**
188
+ * Execute a function with circuit breaker protection.
189
+ *
190
+ * @param isFailure Optional predicate controlling which thrown errors count
191
+ * as service-health failures while CLOSED. Defaults to treating every
192
+ * rejection as a failure. REST uses a narrower predicate (connection and
193
+ * protocol errors). While HALF_OPEN, every terminal rejection still
194
+ * re-opens the breaker so the probe slot cannot wedge.
195
+ */
196
+ async execute(fn, isFailure = () => true) {
197
+ if (!this.canRequest()) {
198
+ const status = this.getStatus();
199
+ throw new errors_1.CircuitBreakerError(status.remainingResetTime ?? this.resetTimeout);
200
+ }
201
+ if (this._state === CircuitState.HALF_OPEN) {
202
+ this.trackHalfOpenRequest();
203
+ }
204
+ try {
205
+ const result = await fn();
206
+ this.recordSuccess();
207
+ return result;
208
+ }
209
+ catch (error) {
210
+ if (this._state === CircuitState.HALF_OPEN || isFailure(error)) {
211
+ this.recordFailure();
212
+ }
213
+ throw error;
214
+ }
215
+ }
216
+ /** Create a wrapped version of a function with circuit breaker protection. */
217
+ wrap(fn, isFailure) {
218
+ return (...args) => this.execute(() => fn(...args), isFailure);
219
+ }
220
+ }
221
+ exports.CircuitBreaker = CircuitBreaker;
@@ -7,6 +7,7 @@
7
7
  * @fileoverview API barrel.
8
8
  */
9
9
  export * from './channel';
10
+ export * from './circuit-breaker';
10
11
  export * from './http';
11
12
  export * from './identity';
12
13
  export * from './mqtt';
package/dist/api/index.js CHANGED
@@ -23,6 +23,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
23
23
  };
24
24
  Object.defineProperty(exports, "__esModule", { value: true });
25
25
  __exportStar(require("./channel"), exports);
26
+ __exportStar(require("./circuit-breaker"), exports);
26
27
  __exportStar(require("./http"), exports);
27
28
  __exportStar(require("./identity"), exports);
28
29
  __exportStar(require("./mqtt"), exports);
@@ -25,6 +25,7 @@
25
25
  * is logged, and the fields the plugin does not need are never read out of it.
26
26
  */
27
27
  import type { PluginLogger } from '../types';
28
+ import { type CircuitBreakerStatus } from './circuit-breaker';
28
29
  import { type JsonPost } from './http';
29
30
  import type { IotCredentials } from './sigv4';
30
31
  /** A signed-in session. */
@@ -67,6 +68,8 @@ export interface NaviLinkRestOptions {
67
68
  durationMs: number;
68
69
  ok: boolean;
69
70
  }) => void;
71
+ /** Fired when the breaker transitions into OPEN, so diagnostics can count a trip. */
72
+ onCircuitOpen?: () => void;
70
73
  }
71
74
  /** Talks to the NaviLink REST service. */
72
75
  export declare class NaviLinkRest {
@@ -75,7 +78,11 @@ export declare class NaviLinkRest {
75
78
  private readonly now;
76
79
  private readonly signal;
77
80
  private readonly metrics;
81
+ private readonly onCircuitOpen;
82
+ private readonly circuitBreaker;
78
83
  constructor(options: NaviLinkRestOptions);
84
+ /** Live breaker status for diagnostics. Never reads the network. */
85
+ getCircuitBreakerStatus(): CircuitBreakerStatus;
79
86
  /**
80
87
  * Exchange an email and password for a session.
81
88
  *
@@ -112,6 +119,21 @@ export declare class NaviLinkRest {
112
119
  additionalValue: string;
113
120
  }): Promise<string | undefined>;
114
121
  private call;
122
+ /**
123
+ * Run one REST logical attempt behind the circuit breaker.
124
+ *
125
+ * Pre-flight OPEN rejections do not count as API samples: nothing was sent.
126
+ * Connection and protocol errors trip the breaker; a rejected password does
127
+ * not. HALF_OPEN treats every terminal rejection as a failed probe so the
128
+ * slot cannot wedge.
129
+ */
130
+ private guarded;
131
+ /**
132
+ * Surface circuit-breaker transitions so operators can see when NaviLink
133
+ * REST is being treated as unavailable and when it recovers. OPEN is warn;
134
+ * HALF_OPEN (probe) and CLOSED (recovery) are info.
135
+ */
136
+ private logCircuitTransition;
115
137
  private readBody;
116
138
  /**
117
139
  * Turn a failed sign-in into the right kind of error.
package/dist/api/rest.js CHANGED
@@ -29,6 +29,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
29
29
  exports.NaviLinkRest = void 0;
30
30
  const settings_1 = require("../settings");
31
31
  const errors_1 = require("../utils/errors");
32
+ const circuit_breaker_1 = require("./circuit-breaker");
32
33
  const http_1 = require("./http");
33
34
  /**
34
35
  * How long a session lasts when the cloud's answer is not believable.
@@ -55,12 +56,24 @@ class NaviLinkRest {
55
56
  now;
56
57
  signal;
57
58
  metrics;
59
+ onCircuitOpen;
60
+ circuitBreaker;
58
61
  constructor(options) {
59
62
  this.log = options.log;
60
63
  this.post = options.post ?? http_1.postJson;
61
64
  this.now = options.now ?? Date.now;
62
65
  this.signal = options.signal;
63
66
  this.metrics = options.metrics;
67
+ this.onCircuitOpen = options.onCircuitOpen;
68
+ this.circuitBreaker = new circuit_breaker_1.CircuitBreaker({
69
+ onStateChange: (from, to) => {
70
+ this.logCircuitTransition(from, to);
71
+ },
72
+ });
73
+ }
74
+ /** Live breaker status for diagnostics. Never reads the network. */
75
+ getCircuitBreakerStatus() {
76
+ return this.circuitBreaker.getStatus();
64
77
  }
65
78
  /**
66
79
  * Exchange an email and password for a session.
@@ -76,43 +89,45 @@ class NaviLinkRest {
76
89
  // COMMON_BAD_REQUEST, which looks like a credential problem.
77
90
  throw new TypeError('sign-in needs an email and a password as two strings');
78
91
  }
79
- const response = await this.call('/user/sign-in', { userId: email, password });
80
- const body = this.readBody(response.body, 'sign-in');
81
- const data = asRecord(body.data);
82
- const token = asRecord(data?.token);
83
- const accessToken = asNonEmptyString(token?.accessToken);
84
- if (accessToken === undefined || token === undefined || data === undefined) {
85
- // Deliberately not keyed on the status code. **This API answers a
86
- // rejected password with HTTP 200** and an error in the body, so a
87
- // status check alone classifies a wrong password as a transient
88
- // protocol fault, and then retries it every few seconds until the
89
- // account is locked. The presence of a token is the only reliable
90
- // discriminator, so that is what decides success here.
91
- throw this.signInFailure(response.status, body);
92
- }
93
- const accessKeyId = asNonEmptyString(token.accessKeyId);
94
- const secretKey = asNonEmptyString(token.secretKey);
95
- const sessionToken = asNonEmptyString(token.sessionToken);
96
- if (accessKeyId === undefined || secretKey === undefined || sessionToken === undefined) {
97
- throw new errors_1.ProtocolError('the sign-in response carried no AWS IoT credentials, so live status is not available');
98
- }
99
- const userSeq = readUserSeq(data);
100
- if (userSeq === undefined) {
101
- throw new errors_1.ProtocolError('the sign-in response carried no account identifier');
102
- }
103
- return {
104
- userSeq,
105
- accessToken,
106
- refreshToken: asNonEmptyString(token.refreshToken),
107
- credentials: {
108
- accessKeyId,
109
- secretKey,
110
- sessionToken,
111
- endpoint: settings_1.IOT_ENDPOINT,
112
- region: settings_1.IOT_REGION,
113
- },
114
- expiresAt: this.now() + this.sessionLifetimeMs(token),
115
- };
92
+ return this.guarded(async () => {
93
+ const response = await this.call('/user/sign-in', { userId: email, password });
94
+ const body = this.readBody(response.body, 'sign-in');
95
+ const data = asRecord(body.data);
96
+ const token = asRecord(data?.token);
97
+ const accessToken = asNonEmptyString(token?.accessToken);
98
+ if (accessToken === undefined || token === undefined || data === undefined) {
99
+ // Deliberately not keyed on the status code. **This API answers a
100
+ // rejected password with HTTP 200** and an error in the body, so a
101
+ // status check alone classifies a wrong password as a transient
102
+ // protocol fault, and then retries it every few seconds until the
103
+ // account is locked. The presence of a token is the only reliable
104
+ // discriminator, so that is what decides success here.
105
+ throw this.signInFailure(response.status, body);
106
+ }
107
+ const accessKeyId = asNonEmptyString(token.accessKeyId);
108
+ const secretKey = asNonEmptyString(token.secretKey);
109
+ const sessionToken = asNonEmptyString(token.sessionToken);
110
+ if (accessKeyId === undefined || secretKey === undefined || sessionToken === undefined) {
111
+ throw new errors_1.ProtocolError('the sign-in response carried no AWS IoT credentials, so live status is not available');
112
+ }
113
+ const userSeq = readUserSeq(data);
114
+ if (userSeq === undefined) {
115
+ throw new errors_1.ProtocolError('the sign-in response carried no account identifier');
116
+ }
117
+ return {
118
+ userSeq,
119
+ accessToken,
120
+ refreshToken: asNonEmptyString(token.refreshToken),
121
+ credentials: {
122
+ accessKeyId,
123
+ secretKey,
124
+ sessionToken,
125
+ endpoint: settings_1.IOT_ENDPOINT,
126
+ region: settings_1.IOT_REGION,
127
+ },
128
+ expiresAt: this.now() + this.sessionLifetimeMs(token),
129
+ };
130
+ });
116
131
  }
117
132
  /**
118
133
  * List the gateways on the account.
@@ -135,31 +150,33 @@ class NaviLinkRest {
135
150
  }
136
151
  /** One page of the device list, already parsed. */
137
152
  async listDevicePage(input, offset) {
138
- const response = await this.call('/device/list', { offset, count: DEVICE_LIST_PAGE_SIZE, userId: input.email }, input.accessToken);
139
- const body = this.readBody(response.body, 'device list');
140
- if (response.status === 401 || response.status === 403) {
141
- throw new errors_1.AuthenticationError('the cloud rejected the session token when listing devices');
142
- }
143
- if (response.status !== 200) {
144
- throw new errors_1.ProtocolError(`the device list failed: ${describeApiFailure(response.status, body)}`);
145
- }
146
- // Two shapes have been seen: a bare array, and an object wrapping one.
147
- // Both are accepted rather than one being declared correct, because there
148
- // is no specification to be right about.
149
- const data = body.data;
150
- const raw = Array.isArray(data)
151
- ? data
152
- : (asRecord(data)?.deviceList ?? asRecord(data)?.devices);
153
- if (!Array.isArray(raw)) {
154
- throw new errors_1.ProtocolError('the device list response did not contain a list of devices');
155
- }
156
- return {
157
- entries: raw.flatMap((entry) => {
158
- const device = this.readListedDevice(entry);
159
- return device === undefined ? [] : [device];
160
- }),
161
- isFull: raw.length >= DEVICE_LIST_PAGE_SIZE,
162
- };
153
+ return this.guarded(async () => {
154
+ const response = await this.call('/device/list', { offset, count: DEVICE_LIST_PAGE_SIZE, userId: input.email }, input.accessToken);
155
+ const body = this.readBody(response.body, 'device list');
156
+ if (response.status === 401 || response.status === 403) {
157
+ throw new errors_1.AuthenticationError('the cloud rejected the session token when listing devices');
158
+ }
159
+ if (response.status !== 200) {
160
+ throw new errors_1.ProtocolError(`the device list failed: ${describeApiFailure(response.status, body)}`);
161
+ }
162
+ // Two shapes have been seen: a bare array, and an object wrapping one.
163
+ // Both are accepted rather than one being declared correct, because there
164
+ // is no specification to be right about.
165
+ const data = body.data;
166
+ const raw = Array.isArray(data)
167
+ ? data
168
+ : (asRecord(data)?.deviceList ?? asRecord(data)?.devices);
169
+ if (!Array.isArray(raw)) {
170
+ throw new errors_1.ProtocolError('the device list response did not contain a list of devices');
171
+ }
172
+ return {
173
+ entries: raw.flatMap((entry) => {
174
+ const device = this.readListedDevice(entry);
175
+ return device === undefined ? [] : [device];
176
+ }),
177
+ isFull: raw.length >= DEVICE_LIST_PAGE_SIZE,
178
+ };
179
+ });
163
180
  }
164
181
  /**
165
182
  * Read a single device's detail.
@@ -170,6 +187,8 @@ class NaviLinkRest {
170
187
  * read, so they cannot reach a log, an accessory context or a capture.
171
188
  */
172
189
  async readFirmware(input) {
190
+ // Not behind the breaker. Firmware is optional, the session swallows
191
+ // failures, and a flaky device/info must not trip OPEN after MQTT is live.
173
192
  const response = await this.call('/device/info', {
174
193
  macAddress: input.macAddress,
175
194
  additionalValue: input.additionalValue,
@@ -209,6 +228,49 @@ class NaviLinkRest {
209
228
  throw error;
210
229
  }
211
230
  }
231
+ /**
232
+ * Run one REST logical attempt behind the circuit breaker.
233
+ *
234
+ * Pre-flight OPEN rejections do not count as API samples: nothing was sent.
235
+ * Connection and protocol errors trip the breaker; a rejected password does
236
+ * not. HALF_OPEN treats every terminal rejection as a failed probe so the
237
+ * slot cannot wedge.
238
+ */
239
+ async guarded(work) {
240
+ if (!this.circuitBreaker.canRequest()) {
241
+ const status = this.circuitBreaker.getStatus();
242
+ throw new errors_1.CircuitBreakerError(status.remainingResetTime ?? circuit_breaker_1.DEFAULT_CIRCUIT_BREAKER_CONFIG.resetTimeout);
243
+ }
244
+ if (this.circuitBreaker.state === circuit_breaker_1.CircuitState.HALF_OPEN) {
245
+ this.circuitBreaker.trackHalfOpenRequest();
246
+ }
247
+ try {
248
+ const result = await work();
249
+ this.circuitBreaker.recordSuccess();
250
+ return result;
251
+ }
252
+ catch (error) {
253
+ if (this.circuitBreaker.state === circuit_breaker_1.CircuitState.HALF_OPEN || isCircuitBreakerFailure(error)) {
254
+ this.circuitBreaker.recordFailure();
255
+ }
256
+ throw error;
257
+ }
258
+ }
259
+ /**
260
+ * Surface circuit-breaker transitions so operators can see when NaviLink
261
+ * REST is being treated as unavailable and when it recovers. OPEN is warn;
262
+ * HALF_OPEN (probe) and CLOSED (recovery) are info.
263
+ */
264
+ logCircuitTransition(from, to) {
265
+ const message = `Circuit breaker ${from} -> ${to}`;
266
+ if (to === circuit_breaker_1.CircuitState.OPEN) {
267
+ this.log.warn(message);
268
+ this.onCircuitOpen?.();
269
+ }
270
+ else {
271
+ this.log.info(message);
272
+ }
273
+ }
212
274
  readBody(text, what) {
213
275
  const parsed = (0, http_1.parseJsonBody)(text);
214
276
  const record = asRecord(parsed);
@@ -289,6 +351,14 @@ class NaviLinkRest {
289
351
  }
290
352
  }
291
353
  exports.NaviLinkRest = NaviLinkRest;
354
+ /**
355
+ * Errors that should count against the circuit breaker: the cloud could not
356
+ * be reached, or it answered with something we cannot parse. A rejected
357
+ * password is the user's problem, not service health.
358
+ */
359
+ function isCircuitBreakerFailure(error) {
360
+ return error instanceof errors_1.ConnectionError || error instanceof errors_1.ProtocolError;
361
+ }
292
362
  /** Describe an API failure without quoting a body that may hold a token. */
293
363
  function describeApiFailure(status, body) {
294
364
  const message = asNonEmptyString(body.msg);
@@ -13,8 +13,9 @@
13
13
  * - `rollup()` — `{ health, reasons[] }`
14
14
  *
15
15
  * NaviLink variant of the sibling collectors: REST sign-in plus an MQTT
16
- * session, no circuit breaker. It only reads in-memory state via `readers`;
17
- * it never touches the network.
16
+ * session. The REST circuit breaker is included so sustained cloud outages
17
+ * surface as `circuitBreakerOpen` in the health rollup. It only reads
18
+ * in-memory state via `readers`; it never touches the network.
18
19
  */
19
20
  import type { DeviceGauges, DiagnosticsSnapshot, MqttTransportState, NaviLinkPlatformConfig, SessionMetrics } from '../types';
20
21
  /**
@@ -28,6 +29,9 @@ export interface DiagnosticsReaders {
28
29
  tokenExpiresInSec: () => number | null;
29
30
  tokenLastRefreshAt: () => number | null;
30
31
  pollingCadenceSec: () => number;
32
+ circuitBreaker: () => {
33
+ state: string;
34
+ };
31
35
  }
32
36
  interface CollectorOptions {
33
37
  pluginVersion: string;
@@ -53,6 +57,8 @@ export declare class DiagnosticsCollector implements SessionMetrics {
53
57
  private mqttReconnects;
54
58
  private commands;
55
59
  private pushes;
60
+ private breakerTrips;
61
+ private lastTripAt;
56
62
  private lastPollDurationMs;
57
63
  private readonly latencies;
58
64
  private readonly recentOutcomes;
@@ -64,12 +70,15 @@ export declare class DiagnosticsCollector implements SessionMetrics {
64
70
  command(): void;
65
71
  sessionRefresh(): void;
66
72
  push(): void;
73
+ /** Record a circuit-breaker trip (transition into the open state). */
74
+ breakerTrip(): void;
67
75
  /** Nearest-rank percentile (0..100) over the recent-latency window. */
68
76
  percentile(p: number): number;
69
77
  /**
70
78
  * Classify current health. Degraded when the MQTT session has been down
71
- * longer than the grace window, credentials were rejected, or recent REST
72
- * calls are failing at a high rate.
79
+ * longer than the grace window, credentials were rejected, the REST
80
+ * circuit breaker is open or probing, or recent REST calls are failing
81
+ * at a high rate.
73
82
  */
74
83
  rollup(readers: DiagnosticsReaders): HealthRollup;
75
84
  buildHeartbeat(readers: DiagnosticsReaders): DiagnosticsSnapshot;
@@ -14,8 +14,9 @@
14
14
  * - `rollup()` — `{ health, reasons[] }`
15
15
  *
16
16
  * NaviLink variant of the sibling collectors: REST sign-in plus an MQTT
17
- * session, no circuit breaker. It only reads in-memory state via `readers`;
18
- * it never touches the network.
17
+ * session. The REST circuit breaker is included so sustained cloud outages
18
+ * surface as `circuitBreakerOpen` in the health rollup. It only reads
19
+ * in-memory state via `readers`; it never touches the network.
19
20
  */
20
21
  Object.defineProperty(exports, "__esModule", { value: true });
21
22
  exports.DiagnosticsCollector = void 0;
@@ -42,6 +43,8 @@ class DiagnosticsCollector {
42
43
  mqttReconnects = 0;
43
44
  commands = 0;
44
45
  pushes = 0;
46
+ breakerTrips = 0;
47
+ lastTripAt = null;
45
48
  lastPollDurationMs = null;
46
49
  latencies = [];
47
50
  recentOutcomes = [];
@@ -88,6 +91,11 @@ class DiagnosticsCollector {
88
91
  push() {
89
92
  this.pushes += 1;
90
93
  }
94
+ /** Record a circuit-breaker trip (transition into the open state). */
95
+ breakerTrip() {
96
+ this.breakerTrips += 1;
97
+ this.lastTripAt = this.now();
98
+ }
91
99
  /** Nearest-rank percentile (0..100) over the recent-latency window. */
92
100
  percentile(p) {
93
101
  if (this.latencies.length === 0) {
@@ -101,8 +109,9 @@ class DiagnosticsCollector {
101
109
  }
102
110
  /**
103
111
  * Classify current health. Degraded when the MQTT session has been down
104
- * longer than the grace window, credentials were rejected, or recent REST
105
- * calls are failing at a high rate.
112
+ * longer than the grace window, credentials were rejected, the REST
113
+ * circuit breaker is open or probing, or recent REST calls are failing
114
+ * at a high rate.
106
115
  */
107
116
  rollup(readers) {
108
117
  const reasons = [];
@@ -120,6 +129,10 @@ class DiagnosticsCollector {
120
129
  reasons.push('mqttDown');
121
130
  }
122
131
  }
132
+ const breakerState = readers.circuitBreaker().state;
133
+ if (breakerState === 'OPEN' || breakerState === 'HALF_OPEN') {
134
+ reasons.push('circuitBreakerOpen');
135
+ }
123
136
  const total = this.recentOutcomes.length;
124
137
  if (total >= API_ERROR_MIN_SAMPLES) {
125
138
  const errors = this.recentOutcomes.filter((ok) => !ok).length;
@@ -143,6 +156,7 @@ class DiagnosticsCollector {
143
156
  reconnects: current.mqttReconnects - this.marker.mqttReconnects,
144
157
  commands: current.commands - this.marker.commands,
145
158
  pushes: current.pushes - this.marker.pushes,
159
+ trips: current.breakerTrips - this.marker.breakerTrips,
146
160
  }, readers);
147
161
  this.marker = current;
148
162
  return report;
@@ -157,6 +171,7 @@ class DiagnosticsCollector {
157
171
  reconnects: this.mqttReconnects,
158
172
  commands: this.commands,
159
173
  pushes: this.pushes,
174
+ trips: this.breakerTrips,
160
175
  }, readers);
161
176
  report.config = { ...this.configEcho };
162
177
  return report;
@@ -174,6 +189,7 @@ class DiagnosticsCollector {
174
189
  mqttReconnects: this.mqttReconnects,
175
190
  commands: this.commands,
176
191
  pushes: this.pushes,
192
+ breakerTrips: this.breakerTrips,
177
193
  };
178
194
  }
179
195
  buildReport(msg, counters, readers) {
@@ -210,6 +226,11 @@ class DiagnosticsCollector {
210
226
  commands: counters.commands,
211
227
  pushes: counters.pushes,
212
228
  },
229
+ circuitBreaker: {
230
+ state: readers.circuitBreaker().state,
231
+ lastTripAt: this.lastTripAt,
232
+ trips: counters.trips,
233
+ },
213
234
  };
214
235
  }
215
236
  }
@@ -17,8 +17,10 @@ export declare function formatReasons(reasons: string[]): string;
17
17
  * Concise summary matching the sibling plugins:
18
18
  * `Health: healthy | devices 1/1 | mqtt live | api p50 12ms p95 40ms (req 3, err 0)`.
19
19
  *
20
- * The heartbeat uses the short `mqtt` token. Standalone lifecycle lines keep
21
- * `Publish-subscribe (mqtt)` so a lone `up` / `recovered` still names the channel.
20
+ * A non-CLOSED REST breaker inserts `breaker OPEN` (or `HALF_OPEN`) after
21
+ * devices, matching the sibling plugins. The heartbeat uses the short `mqtt`
22
+ * token. Standalone lifecycle lines keep `Publish-subscribe (mqtt)` so a
23
+ * lone `up` / `recovered` still names the channel.
22
24
  */
23
25
  export declare function formatDiagnosticLine(report: DiagnosticsSnapshot): string;
24
26
  /** State-only line for a healthy/degraded flip. The heartbeat already has the body. */
@@ -54,17 +54,22 @@ function formatReasons(reasons) {
54
54
  * Concise summary matching the sibling plugins:
55
55
  * `Health: healthy | devices 1/1 | mqtt live | api p50 12ms p95 40ms (req 3, err 0)`.
56
56
  *
57
- * The heartbeat uses the short `mqtt` token. Standalone lifecycle lines keep
58
- * `Publish-subscribe (mqtt)` so a lone `up` / `recovered` still names the channel.
57
+ * A non-CLOSED REST breaker inserts `breaker OPEN` (or `HALF_OPEN`) after
58
+ * devices, matching the sibling plugins. The heartbeat uses the short `mqtt`
59
+ * token. Standalone lifecycle lines keep `Publish-subscribe (mqtt)` so a
60
+ * lone `up` / `recovered` still names the channel.
59
61
  */
60
62
  function formatDiagnosticLine(report) {
61
- const { lifecycle, devices, transport, api } = report;
62
- return [
63
+ const { lifecycle, devices, circuitBreaker, transport, api } = report;
64
+ const parts = [
63
65
  `${diagnosticLabel(report.msg)}: ${lifecycle.health}${formatReasons(lifecycle.reasons)}`,
64
66
  `devices ${devices.online}/${devices.total}`,
65
- `mqtt ${formatMqttTransportState(transport.mqttState)}`,
66
- `api p50 ${api.p50Ms}ms p95 ${api.p95Ms}ms (req ${api.requests}, err ${api.errors})`,
67
- ].join(' | ');
67
+ ];
68
+ if (circuitBreaker.state !== 'CLOSED') {
69
+ parts.push(`breaker ${circuitBreaker.state}`);
70
+ }
71
+ parts.push(`mqtt ${formatMqttTransportState(transport.mqttState)}`, `api p50 ${api.p50Ms}ms p95 ${api.p95Ms}ms (req ${api.requests}, err ${api.errors})`);
72
+ return parts.join(' | ');
68
73
  }
69
74
  /** State-only line for a healthy/degraded flip. The heartbeat already has the body. */
70
75
  function formatHealthTransitionLine(report) {
package/dist/platform.js CHANGED
@@ -130,6 +130,7 @@ class NaviLinkPlatform {
130
130
  devices,
131
131
  statusIntervalSec: this.options.statusIntervalSec,
132
132
  metrics: this.diagnostics,
133
+ onCircuitOpen: () => this.diagnostics.breakerTrip(),
133
134
  });
134
135
  session.onObservation((deviceId, observation, reason) => {
135
136
  this.distribute(deviceId, observation, reason);
@@ -506,6 +507,9 @@ class NaviLinkPlatform {
506
507
  },
507
508
  tokenLastRefreshAt: () => health?.lastRefreshAt ?? null,
508
509
  pollingCadenceSec: () => this.options.statusIntervalSec,
510
+ circuitBreaker: () => ({
511
+ state: this.session?.circuitBreakerState() ?? 'CLOSED',
512
+ }),
509
513
  };
510
514
  }
511
515
  emitDiagnostic(level, report, options = {}) {
package/dist/session.d.ts CHANGED
@@ -72,6 +72,8 @@ export interface NaviLinkSessionOptions {
72
72
  now?: () => number;
73
73
  random?: () => number;
74
74
  metrics?: SessionMetrics;
75
+ /** Fired when REST trips OPEN, so diagnostics can count a trip. */
76
+ onCircuitOpen?: () => void;
75
77
  }
76
78
  /** Owns the plugin's entire relationship with the NaviLink cloud. */
77
79
  export declare class NaviLinkSession {
@@ -122,6 +124,8 @@ export declare class NaviLinkSession {
122
124
  onStale(listener: StaleListener): void;
123
125
  /** In-memory gauges for diagnostics. Never reads the network. */
124
126
  health(): SessionHealth;
127
+ /** REST circuit-breaker state for diagnostics. Never reads the network. */
128
+ circuitBreakerState(): string;
125
129
  /**
126
130
  * The current state of a device, or undefined when there is none to trust.
127
131
  *
@@ -159,6 +163,8 @@ export declare class NaviLinkSession {
159
163
  * readable and there is exactly one place that decides whether to try again.
160
164
  */
161
165
  private runForever;
166
+ /** Sleep the reconnect delay, abortable on shutdown. */
167
+ private waitToReconnect;
162
168
  /** Sign in, list devices, connect MQTT, subscribe, and ask for state. */
163
169
  private establish;
164
170
  /** Match the account's gateways against what the user configured. */
package/dist/session.js CHANGED
@@ -93,6 +93,7 @@ class NaviLinkSession {
93
93
  this.rest = options.rest ?? new rest_1.NaviLinkRest({
94
94
  log: options.log,
95
95
  signal: this.abort.signal,
96
+ ...(options.onCircuitOpen === undefined ? {} : { onCircuitOpen: options.onCircuitOpen }),
96
97
  ...(options.metrics === undefined
97
98
  ? {}
98
99
  : { metrics: (sample) => options.metrics?.apiRequest(sample.durationMs, sample.ok) }),
@@ -129,6 +130,10 @@ class NaviLinkSession {
129
130
  .map((device) => device.id),
130
131
  };
131
132
  }
133
+ /** REST circuit-breaker state for diagnostics. Never reads the network. */
134
+ circuitBreakerState() {
135
+ return this.rest.getCircuitBreakerStatus().state;
136
+ }
132
137
  /**
133
138
  * The current state of a device, or undefined when there is none to trust.
134
139
  *
@@ -258,8 +263,13 @@ class NaviLinkSession {
258
263
  this.stopPermanently(error);
259
264
  return;
260
265
  }
261
- attempt += 1;
262
266
  this.notifyUnreachable(error);
267
+ if (error instanceof utils_1.CircuitBreakerError) {
268
+ // The OPEN line already named the outage. Fail-fast until cooldown.
269
+ await this.waitToReconnect(Math.max(error.retryAfterMs, 1_000));
270
+ continue;
271
+ }
272
+ attempt += 1;
263
273
  this.log.warn(`session failed: ${(0, utils_1.describeError)(error)}`);
264
274
  }
265
275
  if (!this.running) {
@@ -272,12 +282,19 @@ class NaviLinkSession {
272
282
  maxMs: settings_1.RECONNECT_BACKOFF_MAX_MS,
273
283
  ...(this.options.random === undefined ? {} : { random: this.options.random }),
274
284
  });
275
- this.log.info(`reconnect in ${Math.round(delay / 1_000)}s`);
276
- const backoff = (0, utils_1.interruptibleSleep)(delay);
277
- this.backoffSleep = backoff;
278
- await backoff.promise;
279
- this.backoffSleep = undefined;
285
+ await this.waitToReconnect(delay);
286
+ }
287
+ }
288
+ /** Sleep the reconnect delay, abortable on shutdown. */
289
+ async waitToReconnect(delayMs) {
290
+ if (!this.running) {
291
+ return;
280
292
  }
293
+ this.log.info(`reconnect in ${Math.round(delayMs / 1_000)}s`);
294
+ const backoff = (0, utils_1.interruptibleSleep)(delayMs);
295
+ this.backoffSleep = backoff;
296
+ await backoff.promise;
297
+ this.backoffSleep = undefined;
281
298
  }
282
299
  /** Sign in, list devices, connect MQTT, subscribe, and ask for state. */
283
300
  async establish() {
@@ -307,6 +307,11 @@ export interface DiagnosticsSnapshot {
307
307
  commands: number;
308
308
  pushes: number;
309
309
  };
310
+ circuitBreaker: {
311
+ state: string;
312
+ lastTripAt: number | null;
313
+ trips: number;
314
+ };
310
315
  /** Redacted config echo, present only on boot/shutdown snapshots. */
311
316
  config?: Record<string, unknown>;
312
317
  }
@@ -75,3 +75,18 @@ export declare class ControlRejectedError extends Error {
75
75
  cause?: unknown;
76
76
  });
77
77
  }
78
+ /**
79
+ * The REST circuit breaker is open, so this call was not sent.
80
+ *
81
+ * Callers should wait {@link retryAfterMs} before trying again. The session
82
+ * treats this as a transient outage, not a rejected password.
83
+ */
84
+ export declare class CircuitBreakerError extends Error {
85
+ readonly code = "CIRCUIT_OPEN";
86
+ readonly isRetryable = true;
87
+ readonly resetTime: Date;
88
+ constructor(resetTimeMs: number, options?: {
89
+ cause?: Error;
90
+ });
91
+ get retryAfterMs(): number;
92
+ }
@@ -19,7 +19,7 @@
19
19
  * error message or it does not reach it at all.
20
20
  */
21
21
  Object.defineProperty(exports, "__esModule", { value: true });
22
- exports.ControlRejectedError = exports.ConnectionError = exports.ProtocolError = exports.AuthenticationError = void 0;
22
+ exports.CircuitBreakerError = exports.ControlRejectedError = exports.ConnectionError = exports.ProtocolError = exports.AuthenticationError = void 0;
23
23
  exports.describeError = describeError;
24
24
  const redact_1 = require("./redact");
25
25
  /** Longest description produced, so a hostile endpoint cannot flood the log. */
@@ -124,3 +124,24 @@ class ControlRejectedError extends Error {
124
124
  }
125
125
  }
126
126
  exports.ControlRejectedError = ControlRejectedError;
127
+ /**
128
+ * The REST circuit breaker is open, so this call was not sent.
129
+ *
130
+ * Callers should wait {@link retryAfterMs} before trying again. The session
131
+ * treats this as a transient outage, not a rejected password.
132
+ */
133
+ class CircuitBreakerError extends Error {
134
+ code = 'CIRCUIT_OPEN';
135
+ isRetryable = true;
136
+ resetTime;
137
+ constructor(resetTimeMs, options) {
138
+ const resetTime = new Date(Date.now() + resetTimeMs);
139
+ super(`Circuit breaker is open. Service unavailable until ${resetTime.toISOString()}`, options);
140
+ this.name = 'CircuitBreakerError';
141
+ this.resetTime = resetTime;
142
+ }
143
+ get retryAfterMs() {
144
+ return Math.max(0, this.resetTime.getTime() - Date.now());
145
+ }
146
+ }
147
+ exports.CircuitBreakerError = CircuitBreakerError;
package/docs/FEATURES.md CHANGED
@@ -28,6 +28,7 @@ A checklist of what is built. The plugin aims to cover everything about a Navien
28
28
  - ✅ **Never loses your rooms:** a broken config disables the platform without unregistering anything (`platform disabled; cached accessories kept`)
29
29
  - ✅ Control rate-limited per gateway. When the cloud answers `failCode 2`, the log is `rate limited` and the plugin pauses
30
30
  - ✅ Exponential backoff with jitter on reconnect, to a capped ceiling. A real drop says so once, then `reconnect in Ns`
31
+ - ✅ REST circuit breaker for sustained cloud outages: fail-fast while open; a single half-open probe after cooldown. Transitions log as `Circuit breaker CLOSED -> OPEN` (warn) and `HALF_OPEN` / `CLOSED` (info). Firmware reads do not trip it.
31
32
  - ✅ Accessory identity is `{gateway MAC}:{channel}:{kind}`, never the address or the name, so nothing about your network or your naming can orphan a tile
32
33
  - ✅ Cached accessories adopted by identity, never replaced, so rooms, scenes and automations survive
33
34
  - ✅ An unusable configuration disables the platform and keeps every accessory registered. Nothing is deleted
@@ -35,7 +36,7 @@ A checklist of what is built. The plugin aims to cover everything about a Navien
35
36
  - ✅ Read-only mode: every accessory reports state, and no control command is ever sent (`readOnly; write ignored`)
36
37
  - ✅ Power-off guard, on by default, because a combi's power state governs central heating as well as hot water (`power-off disabled (allowPowerOff is off)`)
37
38
  - ✅ Accessory name prefix (`options.accessoryPrefix`): one editable stem for every HomeKit tile, without changing accessory identity
38
- - ✅ Opt-in diagnostics (`options.diagnosticsInterval`, default 0 / off): a periodic heartbeat (`Health: healthy | devices n/n | mqtt live | api p50/p95 (req, err)`), with optional structured JSON (`options.structuredLogs`)
39
+ - ✅ Opt-in diagnostics (`options.diagnosticsInterval`, default 0 / off): a periodic heartbeat (`Health: healthy | devices n/n | mqtt live | api p50/p95 (req, err)`), with `breaker OPEN` / `breaker HALF_OPEN` only when the REST circuit is not closed, and optional structured JSON (`options.structuredLogs`)
39
40
  - ✅ Secrets redacted from every log line by shape, not by field name, so an unfamiliar response cannot leak a token
40
41
  - ✅ HomeKit serial numbers are opaque generated values, never the gateway MAC
41
42
  - ✅ Bounded I/O: separate connect and total timeouts, a capped response size, and a capped MQTT packet size
package/docs/PROTOCOL.md CHANGED
@@ -30,6 +30,8 @@ A rejected password is **not** a 401. It is a 200 with the failure in the body.
30
30
 
31
31
  `src/api/rest.ts` maps `USER_NOT_FOUND` and `INVALID_USER_PASSWORD` to a fatal error that stops the session for good. Anything else stays retryable, because guessing that an unfamiliar error is a bad credential would stop the plugin recovering from something temporary.
32
32
 
33
+ Retryable is not the same as hammering. Sign-in and the device list sit behind a circuit breaker: five connection or protocol failures in a minute open it (`Circuit breaker CLOSED -> OPEN`), later calls fail fast until a cooldown, then a single probe. A rejected password does not trip it. Firmware (`device/info`) is not behind it either: that read is optional, the session swallows failures, and a flaky info endpoint must not open the breaker after MQTT is already live.
34
+
33
35
  ### There is no usable token refresh
34
36
 
35
37
  The sign-in response carries a `refreshToken`, and `/auth/refresh` exists on the v2.1 path. Neither helps: the AWS IoT credentials are what the MQTT connection actually needs, and no refresh path has been found that reissues them.
@@ -108,6 +108,8 @@ An appliance with no probe reports `0`. The plugin treats that as absent, not as
108
108
 
109
109
  **Backoff has jitter.** Reconnects use exponential backoff with full jitter to a capped ceiling, so a regional outage does not produce a synchronised stampede when it ends. A real drop logs the close, then `reconnect in Ns`, then `Publish-subscribe (mqtt) recovered` when the session is up again.
110
110
 
111
+ **REST outages fail fast.** After five connection or protocol failures in a minute, the REST circuit breaker opens (`Circuit breaker CLOSED -> OPEN` at warn). Sign-in is not sent again until a cooldown, then a single probe: `HALF_OPEN` and `CLOSED` at info if the cloud answers. A rejected password does not trip it, and a firmware read does not either. MQTT stays on the session reconnect loop.
112
+
111
113
  ## Full configuration reference
112
114
 
113
115
  ### Platform options
@@ -234,6 +236,17 @@ Publish-subscribe (mqtt) recovered
234
236
 
235
237
  A continuing outage repeats at most hourly, at debug in between.
236
238
 
239
+ A REST cloud outage, as opposed to a broker drop:
240
+
241
+ ```
242
+ Circuit breaker CLOSED -> OPEN
243
+ reconnect in 30s
244
+ Circuit breaker OPEN -> HALF_OPEN
245
+ Circuit breaker HALF_OPEN -> CLOSED
246
+ ```
247
+
248
+ The OPEN line is the warn. While the breaker is open, sign-in is not sent. Diagnostics, if on, add `breaker OPEN` to the heartbeat until the probe succeeds.
249
+
237
250
  A credential refresh is not an outage. The plugin closes the socket itself a few minutes before the AWS credentials expire, signs in again, and the tiles stay current. That is debug only. `family=`, firmware and `Publish-subscribe (mqtt) up` do not repeat at info.
238
251
 
239
252
  ### Diagnostics (optional)
@@ -246,13 +259,19 @@ It pairs with `options.structuredLogs: true`, which adds a JSON line next to the
246
259
  Health: healthy | devices 1/1 | mqtt live | api p50 80ms p95 120ms (req 3, err 0)
247
260
  ```
248
261
 
262
+ When the REST breaker is not closed, it appears on that line:
263
+
264
+ ```
265
+ Health: degraded [circuitBreakerOpen] | devices 1/1 | breaker OPEN | mqtt connecting | api p50 80ms p95 120ms (req 3, err 5)
266
+ ```
267
+
249
268
  | `msg` | Level | When |
250
269
  | --- | --- | --- |
251
270
  | `diagnostics.start` / `diagnostics.stop` | info | Boot and shutdown, with a redacted config echo |
252
271
  | `health` | info | Every `diagnosticsInterval` seconds |
253
272
  | `health.degraded` / `health.recovered` | warn / info | When the rollup flips |
254
273
 
255
- Health is `degraded` when the MQTT session has been down for more than a minute, the account was rejected, or recent REST calls are failing at a high rate. Counters on a `health` line are per-interval deltas; the start/stop snapshots are session totals. Credentials, appliance names and the accessory prefix are never in the JSON (the prefix is a boolean: set or not).
274
+ Health is `degraded` when the MQTT session has been down for more than a minute, the account was rejected, the REST circuit breaker is open or probing (`circuitBreakerOpen`), or recent REST calls are failing at a high rate. A non-CLOSED breaker also appears on the human line as `breaker OPEN` or `breaker HALF_OPEN`. Counters on a `health` line are per-interval deltas; the start/stop snapshots are session totals. Credentials, appliance names and the accessory prefix are never in the JSON (the prefix is a boolean: set or not).
256
275
 
257
276
  ## Apple Shortcuts
258
277
 
@@ -288,7 +307,7 @@ The plugin has stopped trying on purpose, so the account is not locked out. Sign
288
307
  The account does not exist. Check for a typo, and note that the plugin trims surrounding whitespace but cannot fix a wrong address.
289
308
 
290
309
  **3. Everything shows No Response and stays that way.**
291
- Look for `platform disabled` in the log. That means the configuration could not be read; the error above it says which part.
310
+ Look for `platform disabled` in the log. That means the configuration could not be read; the error above it says which part. `Circuit breaker CLOSED -> OPEN` is a cloud outage, not a config error; see 13.
292
311
 
293
312
  **4. Accessories exist but never get a reading.**
294
313
  Check the gateway shows as connected in the NaviLink app. The plugin cannot see anything the app cannot.
@@ -317,7 +336,10 @@ Expected. The appliance clamps to the range your installer set, and a Fahrenheit
317
336
  **12. A tile lost its room after an update.**
318
337
  Should not happen; identity is stable by design. If `id` was edited by hand, that will do it. Otherwise please open an issue, because it is a bug.
319
338
 
320
- **13. It all stops when the internet does.**
339
+ **13. `Circuit breaker CLOSED -> OPEN`**
340
+ NaviLink REST is being treated as down. Sign-in is paused until the cooldown; tiles stay No Response. When the cloud answers again the log is `HALF_OPEN` then `CLOSED`. A firmware line that never arrives is not this: firmware is optional and does not trip the breaker.
341
+
342
+ **14. It all stops when the internet does.**
321
343
  There is no local interface on a NaviLink gateway. Nothing can be done about this from here.
322
344
 
323
345
  ## Security
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "homebridge-navilink",
3
3
  "displayName": "Homebridge NaviLink",
4
- "version": "1.0.2",
4
+ "version": "1.0.3",
5
5
  "description": "Homebridge plugin for Navien combi boilers and water heaters through the North American NaviLink cloud: hot water and space-heating thermostats, power, recirculation, fault and temperature accessories. Verified on an NCB-240E (firmware 4352)",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",