daikin-local-control 0.3.0 → 0.4.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 (114) hide show
  1. package/DESIGN.md +113 -2
  2. package/README.md +131 -13
  3. package/UNSUPPORTED.md +42 -4
  4. package/dist/clients/classic.d.ts +2 -2
  5. package/dist/clients/classic.d.ts.map +1 -1
  6. package/dist/clients/classic.js +4 -4
  7. package/dist/clients/classic.js.map +1 -1
  8. package/dist/clients/client.d.ts +6 -2
  9. package/dist/clients/client.d.ts.map +1 -1
  10. package/dist/clients/dsiot.d.ts +3 -1
  11. package/dist/clients/dsiot.d.ts.map +1 -1
  12. package/dist/clients/dsiot.js +11 -3
  13. package/dist/clients/dsiot.js.map +1 -1
  14. package/dist/codecs/classic/capabilities.d.ts.map +1 -1
  15. package/dist/codecs/classic/capabilities.js +5 -11
  16. package/dist/codecs/classic/capabilities.js.map +1 -1
  17. package/dist/codecs/classic/extendedControl.d.ts.map +1 -1
  18. package/dist/codecs/classic/extendedControl.js +10 -19
  19. package/dist/codecs/classic/extendedControl.js.map +1 -1
  20. package/dist/codecs/classic/state.d.ts.map +1 -1
  21. package/dist/codecs/classic/state.js +36 -95
  22. package/dist/codecs/classic/state.js.map +1 -1
  23. package/dist/codecs/classic/tables.d.ts +32 -0
  24. package/dist/codecs/classic/tables.d.ts.map +1 -0
  25. package/dist/codecs/classic/tables.js +94 -0
  26. package/dist/codecs/classic/tables.js.map +1 -0
  27. package/dist/codecs/credentials.d.ts +3 -2
  28. package/dist/codecs/credentials.d.ts.map +1 -1
  29. package/dist/codecs/credentials.js +8 -3
  30. package/dist/codecs/credentials.js.map +1 -1
  31. package/dist/codecs/device.d.ts +2 -1
  32. package/dist/codecs/device.d.ts.map +1 -1
  33. package/dist/codecs/device.js +7 -6
  34. package/dist/codecs/device.js.map +1 -1
  35. package/dist/codecs/dsiot/capabilities.d.ts.map +1 -1
  36. package/dist/codecs/dsiot/capabilities.js +8 -15
  37. package/dist/codecs/dsiot/capabilities.js.map +1 -1
  38. package/dist/codecs/dsiot/device.d.ts +4 -2
  39. package/dist/codecs/dsiot/device.d.ts.map +1 -1
  40. package/dist/codecs/dsiot/device.js +8 -8
  41. package/dist/codecs/dsiot/device.js.map +1 -1
  42. package/dist/codecs/dsiot/multireq.d.ts +0 -18
  43. package/dist/codecs/dsiot/multireq.d.ts.map +1 -1
  44. package/dist/codecs/dsiot/multireq.js +2 -24
  45. package/dist/codecs/dsiot/multireq.js.map +1 -1
  46. package/dist/codecs/dsiot/state.d.ts +6 -62
  47. package/dist/codecs/dsiot/state.d.ts.map +1 -1
  48. package/dist/codecs/dsiot/state.js +29 -148
  49. package/dist/codecs/dsiot/state.js.map +1 -1
  50. package/dist/codecs/dsiot/tables.d.ts +116 -0
  51. package/dist/codecs/dsiot/tables.d.ts.map +1 -0
  52. package/dist/codecs/dsiot/tables.js +185 -0
  53. package/dist/codecs/dsiot/tables.js.map +1 -0
  54. package/dist/discovery.d.ts +2 -1
  55. package/dist/discovery.d.ts.map +1 -1
  56. package/dist/discovery.js +9 -5
  57. package/dist/discovery.js.map +1 -1
  58. package/dist/factory.d.ts +7 -4
  59. package/dist/factory.d.ts.map +1 -1
  60. package/dist/factory.js +4 -2
  61. package/dist/factory.js.map +1 -1
  62. package/dist/index.d.ts +2 -0
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.js +2 -0
  65. package/dist/index.js.map +1 -1
  66. package/dist/models/device.d.ts +3 -1
  67. package/dist/models/device.d.ts.map +1 -1
  68. package/dist/models/state.d.ts +2 -3
  69. package/dist/models/state.d.ts.map +1 -1
  70. package/dist/simulators/classic.d.ts +61 -0
  71. package/dist/simulators/classic.d.ts.map +1 -0
  72. package/dist/simulators/classic.js +473 -0
  73. package/dist/simulators/classic.js.map +1 -0
  74. package/dist/simulators/dsiot.d.ts +49 -0
  75. package/dist/simulators/dsiot.d.ts.map +1 -0
  76. package/dist/simulators/dsiot.js +519 -0
  77. package/dist/simulators/dsiot.js.map +1 -0
  78. package/dist/simulators/index.d.ts +4 -0
  79. package/dist/simulators/index.d.ts.map +1 -0
  80. package/dist/simulators/index.js +3 -0
  81. package/dist/simulators/index.js.map +1 -0
  82. package/dist/simulators/simulator.d.ts +116 -0
  83. package/dist/simulators/simulator.d.ts.map +1 -0
  84. package/dist/simulators/simulator.js +141 -0
  85. package/dist/simulators/simulator.js.map +1 -0
  86. package/dist/transport.d.ts +6 -2
  87. package/dist/transport.d.ts.map +1 -1
  88. package/dist/transport.js +7 -2
  89. package/dist/transport.js.map +1 -1
  90. package/package.json +5 -2
  91. package/src/clients/classic.ts +8 -8
  92. package/src/clients/client.ts +7 -1
  93. package/src/clients/dsiot.ts +12 -2
  94. package/src/codecs/classic/capabilities.ts +8 -11
  95. package/src/codecs/classic/extendedControl.ts +14 -25
  96. package/src/codecs/classic/state.ts +49 -101
  97. package/src/codecs/classic/tables.ts +123 -0
  98. package/src/codecs/credentials.ts +11 -3
  99. package/src/codecs/device.ts +8 -7
  100. package/src/codecs/dsiot/capabilities.ts +10 -17
  101. package/src/codecs/dsiot/device.ts +8 -9
  102. package/src/codecs/dsiot/multireq.ts +2 -24
  103. package/src/codecs/dsiot/state.ts +57 -193
  104. package/src/codecs/dsiot/tables.ts +249 -0
  105. package/src/discovery.ts +9 -5
  106. package/src/factory.ts +7 -4
  107. package/src/index.ts +2 -0
  108. package/src/models/device.ts +3 -1
  109. package/src/models/state.ts +2 -3
  110. package/src/simulators/classic.ts +635 -0
  111. package/src/simulators/dsiot.ts +736 -0
  112. package/src/simulators/index.ts +9 -0
  113. package/src/simulators/simulator.ts +281 -0
  114. package/src/transport.ts +10 -3
