daikin-local-control 0.3.1 → 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 (150) hide show
  1. package/DESIGN.md +156 -2
  2. package/README.md +274 -48
  3. package/UNSUPPORTED.md +66 -3
  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/classic.d.ts +2 -2
  9. package/dist/clients/classic.d.ts.map +1 -1
  10. package/dist/clients/classic.js +4 -4
  11. package/dist/clients/classic.js.map +1 -1
  12. package/dist/clients/client.d.ts +7 -2
  13. package/dist/clients/client.d.ts.map +1 -1
  14. package/dist/clients/dsiot.d.ts +3 -1
  15. package/dist/clients/dsiot.d.ts.map +1 -1
  16. package/dist/clients/dsiot.js +7 -2
  17. package/dist/clients/dsiot.js.map +1 -1
  18. package/dist/codecs/airbase/capabilities.d.ts +23 -0
  19. package/dist/codecs/airbase/capabilities.d.ts.map +1 -0
  20. package/dist/codecs/airbase/capabilities.js +108 -0
  21. package/dist/codecs/airbase/capabilities.js.map +1 -0
  22. package/dist/codecs/airbase/state.d.ts +29 -0
  23. package/dist/codecs/airbase/state.d.ts.map +1 -0
  24. package/dist/codecs/airbase/state.js +166 -0
  25. package/dist/codecs/airbase/state.js.map +1 -0
  26. package/dist/codecs/airbase/tables.d.ts +7 -0
  27. package/dist/codecs/airbase/tables.d.ts.map +1 -0
  28. package/dist/codecs/airbase/tables.js +27 -0
  29. package/dist/codecs/airbase/tables.js.map +1 -0
  30. package/dist/codecs/airbase/zone.d.ts +12 -0
  31. package/dist/codecs/airbase/zone.d.ts.map +1 -0
  32. package/dist/codecs/airbase/zone.js +50 -0
  33. package/dist/codecs/airbase/zone.js.map +1 -0
  34. package/dist/codecs/classic/capabilities.d.ts.map +1 -1
  35. package/dist/codecs/classic/capabilities.js +5 -11
  36. package/dist/codecs/classic/capabilities.js.map +1 -1
  37. package/dist/codecs/classic/extendedControl.d.ts.map +1 -1
  38. package/dist/codecs/classic/extendedControl.js +10 -19
  39. package/dist/codecs/classic/extendedControl.js.map +1 -1
  40. package/dist/codecs/classic/state.d.ts.map +1 -1
  41. package/dist/codecs/classic/state.js +36 -95
  42. package/dist/codecs/classic/state.js.map +1 -1
  43. package/dist/codecs/classic/tables.d.ts +34 -0
  44. package/dist/codecs/classic/tables.d.ts.map +1 -0
  45. package/dist/codecs/classic/tables.js +96 -0
  46. package/dist/codecs/classic/tables.js.map +1 -0
  47. package/dist/codecs/credentials.d.ts +3 -2
  48. package/dist/codecs/credentials.d.ts.map +1 -1
  49. package/dist/codecs/credentials.js +8 -3
  50. package/dist/codecs/credentials.js.map +1 -1
  51. package/dist/codecs/device.d.ts +7 -1
  52. package/dist/codecs/device.d.ts.map +1 -1
  53. package/dist/codecs/device.js +15 -3
  54. package/dist/codecs/device.js.map +1 -1
  55. package/dist/codecs/dsiot/capabilities.d.ts.map +1 -1
  56. package/dist/codecs/dsiot/capabilities.js +8 -15
  57. package/dist/codecs/dsiot/capabilities.js.map +1 -1
  58. package/dist/codecs/dsiot/device.d.ts.map +1 -1
  59. package/dist/codecs/dsiot/device.js +4 -8
  60. package/dist/codecs/dsiot/device.js.map +1 -1
  61. package/dist/codecs/dsiot/multireq.d.ts +0 -19
  62. package/dist/codecs/dsiot/multireq.d.ts.map +1 -1
  63. package/dist/codecs/dsiot/multireq.js +2 -25
  64. package/dist/codecs/dsiot/multireq.js.map +1 -1
  65. package/dist/codecs/dsiot/state.d.ts +6 -62
  66. package/dist/codecs/dsiot/state.d.ts.map +1 -1
  67. package/dist/codecs/dsiot/state.js +29 -148
  68. package/dist/codecs/dsiot/state.js.map +1 -1
  69. package/dist/codecs/dsiot/tables.d.ts +119 -0
  70. package/dist/codecs/dsiot/tables.d.ts.map +1 -0
  71. package/dist/codecs/dsiot/tables.js +188 -0
  72. package/dist/codecs/dsiot/tables.js.map +1 -0
  73. package/dist/discovery.d.ts +20 -9
  74. package/dist/discovery.d.ts.map +1 -1
  75. package/dist/discovery.js +102 -32
  76. package/dist/discovery.js.map +1 -1
  77. package/dist/factory.d.ts +13 -7
  78. package/dist/factory.d.ts.map +1 -1
  79. package/dist/factory.js +16 -4
  80. package/dist/factory.js.map +1 -1
  81. package/dist/index.d.ts +8 -0
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +8 -0
  84. package/dist/index.js.map +1 -1
  85. package/dist/models/device.d.ts +3 -2
  86. package/dist/models/device.d.ts.map +1 -1
  87. package/dist/models/device.js +1 -1
  88. package/dist/models/device.js.map +1 -1
  89. package/dist/models/state.d.ts +2 -3
  90. package/dist/models/state.d.ts.map +1 -1
  91. package/dist/models/zone.d.ts +9 -0
  92. package/dist/models/zone.d.ts.map +1 -0
  93. package/dist/models/zone.js +2 -0
  94. package/dist/models/zone.js.map +1 -0
  95. package/dist/simulators/airbase.d.ts +82 -0
  96. package/dist/simulators/airbase.d.ts.map +1 -0
  97. package/dist/simulators/airbase.js +352 -0
  98. package/dist/simulators/airbase.js.map +1 -0
  99. package/dist/simulators/classic.d.ts +61 -0
  100. package/dist/simulators/classic.d.ts.map +1 -0
  101. package/dist/simulators/classic.js +473 -0
  102. package/dist/simulators/classic.js.map +1 -0
  103. package/dist/simulators/dsiot.d.ts +49 -0
  104. package/dist/simulators/dsiot.d.ts.map +1 -0
  105. package/dist/simulators/dsiot.js +519 -0
  106. package/dist/simulators/dsiot.js.map +1 -0
  107. package/dist/simulators/index.d.ts +5 -0
  108. package/dist/simulators/index.d.ts.map +1 -0
  109. package/dist/simulators/index.js +4 -0
  110. package/dist/simulators/index.js.map +1 -0
  111. package/dist/simulators/simulator.d.ts +117 -0
  112. package/dist/simulators/simulator.d.ts.map +1 -0
  113. package/dist/simulators/simulator.js +141 -0
  114. package/dist/simulators/simulator.js.map +1 -0
  115. package/dist/transport.d.ts +8 -3
  116. package/dist/transport.d.ts.map +1 -1
  117. package/dist/transport.js +10 -2
  118. package/dist/transport.js.map +1 -1
  119. package/package.json +11 -4
  120. package/src/clients/airbase.ts +274 -0
  121. package/src/clients/classic.ts +8 -8
  122. package/src/clients/client.ts +8 -1
  123. package/src/clients/dsiot.ts +8 -1
  124. package/src/codecs/airbase/capabilities.ts +150 -0
  125. package/src/codecs/airbase/state.ts +223 -0
  126. package/src/codecs/airbase/tables.ts +32 -0
  127. package/src/codecs/airbase/zone.ts +62 -0
  128. package/src/codecs/classic/capabilities.ts +8 -11
  129. package/src/codecs/classic/extendedControl.ts +14 -25
  130. package/src/codecs/classic/state.ts +49 -101
  131. package/src/codecs/classic/tables.ts +126 -0
  132. package/src/codecs/credentials.ts +11 -3
  133. package/src/codecs/device.ts +19 -3
  134. package/src/codecs/dsiot/capabilities.ts +10 -17
  135. package/src/codecs/dsiot/device.ts +4 -9
  136. package/src/codecs/dsiot/multireq.ts +2 -25
  137. package/src/codecs/dsiot/state.ts +57 -193
  138. package/src/codecs/dsiot/tables.ts +253 -0
  139. package/src/discovery.ts +107 -34
  140. package/src/factory.ts +26 -7
  141. package/src/index.ts +8 -0
  142. package/src/models/device.ts +3 -2
  143. package/src/models/state.ts +2 -3
  144. package/src/models/zone.ts +8 -0
  145. package/src/simulators/airbase.ts +495 -0
  146. package/src/simulators/classic.ts +636 -0
  147. package/src/simulators/dsiot.ts +736 -0
  148. package/src/simulators/index.ts +10 -0
  149. package/src/simulators/simulator.ts +282 -0
  150. package/src/transport.ts +15 -4
