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
@@ -0,0 +1,9 @@
1
+ export * from "./classic.js";
2
+ export * from "./dsiot.js";
3
+ export type {
4
+ DaikinSimulator,
5
+ DaikinSimulatorRequest,
6
+ DaikinSimulatorTemperatures,
7
+ DaikinSimulatorUnit,
8
+ StartSimulatorOptions,
9
+ } from "./simulator.js";
@@ -0,0 +1,281 @@
1
+ import { createSocket, type Socket } from "node:dgram";
2
+ import { once } from "node:events";
3
+ import { createServer, type IncomingHttpHeaders } from "node:http";
4
+ import type { AddressInfo } from "node:net";
5
+ import type { DaikinDeviceInfo } from "../models/device.js";
6
+ import type {
7
+ DaikinSensors,
8
+ DaikinState,
9
+ SpecialMode,
10
+ } from "../models/state.js";
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.
14
+
15
+ const DISCOVERY_PROBE = "DAIKIN_UDP/common/basic_info";
16
+ const DISCOVERY_PORT = 30050;
17
+
18
+ const DEFAULT_ADDRESS = "127.0.0.1";
19
+
20
+ /** One HTTP request a simulator received. */
21
+ export interface DaikinSimulatorRequest {
22
+ method: string;
23
+ /** From the leading `/`, without the query string. */
24
+ path: string;
25
+ /** Percent-decoded. */
26
+ query: Record<string, string>;
27
+ headers: IncomingHttpHeaders;
28
+ body: string;
29
+ }
30
+
31
+ /** What `startClassicSimulator` and `startDsiotSimulator` both resolve with. */
32
+ export interface DaikinSimulator {
33
+ /** `<address>:<port>`, as `DaikinDevice.host` takes it: `127.0.0.1`, unless
34
+ * the simulator was started at another `address`, and a port that was free
35
+ * when it started. */
36
+ readonly host: string;
37
+ /** Every request received, oldest first, the ones the simulator refused
38
+ * included. A request is added once its whole body has arrived, before it
39
+ * waits for its turn to be answered. */
40
+ readonly requests: DaikinSimulatorRequest[];
41
+ /** Changes the unit as its remote control would. A field left out, or
42
+ * `null`, stays as the unit holds it, and a new `mode` takes up what the
43
+ * unit remembers for that mode before the other fields are applied. It
44
+ * throws an `Error` for a field the unit has nothing to hold, such as a
45
+ * `verticalPosition` on a unit without fixed louver positions, and holds
46
+ * any other value as given, whether or not `getCapabilities` lists it. */
47
+ setState(state: Partial<DaikinState>): void;
48
+ /** Changes what the unit's sensors read. A reading left out stays as it is,
49
+ * and `null` is a reading the unit does not report. */
50
+ setSensors(sensors: Partial<DaikinSensors>): void;
51
+ /** Closes every connection and stops listening. A request still waiting is
52
+ * never answered. */
53
+ stop(): Promise<void>;
54
+ }
55
+
56
+ /** In °C. */
57
+ export interface DaikinSimulatorTemperatures {
58
+ min: number;
59
+ max: number;
60
+ }
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. */
65
+ export interface DaikinSimulatorUnit {
66
+ /** A unit that only cools offers dehumidify, cool and fan. Defaults to
67
+ * false: a heat pump, which offers auto and heat as well. */
68
+ coolingOnly?: boolean;
69
+ /** Whether the fan speed can be set. Defaults to true. */
70
+ fan?: boolean;
71
+ /** The axes the louvers swing on: `vertical` moves the air up and down
72
+ * alone, and `both` left and right as well. Defaults to `both`. */
73
+ swing?: "none" | "vertical" | "both";
74
+ /** The special modes the unit takes. Defaults to all three. */
75
+ specialModes?: readonly SpecialMode[];
76
+ /** The setpoints cool takes. A classic adapter reports whole degrees.
77
+ * Defaults to 18 to 32 on a DSIOT simulator, and to none reported on a
78
+ * classic one. */
79
+ coolTemperaturesInC?: DaikinSimulatorTemperatures;
80
+ /** The setpoints heat takes. A classic adapter reports whole degrees.
81
+ * Defaults to 10 to 30 on a DSIOT simulator, and to none reported on a
82
+ * classic one. */
83
+ heatTemperaturesInC?: DaikinSimulatorTemperatures;
84
+ }
85
+
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. */
90
+ export interface StartSimulatorOptions {
91
+ /** The adapter's name, MAC address and firmware version, as
92
+ * `getDeviceInfo` reads them back. A field left out keeps its default.
93
+ * Defaults to a name of `Simulator`. */
94
+ deviceInfo?: DaikinDeviceInfo;
95
+ /** The state the unit starts in: see `DaikinSimulator.setState`. Defaults
96
+ * to off, in cool at 24 °C on the auto fan speed, with nothing swinging
97
+ * and no special mode on. */
98
+ state?: Partial<DaikinState>;
99
+ /** What the unit's sensors read at the start: see
100
+ * `DaikinSimulator.setSensors`. Defaults to 26 °C and 55 % indoors, and
101
+ * 30 °C outdoors. */
102
+ sensors?: Partial<DaikinSensors>;
103
+ /** How long, in milliseconds, the adapter takes to answer a request. It
104
+ * answers one request at a time, in the order they arrive, so a request
105
+ * waits this long for each one ahead of it as well. Defaults to 0. */
106
+ replyDelayInMs?: number;
107
+ /** The address of this machine the adapter is at: it listens for HTTP
108
+ * there, and answers a discovery probe from there. Defaults to `127.0.0.1`,
109
+ * which nothing outside the machine reaches. */
110
+ address?: string;
111
+ /** Whether the adapter answers the discovery probe, so `discoverDevices`
112
+ * finds it at `host`. Defaults to false. Any number of simulators answer
113
+ * at once, in one process or several, and `discoverDevices` finds each at
114
+ * its own port. Starting one rejects with the socket's error only while a
115
+ * program that is not a simulator holds UDP port 30050. */
116
+ answersDiscovery?: boolean;
117
+ }
118
+
119
+ /** What `startSimulator` resolves with: the part of a `DaikinSimulator` that
120
+ * is the same for both protocols. */
121
+ export type SimulatorServer = Pick<
122
+ DaikinSimulator,
123
+ "host" | "requests" | "stop"
124
+ >;
125
+
126
+ export interface SimulatorReply {
127
+ statusCode: number;
128
+ body: string;
129
+ }
130
+
131
+ /** The reply to a request whose answer from an adapter is not known: HTTP 501,
132
+ * which no adapter is known to send, so a caller cannot take it for one. */
133
+ export function unanswered(request: DaikinSimulatorRequest): SimulatorReply {
134
+ return {
135
+ statusCode: 501,
136
+ body: `The simulator does not answer ${request.method} ${request.path}`,
137
+ };
138
+ }
139
+
140
+ /** Joins fields as a classic reply and a discovery reply carry them:
141
+ * `key=value` pairs separated by commas, `ret` first. */
142
+ export function formatKeyValue(
143
+ ret: string,
144
+ fields: Record<string, string> = {},
145
+ ): string {
146
+ return Object.entries({ ret, ...fields })
147
+ .map(([name, value]) => `${name}=${value}`)
148
+ .join(",");
149
+ }
150
+
151
+ /** Listens on a free port of `options.address` and answers each request with
152
+ * what `answer` returns for it, one request at a time. With
153
+ * `options.answersDiscovery`, it answers the discovery probe with
154
+ * `discoveryFields()` and the HTTP port, as `api_port`. */
155
+ export async function startSimulator(
156
+ options: StartSimulatorOptions,
157
+ answer: (request: DaikinSimulatorRequest) => SimulatorReply,
158
+ discoveryFields: () => Record<string, string>,
159
+ ): Promise<SimulatorServer> {
160
+ const requests: DaikinSimulatorRequest[] = [];
161
+ let stopped = false;
162
+ let turn = Promise.resolve();
163
+
164
+ const server = createServer((incoming, response) => {
165
+ let body = "";
166
+ incoming.setEncoding("utf8");
167
+ incoming.on("data", (chunk: string) => {
168
+ body += chunk;
169
+ });
170
+ incoming.on("end", () => {
171
+ const url = new URL(incoming.url ?? "/", "http://simulator");
172
+ const request: DaikinSimulatorRequest = {
173
+ method: incoming.method ?? "",
174
+ path: url.pathname,
175
+ query: Object.fromEntries(url.searchParams),
176
+ headers: incoming.headers,
177
+ body,
178
+ };
179
+ requests.push(request);
180
+ turn = turn.then(async () => {
181
+ if (options.replyDelayInMs) {
182
+ await sleep(options.replyDelayInMs);
183
+ }
184
+ if (stopped) {
185
+ return;
186
+ }
187
+ const reply = answer(request);
188
+ response.statusCode = reply.statusCode;
189
+ response.end(reply.body);
190
+ });
191
+ });
192
+ });
193
+ const address = options.address ?? DEFAULT_ADDRESS;
194
+ server.listen(0, address);
195
+ // `once` rejects when the server emits `error` first, as it does for an
196
+ // address the machine does not have.
197
+ await once(server, "listening");
198
+ const { port } = server.address() as AddressInfo;
199
+
200
+ const stopServer = async () => {
201
+ // A client keeps its connection open between requests, and `close` alone
202
+ // waits for it.
203
+ server.closeAllConnections();
204
+ server.close();
205
+ await once(server, "close");
206
+ };
207
+
208
+ let discoverySockets: Socket[] = [];
209
+ if (options.answersDiscovery) {
210
+ try {
211
+ discoverySockets = await startDiscoverySockets(address, () =>
212
+ formatKeyValue("OK", {
213
+ ...discoveryFields(),
214
+ api_port: String(port),
215
+ }),
216
+ );
217
+ } catch (error) {
218
+ await stopServer();
219
+ throw error;
220
+ }
221
+ }
222
+
223
+ return {
224
+ host: `${address}:${port}`,
225
+ requests,
226
+ async stop() {
227
+ // A server closed a second time never emits `close`.
228
+ if (stopped) {
229
+ return;
230
+ }
231
+ stopped = true;
232
+ await Promise.all(discoverySockets.map(closeSocket));
233
+ await stopServer();
234
+ },
235
+ };
236
+ }
237
+
238
+ // The probe is a broadcast, which reaches a socket bound to every address and
239
+ // none bound to one. So each simulator hears it on a socket bound to every
240
+ // address, sharing the port with the other simulators, and answers from a
241
+ // second socket bound to its own address: the reply then comes from that
242
+ // address, and a probe sent to that address alone reaches that simulator
243
+ // alone. An adapter answers the address and port the probe came from.
244
+ async function startDiscoverySockets(
245
+ address: string,
246
+ reply: () => string,
247
+ ): Promise<Socket[]> {
248
+ const own = createSocket({ type: "udp4", reuseAddr: true });
249
+ const every = createSocket({ type: "udp4", reuseAddr: true });
250
+ const sockets = [own, every];
251
+ for (const socket of sockets) {
252
+ socket.on("message", (message, remote) => {
253
+ if (message.toString("utf8") === DISCOVERY_PROBE) {
254
+ // Without a callback, a reply that fails to send is emitted as an
255
+ // `error` nothing listens for.
256
+ own.send(reply(), remote.port, remote.address, () => {});
257
+ }
258
+ });
259
+ }
260
+ try {
261
+ own.bind(DISCOVERY_PORT, address);
262
+ // `once` rejects when the socket emits `error` first, as it does for a
263
+ // port another program holds.
264
+ await once(own, "listening");
265
+ every.bind(DISCOVERY_PORT);
266
+ await once(every, "listening");
267
+ } catch (error) {
268
+ await Promise.all(sockets.map(closeSocket));
269
+ throw error;
270
+ }
271
+ return sockets;
272
+ }
273
+
274
+ async function closeSocket(socket: Socket): Promise<void> {
275
+ socket.close();
276
+ await once(socket, "close");
277
+ }
278
+
279
+ function sleep(delayInMs: number): Promise<void> {
280
+ return new Promise((resolve) => setTimeout(resolve, delayInMs));
281
+ }
package/src/transport.ts CHANGED
@@ -71,7 +71,8 @@ export interface DaikinTransportOptions {
71
71
  host: string;
72
72
  /** HTTPS (a Marvell adapter in secure mode). */
73
73
  usesHttps?: boolean;
74
- /** The terminal uuid, sent as `X-Daikin-uuid` on every call. */
74
+ /** The terminal uuid, sent as `X-Daikin-uuid` on every call: 32 lowercase
75
+ * hexadecimal digits, a UUID without its dashes. */
75
76
  terminalUuid: string;
76
77
  authentication?: DaikinAuthentication;
77
78
  /** How long, in milliseconds, to wait for each of the connection, the
@@ -102,9 +103,15 @@ export interface DaikinTlsOptions {
102
103
  export class DaikinTransport {
103
104
  private readonly origin: string;
104
105
  private readonly client: Client;
106
+ private readonly terminalUuidHeaders: Record<string, string>;
105
107
 
106
- /** Call `close` when done with the adapter. */
108
+ /** Call `close` when done with the adapter. It throws an `Error` when
109
+ * `options.terminalUuid` is not in the form
110
+ * `DaikinTransportOptions.terminalUuid` describes. */
107
111
  constructor(private readonly options: DaikinTransportOptions) {
112
+ // Before the Client, so a uuid that throws leaves no connection open.
113
+ this.terminalUuidHeaders = encodeTerminalUuid(options.terminalUuid);
114
+
108
115
  const scheme = options.usesHttps ? "https" : "http";
109
116
  this.origin = `${scheme}://${options.host.replace(/\/+$/, "")}`;
110
117
 
@@ -137,7 +144,7 @@ export class DaikinTransport {
137
144
  .join("&");
138
145
 
139
146
  const headers: Record<string, string> = {
140
- ...encodeTerminalUuid(this.options.terminalUuid),
147
+ ...this.terminalUuidHeaders,
141
148
  ...((await this.options.authentication?.headers()) ?? {}),
142
149
  ...(request.jsonBody !== undefined ? JSON_CONTENT_HEADERS : {}),
143
150
  };