daikin-local-control 0.1.1

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 (115) hide show
  1. package/DESIGN.md +98 -0
  2. package/LICENSE +674 -0
  3. package/README.md +317 -0
  4. package/UNSUPPORTED.md +43 -0
  5. package/dist/authentication.d.ts +24 -0
  6. package/dist/authentication.d.ts.map +1 -0
  7. package/dist/authentication.js +30 -0
  8. package/dist/authentication.js.map +1 -0
  9. package/dist/clients/classic.d.ts +53 -0
  10. package/dist/clients/classic.d.ts.map +1 -0
  11. package/dist/clients/classic.js +142 -0
  12. package/dist/clients/classic.js.map +1 -0
  13. package/dist/clients/client.d.ts +21 -0
  14. package/dist/clients/client.d.ts.map +1 -0
  15. package/dist/clients/client.js +2 -0
  16. package/dist/clients/client.js.map +1 -0
  17. package/dist/clients/dsiot.d.ts +28 -0
  18. package/dist/clients/dsiot.d.ts.map +1 -0
  19. package/dist/clients/dsiot.js +66 -0
  20. package/dist/clients/dsiot.js.map +1 -0
  21. package/dist/codecs/classic/capabilities.d.ts +19 -0
  22. package/dist/codecs/classic/capabilities.d.ts.map +1 -0
  23. package/dist/codecs/classic/capabilities.js +208 -0
  24. package/dist/codecs/classic/capabilities.js.map +1 -0
  25. package/dist/codecs/classic/extendedControl.d.ts +20 -0
  26. package/dist/codecs/classic/extendedControl.d.ts.map +1 -0
  27. package/dist/codecs/classic/extendedControl.js +88 -0
  28. package/dist/codecs/classic/extendedControl.js.map +1 -0
  29. package/dist/codecs/classic/keyValue.d.ts +10 -0
  30. package/dist/codecs/classic/keyValue.d.ts.map +1 -0
  31. package/dist/codecs/classic/keyValue.js +36 -0
  32. package/dist/codecs/classic/keyValue.js.map +1 -0
  33. package/dist/codecs/classic/state.d.ts +18 -0
  34. package/dist/codecs/classic/state.d.ts.map +1 -0
  35. package/dist/codecs/classic/state.js +235 -0
  36. package/dist/codecs/classic/state.js.map +1 -0
  37. package/dist/codecs/credentials.d.ts +8 -0
  38. package/dist/codecs/credentials.d.ts.map +1 -0
  39. package/dist/codecs/credentials.js +14 -0
  40. package/dist/codecs/credentials.js.map +1 -0
  41. package/dist/codecs/device.d.ts +6 -0
  42. package/dist/codecs/device.d.ts.map +1 -0
  43. package/dist/codecs/device.js +35 -0
  44. package/dist/codecs/device.js.map +1 -0
  45. package/dist/codecs/dsiot/capabilities.d.ts +5 -0
  46. package/dist/codecs/dsiot/capabilities.d.ts.map +1 -0
  47. package/dist/codecs/dsiot/capabilities.js +139 -0
  48. package/dist/codecs/dsiot/capabilities.js.map +1 -0
  49. package/dist/codecs/dsiot/multireq.d.ts +37 -0
  50. package/dist/codecs/dsiot/multireq.d.ts.map +1 -0
  51. package/dist/codecs/dsiot/multireq.js +122 -0
  52. package/dist/codecs/dsiot/multireq.js.map +1 -0
  53. package/dist/codecs/dsiot/state.d.ts +92 -0
  54. package/dist/codecs/dsiot/state.d.ts.map +1 -0
  55. package/dist/codecs/dsiot/state.js +461 -0
  56. package/dist/codecs/dsiot/state.js.map +1 -0
  57. package/dist/discovery.d.ts +17 -0
  58. package/dist/discovery.d.ts.map +1 -0
  59. package/dist/discovery.js +67 -0
  60. package/dist/discovery.js.map +1 -0
  61. package/dist/error.d.ts +15 -0
  62. package/dist/error.d.ts.map +1 -0
  63. package/dist/error.js +20 -0
  64. package/dist/error.js.map +1 -0
  65. package/dist/factory.d.ts +26 -0
  66. package/dist/factory.d.ts.map +1 -0
  67. package/dist/factory.js +31 -0
  68. package/dist/factory.js.map +1 -0
  69. package/dist/index.d.ts +22 -0
  70. package/dist/index.d.ts.map +1 -0
  71. package/dist/index.js +21 -0
  72. package/dist/index.js.map +1 -0
  73. package/dist/models/capabilities.d.ts +42 -0
  74. package/dist/models/capabilities.d.ts.map +1 -0
  75. package/dist/models/capabilities.js +2 -0
  76. package/dist/models/capabilities.js.map +1 -0
  77. package/dist/models/device.d.ts +24 -0
  78. package/dist/models/device.d.ts.map +1 -0
  79. package/dist/models/device.js +2 -0
  80. package/dist/models/device.js.map +1 -0
  81. package/dist/models/extendedControl.d.ts +31 -0
  82. package/dist/models/extendedControl.d.ts.map +1 -0
  83. package/dist/models/extendedControl.js +7 -0
  84. package/dist/models/extendedControl.js.map +1 -0
  85. package/dist/models/state.d.ts +56 -0
  86. package/dist/models/state.d.ts.map +1 -0
  87. package/dist/models/state.js +49 -0
  88. package/dist/models/state.js.map +1 -0
  89. package/dist/transport.d.ts +75 -0
  90. package/dist/transport.d.ts.map +1 -0
  91. package/dist/transport.js +118 -0
  92. package/dist/transport.js.map +1 -0
  93. package/package.json +62 -0
  94. package/src/authentication.ts +53 -0
  95. package/src/clients/classic.ts +251 -0
  96. package/src/clients/client.ts +29 -0
  97. package/src/clients/dsiot.ts +116 -0
  98. package/src/codecs/classic/capabilities.ts +272 -0
  99. package/src/codecs/classic/extendedControl.ts +132 -0
  100. package/src/codecs/classic/keyValue.ts +39 -0
  101. package/src/codecs/classic/state.ts +302 -0
  102. package/src/codecs/credentials.ts +19 -0
  103. package/src/codecs/device.ts +41 -0
  104. package/src/codecs/dsiot/capabilities.ts +209 -0
  105. package/src/codecs/dsiot/multireq.ts +162 -0
  106. package/src/codecs/dsiot/state.ts +644 -0
  107. package/src/discovery.ts +85 -0
  108. package/src/error.ts +20 -0
  109. package/src/factory.ts +66 -0
  110. package/src/index.ts +21 -0
  111. package/src/models/capabilities.ts +52 -0
  112. package/src/models/device.ts +25 -0
  113. package/src/models/extendedControl.ts +41 -0
  114. package/src/models/state.ts +110 -0
  115. package/src/transport.ts +191 -0