package/DESIGN.md CHANGED
@@ -53,6 +53,46 @@ the read. A `DaikinState` holds the current mode's values alone: it has neither
53
53
  the values a classic adapter remembers for the other modes nor the `md` of a
54
54
  DSIOT leaf, so the client would still need the reply.
55
55
 
56
+ ## Discovery
57
+
58
+ `discoverDevices` sends its probes from the first free UDP port from 30000 to
59
+ 30050. An adapter answers the port a probe came from, so a reply reaches the
60
+ call that sent the probe.
61
+
62
+ Rejected: port 30000 alone, bound with `reuseAddr`.
63
+
64
+ - Two calls at the same time both bound the port, and each reply reached one
65
+ of the two sockets alone. One call resolved with every adapter and the other
66
+ with none, and neither rejected.
67
+
68
+ `discoverDevices` asks each adapter that replies as a classic one, over HTTP,
69
+ whether it is an Airbase adapter: `isAirbaseAdapter` takes HTTP 404 at
70
+ `/common/basic_info` with `ret=OK` at `/skyfi/common/basic_info` for one.
71
+
72
+ Rejected: the protocol from the reply alone.
73
+
74
+ - An Airbase adapter reports `type=aircon` as a classic adapter does, and no
75
+ field of the reply is known to differ. Discovery reported it as `classic`,
76
+ and it answered HTTP 404 to every request of the client built from that.
77
+
78
+ Rejected: asking in `createDaikinClient`, for a device written by hand as
79
+ well.
80
+
81
+ - `createDaikinClient` returns a client at once and sends nothing. Asking
82
+ there would make it asynchronous for every caller, to settle what a caller
83
+ who writes a device by hand already knows.
84
+
85
+ `discoverDevices` resolves with the adapters of air conditioners alone:
86
+ `isAirConditioner` passes a reply whose `type` is `aircon`, or `GPF` with a
87
+ `cdev` that is not an air cleaner's.
88
+
89
+ Rejected: a device for every adapter that answers.
90
+
91
+ - The adapter of an air cleaner, a water heater, a floor heater or a
92
+ circulator answers the probe as well. `parseDevice` read each as a classic
93
+ air conditioner, a `DaikinDevice` had nothing to tell them apart by, and no
94
+ method of the client built from one worked.
95
+
56
96
  ## Capabilities
57
97
 
58
98
  `DaikinCapabilities` lists what a unit accepts in the vocabulary of `DaikinState`:
