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,223 @@
1
+ import type {
2
+ DaikinControl,
3
+ DaikinSensors,
4
+ DaikinState,
5
+ FanSpeed,
6
+ OperatingMode,
7
+ SwingMode,
8
+ } from "../../models/state.js";
9
+ import {
10
+ AIRBASE_FAN_SPEED_CODES,
11
+ AIRBASE_NO_SETPOINT,
12
+ AIRBASE_OPERATING_MODE_CODES,
13
+ AIRBASE_SWING_MODE_CODES,
14
+ } from "./tables.js";
15
+
16
+ // Maps between `DaikinState` and an Airbase adapter's `control_info` fields,
17
+ // and reads `DaikinSensors` from its `get_sensor_info`.
18
+ //
19
+ // The unit remembers a setpoint, a fan and a direction for cool and for heat
20
+ // alone: `dt2`, `dfr2`, `dfd2`, `auto2` and `airside2` for cool, and the same
21
+ // fields ending in 1 for heat. Auto, fan and dehumidify run on one of the
22
+ // two, the one `bk_auto` names.
23
+
24
+ const OPERATING_MODES_BY_CODE: Record<string, OperatingMode> =
25
+ Object.fromEntries(
26
+ Object.entries(AIRBASE_OPERATING_MODE_CODES).map(([mode, code]) => [
27
+ code,
28
+ mode as OperatingMode,
29
+ ]),
30
+ );
31
+
32
+ const FAN_SPEEDS_BY_CODE: Record<string, FanSpeed> = Object.fromEntries(
33
+ Object.entries(AIRBASE_FAN_SPEED_CODES).map(([speed, code]) => [
34
+ code,
35
+ speed as FanSpeed,
36
+ ]),
37
+ );
38
+
39
+ const SWING_MODES_BY_CODE: Record<string, SwingMode> = Object.fromEntries(
40
+ Object.entries(AIRBASE_SWING_MODE_CODES).map(([swing, code]) => [
41
+ code,
42
+ swing as SwingMode,
43
+ ]),
44
+ );
45
+
46
+ const ON = "1";
47
+ const OFF = "0";
48
+ // What airside runs the fan at.
49
+ const AIRSIDE_FAN_SPEED = AIRBASE_FAN_SPEED_CODES.level1;
50
+
51
+ /** Turns a `get_control_info` reply into a `DaikinState`. A `mode` or `f_rate`
52
+ * outside the model reads as `auto`, and an `f_dir` outside it as `off`. It
53
+ * reports no humidity setting, louver position or special mode: an Airbase
54
+ * unit is not known to have any. */
55
+ export function parseAirbaseState(fields: Record<string, string>): DaikinState {
56
+ return {
57
+ power: fields.pow === ON,
58
+ mode: OPERATING_MODES_BY_CODE[fields.mode ?? ""] ?? "auto",
59
+ targetTemperatureInC: parseNumeric(fields.stemp),
60
+ targetHumidity: null,
61
+ fanSpeed: FAN_SPEEDS_BY_CODE[fields.f_rate ?? ""] ?? "auto",
62
+ swingMode: SWING_MODES_BY_CODE[fields.f_dir ?? ""] ?? "off",
63
+ verticalPosition: null,
64
+ horizontalPosition: null,
65
+ specialModes: [],
66
+ };
67
+ }
68
+
69
+ export function parseAirbaseSensors(
70
+ fields: Record<string, string>,
71
+ ): DaikinSensors {
72
+ return {
73
+ indoorTemperatureInC: parseNumeric(fields.htemp),
74
+ outdoorTemperatureInC: parseNumeric(fields.otemp),
75
+ indoorHumidityInPercent: parseNumeric(fields.hhum),
76
+ };
77
+ }
78
+
79
+ /** Whether fan auto is on in a `get_control_info` reply: the unit then picks
80
+ * the fan speed itself, up to the one that is set. */
81
+ export function parseAirbaseFanAuto(fields: Record<string, string>): boolean {
82
+ return fields.f_auto === ON;
83
+ }
84
+
85
+ /** Whether airside is on in a `get_control_info` reply. */
86
+ export function parseAirbaseAirside(fields: Record<string, string>): boolean {
87
+ return fields.f_airside === ON;
88
+ }
89
+
90
+ /** Turns `current`, the `get_control_info` reply, and a control into the
91
+ * `set_control_info` query: power, mode, setpoint, humidity, fan speed, fan
92
+ * auto, airside and direction, every time. When the mode changes, the
93
+ * setpoint, the fan and the direction come from what the unit remembers for
94
+ * cool or for heat. It drops a `targetTemperatureInC` in fan and dehumidify,
95
+ * every `targetHumidity` and louver position, and sends `shum` back as the
96
+ * unit holds it. A `fanSpeed` switches airside off and keeps fan auto. */
97
+ export function applyAirbaseControl(
98
+ current: Record<string, string>,
99
+ control: DaikinControl,
100
+ ): Record<string, string> {
101
+ return buildControl(current, control, {});
102
+ }
103
+
104
+ /** Turns `current`, the `get_control_info` reply, into the `set_control_info`
105
+ * query that switches fan auto on or off. It switches airside off, and keeps
106
+ * the fan speed. */
107
+ export function applyAirbaseFanAuto(
108
+ current: Record<string, string>,
109
+ enabled: boolean,
110
+ ): Record<string, string> {
111
+ return buildControl(current, {}, { auto: enabled });
112
+ }
113
+
114
+ /** Turns `current`, the `get_control_info` reply, into the `set_control_info`
115
+ * query that switches airside on or off. Switching it on switches fan auto
116
+ * off and sets the lowest fan speed, which airside runs at. */
117
+ export function applyAirbaseAirside(
118
+ current: Record<string, string>,
119
+ enabled: boolean,
120
+ ): Record<string, string> {
121
+ return buildControl(current, {}, { airside: enabled });
122
+ }
123
+
124
+ function buildControl(
125
+ current: Record<string, string>,
126
+ control: DaikinControl,
127
+ fan: { auto?: boolean; airside?: boolean },
128
+ ): Record<string, string> {
129
+ const { cool, heat, fan: fanMode, dehumidify } = AIRBASE_OPERATING_MODE_CODES;
130
+ // A mode outside the model is sent back as it came.
131
+ const currentMode = current.mode ?? "";
132
+ const mode =
133
+ control.mode === undefined
134
+ ? currentMode
135
+ : AIRBASE_OPERATING_MODE_CODES[control.mode];
136
+ const modeChanged = mode !== currentMode;
137
+
138
+ // A change to cool or to heat makes that mode the one the others run on.
139
+ const backing =
140
+ modeChanged && (mode === cool || mode === heat) ? mode : current.bk_auto;
141
+ // Which of the two memories applies: heat's, or cool's for anything else.
142
+ const memory = (backing || mode) === heat ? heat : cool;
143
+ const remembered = (field: string, memoryPrefix: string, fallback: string) =>
144
+ (modeChanged ? current[`${memoryPrefix}${memory}`] : undefined) ||
145
+ current[field] ||
146
+ fallback;
147
+
148
+ const power = control.power ?? current.pow === ON;
149
+ const next: Record<string, string> = { pow: power ? ON : OFF, mode };
150
+ if (backing) {
151
+ next.bk_auto = backing;
152
+ }
153
+
154
+ next.stemp =
155
+ mode === dehumidify
156
+ ? AIRBASE_NO_SETPOINT
157
+ : control.targetTemperatureInC != null && mode !== fanMode
158
+ ? control.targetTemperatureInC.toFixed(1)
159
+ : remembered("stemp", "dt", AIRBASE_NO_SETPOINT);
160
+ // Each mode's setpoint is written to its own memory as well, and auto's to
161
+ // the memory of the mode it runs on.
162
+ const setpointMemory =
163
+ mode === cool || mode === heat || mode === fanMode || mode === dehumidify
164
+ ? mode
165
+ : mode === AIRBASE_OPERATING_MODE_CODES.auto && backing
166
+ ? memory
167
+ : undefined;
168
+ if (setpointMemory !== undefined) {
169
+ next[`dt${setpointMemory}`] = next.stemp;
170
+ }
171
+ next.shum = current.shum || AIRBASE_NO_SETPOINT;
172
+
173
+ next.f_rate =
174
+ control.fanSpeed === undefined
175
+ ? remembered("f_rate", "dfr", AIRBASE_FAN_SPEED_CODES.auto)
176
+ : AIRBASE_FAN_SPEED_CODES[control.fanSpeed];
177
+ next.f_auto = remembered("f_auto", "auto", OFF);
178
+ next.f_airside = remembered("f_airside", "airside", OFF);
179
+ const fanChanged = control.fanSpeed !== undefined || fan.auto !== undefined;
180
+ if (fan.auto !== undefined) {
181
+ next.f_auto = fan.auto ? ON : OFF;
182
+ }
183
+ if (fanChanged) {
184
+ next.f_airside = OFF;
185
+ }
186
+ if (fan.airside !== undefined) {
187
+ next.f_airside = fan.airside ? ON : OFF;
188
+ }
189
+ if (fan.airside) {
190
+ next.f_rate = AIRSIDE_FAN_SPEED;
191
+ }
192
+ // Airside and fan auto are never on together.
193
+ if (next.f_airside === ON) {
194
+ next.f_auto = OFF;
195
+ }
196
+ // A change to the fan is written to the memory as well.
197
+ if (fanChanged) {
198
+ next[`dfr${memory}`] = next.f_rate;
199
+ next[`auto${memory}`] = next.f_auto;
200
+ }
201
+ if (fanChanged || fan.airside !== undefined) {
202
+ next[`airside${memory}`] = next.f_airside;
203
+ }
204
+
205
+ next.f_dir =
206
+ control.swingMode === undefined
207
+ ? remembered("f_dir", "dfd", AIRBASE_SWING_MODE_CODES.off)
208
+ : AIRBASE_SWING_MODE_CODES[control.swingMode];
209
+ if (control.swingMode !== undefined) {
210
+ next[`dfd${memory}`] = next.f_dir;
211
+ }
212
+ return next;
213
+ }
214
+
215
+ function parseNumeric(value: string | undefined): number | null {
216
+ if (value === undefined || value === "") {
217
+ return null;
218
+ }
219
+ const parsed = Number(value);
220
+ // A reading the unit lacks is "-", and a mode without a setpoint holds
221
+ // "--" or a letter, none of which is a number.
222
+ return Number.isFinite(parsed) ? parsed : null;
223
+ }
@@ -0,0 +1,32 @@
1
+ import type { FanSpeed, OperatingMode, SwingMode } from "../../models/state.js";
2
+
3
+ // The codes an Airbase adapter holds each value of a model as. It numbers
4
+ // the modes and the fan speeds differently from a classic adapter.
5
+
6
+ export const AIRBASE_OPERATING_MODE_CODES: Record<OperatingMode, string> = {
7
+ fan: "0",
8
+ heat: "1",
9
+ cool: "2",
10
+ auto: "3",
11
+ dehumidify: "7",
12
+ };
13
+
14
+ export const AIRBASE_FAN_SPEED_CODES: Record<FanSpeed, string> = {
15
+ auto: "0",
16
+ silent: "B",
17
+ level1: "1",
18
+ level2: "2",
19
+ level3: "3",
20
+ level4: "4",
21
+ level5: "5",
22
+ };
23
+
24
+ export const AIRBASE_SWING_MODE_CODES: Record<SwingMode, string> = {
25
+ off: "0",
26
+ vertical: "1",
27
+ horizontal: "2",
28
+ both: "3",
29
+ };
30
+
31
+ /** What `stemp` holds in a mode without a setpoint, such as dehumidify. */
32
+ export const AIRBASE_NO_SETPOINT = "--";
@@ -0,0 +1,62 @@
1
+ import type { DaikinZone } from "../../models/zone.js";
2
+ import { decodeClassicText } from "../classic/keyValue.js";
3
+ import { parseAirbaseZoneCount } from "./capabilities.js";
4
+
5
+ // `get_zone_setting` answers the zones as lists joined by `;`, form-encoded:
6
+ // their names in `zone_name`, and 1 or 0 for each in `zone_onoff`.
7
+ const SEPARATOR = ";";
8
+ // A unit that sets a temperature for each zone answers these two as well, and
9
+ // takes them back with every write.
10
+ const ZONE_TEMPERATURE_FIELDS = ["lztemp_c", "lztemp_h"];
11
+
12
+ function decodeList(value: string | undefined): string[] {
13
+ return value === undefined || value === ""
14
+ ? []
15
+ : decodeClassicText(value).split(SEPARATOR);
16
+ }
17
+
18
+ /** Turns a `get_zone_setting` reply into the unit's zones, the first
19
+ * `en_zone` of them. `capabilityFields` is the field map of
20
+ * `mergeAirbaseCapabilityFields`. */
21
+ export function parseAirbaseZones(
22
+ zoneSetting: Record<string, string>,
23
+ capabilityFields: Record<string, string>,
24
+ ): DaikinZone[] {
25
+ const flags = decodeList(zoneSetting.zone_onoff);
26
+ return decodeList(zoneSetting.zone_name)
27
+ .slice(0, parseAirbaseZoneCount(capabilityFields))
28
+ .map((name, index) => ({
29
+ name: name.trim(),
30
+ enabled: flags[index] === "1",
31
+ }));
32
+ }
33
+
34
+ /** Turns `current`, the `get_zone_setting` reply, into the `set_zone_setting`
35
+ * query that opens or closes the zone at `index`, counted from 0. Every name
36
+ * goes back as the unit holds it, with each zone's temperatures where the
37
+ * unit has them. It throws an `Error` for an index the unit has no zone
38
+ * at. */
39
+ export function applyAirbaseZone(
40
+ current: Record<string, string>,
41
+ capabilityFields: Record<string, string>,
42
+ index: number,
43
+ enabled: boolean,
44
+ ): Record<string, string> {
45
+ const zoneCount = parseAirbaseZones(current, capabilityFields).length;
46
+ if (!Number.isInteger(index) || index < 0 || index >= zoneCount) {
47
+ throw new Error(`The unit has no zone ${index}: it has ${zoneCount}`);
48
+ }
49
+ const flags = decodeList(current.zone_onoff);
50
+ flags[index] = enabled ? "1" : "0";
51
+ const query: Record<string, string> = {
52
+ zone_name: decodeList(current.zone_name).join(SEPARATOR),
53
+ zone_onoff: flags.join(SEPARATOR),
54
+ };
55
+ for (const field of ZONE_TEMPERATURE_FIELDS) {
56
+ const value = current[field];
57
+ if (value !== undefined) {
58
+ query[field] = decodeClassicText(value);
59
+ }
60
+ }
61
+ return query;
62
+ }
@@ -105,6 +105,9 @@ export const CLASSIC_SPECIAL_MODE_BITS: Record<SpecialMode, number> = {
105
105
  streamer: 4,
106
106
  };
