daikin-local-control 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/DESIGN.md +98 -0
  2. package/LICENSE +674 -0
  3. package/README.md +317 -0
  4. package/UNSUPPORTED.md +43 -0
  5. package/dist/authentication.d.ts +24 -0
  6. package/dist/authentication.d.ts.map +1 -0
  7. package/dist/authentication.js +30 -0
  8. package/dist/authentication.js.map +1 -0
  9. package/dist/clients/classic.d.ts +53 -0
  10. package/dist/clients/classic.d.ts.map +1 -0
  11. package/dist/clients/classic.js +142 -0
  12. package/dist/clients/classic.js.map +1 -0
  13. package/dist/clients/client.d.ts +21 -0
  14. package/dist/clients/client.d.ts.map +1 -0
  15. package/dist/clients/client.js +2 -0
  16. package/dist/clients/client.js.map +1 -0
  17. package/dist/clients/dsiot.d.ts +28 -0
  18. package/dist/clients/dsiot.d.ts.map +1 -0
  19. package/dist/clients/dsiot.js +66 -0
  20. package/dist/clients/dsiot.js.map +1 -0
  21. package/dist/codecs/classic/capabilities.d.ts +19 -0
  22. package/dist/codecs/classic/capabilities.d.ts.map +1 -0
  23. package/dist/codecs/classic/capabilities.js +208 -0
  24. package/dist/codecs/classic/capabilities.js.map +1 -0
  25. package/dist/codecs/classic/extendedControl.d.ts +20 -0
  26. package/dist/codecs/classic/extendedControl.d.ts.map +1 -0
  27. package/dist/codecs/classic/extendedControl.js +88 -0
  28. package/dist/codecs/classic/extendedControl.js.map +1 -0
  29. package/dist/codecs/classic/keyValue.d.ts +10 -0
  30. package/dist/codecs/classic/keyValue.d.ts.map +1 -0
  31. package/dist/codecs/classic/keyValue.js +36 -0
  32. package/dist/codecs/classic/keyValue.js.map +1 -0
  33. package/dist/codecs/classic/state.d.ts +18 -0
  34. package/dist/codecs/classic/state.d.ts.map +1 -0
  35. package/dist/codecs/classic/state.js +235 -0
  36. package/dist/codecs/classic/state.js.map +1 -0
  37. package/dist/codecs/credentials.d.ts +8 -0
  38. package/dist/codecs/credentials.d.ts.map +1 -0
  39. package/dist/codecs/credentials.js +14 -0
  40. package/dist/codecs/credentials.js.map +1 -0
  41. package/dist/codecs/device.d.ts +6 -0
  42. package/dist/codecs/device.d.ts.map +1 -0
  43. package/dist/codecs/device.js +35 -0
  44. package/dist/codecs/device.js.map +1 -0
  45. package/dist/codecs/dsiot/capabilities.d.ts +5 -0
  46. package/dist/codecs/dsiot/capabilities.d.ts.map +1 -0
  47. package/dist/codecs/dsiot/capabilities.js +139 -0
  48. package/dist/codecs/dsiot/capabilities.js.map +1 -0
  49. package/dist/codecs/dsiot/multireq.d.ts +37 -0
  50. package/dist/codecs/dsiot/multireq.d.ts.map +1 -0
  51. package/dist/codecs/dsiot/multireq.js +122 -0
  52. package/dist/codecs/dsiot/multireq.js.map +1 -0
  53. package/dist/codecs/dsiot/state.d.ts +92 -0
  54. package/dist/codecs/dsiot/state.d.ts.map +1 -0
  55. package/dist/codecs/dsiot/state.js +461 -0
  56. package/dist/codecs/dsiot/state.js.map +1 -0
  57. package/dist/discovery.d.ts +17 -0
  58. package/dist/discovery.d.ts.map +1 -0
  59. package/dist/discovery.js +67 -0
  60. package/dist/discovery.js.map +1 -0
  61. package/dist/error.d.ts +15 -0
  62. package/dist/error.d.ts.map +1 -0
  63. package/dist/error.js +20 -0
  64. package/dist/error.js.map +1 -0
  65. package/dist/factory.d.ts +26 -0
  66. package/dist/factory.d.ts.map +1 -0
  67. package/dist/factory.js +31 -0
  68. package/dist/factory.js.map +1 -0
  69. package/dist/index.d.ts +22 -0
  70. package/dist/index.d.ts.map +1 -0
  71. package/dist/index.js +21 -0
  72. package/dist/index.js.map +1 -0
  73. package/dist/models/capabilities.d.ts +42 -0
  74. package/dist/models/capabilities.d.ts.map +1 -0
  75. package/dist/models/capabilities.js +2 -0
  76. package/dist/models/capabilities.js.map +1 -0
  77. package/dist/models/device.d.ts +24 -0
  78. package/dist/models/device.d.ts.map +1 -0
  79. package/dist/models/device.js +2 -0
  80. package/dist/models/device.js.map +1 -0
  81. package/dist/models/extendedControl.d.ts +31 -0
  82. package/dist/models/extendedControl.d.ts.map +1 -0
  83. package/dist/models/extendedControl.js +7 -0
  84. package/dist/models/extendedControl.js.map +1 -0
  85. package/dist/models/state.d.ts +56 -0
  86. package/dist/models/state.d.ts.map +1 -0
  87. package/dist/models/state.js +49 -0
  88. package/dist/models/state.js.map +1 -0
  89. package/dist/transport.d.ts +75 -0
  90. package/dist/transport.d.ts.map +1 -0
  91. package/dist/transport.js +118 -0
  92. package/dist/transport.js.map +1 -0
  93. package/package.json +62 -0
  94. package/src/authentication.ts +53 -0
  95. package/src/clients/classic.ts +251 -0
  96. package/src/clients/client.ts +29 -0
  97. package/src/clients/dsiot.ts +116 -0
  98. package/src/codecs/classic/capabilities.ts +272 -0
  99. package/src/codecs/classic/extendedControl.ts +132 -0
  100. package/src/codecs/classic/keyValue.ts +39 -0
  101. package/src/codecs/classic/state.ts +302 -0
  102. package/src/codecs/credentials.ts +19 -0
  103. package/src/codecs/device.ts +41 -0
  104. package/src/codecs/dsiot/capabilities.ts +209 -0
  105. package/src/codecs/dsiot/multireq.ts +162 -0
  106. package/src/codecs/dsiot/state.ts +644 -0
  107. package/src/discovery.ts +85 -0
  108. package/src/error.ts +20 -0
  109. package/src/factory.ts +66 -0
  110. package/src/index.ts +21 -0
  111. package/src/models/capabilities.ts +52 -0
  112. package/src/models/device.ts +25 -0
  113. package/src/models/extendedControl.ts +41 -0
  114. package/src/models/state.ts +110 -0
  115. package/src/transport.ts +191 -0
