homebridge-navilink 0.1.0

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 (82) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +202 -0
  3. package/README.md +161 -0
  4. package/SECURITY.md +73 -0
  5. package/config.schema.json +138 -0
  6. package/dist/api/channel.d.ts +94 -0
  7. package/dist/api/channel.js +334 -0
  8. package/dist/api/http.d.ts +68 -0
  9. package/dist/api/http.js +205 -0
  10. package/dist/api/identity.d.ts +51 -0
  11. package/dist/api/identity.js +80 -0
  12. package/dist/api/index.d.ts +17 -0
  13. package/dist/api/index.js +33 -0
  14. package/dist/api/mqtt-codec.d.ts +181 -0
  15. package/dist/api/mqtt-codec.js +377 -0
  16. package/dist/api/mqtt.d.ts +178 -0
  17. package/dist/api/mqtt.js +450 -0
  18. package/dist/api/protocol.d.ts +212 -0
  19. package/dist/api/protocol.js +286 -0
  20. package/dist/api/rest.d.ts +139 -0
  21. package/dist/api/rest.js +317 -0
  22. package/dist/api/sigv4.d.ts +70 -0
  23. package/dist/api/sigv4.js +123 -0
  24. package/dist/api/topics.d.ts +75 -0
  25. package/dist/api/topics.js +90 -0
  26. package/dist/devices/base-accessory.d.ts +127 -0
  27. package/dist/devices/base-accessory.js +232 -0
  28. package/dist/devices/dhw-accessory.d.ts +62 -0
  29. package/dist/devices/dhw-accessory.js +100 -0
  30. package/dist/devices/fault-accessory.d.ts +31 -0
  31. package/dist/devices/fault-accessory.js +73 -0
  32. package/dist/devices/heating-accessory.d.ts +63 -0
  33. package/dist/devices/heating-accessory.js +128 -0
  34. package/dist/devices/host.d.ts +77 -0
  35. package/dist/devices/host.js +23 -0
  36. package/dist/devices/index.d.ts +17 -0
  37. package/dist/devices/index.js +33 -0
  38. package/dist/devices/power-accessory.d.ts +32 -0
  39. package/dist/devices/power-accessory.js +86 -0
  40. package/dist/devices/probe-accessory.d.ts +45 -0
  41. package/dist/devices/probe-accessory.js +108 -0
  42. package/dist/devices/recirculation-accessory.d.ts +47 -0
  43. package/dist/devices/recirculation-accessory.js +122 -0
  44. package/dist/devices/thermostat-accessory.d.ts +129 -0
  45. package/dist/devices/thermostat-accessory.js +372 -0
  46. package/dist/discovery.d.ts +99 -0
  47. package/dist/discovery.js +423 -0
  48. package/dist/index.d.ts +11 -0
  49. package/dist/index.js +15 -0
  50. package/dist/platform.d.ts +120 -0
  51. package/dist/platform.js +425 -0
  52. package/dist/session.d.ts +243 -0
  53. package/dist/session.js +717 -0
  54. package/dist/settings.d.ts +218 -0
  55. package/dist/settings.js +240 -0
  56. package/dist/types/index.d.ts +265 -0
  57. package/dist/types/index.js +58 -0
  58. package/dist/ui-api.d.ts +27 -0
  59. package/dist/ui-api.js +39 -0
  60. package/dist/utils/context.d.ts +32 -0
  61. package/dist/utils/context.js +74 -0
  62. package/dist/utils/errors.d.ts +69 -0
  63. package/dist/utils/errors.js +118 -0
  64. package/dist/utils/index.d.ts +15 -0
  65. package/dist/utils/index.js +31 -0
  66. package/dist/utils/redact.d.ts +85 -0
  67. package/dist/utils/redact.js +210 -0
  68. package/dist/utils/serial.d.ts +18 -0
  69. package/dist/utils/serial.js +24 -0
  70. package/dist/utils/temperature.d.ts +114 -0
  71. package/dist/utils/temperature.js +150 -0
  72. package/dist/utils/timing.d.ts +68 -0
  73. package/dist/utils/timing.js +91 -0
  74. package/dist/utils/validators.d.ts +96 -0
  75. package/dist/utils/validators.js +353 -0
  76. package/docs/FEATURES.md +80 -0
  77. package/docs/PROTOCOL.md +200 -0
  78. package/docs/README-DETAILED.md +312 -0
  79. package/homebridge-ui/public/index.html +123 -0
  80. package/homebridge-ui/public/index.js +503 -0
  81. package/homebridge-ui/server.js +174 -0
  82. package/package.json +87 -0
