homebridge-navilink 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +202 -0
  3. package/README.md +161 -0
  4. package/SECURITY.md +73 -0
  5. package/config.schema.json +138 -0
  6. package/dist/api/channel.d.ts +94 -0
  7. package/dist/api/channel.js +334 -0
  8. package/dist/api/http.d.ts +68 -0
  9. package/dist/api/http.js +205 -0
  10. package/dist/api/identity.d.ts +51 -0
  11. package/dist/api/identity.js +80 -0
  12. package/dist/api/index.d.ts +17 -0
  13. package/dist/api/index.js +33 -0
  14. package/dist/api/mqtt-codec.d.ts +181 -0
  15. package/dist/api/mqtt-codec.js +377 -0
  16. package/dist/api/mqtt.d.ts +178 -0
  17. package/dist/api/mqtt.js +450 -0
  18. package/dist/api/protocol.d.ts +212 -0
  19. package/dist/api/protocol.js +286 -0
  20. package/dist/api/rest.d.ts +139 -0
  21. package/dist/api/rest.js +317 -0
  22. package/dist/api/sigv4.d.ts +70 -0
  23. package/dist/api/sigv4.js +123 -0
  24. package/dist/api/topics.d.ts +75 -0
  25. package/dist/api/topics.js +90 -0
  26. package/dist/devices/base-accessory.d.ts +127 -0
  27. package/dist/devices/base-accessory.js +232 -0
  28. package/dist/devices/dhw-accessory.d.ts +62 -0
  29. package/dist/devices/dhw-accessory.js +100 -0
  30. package/dist/devices/fault-accessory.d.ts +31 -0
  31. package/dist/devices/fault-accessory.js +73 -0
  32. package/dist/devices/heating-accessory.d.ts +63 -0
  33. package/dist/devices/heating-accessory.js +128 -0
  34. package/dist/devices/host.d.ts +77 -0
  35. package/dist/devices/host.js +23 -0
  36. package/dist/devices/index.d.ts +17 -0
  37. package/dist/devices/index.js +33 -0
  38. package/dist/devices/power-accessory.d.ts +32 -0
  39. package/dist/devices/power-accessory.js +86 -0
  40. package/dist/devices/probe-accessory.d.ts +45 -0
  41. package/dist/devices/probe-accessory.js +108 -0
  42. package/dist/devices/recirculation-accessory.d.ts +47 -0
  43. package/dist/devices/recirculation-accessory.js +122 -0
  44. package/dist/devices/thermostat-accessory.d.ts +129 -0
  45. package/dist/devices/thermostat-accessory.js +372 -0
  46. package/dist/discovery.d.ts +99 -0
  47. package/dist/discovery.js +423 -0
  48. package/dist/index.d.ts +11 -0
  49. package/dist/index.js +15 -0
  50. package/dist/platform.d.ts +120 -0
  51. package/dist/platform.js +425 -0
  52. package/dist/session.d.ts +243 -0
  53. package/dist/session.js +717 -0
  54. package/dist/settings.d.ts +218 -0
  55. package/dist/settings.js +240 -0
  56. package/dist/types/index.d.ts +265 -0
  57. package/dist/types/index.js +58 -0
  58. package/dist/ui-api.d.ts +27 -0
  59. package/dist/ui-api.js +39 -0
  60. package/dist/utils/context.d.ts +32 -0
  61. package/dist/utils/context.js +74 -0
  62. package/dist/utils/errors.d.ts +69 -0
  63. package/dist/utils/errors.js +118 -0
  64. package/dist/utils/index.d.ts +15 -0
  65. package/dist/utils/index.js +31 -0
  66. package/dist/utils/redact.d.ts +85 -0
  67. package/dist/utils/redact.js +210 -0
  68. package/dist/utils/serial.d.ts +18 -0
  69. package/dist/utils/serial.js +24 -0
  70. package/dist/utils/temperature.d.ts +114 -0
  71. package/dist/utils/temperature.js +150 -0
  72. package/dist/utils/timing.d.ts +68 -0
  73. package/dist/utils/timing.js +91 -0
  74. package/dist/utils/validators.d.ts +96 -0
  75. package/dist/utils/validators.js +353 -0
  76. package/docs/FEATURES.md +80 -0
  77. package/docs/PROTOCOL.md +200 -0
  78. package/docs/README-DETAILED.md +312 -0
  79. package/homebridge-ui/public/index.html +123 -0
  80. package/homebridge-ui/public/index.js +503 -0
  81. package/homebridge-ui/server.js +174 -0
  82. package/package.json +87 -0
@@ -0,0 +1,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;