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.
Files changed (44) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +33 -32
  3. package/SECURITY.md +3 -2
  4. package/config.schema.json +22 -1
  5. package/dist/api/mqtt.js +2 -2
  6. package/dist/api/rest.d.ts +6 -0
  7. package/dist/api/rest.js +24 -15
  8. package/dist/devices/base-accessory.d.ts +2 -2
  9. package/dist/devices/base-accessory.js +5 -7
  10. package/dist/devices/dhw-accessory.js +1 -1
  11. package/dist/devices/fault-accessory.js +3 -4
  12. package/dist/devices/heating-accessory.js +3 -6
  13. package/dist/devices/power-accessory.js +2 -2
  14. package/dist/devices/probe-accessory.js +5 -5
  15. package/dist/devices/recirculation-accessory.js +5 -8
  16. package/dist/devices/thermostat-accessory.js +4 -5
  17. package/dist/diagnostics/collector.d.ts +81 -0
  18. package/dist/diagnostics/collector.js +230 -0
  19. package/dist/diagnostics/format.d.ts +22 -0
  20. package/dist/diagnostics/format.js +70 -0
  21. package/dist/discovery.js +5 -6
  22. package/dist/platform.d.ts +10 -0
  23. package/dist/platform.js +128 -18
  24. package/dist/session.d.ts +32 -3
  25. package/dist/session.js +137 -35
  26. package/dist/settings.d.ts +19 -0
  27. package/dist/settings.js +20 -1
  28. package/dist/types/index.d.ts +84 -0
  29. package/dist/utils/errors.d.ts +8 -0
  30. package/dist/utils/errors.js +8 -0
  31. package/dist/utils/index.d.ts +1 -0
  32. package/dist/utils/index.js +1 -0
  33. package/dist/utils/labels.d.ts +18 -0
  34. package/dist/utils/labels.js +26 -0
  35. package/dist/utils/redact.d.ts +3 -3
  36. package/dist/utils/redact.js +3 -3
  37. package/dist/utils/validators.d.ts +22 -1
  38. package/dist/utils/validators.js +90 -21
  39. package/docs/FEATURES.md +34 -23
  40. package/docs/PROTOCOL.md +1 -1
  41. package/docs/README-DETAILED.md +65 -33
  42. package/homebridge-ui/public/index.html +32 -0
  43. package/homebridge-ui/public/index.js +26 -0
  44. 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
