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,63 @@
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 space-heating thermostat.
8
+ *
9
+ * ## This is a boiler control, not a room thermostat
10
+ *
11
+ * The number on this tile is the temperature of the **water** the appliance
12
+ * sends to the radiators or the underfloor loop, not the temperature of a
13
+ * room. Setting it to 60 °C does not make the house 60 °C; it makes the flow
14
+ * hotter, so the house reaches whatever the room thermostat is asking for
15
+ * sooner and less efficiently.
16
+ *
17
+ * HomeKit has no accessory type for "boiler flow temperature", and a
18
+ * Thermostat is the closest thing it offers. Anyone building an automation on
19
+ * this tile should know which quantity they are automating, which is why the
20
+ * README says so and why this accessory is opt-in rather than created by
21
+ * default.
22
+ *
23
+ * ## Off means off
24
+ *
25
+ * Unlike the hot water tile, this one's off is wired to the appliance's
26
+ * space-heating enable, which is exactly the thing being asked about. Turning
27
+ * it off stops the radiators and leaves the hot water alone, so there is no
28
+ * reason to hesitate over it.
29
+ */
30
+ import type { ChannelObservation, RefreshReason } from '../types';
31
+ import { ThermostatAccessory, type SetpointLimits } from './thermostat-accessory';
32
+ /** Space-heating flow temperature, as a HomeKit thermostat. */
33
+ export declare class SpaceHeatingAccessory extends ThermostatAccessory {
34
+ private hasWarnedNoLoop;
35
+ protected readSetpoint(observation: ChannelObservation): number | undefined;
36
+ /**
37
+ * Say once when this appliance turns out to have no heating loop.
38
+ *
39
+ * Only knowable from a live `channelinfo` frame, which arrives after the
40
+ * accessory is registered, so it can be configured on hardware that cannot
41
+ * do it.
42
+ */
43
+ protected updateFromObservation(observation: ChannelObservation, reason: RefreshReason): void;
44
+ private warnNoLoop;
45
+ /** The flow probe: the water going out to the loop. */
46
+ protected readTemperature(observation: ChannelObservation): number | undefined;
47
+ protected readLimits(observation: ChannelObservation): SetpointLimits | undefined;
48
+ protected readEnabled(observation: ChannelObservation): boolean;
49
+ protected isCommandable(observation: ChannelObservation): boolean;
50
+ protected sendSetpoint(native: number): Promise<void>;
51
+ protected sendEnabled(on: boolean): Promise<boolean>;
52
+ /**
53
+ * True when this appliance has a loop worth commanding.
54
+ *
55
+ * Reads and writes both go through this: a water heater answers the same
56
+ * frames as a combi and fills the heating fields with placeholders, so a
57
+ * HomeKit write that reached the cloud would be a command to hardware that
58
+ * has no radiators attached to it.
59
+ */
60
+ private hasHeatingLoop;
61
+ private requireHeatingLoop;
62
+ protected optimisticSetpointPatch(native: number): Partial<ChannelObservation>;
63
+ }
@@ -0,0 +1,128 @@
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 space-heating thermostat.
9
+ *
10
+ * ## This is a boiler control, not a room thermostat
11
+ *
12
+ * The number on this tile is the temperature of the **water** the appliance
13
+ * sends to the radiators or the underfloor loop, not the temperature of a
14
+ * room. Setting it to 60 °C does not make the house 60 °C; it makes the flow
15
+ * hotter, so the house reaches whatever the room thermostat is asking for
16
+ * sooner and less efficiently.
17
+ *
18
+ * HomeKit has no accessory type for "boiler flow temperature", and a
19
+ * Thermostat is the closest thing it offers. Anyone building an automation on
20
+ * this tile should know which quantity they are automating, which is why the
21
+ * README says so and why this accessory is opt-in rather than created by
22
+ * default.
23
+ *
24
+ * ## Off means off
25
+ *
26
+ * Unlike the hot water tile, this one's off is wired to the appliance's
27
+ * space-heating enable, which is exactly the thing being asked about. Turning
28
+ * it off stops the radiators and leaves the hot water alone, so there is no
29
+ * reason to hesitate over it.
30
+ */
31
+ Object.defineProperty(exports, "__esModule", { value: true });
32
+ exports.SpaceHeatingAccessory = void 0;
33
+ const utils_1 = require("../utils");
34
+ const thermostat_accessory_1 = require("./thermostat-accessory");
35
+ /** Space-heating flow temperature, as a HomeKit thermostat. */
36
+ class SpaceHeatingAccessory extends thermostat_accessory_1.ThermostatAccessory {
37
+ hasWarnedNoLoop = false;
38
+ readSetpoint(observation) {
39
+ // Nothing to report on an appliance with no loop. The fields are present
40
+ // on a water heater too, filled with placeholders, so returning them
41
+ // would put a working-looking thermostat on hardware that has no
42
+ // radiators attached to it.
43
+ return observation.heatingSupported ? observation.heatSetpoint : undefined;
44
+ }
45
+ /**
46
+ * Say once when this appliance turns out to have no heating loop.
47
+ *
48
+ * Only knowable from a live `channelinfo` frame, which arrives after the
49
+ * accessory is registered, so it can be configured on hardware that cannot
50
+ * do it.
51
+ */
52
+ updateFromObservation(observation, reason) {
53
+ if (!observation.heatingSupported) {
54
+ this.warnNoLoop();
55
+ this.markUnavailable();
56
+ return;
57
+ }
58
+ super.updateFromObservation(observation, reason);
59
+ }
60
+ warnNoLoop() {
61
+ if (this.hasWarnedNoLoop) {
62
+ return;
63
+ }
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.');
68
+ }
69
+ /** The flow probe: the water going out to the loop. */
70
+ readTemperature(observation) {
71
+ return observation.heatSupply;
72
+ }
73
+ readLimits(observation) {
74
+ if (!observation.heatingSupported) {
75
+ return undefined;
76
+ }
77
+ const { heatMin, heatMax } = observation;
78
+ return heatMin === undefined || heatMax === undefined
79
+ ? undefined
80
+ : { min: heatMin, max: heatMax };
81
+ }
82
+ readEnabled(observation) {
83
+ return observation.heatingSupported && observation.heating;
84
+ }
85
+ isCommandable(observation) {
86
+ return observation.heatingSupported;
87
+ }
88
+ async sendSetpoint(native) {
89
+ this.requireHeatingLoop();
90
+ await this.host.control(this.deviceId).setHeatingSetpoint(native);
91
+ }
92
+ async sendEnabled(on) {
93
+ if (!this.hasHeatingLoop()) {
94
+ this.warnNoLoop();
95
+ return false;
96
+ }
97
+ try {
98
+ await this.host.control(this.deviceId).setHeating(on);
99
+ return true;
100
+ }
101
+ 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));
104
+ return false;
105
+ }
106
+ }
107
+ /**
108
+ * True when this appliance has a loop worth commanding.
109
+ *
110
+ * Reads and writes both go through this: a water heater answers the same
111
+ * frames as a combi and fills the heating fields with placeholders, so a
112
+ * HomeKit write that reached the cloud would be a command to hardware that
113
+ * has no radiators attached to it.
114
+ */
115
+ hasHeatingLoop() {
116
+ return this.host.observationFor(this.deviceId)?.heatingSupported === true;
117
+ }
118
+ requireHeatingLoop() {
119
+ if (!this.hasHeatingLoop()) {
120
+ this.warnNoLoop();
121
+ throw new utils_1.ControlRejectedError('this appliance reports no space-heating loop, so a heating command was not sent');
122
+ }
123
+ }
124
+ optimisticSetpointPatch(native) {
125
+ return { heatSetpoint: native };
126
+ }
127
+ }
128
+ exports.SpaceHeatingAccessory = SpaceHeatingAccessory;
@@ -0,0 +1,77 @@
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 contract between an accessory and the platform.
8
+ *
9
+ * An explicit interface rather than a reference to the platform class, for two
10
+ * reasons. It breaks the import cycle that would otherwise exist between the
11
+ * platform and the accessories it creates. And it is the whole surface an
12
+ * accessory is allowed to reach, so a test can supply a dozen-line stand-in
13
+ * instead of standing up a platform, a cloud session and a socket.
14
+ *
15
+ * Note what an accessory cannot do through this interface: it cannot reach the
16
+ * MQTT connection, build a frame, or see the account. Every write goes through
17
+ * one of the named intents below, so the rules that govern writes (read-only
18
+ * mode, the power-off guard, rate limiting) are enforced in one place, not in
19
+ * each accessory that might forget. Setpoint coalescing is thermostat-only
20
+ * and lives on {@link ThermostatAccessory}.
21
+ */
22
+ import type { API, HAP, PlatformAccessory } from 'homebridge';
23
+ import type { ChannelObservation, PluginLogger, ResolvedDevice } from '../types';
24
+ /** What an accessory may ask the appliance to do. */
25
+ export interface ControlIntent {
26
+ /** Switch the appliance on or off. Subject to the power-off guard. */
27
+ setPower(on: boolean): Promise<void>;
28
+ /** Enable or disable space heating. */
29
+ setHeating(on: boolean): Promise<void>;
30
+ /** Set the domestic hot water setpoint, in the appliance's native scale. */
31
+ setDomesticHotWaterSetpoint(native: number): Promise<void>;
32
+ /** Set the space-heating water setpoint, in the appliance's native scale. */
33
+ setHeatingSetpoint(native: number): Promise<void>;
34
+ /** Start or stop the recirculation pump. */
35
+ setRecirculation(on: boolean): Promise<void>;
36
+ }
37
+ /** Everything an accessory needs from the platform that owns it. */
38
+ export interface AccessoryHost {
39
+ readonly api: API;
40
+ readonly hap: HAP;
41
+ readonly log: PluginLogger;
42
+ /** This plugin's version, published as FirmwareRevision. */
43
+ readonly pluginVersion: string;
44
+ /** True when configuration forbids every write. */
45
+ readonly isReadOnly: boolean;
46
+ /** The configured device an accessory belongs to, or undefined if removed. */
47
+ deviceFor(deviceId: string): ResolvedDevice | undefined;
48
+ /**
49
+ * The most recent observation for a device.
50
+ *
51
+ * Undefined before the first status frame, and undefined again once the
52
+ * session has been failing long enough that the last reading should no
53
+ * longer be presented as current. Accessories treat both the same way: they
54
+ * report No Response rather than inventing a value.
55
+ */
56
+ observationFor(deviceId: string): ChannelObservation | undefined;
57
+ /** Send a command to an appliance. */
58
+ control(deviceId: string): ControlIntent;
59
+ /**
60
+ * Adopt the result of a write immediately.
61
+ *
62
+ * A control command is acknowledged long before a status frame reflects it,
63
+ * and a tile that springs back to its old value for a second looks like a
64
+ * write that failed. This lets an accessory publish what it just asked for,
65
+ * which the next real observation then confirms or corrects.
66
+ */
67
+ noteOptimisticWrite(deviceId: string, patch: Partial<ChannelObservation>): void;
68
+ }
69
+ /** What an accessory is handed when it is constructed. */
70
+ export interface AccessoryInit {
71
+ host: AccessoryHost;
72
+ accessory: PlatformAccessory;
73
+ deviceId: string;
74
+ displayName: string;
75
+ model: string;
76
+ serialNumber: string;
77
+ }
@@ -0,0 +1,23 @@
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 contract between an accessory and the platform.
9
+ *
10
+ * An explicit interface rather than a reference to the platform class, for two
11
+ * reasons. It breaks the import cycle that would otherwise exist between the
12
+ * platform and the accessories it creates. And it is the whole surface an
13
+ * accessory is allowed to reach, so a test can supply a dozen-line stand-in
14
+ * instead of standing up a platform, a cloud session and a socket.
15
+ *
16
+ * Note what an accessory cannot do through this interface: it cannot reach the
17
+ * MQTT connection, build a frame, or see the account. Every write goes through
18
+ * one of the named intents below, so the rules that govern writes (read-only
19
+ * mode, the power-off guard, rate limiting) are enforced in one place, not in
20
+ * each accessory that might forget. Setpoint coalescing is thermostat-only
21
+ * and lives on {@link ThermostatAccessory}.
22
+ */
23
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,17 @@
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 Devices barrel.
8
+ */
9
+ export * from './base-accessory';
10
+ export * from './dhw-accessory';
11
+ export * from './fault-accessory';
12
+ export * from './heating-accessory';
13
+ export * from './host';
14
+ export * from './power-accessory';
15
+ export * from './probe-accessory';
16
+ export * from './recirculation-accessory';
17
+ export * from './thermostat-accessory';
@@ -0,0 +1,33 @@
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 Devices barrel.
9
+ */
10
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ var desc = Object.getOwnPropertyDescriptor(m, k);
13
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
14
+ desc = { enumerable: true, get: function() { return m[k]; } };
15
+ }
16
+ Object.defineProperty(o, k2, desc);
17
+ }) : (function(o, m, k, k2) {
18
+ if (k2 === undefined) k2 = k;
19
+ o[k2] = m[k];
20
+ }));
21
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
22
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
23
+ };
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ __exportStar(require("./base-accessory"), exports);
26
+ __exportStar(require("./dhw-accessory"), exports);
27
+ __exportStar(require("./fault-accessory"), exports);
28
+ __exportStar(require("./heating-accessory"), exports);
29
+ __exportStar(require("./host"), exports);
30
+ __exportStar(require("./power-accessory"), exports);
31
+ __exportStar(require("./probe-accessory"), exports);
32
+ __exportStar(require("./recirculation-accessory"), exports);
33
+ __exportStar(require("./thermostat-accessory"), exports);
@@ -0,0 +1,32 @@
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 appliance's power switch.
8
+ *
9
+ * Deliberately a separate, opt-in accessory rather than something folded into
10
+ * a thermostat. Powering a combi appliance down stops the hot water *and* the
11
+ * central heating, so it deserves a tile that says what it is instead of
12
+ * hiding behind "off" on a tile labelled Hot Water.
13
+ *
14
+ * Turning it off still requires `options.allowPowerOff`. Asking for the switch
15
+ * is asking to see the state and to be able to switch the appliance back on;
16
+ * it is not, on its own, a decision that a scene should be able to shut the
17
+ * heating down. The two are separated because one of them is recoverable from
18
+ * a phone and the other is recoverable from a cold house.
19
+ */
20
+ import type { ChannelObservation, RefreshReason } from '../types';
21
+ import { BaseAccessory } from './base-accessory';
22
+ import type { AccessoryInit } from './host';
23
+ /** Appliance power, as a HomeKit switch. */
24
+ export declare class PowerAccessory extends BaseAccessory {
25
+ private readonly service;
26
+ private hasExplainedGuard;
27
+ constructor(init: AccessoryInit);
28
+ private writeOn;
29
+ protected updateFromObservation(observation: ChannelObservation, _reason: RefreshReason): void;
30
+ private restore;
31
+ private explainGuard;
32
+ }
@@ -0,0 +1,86 @@
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 appliance's power switch.
9
+ *
10
+ * Deliberately a separate, opt-in accessory rather than something folded into
11
+ * a thermostat. Powering a combi appliance down stops the hot water *and* the
12
+ * central heating, so it deserves a tile that says what it is instead of
13
+ * hiding behind "off" on a tile labelled Hot Water.
14
+ *
15
+ * Turning it off still requires `options.allowPowerOff`. Asking for the switch
16
+ * is asking to see the state and to be able to switch the appliance back on;
17
+ * it is not, on its own, a decision that a scene should be able to shut the
18
+ * heating down. The two are separated because one of them is recoverable from
19
+ * a phone and the other is recoverable from a cold house.
20
+ */
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.PowerAccessory = void 0;
23
+ const utils_1 = require("../utils");
24
+ const base_accessory_1 = require("./base-accessory");
25
+ /** Appliance power, as a HomeKit switch. */
26
+ class PowerAccessory extends base_accessory_1.BaseAccessory {
27
+ service;
28
+ hasExplainedGuard = false;
29
+ constructor(init) {
30
+ super(init);
31
+ const { Characteristic, Service: HapService } = this.host.hap;
32
+ this.service = this.requireService(HapService.Switch);
33
+ this.service.setCharacteristic(Characteristic.Name, this.displayName);
34
+ this.service
35
+ .getCharacteristic(Characteristic.On)
36
+ .onGet(() => this.requireObservedState().power)
37
+ .onSet(async (value) => this.writeOn(value));
38
+ }
39
+ async writeOn(value) {
40
+ const wantOn = value === true;
41
+ if (this.declineIfReadOnly('change the power')) {
42
+ this.restore();
43
+ return;
44
+ }
45
+ const observation = this.host.observationFor(this.deviceId);
46
+ if (observation !== undefined && observation.power === wantOn) {
47
+ this.host.log.debug(`${(0, utils_1.forLog)(this.displayName)}: already ${wantOn ? 'on' : 'off'}; nothing sent`);
48
+ return;
49
+ }
50
+ await this.completeWithinBudget('power change', async () => {
51
+ try {
52
+ await this.host.control(this.deviceId).setPower(wantOn);
53
+ this.host.noteOptimisticWrite(this.deviceId, { power: wantOn });
54
+ this.logAction(wantOn ? 'POWER ON' : 'POWER OFF');
55
+ }
56
+ catch (error) {
57
+ if (error instanceof utils_1.ControlRejectedError) {
58
+ this.explainGuard(error);
59
+ }
60
+ else {
61
+ this.host.log.warn(`${(0, utils_1.forLog)(this.displayName)}: could not change power: ${(0, utils_1.describeError)(error)}`);
62
+ }
63
+ this.restore();
64
+ }
65
+ });
66
+ }
67
+ updateFromObservation(observation, _reason) {
68
+ this.publish(this.service, this.host.hap.Characteristic.On, observation.power);
69
+ }
70
+ restore() {
71
+ const observation = this.host.observationFor(this.deviceId);
72
+ if (observation === undefined) {
73
+ return;
74
+ }
75
+ this.service.updateCharacteristic(this.host.hap.Characteristic.On, observation.power);
76
+ }
77
+ explainGuard(error) {
78
+ if (this.hasExplainedGuard) {
79
+ this.host.log.debug(`${(0, utils_1.forLog)(this.displayName)}: ${error.message}`);
80
+ return;
81
+ }
82
+ this.hasExplainedGuard = true;
83
+ this.host.log.info(`${(0, utils_1.forLog)(this.displayName)}: ${error.message}`);
84
+ }
85
+ }
86
+ exports.PowerAccessory = PowerAccessory;
@@ -0,0 +1,45 @@
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 One of the appliance's temperature probes, as a HomeKit sensor.
8
+ *
9
+ * Five of them share this implementation, differing only in which field they
10
+ * read: the hot water inlet and outlet, the heating flow and return, and the
11
+ * outdoor sensor where one is fitted. They are read-only, and useful mainly
12
+ * for graphs and for automations that want to know whether the appliance is
13
+ * doing anything.
14
+ *
15
+ * The flow and return pair is the interesting one. The difference between them
16
+ * is what the appliance is putting into the house, so a HomeKit automation can
17
+ * notice heating running when nobody asked it to.
18
+ *
19
+ * ## Probes that are not fitted
20
+ *
21
+ * Whether a probe exists is only knowable from a live status frame, and the
22
+ * frames arrive after accessories are registered. So a sensor can be created
23
+ * for something that does not exist. When that turns out to be the case it
24
+ * says so once and reports No Response. It does not publish the zero the
25
+ * appliance sends for an absent probe, which would be -17.8 °C on the tile
26
+ * and could be acted on by an automation.
27
+ */
28
+ import type { ChannelObservation, ProbeKind, RefreshReason } from '../types';
29
+ import { BaseAccessory } from './base-accessory';
30
+ import type { AccessoryInit } from './host';
31
+ /** What an appliance probe is created with. */
32
+ export interface ProbeAccessoryInit extends AccessoryInit {
33
+ kind: ProbeKind;
34
+ }
35
+ /** One appliance temperature probe, as a HomeKit sensor. */
36
+ export declare class ProbeAccessory extends BaseAccessory {
37
+ private readonly service;
38
+ private readonly kind;
39
+ private hasWarnedAbsent;
40
+ constructor(init: ProbeAccessoryInit);
41
+ private readNative;
42
+ private readCelsius;
43
+ protected updateFromObservation(observation: ChannelObservation, _reason: RefreshReason): void;
44
+ private warnAbsent;
45
+ }
@@ -0,0 +1,108 @@
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 One of the appliance's temperature probes, as a HomeKit sensor.
9
+ *
10
+ * Five of them share this implementation, differing only in which field they
11
+ * read: the hot water inlet and outlet, the heating flow and return, and the
12
+ * outdoor sensor where one is fitted. They are read-only, and useful mainly
13
+ * for graphs and for automations that want to know whether the appliance is
14
+ * doing anything.
15
+ *
16
+ * The flow and return pair is the interesting one. The difference between them
17
+ * is what the appliance is putting into the house, so a HomeKit automation can
18
+ * notice heating running when nobody asked it to.
19
+ *
20
+ * ## Probes that are not fitted
21
+ *
22
+ * Whether a probe exists is only knowable from a live status frame, and the
23
+ * frames arrive after accessories are registered. So a sensor can be created
24
+ * for something that does not exist. When that turns out to be the case it
25
+ * says so once and reports No Response. It does not publish the zero the
26
+ * appliance sends for an absent probe, which would be -17.8 °C on the tile
27
+ * and could be acted on by an automation.
28
+ */
29
+ Object.defineProperty(exports, "__esModule", { value: true });
30
+ exports.ProbeAccessory = void 0;
31
+ const utils_1 = require("../utils");
32
+ const base_accessory_1 = require("./base-accessory");
33
+ /** Which observation field each probe accessory reports. */
34
+ const FIELD_BY_KIND = {
35
+ dhwOutlet: 'dhwOutlet',
36
+ dhwInlet: 'dhwInlet',
37
+ heatSupply: 'heatSupply',
38
+ heatReturn: 'heatReturn',
39
+ outdoor: 'outdoor',
40
+ };
41
+ /** What to say when a probe turns out not to exist. */
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',
48
+ };
49
+ /** One appliance temperature probe, as a HomeKit sensor. */
50
+ class ProbeAccessory extends base_accessory_1.BaseAccessory {
51
+ service;
52
+ kind;
53
+ hasWarnedAbsent = false;
54
+ constructor(init) {
55
+ super(init);
56
+ this.kind = init.kind;
57
+ const { Characteristic, Service: HapService } = this.host.hap;
58
+ this.service = this.requireService(HapService.TemperatureSensor);
59
+ this.service.setCharacteristic(Characteristic.Name, this.displayName);
60
+ this.service
61
+ .getCharacteristic(Characteristic.CurrentTemperature)
62
+ .onGet(() => this.readCelsius());
63
+ // Published so a controller that honours it can grey the sensor out
64
+ // rather than showing a stale reading for a probe that has gone away.
65
+ this.service
66
+ .getCharacteristic(Characteristic.StatusActive)
67
+ .onGet(() => this.readNative() !== undefined);
68
+ }
69
+ readNative() {
70
+ const observation = this.host.observationFor(this.deviceId);
71
+ if (observation === undefined) {
72
+ return undefined;
73
+ }
74
+ const value = observation[FIELD_BY_KIND[this.kind]];
75
+ return typeof value === 'number' ? value : undefined;
76
+ }
77
+ readCelsius() {
78
+ const observation = this.requireObservedState();
79
+ const native = this.readNative();
80
+ if (native === undefined) {
81
+ throw this.communicationFailure();
82
+ }
83
+ return (0, utils_1.nativeToCelsius)(native, observation.scale);
84
+ }
85
+ updateFromObservation(observation, _reason) {
86
+ const { Characteristic } = this.host.hap;
87
+ const value = observation[FIELD_BY_KIND[this.kind]];
88
+ const native = typeof value === 'number' ? value : undefined;
89
+ this.publish(this.service, Characteristic.StatusActive, native !== undefined);
90
+ if (native === undefined) {
91
+ this.warnAbsent();
92
+ this.markUnavailable();
93
+ // StatusActive is the honest "nothing here" flag; keep it after the
94
+ // temperature characteristic has been greyed.
95
+ this.publish(this.service, Characteristic.StatusActive, false);
96
+ return;
97
+ }
98
+ this.publish(this.service, Characteristic.CurrentTemperature, (0, utils_1.nativeToCelsius)(native, observation.scale));
99
+ }
100
+ warnAbsent() {
101
+ if (this.hasWarnedAbsent) {
102
+ return;
103
+ }
104
+ this.hasWarnedAbsent = true;
105
+ this.host.log.warn(`${(0, utils_1.forLog)(this.displayName)}: ${ABSENCE_HINT[this.kind]}`);
106
+ }
107
+ }
108
+ exports.ProbeAccessory = ProbeAccessory;
@@ -0,0 +1,47 @@
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 hot water recirculation switch.
8
+ *
9
+ * On an installation with a recirculation pump (Navien's "HotButton" or
10
+ * on-demand feature) this starts it, so the water at a distant tap is hot by
11
+ * the time someone gets there. It is the one accessory here with an obvious
12
+ * and genuinely useful Siri phrase: "turn on the hot water" before walking to
13
+ * the shower.
14
+ *
15
+ * ## It switches itself off, and that is correct
16
+ *
17
+ * The appliance runs the pump for a fixed period and then stops. The tile
18
+ * follows, because it reports what the appliance says, not what was asked
19
+ * for. A user who has not met this feature before will read the tile
20
+ * turning itself off as a bug, which is why the README explains it.
21
+ *
22
+ * ## It disables itself on hardware that has no pump
23
+ *
24
+ * Whether a pump is fitted is only knowable from a live `channelinfo` frame,
25
+ * which arrives after accessories are registered. So the accessory can be
26
+ * configured on an appliance that cannot do it, and when that turns out to be
27
+ * the case it says so once and reports No Response from then on. Silently
28
+ * accepting presses that do nothing would be worse.
29
+ */
30
+ import type { ChannelObservation, RefreshReason } from '../types';
31
+ import { BaseAccessory } from './base-accessory';
32
+ import type { AccessoryInit } from './host';
33
+ /** Hot water recirculation, as a HomeKit switch. */
34
+ export declare class RecirculationAccessory extends BaseAccessory {
35
+ private readonly service;
36
+ /** Undefined until the first frame says whether a pump is fitted. */
37
+ private isEquipped;
38
+ private hasWarnedNotEquipped;
39
+ constructor(init: AccessoryInit);
40
+ private readOn;
41
+ private writeOn;
42
+ protected updateFromObservation(observation: ChannelObservation, _reason: RefreshReason): void;
43
+ /** Fitted state from a live frame, or from the last observation if none yet. */
44
+ private equippedState;
45
+ private restore;
46
+ private warnNotEquipped;
47
+ }