@@ -0,0 +1,85 @@
1
+ import { createSocket } from "node:dgram";
2
+ import {
3
+ isClassicOk,
4
+ parseClassicKeyValue,
5
+ } from "./codecs/classic/keyValue.js";
6
+ import { parseDevice } from "./codecs/device.js";
7
+ import type { DaikinDevice } from "./models/device.js";
8
+
9
+ // The UDP discovery probe: broadcast this to :30050, listen for `basic_info`
10
+ // replies.
11
+ const DISCOVERY_PROBE = "DAIKIN_UDP/common/basic_info";
12
+ const DISCOVERY_SEND_PORT = 30000;
13
+ const DISCOVERY_LISTEN_PORT = 30050;
14
+
15
+ const DEFAULT_DISCOVERY_TIMEOUT_IN_MS = 3_000;
16
+ const BROADCAST_ADDRESS = "255.255.255.255";
17
+ // UDP drops packets, so the probe goes out 10 times, 200 ms apart.
18
+ const PROBE_COUNT = 10;
19
+ const PROBE_INTERVAL_IN_MS = 200;
20
+
21
+ export interface DiscoverDevicesOptions {
22
+ /** How long to listen for replies, in milliseconds. Defaults to 3000. */
23
+ timeoutInMs?: number;
24
+ }
25
+
26
+ /** Finds the adapters on the LAN: broadcasts `DAIKIN_UDP/common/basic_info` to
27
+ * UDP port 30050 and collects the `basic_info` replies, which carry the same
28
+ * `key=value` payload as the HTTP `/common/basic_info`. Classic and DSIOT
29
+ * adapters both answer, and `parseDevice` picks the protocol from the reply.
30
+ * Anything on the LAN can send to the port it listens on, so it ignores a
31
+ * datagram whose `ret` is not `OK`.
32
+ *
33
+ * It resolves once `timeoutInMs` has passed, never earlier, with one device
34
+ * per replying address. It binds UDP port 30000, and rejects with the socket's
35
+ * error when the socket fails. */
36
+ export async function discoverDevices(
37
+ options: DiscoverDevicesOptions = {},
38
+ ): Promise<DaikinDevice[]> {
39
+ const socket = createSocket({ type: "udp4", reuseAddr: true });
40
+ const devices = new Map<string, DaikinDevice>();
41
+
42
+ return new Promise((resolve, reject) => {
43
+ let probesSent = 0;
44
+ let probeTimer: NodeJS.Timeout | undefined;
45
+
46
+ const listenTimer = setTimeout(() => {
47
+ clearInterval(probeTimer);
48
+ socket.close();
49
+ resolve([...devices.values()]);
50
+ }, options.timeoutInMs ?? DEFAULT_DISCOVERY_TIMEOUT_IN_MS);
51
+
52
+ socket.on("error", (error) => {
53
+ clearInterval(probeTimer);
54
+ // A socket throws when it is closed twice, so the timer must not fire.
55
+ clearTimeout(listenTimer);
56
+ socket.close();
57
+ reject(error);
58
+ });
59
+
60
+ socket.on("message", (message, remote) => {
61
+ if (devices.has(remote.address)) {
62
+ return;
63
+ }
64
+ const fields = parseClassicKeyValue(message.toString("utf8"));
65
+ if (!isClassicOk(fields)) {
66
+ return;
67
+ }
68
+ devices.set(remote.address, parseDevice(remote.address, fields));
69
+ });
70
+
71
+ socket.bind(DISCOVERY_SEND_PORT, () => {
72
+ socket.setBroadcast(true);
73
+ const probe = Buffer.from(DISCOVERY_PROBE, "utf8");
74
+ const sendProbe = () => {
75
+ socket.send(probe, DISCOVERY_LISTEN_PORT, BROADCAST_ADDRESS);
76
+ probesSent += 1;
77
+ if (probesSent >= PROBE_COUNT) {
78
+ clearInterval(probeTimer);
79
+ }
80
+ };
81
+ sendProbe();
82
+ probeTimer = setInterval(sendProbe, PROBE_INTERVAL_IN_MS);
83
+ });
84
+ });
85
+ }
package/src/error.ts ADDED
@@ -0,0 +1,20 @@
1
+ export class DaikinError extends Error {
2
+ constructor(
3
+ message: string,
4
+ /** The adapter's own HTTP status, or the status this package gives a
5
+ * failure that has none: the README's "Errors and retries" lists them. */
6
+ readonly httpStatusCode: number,
7
+ ) {
8
+ super(message);
9
+ this.name = "DaikinError";
10
+ }
11
+ }
12
+
13
+ /** The adapter refused or dropped the connection before answering. The only
14
+ * failure `withRetry` repeats: see DESIGN.md#retries. */
15
+ export class DaikinConnectionError extends DaikinError {
16
+ constructor(message: string) {
17
+ super(message, 503);
18
+ this.name = "DaikinConnectionError";
19
+ }
20
+ }
package/src/factory.ts ADDED
@@ -0,0 +1,66 @@
1
+ import {
2
+ ClassicLocalPasswordAuthentication,
3
+ type DaikinAuthentication,
4
+ DsiotLocalPasswordAuthentication,
5
+ } from "./authentication.js";
6
+ import { ClassicDaikinClient } from "./clients/classic.js";
7
+ import type { DaikinClient } from "./clients/client.js";
8
+ import { DsiotDaikinClient } from "./clients/dsiot.js";
9
+ import type { DaikinDevice } from "./models/device.js";
10
+ import {
11
+ type DaikinRetryOptions,
12
+ type DaikinTlsOptions,
13
+ DaikinTransport,
14
+ } from "./transport.js";
15
+
16
+ export interface CreateDaikinClientOptions {
17
+ /** The terminal uuid, sent as `X-Daikin-uuid`. Generate one and reuse it:
18
+ * an adapter that requires registration remembers it. */
19
+ terminalUuid: string;
20
+ /** The child lock code, for a device whose `requiresLocalPassword` is true. */
21
+ localPassword?: string;
22
+ /** How long, in milliseconds, a request waits for the adapter. Defaults to
23
+ * 10000: see `DaikinTransportOptions.timeoutInMs`. */
24
+ timeoutInMs?: number;
25
+ /** The waits between retries of a refused or dropped connection. Defaults to
26
+ * waits of 0, 50, 100, 200 and 400 milliseconds: see
27
+ * `DaikinRetryOptions.delaysInMs`. */
28
+ retry?: DaikinRetryOptions;
29
+ /** HTTPS adapters only. Without a CA the adapter's certificate isn't verified. */
30
+ tls?: DaikinTlsOptions;
31
+ }
32
+
33
+ /** Builds the client for a device: a `DsiotDaikinClient` when
34
+ * `device.protocol` is `dsiot`, a `ClassicDaikinClient` otherwise. The client
35
+ * sends over HTTPS when `device.usesHttps` is set, and sends the local
36
+ * password when `options.localPassword` is given. Call `close` on the client
37
+ * when done with the unit. */
38
+ export function createDaikinClient(
39
+ device: DaikinDevice,
40
+ options: CreateDaikinClientOptions,
41
+ ): DaikinClient {
42
+ const transport = new DaikinTransport({
43
+ host: device.host,
44
+ usesHttps: device.usesHttps,
45
+ terminalUuid: options.terminalUuid,
46
+ authentication: localPasswordAuthentication(device, options.localPassword),
47
+ timeoutInMs: options.timeoutInMs,
48
+ tls: options.tls,
49
+ });
50
+
51
+ return device.protocol === "dsiot"
52
+ ? new DsiotDaikinClient(transport, options.retry)
53
+ : new ClassicDaikinClient(transport, options.retry);
54
+ }
55
+
56
+ function localPasswordAuthentication(
57
+ device: DaikinDevice,
58
+ localPassword: string | undefined,
59
+ ): DaikinAuthentication | undefined {
60
+ if (localPassword === undefined) {
61
+ return undefined;
62
+ }
63
+ return device.protocol === "dsiot"
64
+ ? new DsiotLocalPasswordAuthentication(localPassword)
65
+ : new ClassicLocalPasswordAuthentication(localPassword);
66
+ }
package/src/index.ts ADDED
@@ -0,0 +1,21 @@
1
+ export type { DaikinAuthentication } from "./authentication.js";
2
+ export * from "./clients/classic.js";
3
+ export * from "./clients/client.js";
4
+ export * from "./clients/dsiot.js";
5
+ export * from "./codecs/classic/capabilities.js";
6
+ export * from "./codecs/classic/extendedControl.js";
7
+ export * from "./codecs/classic/keyValue.js";
8
+ export * from "./codecs/classic/state.js";
9
+ export * from "./codecs/credentials.js";
10
+ export * from "./codecs/device.js";
11
+ export * from "./codecs/dsiot/capabilities.js";
12
+ export * from "./codecs/dsiot/multireq.js";
13
+ export * from "./codecs/dsiot/state.js";
14
+ export * from "./discovery.js";
15
+ export * from "./error.js";
16
+ export * from "./factory.js";
17
+ export * from "./models/capabilities.js";
18
+ export * from "./models/device.js";
19
+ export * from "./models/extendedControl.js";
20
+ export * from "./models/state.js";
21
+ export * from "./transport.js";
@@ -0,0 +1,52 @@
1
+ import type {
2
+ FanSpeed,
3
+ HorizontalPosition,
4
+ OperatingMode,
5
+ SwingMode,
6
+ TargetHumidity,
7
+ VerticalPosition,
8
+ } from "./state.js";
9
+
10
+ /** In °C. */
11
+ export interface TemperatureRange {
12
+ min: number;
13
+ max: number;
14
+ step: number;
15
+ }
16
+
17
+ /** What a specific unit accepts. Each control field is named for the
18
+ * `DaikinState` field it limits and lists values in that field's own
19
+ * vocabulary, so a caller can offer `capabilities.fanSpeeds` and send any of
20
+ * them back as `fanSpeed`. */
21
+ export interface DaikinCapabilities {
22
+ /** Operating modes the unit offers. */
23
+ modes: readonly OperatingMode[];
24
+ /** Fan speeds the unit accepts; empty when the fan is not controllable.
25
+ * Dehumidify runs the fan on `auto` only, and so does cool on a DSIOT
26
+ * adapter while a humidity setting is on. */
27
+ fanSpeeds: readonly FanSpeed[];
28
+ /** Swing settings the unit accepts; empty when airflow is not controllable. */
29
+ swingModes: readonly SwingMode[];
30
+ /** Vertical louver positions the unit accepts; empty unless it is a 3D
31
+ * unit. */
32
+ verticalPositions: readonly VerticalPosition[];
33
+ /** Horizontal louver positions the unit accepts; empty unless it is a 3D
34
+ * unit. */
35
+ horizontalPositions: readonly HorizontalPosition[];
36
+ /** Setpoint range per mode; `null` where the mode has no setpoint or the unit
37
+ * does not offer the mode. */
38
+ targetTemperatureInC: Readonly<
39
+ Record<OperatingMode, TemperatureRange | null>
40
+ >;
41
+ /** Humidity settings per mode; empty where the mode has none or the unit does
42
+ * not offer it. A percentage is listed for no unit: see UNSUPPORTED.md. */
43
+ targetHumidity: Readonly<Record<OperatingMode, readonly TargetHumidity[]>>;
44
+ /** Whether the unit has the special air outlet, which heat can swing
45
+ * through. That swing is not modelled: see UNSUPPORTED.md. */
46
+ specialOutlet: boolean;
47
+ energyMonitoring: boolean;
48
+ demandControl: boolean;
49
+ scheduleTimer: boolean;
50
+ /** The adapter's own code for the unit's model: see UNSUPPORTED.md. */
51
+ model?: string;
52
+ }
@@ -0,0 +1,25 @@
1
+ export const DAIKIN_PROTOCOLS = ["classic", "dsiot"] as const;
2
+
3
+ /** The wire protocol an adapter speaks, and the only difference between
4
+ * adapters that takes a separate `DaikinClient` implementation: `classic` for
5
+ * a CGI adapter such as the BRP069 or BRP072, `dsiot` for a BRP084. */
6
+ export type DaikinProtocol = (typeof DAIKIN_PROTOCOLS)[number];
7
+
8
+ /** An adapter on the LAN, as `discoverDevices` finds it or as a caller writes
9
+ * it by hand, carrying what `createDaikinClient` needs to reach it. A flag left
10
+ * out means `false`, so a device written by hand needs only `host` and
11
+ * `protocol`. */
12
+ export interface DaikinDevice {
13
+ /** Address, with `:port` when the adapter announces one. */
14
+ host: string;
15
+ protocol: DaikinProtocol;
16
+ name?: string;
17
+ /** The adapter answers a terminal uuid only once it is registered with the
18
+ * KEY on the adapter's label (`registerTerminal`). Classic only. */
19
+ requiresRegistration?: boolean;
20
+ /** The adapter speaks HTTPS. An adapter can require registration and still
21
+ * speak plain HTTP. */
22
+ usesHttps?: boolean;
23
+ /** The adapter requires the local password: the child lock code. */
24
+ requiresLocalPassword?: boolean;
25
+ }
@@ -0,0 +1,41 @@
1
+ // Inputs to the controls outside `DaikinState`, which only a classic adapter
2
+ // takes.
3
+
4
+ export const TERMINAL_SCOPES = ["self", "others", "all"] as const;
5
+
6
+ /** Which registered terminals to unregister: this uuid, every other uuid, or
7
+ * all of them. */
8
+ export type TerminalScope = (typeof TERMINAL_SCOPES)[number];
9
+
10
+ /** The adapter's third mode, schedule, is not offered: see UNSUPPORTED.md. */
11
+ export const DEMAND_MODES = ["manual", "auto"] as const;
12
+
13
+ /** Who picks the cap: `manual` holds the unit to `maxPowerInPercent`, and `auto`
14
+ * lets the unit choose, which it does in cool and heat only. */
15
+ export type DemandMode = (typeof DEMAND_MODES)[number];
16
+
17
+ export interface DemandControl {
18
+ enabled: boolean;
19
+ /** The unit caps its power draw at this percent. A unit accepts 40, 50 and
20
+ * 70 in `manual` and 85 in `auto`; what else it accepts is not known. */
21
+ maxPowerInPercent: number;
22
+ mode: DemandMode;
23
+ }
24
+
25
+ export const SPECIAL_MODES = ["powerful", "econo", "streamer"] as const;
26
+
27
+ export type SpecialMode = (typeof SPECIAL_MODES)[number];
28
+
29
+ export interface DeviceGroup {
30
+ name: string;
31
+ /** Whether the unit is in the group. */
32
+ enabled: boolean;
33
+ }
34
+
35
+ /** A field left out is not written. */
36
+ export interface DeviceMetadata {
37
+ name?: string;
38
+ /** The adapter's own code for the icon: see UNSUPPORTED.md. */
39
+ icon?: string;
40
+ group?: DeviceGroup;
41
+ }
@@ -0,0 +1,110 @@
1
+ // The unit's controllable state, in the package's own terms. The
2
+ // codecs map it to and from each protocol's wire encoding.
3
+
4
+ export const OPERATING_MODES = [
5
+ "auto",
6
+ "dehumidify",
7
+ "cool",
8
+ "heat",
9
+ "fan",
10
+ ] as const;
11
+
12
+ export type OperatingMode = (typeof OPERATING_MODES)[number];
13
+
14
+ export const FAN_SPEEDS = [
15
+ "auto",
16
+ "silent",
17
+ "level1",
18
+ "level2",
19
+ "level3",
20
+ "level4",
21
+ "level5",
22
+ ] as const;
23
+
24
+ export type FanSpeed = (typeof FAN_SPEEDS)[number];
25
+
26
+ export const SWING_MODES = ["off", "vertical", "horizontal", "both"] as const;
27
+
28
+ /** The axes the louvers swing on: `vertical` moves the air up and down, and
29
+ * `horizontal` moves it left and right. A 3D unit, one with 3D airflow, also
30
+ * reports each axis on its own, as `DaikinState.verticalPosition` and
31
+ * `DaikinState.horizontalPosition`. */
32
+ export type SwingMode = (typeof SWING_MODES)[number];
33
+
34
+ /** The positions a louver holds still at, on either axis. */
35
+ export const FIXED_LOUVER_POSITIONS = [
36
+ "position1",
37
+ "position2",
38
+ "position3",
39
+ "position4",
40
+ "position5",
41
+ ] as const;
42
+
43
+ export const VERTICAL_POSITIONS = [
44
+ "stop",
45
+ ...FIXED_LOUVER_POSITIONS,
46
+ "swing",
47
+ "naturalWind",
48
+ "auto",
49
+ "floorWarming",
50
+ ] as const;
51
+
52
+ /** On a DSIOT adapter, `position1` to `position5` are the first five fixed
53
+ * positions the unit takes, in the adapter's order. */
54
+ export type VerticalPosition = (typeof VERTICAL_POSITIONS)[number];
55
+
56
+ export const HORIZONTAL_POSITIONS = [
57
+ "stop",
58
+ ...FIXED_LOUVER_POSITIONS,
59
+ "swing",
60
+ "auto",
61
+ ] as const;
62
+
63
+ /** A DSIOT adapter reports a fixed position as `stop`: see UNSUPPORTED.md. */
64
+ export type HorizontalPosition = (typeof HORIZONTAL_POSITIONS)[number];
65
+
66
+ export const HUMIDITY_SETTINGS = [
67
+ "off",
68
+ "autoLow",
69
+ "auto",
70
+ "autoHigh",
71
+ "continuous",
72
+ ] as const;
73
+
74
+ /** The humidity settings that name no percentage. */
75
+ export type HumiditySetting = (typeof HUMIDITY_SETTINGS)[number];
76
+
77
+ /** A `HumiditySetting` or a percentage.
78
+ * `DaikinCapabilities.targetHumidity` lists which of them a unit takes. */
79
+ export type TargetHumidity = HumiditySetting | { percent: number };
80
+
81
+ export interface DaikinState {
82
+ power: boolean;
83
+ mode: OperatingMode;
84
+ /** `null` in modes without a setpoint (fan, sometimes dehumidify). */
85
+ targetTemperatureInC: number | null;
86
+ /** `null` when the unit has no humidity setting. */
87
+ targetHumidity: TargetHumidity | null;
88
+ fanSpeed: FanSpeed;
89
+ swingMode: SwingMode;
90
+ /** On a 3D unit. `null` where the unit reports none, or one outside
91
+ * `VerticalPosition`. */
92
+ verticalPosition: VerticalPosition | null;
93
+ /** On a 3D unit. `null` where the unit reports none, or one outside
94
+ * `HorizontalPosition`. */
95
+ horizontalPosition: HorizontalPosition | null;
96
+ }
97
+
98
+ /** A partial `DaikinState`, as `DaikinClient.setState` takes it: set only the
99
+ * fields to change. The client reads the current state, overlays the control,
100
+ * and writes the whole state back. It drops a field the unit does not take in
101
+ * the target mode, such as a louver position outside
102
+ * `DaikinCapabilities.verticalPositions`. */
103
+ export type DaikinControl = Partial<DaikinState>;
104
+
105
+ /** A reading is `null` when the unit does not report it. */
106
+ export interface DaikinSensors {
107
+ indoorTemperatureInC: number | null;
108
+ outdoorTemperatureInC: number | null;
109
+ indoorHumidityInPercent: number | null;
110
+ }
@@ -0,0 +1,191 @@
1
+ import { Client } from "undici";
2
+ import type { DaikinAuthentication } from "./authentication.js";
3
+ import { encodeTerminalUuid } from "./codecs/credentials.js";
4
+ import { DaikinConnectionError, DaikinError } from "./error.js";
5
+
6
+ export interface DaikinRequestOptions {
7
+ /** Retry a refused or dropped connection. Defaults to true. Pass false for a
8
+ * request that is not safe to repeat. */
9
+ retry?: boolean;
10
+ }
11
+
12
+ export interface DaikinRetryOptions {
13
+ /** How long, in milliseconds, to wait before each retry, so its length is
14
+ * how many times a request is retried. Defaults to 0, 50, 100, 200 and 400:
15
+ * see DESIGN.md#retries. Pass an empty array to never retry. */
16
+ delaysInMs?: number[];
17
+ }
18
+
19
+ const DEFAULT_RETRY_DELAYS_IN_MS = [0, 50, 100, 200, 400];
20
+
21
+ /** Runs `operation` again each time it throws a `DaikinConnectionError`, once
22
+ * for each of `delaysInMs`, and throws any other error at once: see
23
+ * DESIGN.md#retries. */
24
+ export async function withRetry<ResultT>(
25
+ operation: () => Promise<ResultT>,
26
+ { delaysInMs = DEFAULT_RETRY_DELAYS_IN_MS }: DaikinRetryOptions = {},
27
+ ): Promise<ResultT> {
28
+ for (const delayInMs of delaysInMs) {
29
+ try {
30
+ return await operation();
31
+ } catch (error) {
32
+ if (!(error instanceof DaikinConnectionError)) {
33
+ throw error;
34
+ }
35
+ }
36
+ await sleep(delayInMs);
37
+ }
38
+ return operation();
39
+ }
40
+
41
+ function sleep(delayInMs: number): Promise<void> {
42
+ return new Promise((resolve) => setTimeout(resolve, delayInMs));
43
+ }
44
+
45
+ const JSON_CONTENT_HEADERS = {
46
+ "Content-Type": "application/json",
47
+ };
48
+
49
+ const DEFAULT_TIMEOUT_IN_MS = 10_000;
50
+
51
+ // The codes for a refused, reset or closed connection. Every other failure,
52
+ // such as a timeout or a TLS error, is not one.
53
+ const CONNECTION_FAILURE_CODES = new Set([
54
+ "ECONNREFUSED",
55
+ "ECONNRESET",
56
+ "UND_ERR_SOCKET",
57
+ ]);
58
+
59
+ export interface DaikinRequest {
60
+ method: "GET" | "POST" | "PUT";
61
+ /** From the leading `/`, without a query string. */
62
+ path: string;
63
+ /** Unencoded: `send` percent-encodes each key and value. */
64
+ query?: Record<string, string>;
65
+ /** JSON body for DSIOT writes. Classic uses `query` only. */
66
+ jsonBody?: unknown;
67
+ }
68
+
69
+ export interface DaikinTransportOptions {
70
+ /** With `:port` when the adapter announces one. */
71
+ host: string;
72
+ /** HTTPS (a Marvell adapter in secure mode). */
73
+ usesHttps?: boolean;
74
+ /** The terminal uuid, sent as `X-Daikin-uuid` on every call. */
75
+ terminalUuid: string;
76
+ authentication?: DaikinAuthentication;
77
+ /** How long, in milliseconds, to wait for each of the connection, the
78
+ * reply's headers and the reply's body before `send` throws a
79
+ * `DaikinError` with status 504. Defaults to 10000. */
80
+ timeoutInMs?: number;
81
+ /** HTTPS adapters only. */
82
+ tls?: DaikinTlsOptions;
83
+ }
84
+
85
+ /** An HTTPS adapter presents a certificate chaining to Daikin's own CA, which
86
+ * this package cannot ship. So by default the certificate is not verified at
87
+ * all; supplying the CA turns chain verification on. */
88
+ export interface DaikinTlsOptions {
89
+ /** Daikin's CA certificate (PEM). The chain is then verified against it, but
90
+ * not the hostname: the adapter's certificate doesn't name its LAN address. */
91
+ ca?: string | Buffer;
92
+ }
93
+
94
+ /** The HTTP connection to one adapter. It builds each URL from `host` and
95
+ * `usesHttps`, attaches the uuid header and whatever the
96
+ * `DaikinAuthentication` contributes, and returns the reply's body as text for
97
+ * a codec to parse.
98
+ *
99
+ * It holds one undici connection per adapter, so requests queue rather than
100
+ * run in parallel: these small embedded servers are best talked to one request
101
+ * at a time. */
102
+ export class DaikinTransport {
103
+ private readonly origin: string;
104
+ private readonly client: Client;
105
+
106
+ /** Call `close` when done with the adapter. */
107
+ constructor(private readonly options: DaikinTransportOptions) {
108
+ const scheme = options.usesHttps ? "https" : "http";
109
+ this.origin = `${scheme}://${options.host.replace(/\/+$/, "")}`;
110
+
111
+ const timeoutInMs = options.timeoutInMs ?? DEFAULT_TIMEOUT_IN_MS;
112
+ this.client = new Client(this.origin, {
113
+ connect: { timeout: timeoutInMs, ...tlsConnectOptions(options.tls) },
114
+ headersTimeout: timeoutInMs,
115
+ bodyTimeout: timeoutInMs,
116
+ });
117
+ }
118
+
119
+ /** Sends one request and returns the reply's body as text. It never retries:
120
+ * wrap the call in `withRetry` for that.
121
+ *
122
+ * It throws a `DaikinConnectionError` when the adapter refuses, resets or
123
+ * closes the connection, and a `DaikinError` with status 504 when the
124
+ * request fails to complete any other way, such as a timeout or a TLS
125
+ * error. A reply whose status is not 2xx makes it throw a `DaikinError`
126
+ * with that status. */
127
+ async send(request: DaikinRequest): Promise<string> {
128
+ const authenticationQuery =
129
+ (await this.options.authentication?.query()) ?? {};
130
+ // Not URLSearchParams: it writes a space as `+`, and the adapter expects
131
+ // `%20` (e.g. `method=home%20only`).
132
+ const query = Object.entries({ ...request.query, ...authenticationQuery })
133
+ .map(
134
+ ([key, value]) =>
135
+ `${encodeURIComponent(key)}=${encodeURIComponent(value)}`,
136
+ )
137
+ .join("&");
138
+
139
+ const headers: Record<string, string> = {
140
+ ...encodeTerminalUuid(this.options.terminalUuid),
141
+ ...((await this.options.authentication?.headers()) ?? {}),
142
+ ...(request.jsonBody !== undefined ? JSON_CONTENT_HEADERS : {}),
143
+ };
144
+
145
+ let statusCode: number;
146
+ let text: string;
147
+ try {
148
+ const response = await this.client.request({
149
+ method: request.method,
150
+ path: query ? `${request.path}?${query}` : request.path,
151
+ headers,
152
+ ...(request.jsonBody !== undefined
153
+ ? { body: JSON.stringify(request.jsonBody) }
154
+ : {}),
155
+ });
156
+ statusCode = response.statusCode;
157
+ text = await response.body.text();
158
+ } catch (error) {
159
+ const message = `Daikin request to ${request.path} did not complete: ${String(error)}`;
160
+ if (
161
+ CONNECTION_FAILURE_CODES.has(
162
+ (error as NodeJS.ErrnoException).code ?? "",
163
+ )
164
+ ) {
165
+ throw new DaikinConnectionError(message);
166
+ }
167
+ throw new DaikinError(message, 504);
168
+ }
169
+
170
+ if (statusCode < 200 || statusCode >= 300) {
171
+ // The classic protocol signals logical failures in the body with HTTP 200,
172
+ // so an HTTP error here is a transport-level one.
173
+ throw new DaikinError(
174
+ `Daikin request to ${request.path} failed`,
175
+ statusCode,
176
+ );
177
+ }
178
+ return text;
179
+ }
180
+
181
+ /** Close the connection to the adapter. */
182
+ async close(): Promise<void> {
183
+ await this.client.close();
184
+ }
185
+ }
186
+
187
+ function tlsConnectOptions(tls: DaikinTlsOptions | undefined) {
188
+ return tls?.ca === undefined
189
+ ? { rejectUnauthorized: false }
190
+ : { ca: tls.ca, checkServerIdentity: () => undefined };
191
+ }