- [![Tests](https://github.com/tbaur/homebridge-navilink/actions/workflows/test.yml/badge.svg)](https://github.com/tbaur/homebridge-navilink/actions/workflows/test.yml) [![npm version](https://img.shields.io/npm/v/homebridge-navilink?style=flat-square)](https://www.npmjs.com/package/homebridge-navilink) [![npm downloads](https://img.shields.io/npm/dt/homebridge-navilink?label=downloads&style=flat-square)](https://www.npmjs.com/package/homebridge-navilink) [![Node.js](https://img.shields.io/badge/node-22%20%7C%7C%2024%20%7C%7C%2026-green)](https://nodejs.org) [![Homebridge](https://img.shields.io/badge/homebridge-2.x-purple)](https://homebridge.io) [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)
3
+ ![Tests](https://github.com/tbaur/homebridge-navilink/actions/workflows/test.yml/badge.svg) ![npm version](https://img.shields.io/npm/v/homebridge-navilink?style=flat-square) ![npm downloads](https://img.shields.io/npm/dt/homebridge-navilink?label=downloads&style=flat-square) ![Node.js](https://img.shields.io/badge/node-22%20%7C%7C%2024%20%7C%7C%2026-green) ![Homebridge](https://img.shields.io/badge/homebridge-2.x-purple) ![License](https://img.shields.io/badge/license-Apache--2.0-blue)
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. Respects the range your installer set
16
- - **Heating thermostat:** the space-heating flow temperature. This is the boiler's water temperature, not a room thermostat. Your room thermostat still decides when heat is called for
17
- - **Power switch:** on and off, with switching *off* refused by default because it stops central heating too
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. The appliance runs the pump for a fixed period and then stops; the tile turns itself off when that happens
19
- - **Fault sensor:** a contact sensor that opens on an error code, so HomeKit can notify you. The code goes to the log
20
- - **Temperature sensors:** hot water in and out, heating flow and return, and the outdoor probe if one is fitted. Each disables itself if the appliance does not report it
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, so the connection is not already dead in the middle of the night
27
- - **A connected socket is not a live appliance.** Readings carry the time they were taken, and a stale one becomes No Response instead of yesterday's setpoint
28
- - **Honest state:** No Response until the appliance has actually been read, never a value it cannot confirm
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 | Notes |
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 | Expected |
101
- |---|---|
98
+ | Family | Expected |
99
+ | ------------------ | ------------------------------------------------------------------------------ |
102
100
  | **NPE, NPE2, NPN** | Tankless water heaters. Hot water only; the heating thermostat disables itself |
103
- | **NCB-H** | Combi with a buffer tank |
104
- | **NHB** | Boiler. Space heating only |
105
- | **NFB, NFC** | Boilers with hot water and space heating |
106
- | **NVW** | Water heater with a tank |
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 | Required to run | Description |
115
- |---|:-:|---|
116
- | `name` | ✓ | Plugin instance name shown in Homebridge logs |
117
- | `email` | ✓ | Your NaviLink account address. Needed before the plugin signs in |
118
- | `password` | ✓ | Your NaviLink password. Needed before the plugin signs in |
119
- | `devices` | ✓ | List of appliances. Needed before the plugin signs in |
120
- | `options.statusIntervalSec` | | Backstop refresh, 30–3600 seconds (default 120) |
121
- | `options.readOnly` | | Report everything, change nothing (default false) |
122
- | `options.allowPowerOff` | | Let HomeKit switch the appliance off (default false) |
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 `the NaviLink platform is disabled`, which means the configuration could not be read
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. **"The appliance refused a command for arriving too soon."** The cloud rate-limits control. The plugin pauses and retries on your next press
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 honest summary; [SECURITY.md](SECURITY.md) has the detail, including what a capture contains and why the fixtures in this repository are pseudonymised.
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` and a gateway MAC as `…E5F6`. Both identify you, and a Homebridge log gets pasted into issues.
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. Names are stripped of control characters and length-capped before they reach a log line or a characteristic.
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
 
@@ -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(`the broker closed the connection (${reason})`)),
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() {
@@ -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(`the account listed ${MAX_DEVICE_LIST_PAGES * DEVICE_LIST_PAGE_SIZE} gateways, `
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 answered HTTP ${response.status}; firmware will be unknown`);
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
- return this.post(`${settings_1.API_BASE}${path}`, body, {
193
- connectTimeoutMs: settings_1.CONNECT_TIMEOUT_MS,
194
- totalTimeoutMs: settings_1.REST_TIMEOUT_MS,
195
- maxBytes: settings_1.MAX_REST_BYTES,
196
- // No `Bearer` prefix. The API wants the raw token, and sending a
197
- // correctly-formed bearer header gets a 401.
198
- ...(accessToken === undefined ? {} : { headers: { authorization: accessToken } }),
199
- ...(this.signal === undefined ? {} : { signal: this.signal }),
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('the cloud did not report a believable session lifetime; '
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 a device-list entry with no MAC address');
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
- * sentence when `options.readOnly` is on.
99
+ * line when `options.readOnly` is on.
100
100
  */
101
- protected declineIfReadOnly(what: string): boolean;
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)}: could not apply an observation: ${(0, utils_1.describeError)(error)}`);
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} is taking longer than `
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
- * sentence when `options.readOnly` is on.
157
+ * line when `options.readOnly` is on.
159
158
  */
160
- declineIfReadOnly(what) {
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)}: ignoring a request to ${what}; `
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)}: could not change the power state: ${(0, utils_1.describeError)(error)}`);
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)}: the fault has cleared`);
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)}: the appliance reports error ${errorCode}`
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)}: this appliance reports no space-heating loop `
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)}: could not ${on ? 'enable' : 'disable'} heating: `
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('this appliance reports no space-heating loop, so a heating command was not sent');
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('change the power')) {
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)}: could not change power: ${(0, utils_1.describeError)(error)}`);
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: 'the appliance is not reporting a hot water outlet temperature',
44
- dhwInlet: 'the appliance is not reporting a hot water inlet temperature',
45
- heatSupply: 'the appliance is not reporting a heating flow temperature',
46
- heatReturn: 'the appliance is not reporting a heating return temperature',
47
- outdoor: 'no outdoor sensor is fitted, so there is nothing to report',
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 is unknown until the appliance reports it`);
64
+ this.host.log.debug(`${(0, utils_1.forLog)(this.displayName)}: recirculation unknown`);
65
65
  throw this.communicationFailure();
66
66
  }
67
- if (this.declineIfReadOnly('change recirculation')) {
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)}: this appliance reports no recirculation pump; `
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)}: could not change recirculation: ${(0, utils_1.describeError)(error)}`);
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)}: the appliance reports no recirculation pump `
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('change the mode')) {
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('change the setpoint')) {
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)}: cannot set a temperature before the appliance has `
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)}: could not set the temperature: ${(0, utils_1.describeError)(error)}`);
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 {