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 +7 -0
- package/README.md +30 -8
- package/dist/api/circuit-breaker.d.ts +111 -0
- package/dist/api/circuit-breaker.js +221 -0
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/rest.d.ts +22 -0
- package/dist/api/rest.js +132 -62
- package/dist/diagnostics/collector.d.ts +13 -4
- package/dist/diagnostics/collector.js +25 -4
- package/dist/diagnostics/format.d.ts +4 -2
- package/dist/diagnostics/format.js +12 -7
- package/dist/platform.js +4 -0
- package/dist/session.d.ts +6 -0
- package/dist/session.js +23 -6
- package/dist/types/index.d.ts +5 -0
- package/dist/utils/errors.d.ts +15 -0
- package/dist/utils/errors.js +22 -1
- package/docs/FEATURES.md +2 -1
- package/docs/PROTOCOL.md +2 -0
- package/docs/README-DETAILED.md +25 -3
- package/package.json +1 -1
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+

|
|
6
|
+

|
|
7
|
+

|
|
8
|
+

|
|
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
|
|
137
|
-
4. **A recirculation switch that is not offered.** No pump is fitted, or it is not commissioned
|
|
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;
|
package/dist/api/index.d.ts
CHANGED
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);
|
package/dist/api/rest.d.ts
CHANGED
|
@@ -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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
17
|
-
*
|
|
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,
|
|
72
|
-
*
|
|
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
|
|
18
|
-
*
|
|
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,
|
|
105
|
-
*
|
|
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
|
-
*
|
|
21
|
-
*
|
|
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
|
-
*
|
|
58
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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.
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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() {
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/utils/errors.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/utils/errors.js
CHANGED
|
@@ -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.
|
package/docs/README-DETAILED.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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",
|