@@ -0,0 +1,127 @@
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 What every accessory in this plugin has in common.
8
+ *
9
+ * Three rules are enforced here rather than trusted to each subclass, because
10
+ * each of them is the kind of thing that is obvious in review and forgotten in
11
+ * code.
12
+ *
13
+ * **Never invent a reading.** If the appliance has not been heard from, a read
14
+ * throws `SERVICE_COMMUNICATION_FAILURE` and the tile shows No Response. It is
15
+ * tempting to return the last value, or a plausible default, so the Home app
16
+ * looks tidy. Both are worse than a grey tile: an automation cannot tell a
17
+ * remembered 49 °C from a current one, and this plugin drives a gas appliance.
18
+ * {@link requireObservedState} is the only way a subclass gets state, and it
19
+ * is the only place that decision is made.
20
+ *
21
+ * **Answer HomeKit inside its budget.** HAP abandons a write after nine
22
+ * seconds and warns at three. A cloud round trip can legitimately take longer
23
+ * than either, so {@link completeWithinBudget} answers on time and lets the
24
+ * work finish behind it. Without that, a slow cloud turns every setpoint
25
+ * change into an error in the Home app even when it worked.
26
+ *
27
+ * **Keep identity stable.** Accessory information is written once here, from
28
+ * values the platform resolved, so no subclass can key an accessory on
29
+ * something that changes and silently take a user's rooms and automations with
30
+ * it.
31
+ */
32
+ import type { Characteristic, CharacteristicValue, PlatformAccessory, Service, WithUUID } from 'homebridge';
33
+ import type { ChannelObservation, RefreshReason } from '../types';
34
+ import type { AccessoryHost, AccessoryInit } from './host';
35
+ /** A concrete HAP service class, such as `Service.Thermostat`. */
36
+ export type ServiceConstructor = {
37
+ UUID: string;
38
+ new (displayName?: string, subtype?: string): Service;
39
+ };
40
+ /** A concrete HAP characteristic class, such as `Characteristic.On`. */
41
+ export type CharacteristicConstructor = WithUUID<new () => Characteristic>;
42
+ /** Shared behaviour for every accessory this plugin exposes. */
43
+ export declare abstract class BaseAccessory {
44
+ protected readonly host: AccessoryHost;
45
+ protected readonly accessory: PlatformAccessory;
46
+ readonly deviceId: string;
47
+ readonly displayName: string;
48
+ /** True once the platform has torn this accessory down. */
49
+ private stopped;
50
+ /** Said once rather than on every write, so a scene cannot flood the log. */
51
+ private hasExplainedReadOnly;
52
+ constructor(init: AccessoryInit);
53
+ /** Apply a fresh observation. Called by the platform, never by a subclass. */
54
+ applyObservation(observation: ChannelObservation, reason: RefreshReason): void;
55
+ /** Report that the appliance can no longer be heard from. */
56
+ noteUnreachable(error: unknown): void;
57
+ /** Release anything held. After this the accessory ignores every update. */
58
+ stop(): void;
59
+ /**
60
+ * The current observation, or a HAP communication failure.
61
+ *
62
+ * The single place an accessory is allowed to obtain state, so the decision
63
+ * to report No Response rather than a remembered value is made once. See the
64
+ * file header.
65
+ */
66
+ protected requireObservedState(): ChannelObservation;
67
+ /** The HAP error that makes a tile show No Response. */
68
+ protected communicationFailure(): Error;
69
+ /**
70
+ * Run a write, answering HomeKit within its budget.
71
+ *
72
+ * On time, the caller's promise decides the outcome. Over budget, HomeKit is
73
+ * told the write succeeded and the work carries on; the next observation is
74
+ * what corrects the tile if it did not. That is the honest trade: HAP has
75
+ * already stopped listening by then, so the alternative is not a truthful
76
+ * error but a `OPERATION_TIMED_OUT` for a command that is still in flight
77
+ * and will probably land.
78
+ */
79
+ protected completeWithinBudget(label: string, work: () => Promise<void>): Promise<void>;
80
+ /**
81
+ * Fetch or create a service, so a restart reuses the cached one.
82
+ *
83
+ * Matched by UUID against the restored service list rather than through
84
+ * `getService`, whose generic signature does not admit a concrete service
85
+ * subclass without a cast. Reusing the restored service is what preserves
86
+ * the user's room assignment, custom name and automations across a restart.
87
+ */
88
+ protected requireService(type: ServiceConstructor): Service;
89
+ /** Remove a service this accessory no longer exposes. */
90
+ protected dropService(type: ServiceConstructor): void;
91
+ /** Publish a characteristic only when the value has actually changed. */
92
+ protected publish(service: Service, characteristic: CharacteristicConstructor, value: CharacteristicValue): void;
93
+ /** One line per accepted command, at info level, with no secrets in it. */
94
+ protected logAction(action: string, detail?: string): void;
95
+ /**
96
+ * True when the write must not happen, having said so at most once.
97
+ *
98
+ * Shared so a thermostat, a power switch and recirculation all use the same
99
+ * sentence when `options.readOnly` is on.
100
+ */
101
+ protected declineIfReadOnly(what: string): boolean;
102
+ /** Update Accessory Information once the appliance has named itself. */
103
+ updateIdentity(input: {
104
+ model: string;
105
+ firmware?: string;
106
+ }): void;
107
+ /**
108
+ * Write the Accessory Information service.
109
+ *
110
+ * SerialNumber is the opaque generated value, never the gateway MAC: the
111
+ * Home app shows it, so it ends up in screenshots and bug reports, and the
112
+ * MAC is the appliance's address in every MQTT topic.
113
+ */
114
+ private configureAccessoryInformation;
115
+ /** Apply an observation to this accessory's characteristics. */
116
+ protected abstract updateFromObservation(observation: ChannelObservation, reason: RefreshReason): void;
117
+ /**
118
+ * Report No Response immediately.
119
+ *
120
+ * HAP only greys a tile when a read throws or a characteristic is updated
121
+ * with {@link HapStatusError}. The session has already dropped the
122
+ * observation, so the next GET would fail; this makes the tile follow now.
123
+ */
124
+ protected markUnavailable(): void;
125
+ /** Keep the last observed scale so a restart can honour display units. */
126
+ private persistScale;
127
+ }
@@ -0,0 +1,232 @@
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 What every accessory in this plugin has in common.
9
+ *
10
+ * Three rules are enforced here rather than trusted to each subclass, because
11
+ * each of them is the kind of thing that is obvious in review and forgotten in
12
+ * code.
13
+ *
14
+ * **Never invent a reading.** If the appliance has not been heard from, a read
15
+ * throws `SERVICE_COMMUNICATION_FAILURE` and the tile shows No Response. It is
16
+ * tempting to return the last value, or a plausible default, so the Home app
17
+ * looks tidy. Both are worse than a grey tile: an automation cannot tell a
18
+ * remembered 49 °C from a current one, and this plugin drives a gas appliance.
19
+ * {@link requireObservedState} is the only way a subclass gets state, and it
20
+ * is the only place that decision is made.
21
+ *
22
+ * **Answer HomeKit inside its budget.** HAP abandons a write after nine
23
+ * seconds and warns at three. A cloud round trip can legitimately take longer
24
+ * than either, so {@link completeWithinBudget} answers on time and lets the
25
+ * work finish behind it. Without that, a slow cloud turns every setpoint
26
+ * change into an error in the Home app even when it worked.
27
+ *
28
+ * **Keep identity stable.** Accessory information is written once here, from
29
+ * values the platform resolved, so no subclass can key an accessory on
30
+ * something that changes and silently take a user's rooms and automations with
31
+ * it.
32
+ */
33
+ Object.defineProperty(exports, "__esModule", { value: true });
34
+ exports.BaseAccessory = void 0;
35
+ const settings_1 = require("../settings");
36
+ const utils_1 = require("../utils");
37
+ /** Shared behaviour for every accessory this plugin exposes. */
38
+ class BaseAccessory {
39
+ host;
40
+ accessory;
41
+ deviceId;
42
+ displayName;
43
+ /** True once the platform has torn this accessory down. */
44
+ stopped = false;
45
+ /** Said once rather than on every write, so a scene cannot flood the log. */
46
+ hasExplainedReadOnly = false;
47
+ constructor(init) {
48
+ this.host = init.host;
49
+ this.accessory = init.accessory;
50
+ this.deviceId = init.deviceId;
51
+ this.displayName = init.displayName;
52
+ this.configureAccessoryInformation(init);
53
+ }
54
+ /** Apply a fresh observation. Called by the platform, never by a subclass. */
55
+ applyObservation(observation, reason) {
56
+ if (this.stopped) {
57
+ return;
58
+ }
59
+ this.persistScale(observation.scale);
60
+ try {
61
+ this.updateFromObservation(observation, reason);
62
+ }
63
+ catch (error) {
64
+ // A subclass fault must not stop the other accessories being updated
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)}`);
67
+ }
68
+ }
69
+ /** Report that the appliance can no longer be heard from. */
70
+ noteUnreachable(error) {
71
+ if (this.stopped) {
72
+ return;
73
+ }
74
+ this.host.log.debug(`${(0, utils_1.forLog)(this.displayName)}: unreachable: ${(0, utils_1.describeError)(error)}`);
75
+ this.markUnavailable();
76
+ }
77
+ /** Release anything held. After this the accessory ignores every update. */
78
+ stop() {
79
+ this.stopped = true;
80
+ }
81
+ /**
82
+ * The current observation, or a HAP communication failure.
83
+ *
84
+ * The single place an accessory is allowed to obtain state, so the decision
85
+ * to report No Response rather than a remembered value is made once. See the
86
+ * file header.
87
+ */
88
+ requireObservedState() {
89
+ const observation = this.host.observationFor(this.deviceId);
90
+ if (observation === undefined) {
91
+ throw this.communicationFailure();
92
+ }
93
+ return observation;
94
+ }
95
+ /** The HAP error that makes a tile show No Response. */
96
+ communicationFailure() {
97
+ return new this.host.api.hap.HapStatusError(-70402 /* this.host.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
98
+ }
99
+ /**
100
+ * Run a write, answering HomeKit within its budget.
101
+ *
102
+ * On time, the caller's promise decides the outcome. Over budget, HomeKit is
103
+ * told the write succeeded and the work carries on; the next observation is
104
+ * what corrects the tile if it did not. That is the honest trade: HAP has
105
+ * already stopped listening by then, so the alternative is not a truthful
106
+ * error but a `OPERATION_TIMED_OUT` for a command that is still in flight
107
+ * and will probably land.
108
+ */
109
+ async completeWithinBudget(label, work) {
110
+ const started = work();
111
+ // Attached before the race so a later rejection cannot become an unhandled
112
+ // rejection once the race has moved on without it.
113
+ const settled = started.catch((error) => {
114
+ this.host.log.warn(`${(0, utils_1.forLog)(this.displayName)}: ${label} failed: ${(0, utils_1.describeError)(error)}`);
115
+ });
116
+ const outcome = await (0, utils_1.raceTimeout)(started, settings_1.HOMEKIT_WRITE_BUDGET_MS);
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`);
120
+ void settled;
121
+ }
122
+ }
123
+ /**
124
+ * Fetch or create a service, so a restart reuses the cached one.
125
+ *
126
+ * Matched by UUID against the restored service list rather than through
127
+ * `getService`, whose generic signature does not admit a concrete service
128
+ * subclass without a cast. Reusing the restored service is what preserves
129
+ * the user's room assignment, custom name and automations across a restart.
130
+ */
131
+ requireService(type) {
132
+ const existing = this.accessory.services.find((service) => service.UUID === type.UUID);
133
+ return existing ?? this.accessory.addService(new type(this.displayName));
134
+ }
135
+ /** Remove a service this accessory no longer exposes. */
136
+ dropService(type) {
137
+ const stale = this.accessory.services.find((service) => service.UUID === type.UUID);
138
+ if (stale !== undefined) {
139
+ this.accessory.removeService(stale);
140
+ }
141
+ }
142
+ /** Publish a characteristic only when the value has actually changed. */
143
+ publish(service, characteristic, value) {
144
+ const current = service.getCharacteristic(characteristic).value;
145
+ if (current === value) {
146
+ return;
147
+ }
148
+ service.updateCharacteristic(characteristic, value);
149
+ }
150
+ /** One line per accepted command, at info level, with no secrets in it. */
151
+ logAction(action, detail) {
152
+ this.host.log.info(`${(0, utils_1.forLog)(this.displayName)}: ${action}${detail === undefined ? '' : ` ${detail}`}`);
153
+ }
154
+ /**
155
+ * True when the write must not happen, having said so at most once.
156
+ *
157
+ * Shared so a thermostat, a power switch and recirculation all use the same
158
+ * sentence when `options.readOnly` is on.
159
+ */
160
+ declineIfReadOnly(what) {
161
+ if (!this.host.isReadOnly) {
162
+ return false;
163
+ }
164
+ if (!this.hasExplainedReadOnly) {
165
+ 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');
168
+ }
169
+ return true;
170
+ }
171
+ /** Update Accessory Information once the appliance has named itself. */
172
+ updateIdentity(input) {
173
+ const { Characteristic, Service: HapService } = this.host.hap;
174
+ const service = this.accessory.getService(HapService.AccessoryInformation);
175
+ if (service === undefined) {
176
+ return;
177
+ }
178
+ this.publish(service, Characteristic.Model, input.model);
179
+ if (input.firmware !== undefined && Characteristic.HardwareRevision !== undefined) {
180
+ this.publish(service, Characteristic.HardwareRevision, input.firmware);
181
+ }
182
+ const context = this.accessory.context;
183
+ context.model = input.model;
184
+ }
185
+ /**
186
+ * Write the Accessory Information service.
187
+ *
188
+ * SerialNumber is the opaque generated value, never the gateway MAC: the
189
+ * Home app shows it, so it ends up in screenshots and bug reports, and the
190
+ * MAC is the appliance's address in every MQTT topic.
191
+ */
192
+ configureAccessoryInformation(init) {
193
+ const { Characteristic, Service: HapService } = this.host.hap;
194
+ const service = this.accessory.getService(HapService.AccessoryInformation)
195
+ ?? this.accessory.addService(HapService.AccessoryInformation);
196
+ service
197
+ .setCharacteristic(Characteristic.Manufacturer, settings_1.MANUFACTURER)
198
+ .setCharacteristic(Characteristic.Model, init.model)
199
+ .setCharacteristic(Characteristic.SerialNumber, init.serialNumber)
200
+ .setCharacteristic(Characteristic.FirmwareRevision, this.host.pluginVersion)
201
+ .setCharacteristic(Characteristic.Name, this.displayName);
202
+ }
203
+ /**
204
+ * Report No Response immediately.
205
+ *
206
+ * HAP only greys a tile when a read throws or a characteristic is updated
207
+ * with {@link HapStatusError}. The session has already dropped the
208
+ * observation, so the next GET would fail; this makes the tile follow now.
209
+ */
210
+ markUnavailable() {
211
+ const { HapStatusError, HAPStatus } = this.host.api.hap;
212
+ const error = new HapStatusError(-70402 /* HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
213
+ const infoUuid = this.host.hap.Service.AccessoryInformation.UUID;
214
+ for (const service of this.accessory.services) {
215
+ if (service.UUID === infoUuid) {
216
+ continue;
217
+ }
218
+ for (const characteristic of service.characteristics) {
219
+ characteristic.updateValue(error);
220
+ }
221
+ }
222
+ }
223
+ /** Keep the last observed scale so a restart can honour display units. */
224
+ persistScale(scale) {
225
+ const context = this.accessory.context;
226
+ if (context.scale === scale) {
227
+ return;
228
+ }
229
+ context.scale = scale;
230
+ }
231
+ }
232
+ exports.BaseAccessory = BaseAccessory;
@@ -0,0 +1,62 @@
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 The domestic hot water thermostat.
8
+ *
9
+ * The accessory most people install this plugin for: the temperature of the
10
+ * water at the taps, as a tile, a scene value and a Siri phrase.
11
+ *
12
+ * ## Why "off" does not normally turn anything off
13
+ *
14
+ * A combi appliance has no separate switch for hot water. There is one power
15
+ * state, and it governs the space-heating loop as well. So the only thing
16
+ * `TargetHeatingCoolingState.OFF` could be wired to is the appliance's power,
17
+ * and that makes "Hey Siri, turn off the hot water", or a bedtime scene that
18
+ * sweeps every switch off, or a HomeKit "turn everything off", into a command
19
+ * that also stops the central heating. In a house that is empty in February,
20
+ * that is a burst pipe.
21
+ *
22
+ * So by default this tile declines an off. It says why, once, and springs
23
+ * back. Someone who genuinely wants HomeKit to be able to shut the appliance
24
+ * down turns on `options.allowPowerOff`, and at that point off means off.
25
+ *
26
+ * This is a departure from the usual rule that an accessory does what HomeKit
27
+ * asks. It is made deliberately and in one direction only: the plugin will
28
+ * always heat when asked, and hesitates only about stopping.
29
+ */
30
+ import type { ChannelObservation } from '../types';
31
+ import type { AccessoryInit } from './host';
32
+ import { ThermostatAccessory, type SetpointLimits } from './thermostat-accessory';
33
+ /** Hot water temperature, as a HomeKit thermostat. */
34
+ export declare class DomesticHotWaterAccessory extends ThermostatAccessory {
35
+ /** Said once per session rather than once per press. */
36
+ private hasExplainedPowerGuard;
37
+ constructor(init: AccessoryInit);
38
+ protected readSetpoint(observation: ChannelObservation): number | undefined;
39
+ /**
40
+ * The outlet probe: the water actually leaving the appliance.
41
+ *
42
+ * Not the setpoint, and not the inlet. Between draws this reads well below
43
+ * the setpoint, which is correct and occasionally surprising. The
44
+ * appliance is not keeping a tank hot, it heats on demand. Reporting the
45
+ * setpoint here instead would make the tile look tidy and mean nothing.
46
+ */
47
+ protected readTemperature(observation: ChannelObservation): number | undefined;
48
+ protected readLimits(observation: ChannelObservation): SetpointLimits | undefined;
49
+ /**
50
+ * Hot water is available whenever the appliance is on.
51
+ *
52
+ * There is no per-demand enable for hot water on the families this plugin
53
+ * supports, so the honest answer is that it is on when the appliance is on.
54
+ * The base class already requires `power`, so this is unconditional rather
55
+ * than reading a flag that does not exist.
56
+ */
57
+ protected readEnabled(_observation: ChannelObservation): boolean;
58
+ protected sendSetpoint(native: number): Promise<void>;
59
+ protected sendEnabled(on: boolean): Promise<boolean>;
60
+ protected optimisticSetpointPatch(native: number): Partial<ChannelObservation>;
61
+ private explainPowerGuard;
62
+ }
@@ -0,0 +1,100 @@
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 The domestic hot water thermostat.
9
+ *
10
+ * The accessory most people install this plugin for: the temperature of the
11
+ * water at the taps, as a tile, a scene value and a Siri phrase.
12
+ *
13
+ * ## Why "off" does not normally turn anything off
14
+ *
15
+ * A combi appliance has no separate switch for hot water. There is one power
16
+ * state, and it governs the space-heating loop as well. So the only thing
17
+ * `TargetHeatingCoolingState.OFF` could be wired to is the appliance's power,
18
+ * and that makes "Hey Siri, turn off the hot water", or a bedtime scene that
19
+ * sweeps every switch off, or a HomeKit "turn everything off", into a command
20
+ * that also stops the central heating. In a house that is empty in February,
21
+ * that is a burst pipe.
22
+ *
23
+ * So by default this tile declines an off. It says why, once, and springs
24
+ * back. Someone who genuinely wants HomeKit to be able to shut the appliance
25
+ * down turns on `options.allowPowerOff`, and at that point off means off.
26
+ *
27
+ * This is a departure from the usual rule that an accessory does what HomeKit
28
+ * asks. It is made deliberately and in one direction only: the plugin will
29
+ * always heat when asked, and hesitates only about stopping.
30
+ */
31
+ Object.defineProperty(exports, "__esModule", { value: true });
32
+ exports.DomesticHotWaterAccessory = void 0;
33
+ const utils_1 = require("../utils");
34
+ const thermostat_accessory_1 = require("./thermostat-accessory");
35
+ /** Hot water temperature, as a HomeKit thermostat. */
36
+ class DomesticHotWaterAccessory extends thermostat_accessory_1.ThermostatAccessory {
37
+ /** Said once per session rather than once per press. */
38
+ hasExplainedPowerGuard = false;
39
+ constructor(init) {
40
+ super(init);
41
+ }
42
+ readSetpoint(observation) {
43
+ return observation.dhwSetpoint;
44
+ }
45
+ /**
46
+ * The outlet probe: the water actually leaving the appliance.
47
+ *
48
+ * Not the setpoint, and not the inlet. Between draws this reads well below
49
+ * the setpoint, which is correct and occasionally surprising. The
50
+ * appliance is not keeping a tank hot, it heats on demand. Reporting the
51
+ * setpoint here instead would make the tile look tidy and mean nothing.
52
+ */
53
+ readTemperature(observation) {
54
+ return observation.dhwOutlet;
55
+ }
56
+ readLimits(observation) {
57
+ const { dhwMin, dhwMax } = observation;
58
+ return dhwMin === undefined || dhwMax === undefined ? undefined : { min: dhwMin, max: dhwMax };
59
+ }
60
+ /**
61
+ * Hot water is available whenever the appliance is on.
62
+ *
63
+ * There is no per-demand enable for hot water on the families this plugin
64
+ * supports, so the honest answer is that it is on when the appliance is on.
65
+ * The base class already requires `power`, so this is unconditional rather
66
+ * than reading a flag that does not exist.
67
+ */
68
+ readEnabled(_observation) {
69
+ return true;
70
+ }
71
+ async sendSetpoint(native) {
72
+ await this.host.control(this.deviceId).setDomesticHotWaterSetpoint(native);
73
+ }
74
+ async sendEnabled(on) {
75
+ try {
76
+ await this.host.control(this.deviceId).setPower(on);
77
+ return true;
78
+ }
79
+ catch (error) {
80
+ if (error instanceof utils_1.ControlRejectedError) {
81
+ this.explainPowerGuard(error);
82
+ return false;
83
+ }
84
+ this.host.log.warn(`${(0, utils_1.forLog)(this.displayName)}: could not change the power state: ${(0, utils_1.describeError)(error)}`);
85
+ return false;
86
+ }
87
+ }
88
+ optimisticSetpointPatch(native) {
89
+ return { dhwSetpoint: native };
90
+ }
91
+ explainPowerGuard(error) {
92
+ if (this.hasExplainedPowerGuard) {
93
+ this.host.log.debug(`${(0, utils_1.forLog)(this.displayName)}: ${error.message}`);
94
+ return;
95
+ }
96
+ this.hasExplainedPowerGuard = true;
97
+ this.host.log.info(`${(0, utils_1.forLog)(this.displayName)}: ${error.message}`);
98
+ }
99
+ }
100
+ exports.DomesticHotWaterAccessory = DomesticHotWaterAccessory;
@@ -0,0 +1,31 @@
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 A contact sensor that opens when the appliance reports a fault.
8
+ *
9
+ * A contact sensor rather than anything cleverer, because HomeKit will send a
10
+ * push notification when one opens and will trigger an automation from it.
11
+ * That is the entire point: a combi appliance that has locked out in January
12
+ * is worth knowing about before the shower is cold, and the NaviLink app's own
13
+ * notifications are easy to miss.
14
+ *
15
+ * The error code is not published as a value. HomeKit has nowhere to show a
16
+ * number. It is logged, with its sub-code, so the log says what to look
17
+ * up in the manual, not only that something is wrong.
18
+ */
19
+ import type { ChannelObservation, RefreshReason } from '../types';
20
+ import { BaseAccessory } from './base-accessory';
21
+ import type { AccessoryInit } from './host';
22
+ /** Appliance fault state, as a HomeKit contact sensor. */
23
+ export declare class FaultAccessory extends BaseAccessory {
24
+ private readonly service;
25
+ /** The code last logged, so a standing fault is reported once, not hourly. */
26
+ private lastLoggedCode;
27
+ constructor(init: AccessoryInit);
28
+ private readState;
29
+ protected updateFromObservation(observation: ChannelObservation, _reason: RefreshReason): void;
30
+ private logCodeChange;
31
+ }
@@ -0,0 +1,73 @@
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 A contact sensor that opens when the appliance reports a fault.
9
+ *
10
+ * A contact sensor rather than anything cleverer, because HomeKit will send a
11
+ * push notification when one opens and will trigger an automation from it.
12
+ * That is the entire point: a combi appliance that has locked out in January
13
+ * is worth knowing about before the shower is cold, and the NaviLink app's own
14
+ * notifications are easy to miss.
15
+ *
16
+ * The error code is not published as a value. HomeKit has nowhere to show a
17
+ * number. It is logged, with its sub-code, so the log says what to look
18
+ * up in the manual, not only that something is wrong.
19
+ */
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.FaultAccessory = void 0;
22
+ const utils_1 = require("../utils");
23
+ const base_accessory_1 = require("./base-accessory");
24
+ /** Appliance fault state, as a HomeKit contact sensor. */
25
+ class FaultAccessory extends base_accessory_1.BaseAccessory {
26
+ service;
27
+ /** The code last logged, so a standing fault is reported once, not hourly. */
28
+ lastLoggedCode;
29
+ constructor(init) {
30
+ super(init);
31
+ const { Characteristic, Service: HapService } = this.host.hap;
32
+ this.service = this.requireService(HapService.ContactSensor);
33
+ this.service.setCharacteristic(Characteristic.Name, this.displayName);
34
+ this.service
35
+ .getCharacteristic(Characteristic.ContactSensorState)
36
+ .onGet(() => this.readState());
37
+ }
38
+ readState() {
39
+ const { ContactSensorState } = this.host.hap.Characteristic;
40
+ const observation = this.requireObservedState();
41
+ // "Detected" is HomeKit's word for a closed contact, which is the resting
42
+ // state. A fault opens it, which is what raises a notification.
43
+ return observation.readings.errorCode === 0
44
+ ? ContactSensorState.CONTACT_DETECTED
45
+ : ContactSensorState.CONTACT_NOT_DETECTED;
46
+ }
47
+ updateFromObservation(observation, _reason) {
48
+ const { ContactSensorState } = this.host.hap.Characteristic;
49
+ const { errorCode, subErrorCode } = observation.readings;
50
+ this.publish(this.service, this.host.hap.Characteristic.ContactSensorState, errorCode === 0
51
+ ? ContactSensorState.CONTACT_DETECTED
52
+ : ContactSensorState.CONTACT_NOT_DETECTED);
53
+ this.logCodeChange(errorCode, subErrorCode);
54
+ }
55
+ logCodeChange(errorCode, subErrorCode) {
56
+ if (errorCode === this.lastLoggedCode) {
57
+ return;
58
+ }
59
+ const previous = this.lastLoggedCode;
60
+ this.lastLoggedCode = errorCode;
61
+ if (errorCode === 0) {
62
+ // First healthy frame is the resting state, not a transition.
63
+ if (previous !== undefined) {
64
+ this.host.log.info(`${(0, utils_1.forLog)(this.displayName)}: the fault has cleared`);
65
+ }
66
+ return;
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');
71
+ }
72
+ }
73
+ exports.FaultAccessory = FaultAccessory;