107
107
 
108
+ /** `type` in a discovery reply from an air conditioner's classic adapter. */
109
+ export const CLASSIC_ADAPTER_TYPE = "aircon";
110
+
108
111
  /** `type` in `get_model_info` on a unit that only cools. */
109
112
  export const CLASSIC_COOLING_ONLY_TYPE = "C";
110
113
 
@@ -1,12 +1,28 @@
1
1
  import type { DaikinDevice, DaikinDeviceInfo } from "../models/device.js";
2
2
  import { decodeClassicText } from "./classic/keyValue.js";
3
- import { DSIOT_ADAPTER_TYPE } from "./dsiot/tables.js";
3
+ import { CLASSIC_ADAPTER_TYPE } from "./classic/tables.js";
4
+ import {
5
+ DSIOT_ADAPTER_TYPE,
6
+ DSIOT_AIR_CLEANER_DEVICES,
7
+ } from "./dsiot/tables.js";
4
8
 
5
9
  const MARVELL_ADP_KIND = "3";
6
10
 
11
+ /** Whether a `basic_info` reply comes from the adapter of an air conditioner:
12
+ * `type=aircon`, or `type=GPF` with a `cdev` that is not an air cleaner's. The
13
+ * adapter of another appliance answers the discovery probe as well, such as
14
+ * an air cleaner (`type=cleaner`) or a water heater (`type=Water Heater`). */
15
+ export function isAirConditioner(basicInfo: Record<string, string>): boolean {
16
+ if (basicInfo.type === DSIOT_ADAPTER_TYPE) {
17
+ return !DSIOT_AIR_CLEANER_DEVICES.includes(basicInfo.cdev ?? "");
18
+ }
19
+ return basicInfo.type === CLASSIC_ADAPTER_TYPE;
20
+ }
21
+
7
22
  /** Turns a `basic_info` reply into a `DaikinDevice`: `type=GPF` marks a DSIOT
8
23
  * adapter, and `api_port`, when present, is the HTTP port and is appended to
9
- * `host`, the address the reply came from. */
24
+ * `host`, the address the reply came from. It reads any reply as an air
25
+ * conditioner's: check the reply with `isAirConditioner` first. */
10
26
  export function parseDevice(
11
27
  host: string,
12
28
  basicInfo: Record<string, string>,
@@ -40,6 +40,10 @@ export const DSIOT_TARGETS = {
40
40
  /** `type` in a discovery reply from a DSIOT adapter. */
41
41
  export const DSIOT_ADAPTER_TYPE = "GPF";
42
42
 
43
+ /** `cdev` in a discovery reply from a DSIOT adapter whose unit is an air
44
+ * cleaner. */
45
+ export const DSIOT_AIR_CLEANER_DEVICES: readonly string[] = ["1D", "CJ"];
46
+
43
47
  /** Dotted paths into the `adp_i` and `adp_d` trees. The MAC address and the
44
48
  * version hold the value a classic adapter sends in `basic_info`, and the
45
49
  * name is plain text, where a classic adapter form-encodes it. */
package/src/discovery.ts CHANGED
@@ -1,53 +1,82 @@
1
- import { createSocket } from "node:dgram";
1
+ import { createSocket, type Socket } from "node:dgram";
2
+ import { once } from "node:events";
3
+ import { isAirbaseAdapter } from "./clients/airbase.js";
2
4
  import {
3
5
  isClassicOk,
4
6
  parseClassicKeyValue,
5
7
  } from "./codecs/classic/keyValue.js";
6
- import { parseDevice } from "./codecs/device.js";
8
+ import { isAirConditioner, parseDevice } from "./codecs/device.js";
7
9
  import type { DaikinDevice } from "./models/device.js";
10
+ import { DaikinTransport } from "./transport.js";
8
11
 
9
12
  // The UDP discovery probe: broadcast this to :30050, listen for `basic_info`
10
13
  // replies.
11
14
  const DISCOVERY_PROBE = "DAIKIN_UDP/common/basic_info";
12
- const DISCOVERY_SEND_PORT = 30000;
13
15
  const DISCOVERY_LISTEN_PORT = 30050;
16
+ // An adapter answers the port a probe came from. The probe leaves from the
17
+ // first of these ports that is free: see DESIGN.md#discovery.
18
+ const FIRST_DISCOVERY_SEND_PORT = 30000;
19
+ const LAST_DISCOVERY_SEND_PORT = 30050;
14
20
 
15
21
  const DEFAULT_DISCOVERY_TIMEOUT_IN_MS = 3_000;
16
22
  const BROADCAST_ADDRESS = "255.255.255.255";
17
23
  // UDP drops packets, so the probe goes out 10 times, 200 ms apart.
18
24
  const PROBE_COUNT = 10;
19
25
  const PROBE_INTERVAL_IN_MS = 200;
26
+ // How long each of the HTTP requests that tell an Airbase adapter from a
27
+ // classic one may take. An adapter that takes longer is reported as classic,
28
+ // so this only bounds how long after `timeoutInMs` a discovery resolves.
29
+ const IDENTIFY_TIMEOUT_IN_MS = 3_000;
20
30
 
21
31
  export interface DiscoverDevicesOptions {
22
32
  /** How long to listen for replies, in milliseconds. Defaults to 3000. */
23
33
  timeoutInMs?: number;
24
34
  }
25
35
 
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`.
36
+ /** Finds the air conditioners' adapters on the LAN: broadcasts
37
+ * `DAIKIN_UDP/common/basic_info` to UDP port 30050 and collects the
38
+ * `basic_info` replies, which carry the same `key=value` payload as the HTTP
39
+ * `/common/basic_info`. Classic and DSIOT adapters both answer, and
40
+ * `parseDevice` picks the protocol from the reply. Anything on the LAN can
41
+ * send to the port it listens on, so it ignores a datagram whose `ret` is not
42
+ * `OK`. The adapter of another appliance answers the probe as well, such as an
43
+ * air cleaner's, so it ignores a reply `isAirConditioner` does not pass.
32
44
  *
33
- * It resolves once `timeoutInMs` has passed, never earlier, with one device
34
- * for each `host` that replied: the address a reply came from, with the port
35
- * the reply announces. It binds UDP port 30000, and rejects with the socket's
36
- * error when the socket fails. */
45
+ * An Airbase adapter replies as a classic adapter does, so it sends each
46
+ * adapter that replies as one an HTTP request, or two, to tell which it is:
47
+ * an Airbase adapter answers HTTP 404 at `/common/basic_info` and `ret=OK`
48
+ * at `/skyfi/common/basic_info`. An adapter that does not answer in 3000 ms
49
+ * is reported as classic. An Airbase adapter takes no registration and no
50
+ * local password, so its device requires neither, whatever its reply says.
51
+ *
52
+ * It resolves once `timeoutInMs` has passed and each of those requests has
53
+ * finished, never earlier, with one device for each `host` that replied: the
54
+ * address a reply came from, with the port the reply announces. It binds the
55
+ * first free UDP port from 30000 to 30050, and rejects with the socket's
56
+ * error when none is free or the socket fails. */
37
57
  export async function discoverDevices(
38
58
  options: DiscoverDevicesOptions = {},
39
59
  ): Promise<DaikinDevice[]> {
40
- const socket = createSocket({ type: "udp4", reuseAddr: true });
41
- const devices = new Map<string, DaikinDevice>();
60
+ const socket = await bindFirstFreePort();
61
+ const devices = new Map<string, Promise<DaikinDevice>>();
42
62
 
43
63
  return new Promise((resolve, reject) => {
44
64
  let probesSent = 0;
45
- let probeTimer: NodeJS.Timeout | undefined;
65
+
66
+ const probe = Buffer.from(DISCOVERY_PROBE, "utf8");
67
+ const sendProbe = () => {
68
+ socket.send(probe, DISCOVERY_LISTEN_PORT, BROADCAST_ADDRESS);
69
+ probesSent += 1;
70
+ if (probesSent >= PROBE_COUNT) {
71
+ clearInterval(probeTimer);
72
+ }
73
+ };
74
+ const probeTimer = setInterval(sendProbe, PROBE_INTERVAL_IN_MS);
46
75
 
47
76
  const listenTimer = setTimeout(() => {
48
77
  clearInterval(probeTimer);
49
78
  socket.close();
50
- resolve([...devices.values()]);
79
+ resolve(Promise.all(devices.values()));
51
80
  }, options.timeoutInMs ?? DEFAULT_DISCOVERY_TIMEOUT_IN_MS);
52
81
 
53
82
  socket.on("error", (error) => {
@@ -60,7 +89,7 @@ export async function discoverDevices(
60
89
 
61
90
  socket.on("message", (message, remote) => {
62
91
  const fields = parseClassicKeyValue(message.toString("utf8"));
63
- if (!isClassicOk(fields)) {
92
+ if (!isClassicOk(fields) || !isAirConditioner(fields)) {
64
93
  return;
65
94
  }
66
95
  // An adapter answers each of the probes, so the first reply from a
@@ -68,22 +97,62 @@ export async function discoverDevices(
68
97
  // are, differ by the port they announce.
69
98
  const device = parseDevice(remote.address, fields);
70
99
  if (!devices.has(device.host)) {
71
- devices.set(device.host, device);
100
+ devices.set(device.host, identifyProtocol(device));
72
101
  }
73
102
  });
74
103
 
75
- socket.bind(DISCOVERY_SEND_PORT, () => {
76
- socket.setBroadcast(true);
77
- const probe = Buffer.from(DISCOVERY_PROBE, "utf8");
78
- const sendProbe = () => {
79
- socket.send(probe, DISCOVERY_LISTEN_PORT, BROADCAST_ADDRESS);
80
- probesSent += 1;
81
- if (probesSent >= PROBE_COUNT) {
82
- clearInterval(probeTimer);
83
- }
84
- };
85
- sendProbe();
86
- probeTimer = setInterval(sendProbe, PROBE_INTERVAL_IN_MS);
87
- });
104
+ socket.setBroadcast(true);
105
+ sendProbe();
88
106
  });
89
107
  }
108
+
109
+ // A reply does not say whether a classic adapter is an Airbase adapter, so
110
+ // the adapter is asked over HTTP.
111
+ async function identifyProtocol(device: DaikinDevice): Promise<DaikinDevice> {
112
+ if (device.protocol !== "classic") {
113
+ return device;
114
+ }
115
+ const transport = new DaikinTransport({
116
+ host: device.host,
117
+ usesHttps: device.usesHttps,
118
+ timeoutInMs: IDENTIFY_TIMEOUT_IN_MS,
119
+ });
120
+ try {
121
+ // `parseDevice` read the reply as a classic adapter's, which may require
122
+ // registration or a local password. An Airbase adapter takes neither.
123
+ // `usesHttps` stays: the adapter has just answered over that scheme.
124
+ return (await isAirbaseAdapter(transport))
125
+ ? {
126
+ ...device,
127
+ protocol: "airbase",
128
+ requiresRegistration: false,
129
+ requiresLocalPassword: false,
130
+ }
131
+ : device;
132
+ } finally {
133
+ await transport.close();
134
+ }
135
+ }
136
+
137
+ // Binds without `reuseAddr`: with it, a second socket binds a port that is
138
+ // held, and each reply then reaches one of the two alone.
139
+ async function bindFirstFreePort(): Promise<Socket> {
140
+ for (let port = FIRST_DISCOVERY_SEND_PORT; ; port += 1) {
141
+ const socket = createSocket("udp4");
142
+ try {
143
+ socket.bind(port);
144
+ // `once` rejects when the socket emits `error` first, as it does for a
145
+ // port that is held.
146
+ await once(socket, "listening");
147
+ return socket;
148
+ } catch (error) {
149
+ socket.close();
150
+ if (
151
+ (error as NodeJS.ErrnoException).code !== "EADDRINUSE" ||
152
+ port === LAST_DISCOVERY_SEND_PORT
153
+ ) {
154
+ throw error;
155
+ }
156
+ }
157
+ }
158
+ }
package/src/factory.ts CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  type DaikinAuthentication,
4
4
  DsiotLocalPasswordAuthentication,
5
5
  } from "./authentication.js";
6
+ import { AirbaseDaikinClient } from "./clients/airbase.js";
6
7
  import { ClassicDaikinClient } from "./clients/classic.js";
7
8
  import type { DaikinClient } from "./clients/client.js";
8
9
  import { DsiotDaikinClient } from "./clients/dsiot.js";
@@ -16,9 +17,11 @@ import {
16
17
  export interface CreateDaikinClientOptions {
17
18
  /** The terminal uuid, sent as `X-Daikin-uuid`: 32 lowercase hexadecimal
18
19
  * digits, a UUID without its dashes. Generate one and reuse it: an adapter
19
- * that requires registration remembers it. */
20
+ * that requires registration remembers it. An Airbase adapter takes none,
21
+ * so it is neither checked nor sent to one. */
20
22
  terminalUuid: string;
21
- /** The child lock code, for a device whose `requiresLocalPassword` is true. */
23
+ /** The child lock code, for a device whose `requiresLocalPassword` is true.
24
+ * It is not sent to an Airbase adapter, which takes none. */
22
25
  localPassword?: string;
23
26
  /** How long, in milliseconds, a request waits for the adapter. Defaults to
24
27
  * 10000: see `DaikinTransportOptions.timeoutInMs`. */
@@ -39,9 +42,10 @@ export interface CreateDaikinClientOptions {
39
42
  }
40
43
 
41
44
  /** Builds the client for a device: a `DsiotDaikinClient` when
42
- * `device.protocol` is `dsiot`, a `ClassicDaikinClient` otherwise. The client
43
- * sends over HTTPS when `device.usesHttps` is set, and sends the local
44
- * password when `options.localPassword` is given. It throws an `Error` when
45
+ * `device.protocol` is `dsiot`, an `AirbaseDaikinClient` when it is
46
+ * `airbase`, and a `ClassicDaikinClient` otherwise. The client sends over
47
+ * HTTPS when `device.usesHttps` is set, and sends the local password when
48
+ * `options.localPassword` is given. It throws an `Error` when
45
49
  * `options.terminalUuid` is not in the form
46
50
  * `CreateDaikinClientOptions.terminalUuid` describes. Call `close` on the
47
51
  * client when done with the unit. */
@@ -49,6 +53,18 @@ export function createDaikinClient(
49
53
  device: DaikinDevice,
50
54
  options: CreateDaikinClientOptions,
51
55
  ): DaikinClient {
56
+ if (device.protocol === "airbase") {
57
+ return new AirbaseDaikinClient(
58
+ new DaikinTransport({
59
+ host: device.host,
60
+ usesHttps: device.usesHttps,
61
+ timeoutInMs: options.timeoutInMs,
62
+ tls: options.tls,
63
+ }),
64
+ options.retry,
65
+ options.maxStateAgeForSetStateInMs,
66
+ );
67
+ }
52
68
  const transport = new DaikinTransport({
53
69
  host: device.host,
54
70
  usesHttps: device.usesHttps,
package/src/index.ts CHANGED
@@ -1,7 +1,12 @@
1
1
  export type { DaikinAuthentication } from "./authentication.js";
2
+ export { AirbaseDaikinClient } from "./clients/airbase.js";
2
3
  export * from "./clients/classic.js";
3
4
  export * from "./clients/client.js";
4
5
  export * from "./clients/dsiot.js";
6
+ export * from "./codecs/airbase/capabilities.js";
7
+ export * from "./codecs/airbase/state.js";
8
+ export * from "./codecs/airbase/tables.js";
9
+ export * from "./codecs/airbase/zone.js";
5
10
  export * from "./codecs/classic/capabilities.js";
6
11
  export * from "./codecs/classic/extendedControl.js";
7
12
  export * from "./codecs/classic/keyValue.js";
@@ -21,4 +26,5 @@ export * from "./models/capabilities.js";
21
26
  export * from "./models/device.js";
22
27
  export * from "./models/extendedControl.js";
23
28
  export * from "./models/state.js";
29
+ export * from "./models/zone.js";
24
30
  export * from "./transport.js";
@@ -1,8 +1,9 @@
1
- export const DAIKIN_PROTOCOLS = ["classic", "dsiot"] as const;
1
+ export const DAIKIN_PROTOCOLS = ["classic", "dsiot", "airbase"] as const;
2
2
 
3
3
  /** The wire protocol an adapter speaks, and the only difference between
4
4
  * adapters that takes a separate `DaikinClient` implementation: `classic` for
5
- * a CGI adapter such as the BRP069 or BRP072, `dsiot` for a BRP084. */
5
+ * a CGI adapter such as the BRP069 or BRP072, `dsiot` for a BRP084, and
6
+ * `airbase` for the BRP15B61 of a ducted unit. */
6
7
  export type DaikinProtocol = (typeof DAIKIN_PROTOCOLS)[number];
7
8
 
8
9
  /** What an adapter reports to identify itself. `discoverDevices` fills it into
@@ -0,0 +1,8 @@
1
+ /** One zone of a ducted unit on an Airbase adapter: a damper that opens or
2
+ * closes the air to part of the building. */
3
+ export interface DaikinZone {
4
+ /** The name the zone was given, without the spaces around it. */
5
+ name: string;
6
+ /** Whether the zone is open. */
7
+ enabled: boolean;
8
+ }