@@ -130,8 +170,11 @@ Rejected: positions as the numbers 0 to 25, typed `number`, with
130
170
  ## Special modes
131
171
 
132
172
  `DaikinState.specialModes` lists the special modes that are on, and `getState`
133
- reads it on both protocols. `DaikinControl` leaves it out, so `setState` is
134
- never handed a field it cannot write: UNSUPPORTED.md says what is not known.
173
+ reads it on both protocols. `DaikinControl` leaves it out: `setSpecialMode`
174
+ turns one mode on or off, with a request of its own on both protocols. A
175
+ classic adapter takes it at `/aircon/set_special_mode`, beside
176
+ `set_control_info`, and a DSIOT adapter takes one leaf, on the outdoor edge
177
+ for powerful where every leaf `setState` writes is on the indoor edge.
135
178
 
136
179
  Rejected: a `getSpecialModes` on `ClassicDaikinClient`, beside `setSpecialMode`.
137
180
 
@@ -148,3 +191,114 @@ Rejected: one list for the whole unit.
148
191
 
149
192
  - Econo is not known to be taken in fan mode, so the one list offered it in a
150
193
  mode where UNSUPPORTED.md lists it as unknown.
194
+
195
+ ## Simulators
196
+
197
+ `startClassicSimulator`, `startDsiotSimulator` and `startAirbaseSimulator`
198
+ each start an adapter as a server. A simulator shares the codecs' tables, `src/codecs/*/tables.ts`, and
199
+ none of their functions. A table holds what an adapter calls a thing: its
200
+ field and leaf names, and the code for each value. A function does something
201
+ with it: a codec parses a reply and builds a request, and a simulator answers
202
+ the request, holds what it was sent, and builds the reply.
203
+
204
+ Rejected: a simulator that builds its replies with the codecs' functions.
205
+
206
+ - A simulator built from a codec agrees with it by construction. A codec that
207
+ reads a field wrongly would meet a simulator that writes it wrongly the same
208
+ way, and the test of one against the other would pass.
209
+
210
+ Rejected: a simulator with tables of its own, sharing nothing with the codecs.
211
+
212
+ - Every code was written twice, in two files that had to be kept alike, and a
213
+ caller setting a field of a simulator by hand had no table to read it from.
214
+ - The second writing caught a wrong code only where the simulator encoded a
215
+ value itself. `tables.test.ts` writes every entry out again, and catches
216
+ one wherever it is used.
217
+
218
+ `package.json` exports the simulators as `daikin-local-control/simulators`,
219
+ an entry of their own beside the package root.
220
+
221
+ Rejected: exporting them from the package root.
222
+
223
+ - A caller's production code imports the root. With the simulators in it, that
224
+ import loads two servers the code never starts, and a bundler has to work
225
+ out that it can drop them.
226
+
227
+ A simulator holds its state in the adapter's terms, `controlInfo.stemp` on a
228
+ classic simulator and a leaf's `pv` by its address on a DSIOT one, and takes
229
+ it in the package's models: a `DaikinState`, a `DaikinSensors` and a
230
+ `DaikinDeviceInfo`. It turns a model into the adapter's fields with functions
231
+ of its own, in `src/simulators/`, so a client that reads the state back
232
+ checks the codec that parses it against a second writing of the same
233
+ encoding.
234
+
235
+ Rejected: options in the adapter's terms alone, a record of strings for each
236
+ reply.
237
+
238
+ - A caller had to know the adapter's encodings to start a unit in a state: a
239
+ DSIOT setpoint of 24 °C is the leaf value `3000`.
240
+
241
+ A classic or a DSIOT simulator takes what the unit has as a
242
+ `DaikinSimulatorUnit`, which says it in terms both protocols have flags for:
243
+ `coolingOnly`, `swing`, `fan`. An Airbase simulator takes an
244
+ `AirbaseSimulatorUnit` of its own: an Airbase unit has zones and airside, and
245
+ neither a cooling-only kind nor a special mode.
246
+
247
+ Rejected: taking a `DaikinCapabilities`.
248
+
249
+ - Most values of it are ones no adapter reports. Fan speeds of `auto`,
250
+ `level1` and `level3` have no `frate_steps`, and a DSIOT adapter reports
251
+ every fan speed or none, so the simulator would throw for most of what the
252
+ type allows.
253
+
254
+ A simulator answers HTTP 501 to a request whose answer from an adapter is not
255
+ known, such as a path it does not serve.
256
+
257
+ Rejected: answering such a request the way an adapter plausibly would, with
258
+ HTTP 404 or `ret=PARAM NG`.
259
+
260
+ - A caller's test would then pass or fail on an answer no adapter is known to
261
+ give. No adapter is known to answer 501, so a caller cannot take it for an
262
+ adapter's reply.
263
+
264
+ A simulator answers one request at a time, and a request that arrives
265
+ meanwhile waits for its turn: an adapter makes concurrent requests wait.
266
+ `replyDelayInMs` makes the wait long enough for a test to see. Whether an
267
+ adapter also drops a connection while it is busy is not known, so a simulator
268
+ never does.
269
+
270
+ A simulator answers the discovery probe only when started with
271
+ `answersDiscovery`.
272
+
273
+ Rejected: answering it always.
274
+
275
+ - An adapter answers on UDP port 30050, a fixed port, so a simulator that
276
+ always bound it could not start beside another program that holds it, in a
277
+ test that never discovers anything.
278
+
279
+ Each simulator is at one `address` of the machine, `127.0.0.1` unless it is
280
+ given another. It answers the probe from that address, and the simulators
281
+ share port 30050: each hears the probe on a socket bound to every address,
282
+ and answers from a second socket bound to its own.
283
+
284
+ Rejected: one socket for each simulator, bound to port 30050 of its own
285
+ address.
286
+
287
+ - The probe is a broadcast to `255.255.255.255`, and a socket bound to one
288
+ address receives none of it: two simulators bound that way answered no
289
+ probe.
290
+
291
+ Rejected: one socket bound to every address, answering from it.
292
+
293
+ - The machine then picks the address a reply leaves from, its address on the
294
+ LAN, so `discoverDevices` reports the simulator there, and the simulator
295
+ has to listen for HTTP on the LAN where a test needs loopback alone.
296
+
297
+ `discoverDevices` keeps one device for each `host`, the address a reply came
298
+ from with the `api_port` it announces, so it finds every simulator on a
299
+ machine: they answer from one address and announce a port each.
300
+
301
+ Rejected: one device for each address.
302
+
303
+ - Simulators on one machine share its addresses, so a caller could have as
304
+ many discovered as the machine has addresses, two on a laptop.
package/README.md CHANGED
@@ -1,26 +1,32 @@
1
1
  # daikin-local-control
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/daikin-local-control)](https://www.npmjs.com/package/daikin-local-control)
4
+ [![npm downloads](https://img.shields.io/npm/dm/daikin-local-control)](https://www.npmjs.com/package/daikin-local-control)
4
5
  [![CI](https://github.com/cheziyi/daikin-local-control/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/cheziyi/daikin-local-control/actions/workflows/ci.yml?query=branch%3Amain)
5
6
  [![Node version](https://img.shields.io/node/v/daikin-local-control)](package.json)
7
+ [![Types](https://img.shields.io/npm/types/daikin-local-control)](package.json)
6
8
  [![License](https://img.shields.io/npm/l/daikin-local-control)](LICENSE)
9
+ [![Checked with Biome](https://img.shields.io/badge/Checked_with-Biome-60a5fa?style=flat&logo=biome)](https://biomejs.dev)
7
10
 
8
11
  A TypeScript and Node.js client for controlling Daikin air conditioners **over
9
12
  the local network** (LAN), through their Wi-Fi adapters and with no cloud
10
- dependency. It implements the same local-control protocol as the **DAIKIN Mobile
11
- Controller** app, so it works with the units and Wi-Fi adapters that app
12
- supports — split / multi-split and SkyAir units fitted with adapters such as the
13
- BRP072A42, BRP072C42 and BRP084 — in the
14
- [markets where that app is used](#coverage--limitations).
13
+ dependency. It implements the local-control protocols of the **DAIKIN Mobile
14
+ Controller**, **Daikin Airbase** and **ONECTA** apps, so it works with the units
15
+ and Wi-Fi adapters those apps support — split / multi-split, SkyAir and ducted
16
+ units fitted with adapters such as the BRP072A42, BRP072C42, BRP084, BRP069 and
17
+ BRP15B61 — in the [markets where those apps are used](#coverage--limitations).
15
18
 
16
- Daikin Wi-Fi adapters come in two generations, and the package has a client for
17
- each:
19
+ Daikin Wi-Fi adapters speak one of three protocols, and the package has a
20
+ client for each:
18
21
 
19
22
  - **`ClassicDaikinClient`** — classic adapters (BRP072A42, BRP072C42, BRP069).
20
23
  Full core control plus typed [extended controls](#extended-controls).
21
24
  - **`DsiotDaikinClient`** — newer DSIOT adapters (BRP084). Power, mode,
22
- setpoint, humidity, fan, swing and louver positions;
25
+ setpoint, humidity, fan, swing, louver positions and special modes;
23
26
  [UNSUPPORTED.md](UNSUPPORTED.md) lists what is not mapped yet.
27
+ - **`AirbaseDaikinClient`** — Airbase adapters (BRP15B61) on ducted units.
28
+ Power, mode, setpoint, fan and direction, plus [zones](#airbase), fan auto
29
+ and airside.
24
30
 
25
31
  You normally don't pick one yourself: `createDaikinClient` chooses the right
26
32
  client for each discovered device.
@@ -47,9 +53,9 @@ if (device === undefined) {
47
53
  throw new Error("No adapter answered");
48
54
  }
49
55
 
50
- // 2. Build the right client: createDaikinClient picks the classic or the DSIOT
51
- // client from the device. Pass localPassword only when the adapter has a
52
- // child lock code (see Credentials below).
56
+ // 2. Build the right client: createDaikinClient picks the classic, the DSIOT
57
+ // or the Airbase client from the device. Pass localPassword only when the
58
+ // adapter has a child lock code (see Credentials below).
53
59
  const client = createDaikinClient(device, {
54
60
  terminalUuid: savedUuid, // generated once and stored (see Credentials below)
55
61
  localPassword: device.requiresLocalPassword ? "<child lock code>" : undefined,
@@ -73,17 +79,19 @@ await client.close();
73
79
 
74
80
  ### Discovery
75
81
 
76
- `discoverDevices()` resolves with one `DaikinDevice` for each adapter that
77
- answered, and with an empty array when none did. Each carries the adapter's
78
- `macAddress`, such as `A4:08:EA:CC:91:D4`, and `firmwareVersion`, such as
79
- `1.2.51`, when the adapter reports them: key a stored device on `macAddress`,
80
- because `host` changes when the adapter is given a new address.
82
+ `discoverDevices()` resolves with one `DaikinDevice` for each air conditioner's
83
+ adapter that answered, and with an empty array when none did. Each carries the
84
+ adapter's `macAddress`, such as `A4:08:EA:CC:91:D4`, and `firmwareVersion`,
85
+ such as `1.2.51`, when the adapter reports them: key a stored device on
86
+ `macAddress`, because `host` changes when the adapter is given a new address.
81
87
 
82
88
  | It | So |
83
89
  | --- | --- |
84
90
  | Resolves after `timeoutInMs`, never earlier | Pass `discoverDevices({ timeoutInMs: 1000 })` to wait less than the default 3000 |
85
91
  | Broadcasts to `255.255.255.255` | It does not find an adapter on another subnet or VLAN |
86
- | Binds UDP port 30000 | It rejects with the socket's error when the socket fails |
92
+ | Keeps the adapters of air conditioners alone | It leaves out the adapter of an air cleaner, a water heater or another appliance, which answers the same broadcast |
93
+ | Asks each classic adapter over HTTP whether it is an Airbase adapter, which replies to the broadcast as a classic one does | It resolves later than `timeoutInMs` while an adapter is slow to answer, by 3000 ms at most for each request after up to 750 ms of retrying a dropped connection, and reports as `classic` one that does not answer. An Airbase device requires no registration and no child lock code, whatever the adapter's reply says |
94
+ | Binds the first free UDP port from 30000 to 30050 | Calls made at the same time each get every reply. It rejects with the socket's error when none of the ports is free, or when the socket fails |
87
95
 
88
96
  Write the device by hand when you already know its address, or when discovery
89
97
  cannot reach it:
@@ -98,8 +106,13 @@ const client = createDaikinClient(device, { terminalUuid: savedUuid });
98
106
  A device written by hand has no `name`, `macAddress` or `firmwareVersion`: read
99
107
  them from the adapter with `await client.getDeviceInfo()`.
100
108
 
101
- Set `protocol` to `dsiot` for a BRP084 and to `classic` for every other
102
- adapter. A device without flags is an adapter that requires neither
109
+ To tell an Airbase adapter from a classic one by its address, request
110
+ `/common/basic_info`: an Airbase adapter answers HTTP 404 there, and `ret=OK`
111
+ at `/skyfi/common/basic_info`.
112
+
113
+ Set `protocol` to `dsiot` for a BRP084, to `airbase` for a BRP15B61 and to
114
+ `classic` for every other adapter: `createDaikinClient()` goes by it and asks
115
+ the adapter nothing. A device without flags is an adapter that requires neither
103
116
  registration nor a child lock code. If yours requires either, set
104
117
  `requiresRegistration: true` / `requiresLocalPassword: true` and set up
105
118
  [credentials](#credentials) to match; add `usesHttps: true` if it also speaks
@@ -109,25 +122,29 @@ for you.
109
122
  ### Credentials
110
123
 
111
124
  An adapter can need up to three things. Discovery tells you which ones apply
112
- (`device.requiresRegistration`, `device.requiresLocalPassword`).
125
+ (`device.requiresRegistration`, `device.requiresLocalPassword`). An Airbase
126
+ adapter takes none of them: `createDaikinClient()` still takes a
127
+ `terminalUuid`, and sends it nothing of the three.
113
128
 
114
- **Terminal uuid (always).** It identifies your app to the adapter. Any random
115
- UUID works. Generate it **once**, store it, and reuse it on every run: an
116
- adapter that requires registration remembers it, so a new uuid means registering
117
- again.
129
+ **Terminal uuid (always).** It identifies your app to the adapter. Pass 32
130
+ lowercase hexadecimal digits, a UUID without its dashes: `createDaikinClient()`
131
+ throws an `Error` for any other form, and sends the uuid as given. Generate it
132
+ **once**, store it, and reuse it on every run: an adapter that requires
133
+ registration remembers it, so a new uuid means registering again.
118
134
 
119
135
  ```ts
120
136
  import { randomUUID } from "node:crypto";
121
137
 
122
- const savedUuid = loadFromConfig() ?? saveToConfig(randomUUID());
138
+ const savedUuid =
139
+ loadFromConfig() ?? saveToConfig(randomUUID().replaceAll("-", ""));
123
140
  ```
124
141
 
125
142
  **KEY (when `device.requiresRegistration` is `true`).** These
126
143
  adapters only answer terminals they have registered. Each adapter's KEY is
127
144
  printed on its label, next to the SSID (also in the operation manual). Register
128
145
  your uuid with it once. Until you do, every call fails with HTTP 401/403.
129
- Each uuid is registered separately, so an adapter already set up in the DAIKIN
130
- Mobile Controller app still needs registering here.
146
+ Each uuid is registered separately, so an adapter already set up in a Daikin
147
+ app still needs registering here.
131
148
 
132
149
  ```ts
133
150
  import { ClassicDaikinClient } from "daikin-local-control";
@@ -147,8 +164,8 @@ Some of these adapters also switch to HTTPS (`device.usesHttps`); see
147
164
 
148
165
  **Child lock code (when `device.requiresLocalPassword` is `true`).** The protocol
149
166
  calls it the local password (`lpw`). It is the 4-digit code set under Child lock
150
- in the DAIKIN Mobile Controller app. It is **not** printed on the label, and it
151
- is not your Wi-Fi or Out-of-Home password. Pass it as `localPassword` to
167
+ in the Daikin app the adapter was set up with. It is **not** printed on the
168
+ label, and it is not your Wi-Fi or Out-of-Home password. Pass it as `localPassword` to
152
169
  `createDaikinClient`. If it's forgotten, the adapter has to be reset.
153
170
 
154
171
  ### State and capabilities
@@ -201,10 +218,20 @@ given, whether or not the unit lists it.
201
218
  | `targetTemperatureInC` | In `fan` and `dehumidify` |
202
219
  | `targetHumidity` | In `fan` |
203
220
  | `fanSpeed` | In `dehumidify`, and on a DSIOT adapter in `cool` while a humidity setting is on: the fan runs on `auto` |
204
- | `verticalPosition`, `horizontalPosition` | On a classic adapter, when the unit has no 3D airflow. On a DSIOT adapter, a fixed vertical position the unit lacks, and every fixed horizontal position |
221
+ | `verticalPosition`, `horizontalPosition` | On a classic adapter, when the unit has no 3D airflow. On a DSIOT adapter, a fixed vertical position the unit lacks, and every fixed horizontal position. On an Airbase adapter, always |
222
+
223
+ An Airbase adapter also drops every `targetHumidity`, and a
224
+ `targetTemperatureInC` in `fan` as well as in `dehumidify`.
205
225
 
206
226
  `setState()` takes no `specialModes`: turn one on or off with
207
- [`setSpecialMode()`](#extended-controls), which only a classic adapter has.
227
+ `setSpecialMode()`, which sends a request of its own. Check the mode against
228
+ `getCapabilities().specialModes` first: the client sends any mode as given.
229
+ An Airbase adapter has no request for one, so `AirbaseDaikinClient` rejects
230
+ with a `DaikinError` whose `httpStatusCode` is 501.
231
+
232
+ ```ts
233
+ await client.setSpecialMode("powerful", true);
234
+ ```
208
235
 
209
236
  Pass `maxStateAgeForSetStateInMs` to `createDaikinClient` when you poll the
210
237
  state anyway: `setState()` reads the unit before each write, and with it builds
@@ -238,7 +265,6 @@ if (client instanceof ClassicDaikinClient) {
238
265
  maxPowerInPercent: 70,
239
266
  mode: "manual",
240
267
  });
241
- await client.setSpecialMode("powerful", true);
242
268
  await client.setOutOfHomeAccess(false); // home network only
243
269
  await client.setEnergyPrice(0.3); // per kWh
244
270
  await client.setMetadata({
@@ -265,6 +291,43 @@ what is not known about each.
265
291
  `DsiotDaikinClient` has `reboot()` and the raw `multireqGet` / `multireqUpdate`
266
292
  hatches for any `dgc_status` leaf.
267
293
 
294
+ ### Airbase
295
+
296
+ `AirbaseDaikinClient` adds the controls a ducted unit has. Narrow to the
297
+ concrete class to reach them:
298
+
299
+ ```ts
300
+ import { AirbaseDaikinClient } from "daikin-local-control";
301
+
302
+ if (client instanceof AirbaseDaikinClient) {
303
+ const zones = await client.getZones(); // [{ name: "Living", enabled: true }, ...]
304
+ await client.setZone(1, true); // open the second zone
305
+
306
+ if ((await client.getFanAuto()) !== null) {
307
+ await client.setFanAuto(true);
308
+ }
309
+ if ((await client.getAirside()) !== null) {
310
+ await client.setAirside(true);
311
+ }
312
+
313
+ // Any other endpoint, by its path below /skyfi.
314
+ const holiday = await client.command("/common/get_holiday");
315
+ }
316
+ ```
317
+
318
+ | Method | Does |
319
+ | --- | --- |
320
+ | `getZones()` | Resolves with `{ name, enabled }` for each zone, in the unit's order, and with an empty array on a unit without zones |
321
+ | `setZone(index, enabled)` | Opens or closes the zone at that place in `getZones()`, counted from 0 |
322
+ | `getFanAuto()`, `getAirside()` | Resolve with `true` or `false`, or `null` on a unit that does not take the setting |
323
+ | `setFanAuto(enabled)` | Lets the unit pick the fan speed itself, up to the `fanSpeed` that is set. It switches airside off |
324
+ | `setAirside(enabled)` | Switches airside on or off. Switching it on switches fan auto off and sets `fanSpeed` to `level1` |
325
+
326
+ A `fanSpeed` in `setState()` switches airside off and keeps fan auto.
327
+
328
+ Only a unit with zones takes fan auto and airside, and it takes no `auto` fan
329
+ speed and no `swingMode`: `getCapabilities()` lists what a unit takes.
330
+
268
331
  ### Errors and retries
269
332
 
270
333
  A client method that fails rejects with a `DaikinError`; branch on
@@ -276,11 +339,15 @@ A client method that fails rejects with a `DaikinError`; branch on
276
339
  | 502 | The adapter answered with an error: a `ret` or `rsc` other than OK, or a reply that does not parse |
277
340
  | 503 | The adapter refused or dropped the connection (a `DaikinConnectionError`), or DSIOT answered `rsc` 503 |
278
341
  | 504 | The request timed out, or the TLS handshake failed |
342
+ | 501 | `AirbaseDaikinClient.setSpecialMode()`: an Airbase adapter has no request for a special mode |
279
343
  | Any other | The adapter's own HTTP status, such as 403 |
280
344
 
281
- Two failures are not a `DaikinError`: `discoverDevices()` rejects with the
282
- socket's error, and `multireqUpdate()` throws an `Error` for a path that names
283
- a resource without a leaf, such as `dgc_status`.
345
+ Four failures are not a `DaikinError`: `discoverDevices()` rejects with the
346
+ socket's error, `createDaikinClient()` throws an `Error` for a
347
+ [terminal uuid](#credentials) in the wrong form, `multireqUpdate()` throws
348
+ an `Error` for a path that names a resource without a leaf, such as
349
+ `dgc_status`, and `setZone()` rejects with an `Error` for an index the unit
350
+ has no zone at.
284
351
 
285
352
  Only a `DaikinConnectionError` is retried, up to five times by default.
286
353
  [DESIGN.md](DESIGN.md#retries) says why.
@@ -291,22 +358,181 @@ Only a `DaikinConnectionError` is retried, up to five times by default.
291
358
  | Stop a client retrying | `retry: { delaysInMs: [] }` to `createDaikinClient` |
292
359
  | Send one request that is not safe to repeat | `{ retry: false }` as the last argument of `command()`, `multireqGet()` or `multireqUpdate()` |
293
360
 
294
- ## Coverage & limitations
361
+ ### Simulators
362
+
363
+ Test your code against a simulated adapter instead of a real one: start a
364
+ simulator, and point a client at its `host`. Import the simulators from
365
+ `daikin-local-control/simulators`: the package root does not export them.
366
+
367
+ ```ts
368
+ import { createDaikinClient } from "daikin-local-control";
369
+ import { startClassicSimulator } from "daikin-local-control/simulators";
370
+
371
+ const simulator = await startClassicSimulator({
372
+ deviceInfo: { name: "Bedroom", macAddress: "A4:08:EA:CC:91:D4" },
373
+ unit: { coolingOnly: true, swing: "vertical" },
374
+ state: { power: true, targetTemperatureInC: 22 },
375
+ sensors: { indoorTemperatureInC: 27 },
376
+ });
377
+ const client = createDaikinClient(
378
+ { host: simulator.host, protocol: "classic" },
379
+ { terminalUuid: savedUuid },
380
+ );
381
+
382
+ await client.getState(); // power: true, mode: "cool", targetTemperatureInC: 22
383
+
384
+ simulator.setState({ power: false }); // someone uses the remote control
385
+ simulator.setSensors({ indoorTemperatureInC: 24 }); // the room cools down
386
+
387
+ await client.close();
388
+ await simulator.stop();
389
+ ```
390
+
391
+ `startDsiotSimulator()` starts a DSIOT adapter and takes the same options:
392
+ pass `protocol: "dsiot"` to `createDaikinClient`.
393
+ `startAirbaseSimulator()` starts an [Airbase adapter](#airbase-simulator):
394
+ pass `protocol: "airbase"`. A simulator speaks plain HTTP on a free port of
395
+ `127.0.0.1`, which nothing outside the machine reaches.
396
+
397
+ Describe the adapter and its unit in the types the clients resolve with:
398
+
399
+ | Option | Holds | Defaults to |
400
+ | --- | --- | --- |
401
+ | `deviceInfo` | `name`, `macAddress` and `firmwareVersion`, as `getDeviceInfo()` reads them | A name of `Simulator` |
402
+ | `unit` | What the unit has: see below | A heat pump with every fan speed, swing on both axes and all three special modes |
403
+ | `state` | Any fields of a `DaikinState`, `specialModes` included | Off, in cool at 24 °C, on the auto fan speed |
404
+ | `sensors` | Any fields of a `DaikinSensors`; `null` is a reading the unit lacks | 26 °C and 55 % indoors, 30 °C outdoors |
405
+
406
+ | `unit` field | Holds | Defaults to |
407
+ | --- | --- | --- |
408
+ | `coolingOnly` | `true` for a unit that offers dehumidify, cool and fan alone | `false` |
409
+ | `fan` | `false` for a unit whose fan speed cannot be set | `true` |
410
+ | `fanLevels` | 2, 3 or 5 fan levels. Classic simulator only | 5 |
411
+ | `swing` | `none`, `vertical` or `both` | `both` |
412
+ | `specialModes` | Those of `powerful`, `econo` and `streamer` the unit takes | All three |
413
+ | `coolTemperaturesInC`, `heatTemperaturesInC` | `{ min, max }` | 18 to 32 and 10 to 30 |
414
+
415
+ `setState()` and `setSensors()` change a running simulator the same way.
416
+ `setState()` throws an `Error` for a field the unit has nothing to hold, such
417
+ as a `verticalPosition` on a unit without fixed louver positions.
418
+
419
+ #### Airbase simulator
420
+
421
+ `startAirbaseSimulator()` takes `deviceInfo`, `state` and `sensors` as the
422
+ other two do, and a `unit` of its own. It reports no humidity unless
423
+ `sensors` gives one, and its `setState()` throws an `Error` for a
424
+ `targetHumidity`, a louver position or a special mode.
425
+
426
+ ```ts
427
+ import { startAirbaseSimulator } from "daikin-local-control/simulators";
428
+
429
+ const simulator = await startAirbaseSimulator({
430
+ unit: { zones: ["Living", "Bedrooms"], airside: true },
431
+ state: { power: true, fanSpeed: "level3" },
432
+ });
295
433
 
296
- It supports the same markets and Wi-Fi adapters as the DAIKIN Mobile Controller
297
- app, so it is **not valid for every Daikin region or app**.
434
+ simulator.setZone(1, false); // someone closes a zone on the wall controller
435
+ simulator.controlInfo.f_auto = "1"; // and switches fan auto on
436
+ ```
437
+
438
+ | `unit` field | Holds | Defaults to |
439
+ | --- | --- | --- |
440
+ | `zones` | The names of the unit's zones, each open at the start | None: a unit without zones |
441
+ | `auto`, `dehumidify` | `false` for a unit that does not offer the mode | `true` |
442
+ | `fan` | `false` for a unit whose fan speed cannot be set | `true` |
443
+ | `fanLevels` | 2 or 3 fan levels | 3 |
444
+ | `airside` | `true` for a unit with zones that takes airside | `false` |
445
+ | `swing` | `false` for a unit without zones that takes no direction | `true` |
446
+ | `coolTemperaturesInC`, `heatTemperaturesInC` | `{ min, max }`, in whole degrees | None reported, so 18 to 32 and 10 to 30 |
447
+
448
+ A unit with zones takes fan auto, and no `auto` fan speed and no direction.
449
+ It answers HTTP 404 to a path outside `/skyfi`, as an Airbase adapter does,
450
+ so `discoverDevices()` reports it as `airbase` when it is started with
451
+ `answersDiscovery`.
452
+
453
+ | To | Do |
454
+ | --- | --- |
455
+ | Check what the unit holds | Call `getState()` on a client |
456
+ | Check what your code sent | Read `simulator.requests`: each has `method`, `path`, `query`, `headers` and `body` |
457
+ | Require registration (classic) | Pass `key`: the simulator answers HTTP 403 until `registerTerminal()` is given it |
458
+ | Require the child lock code | Pass `localPassword` |
459
+ | Make the adapter slow | Pass `replyDelayInMs`: it answers one request at a time, so a second request waits for the first |
460
+ | Make the adapter unreachable | `await simulator.stop()` |
461
+ | Reach it from another machine | Pass `address`, an address of this machine other than the default `127.0.0.1` |
462
+ | Have `discoverDevices()` find it | Pass `answersDiscovery: true` |
463
+
464
+ Start as many discoverable simulators as you like, in one process or several:
465
+ `discoverDevices()` resolves with one device for each, at `127.0.0.1` and the
466
+ simulator's own port.
467
+
468
+ `discoverDevices()` broadcasts the probe, so it also finds the real adapters
469
+ on the LAN, and it needs a network to broadcast on. Only a program that is
470
+ not a simulator gets in the way: while one holds UDP port 30050, starting a
471
+ simulator with `answersDiscovery` rejects with the socket's error.
472
+
473
+ For anything the options above do not cover, such as humidity settings or
474
+ fixed louver positions, set the adapter's own fields. A classic simulator
475
+ takes `basicInfo`, `modelInfo`, `controlInfo` and `sensorInfo`, the fields of
476
+ each reply, and holds them under the same names. An Airbase simulator takes
477
+ those four and `zoneSetting`. A DSIOT simulator takes `leaves`, and holds each
478
+ leaf by its address. A field given this way replaces
479
+ what `unit` sets, and `state` and `sensors` replace it in turn. The package
480
+ root exports the adapter's codes as tables, such as `CLASSIC_FAN_SPEED_CODES`
481
+ and `DSIOT_PATHS`.
482
+
483
+ ```ts
484
+ // A classic unit with 3D airflow: both louvers, and five fixed positions.
485
+ const classic = await startClassicSimulator({
486
+ modelInfo: { en_fdir2: "1", s_fdir_ud: "81", s_fdir_ud_step: "5", s_fdir_lr: "1" },
487
+ controlInfo: { f_dir_ud: "0", f_dir_lr: "0" },
488
+ });
489
+ classic.setState({ verticalPosition: "position3" });
298
490
 
299
- | Region | Markets using the DAIKIN Mobile Controller app |
300
- | -------------- | -------------------------------------------------------------- |
301
- | Southeast Asia | Thailand, Singapore, the Philippines |
302
- | East Asia | Hong Kong, Taiwan |
303
- | Oceania | Australia, New Zealand |
491
+ // A DSIOT unit that takes a humidity setting in cool.
492
+ const dsiot = await startDsiotSimulator({
493
+ leaves: {
494
+ "/dsiot/edge/adr_0100.dgc_status.e_1002.e_3001.p_0C": { pv: "00", pt: 2 },
495
+ },
496
+ });
497
+ dsiot.setState({ targetHumidity: "auto" });
498
+ ```
499
+
500
+ A simulator answers HTTP 501 to a request it has no answer for, so a client
501
+ rejects with a `DaikinError` whose `httpStatusCode` is 501.
502
+ [UNSUPPORTED.md](UNSUPPORTED.md#simulators) lists what a simulator leaves out,
503
+ and each answer that is not known to be a real adapter's.
504
+ [DESIGN.md](DESIGN.md#simulators) says what a simulator shares with the
505
+ clients' codecs, and why no more.
506
+
507
+ ## Coverage & limitations
304
508
 
305
- The app is published by Daikin Thailand and may be available in other countries
306
- too; if your local Daikin site points you to it, this package should apply. Units
307
- sold for other apps likely use a different protocol, e.g. Onecta / Daikin Online
308
- Controller (Europe), GO DAIKIN (Malaysia) or Daikin Comfort Control / Daikin One
309
- (North America).
509
+ It supports the Wi-Fi adapters of three apps, in the markets where each is
510
+ used, so it is **not valid for every Daikin region or app**.
511
+
512
+ | App | Markets | Adapters | Client |
513
+ | --- | --- | --- | --- |
514
+ | DAIKIN Mobile Controller | Thailand, Singapore, the Philippines, Hong Kong, Taiwan, Australia, New Zealand | BRP072A42, BRP072C42, BRP069 | `ClassicDaikinClient` |
515
+ | DAIKIN Mobile Controller | The same | BRP084 | `DsiotDaikinClient` |
516
+ | Daikin Airbase | Australia | BRP15B61, on a ducted unit | `AirbaseDaikinClient` |
517
+ | ONECTA, once Daikin Online Controller | Europe | BRP069A and BRP069B series | `ClassicDaikinClient` |
518
+
519
+ Each app may be available in more countries than the table lists: if your
520
+ local Daikin site points you to one of the three, this package should apply.
521
+
522
+ Three limits apply, and [UNSUPPORTED.md](UNSUPPORTED.md) says what is not known
523
+ about each:
524
+
525
+ - **Europe**: power, mode, setpoint, fan, swing and special modes go to a
526
+ classic adapter there as they do elsewhere. `getCapabilities()` follows the
527
+ rules of the other markets, and which adapters there speak HTTPS is not
528
+ known.
529
+ - **A DSIOT adapter in Europe** (BRP069C series) is not covered: it may not
530
+ answer on the LAN until it is switched on.
531
+ - **An Airbase adapter** is told from a classic one by how it answers over
532
+ HTTP: no field of its discovery reply is known to differ.
533
+
534
+ Units sold for other apps likely use a different protocol, e.g. GO DAIKIN
535
+ (Malaysia) or Daikin Comfort Control / Daikin One (North America).
310
536
 
311
537
  This package is still young, so expect some rough edges. If something doesn't
312
538
  work with your unit, please