homebridge-navilink 0.1.0 → 0.1.2
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 +18 -0
- package/README.md +33 -32
- package/SECURITY.md +3 -2
- package/config.schema.json +22 -1
- package/dist/api/mqtt.js +2 -2
- package/dist/api/rest.d.ts +6 -0
- package/dist/api/rest.js +24 -15
- package/dist/devices/base-accessory.d.ts +2 -2
- package/dist/devices/base-accessory.js +5 -7
- package/dist/devices/dhw-accessory.js +1 -1
- package/dist/devices/fault-accessory.js +3 -4
- package/dist/devices/heating-accessory.js +3 -6
- package/dist/devices/power-accessory.js +2 -2
- package/dist/devices/probe-accessory.js +5 -5
- package/dist/devices/recirculation-accessory.js +5 -8
- package/dist/devices/thermostat-accessory.js +4 -5
- package/dist/diagnostics/collector.d.ts +81 -0
- package/dist/diagnostics/collector.js +230 -0
- package/dist/diagnostics/format.d.ts +22 -0
- package/dist/diagnostics/format.js +70 -0
- package/dist/discovery.js +5 -6
- package/dist/platform.d.ts +10 -0
- package/dist/platform.js +128 -18
- package/dist/session.d.ts +32 -3
- package/dist/session.js +137 -35
- package/dist/settings.d.ts +19 -0
- package/dist/settings.js +20 -1
- package/dist/types/index.d.ts +84 -0
- package/dist/utils/errors.d.ts +8 -0
- package/dist/utils/errors.js +8 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/labels.d.ts +18 -0
- package/dist/utils/labels.js +26 -0
- package/dist/utils/redact.d.ts +3 -3
- package/dist/utils/redact.js +3 -3
- package/dist/utils/validators.d.ts +22 -1
- package/dist/utils/validators.js +90 -21
- package/docs/FEATURES.md +34 -23
- package/docs/PROTOCOL.md +1 -1
- package/docs/README-DETAILED.md +65 -33
- package/homebridge-ui/public/index.html +32 -0
- package/homebridge-ui/public/index.js +26 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.2](https://github.com/tbaur/homebridge-navilink/compare/v0.1.1...v0.1.2) (2026-09-20)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add accessory name prefix and opt-in diagnostics ([#6](https://github.com/tbaur/homebridge-navilink/issues/6)) ([d18f3cd](https://github.com/tbaur/homebridge-navilink/commit/d18f3cd0890611e8ac596b182f01086a8149d5d7))
|
|
9
|
+
|
|
10
|
+
## [0.1.1](https://github.com/tbaur/homebridge-navilink/compare/v0.1.0...v0.1.1) (2026-09-19)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* align the version-bump table with release-please before 1.0 ([#2](https://github.com/tbaur/homebridge-navilink/issues/2)) ([b35aea7](https://github.com/tbaur/homebridge-navilink/commit/b35aea7c30fd23de88c0dd81d0a23119ee6fd20b))
|
|
16
|
+
* drop the removed OSV --skip-git flag ([#4](https://github.com/tbaur/homebridge-navilink/issues/4)) ([655cf3b](https://github.com/tbaur/homebridge-navilink/commit/655cf3beededed5eafbd0ac0f7dc21e4c18c702a))
|
|
17
|
+
* read FirmwareRevision from package.json in tests ([#5](https://github.com/tbaur/homebridge-navilink/issues/5)) ([d263493](https://github.com/tbaur/homebridge-navilink/commit/d2634938622739d1adf559bd0c099c0305be915d))
|
|
18
|
+
|
|
19
|
+
## Changelog
|
|
20
|
+
|
|
3
21
|
All notable changes to this project are documented here. This file is generated by
|
|
4
22
|
[release-please](https://github.com/googleapis/release-please) from Conventional Commit
|
|
5
23
|
messages; do not edit it by hand. See [RELEASING.md](RELEASING.md).
|
package/README.md
CHANGED
|
@@ -1,31 +1,29 @@
|
|
|
1
1
|
# homebridge-navilink
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
     
|
|
4
4
|
|
|
5
5
|
**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).
|
|
6
6
|
|
|
7
7
|
Scheduling, weekly programmes and commissioning stay in the NaviLink app, which does them properly. This plugin adds the tile, the scene, the automation and the spoken command. It also adds a fault sensor that tells you the boiler has stopped before the shower does.
|
|
8
8
|
|
|
9
|
-
> **This plugin talks to Navien's cloud, not to your boiler.** There is no local API on a NaviLink gateway. It needs your NaviLink account. See [Supported devices](#supported-devices) and [Security](#security) before you install it.
|
|
10
|
-
|
|
11
9
|
## Features
|
|
12
10
|
|
|
13
11
|
### Per appliance
|
|
14
12
|
|
|
15
|
-
- **Hot water thermostat:** the domestic hot water setpoint, reading the actual outlet temperature
|
|
16
|
-
- **Heating thermostat:** the space-heating flow temperature. This is the boiler's water temperature
|
|
17
|
-
- **Power switch:** on and off, with switching *off* refused by default because it stops central heating
|
|
18
|
-
- **Recirculation switch:** starts the on-demand pump, so hot water reaches the tap without running it first. Only offered where a pump is fitted.
|
|
19
|
-
- **Fault sensor:** a contact sensor that opens on an error code, so HomeKit can notify you
|
|
20
|
-
- **Temperature sensors:** hot water in and out, heating flow and return, and the outdoor probe
|
|
13
|
+
- **Hot water thermostat:** the domestic hot water setpoint, reading the actual outlet temperature
|
|
14
|
+
- **Heating thermostat:** the space-heating flow temperature. This is the boiler's water temperature
|
|
15
|
+
- **Power switch:** on and off, with switching *off* refused by default because it stops central heating
|
|
16
|
+
- **Recirculation switch:** starts the on-demand pump, so hot water reaches the tap without running it first. Only offered where a pump is fitted.
|
|
17
|
+
- **Fault sensor:** a contact sensor that opens and logs on an error code, so HomeKit can notify you
|
|
18
|
+
- **Temperature sensors:** hot water in and out, heating flow and return, and the optional outdoor probe
|
|
21
19
|
|
|
22
20
|
### Reliability
|
|
23
21
|
|
|
24
22
|
- **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
|
|
25
23
|
- **A rejected password is never retried.** Repeating a wrong password at a cloud is how an account gets locked. The plugin stops, says so once, and waits for you
|
|
26
|
-
- **Credentials are renewed on a clock,** a few minutes before they expire
|
|
27
|
-
- **A connected socket is not a live appliance.** Readings carry the time they were taken, and a stale one becomes No Response instead
|
|
28
|
-
- **
|
|
24
|
+
- **Credentials are renewed on a clock,** a few minutes before they expire
|
|
25
|
+
- **A connected socket is not a live appliance.** Readings carry the time they were taken, and a stale one becomes No Response instead
|
|
26
|
+
- **State:** No Response until the appliance has actually been read, and not a value it cannot confirm
|
|
29
27
|
- **Never loses your rooms:** a broken config disables the platform without unregistering anything
|
|
30
28
|
|
|
31
29
|
### Quality
|
|
@@ -91,19 +89,19 @@ Accessories appear in the Home app after restart, showing No Response for the fe
|
|
|
91
89
|
|
|
92
90
|
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.
|
|
93
91
|
|
|
94
|
-
| Verified against
|
|
95
|
-
|
|
92
|
+
| Verified against | Notes |
|
|
93
|
+
| ----------------- | ------------------------------------------------- |
|
|
96
94
|
| **NCB-240E (NG)** | Combi: hot water and space heating, firmware 4352 |
|
|
97
95
|
|
|
98
96
|
Other families are decoded from the same table the NaviLink app uses and are expected to work, but nobody has confirmed them here:
|
|
99
97
|
|
|
100
|
-
| Family
|
|
101
|
-
|
|
98
|
+
| Family | Expected |
|
|
99
|
+
| ------------------ | ------------------------------------------------------------------------------ |
|
|
102
100
|
| **NPE, NPE2, NPN** | Tankless water heaters. Hot water only; the heating thermostat disables itself |
|
|
103
|
-
| **NCB-H**
|
|
104
|
-
| **NHB**
|
|
105
|
-
| **NFB, NFC**
|
|
106
|
-
| **NVW**
|
|
101
|
+
| **NCB-H** | Combi with a buffer tank |
|
|
102
|
+
| **NHB** | Boiler. Space heating only |
|
|
103
|
+
| **NFB, NFC** | Boilers with hot water and space heating |
|
|
104
|
+
| **NVW** | Water heater with a tank |
|
|
107
105
|
|
|
108
106
|
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.
|
|
109
107
|
|
|
@@ -111,31 +109,34 @@ If yours is not listed, it will most likely work. Please open an issue with the
|
|
|
111
109
|
|
|
112
110
|
Only one `NaviLink` platform block is supported. It can hold as many appliances as the account owns.
|
|
113
111
|
|
|
114
|
-
| Option
|
|
115
|
-
|
|
116
|
-
| `name`
|
|
117
|
-
| `email`
|
|
118
|
-
| `password`
|
|
119
|
-
| `devices`
|
|
120
|
-
| `options.statusIntervalSec`
|
|
121
|
-
| `options.readOnly`
|
|
122
|
-
| `options.allowPowerOff`
|
|
112
|
+
| Option | Required to run | Description |
|
|
113
|
+
| ----------------------------- | --------------- | ------------------------------------------------------------------------------- |
|
|
114
|
+
| `name` | ✓ | Plugin instance name shown in Homebridge logs |
|
|
115
|
+
| `email` | ✓ | Your NaviLink account address. Needed before the plugin signs in |
|
|
116
|
+
| `password` | ✓ | Your NaviLink password. Needed before the plugin signs in |
|
|
117
|
+
| `devices` | ✓ | List of appliances. Needed before the plugin signs in |
|
|
118
|
+
| `options.statusIntervalSec` | | Backstop refresh, 30–3600 seconds (default 120) |
|
|
119
|
+
| `options.readOnly` | | Report everything, change nothing (default false) |
|
|
120
|
+
| `options.allowPowerOff` | | Let HomeKit switch the appliance off (default false) |
|
|
121
|
+
| `options.diagnosticsInterval` | | Seconds between health lines in the log; `0` is off (default), else `30`–`3600` |
|
|
122
|
+
| `options.structuredLogs` | | With diagnostics, also emit a JSON line (default false) |
|
|
123
|
+
| `options.accessoryPrefix` | | Prefix for every HomeKit name. Blank uses each appliance's name |
|
|
123
124
|
|
|
124
125
|
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.
|
|
125
126
|
|
|
126
127
|
## Not Working?
|
|
127
128
|
|
|
128
129
|
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
|
|
129
|
-
2. **Everything shows No Response.** Check the log for `
|
|
130
|
+
2. **Everything shows No Response.** Check the log for `platform disabled`, which means the configuration could not be read
|
|
130
131
|
3. **The heating thermostat says it has no loop.** Your appliance reports no space-heating circuit. Turn the accessory off in the settings
|
|
131
132
|
4. **A recirculation switch that is not offered.** No pump is fitted, or it is not commissioned. Check the NaviLink app
|
|
132
|
-
5.
|
|
133
|
+
5. **`rate limited`.** The cloud rate-limits control. The plugin pauses and retries on the next press
|
|
133
134
|
|
|
134
135
|
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.
|
|
135
136
|
|
|
136
137
|
## Security
|
|
137
138
|
|
|
138
|
-
This plugin needs your NaviLink password, stores it in the Homebridge configuration file as every Homebridge credential is stored, and sends it to Navien to sign in. It is never written to the Homebridge log, never put into an accessory's cache, and redacted from anything the plugin prints. That is the
|
|
139
|
+
This plugin needs your NaviLink password, stores it in the Homebridge configuration file as every Homebridge credential is stored, and sends it to Navien to sign in. It is never written to the Homebridge log, never put into an accessory's cache, and redacted from anything the plugin prints. That is the summary; [SECURITY.md](SECURITY.md) has the detail, including what a capture contains and why the fixtures in this repository are pseudonymised.
|
|
139
140
|
|
|
140
141
|
## Requirements
|
|
141
142
|
|
package/SECURITY.md
CHANGED
|
@@ -26,15 +26,16 @@ If those are not acceptable, this plugin is not for you, and that is a reasonabl
|
|
|
26
26
|
|
|
27
27
|
- **Credentials in one place.** The password is read from configuration, used to sign in, and held only in memory. It is never written to a log, never placed in an accessory's cache, never sent anywhere except Navien's sign-in endpoint over HTTPS, and never passed to a script as a command-line argument.
|
|
28
28
|
- **Redaction by shape, not by name.** `src/utils/redact.ts` matches JWTs, AWS key ids, signed-URL parameters and credential-shaped JSON fields by their *form*. A field name list would miss a token in a response shape nobody anticipated; this does not.
|
|
29
|
-
- **Masked identifiers in the log.** The account address appears as `s…e@example.com
|
|
29
|
+
- **Masked identifiers in the log.** The account address is debug only, and appears as `s…e@example.com`. Appliance lines use the name you configured. A gateway MAC is logged only as a fallback, as `gateway …E5F6` (last four characters). Discovery warnings use that fallback too: the cloud's own appliance label is often a room, so it stays on the settings page and out of the log. Both the configured name and the masked MAC identify you, and a Homebridge log gets pasted into issues. `options.accessoryPrefix` is a room-shaped name: it is length-capped and stripped of control characters the same way, and it never appears in structured diagnostics (those emit a boolean: set or not).
|
|
30
30
|
- **The accessory cache holds identity, not credentials.** Homebridge persists `{mac}:{channel}` as `deviceId` so a restart adopts the same tile. That file is plain JSON and is included in diagnostic bundles, so it must never hold the password, a token or a signed AWS URL. The MAC is the appliance's address in every MQTT topic. HomeKit serial numbers are opaque generated values, not the MAC.
|
|
31
31
|
- **HomeKit serial numbers are opaque generated values,** not the gateway MAC. The Home app displays them, so they reach screenshots.
|
|
32
|
-
- **Input validation.** Config is checked at startup. A missing account, or a `devices` value that is not a list, disables the platform without unregistering accessories. A bad appliance entry is skipped.
|
|
32
|
+
- **Input validation.** Config is checked at startup. A missing account, or a `devices` value that is not a list, disables the platform without unregistering accessories. A bad appliance entry is skipped. Appliance names and `options.accessoryPrefix` are stripped of control characters and length-capped before they reach a log line or a characteristic.
|
|
33
33
|
- **Bounded I/O.** Separate connect and total timeouts on every request, a capped HTTP response size, and a capped MQTT packet size, so a hostile or broken response cannot exhaust memory.
|
|
34
34
|
- **A rejected credential stops the session.** Retrying a wrong password is how an account gets locked; the plugin declines to do it.
|
|
35
35
|
- **Minimal dependencies.** One at runtime: Homebridge's own UI helper. The MQTT client is in this repository, for a protocol reason ([PROTOCOL.md](docs/PROTOCOL.md)) that also reduces supply-chain surface.
|
|
36
36
|
- **Dependencies audited.** CI runs `npm audit` on the runtime tree and OSV-Scanner on the full tree.
|
|
37
37
|
- **A CI step rejects credential-shaped strings** in the published docs, fixtures, schema and issue templates, so a token cannot be committed by accident. Unit tests that hold fake JWTs are outside that scan on purpose.
|
|
38
|
+
- **Opt-in diagnostics are logs only.** `options.diagnosticsInterval` writes a periodic health line from in-memory counters. It never makes a network call, never appears in HomeKit, and the structured JSON (`options.structuredLogs`) omits credentials, appliance names and the accessory prefix string.
|
|
38
39
|
|
|
39
40
|
## The settings page
|
|
40
41
|
|
package/config.schema.json
CHANGED
|
@@ -53,6 +53,27 @@
|
|
|
53
53
|
"type": "boolean",
|
|
54
54
|
"default": false,
|
|
55
55
|
"description": "Off by default, and worth leaving off. On a combi appliance the power state governs central heating as well as hot water, so \"turn off the water heater\" said to Siri, or a scene that turns everything off, would stop the heating in a house that may be empty and freezing. With this off, a power-off request is declined and explained instead of obeyed."
|
|
56
|
+
},
|
|
57
|
+
"diagnosticsInterval": {
|
|
58
|
+
"title": "Diagnostics Interval (seconds)",
|
|
59
|
+
"type": "integer",
|
|
60
|
+
"default": 0,
|
|
61
|
+
"minimum": 0,
|
|
62
|
+
"maximum": 3600,
|
|
63
|
+
"description": "Emit a plugin health line to the Homebridge log every N seconds. 0 is off (the default). Allowed range 0–3600; values between 1 and 29 are clamped up to 30. Logs only; nothing is exposed in HomeKit."
|
|
64
|
+
},
|
|
65
|
+
"structuredLogs": {
|
|
66
|
+
"title": "Structured (JSON) Logs",
|
|
67
|
+
"type": "boolean",
|
|
68
|
+
"default": false,
|
|
69
|
+
"description": "When diagnostics are on (diagnosticsInterval > 0), also emit each report as a JSON line next to the human summary. Has no effect while diagnostics are off."
|
|
70
|
+
},
|
|
71
|
+
"accessoryPrefix": {
|
|
72
|
+
"title": "Accessory Name Prefix",
|
|
73
|
+
"type": "string",
|
|
74
|
+
"maxLength": 64,
|
|
75
|
+
"examples": ["Zone One"],
|
|
76
|
+
"description": "HomeKit tiles become this plus the accessory: \"Zone One Hot Water\", \"Zone One Heating\", and so on. Leave blank to use each appliance's own name. Changing it renames the tiles; it does not detach rooms or automations."
|
|
56
77
|
}
|
|
57
78
|
}
|
|
58
79
|
},
|
|
@@ -78,7 +99,7 @@
|
|
|
78
99
|
"type": "string",
|
|
79
100
|
"maxLength": 64,
|
|
80
101
|
"examples": ["Zone One"],
|
|
81
|
-
"description": "The appliance's name. Accessories are named from it: \"Boiler\" gives \"Boiler Hot Water\", \"Boiler Heating\", \"Boiler Fault\" and so on."
|
|
102
|
+
"description": "The appliance's name. Accessories are named from it unless options.accessoryPrefix is set: \"Boiler\" gives \"Boiler Hot Water\", \"Boiler Heating\", \"Boiler Fault\" and so on."
|
|
82
103
|
},
|
|
83
104
|
"channel": {
|
|
84
105
|
"title": "Channel",
|
package/dist/api/mqtt.js
CHANGED
|
@@ -151,7 +151,7 @@ class MqttConnection {
|
|
|
151
151
|
this.socket = open(url, {
|
|
152
152
|
onOpen: () => this.handleOpen(),
|
|
153
153
|
onMessage: (data) => this.handleData(data),
|
|
154
|
-
onClose: (reason) => this.fail(new errors_1.ConnectionError(`
|
|
154
|
+
onClose: (reason) => this.fail(new errors_1.ConnectionError(`NaviLink broker closed the connection (${reason})`)),
|
|
155
155
|
onError: (error) => this.fail(error),
|
|
156
156
|
});
|
|
157
157
|
return handshake;
|
|
@@ -210,7 +210,7 @@ class MqttConnection {
|
|
|
210
210
|
if (this.connected) {
|
|
211
211
|
this.trySend((0, mqtt_codec_1.encodeDisconnect)());
|
|
212
212
|
}
|
|
213
|
-
this.fail(new errors_1.ConnectionError('the connection was closed by this plugin'), { expected: true });
|
|
213
|
+
this.fail(new errors_1.ConnectionError('the connection was closed by this plugin', { expected: true }), { expected: true });
|
|
214
214
|
}
|
|
215
215
|
// --- Internals ------------------------------------------------------------
|
|
216
216
|
handleOpen() {
|
package/dist/api/rest.d.ts
CHANGED
|
@@ -62,6 +62,11 @@ export interface NaviLinkRestOptions {
|
|
|
62
62
|
now?: () => number;
|
|
63
63
|
/** Cancels in-flight calls, so Homebridge shutdown need not wait out a deadline. */
|
|
64
64
|
signal?: AbortSignal;
|
|
65
|
+
/** One sample per REST attempt, for diagnostics. Never receives the body. */
|
|
66
|
+
metrics?: (sample: {
|
|
67
|
+
durationMs: number;
|
|
68
|
+
ok: boolean;
|
|
69
|
+
}) => void;
|
|
65
70
|
}
|
|
66
71
|
/** Talks to the NaviLink REST service. */
|
|
67
72
|
export declare class NaviLinkRest {
|
|
@@ -69,6 +74,7 @@ export declare class NaviLinkRest {
|
|
|
69
74
|
private readonly post;
|
|
70
75
|
private readonly now;
|
|
71
76
|
private readonly signal;
|
|
77
|
+
private readonly metrics;
|
|
72
78
|
constructor(options: NaviLinkRestOptions);
|
|
73
79
|
/**
|
|
74
80
|
* Exchange an email and password for a session.
|
package/dist/api/rest.js
CHANGED
|
@@ -54,11 +54,13 @@ class NaviLinkRest {
|
|
|
54
54
|
post;
|
|
55
55
|
now;
|
|
56
56
|
signal;
|
|
57
|
+
metrics;
|
|
57
58
|
constructor(options) {
|
|
58
59
|
this.log = options.log;
|
|
59
60
|
this.post = options.post ?? http_1.postJson;
|
|
60
61
|
this.now = options.now ?? Date.now;
|
|
61
62
|
this.signal = options.signal;
|
|
63
|
+
this.metrics = options.metrics;
|
|
62
64
|
}
|
|
63
65
|
/**
|
|
64
66
|
* Exchange an email and password for a session.
|
|
@@ -128,8 +130,7 @@ class NaviLinkRest {
|
|
|
128
130
|
return found;
|
|
129
131
|
}
|
|
130
132
|
}
|
|
131
|
-
this.log.warn(`
|
|
132
|
-
+ 'which is this plugin\'s cap; any further gateway is not shown');
|
|
133
|
+
this.log.warn(`device list capped at ${MAX_DEVICE_LIST_PAGES * DEVICE_LIST_PAGE_SIZE}`);
|
|
133
134
|
return found;
|
|
134
135
|
}
|
|
135
136
|
/** One page of the device list, already parsed. */
|
|
@@ -178,7 +179,7 @@ class NaviLinkRest {
|
|
|
178
179
|
// Not fatal, and deliberately quiet: firmware is a nicety, and this
|
|
179
180
|
// endpoint has been observed answering 403 on accounts where the device
|
|
180
181
|
// list works perfectly well.
|
|
181
|
-
this.log.debug(`device/info
|
|
182
|
+
this.log.debug(`device/info HTTP ${response.status}; firmware unknown`);
|
|
182
183
|
return undefined;
|
|
183
184
|
}
|
|
184
185
|
const body = this.readBody(response.body, 'device info');
|
|
@@ -189,15 +190,24 @@ class NaviLinkRest {
|
|
|
189
190
|
// The path, never the body: the body of the very first call is the
|
|
190
191
|
// password.
|
|
191
192
|
this.log.debug(`POST ${path}`);
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
193
|
+
const started = this.now();
|
|
194
|
+
try {
|
|
195
|
+
const response = await this.post(`${settings_1.API_BASE}${path}`, body, {
|
|
196
|
+
connectTimeoutMs: settings_1.CONNECT_TIMEOUT_MS,
|
|
197
|
+
totalTimeoutMs: settings_1.REST_TIMEOUT_MS,
|
|
198
|
+
maxBytes: settings_1.MAX_REST_BYTES,
|
|
199
|
+
// No `Bearer` prefix. The API wants the raw token, and sending a
|
|
200
|
+
// correctly-formed bearer header gets a 401.
|
|
201
|
+
...(accessToken === undefined ? {} : { headers: { authorization: accessToken } }),
|
|
202
|
+
...(this.signal === undefined ? {} : { signal: this.signal }),
|
|
203
|
+
});
|
|
204
|
+
this.metrics?.({ durationMs: this.now() - started, ok: true });
|
|
205
|
+
return response;
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
this.metrics?.({ durationMs: this.now() - started, ok: false });
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
201
211
|
}
|
|
202
212
|
readBody(text, what) {
|
|
203
213
|
const parsed = (0, http_1.parseJsonBody)(text);
|
|
@@ -249,8 +259,7 @@ class NaviLinkRest {
|
|
|
249
259
|
.filter((value) => value !== undefined)
|
|
250
260
|
.filter((value) => value >= MIN_BELIEVABLE_SESSION_MS && value <= MAX_BELIEVABLE_SESSION_MS);
|
|
251
261
|
if (candidates.length === 0) {
|
|
252
|
-
this.log.debug(
|
|
253
|
-
+ `assuming ${Math.round(FALLBACK_SESSION_MS / 60_000)} minutes`);
|
|
262
|
+
this.log.debug(`session lifetime missing; assuming ${Math.round(FALLBACK_SESSION_MS / 60_000)}m`);
|
|
254
263
|
return FALLBACK_SESSION_MS;
|
|
255
264
|
}
|
|
256
265
|
return Math.min(...candidates);
|
|
@@ -266,7 +275,7 @@ class NaviLinkRest {
|
|
|
266
275
|
const record = asRecord(asRecord(entry)?.deviceInfo) ?? asRecord(entry);
|
|
267
276
|
const macAddress = asNonEmptyString(record?.macAddress)?.toLowerCase();
|
|
268
277
|
if (record === undefined || macAddress === undefined) {
|
|
269
|
-
this.log.debug('skipping
|
|
278
|
+
this.log.debug('skipping device-list entry: no MAC');
|
|
270
279
|
return undefined;
|
|
271
280
|
}
|
|
272
281
|
return {
|
|
@@ -96,9 +96,9 @@ export declare abstract class BaseAccessory {
|
|
|
96
96
|
* True when the write must not happen, having said so at most once.
|
|
97
97
|
*
|
|
98
98
|
* Shared so a thermostat, a power switch and recirculation all use the same
|
|
99
|
-
*
|
|
99
|
+
* line when `options.readOnly` is on.
|
|
100
100
|
*/
|
|
101
|
-
protected declineIfReadOnly(
|
|
101
|
+
protected declineIfReadOnly(): boolean;
|
|
102
102
|
/** Update Accessory Information once the appliance has named itself. */
|
|
103
103
|
updateIdentity(input: {
|
|
104
104
|
model: string;
|
|
@@ -63,7 +63,7 @@ class BaseAccessory {
|
|
|
63
63
|
catch (error) {
|
|
64
64
|
// A subclass fault must not stop the other accessories being updated
|
|
65
65
|
// from the same frame.
|
|
66
|
-
this.host.log.debug(`${(0, utils_1.forLog)(this.displayName)}:
|
|
66
|
+
this.host.log.debug(`${(0, utils_1.forLog)(this.displayName)}: apply failed: ${(0, utils_1.describeError)(error)}`);
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
/** Report that the appliance can no longer be heard from. */
|
|
@@ -115,8 +115,7 @@ class BaseAccessory {
|
|
|
115
115
|
});
|
|
116
116
|
const outcome = await (0, utils_1.raceTimeout)(started, settings_1.HOMEKIT_WRITE_BUDGET_MS);
|
|
117
117
|
if (outcome === utils_1.TIMED_OUT) {
|
|
118
|
-
this.host.log.debug(`${(0, utils_1.forLog)(this.displayName)}: ${label}
|
|
119
|
-
+ `${settings_1.HOMEKIT_WRITE_BUDGET_MS}ms; answering HomeKit and finishing in the background`);
|
|
118
|
+
this.host.log.debug(`${(0, utils_1.forLog)(this.displayName)}: ${label} still in flight after ${settings_1.HOMEKIT_WRITE_BUDGET_MS}ms`);
|
|
120
119
|
void settled;
|
|
121
120
|
}
|
|
122
121
|
}
|
|
@@ -155,16 +154,15 @@ class BaseAccessory {
|
|
|
155
154
|
* True when the write must not happen, having said so at most once.
|
|
156
155
|
*
|
|
157
156
|
* Shared so a thermostat, a power switch and recirculation all use the same
|
|
158
|
-
*
|
|
157
|
+
* line when `options.readOnly` is on.
|
|
159
158
|
*/
|
|
160
|
-
declineIfReadOnly(
|
|
159
|
+
declineIfReadOnly() {
|
|
161
160
|
if (!this.host.isReadOnly) {
|
|
162
161
|
return false;
|
|
163
162
|
}
|
|
164
163
|
if (!this.hasExplainedReadOnly) {
|
|
165
164
|
this.hasExplainedReadOnly = true;
|
|
166
|
-
this.host.log.info(`${(0, utils_1.forLog)(this.displayName)}:
|
|
167
|
-
+ 'options.readOnly is on in the plugin settings');
|
|
165
|
+
this.host.log.info(`${(0, utils_1.forLog)(this.displayName)}: readOnly; write ignored`);
|
|
168
166
|
}
|
|
169
167
|
return true;
|
|
170
168
|
}
|
|
@@ -81,7 +81,7 @@ class DomesticHotWaterAccessory extends thermostat_accessory_1.ThermostatAccesso
|
|
|
81
81
|
this.explainPowerGuard(error);
|
|
82
82
|
return false;
|
|
83
83
|
}
|
|
84
|
-
this.host.log.warn(`${(0, utils_1.forLog)(this.displayName)}:
|
|
84
|
+
this.host.log.warn(`${(0, utils_1.forLog)(this.displayName)}: power failed: ${(0, utils_1.describeError)(error)}`);
|
|
85
85
|
return false;
|
|
86
86
|
}
|
|
87
87
|
}
|
|
@@ -61,13 +61,12 @@ class FaultAccessory extends base_accessory_1.BaseAccessory {
|
|
|
61
61
|
if (errorCode === 0) {
|
|
62
62
|
// First healthy frame is the resting state, not a transition.
|
|
63
63
|
if (previous !== undefined) {
|
|
64
|
-
this.host.log.info(`${(0, utils_1.forLog)(this.displayName)}:
|
|
64
|
+
this.host.log.info(`${(0, utils_1.forLog)(this.displayName)}: fault cleared`);
|
|
65
65
|
}
|
|
66
66
|
return;
|
|
67
67
|
}
|
|
68
|
-
this.host.log.warn(`${(0, utils_1.forLog)(this.displayName)}:
|
|
69
|
-
+ `${subErrorCode === 0 ? '' : `.${subErrorCode}`};
|
|
70
|
-
+ 'look the code up in the installation manual');
|
|
68
|
+
this.host.log.warn(`${(0, utils_1.forLog)(this.displayName)}: error ${errorCode}`
|
|
69
|
+
+ `${subErrorCode === 0 ? '' : `.${subErrorCode}`}`);
|
|
71
70
|
}
|
|
72
71
|
}
|
|
73
72
|
exports.FaultAccessory = FaultAccessory;
|
|
@@ -62,9 +62,7 @@ class SpaceHeatingAccessory extends thermostat_accessory_1.ThermostatAccessory {
|
|
|
62
62
|
return;
|
|
63
63
|
}
|
|
64
64
|
this.hasWarnedNoLoop = true;
|
|
65
|
-
this.host.log.warn(`${(0, utils_1.forLog)(this.displayName)}:
|
|
66
|
-
+ '(heatControl is off, or the installer setpoint range is empty). Turn the heating '
|
|
67
|
-
+ 'accessory off in the plugin settings.');
|
|
65
|
+
this.host.log.warn(`${(0, utils_1.forLog)(this.displayName)}: no heating loop`);
|
|
68
66
|
}
|
|
69
67
|
/** The flow probe: the water going out to the loop. */
|
|
70
68
|
readTemperature(observation) {
|
|
@@ -99,8 +97,7 @@ class SpaceHeatingAccessory extends thermostat_accessory_1.ThermostatAccessory {
|
|
|
99
97
|
return true;
|
|
100
98
|
}
|
|
101
99
|
catch (error) {
|
|
102
|
-
this.host.log.warn(`${(0, utils_1.forLog)(this.displayName)}:
|
|
103
|
-
+ (0, utils_1.describeError)(error));
|
|
100
|
+
this.host.log.warn(`${(0, utils_1.forLog)(this.displayName)}: heating ${on ? 'on' : 'off'} failed: ${(0, utils_1.describeError)(error)}`);
|
|
104
101
|
return false;
|
|
105
102
|
}
|
|
106
103
|
}
|
|
@@ -118,7 +115,7 @@ class SpaceHeatingAccessory extends thermostat_accessory_1.ThermostatAccessory {
|
|
|
118
115
|
requireHeatingLoop() {
|
|
119
116
|
if (!this.hasHeatingLoop()) {
|
|
120
117
|
this.warnNoLoop();
|
|
121
|
-
throw new utils_1.ControlRejectedError('
|
|
118
|
+
throw new utils_1.ControlRejectedError('no heating loop');
|
|
122
119
|
}
|
|
123
120
|
}
|
|
124
121
|
optimisticSetpointPatch(native) {
|
|
@@ -38,7 +38,7 @@ class PowerAccessory extends base_accessory_1.BaseAccessory {
|
|
|
38
38
|
}
|
|
39
39
|
async writeOn(value) {
|
|
40
40
|
const wantOn = value === true;
|
|
41
|
-
if (this.declineIfReadOnly(
|
|
41
|
+
if (this.declineIfReadOnly()) {
|
|
42
42
|
this.restore();
|
|
43
43
|
return;
|
|
44
44
|
}
|
|
@@ -58,7 +58,7 @@ class PowerAccessory extends base_accessory_1.BaseAccessory {
|
|
|
58
58
|
this.explainGuard(error);
|
|
59
59
|
}
|
|
60
60
|
else {
|
|
61
|
-
this.host.log.warn(`${(0, utils_1.forLog)(this.displayName)}:
|
|
61
|
+
this.host.log.warn(`${(0, utils_1.forLog)(this.displayName)}: power failed: ${(0, utils_1.describeError)(error)}`);
|
|
62
62
|
}
|
|
63
63
|
this.restore();
|
|
64
64
|
}
|
|
@@ -40,11 +40,11 @@ const FIELD_BY_KIND = {
|
|
|
40
40
|
};
|
|
41
41
|
/** What to say when a probe turns out not to exist. */
|
|
42
42
|
const ABSENCE_HINT = {
|
|
43
|
-
dhwOutlet: '
|
|
44
|
-
dhwInlet: '
|
|
45
|
-
heatSupply: '
|
|
46
|
-
heatReturn: '
|
|
47
|
-
outdoor: 'no outdoor sensor
|
|
43
|
+
dhwOutlet: 'no outlet temp',
|
|
44
|
+
dhwInlet: 'no inlet temp',
|
|
45
|
+
heatSupply: 'no flow temp',
|
|
46
|
+
heatReturn: 'no return temp',
|
|
47
|
+
outdoor: 'no outdoor sensor',
|
|
48
48
|
};
|
|
49
49
|
/** One appliance temperature probe, as a HomeKit sensor. */
|
|
50
50
|
class ProbeAccessory extends base_accessory_1.BaseAccessory {
|
|
@@ -61,16 +61,15 @@ class RecirculationAccessory extends base_accessory_1.BaseAccessory {
|
|
|
61
61
|
const wantOn = value === true;
|
|
62
62
|
const equipped = this.equippedState();
|
|
63
63
|
if (equipped === undefined) {
|
|
64
|
-
this.host.log.debug(`${(0, utils_1.forLog)(this.displayName)}: recirculation
|
|
64
|
+
this.host.log.debug(`${(0, utils_1.forLog)(this.displayName)}: recirculation unknown`);
|
|
65
65
|
throw this.communicationFailure();
|
|
66
66
|
}
|
|
67
|
-
if (this.declineIfReadOnly(
|
|
67
|
+
if (this.declineIfReadOnly()) {
|
|
68
68
|
this.restore();
|
|
69
69
|
return;
|
|
70
70
|
}
|
|
71
71
|
if (equipped === false) {
|
|
72
|
-
this.host.log.info(`${(0, utils_1.forLog)(this.displayName)}:
|
|
73
|
-
+ 'nothing was sent');
|
|
72
|
+
this.host.log.info(`${(0, utils_1.forLog)(this.displayName)}: no recirculation pump`);
|
|
74
73
|
this.service.updateCharacteristic(this.host.hap.Characteristic.On, false);
|
|
75
74
|
return;
|
|
76
75
|
}
|
|
@@ -81,7 +80,7 @@ class RecirculationAccessory extends base_accessory_1.BaseAccessory {
|
|
|
81
80
|
this.logAction(wantOn ? 'RECIRCULATE' : 'RECIRCULATE OFF');
|
|
82
81
|
}
|
|
83
82
|
catch (error) {
|
|
84
|
-
this.host.log.warn(`${(0, utils_1.forLog)(this.displayName)}:
|
|
83
|
+
this.host.log.warn(`${(0, utils_1.forLog)(this.displayName)}: recirculation failed: ${(0, utils_1.describeError)(error)}`);
|
|
85
84
|
this.restore();
|
|
86
85
|
}
|
|
87
86
|
});
|
|
@@ -114,9 +113,7 @@ class RecirculationAccessory extends base_accessory_1.BaseAccessory {
|
|
|
114
113
|
return;
|
|
115
114
|
}
|
|
116
115
|
this.hasWarnedNotEquipped = true;
|
|
117
|
-
this.host.log.warn(`${(0, utils_1.forLog)(this.displayName)}:
|
|
118
|
-
+ '(onDemandUse and recirculationUse are both off). Turn the recirculation accessory '
|
|
119
|
-
+ 'off in the plugin settings, or enable the pump in the NaviLink app if one is fitted.');
|
|
116
|
+
this.host.log.warn(`${(0, utils_1.forLog)(this.displayName)}: no recirculation pump`);
|
|
120
117
|
}
|
|
121
118
|
}
|
|
122
119
|
exports.RecirculationAccessory = RecirculationAccessory;
|
|
@@ -192,7 +192,7 @@ class ThermostatAccessory extends base_accessory_1.BaseAccessory {
|
|
|
192
192
|
async writeTargetState(value) {
|
|
193
193
|
const { TargetHeatingCoolingState } = this.host.hap.Characteristic;
|
|
194
194
|
const wantOn = value === TargetHeatingCoolingState.HEAT;
|
|
195
|
-
if (this.declineIfReadOnly(
|
|
195
|
+
if (this.declineIfReadOnly()) {
|
|
196
196
|
this.restoreTargetState();
|
|
197
197
|
return;
|
|
198
198
|
}
|
|
@@ -221,7 +221,7 @@ class ThermostatAccessory extends base_accessory_1.BaseAccessory {
|
|
|
221
221
|
if (typeof value !== 'number') {
|
|
222
222
|
return;
|
|
223
223
|
}
|
|
224
|
-
if (this.declineIfReadOnly(
|
|
224
|
+
if (this.declineIfReadOnly()) {
|
|
225
225
|
this.revertTargetTemperature();
|
|
226
226
|
return;
|
|
227
227
|
}
|
|
@@ -255,8 +255,7 @@ class ThermostatAccessory extends base_accessory_1.BaseAccessory {
|
|
|
255
255
|
const observation = this.host.observationFor(this.deviceId);
|
|
256
256
|
const limits = observation === undefined ? undefined : this.readLimits(observation);
|
|
257
257
|
if (observation === undefined || limits === undefined) {
|
|
258
|
-
this.host.log.warn(`${(0, utils_1.forLog)(this.displayName)}:
|
|
259
|
-
+ 'reported its limits');
|
|
258
|
+
this.host.log.warn(`${(0, utils_1.forLog)(this.displayName)}: no limits yet; setpoint ignored`);
|
|
260
259
|
this.optimisticTargetCelsius = undefined;
|
|
261
260
|
return;
|
|
262
261
|
}
|
|
@@ -277,7 +276,7 @@ class ThermostatAccessory extends base_accessory_1.BaseAccessory {
|
|
|
277
276
|
}
|
|
278
277
|
catch (error) {
|
|
279
278
|
this.optimisticTargetCelsius = undefined;
|
|
280
|
-
this.host.log.warn(`${(0, utils_1.forLog)(this.displayName)}:
|
|
279
|
+
this.host.log.warn(`${(0, utils_1.forLog)(this.displayName)}: setpoint failed: ${(0, utils_1.describeError)(error)}`);
|
|
281
280
|
this.revertTargetTemperature();
|
|
282
281
|
}
|
|
283
282
|
finally {
|