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,94 @@
|
|
|
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 Turning NaviLink status frames into observations.
|
|
8
|
+
*
|
|
9
|
+
* Two frame types carry everything the plugin knows. `channelinfo` is the
|
|
10
|
+
* appliance's description of itself: what family it is, which scale it
|
|
11
|
+
* speaks, what the installer set the setpoint limits to, whether a
|
|
12
|
+
* recirculation pump is fitted. It changes only at commissioning.
|
|
13
|
+
* `channelstatus` is what it is doing now, and arrives continuously.
|
|
14
|
+
*
|
|
15
|
+
* They must be combined, not read separately: a setpoint from `channelstatus`
|
|
16
|
+
* is meaningless without the `temperatureType` from `channelinfo`, because the
|
|
17
|
+
* same integer is 47.5 °C on one appliance and 95 °F on another.
|
|
18
|
+
*
|
|
19
|
+
* Every field name here was read off a live NCB-240E. Where a field's meaning
|
|
20
|
+
* is inferred rather than observed, it says so, and the plugin does not make a
|
|
21
|
+
* decision on it. Where the appliance contradicts the obvious reading of a
|
|
22
|
+
* field, the contradiction is recorded, not resolved. See
|
|
23
|
+
* {@link hasDomesticHotWater} for `DHWUse`.
|
|
24
|
+
*/
|
|
25
|
+
import type { ApplianceFamily, ChannelObservation } from '../types';
|
|
26
|
+
import { type TemperatureScale } from '../utils/temperature';
|
|
27
|
+
/** True for a flag field the appliance set to its "on" value. */
|
|
28
|
+
export declare function isFlagOn(value: unknown): boolean;
|
|
29
|
+
/** A `channelinfo` frame's per-channel description. */
|
|
30
|
+
export interface ChannelInfo {
|
|
31
|
+
channelNumber: number;
|
|
32
|
+
raw: Readonly<Record<string, unknown>>;
|
|
33
|
+
}
|
|
34
|
+
/** A `channelstatus` frame's per-channel state. */
|
|
35
|
+
export interface ChannelStatus {
|
|
36
|
+
channelNumber: number;
|
|
37
|
+
raw: Readonly<Record<string, unknown>>;
|
|
38
|
+
}
|
|
39
|
+
/** Read the channel descriptions out of a `channelinfo` frame. */
|
|
40
|
+
export declare function parseChannelInfo(frame: unknown): ChannelInfo[];
|
|
41
|
+
/** Read the channel state out of a `channelstatus` frame. */
|
|
42
|
+
export declare function parseChannelStatus(frame: unknown): ChannelStatus | undefined;
|
|
43
|
+
/** The scale an appliance speaks, from its `channelinfo`. */
|
|
44
|
+
export declare function scaleOf(info: ChannelInfo): TemperatureScale | undefined;
|
|
45
|
+
/** The family an appliance belongs to, from either frame. */
|
|
46
|
+
export declare function familyOf(raw: Readonly<Record<string, unknown>>): ApplianceFamily;
|
|
47
|
+
/**
|
|
48
|
+
* Spell a family the way HomeKit and the settings page show it.
|
|
49
|
+
*
|
|
50
|
+
* Underscores become hyphens so `NCB_H` is `NCB-H` in both places.
|
|
51
|
+
*/
|
|
52
|
+
export declare function formatFamily(family: ApplianceFamily): string;
|
|
53
|
+
/**
|
|
54
|
+
* True when this family heats domestic hot water.
|
|
55
|
+
*
|
|
56
|
+
* Decided from the family table, not from `DHWUse`. The verified NCB-240E
|
|
57
|
+
* reports `DHWUse: 2` while it is a working combi with a usable DHW setpoint
|
|
58
|
+
* range. Reading that flag as "DHW absent" would hide the accessory this
|
|
59
|
+
* plugin exists to offer. Family membership is the only signal that has
|
|
60
|
+
* matched the hardware so far.
|
|
61
|
+
*/
|
|
62
|
+
export declare function hasDomesticHotWater(family: ApplianceFamily): boolean;
|
|
63
|
+
/** True when this family heats a space-heating loop. */
|
|
64
|
+
export declare function hasSpaceHeating(family: ApplianceFamily): boolean;
|
|
65
|
+
/**
|
|
66
|
+
* True when this particular appliance has a space-heating loop worth
|
|
67
|
+
* exposing.
|
|
68
|
+
*
|
|
69
|
+
* The family is necessary but not sufficient. A tankless water heater answers
|
|
70
|
+
* exactly the same frames as a combi and fills the heating fields with
|
|
71
|
+
* placeholders rather than omitting them: an NPE-2 has been observed
|
|
72
|
+
* reporting `setupHeatTempMin` and `setupHeatTempMax` both as 32, which is
|
|
73
|
+
* not a one-degree range, it is "there is nothing here".
|
|
74
|
+
*
|
|
75
|
+
* So three things have to agree. The family must be one that can heat a
|
|
76
|
+
* loop, the commissioning flag must say heating is configured, and the
|
|
77
|
+
* installer's bounds must describe a real range. Any one of them alone would
|
|
78
|
+
* put a heating thermostat on a water heater.
|
|
79
|
+
*/
|
|
80
|
+
export declare function hasUsableHeatingLoop(description: Readonly<Record<string, unknown>>): boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Combine a description and a state into one observation.
|
|
83
|
+
*
|
|
84
|
+
* Returns undefined when the pair cannot be read as a coherent appliance,
|
|
85
|
+
* which in practice means `channelinfo` has not arrived yet or reported
|
|
86
|
+
* `temperatureType: 0`. Every temperature in a status frame is unreadable
|
|
87
|
+
* without the scale, so producing a partial observation would mean publishing
|
|
88
|
+
* numbers that are wrong by a factor of two.
|
|
89
|
+
*/
|
|
90
|
+
export declare function decodeChannel(input: {
|
|
91
|
+
info: ChannelInfo;
|
|
92
|
+
status: ChannelStatus;
|
|
93
|
+
now?: number;
|
|
94
|
+
}): ChannelObservation | undefined;
|
|
@@ -0,0 +1,334 @@
|
|
|
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 Turning NaviLink status frames into observations.
|
|
9
|
+
*
|
|
10
|
+
* Two frame types carry everything the plugin knows. `channelinfo` is the
|
|
11
|
+
* appliance's description of itself: what family it is, which scale it
|
|
12
|
+
* speaks, what the installer set the setpoint limits to, whether a
|
|
13
|
+
* recirculation pump is fitted. It changes only at commissioning.
|
|
14
|
+
* `channelstatus` is what it is doing now, and arrives continuously.
|
|
15
|
+
*
|
|
16
|
+
* They must be combined, not read separately: a setpoint from `channelstatus`
|
|
17
|
+
* is meaningless without the `temperatureType` from `channelinfo`, because the
|
|
18
|
+
* same integer is 47.5 °C on one appliance and 95 °F on another.
|
|
19
|
+
*
|
|
20
|
+
* Every field name here was read off a live NCB-240E. Where a field's meaning
|
|
21
|
+
* is inferred rather than observed, it says so, and the plugin does not make a
|
|
22
|
+
* decision on it. Where the appliance contradicts the obvious reading of a
|
|
23
|
+
* field, the contradiction is recorded, not resolved. See
|
|
24
|
+
* {@link hasDomesticHotWater} for `DHWUse`.
|
|
25
|
+
*/
|
|
26
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
27
|
+
exports.isFlagOn = isFlagOn;
|
|
28
|
+
exports.parseChannelInfo = parseChannelInfo;
|
|
29
|
+
exports.parseChannelStatus = parseChannelStatus;
|
|
30
|
+
exports.scaleOf = scaleOf;
|
|
31
|
+
exports.familyOf = familyOf;
|
|
32
|
+
exports.formatFamily = formatFamily;
|
|
33
|
+
exports.hasDomesticHotWater = hasDomesticHotWater;
|
|
34
|
+
exports.hasSpaceHeating = hasSpaceHeating;
|
|
35
|
+
exports.hasUsableHeatingLoop = hasUsableHeatingLoop;
|
|
36
|
+
exports.decodeChannel = decodeChannel;
|
|
37
|
+
const settings_1 = require("../settings");
|
|
38
|
+
const temperature_1 = require("../utils/temperature");
|
|
39
|
+
/**
|
|
40
|
+
* `unitType` to family.
|
|
41
|
+
*
|
|
42
|
+
* The same table the NaviLink app uses. `CAS_` is a
|
|
43
|
+
* cascade: several appliances presented as one channel.
|
|
44
|
+
*/
|
|
45
|
+
const FAMILY_BY_UNIT_TYPE = {
|
|
46
|
+
1: 'NPE',
|
|
47
|
+
2: 'NCB',
|
|
48
|
+
3: 'NHB',
|
|
49
|
+
4: 'CAS_NPE',
|
|
50
|
+
5: 'CAS_NHB',
|
|
51
|
+
6: 'NFB',
|
|
52
|
+
7: 'CAS_NFB',
|
|
53
|
+
8: 'NFC',
|
|
54
|
+
9: 'NPN',
|
|
55
|
+
10: 'CAS_NPN',
|
|
56
|
+
11: 'NPE2',
|
|
57
|
+
12: 'CAS_NPE2',
|
|
58
|
+
13: 'NCB_H',
|
|
59
|
+
14: 'NVW',
|
|
60
|
+
15: 'CAS_NVW',
|
|
61
|
+
};
|
|
62
|
+
/** Families that heat domestic hot water. */
|
|
63
|
+
const DHW_FAMILIES = new Set([
|
|
64
|
+
'NPE', 'NPN', 'NPE2', 'NCB', 'NFC', 'NCB_H', 'NFB', 'NVW',
|
|
65
|
+
'CAS_NPE', 'CAS_NPN', 'CAS_NPE2', 'CAS_NFB', 'CAS_NVW',
|
|
66
|
+
]);
|
|
67
|
+
/** Families that heat a space-heating loop. */
|
|
68
|
+
const SPACE_HEAT_FAMILIES = new Set([
|
|
69
|
+
'NHB', 'CAS_NHB', 'NFB', 'CAS_NFB', 'NCB', 'NFC', 'NCB_H',
|
|
70
|
+
]);
|
|
71
|
+
/**
|
|
72
|
+
* The value meaning "on" or "fitted" in the appliance's flag fields.
|
|
73
|
+
*
|
|
74
|
+
* Confirmed across several unrelated fields on one appliance: `heatControl`,
|
|
75
|
+
* `powerStatus` and `heatStatus` all read `1` while the appliance was on and
|
|
76
|
+
* heating, and `wwsd`, `commercialLock`, `weeklyControl`, `onDemandUse` and
|
|
77
|
+
* `recirculationUse` all read `2` on an installation where none of those
|
|
78
|
+
* features is fitted or enabled. So `1` is on, `2` is off.
|
|
79
|
+
*/
|
|
80
|
+
const FLAG_ON = 1;
|
|
81
|
+
/** True for a flag field the appliance set to its "on" value. */
|
|
82
|
+
function isFlagOn(value) {
|
|
83
|
+
return value === FLAG_ON;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* True for a probe reading the appliance is not actually making.
|
|
87
|
+
*
|
|
88
|
+
* Exactly zero is how this firmware reports "no probe fitted": the NCB-240E
|
|
89
|
+
* measured here has no outdoor sensor and sends `outdoorTemperature: 0`, and
|
|
90
|
+
* sends `0` for the recirculation probe it also does not have, while sending
|
|
91
|
+
* real values for the four probes it does have.
|
|
92
|
+
*
|
|
93
|
+
* This does mean a genuine reading of exactly 0 °F or 0 °C is discarded. That
|
|
94
|
+
* is the right trade: these are probes inside a boiler and on its flow and
|
|
95
|
+
* return, so zero is not an operating value, and the alternative is
|
|
96
|
+
* publishing -17.8 °C to HomeKit for a sensor that does not exist. That
|
|
97
|
+
* looks like a fault, and an automation could act on it.
|
|
98
|
+
*/
|
|
99
|
+
function isAbsentProbe(raw) {
|
|
100
|
+
return raw === 0;
|
|
101
|
+
}
|
|
102
|
+
/** Read the channel descriptions out of a `channelinfo` frame. */
|
|
103
|
+
function parseChannelInfo(frame) {
|
|
104
|
+
const list = asRecord(asRecord(asRecord(frame)?.response)?.channelInfo)?.channelList;
|
|
105
|
+
if (!Array.isArray(list)) {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
const found = new Map();
|
|
109
|
+
for (const entry of list) {
|
|
110
|
+
const wrapper = asRecord(entry);
|
|
111
|
+
const channel = asRecord(wrapper?.channel) ?? wrapper;
|
|
112
|
+
if (channel === undefined) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const channelNumber = readInteger(wrapper?.channelNumber ?? channel.channelNumber);
|
|
116
|
+
if (channelNumber === undefined) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
// A Map rather than an array: a gateway can repeat a channel across
|
|
120
|
+
// frames, and two entries for one channel would produce two accessories.
|
|
121
|
+
found.set(channelNumber, { channelNumber, raw: channel });
|
|
122
|
+
}
|
|
123
|
+
return [...found.values()];
|
|
124
|
+
}
|
|
125
|
+
/** Read the channel state out of a `channelstatus` frame. */
|
|
126
|
+
function parseChannelStatus(frame) {
|
|
127
|
+
const wrapper = asRecord(asRecord(asRecord(frame)?.response)?.channelStatus);
|
|
128
|
+
if (wrapper === undefined) {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
const channel = asRecord(wrapper.channel) ?? wrapper;
|
|
132
|
+
const channelNumber = readInteger(wrapper.channelNumber);
|
|
133
|
+
if (channelNumber === undefined) {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
channelNumber,
|
|
138
|
+
raw: channel,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/** The scale an appliance speaks, from its `channelinfo`. */
|
|
142
|
+
function scaleOf(info) {
|
|
143
|
+
return (0, temperature_1.scaleFromTemperatureType)(info.raw.temperatureType);
|
|
144
|
+
}
|
|
145
|
+
/** The family an appliance belongs to, from either frame. */
|
|
146
|
+
function familyOf(raw) {
|
|
147
|
+
const unitType = readInteger(raw.unitType);
|
|
148
|
+
return (unitType === undefined ? undefined : FAMILY_BY_UNIT_TYPE[unitType]) ?? 'UNKNOWN';
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Spell a family the way HomeKit and the settings page show it.
|
|
152
|
+
*
|
|
153
|
+
* Underscores become hyphens so `NCB_H` is `NCB-H` in both places.
|
|
154
|
+
*/
|
|
155
|
+
function formatFamily(family) {
|
|
156
|
+
return family.replaceAll('_', '-');
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* True when this family heats domestic hot water.
|
|
160
|
+
*
|
|
161
|
+
* Decided from the family table, not from `DHWUse`. The verified NCB-240E
|
|
162
|
+
* reports `DHWUse: 2` while it is a working combi with a usable DHW setpoint
|
|
163
|
+
* range. Reading that flag as "DHW absent" would hide the accessory this
|
|
164
|
+
* plugin exists to offer. Family membership is the only signal that has
|
|
165
|
+
* matched the hardware so far.
|
|
166
|
+
*/
|
|
167
|
+
function hasDomesticHotWater(family) {
|
|
168
|
+
return DHW_FAMILIES.has(family);
|
|
169
|
+
}
|
|
170
|
+
/** True when this family heats a space-heating loop. */
|
|
171
|
+
function hasSpaceHeating(family) {
|
|
172
|
+
return SPACE_HEAT_FAMILIES.has(family);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* True when this particular appliance has a space-heating loop worth
|
|
176
|
+
* exposing.
|
|
177
|
+
*
|
|
178
|
+
* The family is necessary but not sufficient. A tankless water heater answers
|
|
179
|
+
* exactly the same frames as a combi and fills the heating fields with
|
|
180
|
+
* placeholders rather than omitting them: an NPE-2 has been observed
|
|
181
|
+
* reporting `setupHeatTempMin` and `setupHeatTempMax` both as 32, which is
|
|
182
|
+
* not a one-degree range, it is "there is nothing here".
|
|
183
|
+
*
|
|
184
|
+
* So three things have to agree. The family must be one that can heat a
|
|
185
|
+
* loop, the commissioning flag must say heating is configured, and the
|
|
186
|
+
* installer's bounds must describe a real range. Any one of them alone would
|
|
187
|
+
* put a heating thermostat on a water heater.
|
|
188
|
+
*/
|
|
189
|
+
function hasUsableHeatingLoop(description) {
|
|
190
|
+
if (!hasSpaceHeating(familyOf(description))) {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
if (!isFlagOn(description.heatControl)) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
const min = readInteger(description.setupHeatTempMin);
|
|
197
|
+
const max = readInteger(description.setupHeatTempMax);
|
|
198
|
+
return min !== undefined && max !== undefined && max > min;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Combine a description and a state into one observation.
|
|
202
|
+
*
|
|
203
|
+
* Returns undefined when the pair cannot be read as a coherent appliance,
|
|
204
|
+
* which in practice means `channelinfo` has not arrived yet or reported
|
|
205
|
+
* `temperatureType: 0`. Every temperature in a status frame is unreadable
|
|
206
|
+
* without the scale, so producing a partial observation would mean publishing
|
|
207
|
+
* numbers that are wrong by a factor of two.
|
|
208
|
+
*/
|
|
209
|
+
function decodeChannel(input) {
|
|
210
|
+
const { info, status, now = Date.now() } = input;
|
|
211
|
+
const scale = scaleOf(info);
|
|
212
|
+
if (scale === undefined) {
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
const description = info.raw;
|
|
216
|
+
const state = status.raw;
|
|
217
|
+
const unit = firstUnitStatus(state);
|
|
218
|
+
const faulted = firstFaultedUnit(state);
|
|
219
|
+
const temperature = (raw) => ((0, temperature_1.decodeWireTemperature)(raw, scale));
|
|
220
|
+
const probe = (raw) => (isAbsentProbe(raw) ? undefined : (0, temperature_1.decodeWireTemperature)(raw, scale));
|
|
221
|
+
const observation = {
|
|
222
|
+
channelNumber: status.channelNumber,
|
|
223
|
+
family: familyOf(description),
|
|
224
|
+
scale,
|
|
225
|
+
unitCount: clampUnitCount(readInteger(description.unitCount) ?? readInteger(state.unitCount)),
|
|
226
|
+
power: isFlagOn(state.powerStatus),
|
|
227
|
+
heating: isFlagOn(state.heatStatus),
|
|
228
|
+
// The enable flag, not the burner. No field in this frame distinguishes
|
|
229
|
+
// "heating is switched on" from "the burner is firing right now":
|
|
230
|
+
// `operationMode` read 0 on an appliance that was on and warm, so
|
|
231
|
+
// nothing is decided on it. HomeKit is
|
|
232
|
+
// therefore told the appliance is heating whenever heating is enabled,
|
|
233
|
+
// which is the honest reading of what we can see.
|
|
234
|
+
heatingActive: isFlagOn(state.heatStatus),
|
|
235
|
+
heatingSupported: hasUsableHeatingLoop(description),
|
|
236
|
+
recirculationEquipped: isFlagOn(description.onDemandUse)
|
|
237
|
+
|| isFlagOn(description.recirculationUse),
|
|
238
|
+
recirculationOn: isFlagOn(state.onDemandUseFlag),
|
|
239
|
+
readings: readUnitReadings(unit, faulted),
|
|
240
|
+
observedAt: now,
|
|
241
|
+
};
|
|
242
|
+
assignDefined(observation, {
|
|
243
|
+
dhwSetpoint: temperature(state.DHWSettingTemp),
|
|
244
|
+
dhwMin: temperature(description.setupDHWTempMin),
|
|
245
|
+
dhwMax: temperature(description.setupDHWTempMax),
|
|
246
|
+
heatSetpoint: temperature(state.heatSettingTemp),
|
|
247
|
+
heatMin: temperature(description.setupHeatTempMin),
|
|
248
|
+
heatMax: temperature(description.setupHeatTempMax),
|
|
249
|
+
// The averages rather than the per-unit readings. On a single appliance
|
|
250
|
+
// they are the same number; on a cascade the average is the one that
|
|
251
|
+
// describes what the installation is doing, which is what a tile should
|
|
252
|
+
// show.
|
|
253
|
+
dhwOutlet: probe(state.avgOutletTemp ?? unit?.currentOutletTemp),
|
|
254
|
+
dhwInlet: probe(state.avgInletTemp ?? unit?.currentInletTemp),
|
|
255
|
+
heatSupply: probe(state.avgSupplyTemp ?? unit?.currentSupplyTemp),
|
|
256
|
+
heatReturn: probe(state.avgReturnTemp ?? unit?.currentReturnTemp),
|
|
257
|
+
outdoor: probe(state.outdoorTemperature),
|
|
258
|
+
});
|
|
259
|
+
return observation;
|
|
260
|
+
}
|
|
261
|
+
/** Every unit status the frame named, skipping entries that are not objects. */
|
|
262
|
+
function unitStatusList(state) {
|
|
263
|
+
const list = asRecord(state.unitInfo)?.unitStatusList;
|
|
264
|
+
if (!Array.isArray(list)) {
|
|
265
|
+
return [];
|
|
266
|
+
}
|
|
267
|
+
return list.flatMap((entry) => {
|
|
268
|
+
const record = asRecord(entry);
|
|
269
|
+
return record === undefined ? [] : [record];
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* The first unit's non-fault readings.
|
|
274
|
+
*
|
|
275
|
+
* A cascade reports one entry per appliance. Controller firmware is taken
|
|
276
|
+
* from the first unit; the error code is taken from the first unit that is
|
|
277
|
+
* actually faulted, so a later burner opening a lockout still opens the
|
|
278
|
+
* contact sensor.
|
|
279
|
+
*/
|
|
280
|
+
function firstUnitStatus(state) {
|
|
281
|
+
return unitStatusList(state)[0];
|
|
282
|
+
}
|
|
283
|
+
/** The first unit reporting a non-zero error, or the first unit if none is. */
|
|
284
|
+
function firstFaultedUnit(state) {
|
|
285
|
+
const units = unitStatusList(state);
|
|
286
|
+
return units.find((unit) => (readInteger(unit.errorCode) ?? 0) !== 0) ?? units[0];
|
|
287
|
+
}
|
|
288
|
+
function readUnitReadings(unit, faulted = unit) {
|
|
289
|
+
const readings = {
|
|
290
|
+
errorCode: readInteger(faulted?.errorCode) ?? 0,
|
|
291
|
+
subErrorCode: readInteger(faulted?.subErrorCode) ?? 0,
|
|
292
|
+
};
|
|
293
|
+
const controllerVersion = readInteger(unit?.controllerVersion);
|
|
294
|
+
if (controllerVersion !== undefined) {
|
|
295
|
+
readings.controllerVersion = controllerVersion;
|
|
296
|
+
}
|
|
297
|
+
return readings;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Bound the reported unit count.
|
|
301
|
+
*
|
|
302
|
+
* It decides how many units a status request asks for, so a malformed or
|
|
303
|
+
* hostile frame must not be able to turn one request into an enormous one.
|
|
304
|
+
*/
|
|
305
|
+
function clampUnitCount(value) {
|
|
306
|
+
if (value === undefined || value < 1) {
|
|
307
|
+
return 1;
|
|
308
|
+
}
|
|
309
|
+
return Math.min(value, settings_1.MAX_UNITS);
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Copy only the defined entries.
|
|
313
|
+
*
|
|
314
|
+
* `exactOptionalPropertyTypes` is not on, but the distinction still matters at
|
|
315
|
+
* runtime: an explicit `dhwSetpoint: undefined` and an absent `dhwSetpoint`
|
|
316
|
+
* behave identically to a reader using `?.`, and differently to one using
|
|
317
|
+
* `in`. Assigning only what is present keeps the observation honest about
|
|
318
|
+
* what the appliance actually reported.
|
|
319
|
+
*/
|
|
320
|
+
function assignDefined(target, values) {
|
|
321
|
+
for (const [key, value] of Object.entries(values)) {
|
|
322
|
+
if (value !== undefined) {
|
|
323
|
+
target[key] = value;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
function asRecord(value) {
|
|
328
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
329
|
+
? value
|
|
330
|
+
: undefined;
|
|
331
|
+
}
|
|
332
|
+
function readInteger(value) {
|
|
333
|
+
return typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : undefined;
|
|
334
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
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 JSON over HTTPS against the NaviLink cloud.
|
|
8
|
+
*
|
|
9
|
+
* Built on `node:https` rather than `fetch` for three reasons, in ascending
|
|
10
|
+
* order of how much they matter.
|
|
11
|
+
*
|
|
12
|
+
* A response size cap. `fetch` gives you a body you have already committed to
|
|
13
|
+
* buffering, or a stream you have to cap by hand; a cap applied as bytes
|
|
14
|
+
* arrive is both simpler and actually enforced. The cloud is not ours and its
|
|
15
|
+
* responses are not bounded by anything we control.
|
|
16
|
+
*
|
|
17
|
+
* Separate connect and total deadlines. `AbortSignal.timeout` expresses one
|
|
18
|
+
* instant, so it cannot say "reach the host quickly, then allow it time to
|
|
19
|
+
* answer". An unreachable cloud should fail startup in seconds, not hold a
|
|
20
|
+
* slot for the whole request budget.
|
|
21
|
+
*
|
|
22
|
+
* Control over what is logged. Everything on this path is a credential: the
|
|
23
|
+
* request body carries the password, and the response body carries a JWT pair
|
|
24
|
+
* and a set of AWS keys. `fetch` errors quote the URL, and a debug logger
|
|
25
|
+
* wrapped round it would eventually quote a body. Here, the only thing that
|
|
26
|
+
* reaches the log is the method and the path.
|
|
27
|
+
*/
|
|
28
|
+
/** A completed HTTP response, with the body still unparsed. */
|
|
29
|
+
export interface HttpResponse {
|
|
30
|
+
status: number;
|
|
31
|
+
body: string;
|
|
32
|
+
}
|
|
33
|
+
/** Per-request timing and size limits. */
|
|
34
|
+
export interface JsonRequestOptions {
|
|
35
|
+
/** Deadline for establishing the TCP and TLS connection. */
|
|
36
|
+
connectTimeoutMs: number;
|
|
37
|
+
/** Deadline for the whole exchange. */
|
|
38
|
+
totalTimeoutMs: number;
|
|
39
|
+
/** Largest response body accepted, in bytes. */
|
|
40
|
+
maxBytes: number;
|
|
41
|
+
/** Extra headers. Used for the bearer-less `authorization` this API wants. */
|
|
42
|
+
headers?: Readonly<Record<string, string>>;
|
|
43
|
+
/** Cancels the request, so a shutdown need not wait out a deadline. */
|
|
44
|
+
signal?: AbortSignal;
|
|
45
|
+
}
|
|
46
|
+
/** Performs one JSON POST. Injectable so tests never open a socket. */
|
|
47
|
+
export type JsonPost = (url: string, body: unknown, options: JsonRequestOptions) => Promise<HttpResponse>;
|
|
48
|
+
/**
|
|
49
|
+
* POST a JSON body and read the response as text.
|
|
50
|
+
*
|
|
51
|
+
* Rejects with {@link ConnectionError} for anything that prevented an answer
|
|
52
|
+
* and {@link ProtocolError} when the answer arrived but was unusable. Parsing
|
|
53
|
+
* is left to the caller: a non-200 from this API still carries a JSON body
|
|
54
|
+
* explaining why, and throwing on the status here would discard it.
|
|
55
|
+
*
|
|
56
|
+
* Redirects are not followed. The cloud does not issue them, and following one
|
|
57
|
+
* would let a compromised or spoofed endpoint move a request that carries the
|
|
58
|
+
* user's password to a host of its choosing.
|
|
59
|
+
*/
|
|
60
|
+
export declare const postJson: JsonPost;
|
|
61
|
+
/**
|
|
62
|
+
* Parse a response body as JSON, or explain why it could not be.
|
|
63
|
+
*
|
|
64
|
+
* The excerpt in the failure message is capped and redacted: a gateway error
|
|
65
|
+
* page can be a whole HTML document, and a partial JSON body from this API
|
|
66
|
+
* would otherwise put a token into the log.
|
|
67
|
+
*/
|
|
68
|
+
export declare function parseJsonBody(body: string): unknown;
|