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,302 @@
1
+ import type {
2
+ DaikinControl,
3
+ DaikinSensors,
4
+ DaikinState,
5
+ FanSpeed,
6
+ HorizontalPosition,
7
+ HumiditySetting,
8
+ OperatingMode,
9
+ SwingMode,
10
+ TargetHumidity,
11
+ VerticalPosition,
12
+ } from "../../models/state.js";
13
+ import { takesClassicDirection, takesClassicHumidity } from "./capabilities.js";
14
+
15
+ // Maps between `DaikinState` and the classic `control_info` wire fields, and reads
16
+ // `DaikinSensors` from `get_sensor_info`.
17
+
18
+ const OPERATING_MODE_CODES: Record<OperatingMode, string> = {
19
+ auto: "1",
20
+ dehumidify: "2",
21
+ cool: "3",
22
+ heat: "4",
23
+ fan: "6",
24
+ };
25
+
26
+ const OPERATING_MODES_BY_CODE: Record<string, OperatingMode> = {
27
+ "0": "auto",
28
+ "1": "auto",
29
+ "7": "auto",
30
+ "2": "dehumidify",
31
+ "3": "cool",
32
+ "4": "heat",
33
+ "6": "fan",
34
+ };
35
+
36
+ const FAN_SPEED_CODES: Record<FanSpeed, string> = {
37
+ auto: "A",
38
+ silent: "B",
39
+ level1: "3",
40
+ level2: "4",
41
+ level3: "5",
42
+ level4: "6",
43
+ level5: "7",
44
+ };
45
+
46
+ const FAN_SPEEDS_BY_CODE: Record<string, FanSpeed> = Object.fromEntries(
47
+ Object.entries(FAN_SPEED_CODES).map(([speed, code]) => [
48
+ code,
49
+ speed as FanSpeed,
50
+ ]),
51
+ );
52
+
53
+ const SWING_MODE_CODES: Record<SwingMode, string> = {
54
+ off: "0",
55
+ vertical: "1",
56
+ horizontal: "2",
57
+ both: "3",
58
+ };
59
+
60
+ const SWING_MODES_BY_CODE: Record<string, SwingMode> = Object.fromEntries(
61
+ Object.entries(SWING_MODE_CODES).map(([swing, code]) => [
62
+ code,
63
+ swing as SwingMode,
64
+ ]),
65
+ );
66
+
67
+ // `f_dir_ud` and `f_dir_lr` carry a digit for stop and each fixed position, and
68
+ // a letter for the rest.
69
+ const LOUVER_STOP = "0";
70
+ const LOUVER_SWING = "S";
71
+ const VERTICAL_POSITION_CODES: Record<VerticalPosition, string> = {
72
+ stop: LOUVER_STOP,
73
+ position1: "1",
74
+ position2: "2",
75
+ position3: "3",
76
+ position4: "4",
77
+ position5: "5",
78
+ swing: LOUVER_SWING,
79
+ naturalWind: "N",
80
+ auto: "A",
81
+ floorWarming: "F",
82
+ };
83
+ const HORIZONTAL_POSITION_CODES: Record<HorizontalPosition, string> = {
84
+ stop: LOUVER_STOP,
85
+ position1: "1",
86
+ position2: "2",
87
+ position3: "3",
88
+ position4: "4",
89
+ position5: "5",
90
+ swing: LOUVER_SWING,
91
+ auto: "A",
92
+ };
93
+
94
+ const VERTICAL_POSITIONS_BY_CODE: Record<string, VerticalPosition> =
95
+ Object.fromEntries(
96
+ Object.entries(VERTICAL_POSITION_CODES).map(([position, code]) => [
97
+ code,
98
+ position as VerticalPosition,
99
+ ]),
100
+ );
101
+
102
+ const HORIZONTAL_POSITIONS_BY_CODE: Record<string, HorizontalPosition> =
103
+ Object.fromEntries(
104
+ Object.entries(HORIZONTAL_POSITION_CODES).map(([position, code]) => [
105
+ code,
106
+ position as HorizontalPosition,
107
+ ]),
108
+ );
109
+
110
+ const HUMIDITY_SETTING_CODES: Record<HumiditySetting, string> = {
111
+ off: "0",
112
+ autoLow: "AUTO_L",
113
+ auto: "AUTO",
114
+ autoHigh: "AUTO_H",
115
+ continuous: "CONTINUE",
116
+ };
117
+
118
+ const HUMIDITY_SETTINGS_BY_CODE: Record<string, HumiditySetting> =
119
+ Object.fromEntries(
120
+ Object.entries(HUMIDITY_SETTING_CODES).map(([setting, code]) => [
121
+ code,
122
+ setting as HumiditySetting,
123
+ ]),
124
+ );
125
+
126
+ // Fan mode keeps no setpoint memory, and dehumidify runs the fan on auto only.
127
+ const NO_SETPOINT = "--";
128
+ const DEHUMIDIFY_FAN_SPEED = "A";
129
+ // In `ndfd<mode>`, a louver the unit lacks.
130
+ const NO_LOUVER = "-";
131
+
132
+ /** Turns a `get_control_info` reply into a `DaikinState`. A `mode` or `f_rate`
133
+ * outside the model reads as `auto`, an `f_dir` outside it as `off`, and an
134
+ * `f_dir_ud` or `f_dir_lr` outside it as `null`. */
135
+ export function parseClassicState(fields: Record<string, string>): DaikinState {
136
+ return {
137
+ power: fields.pow === "1",
138
+ mode: OPERATING_MODES_BY_CODE[fields.mode ?? ""] ?? "auto",
139
+ targetTemperatureInC: parseNumeric(fields.stemp),
140
+ targetHumidity: parseTargetHumidity(fields.shum),
141
+ fanSpeed: FAN_SPEEDS_BY_CODE[fields.f_rate ?? ""] ?? "auto",
142
+ swingMode: SWING_MODES_BY_CODE[fields.f_dir ?? ""] ?? "off",
143
+ verticalPosition: VERTICAL_POSITIONS_BY_CODE[fields.f_dir_ud ?? ""] ?? null,
144
+ horizontalPosition:
145
+ HORIZONTAL_POSITIONS_BY_CODE[fields.f_dir_lr ?? ""] ?? null,
146
+ };
147
+ }
148
+
149
+ export function parseClassicSensors(
150
+ fields: Record<string, string>,
151
+ ): DaikinSensors {
152
+ return {
153
+ indoorTemperatureInC: parseNumeric(fields.htemp),
154
+ outdoorTemperatureInC: parseNumeric(fields.otemp),
155
+ indoorHumidityInPercent: parseNumeric(fields.hhum),
156
+ };
157
+ }
158
+
159
+ /** Turns `current`, the `get_control_info` reply, and a control into the
160
+ * `set_control_info` query: power and mode, the setpoint, humidity and fan
161
+ * speed for the target mode, and the airflow fields the unit takes. When the
162
+ * mode changes, each comes from the target mode's memory: `dt`, `dh`, `dfr`,
163
+ * `dfd` and, for the louvers, `ndfd`. `capabilityFields`, the field map of
164
+ * `mergeClassicCapabilityFields`, says which fields the unit takes: `shum`
165
+ * unless it is `subtype=qa`, `f_dir_ud` and `f_dir_lr` on a 3D unit
166
+ * (`en_fdir2`), and `f_dir` on a unit that takes a direction. It drops a
167
+ * `targetTemperatureInC` in dehumidify, writes `mode=0` and `mode=7` as
168
+ * `mode=1`, and never echoes `ret`, `adv` or the other modes' memory. */
169
+ export function applyClassicControl(
170
+ current: Record<string, string>,
171
+ control: DaikinControl,
172
+ capabilityFields: Record<string, string>,
173
+ ): Record<string, string> {
174
+ const takesDirection = takesClassicDirection(capabilityFields);
175
+ const heldMode = OPERATING_MODES_BY_CODE[current.mode ?? ""];
176
+ // A mode outside the model is sent back as it came.
177
+ const currentMode =
178
+ heldMode === undefined
179
+ ? (current.mode ?? "")
180
+ : OPERATING_MODE_CODES[heldMode];
181
+ const mode =
182
+ control.mode === undefined
183
+ ? currentMode
184
+ : OPERATING_MODE_CODES[control.mode];
185
+ const modeChanged = mode !== currentMode;
186
+ const remembered = (field: string, memoryPrefix: string) =>
187
+ (modeChanged ? current[`${memoryPrefix}${mode}`] : undefined) ??
188
+ current[field] ??
189
+ "";
190
+
191
+ const power = control.power ?? current.pow === "1";
192
+ const next: Record<string, string> = { pow: power ? "1" : "0", mode };
193
+
194
+ const setpointFrozen = mode === OPERATING_MODE_CODES.fan;
195
+ // Only a mode in the model has a `dt` and a `dh` memory.
196
+ const hasMemory =
197
+ !setpointFrozen && (control.mode !== undefined || heldMode !== undefined);
198
+ next.stemp = setpointFrozen
199
+ ? NO_SETPOINT
200
+ : control.targetTemperatureInC != null &&
201
+ mode !== OPERATING_MODE_CODES.dehumidify
202
+ ? control.targetTemperatureInC.toFixed(1)
203
+ : remembered("stemp", "dt");
204
+ if (hasMemory) {
205
+ next[`dt${mode}`] = next.stemp;
206
+ }
207
+ if (takesClassicHumidity(capabilityFields)) {
208
+ next.shum = setpointFrozen
209
+ ? NO_SETPOINT
210
+ : control.targetHumidity != null
211
+ ? encodeTargetHumidity(control.targetHumidity)
212
+ : remembered("shum", "dh");
213
+ if (hasMemory) {
214
+ next[`dh${mode}`] = next.shum;
215
+ }
216
+ }
217
+
218
+ next.f_rate =
219
+ mode === OPERATING_MODE_CODES.dehumidify
220
+ ? DEHUMIDIFY_FAN_SPEED
221
+ : control.fanSpeed === undefined
222
+ ? remembered("f_rate", "dfr")
223
+ : FAN_SPEED_CODES[control.fanSpeed];
224
+
225
+ if (capabilityFields.en_fdir2 === "1") {
226
+ // `ndfd<mode>` holds the mode's `f_dir_ud` value, then its `f_dir_lr`
227
+ // value. A value outside the model is sent back as it came.
228
+ const louverMemory = modeChanged ? current[`ndfd${mode}`] : undefined;
229
+ let vertical =
230
+ rememberedLouver(louverMemory, 0) || current.f_dir_ud || LOUVER_STOP;
231
+ let horizontal =
232
+ rememberedLouver(louverMemory, 1) || current.f_dir_lr || LOUVER_STOP;
233
+ if (control.swingMode !== undefined) {
234
+ const swingsVertically =
235
+ control.swingMode === "vertical" || control.swingMode === "both";
236
+ const swingsHorizontally =
237
+ control.swingMode === "horizontal" || control.swingMode === "both";
238
+ vertical = swingsVertically
239
+ ? LOUVER_SWING
240
+ : vertical === LOUVER_SWING
241
+ ? LOUVER_STOP
242
+ : vertical;
243
+ horizontal = swingsHorizontally
244
+ ? LOUVER_SWING
245
+ : horizontal === LOUVER_SWING
246
+ ? LOUVER_STOP
247
+ : horizontal;
248
+ }
249
+ if (control.verticalPosition != null) {
250
+ vertical = VERTICAL_POSITION_CODES[control.verticalPosition];
251
+ }
252
+ if (control.horizontalPosition != null) {
253
+ horizontal = HORIZONTAL_POSITION_CODES[control.horizontalPosition];
254
+ }
255
+ next.f_dir_ud = vertical;
256
+ next.f_dir_lr = horizontal;
257
+ if (takesDirection) {
258
+ next.f_dir = String(
259
+ (vertical === LOUVER_SWING ? 1 : 0) |
260
+ (horizontal === LOUVER_SWING ? 2 : 0),
261
+ );
262
+ }
263
+ } else if (takesDirection) {
264
+ next.f_dir =
265
+ control.swingMode === undefined
266
+ ? remembered("f_dir", "dfd")
267
+ : SWING_MODE_CODES[control.swingMode];
268
+ }
269
+ return next;
270
+ }
271
+
272
+ function parseTargetHumidity(shum: string | undefined): TargetHumidity | null {
273
+ const setting = HUMIDITY_SETTINGS_BY_CODE[shum ?? ""];
274
+ if (setting !== undefined) {
275
+ return setting;
276
+ }
277
+ const percent = parseNumeric(shum);
278
+ return percent === null ? null : { percent };
279
+ }
280
+
281
+ function encodeTargetHumidity(targetHumidity: TargetHumidity): string {
282
+ return typeof targetHumidity === "string"
283
+ ? HUMIDITY_SETTING_CODES[targetHumidity]
284
+ : String(targetHumidity.percent);
285
+ }
286
+
287
+ function rememberedLouver(
288
+ louverMemory: string | undefined,
289
+ index: number,
290
+ ): string | undefined {
291
+ const value = louverMemory?.[index];
292
+ return value === NO_LOUVER ? undefined : value;
293
+ }
294
+
295
+ function parseNumeric(value: string | undefined): number | null {
296
+ if (value === undefined || value === "") {
297
+ return null;
298
+ }
299
+ const parsed = Number(value);
300
+ // Modes without a setpoint answer "M", "--" or "-", which are not numbers.
301
+ return Number.isFinite(parsed) ? parsed : null;
302
+ }
@@ -0,0 +1,19 @@
1
+ /** The header that carries the terminal uuid on every call. The adapter takes
2
+ * the uuid without its dashes. */
3
+ export function encodeTerminalUuid(uuid: string): Record<string, string> {
4
+ return { "X-Daikin-uuid": uuid.replaceAll("-", "") };
5
+ }
6
+
7
+ /** The query parameter a classic adapter takes the local password as. */
8
+ export function encodeClassicLocalPassword(
9
+ localPassword: string,
10
+ ): Record<string, string> {
11
+ return { lpw: localPassword };
12
+ }
13
+
14
+ /** The header a DSIOT adapter takes the local password as. */
15
+ export function encodeDsiotLocalPassword(
16
+ localPassword: string,
17
+ ): Record<string, string> {
18
+ return { Authorization: `Basic ${localPassword}` };
19
+ }
@@ -0,0 +1,41 @@
1
+ import type { DaikinDevice } from "../models/device.js";
2
+
3
+ const DSIOT_TYPE = "GPF";
4
+ const MARVELL_ADP_KIND = "3";
5
+
6
+ /** Turns a `basic_info` reply into a `DaikinDevice`: `type=GPF` marks a DSIOT
7
+ * adapter, and `api_port`, when present, is the HTTP port and is appended to
8
+ * `host`, the address the reply came from. */
9
+ export function parseDevice(
10
+ host: string,
11
+ basicInfo: Record<string, string>,
12
+ ): DaikinDevice {
13
+ const protocol = basicInfo.type === DSIOT_TYPE ? "dsiot" : "classic";
14
+ const secure = basicInfo.en_secure === "1";
15
+ return {
16
+ host: basicInfo.api_port ? `${host}:${basicInfo.api_port}` : host,
17
+ protocol,
18
+ name: decodeName(basicInfo.name),
19
+ // Only classic adapters register terminals.
20
+ requiresRegistration: secure && protocol === "classic",
21
+ // Only a Marvell adapter (adp_kind=3) in secure mode speaks HTTPS; every
22
+ // other adapter, DSIOT included, stays on HTTP on the LAN.
23
+ usesHttps:
24
+ secure &&
25
+ protocol === "classic" &&
26
+ basicInfo.adp_kind === MARVELL_ADP_KIND,
27
+ requiresLocalPassword: basicInfo.lpw_flag === "1",
28
+ };
29
+ }
30
+
31
+ // The name is form-encoded: `+` is a space.
32
+ function decodeName(value: string | undefined): string | undefined {
33
+ if (value === undefined) {
34
+ return undefined;
35
+ }
36
+ try {
37
+ return decodeURIComponent(value.replaceAll("+", " "));
38
+ } catch {
39
+ return value;
40
+ }
41
+ }
@@ -0,0 +1,209 @@
1
+ import type {
2
+ DaikinCapabilities,
3
+ TemperatureRange,
4
+ } from "../../models/capabilities.js";
5
+ import {
6
+ FAN_SPEEDS,
7
+ type FanSpeed,
8
+ FIXED_LOUVER_POSITIONS,
9
+ HORIZONTAL_POSITIONS,
10
+ type HorizontalPosition,
11
+ HUMIDITY_SETTINGS,
12
+ OPERATING_MODES,
13
+ type OperatingMode,
14
+ SWING_MODES,
15
+ type SwingMode,
16
+ VERTICAL_POSITIONS,
17
+ type VerticalPosition,
18
+ } from "../../models/state.js";
19
+ import {
20
+ DSIOT_HORIZONTAL_POSITION_VALUES,
21
+ DSIOT_MODE_PATHS,
22
+ DSIOT_PATHS,
23
+ DSIOT_VERTICAL_POSITION_VALUES,
24
+ type DsiotSetpoint,
25
+ decodeDsiotLittleEndian,
26
+ } from "./state.js";
27
+
28
+ const MODE_PATHS = Object.values(DSIOT_MODE_PATHS);
29
+
30
+ const ADAPTER_PATHS = {
31
+ region: "adp_i.reg",
32
+ kind: "adp_i.kind",
33
+ energyMonitoring: "adp_i.func.en_ipower",
34
+ demandControl: "adp_i.func.en_demand",
35
+ scheduleTimer: "adp_i.func.en_scdltmr",
36
+ } as const;
37
+
38
+ const COOLING_ONLY_MODEL_TYPE = "01";
39
+ const COOLING_ONLY_MODES: readonly OperatingMode[] = [
40
+ "dehumidify",
41
+ "cool",
42
+ "fan",
43
+ ];
44
+
45
+ // The fixed positions, 1-14, in a louver leaf's `md.mx`.
46
+ const FIXED_LOUVER_MASK = 0x7ffe;
47
+ const FIXED_POSITIONS: readonly string[] = FIXED_LOUVER_POSITIONS;
48
+
49
+ type LouverAxis = "vertical" | "horizontal";
50
+
51
+ /** Turns the flattened `adp_i` and indoor `dgc_status` trees (with `md`) into
52
+ * `DaikinCapabilities`. Setpoints count half degrees. */
53
+ export function parseDsiotCapabilities(
54
+ values: Record<string, string>,
55
+ ): DaikinCapabilities {
56
+ const modes =
57
+ values[DSIOT_PATHS.modelType] === COOLING_ONLY_MODEL_TYPE
58
+ ? [...COOLING_ONLY_MODES]
59
+ : [...OPERATING_MODES];
60
+ const offered = (mode: OperatingMode, range: TemperatureRange) =>
61
+ modes.includes(mode) ? range : null;
62
+ // A mode takes a humidity setting when the unit reports the mode's flag.
63
+ const humiditySettings = (mode: OperatingMode) => {
64
+ const flag = DSIOT_MODE_PATHS[mode].humidity?.flag;
65
+ return modes.includes(mode) && flag !== undefined && flag in values
66
+ ? [...HUMIDITY_SETTINGS]
67
+ : [];
68
+ };
69
+ const vertical = hasLouver(values, "vertical");
70
+ const horizontal = hasLouver(values, "horizontal");
71
+ const energyMonitoring =
72
+ values[ADAPTER_PATHS.energyMonitoring] === "1" &&
73
+ values[DSIOT_PATHS.energyMonitoring] === "01";
74
+
75
+ return {
76
+ modes,
77
+ fanSpeeds: fanSpeeds(values),
78
+ swingModes: swingModes(vertical, horizontal),
79
+ verticalPositions: vertical
80
+ ? verticalPositions(louverMask(values, "vertical"))
81
+ : [],
82
+ horizontalPositions: horizontal
83
+ ? horizontalPositions(louverMask(values, "horizontal"))
84
+ : [],
85
+ targetTemperatureInC: {
86
+ auto: offered("auto", { min: 18, max: 30, step: 0.5 }),
87
+ cool: offered(
88
+ "cool",
89
+ range(values, DSIOT_MODE_PATHS.cool.setpoint, 18, 32),
90
+ ),
91
+ heat: offered(
92
+ "heat",
93
+ range(values, DSIOT_MODE_PATHS.heat.setpoint, 10, 30),
94
+ ),
95
+ dehumidify: null,
96
+ fan: null,
97
+ },
98
+ targetHumidity: {
99
+ auto: humiditySettings("auto"),
100
+ // Dehumidify takes no off.
101
+ dehumidify: humiditySettings("dehumidify").filter(
102
+ (setting) => setting !== "off",
103
+ ),
104
+ cool: humiditySettings("cool"),
105
+ heat: humiditySettings("heat"),
106
+ fan: [],
107
+ },
108
+ // Which leaf reports the special air outlet is not known: see
109
+ // UNSUPPORTED.md.
110
+ specialOutlet: false,
111
+ energyMonitoring,
112
+ demandControl:
113
+ energyMonitoring &&
114
+ values[ADAPTER_PATHS.kind] === "4" &&
115
+ values[ADAPTER_PATHS.demandControl] === "1",
116
+ scheduleTimer: ["1", "2"].includes(
117
+ values[ADAPTER_PATHS.scheduleTimer] ?? "",
118
+ ),
119
+ model: values[DSIOT_PATHS.model],
120
+ };
121
+ }
122
+
123
+ function hasLouver(values: Record<string, string>, axis: LouverAxis): boolean {
124
+ return MODE_PATHS.some((paths) => paths[axis] in values);
125
+ }
126
+
127
+ // Every value the unit takes on an axis in any mode, as `md.mx` bits.
128
+ function louverMask(values: Record<string, string>, axis: LouverAxis): number {
129
+ return MODE_PATHS.reduce(
130
+ (mask, paths) =>
131
+ mask | decodeDsiotLittleEndian(values[`${paths[axis]}.md.mx`] ?? ""),
132
+ 0,
133
+ );
134
+ }
135
+
136
+ function takes(mask: number, value: number | undefined): boolean {
137
+ return value !== undefined && (mask & (1 << value)) !== 0;
138
+ }
139
+
140
+ // Japanese units (`jp`) follow other rules: see UNSUPPORTED.md.
141
+ function fanSpeeds(values: Record<string, string>): FanSpeed[] {
142
+ const region = values[ADAPTER_PATHS.region];
143
+ if (!region || region === "jp") {
144
+ return [];
145
+ }
146
+ return MODE_PATHS.some(({ fanSpeed }) => fanSpeed in values)
147
+ ? [...FAN_SPEEDS]
148
+ : [];
149
+ }
150
+
151
+ function swingModes(vertical: boolean, horizontal: boolean): SwingMode[] {
152
+ if (vertical && horizontal) {
153
+ return [...SWING_MODES];
154
+ }
155
+ if (vertical) {
156
+ return ["off", "vertical"];
157
+ }
158
+ return horizontal ? ["off", "horizontal"] : [];
159
+ }
160
+
161
+ // A unit that takes n fixed values offers the first n fixed positions, as
162
+ // `parseDsiotState` names them by rank.
163
+ function verticalPositions(mask: number): VerticalPosition[] {
164
+ // Without `md.mx` the unit does not say what it takes.
165
+ if (mask === 0) {
166
+ return ["stop", "swing"];
167
+ }
168
+ const fixedCount = bitCount(mask & FIXED_LOUVER_MASK);
169
+ return VERTICAL_POSITIONS.filter((position) => {
170
+ const rank = FIXED_POSITIONS.indexOf(position);
171
+ return rank === -1
172
+ ? takes(mask, DSIOT_VERTICAL_POSITION_VALUES[position])
173
+ : rank < fixedCount;
174
+ });
175
+ }
176
+
177
+ // `parseDsiotState` reads a fixed position as `stop`, so stop is always
178
+ // offered: see UNSUPPORTED.md.
179
+ function horizontalPositions(mask: number): HorizontalPosition[] {
180
+ return HORIZONTAL_POSITIONS.filter(
181
+ (position) =>
182
+ position === "stop" ||
183
+ takes(mask, DSIOT_HORIZONTAL_POSITION_VALUES[position]),
184
+ );
185
+ }
186
+
187
+ function bitCount(mask: number): number {
188
+ return mask.toString(2).replaceAll("0", "").length;
189
+ }
190
+
191
+ // A setpoint's `md.mi` and `md.mx` count half degrees. Without both, the range
192
+ // is `fallbackMin` to `ceiling`, and `ceiling` caps the unit's own maximum.
193
+ function range(
194
+ values: Record<string, string>,
195
+ setpoint: DsiotSetpoint | undefined,
196
+ fallbackMin: number,
197
+ ceiling: number,
198
+ ): TemperatureRange {
199
+ const min =
200
+ decodeDsiotLittleEndian(values[`${setpoint?.path}.md.mi`] ?? "") / 2;
201
+ const max =
202
+ decodeDsiotLittleEndian(values[`${setpoint?.path}.md.mx`] ?? "") / 2;
203
+ const reported = Number.isFinite(min) && Number.isFinite(max);
204
+ return {
205
+ min: reported ? min : fallbackMin,
206
+ max: reported ? Math.min(max, ceiling) : ceiling,
207
+ step: 0.5,
208
+ };
209
+ }