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
@@ -9,6 +9,9 @@ export type DaikinProtocol = (typeof DAIKIN_PROTOCOLS)[number];
9
9
  * each `DaikinDevice`, and `DaikinClient.getDeviceInfo` reads it for a device
10
10
  * written by hand. */
11
11
  export interface DaikinDeviceInfo {
12
+ /** The name the adapter was given, such as `Bedroom`. Left out when the
13
+ * adapter reports none. */
14
+ name?: string;
12
15
  /** In pairs joined by colons, in the letter case the adapter sends, such as
13
16
  * `A4:08:EA:CC:91:D4`. Left out when the adapter reports none. */
14
17
  macAddress?: string;
@@ -25,7 +28,6 @@ export interface DaikinDevice extends DaikinDeviceInfo {
25
28
  /** Address, with `:port` when the adapter announces one. */
26
29
  host: string;
27
30
  protocol: DaikinProtocol;
28
- name?: string;
29
31
  /** The adapter answers a terminal uuid only once it is registered with the
30
32
  * KEY on the adapter's label (`registerTerminal`). Classic only. */
31
33
  requiresRegistration?: boolean;
@@ -106,9 +106,8 @@ export interface DaikinState {
106
106
  * fields to change. The client overlays the control on the state the unit
107
107
  * holds, and writes the whole state back. It drops a field the unit does not take in
108
108
  * the target mode, such as a louver position outside
109
- * `DaikinCapabilities.verticalPositions`. It takes no `specialModes`: a
110
- * classic adapter is set one with `ClassicDaikinClient.setSpecialMode`, and
111
- * UNSUPPORTED.md says what is not known about the rest. */
109
+ * `DaikinCapabilities.verticalPositions`. It takes no `specialModes`:
110
+ * `DaikinClient.setSpecialMode` turns one on or off. */
112
111
  export type DaikinControl = Partial<Omit<DaikinState, "specialModes">>;
113
112
 
114
113
  /** A reading is `null` when the unit does not report it. */
@@ -0,0 +1,635 @@
1
+ import {
2
+ CLASSIC_COOLING_ONLY_TYPE,
3
+ CLASSIC_FAN_SPEED_CODES,
4
+ CLASSIC_HORIZONTAL_POSITION_CODES,
5
+ CLASSIC_HUMIDITY_SETTING_CODES,
6
+ CLASSIC_LOUVER_STOP,
7
+ CLASSIC_LOUVER_SWING,
8
+ CLASSIC_NO_SETPOINT,
9
+ CLASSIC_OPERATING_MODE_CODES,
10
+ CLASSIC_REMOTE_METHODS,
11
+ CLASSIC_SPECIAL_MODE_ADV_CODES,
12
+ CLASSIC_SPECIAL_MODE_BITS,
13
+ CLASSIC_SPECIAL_MODE_KIND_CODES,
14
+ CLASSIC_SWING_MODE_CODES,
15
+ CLASSIC_TERMINAL_SCOPE_CODES,
16
+ CLASSIC_VERTICAL_POSITION_CODES,
17
+ } from "../codecs/classic/tables.js";
18
+ import type { DaikinDeviceInfo } from "../models/device.js";
19
+ import type { DaikinSensors, DaikinState } from "../models/state.js";
20
+ import {
21
+ type DaikinSimulator,
22
+ type DaikinSimulatorRequest,
23
+ type DaikinSimulatorUnit,
24
+ formatKeyValue,
25
+ type SimulatorReply,
26
+ type StartSimulatorOptions,
27
+ startSimulator,
28
+ unanswered,
29
+ } from "./simulator.js";
30
+
31
+ // A classic adapter (BRP069, BRP072) as an HTTP server. It reads the adapter's
32
+ // codes from the codecs' tables and shares none of their functions: see
33
+ // DESIGN.md#simulators.
34
+
35
+ const DEFAULT_BASIC_INFO: Record<string, string> = {
36
+ type: "aircon",
37
+ reg: "th",
38
+ ver: "1_2_51",
39
+ name: "Simulator",
40
+ icon: "0",
41
+ method: CLASSIC_REMOTE_METHODS.homeOnly,
42
+ adp_kind: "4",
43
+ cpv: "3",
44
+ cpv_minor: "20",
45
+ led: "1",
46
+ mac: "02DA1C000001",
47
+ en_hol: "0",
48
+ grp_name: "",
49
+ en_grp: "0",
50
+ };
51
+
52
+ const DEFAULT_MODEL_INFO: Record<string, string> = {
53
+ model: "0ABB",
54
+ type: "N",
55
+ en_frate: "1",
56
+ en_frate_auto: "1",
57
+ en_frate_silent: "1",
58
+ frate_steps: "5",
59
+ en_fdir: "1",
60
+ s_fdir: "3",
61
+ s_humd: "0",
62
+ en_spmode: "7",
63
+ elec: "1",
64
+ dmnd: "1",
65
+ en_scdltmr: "1",
66
+ };
67
+
68
+ const DEFAULT_CONTROL_INFO: Record<string, string> = {
69
+ pow: "0",
70
+ mode: "3",
71
+ adv: "",
72
+ stemp: "24.0",
73
+ shum: "0",
74
+ dt1: "25.0",
75
+ dt2: "M",
76
+ dt3: "24.0",
77
+ dt4: "25.0",
78
+ dh1: "0",
79
+ dh2: "0",
80
+ dh3: "0",
81
+ dh4: "0",
82
+ f_rate: "A",
83
+ f_dir: "0",
84
+ dfr1: "A",
85
+ dfr2: "A",
86
+ dfr3: "A",
87
+ dfr4: "A",
88
+ dfr6: "A",
89
+ dfd1: "0",
90
+ dfd2: "0",
91
+ dfd3: "0",
92
+ dfd4: "0",
93
+ dfd6: "0",
94
+ };
95
+
96
+ const DEFAULT_SENSOR_INFO: Record<string, string> = {
97
+ htemp: "26.0",
98
+ hhum: "55",
99
+ otemp: "30.0",
100
+ };
101
+
102
+ const DEFAULT_REMOTE_METHOD: Record<string, string> = {
103
+ notice_ip_int: "3600",
104
+ notice_sync_int: "60",
105
+ };
106
+
107
+ const REGISTER_TERMINAL_PATH = "/common/register_terminal";
108
+
109
+ // A `set_control_info` without one of these is answered `PARAM NG`. `shum`
110
+ // joins them on a unit whose `get_control_info` reports it.
111
+ const REQUIRED_CONTROL_PARAMETERS = ["pow", "mode", "stemp", "f_rate"];
112
+ // The parameters `set_control_info` stores: the control fields, and the
113
+ // memory of a mode by name, such as `dt3` and `dfd4`.
114
+ const CONTROL_PARAMETER_PATTERN =
115
+ /^(pow|mode|stemp|shum|f_rate|f_dir|f_dir_ud|f_dir_lr|(dt|dh|dfd)\w)$/;
116
+ // The control field each memory prefix remembers: `dt3` holds the `stemp` of
117
+ // mode 3.
118
+ const MODE_MEMORY_FIELDS = {
119
+ dt: "stemp",
120
+ dh: "shum",
121
+ dfr: "f_rate",
122
+ dfd: "f_dir",
123
+ } as const;
124
+
125
+ // `spmode_kind` 4 is heat plus, which `adv` reports as 4. It is outside
126
+ // `SpecialMode`: see UNSUPPORTED.md.
127
+ const HEAT_PLUS_KIND_CODE = "4";
128
+ const HEAT_PLUS_ADV_CODE = "4";
129
+ // Powerful, econo and heat plus replace one another in `adv`.
130
+ const ADV_CODES_BY_SPECIAL_MODE_KIND: Record<string, string> = {
131
+ [CLASSIC_SPECIAL_MODE_KIND_CODES.powerful]:
132
+ CLASSIC_SPECIAL_MODE_ADV_CODES.powerful,
133
+ [CLASSIC_SPECIAL_MODE_KIND_CODES.econo]: CLASSIC_SPECIAL_MODE_ADV_CODES.econo,
134
+ [HEAT_PLUS_KIND_CODE]: HEAT_PLUS_ADV_CODE,
135
+ };
136
+
137
+ // A sensor field the unit has no reading for.
138
+ const NO_READING = "-";
139
+
140
+ // `type` in `get_model_info` on a heat pump.
141
+ const HEAT_PUMP_TYPE = "N";
142
+ // `s_fdir`: the axes the louvers swing on.
143
+ const SWING_AXES_CODES = { none: "0", vertical: "1", both: "3" } as const;
144
+
145
+ /** What a simulated classic unit has. */
146
+ export interface ClassicSimulatorUnit extends DaikinSimulatorUnit {
147
+ /** How many fan levels the unit takes: `level1` and `level5` for 2,
148
+ * `level3` as well for 3, and all five for 5. Defaults to 5. */
149
+ fanLevels?: 2 | 3 | 5;
150
+ }
151
+
152
+ export interface StartClassicSimulatorOptions extends StartSimulatorOptions {
153
+ /** What the unit has. Defaults to a heat pump with every fan speed, swing
154
+ * on both axes and all three special modes. A field of `modelInfo`
155
+ * replaces what this sets. */
156
+ unit?: ClassicSimulatorUnit;
157
+ /** Fields added to the `/common/basic_info` reply, each replacing a default
158
+ * of the same name. Defaults to none. */
159
+ basicInfo?: Record<string, string>;
160
+ /** Fields added to the `/aircon/get_model_info` reply, each replacing a
161
+ * default of the same name. Defaults to none. */
162
+ modelInfo?: Record<string, string>;
163
+ /** Fields added to the `/aircon/get_control_info` reply, each replacing a
164
+ * default of the same name. Defaults to none. */
165
+ controlInfo?: Record<string, string>;
166
+ /** Fields added to the `/aircon/get_sensor_info` reply, each replacing a
167
+ * default of the same name. Defaults to none. */
168
+ sensorInfo?: Record<string, string>;
169
+ /** The KEY on the adapter's label. With one, the adapter requires
170
+ * registration: it reports `en_secure=1`, and answers a terminal uuid with
171
+ * HTTP 403 until `/common/register_terminal` is sent this key. Defaults to
172
+ * none: every terminal is answered. */
173
+ key?: string;
174
+ /** The child lock code. With one, the adapter reports `lpw_flag=1`, and
175
+ * answers `ret=LPW NG` to a request whose `lpw` is not this code. Defaults
176
+ * to none: no request needs an `lpw`. */
177
+ localPassword?: string;
178
+ }
179
+
180
+ /** A classic adapter and the unit behind it. Each field map is what its
181
+ * endpoint answers, in the adapter's own names and encodings, without the
182
+ * `ret`. A write through the adapter changes them, and so do `setState` and
183
+ * `setSensors`. Change a field directly for what those two do not cover. */
184
+ export interface ClassicDaikinSimulator extends DaikinSimulator {
185
+ /** What `/common/basic_info` answers. `name` and `grp_name` are held
186
+ * percent-encoded, as the reply carries them. */
187
+ readonly basicInfo: Record<string, string>;
188
+ /** What `/aircon/get_model_info` answers. */
189
+ readonly modelInfo: Record<string, string>;
190
+ /** What `/aircon/get_control_info` answers. */
191
+ readonly controlInfo: Record<string, string>;
192
+ /** What `/aircon/get_sensor_info` answers. */
193
+ readonly sensorInfo: Record<string, string>;
194
+ /** What `/common/get_remote_method` answers. */
195
+ readonly remoteMethod: Record<string, string>;
196
+ /** The terminal uuids `/common/register_terminal` has registered. Only an
197
+ * adapter started with a `key` checks them. */
198
+ readonly registeredTerminalUuids: Set<string>;
199
+ }
200
+
201
+ /** Starts a classic adapter on a free port, as a heat pump that is off, in
202
+ * cool at 24 °C. It answers the endpoints `ClassicDaikinClient` has a typed
203
+ * method for, over HTTP, and answers any other request with HTTP 501:
204
+ * UNSUPPORTED.md lists what it leaves out and where its answer is not known
205
+ * to be an adapter's. Call `stop` when done with it. */
206
+ export async function startClassicSimulator(
207
+ options: StartClassicSimulatorOptions = {},
208
+ ): Promise<ClassicDaikinSimulator> {
209
+ const basicInfo: Record<string, string> = {
210
+ ...DEFAULT_BASIC_INFO,
211
+ lpw_flag: flag(options.localPassword !== undefined),
212
+ ...(options.key === undefined ? {} : { en_secure: "1" }),
213
+ ...options.basicInfo,
214
+ };
215
+ const modelInfo = {
216
+ ...DEFAULT_MODEL_INFO,
217
+ ...encodeUnit(options.unit ?? {}),
218
+ ...options.modelInfo,
219
+ };
220
+ const controlInfo = { ...DEFAULT_CONTROL_INFO, ...options.controlInfo };
221
+ const sensorInfo = { ...DEFAULT_SENSOR_INFO, ...options.sensorInfo };
222
+ const remoteMethod = { ...DEFAULT_REMOTE_METHOD };
223
+ const registeredTerminalUuids = new Set<string>();
224
+
225
+ const setState = (state: Partial<DaikinState>) =>
226
+ applyState(controlInfo, state);
227
+ const setSensors = (sensors: Partial<DaikinSensors>) =>
228
+ Object.assign(sensorInfo, encodeSensors(sensors));
229
+ // Before the server starts, so a state that throws leaves nothing
230
+ // listening.
231
+ Object.assign(basicInfo, encodeDeviceInfo(options.deviceInfo ?? {}));
232
+ setState(options.state ?? {});
233
+ setSensors(options.sensors ?? {});
234
+
235
+ const reads: Record<string, Record<string, string>> = {
236
+ "/common/basic_info": basicInfo,
237
+ "/common/get_remote_method": remoteMethod,
238
+ "/aircon/get_model_info": modelInfo,
239
+ "/aircon/get_control_info": controlInfo,
240
+ "/aircon/get_sensor_info": sensorInfo,
241
+ };
242
+
243
+ // Each write returns false for a request that lacks a parameter.
244
+ const writes: Record<
245
+ string,
246
+ (query: Record<string, string>, terminalUuid: string | undefined) => boolean
247
+ > = {
248
+ "/aircon/set_control_info": (query) => {
249
+ const required =
250
+ "shum" in controlInfo
251
+ ? [...REQUIRED_CONTROL_PARAMETERS, "shum"]
252
+ : REQUIRED_CONTROL_PARAMETERS;
253
+ if (!hasEvery(query, required)) {
254
+ return false;
255
+ }
256
+ for (const [name, value] of Object.entries(query)) {
257
+ if (CONTROL_PARAMETER_PATTERN.test(name) && name in controlInfo) {
258
+ controlInfo[name] = value;
259
+ }
260
+ }
261
+ rememberMode(controlInfo);
262
+ return true;
263
+ },
264
+ "/aircon/set_special_mode": (query) => {
265
+ const codes = new Set(controlInfo.adv?.split("/").filter(Boolean));
266
+ const kindCode = ADV_CODES_BY_SPECIAL_MODE_KIND[query.spmode_kind ?? ""];
267
+ if (query.en_streamer !== undefined) {
268
+ codes.delete(CLASSIC_SPECIAL_MODE_ADV_CODES.streamer);
269
+ if (query.en_streamer === "1") {
270
+ codes.add(CLASSIC_SPECIAL_MODE_ADV_CODES.streamer);
271
+ }
272
+ } else if (query.set_spmode !== undefined && kindCode !== undefined) {
273
+ for (const code of Object.values(ADV_CODES_BY_SPECIAL_MODE_KIND)) {
274
+ codes.delete(code);
275
+ }
276
+ if (query.set_spmode === "1") {
277
+ codes.add(kindCode);
278
+ }
279
+ } else {
280
+ return false;
281
+ }
282
+ controlInfo.adv = [...codes]
283
+ .sort((first, second) => Number(first) - Number(second))
284
+ .join("/");
285
+ return true;
286
+ },
287
+ "/aircon/set_demand_control": (query) =>
288
+ hasEvery(query, ["demand_type", "en_demand", "mode", "max_pow"]),
289
+ "/aircon/set_price": (query) => hasEvery(query, ["price_int", "price_dec"]),
290
+ "/aircon/set_target": (query) => hasEvery(query, ["target"]),
291
+ "/common/set_led": (query) => copy(query, basicInfo, ["led"]),
292
+ "/common/set_holiday": (query) => copy(query, basicInfo, ["en_hol"]),
293
+ "/common/set_icon": (query) => copy(query, basicInfo, ["icon"]),
294
+ "/common/set_name": (query) => {
295
+ if (query.name === undefined) {
296
+ return false;
297
+ }
298
+ basicInfo.name = encodeURIComponent(query.name);
299
+ return true;
300
+ },
301
+ "/common/set_group": (query) => {
302
+ if (query.grp_name === undefined || query.en_grp === undefined) {
303
+ return false;
304
+ }
305
+ basicInfo.grp_name = encodeURIComponent(query.grp_name);
306
+ basicInfo.en_grp = query.en_grp;
307
+ return true;
308
+ },
309
+ "/common/set_remote_method": (query) => {
310
+ if (!copy(query, basicInfo, ["method"])) {
311
+ return false;
312
+ }
313
+ for (const name of Object.keys(remoteMethod)) {
314
+ const value = query[name];
315
+ if (value !== undefined) {
316
+ remoteMethod[name] = value;
317
+ }
318
+ }
319
+ return true;
320
+ },
321
+ "/common/reboot": () => true,
322
+ "/common/unregister_terminal": (query, terminalUuid) => {
323
+ const others = [...registeredTerminalUuids].filter(
324
+ (uuid) => uuid !== terminalUuid,
325
+ );
326
+ if (
327
+ query.type === CLASSIC_TERMINAL_SCOPE_CODES.self &&
328
+ terminalUuid !== undefined
329
+ ) {
330
+ registeredTerminalUuids.delete(terminalUuid);
331
+ } else if (query.type === CLASSIC_TERMINAL_SCOPE_CODES.others) {
332
+ for (const uuid of others) {
333
+ registeredTerminalUuids.delete(uuid);
334
+ }
335
+ } else if (query.type === CLASSIC_TERMINAL_SCOPE_CODES.all) {
336
+ registeredTerminalUuids.clear();
337
+ } else {
338
+ return false;
339
+ }
340
+ return true;
341
+ },
342
+ };
343
+
344
+ const answer = (request: DaikinSimulatorRequest): SimulatorReply => {
345
+ const fields = reads[request.path];
346
+ const write = writes[request.path];
347
+ const registers = request.path === REGISTER_TERMINAL_PATH;
348
+ if (
349
+ request.method !== "GET" ||
350
+ (fields === undefined && write === undefined && !registers)
351
+ ) {
352
+ return unanswered(request);
353
+ }
354
+
355
+ const terminalUuid = terminalUuidOf(request);
356
+ if (registers) {
357
+ if (options.key === undefined) {
358
+ return unanswered(request);
359
+ }
360
+ if (request.query.key !== options.key || terminalUuid === undefined) {
361
+ return FORBIDDEN;
362
+ }
363
+ registeredTerminalUuids.add(terminalUuid);
364
+ return reply("OK");
365
+ }
366
+ if (
367
+ options.key !== undefined &&
368
+ (terminalUuid === undefined || !registeredTerminalUuids.has(terminalUuid))
369
+ ) {
370
+ return FORBIDDEN;
371
+ }
372
+ if (
373
+ options.localPassword !== undefined &&
374
+ request.query.lpw !== options.localPassword
375
+ ) {
376
+ return reply("LPW NG");
377
+ }
378
+
379
+ if (fields !== undefined) {
380
+ return reply("OK", fields);
381
+ }
382
+ return reply(write?.(request.query, terminalUuid) ? "OK" : "PARAM NG");
383
+ };
384
+
385
+ // The discovery reply carries the fields of `basic_info`.
386
+ const server = await startSimulator(options, answer, () => basicInfo);
387
+
388
+ return {
389
+ ...server,
390
+ basicInfo,
391
+ modelInfo,
392
+ controlInfo,
393
+ sensorInfo,
394
+ remoteMethod,
395
+ registeredTerminalUuids,
396
+ setState,
397
+ setSensors,
398
+ };
399
+ }
400
+
401
+ // The `get_model_info` fields that say what `unit` has. A field `unit` leaves
402
+ // out is left to the defaults.
403
+ function encodeUnit(unit: ClassicSimulatorUnit): Record<string, string> {
404
+ const fields: Record<string, string> = {};
405
+ if (unit.coolingOnly !== undefined) {
406
+ fields.type = unit.coolingOnly ? CLASSIC_COOLING_ONLY_TYPE : HEAT_PUMP_TYPE;
407
+ }
408
+ if (unit.fan !== undefined) {
409
+ fields.en_frate = flag(unit.fan);
410
+ }
411
+ if (unit.fanLevels !== undefined) {
412
+ fields.frate_steps = String(unit.fanLevels);
413
+ }
414
+ if (unit.swing !== undefined) {
415
+ fields.en_fdir = flag(unit.swing !== "none");
416
+ fields.s_fdir = SWING_AXES_CODES[unit.swing];
417
+ }
418
+ if (unit.specialModes !== undefined) {
419
+ fields.en_spmode = String(
420
+ unit.specialModes.reduce(
421
+ (bits, mode) => bits | CLASSIC_SPECIAL_MODE_BITS[mode],
422
+ 0,
423
+ ),
424
+ );
425
+ }
426
+ if (unit.coolTemperaturesInC !== undefined) {
427
+ fields.cmlmt_l = unit.coolTemperaturesInC.min.toFixed(1);
428
+ fields.cmlmt_h = unit.coolTemperaturesInC.max.toFixed(1);
429
+ }
430
+ if (unit.heatTemperaturesInC !== undefined) {
431
+ fields.hmlmt_l = unit.heatTemperaturesInC.min.toFixed(1);
432
+ fields.hmlmt_h = unit.heatTemperaturesInC.max.toFixed(1);
433
+ }
434
+ return fields;
435
+ }
436
+
437
+ // The `basic_info` fields: the name percent-encoded, the MAC address without
438
+ // its colons, and the version with underscores for its dots.
439
+ function encodeDeviceInfo(info: DaikinDeviceInfo): Record<string, string> {
440
+ return {
441
+ ...(info.name === undefined ? {} : { name: encodeURIComponent(info.name) }),
442
+ ...(info.macAddress === undefined
443
+ ? {}
444
+ : { mac: info.macAddress.replaceAll(":", "") }),
445
+ ...(info.firmwareVersion === undefined
446
+ ? {}
447
+ : { ver: info.firmwareVersion.replaceAll(".", "_") }),
448
+ };
449
+ }
450
+
451
+ function encodeSensors(
452
+ sensors: Partial<DaikinSensors>,
453
+ ): Record<string, string> {
454
+ const fields: Record<string, string> = {};
455
+ const {
456
+ indoorTemperatureInC,
457
+ outdoorTemperatureInC,
458
+ indoorHumidityInPercent,
459
+ } = sensors;
460
+ if (indoorTemperatureInC !== undefined) {
461
+ fields.htemp = indoorTemperatureInC?.toFixed(1) ?? NO_READING;
462
+ }
463
+ if (outdoorTemperatureInC !== undefined) {
464
+ fields.otemp = outdoorTemperatureInC?.toFixed(1) ?? NO_READING;
465
+ }
466
+ if (indoorHumidityInPercent !== undefined) {
467
+ fields.hhum = indoorHumidityInPercent?.toFixed(0) ?? NO_READING;
468
+ }
469
+ return fields;
470
+ }
471
+
472
+ // Changes `controlInfo` as the unit's remote control would: see
473
+ // `DaikinSimulator.setState`.
474
+ function applyState(
475
+ controlInfo: Record<string, string>,
476
+ state: Partial<DaikinState>,
477
+ ): void {
478
+ const hold = (field: string, value: string) => {
479
+ if (!(field in controlInfo)) {
480
+ throw new Error(`The simulated unit reports no ${field} to set`);
481
+ }
482
+ controlInfo[field] = value;
483
+ };
484
+ const hasLouvers = "f_dir_ud" in controlInfo && "f_dir_lr" in controlInfo;
485
+
486
+ if (state.power != null) {
487
+ hold("pow", flag(state.power));
488
+ }
489
+ if (
490
+ state.mode != null &&
491
+ CLASSIC_OPERATING_MODE_CODES[state.mode] !== controlInfo.mode
492
+ ) {
493
+ controlInfo.mode = CLASSIC_OPERATING_MODE_CODES[state.mode];
494
+ recallMode(controlInfo);
495
+ }
496
+ if (state.targetTemperatureInC != null) {
497
+ hold("stemp", state.targetTemperatureInC.toFixed(1));
498
+ }
499
+ if (state.targetHumidity != null) {
500
+ hold(
501
+ "shum",
502
+ typeof state.targetHumidity === "string"
503
+ ? CLASSIC_HUMIDITY_SETTING_CODES[state.targetHumidity]
504
+ : String(state.targetHumidity.percent),
505
+ );
506
+ }
507
+ if (state.fanSpeed != null) {
508
+ hold("f_rate", CLASSIC_FAN_SPEED_CODES[state.fanSpeed]);
509
+ }
510
+ if (state.swingMode != null) {
511
+ const swings = {
512
+ f_dir_ud: state.swingMode === "vertical" || state.swingMode === "both",
513
+ f_dir_lr: state.swingMode === "horizontal" || state.swingMode === "both",
514
+ };
515
+ if (hasLouvers) {
516
+ // An axis that stops swinging stops, and one at a fixed position
517
+ // keeps it.
518
+ for (const [field, swing] of Object.entries(swings)) {
519
+ if (swing) {
520
+ controlInfo[field] = CLASSIC_LOUVER_SWING;
521
+ } else if (controlInfo[field] === CLASSIC_LOUVER_SWING) {
522
+ controlInfo[field] = CLASSIC_LOUVER_STOP;
523
+ }
524
+ }
525
+ } else {
526
+ hold("f_dir", CLASSIC_SWING_MODE_CODES[state.swingMode]);
527
+ }
528
+ }
529
+ if (state.verticalPosition != null) {
530
+ hold("f_dir_ud", CLASSIC_VERTICAL_POSITION_CODES[state.verticalPosition]);
531
+ }
532
+ if (state.horizontalPosition != null) {
533
+ hold(
534
+ "f_dir_lr",
535
+ CLASSIC_HORIZONTAL_POSITION_CODES[state.horizontalPosition],
536
+ );
537
+ }
538
+ // `f_dir` sets bit 1 while the vertical louver swings and bit 2 while the
539
+ // horizontal one does.
540
+ if (hasLouvers && "f_dir" in controlInfo) {
541
+ controlInfo.f_dir = String(
542
+ (controlInfo.f_dir_ud === CLASSIC_LOUVER_SWING ? 1 : 0) |
543
+ (controlInfo.f_dir_lr === CLASSIC_LOUVER_SWING ? 2 : 0),
544
+ );
545
+ }
546
+ if (state.specialModes != null) {
547
+ hold(
548
+ "adv",
549
+ state.specialModes
550
+ .map((mode) => Number(CLASSIC_SPECIAL_MODE_ADV_CODES[mode]))
551
+ .sort((first, second) => first - second)
552
+ .join("/"),
553
+ );
554
+ }
555
+ rememberMode(controlInfo);
556
+ }
557
+
558
+ // Takes up what the unit remembers for the mode it is now in. A mode without
559
+ // a `dt` or a `dh` has no setpoint or humidity setting.
560
+ function recallMode(controlInfo: Record<string, string>): void {
561
+ const mode = controlInfo.mode ?? "";
562
+ for (const [prefix, field] of Object.entries(MODE_MEMORY_FIELDS)) {
563
+ if (field in controlInfo) {
564
+ controlInfo[field] =
565
+ controlInfo[`${prefix}${mode}`] ??
566
+ (prefix === "dt" || prefix === "dh"
567
+ ? CLASSIC_NO_SETPOINT
568
+ : (controlInfo[field] ?? ""));
569
+ }
570
+ }
571
+ const [vertical, horizontal] = controlInfo[`ndfd${mode}`] ?? "";
572
+ if ("f_dir_ud" in controlInfo && vertical !== undefined) {
573
+ controlInfo.f_dir_ud = vertical;
574
+ }
575
+ if ("f_dir_lr" in controlInfo && horizontal !== undefined) {
576
+ controlInfo.f_dir_lr = horizontal;
577
+ }
578
+ }
579
+
580
+ const FORBIDDEN: SimulatorReply = { statusCode: 403, body: "" };
581
+
582
+ // A classic adapter answers HTTP 200 whatever the `ret`.
583
+ function reply(ret: string, fields?: Record<string, string>): SimulatorReply {
584
+ return { statusCode: 200, body: formatKeyValue(ret, fields) };
585
+ }
586
+
587
+ function terminalUuidOf(request: DaikinSimulatorRequest): string | undefined {
588
+ const header = request.headers["x-daikin-uuid"];
589
+ return typeof header === "string" ? header : undefined;
590
+ }
591
+
592
+ function flag(on: boolean): string {
593
+ return on ? "1" : "0";
594
+ }
595
+
596
+ function hasEvery(query: Record<string, string>, names: string[]): boolean {
597
+ return names.every((name) => name in query);
598
+ }
599
+
600
+ function copy(
601
+ query: Record<string, string>,
602
+ fields: Record<string, string>,
603
+ names: string[],
604
+ ): boolean {
605
+ if (!hasEvery(query, names)) {
606
+ return false;
607
+ }
608
+ for (const name of names) {
609
+ fields[name] = query[name] ?? "";
610
+ }
611
+ return true;
612
+ }
613
+
614
+ // An adapter remembers the setpoint, humidity, fan speed and direction of
615
+ // each mode, and answers them as `dt3`, `dh3`, `dfr3` and `dfd3` for mode 3.
616
+ // It takes `dt`, `dh` and `dfd` as parameters and no `dfr`, so it keeps the
617
+ // memory of the mode the unit is in equal to what the unit holds. `ndfd3`
618
+ // holds the `f_dir_ud` value of mode 3, then its `f_dir_lr` value.
619
+ function rememberMode(controlInfo: Record<string, string>): void {
620
+ const mode = controlInfo.mode ?? "";
621
+ for (const [prefix, field] of Object.entries(MODE_MEMORY_FIELDS)) {
622
+ const value = controlInfo[field];
623
+ if (`${prefix}${mode}` in controlInfo && value !== undefined) {
624
+ controlInfo[`${prefix}${mode}`] = value;
625
+ }
626
+ }
627
+ const { f_dir_ud: vertical, f_dir_lr: horizontal } = controlInfo;
628
+ if (
629
+ `ndfd${mode}` in controlInfo &&
630
+ vertical !== undefined &&
631
+ horizontal !== undefined
632
+ ) {
633
+ controlInfo[`ndfd${mode}`] = `${vertical}${horizontal}`;
634
+ }
635
+ }