daikin-local-control 0.4.0 → 0.5.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 (96) hide show
  1. package/DESIGN.md +47 -4
  2. package/README.md +154 -46
  3. package/UNSUPPORTED.md +27 -2
  4. package/dist/clients/airbase.d.ts +64 -0
  5. package/dist/clients/airbase.d.ts.map +1 -0
  6. package/dist/clients/airbase.js +175 -0
  7. package/dist/clients/airbase.js.map +1 -0
  8. package/dist/clients/client.d.ts +3 -2
  9. package/dist/clients/client.d.ts.map +1 -1
  10. package/dist/codecs/airbase/capabilities.d.ts +23 -0
  11. package/dist/codecs/airbase/capabilities.d.ts.map +1 -0
  12. package/dist/codecs/airbase/capabilities.js +108 -0
  13. package/dist/codecs/airbase/capabilities.js.map +1 -0
  14. package/dist/codecs/airbase/state.d.ts +29 -0
  15. package/dist/codecs/airbase/state.d.ts.map +1 -0
  16. package/dist/codecs/airbase/state.js +166 -0
  17. package/dist/codecs/airbase/state.js.map +1 -0
  18. package/dist/codecs/airbase/tables.d.ts +7 -0
  19. package/dist/codecs/airbase/tables.d.ts.map +1 -0
  20. package/dist/codecs/airbase/tables.js +27 -0
  21. package/dist/codecs/airbase/tables.js.map +1 -0
  22. package/dist/codecs/airbase/zone.d.ts +12 -0
  23. package/dist/codecs/airbase/zone.d.ts.map +1 -0
  24. package/dist/codecs/airbase/zone.js +50 -0
  25. package/dist/codecs/airbase/zone.js.map +1 -0
  26. package/dist/codecs/classic/tables.d.ts +2 -0
  27. package/dist/codecs/classic/tables.d.ts.map +1 -1
  28. package/dist/codecs/classic/tables.js +2 -0
  29. package/dist/codecs/classic/tables.js.map +1 -1
  30. package/dist/codecs/device.d.ts +7 -1
  31. package/dist/codecs/device.d.ts.map +1 -1
  32. package/dist/codecs/device.js +14 -2
  33. package/dist/codecs/device.js.map +1 -1
  34. package/dist/codecs/dsiot/tables.d.ts +3 -0
  35. package/dist/codecs/dsiot/tables.d.ts.map +1 -1
  36. package/dist/codecs/dsiot/tables.js +3 -0
  37. package/dist/codecs/dsiot/tables.js.map +1 -1
  38. package/dist/discovery.d.ts +20 -10
  39. package/dist/discovery.d.ts.map +1 -1
  40. package/dist/discovery.js +96 -30
  41. package/dist/discovery.js.map +1 -1
  42. package/dist/factory.d.ts +8 -5
  43. package/dist/factory.d.ts.map +1 -1
  44. package/dist/factory.js +13 -3
  45. package/dist/factory.js.map +1 -1
  46. package/dist/index.d.ts +6 -0
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +6 -0
  49. package/dist/index.js.map +1 -1
  50. package/dist/models/device.d.ts +3 -2
  51. package/dist/models/device.d.ts.map +1 -1
  52. package/dist/models/device.js +1 -1
  53. package/dist/models/device.js.map +1 -1
  54. package/dist/models/zone.d.ts +9 -0
  55. package/dist/models/zone.d.ts.map +1 -0
  56. package/dist/models/zone.js +2 -0
  57. package/dist/models/zone.js.map +1 -0
  58. package/dist/simulators/airbase.d.ts +82 -0
  59. package/dist/simulators/airbase.d.ts.map +1 -0
  60. package/dist/simulators/airbase.js +352 -0
  61. package/dist/simulators/airbase.js.map +1 -0
  62. package/dist/simulators/classic.d.ts.map +1 -1
  63. package/dist/simulators/classic.js +2 -2
  64. package/dist/simulators/classic.js.map +1 -1
  65. package/dist/simulators/index.d.ts +1 -0
  66. package/dist/simulators/index.d.ts.map +1 -1
  67. package/dist/simulators/index.js +1 -0
  68. package/dist/simulators/index.js.map +1 -1
  69. package/dist/simulators/simulator.d.ts +11 -10
  70. package/dist/simulators/simulator.d.ts.map +1 -1
  71. package/dist/simulators/simulator.js +2 -2
  72. package/dist/simulators/simulator.js.map +1 -1
  73. package/dist/transport.d.ts +3 -2
  74. package/dist/transport.d.ts.map +1 -1
  75. package/dist/transport.js +4 -1
  76. package/dist/transport.js.map +1 -1
  77. package/package.json +7 -3
  78. package/src/clients/airbase.ts +274 -0
  79. package/src/clients/client.ts +3 -2
  80. package/src/codecs/airbase/capabilities.ts +150 -0
  81. package/src/codecs/airbase/state.ts +223 -0
  82. package/src/codecs/airbase/tables.ts +32 -0
  83. package/src/codecs/airbase/zone.ts +62 -0
  84. package/src/codecs/classic/tables.ts +3 -0
  85. package/src/codecs/device.ts +18 -2
  86. package/src/codecs/dsiot/tables.ts +4 -0
  87. package/src/discovery.ts +101 -32
  88. package/src/factory.ts +21 -5
  89. package/src/index.ts +6 -0
  90. package/src/models/device.ts +3 -2
  91. package/src/models/zone.ts +8 -0
  92. package/src/simulators/airbase.ts +495 -0
  93. package/src/simulators/classic.ts +2 -1
  94. package/src/simulators/index.ts +1 -0
  95. package/src/simulators/simulator.ts +13 -12
  96. package/src/transport.ts +7 -3
