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.
- package/CHANGELOG.md +5 -0
- package/LICENSE +202 -0
- package/README.md +161 -0
- package/SECURITY.md +73 -0
- package/config.schema.json +138 -0
- package/dist/api/channel.d.ts +94 -0
- package/dist/api/channel.js +334 -0
- package/dist/api/http.d.ts +68 -0
- package/dist/api/http.js +205 -0
- package/dist/api/identity.d.ts +51 -0
- package/dist/api/identity.js +80 -0
- package/dist/api/index.d.ts +17 -0
- package/dist/api/index.js +33 -0
- package/dist/api/mqtt-codec.d.ts +181 -0
- package/dist/api/mqtt-codec.js +377 -0
- package/dist/api/mqtt.d.ts +178 -0
- package/dist/api/mqtt.js +450 -0
- package/dist/api/protocol.d.ts +212 -0
- package/dist/api/protocol.js +286 -0
- package/dist/api/rest.d.ts +139 -0
- package/dist/api/rest.js +317 -0
- package/dist/api/sigv4.d.ts +70 -0
- package/dist/api/sigv4.js +123 -0
- package/dist/api/topics.d.ts +75 -0
- package/dist/api/topics.js +90 -0
- package/dist/devices/base-accessory.d.ts +127 -0
- package/dist/devices/base-accessory.js +232 -0
- package/dist/devices/dhw-accessory.d.ts +62 -0
- package/dist/devices/dhw-accessory.js +100 -0
- package/dist/devices/fault-accessory.d.ts +31 -0
- package/dist/devices/fault-accessory.js +73 -0
- package/dist/devices/heating-accessory.d.ts +63 -0
- package/dist/devices/heating-accessory.js +128 -0
- package/dist/devices/host.d.ts +77 -0
- package/dist/devices/host.js +23 -0
- package/dist/devices/index.d.ts +17 -0
- package/dist/devices/index.js +33 -0
- package/dist/devices/power-accessory.d.ts +32 -0
- package/dist/devices/power-accessory.js +86 -0
- package/dist/devices/probe-accessory.d.ts +45 -0
- package/dist/devices/probe-accessory.js +108 -0
- package/dist/devices/recirculation-accessory.d.ts +47 -0
- package/dist/devices/recirculation-accessory.js +122 -0
- package/dist/devices/thermostat-accessory.d.ts +129 -0
- package/dist/devices/thermostat-accessory.js +372 -0
- package/dist/discovery.d.ts +99 -0
- package/dist/discovery.js +423 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +15 -0
- package/dist/platform.d.ts +120 -0
- package/dist/platform.js +425 -0
- package/dist/session.d.ts +243 -0
- package/dist/session.js +717 -0
- package/dist/settings.d.ts +218 -0
- package/dist/settings.js +240 -0
- package/dist/types/index.d.ts +265 -0
- package/dist/types/index.js +58 -0
- package/dist/ui-api.d.ts +27 -0
- package/dist/ui-api.js +39 -0
- package/dist/utils/context.d.ts +32 -0
- package/dist/utils/context.js +74 -0
- package/dist/utils/errors.d.ts +69 -0
- package/dist/utils/errors.js +118 -0
- package/dist/utils/index.d.ts +15 -0
- package/dist/utils/index.js +31 -0
- package/dist/utils/redact.d.ts +85 -0
- package/dist/utils/redact.js +210 -0
- package/dist/utils/serial.d.ts +18 -0
- package/dist/utils/serial.js +24 -0
- package/dist/utils/temperature.d.ts +114 -0
- package/dist/utils/temperature.js +150 -0
- package/dist/utils/timing.d.ts +68 -0
- package/dist/utils/timing.js +91 -0
- package/dist/utils/validators.d.ts +96 -0
- package/dist/utils/validators.js +353 -0
- package/docs/FEATURES.md +80 -0
- package/docs/PROTOCOL.md +200 -0
- package/docs/README-DETAILED.md +312 -0
- package/homebridge-ui/public/index.html +123 -0
- package/homebridge-ui/public/index.js +503 -0
- package/homebridge-ui/server.js +174 -0
- 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;
|