@@ -0,0 +1,736 @@
1
+ import {
2
+ DSIOT_ADAPTER_PATHS,
3
+ DSIOT_ADAPTER_TYPE,
4
+ DSIOT_COOLING_ONLY_MODEL_TYPE,
5
+ DSIOT_EDGE_ROOT,
6
+ DSIOT_EDGES,
7
+ DSIOT_FAN_SPEED_CODES,
8
+ DSIOT_FIXED_LOUVER_VALUES,
9
+ DSIOT_HUMIDITY_OFF,
10
+ DSIOT_HUMIDITY_SETTING_FLAGS,
11
+ DSIOT_LOUVER_VALUES,
12
+ DSIOT_MODE_PATHS,
13
+ DSIOT_OFF,
14
+ DSIOT_ON,
15
+ DSIOT_OPERATING_MODE_CODES,
16
+ DSIOT_OPERATIONS,
17
+ DSIOT_PATHS,
18
+ DSIOT_SPECIAL_MODE_PATHS,
19
+ } from "../codecs/dsiot/tables.js";
20
+ import type { DaikinDeviceInfo } from "../models/device.js";
21
+ import type {
22
+ DaikinSensors,
23
+ DaikinState,
24
+ OperatingMode,
25
+ SpecialMode,
26
+ } from "../models/state.js";
27
+ import {
28
+ type DaikinSimulator,
29
+ type DaikinSimulatorRequest,
30
+ type DaikinSimulatorTemperatures,
31
+ type DaikinSimulatorUnit,
32
+ type SimulatorReply,
33
+ type StartSimulatorOptions,
34
+ startSimulator,
35
+ unanswered,
36
+ } from "./simulator.js";
37
+
38
+ // A DSIOT adapter (BRP084) as an HTTP server. It reads the adapter's leaves
39
+ // and codes from the codecs' tables and shares none of their functions: see
40
+ // DESIGN.md#simulators.
41
+
42
+ const MULTIREQ_PATH = "/dsiot/multireq";
43
+ const REBOOT_PATH = "/config/system/reboot";
44
+
45
+ const RSC_OK = 2000;
46
+ const RSC_UPDATED = 2004;
47
+ const RSC_UNAUTHORIZED = 4001;
48
+
49
+ // A node's `pt`: 1 for one that holds children, and 2 or 3 for a leaf. A
50
+ // special mode's leaf is 2 on a unit that takes the mode.
51
+ const NODE_TYPE = 1;
52
+ const WRITTEN_LEAF_TYPE = 2;
53
+ const REPORTED_LEAF_TYPE = 3;
54
+
55
+ // The `pt` inside a leaf's `md`. What it stands for is not known: see
56
+ // UNSUPPORTED.md.
57
+ const METADATA_TYPE = "b";
58
+
59
+ const INDOOR = `${DSIOT_EDGE_ROOT}/${DSIOT_EDGES.indoor}`;
60
+ const OUTDOOR = `${DSIOT_EDGE_ROOT}/${DSIOT_EDGES.outdoor}`;
61
+
62
+ // A leaf's address is the `to` of its resource, then its path. Of the
63
+ // `dgc_status` entities, `e_1003` is on the outdoor edge and the rest on the
64
+ // indoor one.
65
+ function statusAddress(path: string): string {
66
+ return `${path.includes(".e_1003.") ? OUTDOOR : INDOOR}.${path}`;
67
+ }
68
+
69
+ function adapterAddress(path: string): string {
70
+ return `${DSIOT_EDGE_ROOT}.${path}`;
71
+ }
72
+
73
+ // A `to` names one resource: an edge, then one property of it.
74
+ const RESOURCE_PATTERN = /^(\/dsiot\/edge(?:\/\w+)?)\.(\w+)$/;
75
+ // The leaves of a `dgc_status` tree hold hex bytes.
76
+ const STATUS_ADDRESS_PATTERN = /\.dgc_status\./;
77
+
78
+ // Sent with every change of power, as 00 turning on, 01 turning off and 02
79
+ // unchanged. The adapter takes it and reports no such leaf.
80
+ const POWER_TRANSITION_ADDRESS = statusAddress(DSIOT_PATHS.powerTransition);
81
+
82
+ const POWER_ADDRESS = statusAddress(DSIOT_PATHS.power);
83
+ const MODE_ADDRESS = statusAddress(DSIOT_PATHS.mode);
84
+ const MODEL_TYPE_ADDRESS = statusAddress(DSIOT_PATHS.modelType);
85
+ // A signed byte of whole degrees, a byte of percent, and two bytes of half
86
+ // degrees.
87
+ const INDOOR_TEMPERATURE_ADDRESS = statusAddress(DSIOT_PATHS.indoorTemperature);
88
+ const INDOOR_HUMIDITY_ADDRESS = statusAddress(DSIOT_PATHS.indoorHumidity);
89
+ const OUTDOOR_TEMPERATURE_ADDRESS = statusAddress(
90
+ DSIOT_PATHS.outdoorTemperature,
91
+ );
92
+ const SPECIAL_MODE_ADDRESSES: Record<SpecialMode, string> = {
93
+ powerful: statusAddress(DSIOT_SPECIAL_MODE_PATHS.powerful),
94
+ econo: statusAddress(DSIOT_SPECIAL_MODE_PATHS.econo),
95
+ streamer: statusAddress(DSIOT_SPECIAL_MODE_PATHS.streamer),
96
+ };
97
+ const ADAPTER_NAME_ADDRESS = adapterAddress(DSIOT_ADAPTER_PATHS.name);
98
+ const ADAPTER_MAC_ADDRESS = adapterAddress(DSIOT_ADAPTER_PATHS.macAddress);
99
+ const ADAPTER_VERSION_ADDRESS = adapterAddress(
100
+ DSIOT_ADAPTER_PATHS.firmwareVersion,
101
+ );
102
+
103
+ // A louver leaf is three bytes, the low byte first.
104
+ const LOUVER_BYTES = 3;
105
+
106
+ // What `DSIOT_PATHS.modelType` holds on a heat pump.
107
+ const HEAT_PUMP_MODEL_TYPE = "00";
108
+
109
+ // Every setting has a leaf for each mode, and the unit runs on those of the
110
+ // mode it is in. A signed setpoint is one byte of half degrees, and the rest
111
+ // two. Fan has neither a setpoint nor a humidity setting.
112
+ interface ModeLeaves {
113
+ setpoint?: { address: string; bytes: number };
114
+ humidityFlag?: string;
115
+ fanSpeed: string;
116
+ vertical: string;
117
+ horizontal: string;
118
+ }
119
+ const modeLeaves = (mode: OperatingMode): ModeLeaves => {
120
+ const { setpoint, humidity, fanSpeed, vertical, horizontal } =
121
+ DSIOT_MODE_PATHS[mode];
122
+ return {
123
+ fanSpeed: statusAddress(fanSpeed),
124
+ vertical: statusAddress(vertical),
125
+ horizontal: statusAddress(horizontal),
126
+ ...(setpoint === undefined
127
+ ? {}
128
+ : {
129
+ setpoint: {
130
+ address: statusAddress(setpoint.path),
131
+ bytes: setpoint.signed ? 1 : 2,
132
+ },
133
+ }),
134
+ ...(humidity === undefined
135
+ ? {}
136
+ : { humidityFlag: statusAddress(humidity.flag) }),
137
+ };
138
+ };
139
+ const MODE_LEAVES: Record<OperatingMode, ModeLeaves> = {
140
+ auto: modeLeaves("auto"),
141
+ dehumidify: modeLeaves("dehumidify"),
142
+ cool: modeLeaves("cool"),
143
+ heat: modeLeaves("heat"),
144
+ fan: modeLeaves("fan"),
145
+ };
146
+
147
+ // The leaves a discovery reply carries, under the names a classic
148
+ // `basic_info` gives them.
149
+ const DISCOVERY_FIELD_ADDRESSES = {
150
+ reg: adapterAddress(DSIOT_ADAPTER_PATHS.region),
151
+ ver: ADAPTER_VERSION_ADDRESS,
152
+ mac: ADAPTER_MAC_ADDRESS,
153
+ adp_kind: adapterAddress(DSIOT_ADAPTER_PATHS.kind),
154
+ name: ADAPTER_NAME_ADDRESS,
155
+ } as const;
156
+
157
+ // A read's `filter` lists what each leaf comes with. `pn` and `pt` come
158
+ // whatever it lists.
159
+ const FILTER_VALUE = "pv";
160
+ const FILTER_METADATA = "md";
161
+
162
+ /** One leaf of an adapter's property trees. */
163
+ export interface DsiotSimulatorLeaf {
164
+ /** The leaf's value. In a `dgc_status` tree it is hex bytes in upper case,
165
+ * the low byte first where there are several. */
166
+ pv: string;
167
+ /** 2 or 3. A special mode's leaf is 2 on a unit that takes the mode. What
168
+ * else tells the two apart is not known: the simulator takes a write to
169
+ * either, and its own leaves are 2 where a write is known for the leaf. */
170
+ pt: number;
171
+ /** A setpoint's lowest and highest value in `mi` and `mx`, a louver's values
172
+ * as a bit mask in `mx`, and a humidity value's step in `st`. */
173
+ md?: { pt: string; st?: string; mi?: string; mx?: string };
174
+ }
175
+
176
+ export interface StartDsiotSimulatorOptions extends StartSimulatorOptions {
177
+ /** What the unit has. Defaults to a heat pump with every fan speed, five
178
+ * fixed vertical louver positions, swing on both axes and all three special
179
+ * modes. A leaf in `leaves` replaces what this sets. */
180
+ unit?: DaikinSimulatorUnit;
181
+ /** Leaves added to the adapter's, each replacing a default at the same
182
+ * address: see `DsiotDaikinSimulator.leaves`. Defaults to none. */
183
+ leaves?: Record<string, DsiotSimulatorLeaf>;
184
+ /** The child lock code. With one, the adapter answers `rsc` 4001 to a
185
+ * request whose `Authorization` is not `Basic` and this code. Defaults to
186
+ * none: no request needs an `Authorization`. */
187
+ localPassword?: string;
188
+ }
189
+
190
+ /** A DSIOT adapter and the unit behind it. */
191
+ export interface DsiotDaikinSimulator extends DaikinSimulator {
192
+ /** Every leaf the adapter reports, by its address: the `to` of its
193
+ * resource, then the names down to the leaf, such as
194
+ * `/dsiot/edge/adr_0100.dgc_status.e_1002.e_A002.p_01` for the power. A
195
+ * write through the adapter changes a `pv`, and so do `setState` and
196
+ * `setSensors`. For what those two do not cover, set a `pv` directly, and
197
+ * delete a leaf or add one to change what the unit has. */
198
+ readonly leaves: Record<string, DsiotSimulatorLeaf>;
199
+ }
200
+
201
+ interface PropertyNode {
202
+ pn: string;
203
+ pt: number;
204
+ pv?: string;
205
+ pch?: PropertyNode[];
206
+ md?: DsiotSimulatorLeaf["md"];
207
+ }
208
+
209
+ /** Starts a DSIOT adapter on a free port, as a heat pump that is off, in
210
+ * cool at 24 °C. It answers `/dsiot/multireq` reads and writes and
211
+ * `/config/system/reboot`, over HTTP, and answers any other request with HTTP
212
+ * 501: UNSUPPORTED.md lists what it leaves out and where its answer is not
213
+ * known to be an adapter's. Call `stop` when done with it. */
214
+ export async function startDsiotSimulator(
215
+ options: StartDsiotSimulatorOptions = {},
216
+ ): Promise<DsiotDaikinSimulator> {
217
+ const leaves = {
218
+ ...applyUnit(defaultLeaves(), options.unit ?? {}),
219
+ ...options.leaves,
220
+ };
221
+
222
+ const setState = (state: Partial<DaikinState>) => applyState(leaves, state);
223
+ const setSensors = (sensors: Partial<DaikinSensors>) =>
224
+ applySensors(leaves, sensors);
225
+ // Before the server starts, so a state that throws leaves nothing
226
+ // listening.
227
+ applyDeviceInfo(leaves, options.deviceInfo ?? {});
228
+ setState(options.state ?? {});
229
+ setSensors(options.sensors ?? {});
230
+
231
+ const read = (to: unknown): PropertyNode | undefined => {
232
+ if (typeof to !== "string") {
233
+ return undefined;
234
+ }
235
+ const [resource = "", query, ...rest] = to.split("?");
236
+ const filter = query?.startsWith("filter=")
237
+ ? query.slice("filter=".length).split(",")
238
+ : undefined;
239
+ const name = RESOURCE_PATTERN.exec(resource)?.[2];
240
+ if (
241
+ name === undefined ||
242
+ rest.length > 0 ||
243
+ (query !== undefined && filter === undefined)
244
+ ) {
245
+ return undefined;
246
+ }
247
+ const root: PropertyNode = { pn: name, pt: NODE_TYPE, pch: [] };
248
+ for (const [address, leaf] of Object.entries(leaves)) {
249
+ if (!address.startsWith(`${resource}.`)) {
250
+ continue;
251
+ }
252
+ const names = address.slice(resource.length + 1).split(".");
253
+ let node = root;
254
+ for (const child of names.slice(0, -1)) {
255
+ node = childNamed(node, child);
256
+ }
257
+ node.pch?.push({
258
+ pn: names.at(-1) ?? "",
259
+ pt: leaf.pt,
260
+ ...(filter?.includes(FILTER_VALUE) === false ? {} : { pv: leaf.pv }),
261
+ ...(leaf.md === undefined || filter?.includes(FILTER_METADATA) === false
262
+ ? {}
263
+ : { md: leaf.md }),
264
+ });
265
+ }
266
+ return root.pch?.length === 0 ? undefined : root;
267
+ };
268
+
269
+ // The addresses and values a write names, or `undefined` for a write the
270
+ // adapter is not known to take: one that names a leaf it does not report,
271
+ // or a tree that is not the resource in `to`.
272
+ const write = (
273
+ to: unknown,
274
+ content: unknown,
275
+ ): [address: string, value: string][] | undefined => {
276
+ const resource = typeof to === "string" ? to : "";
277
+ const edge = RESOURCE_PATTERN.exec(resource)?.[1];
278
+ const changes: [string, string][] = [];
279
+ if (edge === undefined || !collectValues(content, edge, changes)) {
280
+ return undefined;
281
+ }
282
+ return changes.every(
283
+ ([address]) =>
284
+ address.startsWith(`${resource}.`) &&
285
+ (address in leaves || address === POWER_TRANSITION_ADDRESS),
286
+ )
287
+ ? changes
288
+ : undefined;
289
+ };
290
+
291
+ const answer = (request: DaikinSimulatorRequest): SimulatorReply => {
292
+ const reboots = request.path === REBOOT_PATH && request.method === "POST";
293
+ const operation =
294
+ request.path !== MULTIREQ_PATH
295
+ ? undefined
296
+ : request.method === "POST"
297
+ ? DSIOT_OPERATIONS.get
298
+ : request.method === "PUT"
299
+ ? DSIOT_OPERATIONS.update
300
+ : undefined;
301
+ if (!reboots && operation === undefined) {
302
+ return unanswered(request);
303
+ }
304
+ if (
305
+ options.localPassword !== undefined &&
306
+ request.headers.authorization !== `Basic ${options.localPassword}`
307
+ ) {
308
+ return json({ responses: [{ rsc: RSC_UNAUTHORIZED }] });
309
+ }
310
+ if (reboots) {
311
+ return json({ rsc: RSC_OK });
312
+ }
313
+
314
+ const requests = parseRequests(request.body);
315
+ if (requests === undefined || requests.some(({ op }) => op !== operation)) {
316
+ return unanswered(request);
317
+ }
318
+ if (operation === DSIOT_OPERATIONS.get) {
319
+ const trees = requests.map(({ to }) => read(to));
320
+ return trees.includes(undefined)
321
+ ? unanswered(request)
322
+ : json({ responses: trees.map((pc) => ({ rsc: RSC_OK, pc })) });
323
+ }
324
+ const changes = requests.map(({ to, pc }) => write(to, pc));
325
+ if (changes.includes(undefined)) {
326
+ return unanswered(request);
327
+ }
328
+ for (const [address, value] of changes.flatMap((each) => each ?? [])) {
329
+ const leaf = leaves[address];
330
+ if (leaf !== undefined) {
331
+ leaf.pv = heldValue(address, leaf.pv, value);
332
+ }
333
+ }
334
+ return json({ responses: requests.map(() => ({ rsc: RSC_UPDATED })) });
335
+ };
336
+
337
+ // The discovery reply names these leaves as a classic `basic_info` does,
338
+ // and carries the name percent-encoded.
339
+ const discoveryFields = () => {
340
+ const fields: Record<string, string> = { type: DSIOT_ADAPTER_TYPE };
341
+ for (const [field, address] of Object.entries(DISCOVERY_FIELD_ADDRESSES)) {
342
+ const value = leaves[address]?.pv;
343
+ if (value !== undefined) {
344
+ fields[field] = field === "name" ? encodeURIComponent(value) : value;
345
+ }
346
+ }
347
+ fields.lpw_flag = options.localPassword === undefined ? "0" : "1";
348
+ return fields;
349
+ };
350
+
351
+ const server = await startSimulator(options, answer, discoveryFields);
352
+
353
+ return { ...server, leaves, setState, setSensors };
354
+ }
355
+
356
+ type Leaves = Record<string, DsiotSimulatorLeaf>;
357
+
358
+ function hold(leaves: Leaves, address: string, pv: string): void {
359
+ const leaf = leaves[address];
360
+ if (leaf === undefined) {
361
+ throw new Error(`The simulated unit reports no leaf ${address} to set`);
362
+ }
363
+ leaf.pv = pv;
364
+ }
365
+
366
+ // `bytes` bytes of hex in upper case, the low byte first. A negative value is
367
+ // held as its two's complement.
368
+ function littleEndian(value: number, bytes: number): string {
369
+ return Array.from({ length: bytes }, (_, index) =>
370
+ ((value >> (8 * index)) & 0xff).toString(16).toUpperCase().padStart(2, "0"),
371
+ ).join("");
372
+ }
373
+
374
+ function halfDegrees(temperatureInC: number, bytes: number): string {
375
+ return littleEndian(Math.round(temperatureInC * 2), bytes);
376
+ }
377
+
378
+ // Removes the leaves a unit without something does not report, and sets the
379
+ // ones that say what it has. A field `unit` leaves out is left as it is.
380
+ function applyUnit(leaves: Leaves, unit: DaikinSimulatorUnit): Leaves {
381
+ const modes = Object.values(MODE_LEAVES);
382
+ if (unit.coolingOnly !== undefined) {
383
+ hold(
384
+ leaves,
385
+ MODEL_TYPE_ADDRESS,
386
+ unit.coolingOnly ? DSIOT_COOLING_ONLY_MODEL_TYPE : HEAT_PUMP_MODEL_TYPE,
387
+ );
388
+ }
389
+ if (unit.fan === false) {
390
+ for (const { fanSpeed } of modes) {
391
+ delete leaves[fanSpeed];
392
+ }
393
+ }
394
+ for (const { vertical, horizontal } of modes) {
395
+ if (unit.swing === "none") {
396
+ delete leaves[vertical];
397
+ }
398
+ if (unit.swing === "none" || unit.swing === "vertical") {
399
+ delete leaves[horizontal];
400
+ }
401
+ }
402
+ if (unit.specialModes !== undefined) {
403
+ for (const [mode, address] of Object.entries(SPECIAL_MODE_ADDRESSES)) {
404
+ if (!unit.specialModes.includes(mode as SpecialMode)) {
405
+ delete leaves[address];
406
+ }
407
+ }
408
+ }
409
+ const limit = (
410
+ mode: OperatingMode,
411
+ temperatures: DaikinSimulatorTemperatures | undefined,
412
+ ) => {
413
+ const setpoint = MODE_LEAVES[mode].setpoint;
414
+ const leaf = leaves[setpoint?.address ?? ""];
415
+ if (temperatures !== undefined && setpoint && leaf) {
416
+ leaf.md = {
417
+ pt: METADATA_TYPE,
418
+ mi: halfDegrees(temperatures.min, setpoint.bytes),
419
+ mx: halfDegrees(temperatures.max, setpoint.bytes),
420
+ };
421
+ }
422
+ };
423
+ limit("cool", unit.coolTemperaturesInC);
424
+ limit("heat", unit.heatTemperaturesInC);
425
+ return leaves;
426
+ }
427
+
428
+ // The name as plain text, the MAC address without its colons, and the
429
+ // version with underscores for its dots.
430
+ function applyDeviceInfo(leaves: Leaves, info: DaikinDeviceInfo): void {
431
+ if (info.name !== undefined) {
432
+ hold(leaves, ADAPTER_NAME_ADDRESS, info.name);
433
+ }
434
+ if (info.macAddress !== undefined) {
435
+ hold(leaves, ADAPTER_MAC_ADDRESS, info.macAddress.replaceAll(":", ""));
436
+ }
437
+ if (info.firmwareVersion !== undefined) {
438
+ hold(
439
+ leaves,
440
+ ADAPTER_VERSION_ADDRESS,
441
+ info.firmwareVersion.replaceAll(".", "_"),
442
+ );
443
+ }
444
+ }
445
+
446
+ function applySensors(leaves: Leaves, sensors: Partial<DaikinSensors>): void {
447
+ const read = (address: string, pv: string | null | undefined) => {
448
+ if (pv === null) {
449
+ delete leaves[address];
450
+ } else if (pv !== undefined) {
451
+ leaves[address] = { pv, pt: REPORTED_LEAF_TYPE };
452
+ }
453
+ };
454
+ const whole = (value: number | null | undefined) =>
455
+ value == null ? value : littleEndian(Math.round(value), 1);
456
+ read(INDOOR_TEMPERATURE_ADDRESS, whole(sensors.indoorTemperatureInC));
457
+ read(INDOOR_HUMIDITY_ADDRESS, whole(sensors.indoorHumidityInPercent));
458
+ read(
459
+ OUTDOOR_TEMPERATURE_ADDRESS,
460
+ sensors.outdoorTemperatureInC == null
461
+ ? sensors.outdoorTemperatureInC
462
+ : halfDegrees(sensors.outdoorTemperatureInC, 2),
463
+ );
464
+ }
465
+
466
+ // Changes the leaves as the unit's remote control would: see
467
+ // `DaikinSimulator.setState`. Every setting but power and the special modes
468
+ // goes to the leaf of the mode the unit is then in.
469
+ function applyState(leaves: Leaves, state: Partial<DaikinState>): void {
470
+ if (state.power != null) {
471
+ hold(leaves, POWER_ADDRESS, state.power ? DSIOT_ON : DSIOT_OFF);
472
+ }
473
+ if (state.mode != null) {
474
+ hold(leaves, MODE_ADDRESS, DSIOT_OPERATING_MODE_CODES[state.mode]);
475
+ }
476
+ if (state.specialModes != null) {
477
+ for (const [mode, address] of Object.entries(SPECIAL_MODE_ADDRESSES)) {
478
+ const on = state.specialModes.includes(mode as SpecialMode);
479
+ if (on || address in leaves) {
480
+ hold(leaves, address, on ? DSIOT_ON : DSIOT_OFF);
481
+ }
482
+ }
483
+ }
484
+
485
+ const held = leaves[MODE_ADDRESS]?.pv;
486
+ const mode = (
487
+ Object.keys(DSIOT_OPERATING_MODE_CODES) as OperatingMode[]
488
+ ).find((each) => DSIOT_OPERATING_MODE_CODES[each] === held);
489
+ const {
490
+ targetTemperatureInC,
491
+ targetHumidity,
492
+ fanSpeed,
493
+ swingMode,
494
+ verticalPosition,
495
+ horizontalPosition,
496
+ } = state;
497
+ if (mode === undefined) {
498
+ if (
499
+ [
500
+ targetTemperatureInC,
501
+ targetHumidity,
502
+ fanSpeed,
503
+ swingMode,
504
+ verticalPosition,
505
+ horizontalPosition,
506
+ ].some((each) => each != null)
507
+ ) {
508
+ throw new Error(
509
+ `The simulated unit is in a mode, ${held}, whose leaves are not known`,
510
+ );
511
+ }
512
+ return;
513
+ }
514
+ const { setpoint, humidityFlag, ...addresses } = MODE_LEAVES[mode];
515
+ const louver = (value: number) => littleEndian(value, LOUVER_BYTES);
516
+
517
+ if (targetTemperatureInC != null) {
518
+ if (setpoint === undefined) {
519
+ throw new Error(`The simulated unit has no setpoint in ${mode}`);
520
+ }
521
+ hold(
522
+ leaves,
523
+ setpoint.address,
524
+ halfDegrees(targetTemperatureInC, setpoint.bytes),
525
+ );
526
+ }
527
+ if (targetHumidity != null) {
528
+ if (humidityFlag === undefined || typeof targetHumidity !== "string") {
529
+ throw new Error(
530
+ humidityFlag === undefined
531
+ ? `The simulated unit has no humidity setting in ${mode}`
532
+ : "Set a humidity percentage in the simulator's leaves",
533
+ );
534
+ }
535
+ hold(
536
+ leaves,
537
+ humidityFlag,
538
+ targetHumidity === "off"
539
+ ? DSIOT_HUMIDITY_OFF
540
+ : DSIOT_HUMIDITY_SETTING_FLAGS[targetHumidity],
541
+ );
542
+ }
543
+ if (fanSpeed != null) {
544
+ hold(leaves, addresses.fanSpeed, DSIOT_FAN_SPEED_CODES[fanSpeed]);
545
+ }
546
+ if (swingMode != null) {
547
+ const swings = {
548
+ vertical: swingMode === "vertical" || swingMode === "both",
549
+ horizontal: swingMode === "horizontal" || swingMode === "both",
550
+ };
551
+ for (const axis of ["vertical", "horizontal"] as const) {
552
+ const address = addresses[axis];
553
+ // An axis that stops swinging stops, and one at a fixed position keeps
554
+ // it. A unit without the axis has nothing to stop.
555
+ if (swings[axis]) {
556
+ hold(leaves, address, louver(DSIOT_LOUVER_VALUES.swing));
557
+ } else if (leaves[address]?.pv === louver(DSIOT_LOUVER_VALUES.swing)) {
558
+ hold(leaves, address, louver(DSIOT_LOUVER_VALUES.stop));
559
+ }
560
+ }
561
+ }
562
+ for (const [axis, position] of [
563
+ ["vertical", verticalPosition],
564
+ ["horizontal", horizontalPosition],
565
+ ] as const) {
566
+ if (position == null) {
567
+ continue;
568
+ }
569
+ const address = addresses[axis];
570
+ const rank = FIXED_POSITIONS.indexOf(position);
571
+ // A fixed position is the value at its rank among the fixed values the
572
+ // unit takes. Which value a horizontal one is, is not known.
573
+ const value =
574
+ rank === -1
575
+ ? DSIOT_LOUVER_VALUES[position as keyof typeof DSIOT_LOUVER_VALUES]
576
+ : axis === "vertical"
577
+ ? fixedLouverValues(leaves[address])[rank]
578
+ : undefined;
579
+ if (value === undefined) {
580
+ throw new Error(`The simulated unit takes no ${axis} louver ${position}`);
581
+ }
582
+ hold(leaves, address, louver(value));
583
+ }
584
+ }
585
+
586
+ const FIXED_POSITIONS: readonly string[] = [
587
+ "position1",
588
+ "position2",
589
+ "position3",
590
+ "position4",
591
+ "position5",
592
+ ];
593
+
594
+ function fixedLouverValues(leaf: DsiotSimulatorLeaf | undefined): number[] {
595
+ const bytes = leaf?.md?.mx?.match(/../g) ?? [];
596
+ const mask = Number.parseInt(bytes.reverse().join(""), 16);
597
+ return DSIOT_FIXED_LOUVER_VALUES.filter((value) => mask & (1 << value));
598
+ }
599
+
600
+ function json(body: unknown): SimulatorReply {
601
+ return { statusCode: 200, body: JSON.stringify(body) };
602
+ }
603
+
604
+ function parseRequests(
605
+ body: string,
606
+ ): { op?: unknown; to?: unknown; pc?: unknown }[] | undefined {
607
+ try {
608
+ const requests = (JSON.parse(body) as { requests?: unknown } | null)
609
+ ?.requests;
610
+ return Array.isArray(requests) &&
611
+ requests.length > 0 &&
612
+ requests.every((each) => typeof each === "object" && each !== null)
613
+ ? requests
614
+ : undefined;
615
+ } catch {
616
+ return undefined;
617
+ }
618
+ }
619
+
620
+ function childNamed(parent: PropertyNode, name: string): PropertyNode {
621
+ let child = parent.pch?.find((node) => node.pn === name);
622
+ if (child === undefined) {
623
+ child = { pn: name, pt: NODE_TYPE, pch: [] };
624
+ parent.pch?.push(child);
625
+ }
626
+ return child;
627
+ }
628
+
629
+ // Adds each leaf under `node` to `changes`, by its address. It returns false
630
+ // for a node that has neither children nor a string value.
631
+ function collectValues(
632
+ node: unknown,
633
+ parent: string,
634
+ changes: [string, string][],
635
+ ): boolean {
636
+ const { pn, pv, pch } = (node ?? {}) as Partial<
637
+ Record<"pn" | "pv" | "pch", unknown>
638
+ >;
639
+ if (typeof pn !== "string") {
640
+ return false;
641
+ }
642
+ const address = `${parent}.${pn}`;
643
+ if (Array.isArray(pch)) {
644
+ return (
645
+ pch.length > 0 &&
646
+ pch.every((child) => collectValues(child, address, changes))
647
+ );
648
+ }
649
+ if (typeof pv !== "string") {
650
+ return false;
651
+ }
652
+ changes.push([address, pv]);
653
+ return true;
654
+ }
655
+
656
+ // A write may carry a `dgc_status` value in lower case, and as its low bytes
657
+ // alone: a setpoint of two bytes is written as one, `30` for `3000`.
658
+ function heldValue(address: string, held: string, written: string): string {
659
+ return STATUS_ADDRESS_PATTERN.test(address)
660
+ ? written.toUpperCase().padEnd(held.length, "0")
661
+ : written;
662
+ }
663
+
664
+ function defaultLeaves(): Leaves {
665
+ const reported = (pv: string): DsiotSimulatorLeaf => ({
666
+ pv,
667
+ pt: REPORTED_LEAF_TYPE,
668
+ });
669
+ const written = (
670
+ pv: string,
671
+ md?: { mi?: string; mx?: string },
672
+ ): DsiotSimulatorLeaf => ({
673
+ pv,
674
+ pt: WRITTEN_LEAF_TYPE,
675
+ ...(md === undefined ? {} : { md: { pt: METADATA_TYPE, ...md } }),
676
+ });
677
+ const setpoint = (
678
+ mode: OperatingMode,
679
+ temperatureInC: number,
680
+ minInC: number,
681
+ maxInC: number,
682
+ ) => {
683
+ const { address = "", bytes = 0 } = MODE_LEAVES[mode].setpoint ?? {};
684
+ return {
685
+ [address]: written(halfDegrees(temperatureInC, bytes), {
686
+ mi: halfDegrees(minInC, bytes),
687
+ mx: halfDegrees(maxInC, bytes),
688
+ }),
689
+ };
690
+ };
691
+ // The values each louver takes: stop and swing, and five fixed positions
692
+ // on the vertical one.
693
+ const louverMask = (...values: number[]) =>
694
+ littleEndian(
695
+ values.reduce((mask, value) => mask | (1 << value), 0),
696
+ 4,
697
+ );
698
+ const { stop, swing } = DSIOT_LOUVER_VALUES;
699
+ const stopped = littleEndian(stop, LOUVER_BYTES);
700
+
701
+ const leaves: Leaves = {
702
+ [adapterAddress(DSIOT_ADAPTER_PATHS.kind)]: reported("4"),
703
+ [ADAPTER_VERSION_ADDRESS]: reported("2_8_0"),
704
+ [adapterAddress(DSIOT_ADAPTER_PATHS.region)]: reported("th"),
705
+ [ADAPTER_MAC_ADDRESS]: reported("02DA1C000002"),
706
+ [adapterAddress(DSIOT_ADAPTER_PATHS.scheduleTimer)]: reported("1"),
707
+ [adapterAddress(DSIOT_ADAPTER_PATHS.demandControl)]: reported("1"),
708
+ [adapterAddress(DSIOT_ADAPTER_PATHS.energyMonitoring)]: reported("1"),
709
+ [ADAPTER_NAME_ADDRESS]: written("Simulator"),
710
+
711
+ [statusAddress(DSIOT_PATHS.model)]: reported("0ABB"),
712
+ [MODEL_TYPE_ADDRESS]: reported(HEAT_PUMP_MODEL_TYPE),
713
+ [POWER_ADDRESS]: written(DSIOT_OFF),
714
+ [INDOOR_TEMPERATURE_ADDRESS]: reported("1A"),
715
+ [INDOOR_HUMIDITY_ADDRESS]: reported("37"),
716
+ [statusAddress(DSIOT_PATHS.energyMonitoring)]: reported(DSIOT_ON),
717
+ [SPECIAL_MODE_ADDRESSES.econo]: written(DSIOT_OFF),
718
+ [SPECIAL_MODE_ADDRESSES.streamer]: written(DSIOT_OFF),
719
+
720
+ [MODE_ADDRESS]: written(DSIOT_OPERATING_MODE_CODES.cool),
721
+ ...setpoint("cool", 24, 18, 32),
722
+ ...setpoint("heat", 25, 10, 30),
723
+ ...setpoint("auto", 25, 18, 30),
724
+
725
+ [OUTDOOR_TEMPERATURE_ADDRESS]: reported("3C00"),
726
+ [SPECIAL_MODE_ADDRESSES.powerful]: written(DSIOT_OFF),
727
+ };
728
+ for (const { fanSpeed, vertical, horizontal } of Object.values(MODE_LEAVES)) {
729
+ leaves[fanSpeed] = written(DSIOT_FAN_SPEED_CODES.auto);
730
+ leaves[vertical] = written(stopped, {
731
+ mx: louverMask(stop, 1, 2, 3, 4, 5, swing),
732
+ });
733
+ leaves[horizontal] = written(stopped, { mx: louverMask(stop, swing) });
734
+ }
735
+ return leaves;
736
+ }