@@ -0,0 +1,495 @@
1
+ import {
2
+ AIRBASE_FAN_SPEED_CODES,
3
+ AIRBASE_NO_SETPOINT,
4
+ AIRBASE_OPERATING_MODE_CODES,
5
+ AIRBASE_SWING_MODE_CODES,
6
+ } from "../codecs/airbase/tables.js";
7
+ import { CLASSIC_ADAPTER_TYPE } from "../codecs/classic/tables.js";
8
+ import type { DaikinDeviceInfo } from "../models/device.js";
9
+ import type { DaikinSensors, DaikinState } from "../models/state.js";
10
+ import {
11
+ type DaikinSimulator,
12
+ type DaikinSimulatorRequest,
13
+ type DaikinSimulatorTemperatures,
14
+ formatKeyValue,
15
+ type SimulatorReply,
16
+ type StartSimulatorOptions,
17
+ startSimulator,
18
+ unanswered,
19
+ } from "./simulator.js";
20
+
21
+ // An Airbase adapter (BRP15B61) as an HTTP server. It reads the adapter's
22
+ // codes from the codec's tables and shares none of its functions: see
23
+ // DESIGN.md#simulators.
24
+
25
+ // Every endpoint is below this path, and any other path is answered HTTP 404.
26
+ const ROOT = "/skyfi";
27
+
28
+ // An Airbase adapter replies to the discovery probe as a classic adapter of
29
+ // an air conditioner does.
30
+ const DEFAULT_BASIC_INFO: Record<string, string> = {
31
+ type: CLASSIC_ADAPTER_TYPE,
32
+ reg: "au",
33
+ ver: "1_1_8",
34
+ name: "Simulator",
35
+ mac: "02DA1C000002",
36
+ lpw_flag: "0",
37
+ };
38
+
39
+ const DEFAULT_MODEL_INFO: Record<string, string> = {
40
+ model: "NOTSUPPORT",
41
+ en_auto: "1",
42
+ en_dry: "1",
43
+ en_frate: "1",
44
+ en_frate_auto: "1",
45
+ frate_steps: "3",
46
+ en_fdir: "1",
47
+ };
48
+
49
+ const { cool: COOL, heat: HEAT } = AIRBASE_OPERATING_MODE_CODES;
50
+
51
+ const DEFAULT_CONTROL_INFO: Record<string, string> = {
52
+ pow: "0",
53
+ mode: COOL,
54
+ bk_auto: COOL,
55
+ stemp: "24.0",
56
+ f_rate: AIRBASE_FAN_SPEED_CODES.auto,
57
+ f_auto: "0",
58
+ f_airside: "0",
59
+ f_dir: AIRBASE_SWING_MODE_CODES.off,
60
+ dt1: "25.0",
61
+ dt2: "24.0",
62
+ dfr1: AIRBASE_FAN_SPEED_CODES.auto,
63
+ dfr2: AIRBASE_FAN_SPEED_CODES.auto,
64
+ dfd1: AIRBASE_SWING_MODE_CODES.off,
65
+ dfd2: AIRBASE_SWING_MODE_CODES.off,
66
+ auto1: "0",
67
+ auto2: "0",
68
+ airside1: "0",
69
+ airside2: "0",
70
+ };
71
+
72
+ const DEFAULT_SENSOR_INFO: Record<string, string> = {
73
+ htemp: "26.0",
74
+ otemp: "30.0",
75
+ };
76
+
77
+ // A `set_control_info` without one of these is answered `PARAM NG`.
78
+ const REQUIRED_CONTROL_PARAMETERS = [
79
+ "pow",
80
+ "mode",
81
+ "stemp",
82
+ "shum",
83
+ "f_rate",
84
+ "f_auto",
85
+ "f_airside",
86
+ "f_dir",
87
+ ];
88
+ // The parameters `set_control_info` stores: the control fields, and what the
89
+ // unit remembers for cool and for heat, such as `dt2` and `auto1`.
90
+ const CONTROL_PARAMETER_PATTERN =
91
+ /^(pow|mode|bk_auto|stemp|shum|f_rate|f_auto|f_airside|f_dir|(dt|dfr|dfd|auto|airside)\w)$/;
92
+ // The control field each memory prefix remembers: `dfr2` holds the `f_rate`
93
+ // of cool.
94
+ const MEMORY_FIELDS = {
95
+ dt: "stemp",
96
+ dfr: "f_rate",
97
+ dfd: "f_dir",
98
+ auto: "f_auto",
99
+ airside: "f_airside",
100
+ } as const;
101
+
102
+ // The lists of `get_zone_setting` are joined by this, percent-encoded.
103
+ const ZONE_SEPARATOR = ";";
104
+ const ZONE_PARAMETERS = ["zone_name", "zone_onoff"];
105
+ // A unit that sets a temperature for each zone holds these as well.
106
+ const ZONE_TEMPERATURE_PARAMETERS = ["lztemp_c", "lztemp_h"];
107
+
108
+ // A sensor field the unit has no reading for.
109
+ const NO_READING = "-";
110
+
111
+ /** What a simulated Airbase unit has. Anything else about it is set in the
112
+ * adapter's own terms, in `modelInfo`. */
113
+ export interface AirbaseSimulatorUnit {
114
+ /** The names of the unit's zones, each open at the start. Defaults to none:
115
+ * a unit without zones. A unit with zones takes fan auto, and neither the
116
+ * `auto` fan speed nor a direction. */
117
+ zones?: readonly string[];
118
+ /** Whether the unit offers auto. Defaults to true. */
119
+ auto?: boolean;
120
+ /** Whether the unit offers dehumidify. Defaults to true. */
121
+ dehumidify?: boolean;
122
+ /** Whether the fan speed can be set. Defaults to true. */
123
+ fan?: boolean;
124
+ /** How many fan levels the unit takes: `level1` and `level5` for 2, and
125
+ * `level3` as well for 3. Defaults to 3. */
126
+ fanLevels?: 2 | 3;
127
+ /** Whether a unit with zones takes airside. Defaults to false. */
128
+ airside?: boolean;
129
+ /** Whether a unit without zones takes a direction. Defaults to true. */
130
+ swing?: boolean;
131
+ /** The setpoints cool takes, in whole degrees. Defaults to none
132
+ * reported. */
133
+ coolTemperaturesInC?: DaikinSimulatorTemperatures;
134
+ /** The setpoints heat takes, in whole degrees. Defaults to none
135
+ * reported. */
136
+ heatTemperaturesInC?: DaikinSimulatorTemperatures;
137
+ }
138
+
139
+ export interface StartAirbaseSimulatorOptions extends StartSimulatorOptions {
140
+ /** What the unit has. Defaults to a unit without zones that offers every
141
+ * mode, three fan levels and the `auto` fan speed, and takes a direction.
142
+ * A field of `modelInfo` replaces what this sets. */
143
+ unit?: AirbaseSimulatorUnit;
144
+ /** Fields added to the `/skyfi/common/basic_info` reply, each replacing a
145
+ * default of the same name. Defaults to none. */
146
+ basicInfo?: Record<string, string>;
147
+ /** Fields added to the `/skyfi/aircon/get_model_info` reply, each replacing
148
+ * a default of the same name. Defaults to none. */
149
+ modelInfo?: Record<string, string>;
150
+ /** Fields added to the `/skyfi/aircon/get_control_info` reply, each
151
+ * replacing a default of the same name. Defaults to none. */
152
+ controlInfo?: Record<string, string>;
153
+ /** Fields added to the `/skyfi/aircon/get_sensor_info` reply, each replacing
154
+ * a default of the same name. Defaults to none. */
155
+ sensorInfo?: Record<string, string>;
156
+ /** Fields added to the `/skyfi/aircon/get_zone_setting` reply, each
157
+ * replacing what `unit.zones` sets. Defaults to none. */
158
+ zoneSetting?: Record<string, string>;
159
+ }
160
+
161
+ /** An Airbase adapter and the unit behind it. Each field map is what its
162
+ * endpoint answers, in the adapter's own names and encodings, without the
163
+ * `ret`. A write through the adapter changes them, and so do `setState`,
164
+ * `setSensors` and `setZone`. Change a field directly for what those do not
165
+ * cover, such as `controlInfo.f_auto` for fan auto. */
166
+ export interface AirbaseDaikinSimulator extends DaikinSimulator {
167
+ /** What `/skyfi/common/basic_info` answers. `name` is held percent-encoded,
168
+ * as the reply carries it. */
169
+ readonly basicInfo: Record<string, string>;
170
+ /** What `/skyfi/aircon/get_model_info` answers. */
171
+ readonly modelInfo: Record<string, string>;
172
+ /** What `/skyfi/aircon/get_control_info` answers. */
173
+ readonly controlInfo: Record<string, string>;
174
+ /** What `/skyfi/aircon/get_sensor_info` answers. */
175
+ readonly sensorInfo: Record<string, string>;
176
+ /** What `/skyfi/aircon/get_zone_setting` answers, each list held
177
+ * percent-encoded, as the reply carries it. Empty on a unit without
178
+ * zones. */
179
+ readonly zoneSetting: Record<string, string>;
180
+ /** Opens or closes the zone at `index`, counted from 0, as the unit's wall
181
+ * controller would. It throws an `Error` for an index the unit has no zone
182
+ * at. */
183
+ setZone(index: number, enabled: boolean): void;
184
+ }
185
+
186
+ /** Starts an Airbase adapter on a free port, as a unit without zones that is
187
+ * off, in cool at 24 °C. It answers the endpoints `AirbaseDaikinClient` has a
188
+ * typed method for, below `/skyfi`, and takes no terminal uuid and no local
189
+ * password. It answers HTTP 404 to a path outside `/skyfi`, as an Airbase
190
+ * adapter does, and HTTP 501 to any other request it has no answer for:
191
+ * UNSUPPORTED.md lists what it leaves out and where its answer is not known
192
+ * to be an adapter's. Call `stop` when done with it. */
193
+ export async function startAirbaseSimulator(
194
+ options: StartAirbaseSimulatorOptions = {},
195
+ ): Promise<AirbaseDaikinSimulator> {
196
+ const unit = options.unit ?? {};
197
+ const basicInfo = { ...DEFAULT_BASIC_INFO, ...options.basicInfo };
198
+ const modelInfo = {
199
+ ...DEFAULT_MODEL_INFO,
200
+ ...encodeUnit(unit),
201
+ ...options.modelInfo,
202
+ };
203
+ const controlInfo = { ...DEFAULT_CONTROL_INFO, ...options.controlInfo };
204
+ const sensorInfo = { ...DEFAULT_SENSOR_INFO, ...options.sensorInfo };
205
+ const zoneSetting = {
206
+ ...encodeZones(unit.zones ?? []),
207
+ ...options.zoneSetting,
208
+ };
209
+
210
+ const setState = (state: Partial<DaikinState>) =>
211
+ applyState(controlInfo, state);
212
+ const setSensors = (sensors: Partial<DaikinSensors>) =>
213
+ Object.assign(sensorInfo, encodeSensors(sensors));
214
+ const setZone = (index: number, enabled: boolean) => {
215
+ const flags = decodeList(zoneSetting.zone_onoff);
216
+ if (!Number.isInteger(index) || index < 0 || index >= flags.length) {
217
+ throw new Error(`The simulated unit has no zone ${index}`);
218
+ }
219
+ flags[index] = flag(enabled);
220
+ zoneSetting.zone_onoff = encodeList(flags);
221
+ };
222
+ // Before the server starts, so a state that throws leaves nothing
223
+ // listening.
224
+ Object.assign(basicInfo, encodeDeviceInfo(options.deviceInfo ?? {}));
225
+ setState(options.state ?? {});
226
+ setSensors(options.sensors ?? {});
227
+
228
+ const hasZones = () => "zone_name" in zoneSetting;
229
+ const reads: Record<string, () => Record<string, string> | undefined> = {
230
+ "/common/basic_info": () => basicInfo,
231
+ "/aircon/get_model_info": () => modelInfo,
232
+ "/aircon/get_control_info": () => controlInfo,
233
+ "/aircon/get_sensor_info": () => sensorInfo,
234
+ // What a unit without zones answers is not known.
235
+ "/aircon/get_zone_setting": () => (hasZones() ? zoneSetting : undefined),
236
+ };
237
+
238
+ // Each write returns false for a request that lacks a parameter, and
239
+ // `undefined` for one the simulator has no answer for.
240
+ const writes: Record<
241
+ string,
242
+ (query: Record<string, string>) => boolean | undefined
243
+ > = {
244
+ "/aircon/set_control_info": (query) => {
245
+ if (!hasEvery(query, REQUIRED_CONTROL_PARAMETERS)) {
246
+ return false;
247
+ }
248
+ for (const [name, value] of Object.entries(query)) {
249
+ if (CONTROL_PARAMETER_PATTERN.test(name) && name in controlInfo) {
250
+ controlInfo[name] = value;
251
+ }
252
+ }
253
+ remember(controlInfo);
254
+ return true;
255
+ },
256
+ "/aircon/set_zone_setting": (query) => {
257
+ if (!hasZones()) {
258
+ return undefined;
259
+ }
260
+ if (!hasEvery(query, ZONE_PARAMETERS)) {
261
+ return false;
262
+ }
263
+ for (const name of [...ZONE_PARAMETERS, ...ZONE_TEMPERATURE_PARAMETERS]) {
264
+ const value = query[name];
265
+ if (value !== undefined && name in zoneSetting) {
266
+ zoneSetting[name] = encodeURIComponent(value);
267
+ }
268
+ }
269
+ return true;
270
+ },
271
+ };
272
+
273
+ const answer = (request: DaikinSimulatorRequest): SimulatorReply => {
274
+ if (!request.path.startsWith(`${ROOT}/`)) {
275
+ return NOT_FOUND;
276
+ }
277
+ if (request.method !== "GET") {
278
+ return unanswered(request);
279
+ }
280
+ const path = request.path.slice(ROOT.length);
281
+ const fields = reads[path]?.();
282
+ if (fields !== undefined) {
283
+ return reply("OK", fields);
284
+ }
285
+ const written = writes[path]?.(request.query);
286
+ return written === undefined
287
+ ? unanswered(request)
288
+ : reply(written ? "OK" : "PARAM NG");
289
+ };
290
+
291
+ // The discovery reply carries the fields of `basic_info`.
292
+ const server = await startSimulator(options, answer, () => basicInfo);
293
+
294
+ return {
295
+ ...server,
296
+ basicInfo,
297
+ modelInfo,
298
+ controlInfo,
299
+ sensorInfo,
300
+ zoneSetting,
301
+ setState,
302
+ setSensors,
303
+ setZone,
304
+ };
305
+ }
306
+
307
+ // The `get_model_info` fields that say what `unit` has. A field `unit` leaves
308
+ // out is left to the defaults.
309
+ function encodeUnit(unit: AirbaseSimulatorUnit): Record<string, string> {
310
+ const fields: Record<string, string> = {};
311
+ if (unit.zones !== undefined && unit.zones.length > 0) {
312
+ fields.en_zone = String(unit.zones.length);
313
+ }
314
+ if (unit.auto !== undefined) {
315
+ fields.en_auto = flag(unit.auto);
316
+ }
317
+ if (unit.dehumidify !== undefined) {
318
+ fields.en_dry = flag(unit.dehumidify);
319
+ }
320
+ if (unit.fan !== undefined) {
321
+ fields.en_frate = flag(unit.fan);
322
+ }
323
+ if (unit.fanLevels !== undefined) {
324
+ fields.frate_steps = String(unit.fanLevels);
325
+ }
326
+ if (unit.airside !== undefined) {
327
+ fields.en_airside = flag(unit.airside);
328
+ }
329
+ if (unit.swing !== undefined) {
330
+ fields.en_fdir = flag(unit.swing);
331
+ }
332
+ if (unit.coolTemperaturesInC !== undefined) {
333
+ fields.cool_l = unit.coolTemperaturesInC.min.toFixed(0);
334
+ fields.cool_h = unit.coolTemperaturesInC.max.toFixed(0);
335
+ }
336
+ if (unit.heatTemperaturesInC !== undefined) {
337
+ fields.heat_l = unit.heatTemperaturesInC.min.toFixed(0);
338
+ fields.heat_h = unit.heatTemperaturesInC.max.toFixed(0);
339
+ }
340
+ return fields;
341
+ }
342
+
343
+ // The `get_zone_setting` fields of a unit with these zones, each open. A unit
344
+ // without zones has none.
345
+ function encodeZones(names: readonly string[]): Record<string, string> {
346
+ return names.length === 0
347
+ ? {}
348
+ : {
349
+ zone_name: encodeList(names),
350
+ zone_onoff: encodeList(names.map(() => flag(true))),
351
+ };
352
+ }
353
+
354
+ function encodeList(values: readonly string[]): string {
355
+ return encodeURIComponent(values.join(ZONE_SEPARATOR));
356
+ }
357
+
358
+ function decodeList(value: string | undefined): string[] {
359
+ return value === undefined
360
+ ? []
361
+ : decodeURIComponent(value).split(ZONE_SEPARATOR);
362
+ }
363
+
364
+ // The `basic_info` fields: the name percent-encoded, the MAC address without
365
+ // its colons, and the version with underscores for its dots.
366
+ function encodeDeviceInfo(info: DaikinDeviceInfo): Record<string, string> {
367
+ return {
368
+ ...(info.name === undefined ? {} : { name: encodeURIComponent(info.name) }),
369
+ ...(info.macAddress === undefined
370
+ ? {}
371
+ : { mac: info.macAddress.replaceAll(":", "") }),
372
+ ...(info.firmwareVersion === undefined
373
+ ? {}
374
+ : { ver: info.firmwareVersion.replaceAll(".", "_") }),
375
+ };
376
+ }
377
+
378
+ function encodeSensors(
379
+ sensors: Partial<DaikinSensors>,
380
+ ): Record<string, string> {
381
+ const fields: Record<string, string> = {};
382
+ const {
383
+ indoorTemperatureInC,
384
+ outdoorTemperatureInC,
385
+ indoorHumidityInPercent,
386
+ } = sensors;
387
+ if (indoorTemperatureInC !== undefined) {
388
+ fields.htemp = indoorTemperatureInC?.toFixed(1) ?? NO_READING;
389
+ }
390
+ if (outdoorTemperatureInC !== undefined) {
391
+ fields.otemp = outdoorTemperatureInC?.toFixed(1) ?? NO_READING;
392
+ }
393
+ if (indoorHumidityInPercent !== undefined) {
394
+ fields.hhum = indoorHumidityInPercent?.toFixed(0) ?? NO_READING;
395
+ }
396
+ return fields;
397
+ }
398
+
399
+ // Changes `controlInfo` as the unit's wall controller would: see
400
+ // `DaikinSimulator.setState`.
401
+ function applyState(
402
+ controlInfo: Record<string, string>,
403
+ state: Partial<DaikinState>,
404
+ ): void {
405
+ for (const field of [
406
+ "targetHumidity",
407
+ "verticalPosition",
408
+ "horizontalPosition",
409
+ ] as const) {
410
+ if (state[field] != null) {
411
+ throw new Error(`The simulated unit has no ${field} to set`);
412
+ }
413
+ }
414
+ if (state.specialModes != null && state.specialModes.length > 0) {
415
+ throw new Error("The simulated unit has no specialModes to set");
416
+ }
417
+
418
+ if (state.power != null) {
419
+ controlInfo.pow = flag(state.power);
420
+ }
421
+ if (
422
+ state.mode != null &&
423
+ AIRBASE_OPERATING_MODE_CODES[state.mode] !== controlInfo.mode
424
+ ) {
425
+ controlInfo.mode = AIRBASE_OPERATING_MODE_CODES[state.mode];
426
+ recall(controlInfo);
427
+ }
428
+ if (state.targetTemperatureInC != null) {
429
+ controlInfo.stemp = state.targetTemperatureInC.toFixed(1);
430
+ }
431
+ if (state.fanSpeed != null) {
432
+ controlInfo.f_rate = AIRBASE_FAN_SPEED_CODES[state.fanSpeed];
433
+ }
434
+ if (state.swingMode != null) {
435
+ controlInfo.f_dir = AIRBASE_SWING_MODE_CODES[state.swingMode];
436
+ }
437
+ remember(controlInfo);
438
+ }
439
+
440
+ // The unit remembers a setpoint, a fan and a direction for cool and for heat
441
+ // alone, and every other mode runs on the one `bk_auto` names. A unit set to
442
+ // cool or to heat names that mode there.
443
+ function memoryOf(controlInfo: Record<string, string>): string {
444
+ if (controlInfo.mode === COOL || controlInfo.mode === HEAT) {
445
+ controlInfo.bk_auto = controlInfo.mode;
446
+ }
447
+ return controlInfo.bk_auto === HEAT ? HEAT : COOL;
448
+ }
449
+
450
+ // Takes up what the unit remembers for the mode it is now in. Dehumidify has
451
+ // no setpoint.
452
+ function recall(controlInfo: Record<string, string>): void {
453
+ const memory = memoryOf(controlInfo);
454
+ for (const [prefix, field] of Object.entries(MEMORY_FIELDS)) {
455
+ const remembered = controlInfo[`${prefix}${memory}`];
456
+ if (remembered !== undefined) {
457
+ controlInfo[field] = remembered;
458
+ }
459
+ }
460
+ if (controlInfo.mode === AIRBASE_OPERATING_MODE_CODES.dehumidify) {
461
+ controlInfo.stemp = AIRBASE_NO_SETPOINT;
462
+ }
463
+ }
464
+
465
+ // Keeps what the unit remembers for the mode it runs on equal to what it
466
+ // holds. A setpoint is remembered in cool and in heat alone.
467
+ function remember(controlInfo: Record<string, string>): void {
468
+ const memory = memoryOf(controlInfo);
469
+ for (const [prefix, field] of Object.entries(MEMORY_FIELDS)) {
470
+ const value = controlInfo[field];
471
+ const isSetpoint = prefix === "dt";
472
+ if (
473
+ `${prefix}${memory}` in controlInfo &&
474
+ value !== undefined &&
475
+ (!isSetpoint || controlInfo.mode === memory)
476
+ ) {
477
+ controlInfo[`${prefix}${memory}`] = value;
478
+ }
479
+ }
480
+ }
481
+
482
+ const NOT_FOUND: SimulatorReply = { statusCode: 404, body: "" };
483
+
484
+ // An Airbase adapter answers HTTP 200 whatever the `ret`.
485
+ function reply(ret: string, fields?: Record<string, string>): SimulatorReply {
486
+ return { statusCode: 200, body: formatKeyValue(ret, fields) };
487
+ }
488
+
489
+ function flag(on: boolean): string {
490
+ return on ? "1" : "0";
491
+ }
492
+
493
+ function hasEvery(query: Record<string, string>, names: string[]): boolean {
494
+ return names.every((name) => name in query);
495
+ }
@@ -1,4 +1,5 @@
1
1
  import {
2
+ CLASSIC_ADAPTER_TYPE,
2
3
  CLASSIC_COOLING_ONLY_TYPE,
3
4
  CLASSIC_FAN_SPEED_CODES,
4
5
  CLASSIC_HORIZONTAL_POSITION_CODES,
@@ -33,7 +34,7 @@ import {
33
34
  // DESIGN.md#simulators.
34
35
 
35
36
  const DEFAULT_BASIC_INFO: Record<string, string> = {
36
- type: "aircon",
37
+ type: CLASSIC_ADAPTER_TYPE,
37
38
  reg: "th",
38
39
  ver: "1_2_51",
39
40
  name: "Simulator",
@@ -1,3 +1,4 @@
1
+ export * from "./airbase.js";
1
2
  export * from "./classic.js";
2
3
  export * from "./dsiot.js";
3
4
  export type {
@@ -9,8 +9,8 @@ import type {
9
9
  SpecialMode,
10
10
  } from "../models/state.js";
11
11
 
12
- // What the classic and the DSIOT simulator share: the HTTP server, and the
13
- // reply to a discovery probe, which both adapters send in the same form.
12
+ // What the simulators share: the HTTP server, and the reply to a discovery
13
+ // probe, which every adapter sends in the same form.
14
14
 
15
15
  const DISCOVERY_PROBE = "DAIKIN_UDP/common/basic_info";
16
16
  const DISCOVERY_PORT = 30050;
@@ -28,7 +28,7 @@ export interface DaikinSimulatorRequest {
28
28
  body: string;
29
29
  }
30
30
 
31
- /** What `startClassicSimulator` and `startDsiotSimulator` both resolve with. */
31
+ /** What every simulator resolves with. */
32
32
  export interface DaikinSimulator {
33
33
  /** `<address>:<port>`, as `DaikinDevice.host` takes it: `127.0.0.1`, unless
34
34
  * the simulator was started at another `address`, and a port that was free
@@ -59,9 +59,10 @@ export interface DaikinSimulatorTemperatures {
59
59
  max: number;
60
60
  }
61
61
 
62
- /** What a simulated unit has, where both protocols can say it. Anything else
63
- * about a unit is set in the adapter's own terms: `modelInfo` on a classic
64
- * simulator, and `leaves` on a DSIOT one. */
62
+ /** What a simulated unit has, where the classic and the DSIOT protocol can
63
+ * both say it. Anything else about a unit is set in the adapter's own terms:
64
+ * `modelInfo` on a classic simulator, and `leaves` on a DSIOT one. An Airbase
65
+ * simulator takes an `AirbaseSimulatorUnit` instead. */
65
66
  export interface DaikinSimulatorUnit {
66
67
  /** A unit that only cools offers dehumidify, cool and fan. Defaults to
67
68
  * false: a heat pump, which offers auto and heat as well. */
@@ -83,10 +84,9 @@ export interface DaikinSimulatorUnit {
83
84
  heatTemperaturesInC?: DaikinSimulatorTemperatures;
84
85
  }
85
86
 
86
- /** The options `startClassicSimulator` and `startDsiotSimulator` both take.
87
- * A simulator applies `deviceInfo`, `state` and `sensors` after the fields
88
- * given in the adapter's own terms, so they replace a field given both
89
- * ways. */
87
+ /** The options every simulator takes. A simulator applies `deviceInfo`,
88
+ * `state` and `sensors` after the fields given in the adapter's own terms, so
89
+ * they replace a field given both ways. */
90
90
  export interface StartSimulatorOptions {
91
91
  /** The adapter's name, MAC address and firmware version, as
92
92
  * `getDeviceInfo` reads them back. A field left out keeps its default.
@@ -98,7 +98,8 @@ export interface StartSimulatorOptions {
98
98
  state?: Partial<DaikinState>;
99
99
  /** What the unit's sensors read at the start: see
100
100
  * `DaikinSimulator.setSensors`. Defaults to 26 °C and 55 % indoors, and
101
- * 30 °C outdoors. */
101
+ * 30 °C outdoors. An Airbase simulator reports no humidity unless it is
102
+ * given one. */
102
103
  sensors?: Partial<DaikinSensors>;
103
104
  /** How long, in milliseconds, the adapter takes to answer a request. It
104
105
  * answers one request at a time, in the order they arrive, so a request
@@ -117,7 +118,7 @@ export interface StartSimulatorOptions {
117
118
  }
118
119
 
119
120
  /** What `startSimulator` resolves with: the part of a `DaikinSimulator` that
120
- * is the same for both protocols. */
121
+ * is the same for every protocol. */
121
122
  export type SimulatorServer = Pick<
122
123
  DaikinSimulator,
123
124
  "host" | "requests" | "stop"
package/src/transport.ts CHANGED
@@ -72,8 +72,9 @@ export interface DaikinTransportOptions {
72
72
  /** HTTPS (a Marvell adapter in secure mode). */
73
73
  usesHttps?: boolean;
74
74
  /** The terminal uuid, sent as `X-Daikin-uuid` on every call: 32 lowercase
75
- * hexadecimal digits, a UUID without its dashes. */
76
- terminalUuid: string;
75
+ * hexadecimal digits, a UUID without its dashes. Without one the header is
76
+ * left out, as an Airbase adapter takes it. */
77
+ terminalUuid?: string;
77
78
  authentication?: DaikinAuthentication;
78
79
  /** How long, in milliseconds, to wait for each of the connection, the
79
80
  * reply's headers and the reply's body before `send` throws a
@@ -110,7 +111,10 @@ export class DaikinTransport {
110
111
  * `DaikinTransportOptions.terminalUuid` describes. */
111
112
  constructor(private readonly options: DaikinTransportOptions) {
112
113
  // Before the Client, so a uuid that throws leaves no connection open.
113
- this.terminalUuidHeaders = encodeTerminalUuid(options.terminalUuid);
114
+ this.terminalUuidHeaders =
115
+ options.terminalUuid === undefined
116
+ ? {}
117
+ : encodeTerminalUuid(options.terminalUuid);
114
118
 
115
119
  const scheme = options.usesHttps ? "https" : "http";
116
120
  this.origin = `${scheme}://${options.host.replace(/\/+$/, "")}`;