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,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
|
+
}
|