@@ -0,0 +1,644 @@
1
+ import {
2
+ type DaikinControl,
3
+ type DaikinSensors,
4
+ type DaikinState,
5
+ type FanSpeed,
6
+ FIXED_LOUVER_POSITIONS,
7
+ HORIZONTAL_POSITIONS,
8
+ type HorizontalPosition,
9
+ type HumiditySetting,
10
+ type OperatingMode,
11
+ type SwingMode,
12
+ type TargetHumidity,
13
+ VERTICAL_POSITIONS,
14
+ type VerticalPosition,
15
+ } from "../../models/state.js";
16
+ import type { DsiotUpdate } from "./multireq.js";
17
+
18
+ /** Full dotted paths into the `dgc_status` trees. `e_1002` lives on the indoor
19
+ * edge and `e_1003` on the outdoor edge. */
20
+ export const DSIOT_PATHS = {
21
+ power: "dgc_status.e_1002.e_A002.p_01",
22
+ /** Sent with every control. */
23
+ powerTransition: "dgc_status.e_1002.e_3003.p_2D",
24
+ mode: "dgc_status.e_1002.e_3001.p_01",
25
+ indoorTemperature: "dgc_status.e_1002.e_A00B.p_01",
26
+ indoorHumidity: "dgc_status.e_1002.e_A00B.p_02",
27
+ outdoorTemperature: "dgc_status.e_1003.e_A00D.p_01",
28
+ model: "dgc_status.e_1002.e_A001.p_0D",
29
+ modelType: "dgc_status.e_1002.e_A001.p_07",
30
+ energyMonitoring: "dgc_status.e_1002.e_A011.p_01",
31
+ } as const;
32
+
33
+ export interface DsiotSetpoint {
34
+ path: string;
35
+ /** Auto and dehumidify are a signed byte; cool and heat are little-endian. */
36
+ signed: boolean;
37
+ /** The dehumidify setpoint is read-only. */
38
+ writable: boolean;
39
+ }
40
+
41
+ export interface DsiotHumidity {
42
+ /** Counted in steps of the leaf's `md.st`. */
43
+ value: string;
44
+ /** The setting: `00` or `05` off, `01` the percentage in `value`, and one
45
+ * value for each `HumiditySetting`. */
46
+ flag: string;
47
+ }
48
+
49
+ export interface DsiotModePaths {
50
+ /** Absent in fan mode. */
51
+ setpoint?: DsiotSetpoint;
52
+ /** Absent in fan mode. */
53
+ humidity?: DsiotHumidity;
54
+ fanSpeed: string;
55
+ vertical: string;
56
+ horizontal: string;
57
+ }
58
+
59
+ export const DSIOT_MODE_NODE = "dgc_status.e_1002.e_3001";
60
+
61
+ export const DSIOT_MODE_PATHS: Record<OperatingMode, DsiotModePaths> = {
62
+ auto: {
63
+ setpoint: { path: `${DSIOT_MODE_NODE}.p_1D`, signed: true, writable: true },
64
+ humidity: {
65
+ value: `${DSIOT_MODE_NODE}.p_2E`,
66
+ flag: `${DSIOT_MODE_NODE}.p_2F`,
67
+ },
68
+ fanSpeed: `${DSIOT_MODE_NODE}.p_26`,
69
+ vertical: `${DSIOT_MODE_NODE}.p_20`,
70
+ horizontal: `${DSIOT_MODE_NODE}.p_21`,
71
+ },
72
+ dehumidify: {
73
+ setpoint: {
74
+ path: `${DSIOT_MODE_NODE}.p_1E`,
75
+ signed: true,
76
+ writable: false,
77
+ },
78
+ humidity: {
79
+ value: `${DSIOT_MODE_NODE}.p_30`,
80
+ flag: `${DSIOT_MODE_NODE}.p_31`,
81
+ },
82
+ fanSpeed: `${DSIOT_MODE_NODE}.p_27`,
83
+ vertical: `${DSIOT_MODE_NODE}.p_22`,
84
+ horizontal: `${DSIOT_MODE_NODE}.p_23`,
85
+ },
86
+ cool: {
87
+ setpoint: {
88
+ path: `${DSIOT_MODE_NODE}.p_02`,
89
+ signed: false,
90
+ writable: true,
91
+ },
92
+ humidity: {
93
+ value: `${DSIOT_MODE_NODE}.p_0B`,
94
+ flag: `${DSIOT_MODE_NODE}.p_0C`,
95
+ },
96
+ fanSpeed: `${DSIOT_MODE_NODE}.p_09`,
97
+ vertical: `${DSIOT_MODE_NODE}.p_05`,
98
+ horizontal: `${DSIOT_MODE_NODE}.p_06`,
99
+ },
100
+ heat: {
101
+ setpoint: {
102
+ path: `${DSIOT_MODE_NODE}.p_03`,
103
+ signed: false,
104
+ writable: true,
105
+ },
106
+ humidity: {
107
+ value: `${DSIOT_MODE_NODE}.p_2C`,
108
+ flag: `${DSIOT_MODE_NODE}.p_2D`,
109
+ },
110
+ fanSpeed: `${DSIOT_MODE_NODE}.p_0A`,
111
+ vertical: `${DSIOT_MODE_NODE}.p_07`,
112
+ horizontal: `${DSIOT_MODE_NODE}.p_08`,
113
+ },
114
+ fan: {
115
+ fanSpeed: `${DSIOT_MODE_NODE}.p_28`,
116
+ vertical: `${DSIOT_MODE_NODE}.p_24`,
117
+ horizontal: `${DSIOT_MODE_NODE}.p_25`,
118
+ },
119
+ };
120
+
121
+ // --- value encodings -------------------------------------------------------
122
+ // Leaf values are hex strings. Multi-byte values are little-endian, and
123
+ // temperatures count half degrees.
124
+
125
+ export function encodeDsiotBoolean(on: boolean): string {
126
+ return on ? "01" : "00";
127
+ }
128
+
129
+ export function decodeDsiotBoolean(pv: string | undefined): boolean {
130
+ return pv !== undefined && Number.parseInt(pv, 16) > 0;
131
+ }
132
+
133
+ export function decodeDsiotLittleEndian(pv: string): number {
134
+ const bytes = pv.match(/../g) ?? [];
135
+ return Number.parseInt(bytes.reverse().join(""), 16);
136
+ }
137
+
138
+ export function encodeDsiotSetpoint(setpointInC: number): string {
139
+ return (Math.trunc(setpointInC * 2) & 0xff).toString(16);
140
+ }
141
+
142
+ export function decodeDsiotSetpoint(
143
+ pv: string | undefined,
144
+ signed: boolean,
145
+ ): number | null {
146
+ if (!pv) {
147
+ return null;
148
+ }
149
+ const halfDegrees = signed ? signedByte(pv) : decodeDsiotLittleEndian(pv);
150
+ return Number.isFinite(halfDegrees) ? halfDegrees / 2 : null;
151
+ }
152
+
153
+ /** Indoor temperature: a signed byte of whole °C. */
154
+ export function decodeDsiotIndoorTemperature(
155
+ pv: string | undefined,
156
+ ): number | null {
157
+ return pv ? nullIfNaN(signedByte(pv)) : null;
158
+ }
159
+
160
+ /** Indoor humidity: an unsigned byte of %. */
161
+ export function decodeDsiotIndoorHumidity(
162
+ pv: string | undefined,
163
+ ): number | null {
164
+ return pv ? nullIfNaN(Number.parseInt(pv, 16)) : null;
165
+ }
166
+
167
+ /** Outdoor temperature: a signed little-endian 16-bit count of half degrees. */
168
+ export function decodeDsiotOutdoorTemperature(
169
+ pv: string | undefined,
170
+ ): number | null {
171
+ if (!pv) {
172
+ return null;
173
+ }
174
+ const unsigned = decodeDsiotLittleEndian(pv);
175
+ // The shift turns NaN into 0, so a value that is not hex is caught first.
176
+ return Number.isFinite(unsigned) ? ((unsigned << 16) >> 16) / 2 : null;
177
+ }
178
+
179
+ function signedByte(pv: string): number {
180
+ const byte = Number.parseInt(pv.slice(0, 2), 16);
181
+ return byte > 0x7f ? byte - 0x100 : byte;
182
+ }
183
+
184
+ function nullIfNaN(value: number): number | null {
185
+ return Number.isFinite(value) ? value : null;
186
+ }
187
+
188
+ const OPERATING_MODE_CODES: Record<OperatingMode, string> = {
189
+ fan: "0000",
190
+ heat: "0100",
191
+ cool: "0200",
192
+ auto: "0300",
193
+ dehumidify: "0500",
194
+ };
195
+
196
+ const OPERATING_MODES_BY_CODE: Record<string, OperatingMode> = {
197
+ "0000": "fan",
198
+ "0100": "heat",
199
+ "0200": "cool",
200
+ "0300": "auto",
201
+ "0500": "dehumidify",
202
+ "0900": "cool",
203
+ "0A00": "heat",
204
+ };
205
+
206
+ export function encodeDsiotMode(mode: OperatingMode): string {
207
+ return OPERATING_MODE_CODES[mode];
208
+ }
209
+
210
+ export function decodeDsiotMode(
211
+ pv: string | undefined,
212
+ ): OperatingMode | undefined {
213
+ return OPERATING_MODES_BY_CODE[(pv ?? "").toUpperCase()];
214
+ }
215
+
216
+ const FAN_SPEED_CODES: Record<FanSpeed, string> = {
217
+ auto: "0A00",
218
+ silent: "0B00",
219
+ level1: "0300",
220
+ level2: "0400",
221
+ level3: "0500",
222
+ level4: "0600",
223
+ level5: "0700",
224
+ };
225
+
226
+ const FAN_SPEEDS_BY_CODE: Record<string, FanSpeed> = Object.fromEntries(
227
+ Object.entries(FAN_SPEED_CODES).map(([speed, code]) => [
228
+ code,
229
+ speed as FanSpeed,
230
+ ]),
231
+ );
232
+
233
+ export function encodeDsiotFanSpeed(speed: FanSpeed): string {
234
+ return FAN_SPEED_CODES[speed];
235
+ }
236
+
237
+ export function decodeDsiotFanSpeed(
238
+ pv: string | undefined,
239
+ ): FanSpeed | undefined {
240
+ return FAN_SPEEDS_BY_CODE[(pv ?? "").toUpperCase()];
241
+ }
242
+
243
+ /** The values a louver leaf holds besides a fixed position, which is 1-14.
244
+ * The leaf is three bytes, little-endian, and bit n of its `md.mx` is set
245
+ * when the unit takes value n. */
246
+ export const DSIOT_LOUVER_VALUES = {
247
+ stop: 0,
248
+ swing: 15,
249
+ auto: 16,
250
+ naturalWind: 19,
251
+ floorWarming: 25,
252
+ } as const;
253
+
254
+ const FIXED_LOUVER_VALUES = Array.from({ length: 14 }, (_, index) => index + 1);
255
+
256
+ export const DSIOT_LOUVER_STOP = "000000";
257
+ export const DSIOT_LOUVER_SWING = "0F0000";
258
+
259
+ export function isDsiotLouverSwinging(pv: string | undefined): boolean {
260
+ return (pv ?? "").toUpperCase() === DSIOT_LOUVER_SWING;
261
+ }
262
+
263
+ /** The value each `VerticalPosition` is held as. A fixed position has none: it
264
+ * is held as the value at its rank among the fixed values the unit takes. */
265
+ export const DSIOT_VERTICAL_POSITION_VALUES: Partial<
266
+ Record<VerticalPosition, number>
267
+ > = {
268
+ stop: DSIOT_LOUVER_VALUES.stop,
269
+ swing: DSIOT_LOUVER_VALUES.swing,
270
+ naturalWind: DSIOT_LOUVER_VALUES.naturalWind,
271
+ auto: DSIOT_LOUVER_VALUES.auto,
272
+ floorWarming: DSIOT_LOUVER_VALUES.floorWarming,
273
+ };
274
+ const FIXED_VERTICAL_POSITIONS: readonly VerticalPosition[] =
275
+ FIXED_LOUVER_POSITIONS;
276
+ /** The value each `HorizontalPosition` is held as. `stop` and a fixed position
277
+ * have none: see UNSUPPORTED.md. */
278
+ export const DSIOT_HORIZONTAL_POSITION_VALUES: Partial<
279
+ Record<HorizontalPosition, number>
280
+ > = {
281
+ swing: DSIOT_LOUVER_VALUES.swing,
282
+ auto: DSIOT_LOUVER_VALUES.auto,
283
+ };
284
+
285
+ function positionOf<PositionT extends string>(
286
+ positions: readonly PositionT[],
287
+ held: Partial<Record<PositionT, number>>,
288
+ value: number,
289
+ ): PositionT | null {
290
+ return positions.find((position) => held[position] === value) ?? null;
291
+ }
292
+
293
+ function isFixedLouverValue(value: number): boolean {
294
+ return value >= 1 && value <= 14;
295
+ }
296
+
297
+ function fixedLouverValues(
298
+ values: Record<string, string>,
299
+ path: string,
300
+ ): number[] {
301
+ const mask = decodeDsiotLittleEndian(values[`${path}.md.mx`] ?? "");
302
+ return FIXED_LOUVER_VALUES.filter((value) => mask & (1 << value));
303
+ }
304
+
305
+ function encodeLouver(value: number): string {
306
+ return `${value.toString(16).toUpperCase().padStart(2, "0")}0000`;
307
+ }
308
+
309
+ // A fixed vertical position is named by its rank among the values the unit
310
+ // takes: a unit taking 2, 4 and 6 reports them as `position1`, `position2` and
311
+ // `position3`. A value the unit does not list reads as `stop`.
312
+ function parseVerticalPosition(
313
+ values: Record<string, string>,
314
+ path: string,
315
+ ): VerticalPosition | null {
316
+ const pv = values[path];
317
+ if (pv === undefined) {
318
+ return null;
319
+ }
320
+ const value = decodeDsiotLittleEndian(pv);
321
+ if (!isFixedLouverValue(value)) {
322
+ return positionOf(
323
+ VERTICAL_POSITIONS,
324
+ DSIOT_VERTICAL_POSITION_VALUES,
325
+ value,
326
+ );
327
+ }
328
+ const rank = fixedLouverValues(values, path).indexOf(value);
329
+ return rank === -1 ? "stop" : (FIXED_VERTICAL_POSITIONS[rank] ?? null);
330
+ }
331
+
332
+ // `undefined` for a position the unit does not take in this mode.
333
+ function encodeVerticalPosition(
334
+ position: VerticalPosition,
335
+ values: Record<string, string>,
336
+ path: string,
337
+ ): string | undefined {
338
+ const rank = FIXED_VERTICAL_POSITIONS.indexOf(position);
339
+ const value =
340
+ rank === -1
341
+ ? DSIOT_VERTICAL_POSITION_VALUES[position]
342
+ : fixedLouverValues(values, path)[rank];
343
+ return value === undefined ? undefined : encodeLouver(value);
344
+ }
345
+
346
+ // A fixed horizontal position reads as `stop`: see UNSUPPORTED.md.
347
+ function parseHorizontalPosition(
348
+ pv: string | undefined,
349
+ ): HorizontalPosition | null {
350
+ if (pv === undefined) {
351
+ return null;
352
+ }
353
+ const value = decodeDsiotLittleEndian(pv);
354
+ return value === DSIOT_LOUVER_VALUES.stop || isFixedLouverValue(value)
355
+ ? "stop"
356
+ : positionOf(HORIZONTAL_POSITIONS, DSIOT_HORIZONTAL_POSITION_VALUES, value);
357
+ }
358
+
359
+ // `undefined` for a fixed position, which has no encoding: see UNSUPPORTED.md.
360
+ function encodeHorizontalPosition(
361
+ position: HorizontalPosition,
362
+ current: string | undefined,
363
+ ): string | undefined {
364
+ if (position === "stop") {
365
+ // Stopping the axis keeps a fixed position it already holds.
366
+ return current !== undefined &&
367
+ isFixedLouverValue(decodeDsiotLittleEndian(current))
368
+ ? current
369
+ : DSIOT_LOUVER_STOP;
370
+ }
371
+ const value = DSIOT_HORIZONTAL_POSITION_VALUES[position];
372
+ return value === undefined ? undefined : encodeLouver(value);
373
+ }
374
+
375
+ const HUMIDITY_OFF = "00";
376
+ const HUMIDITY_ON = "01";
377
+ // Also reads as off, and a control that switches humidity off leaves it.
378
+ const HUMIDITY_OFF_KEPT = "05";
379
+ const HUMIDITY_SETTING_FLAGS: Record<
380
+ Exclude<HumiditySetting, "off">,
381
+ string
382
+ > = {
383
+ auto: "02",
384
+ autoHigh: "03",
385
+ autoLow: "04",
386
+ continuous: "06",
387
+ };
388
+ const HUMIDITY_SETTINGS_BY_FLAG: Record<string, HumiditySetting> =
389
+ Object.fromEntries(
390
+ Object.entries(HUMIDITY_SETTING_FLAGS).map(([setting, flag]) => [
391
+ flag,
392
+ setting as HumiditySetting,
393
+ ]),
394
+ );
395
+
396
+ // A step (`md.st`) is one byte, in decimal: the low nibble counts, and the
397
+ // high nibble, as a signed nibble, is the power of ten that scales the count.
398
+ // A value is used as it is when its leaf has no step, or a step of zero.
399
+ function decodeStep(st: string | undefined): number {
400
+ const byte = Number(st ?? Number.NaN);
401
+ if (!Number.isInteger(byte) || byte < 0 || byte > 255) {
402
+ return 1;
403
+ }
404
+ const exponent = byte >> 4;
405
+ return (byte & 0x0f) * 10 ** (exponent < 8 ? exponent : exponent - 16) || 1;
406
+ }
407
+
408
+ function parseTargetHumidity(
409
+ values: Record<string, string>,
410
+ humidity: DsiotHumidity | undefined,
411
+ ): TargetHumidity | null {
412
+ const flag = humidity && values[humidity.flag];
413
+ if (flag === HUMIDITY_OFF || flag === HUMIDITY_OFF_KEPT) {
414
+ return "off";
415
+ }
416
+ if (humidity === undefined || flag !== HUMIDITY_ON) {
417
+ return HUMIDITY_SETTINGS_BY_FLAG[flag ?? ""] ?? null;
418
+ }
419
+ const count = Number.parseInt(values[humidity.value] ?? "", 16);
420
+ return Number.isFinite(count)
421
+ ? {
422
+ percent: Math.trunc(
423
+ count * decodeStep(values[`${humidity.value}.md.st`]),
424
+ ),
425
+ }
426
+ : null;
427
+ }
428
+
429
+ function applyTargetHumidity(
430
+ values: Record<string, string>,
431
+ { value, flag }: DsiotHumidity,
432
+ targetHumidity: TargetHumidity,
433
+ ): DsiotUpdate[] {
434
+ if (targetHumidity === "off") {
435
+ return [
436
+ {
437
+ path: flag,
438
+ value:
439
+ values[flag] === HUMIDITY_OFF_KEPT ? HUMIDITY_OFF_KEPT : HUMIDITY_OFF,
440
+ },
441
+ ];
442
+ }
443
+ if (typeof targetHumidity === "string") {
444
+ return [{ path: flag, value: HUMIDITY_SETTING_FLAGS[targetHumidity] }];
445
+ }
446
+ const count = Math.trunc(
447
+ targetHumidity.percent / decodeStep(values[`${value}.md.st`]),
448
+ );
449
+ return [
450
+ { path: flag, value: HUMIDITY_ON },
451
+ { path: value, value: count.toString(16).toUpperCase().padStart(2, "0") },
452
+ ];
453
+ }
454
+
455
+ // Whether the mode's humidity setting is on once the control is applied.
456
+ function isHumidityOn(
457
+ values: Record<string, string>,
458
+ humidity: DsiotHumidity | undefined,
459
+ targetHumidity: TargetHumidity | null | undefined,
460
+ ): boolean {
461
+ const flag = humidity && values[humidity.flag];
462
+ if (flag === undefined) {
463
+ return false;
464
+ }
465
+ return targetHumidity == null
466
+ ? flag !== HUMIDITY_OFF && flag !== HUMIDITY_OFF_KEPT
467
+ : targetHumidity !== "off";
468
+ }
469
+
470
+ const POWER_TRANSITIONS = {
471
+ turningOn: "00",
472
+ turningOff: "01",
473
+ unchanged: "02",
474
+ } as const;
475
+
476
+ /** Turns the flattened indoor `dgc_status` tree, read with `md`, into a
477
+ * `DaikinState`. Every value is per mode: the setpoint, humidity, fan speed and
478
+ * louvers each have a leaf for each mode, and the state reads the current
479
+ * mode's. */
480
+ export function parseDsiotState(values: Record<string, string>): DaikinState {
481
+ const mode = decodeDsiotMode(values[DSIOT_PATHS.mode]) ?? "auto";
482
+ const paths = DSIOT_MODE_PATHS[mode];
483
+ return {
484
+ power: decodeDsiotBoolean(values[DSIOT_PATHS.power]),
485
+ mode,
486
+ targetTemperatureInC: paths.setpoint
487
+ ? decodeDsiotSetpoint(values[paths.setpoint.path], paths.setpoint.signed)
488
+ : null,
489
+ targetHumidity: parseTargetHumidity(values, paths.humidity),
490
+ fanSpeed: decodeDsiotFanSpeed(values[paths.fanSpeed]) ?? "auto",
491
+ swingMode: decodeSwing(values[paths.vertical], values[paths.horizontal]),
492
+ verticalPosition: parseVerticalPosition(values, paths.vertical),
493
+ horizontalPosition: parseHorizontalPosition(values[paths.horizontal]),
494
+ };
495
+ }
496
+
497
+ /** Turns `values`, the flattened indoor `dgc_status` tree read with `md`, and
498
+ * a control into the leaves `buildDsiotMultireqUpdate` writes: power, its
499
+ * transition and the mode every time, then whichever of the target mode's
500
+ * leaves the control changes. It drops a `targetTemperatureInC` in a mode without
501
+ * a writable setpoint, a `targetHumidity` in a mode the unit has no humidity
502
+ * leaves for, and a louver position the unit does not take. It always writes
503
+ * the `auto` fan speed in dehumidify, and in cool while a humidity setting is
504
+ * on. A unit in a mode outside `OperatingMode` keeps it unless the control
505
+ * sets one, and gets power, its transition and that mode alone. */
506
+ export function applyDsiotControl(
507
+ values: Record<string, string>,
508
+ control: DaikinControl,
509
+ ): DsiotUpdate[] {
510
+ const currentPower = decodeDsiotBoolean(values[DSIOT_PATHS.power]);
511
+ const power = control.power ?? currentPower;
512
+ const heldMode =
513
+ control.mode === undefined
514
+ ? (values[DSIOT_PATHS.mode] ?? encodeDsiotMode("auto"))
515
+ : encodeDsiotMode(control.mode);
516
+ const mode = decodeDsiotMode(heldMode);
517
+
518
+ const updates: DsiotUpdate[] = [
519
+ { path: DSIOT_PATHS.power, value: encodeDsiotBoolean(power) },
520
+ {
521
+ path: DSIOT_PATHS.powerTransition,
522
+ value:
523
+ power === currentPower
524
+ ? POWER_TRANSITIONS.unchanged
525
+ : power
526
+ ? POWER_TRANSITIONS.turningOn
527
+ : POWER_TRANSITIONS.turningOff,
528
+ },
529
+ {
530
+ path: DSIOT_PATHS.mode,
531
+ value: mode === undefined ? heldMode : encodeDsiotMode(mode),
532
+ },
533
+ ];
534
+ // Which leaves a mode outside the model reads and writes is not known.
535
+ if (mode === undefined) {
536
+ return updates;
537
+ }
538
+ const paths = DSIOT_MODE_PATHS[mode];
539
+ if (control.targetTemperatureInC != null && paths.setpoint?.writable) {
540
+ updates.push({
541
+ path: paths.setpoint.path,
542
+ value: encodeDsiotSetpoint(control.targetTemperatureInC),
543
+ });
544
+ }
545
+ if (
546
+ control.targetHumidity != null &&
547
+ paths.humidity &&
548
+ paths.humidity.flag in values
549
+ ) {
550
+ updates.push(
551
+ ...applyTargetHumidity(values, paths.humidity, control.targetHumidity),
552
+ );
553
+ }
554
+ // Dehumidify runs the fan on auto only, and so does cool while a humidity
555
+ // setting is on.
556
+ if (
557
+ mode === "dehumidify" ||
558
+ (mode === "cool" &&
559
+ isHumidityOn(values, paths.humidity, control.targetHumidity))
560
+ ) {
561
+ updates.push({ path: paths.fanSpeed, value: encodeDsiotFanSpeed("auto") });
562
+ } else if (control.fanSpeed !== undefined) {
563
+ updates.push({
564
+ path: paths.fanSpeed,
565
+ value: encodeDsiotFanSpeed(control.fanSpeed),
566
+ });
567
+ }
568
+ let vertical: string | undefined;
569
+ let horizontal: string | undefined;
570
+ if (control.swingMode !== undefined) {
571
+ const swingsVertically =
572
+ control.swingMode === "vertical" || control.swingMode === "both";
573
+ const swingsHorizontally =
574
+ control.swingMode === "horizontal" || control.swingMode === "both";
575
+ vertical = louver(swingsVertically, values[paths.vertical]);
576
+ horizontal = louver(swingsHorizontally, values[paths.horizontal]);
577
+ }
578
+ // A position overrides the swing mode sent with it.
579
+ if (control.verticalPosition != null) {
580
+ vertical =
581
+ encodeVerticalPosition(
582
+ control.verticalPosition,
583
+ values,
584
+ paths.vertical,
585
+ ) ?? vertical;
586
+ }
587
+ if (control.horizontalPosition != null) {
588
+ horizontal =
589
+ encodeHorizontalPosition(
590
+ control.horizontalPosition,
591
+ values[paths.horizontal],
592
+ ) ?? horizontal;
593
+ }
594
+ if (vertical !== undefined) {
595
+ updates.push({ path: paths.vertical, value: vertical });
596
+ }
597
+ if (horizontal !== undefined) {
598
+ updates.push({ path: paths.horizontal, value: horizontal });
599
+ }
600
+ return updates;
601
+ }
602
+
603
+ export function parseDsiotSensors(
604
+ values: Record<string, string>,
605
+ ): DaikinSensors {
606
+ return {
607
+ indoorTemperatureInC: decodeDsiotIndoorTemperature(
608
+ values[DSIOT_PATHS.indoorTemperature],
609
+ ),
610
+ indoorHumidityInPercent: decodeDsiotIndoorHumidity(
611
+ values[DSIOT_PATHS.indoorHumidity],
612
+ ),
613
+ outdoorTemperatureInC: decodeDsiotOutdoorTemperature(
614
+ values[DSIOT_PATHS.outdoorTemperature],
615
+ ),
616
+ };
617
+ }
618
+
619
+ function decodeSwing(
620
+ vertical: string | undefined,
621
+ horizontal: string | undefined,
622
+ ): SwingMode {
623
+ const swingsVertically = isDsiotLouverSwinging(vertical);
624
+ const swingsHorizontally = isDsiotLouverSwinging(horizontal);
625
+ if (swingsVertically && swingsHorizontally) {
626
+ return "both";
627
+ }
628
+ return swingsVertically
629
+ ? "vertical"
630
+ : swingsHorizontally
631
+ ? "horizontal"
632
+ : "off";
633
+ }
634
+
635
+ // Stopping a swinging axis stops the louver; an axis already at a fixed
636
+ // position keeps it.
637
+ function louver(swing: boolean, current: string | undefined): string {
638
+ if (swing) {
639
+ return DSIOT_LOUVER_SWING;
640
+ }
641
+ return current === undefined || isDsiotLouverSwinging(current)
642
+ ? DSIOT_LOUVER_STOP
643
+ : current;
644
+ }