daikin-local-control 0.2.1 → 0.3.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 (92) hide show
  1. package/DESIGN.md +52 -0
  2. package/README.md +39 -2
  3. package/UNSUPPORTED.md +10 -1
  4. package/dist/clients/classic.d.ts +22 -4
  5. package/dist/clients/classic.d.ts.map +1 -1
  6. package/dist/clients/classic.js +55 -8
  7. package/dist/clients/classic.js.map +1 -1
  8. package/dist/clients/client.d.ts +11 -2
  9. package/dist/clients/client.d.ts.map +1 -1
  10. package/dist/clients/dsiot.d.ts +10 -3
  11. package/dist/clients/dsiot.d.ts.map +1 -1
  12. package/dist/clients/dsiot.js +40 -5
  13. package/dist/clients/dsiot.js.map +1 -1
  14. package/dist/clients/setStateCache.d.ts +15 -0
  15. package/dist/clients/setStateCache.d.ts.map +1 -0
  16. package/dist/clients/setStateCache.js +26 -0
  17. package/dist/clients/setStateCache.js.map +1 -0
  18. package/dist/codecs/classic/capabilities.d.ts.map +1 -1
  19. package/dist/codecs/classic/capabilities.js +28 -1
  20. package/dist/codecs/classic/capabilities.js.map +1 -1
  21. package/dist/codecs/classic/extendedControl.d.ts +14 -1
  22. package/dist/codecs/classic/extendedControl.d.ts.map +1 -1
  23. package/dist/codecs/classic/extendedControl.js +39 -4
  24. package/dist/codecs/classic/extendedControl.js.map +1 -1
  25. package/dist/codecs/classic/keyValue.d.ts +3 -0
  26. package/dist/codecs/classic/keyValue.d.ts.map +1 -1
  27. package/dist/codecs/classic/keyValue.js +10 -0
  28. package/dist/codecs/classic/keyValue.js.map +1 -1
  29. package/dist/codecs/classic/state.d.ts +3 -2
  30. package/dist/codecs/classic/state.d.ts.map +1 -1
  31. package/dist/codecs/classic/state.js +14 -1
  32. package/dist/codecs/classic/state.js.map +1 -1
  33. package/dist/codecs/device.d.ts +10 -1
  34. package/dist/codecs/device.d.ts.map +1 -1
  35. package/dist/codecs/device.js +25 -10
  36. package/dist/codecs/device.js.map +1 -1
  37. package/dist/codecs/dsiot/capabilities.d.ts +4 -2
  38. package/dist/codecs/dsiot/capabilities.d.ts.map +1 -1
  39. package/dist/codecs/dsiot/capabilities.js +20 -4
  40. package/dist/codecs/dsiot/capabilities.js.map +1 -1
  41. package/dist/codecs/dsiot/device.d.ts +5 -0
  42. package/dist/codecs/dsiot/device.d.ts.map +1 -0
  43. package/dist/codecs/dsiot/device.js +14 -0
  44. package/dist/codecs/dsiot/device.js.map +1 -0
  45. package/dist/codecs/dsiot/multireq.d.ts +6 -4
  46. package/dist/codecs/dsiot/multireq.d.ts.map +1 -1
  47. package/dist/codecs/dsiot/multireq.js +9 -4
  48. package/dist/codecs/dsiot/multireq.js.map +1 -1
  49. package/dist/codecs/dsiot/state.d.ts +6 -2
  50. package/dist/codecs/dsiot/state.d.ts.map +1 -1
  51. package/dist/codecs/dsiot/state.js +12 -2
  52. package/dist/codecs/dsiot/state.js.map +1 -1
  53. package/dist/factory.d.ts +7 -0
  54. package/dist/factory.d.ts.map +1 -1
  55. package/dist/factory.js +2 -2
  56. package/dist/factory.js.map +1 -1
  57. package/dist/index.d.ts +1 -0
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +1 -0
  60. package/dist/index.js.map +1 -1
  61. package/dist/models/capabilities.d.ts +5 -1
  62. package/dist/models/capabilities.d.ts.map +1 -1
  63. package/dist/models/device.d.ts +12 -1
  64. package/dist/models/device.d.ts.map +1 -1
  65. package/dist/models/extendedControl.d.ts +2 -3
  66. package/dist/models/extendedControl.d.ts.map +1 -1
  67. package/dist/models/extendedControl.js +0 -1
  68. package/dist/models/extendedControl.js.map +1 -1
  69. package/dist/models/state.d.ts +15 -4
  70. package/dist/models/state.d.ts.map +1 -1
  71. package/dist/models/state.js +1 -0
  72. package/dist/models/state.js.map +1 -1
  73. package/package.json +1 -1
  74. package/src/clients/classic.ts +76 -10
  75. package/src/clients/client.ts +13 -1
  76. package/src/clients/dsiot.ts +52 -9
  77. package/src/clients/setStateCache.ts +28 -0
  78. package/src/codecs/classic/capabilities.ts +39 -0
  79. package/src/codecs/classic/extendedControl.ts +56 -5
  80. package/src/codecs/classic/keyValue.ts +10 -0
  81. package/src/codecs/classic/state.ts +30 -12
  82. package/src/codecs/device.ts +35 -10
  83. package/src/codecs/dsiot/capabilities.ts +27 -2
  84. package/src/codecs/dsiot/device.ts +20 -0
  85. package/src/codecs/dsiot/multireq.ts +9 -4
  86. package/src/codecs/dsiot/state.ts +16 -1
  87. package/src/factory.ts +17 -2
  88. package/src/index.ts +1 -0
  89. package/src/models/capabilities.ts +5 -0
  90. package/src/models/device.ts +13 -1
  91. package/src/models/extendedControl.ts +2 -5
  92. package/src/models/state.ts +18 -4
@@ -15,6 +15,10 @@ import {
15
15
  encodeClassicSpecialMode,
16
16
  encodeClassicTerminalKey,
17
17
  encodeClassicTerminalScope,
18
+ parseClassicHolidayMode,
19
+ parseClassicLed,
20
+ parseClassicMetadata,
21
+ parseClassicOutOfHomeAccess,
18
22
  } from "../codecs/classic/extendedControl.js";
19
23
  import {
20
24
  assertClassicOk,
@@ -25,17 +29,20 @@ import {
25
29
  parseClassicSensors,
26
30
  parseClassicState,
27
31
  } from "../codecs/classic/state.js";
32
+ import { parseDeviceInfo } from "../codecs/device.js";
28
33
  import type { DaikinCapabilities } from "../models/capabilities.js";
34
+ import type { DaikinDeviceInfo } from "../models/device.js";
29
35
  import type {
30
36
  DemandControl,
31
37
  DeviceMetadata,
32
- SpecialMode,
33
38
  TerminalScope,
34
39
  } from "../models/extendedControl.js";
35
40
  import type {
36
41
  DaikinControl,
37
42
  DaikinSensors,
38
43
  DaikinState,
44
+ DaikinStateAndSensors,
45
+ SpecialMode,
39
46
  } from "../models/state.js";
40
47
  import {
41
48
  type DaikinRequestOptions,
@@ -44,6 +51,7 @@ import {
44
51
  withRetry,
45
52
  } from "../transport.js";
46
53
  import type { DaikinClient } from "./client.js";
54
+ import { SetStateCache } from "./setStateCache.js";
47
55
 
48
56
  // Classic CGI endpoints (BRP069-style). Reads and writes are both HTTP GET with
49
57
  // query parameters; the reply is `key=value,key=value`.
@@ -75,28 +83,40 @@ export class ClassicDaikinClient implements DaikinClient {
75
83
  // A unit's capabilities don't change, and `setState` needs them, so they are
76
84
  // read once.
77
85
  private capabilityFields: Promise<Record<string, string>> | undefined;
86
+ private readonly setStateCache: SetStateCache;
78
87
 
79
- /** `close` closes `transport`, so give each client a transport of its own. */
88
+ /** `close` closes `transport`, so give each client a transport of its own.
89
+ * `maxStateAgeForSetStateInMs` defaults to 0: see
90
+ * `CreateDaikinClientOptions.maxStateAgeForSetStateInMs`. */
80
91
  constructor(
81
92
  private readonly transport: DaikinTransport,
82
93
  private readonly retryOptions?: DaikinRetryOptions,
83
- ) {}
94
+ maxStateAgeForSetStateInMs = 0,
95
+ ) {
96
+ this.setStateCache = new SetStateCache(maxStateAgeForSetStateInMs);
97
+ }
84
98
 
85
99
  close(): Promise<void> {
86
100
  return this.transport.close();
87
101
  }
88
102
 
103
+ async getDeviceInfo(): Promise<DaikinDeviceInfo> {
104
+ return parseDeviceInfo(await this.command(CLASSIC_ROUTES.basicInfo));
105
+ }
106
+
89
107
  async getCapabilities(): Promise<DaikinCapabilities> {
90
108
  return parseClassicCapabilities(await this.readCapabilityFields());
91
109
  }
92
110
 
93
111
  async getState(): Promise<DaikinState> {
94
- return parseClassicState(await this.command(CLASSIC_ROUTES.getControlInfo));
112
+ return parseClassicState(await this.readControlInfo());
95
113
  }
96
114
 
97
115
  async setState(control: DaikinControl): Promise<void> {
98
116
  const capabilityFields = await this.readCapabilityFields();
99
- const current = await this.command(CLASSIC_ROUTES.getControlInfo);
117
+ const current =
118
+ this.setStateCache.get() ??
119
+ (await this.command(CLASSIC_ROUTES.getControlInfo));
100
120
  await this.command(
101
121
  CLASSIC_ROUTES.setControlInfo,
102
122
  applyClassicControl(current, control, capabilityFields),
@@ -109,14 +129,32 @@ export class ClassicDaikinClient implements DaikinClient {
109
129
  );
110
130
  }
111
131
 
132
+ async getStateAndSensors(): Promise<DaikinStateAndSensors> {
133
+ return { state: await this.getState(), sensors: await this.getSensors() };
134
+ }
135
+
112
136
  // --- extended controls ---------------------------------------------------
113
- // Each maps to its own endpoint (not control_info).
137
+ // Each write goes to its own endpoint, not `set_control_info`.
138
+
139
+ /** Whether the adapter's status LED is on (`basic_info`): `null` when the
140
+ * adapter does not report it. */
141
+ async getLed(): Promise<boolean | null> {
142
+ return parseClassicLed(await this.command(CLASSIC_ROUTES.basicInfo));
143
+ }
114
144
 
115
145
  /** Adapter status LED on/off (`set_led`). */
116
146
  async setLed(on: boolean): Promise<void> {
117
147
  await this.command(CLASSIC_ROUTES.setLed, encodeClassicLed(on));
118
148
  }
119
149
 
150
+ /** Whether holiday mode is on (`basic_info`): `null` when the adapter does
151
+ * not report it. */
152
+ async getHolidayMode(): Promise<boolean | null> {
153
+ return parseClassicHolidayMode(
154
+ await this.command(CLASSIC_ROUTES.basicInfo),
155
+ );
156
+ }
157
+
120
158
  /** Holiday / away mode (`set_holiday`). */
121
159
  async setHolidayMode(enabled: boolean): Promise<void> {
122
160
  await this.command(
@@ -141,6 +179,14 @@ export class ClassicDaikinClient implements DaikinClient {
141
179
  );
142
180
  }
143
181
 
182
+ /** Whether the unit can be operated from outside the home network
183
+ * (`basic_info`): `null` when the adapter does not report it. */
184
+ async getOutOfHomeAccess(): Promise<boolean | null> {
185
+ return parseClassicOutOfHomeAccess(
186
+ await this.command(CLASSIC_ROUTES.basicInfo),
187
+ );
188
+ }
189
+
144
190
  /** Whether the unit can be operated from outside the home network
145
191
  * (`set_remote_method`). */
146
192
  async setOutOfHomeAccess(enabled: boolean): Promise<void> {
@@ -168,6 +214,11 @@ export class ClassicDaikinClient implements DaikinClient {
168
214
  );
169
215
  }
170
216
 
217
+ /** Name / icon / group metadata (`basic_info`). */
218
+ async getMetadata(): Promise<DeviceMetadata> {
219
+ return parseClassicMetadata(await this.command(CLASSIC_ROUTES.basicInfo));
220
+ }
221
+
171
222
  /** Name / icon / group metadata (`set_name` etc.). */
172
223
  async setMetadata(metadata: DeviceMetadata): Promise<void> {
173
224
  if (metadata.name !== undefined) {
@@ -229,10 +280,25 @@ export class ClassicDaikinClient implements DaikinClient {
229
280
  path,
230
281
  ...(parameters ? { query: parameters } : {}),
231
282
  });
232
- const fields = parseClassicKeyValue(
233
- await (retry ? withRetry(send, this.retryOptions) : send()),
234
- );
235
- assertClassicOk(fields);
283
+ try {
284
+ const fields = parseClassicKeyValue(
285
+ await (retry ? withRetry(send, this.retryOptions) : send()),
286
+ );
287
+ assertClassicOk(fields);
288
+ return fields;
289
+ } finally {
290
+ // Every request that changes the unit takes parameters, and any of them
291
+ // may change what `get_control_info` answers, even one that failed: a
292
+ // request that timed out may still have reached the unit.
293
+ if (parameters) {
294
+ this.setStateCache.clear();
295
+ }
296
+ }
297
+ }
298
+
299
+ private async readControlInfo(): Promise<Record<string, string>> {
300
+ const fields = await this.command(CLASSIC_ROUTES.getControlInfo);
301
+ this.setStateCache.set(fields);
236
302
  return fields;
237
303
  }
238
304
 
@@ -1,8 +1,10 @@
1
1
  import type { DaikinCapabilities } from "../models/capabilities.js";
2
+ import type { DaikinDeviceInfo } from "../models/device.js";
2
3
  import type {
3
4
  DaikinControl,
4
5
  DaikinSensors,
5
6
  DaikinState,
7
+ DaikinStateAndSensors,
6
8
  } from "../models/state.js";
7
9
 
8
10
  /** The controls every adapter offers, whatever its wire protocol or
@@ -11,6 +13,9 @@ import type {
11
13
  * `ClassicDaikinClient.registerTerminal`. Every method rejects with a
12
14
  * `DaikinError` when its request fails. */
13
15
  export interface DaikinClient {
16
+ /** The adapter's MAC address and firmware version. */
17
+ getDeviceInfo(): Promise<DaikinDeviceInfo>;
18
+
14
19
  /** The unit's supported features. */
15
20
  getCapabilities(): Promise<DaikinCapabilities>;
16
21
 
@@ -18,12 +23,19 @@ export interface DaikinClient {
18
23
  getState(): Promise<DaikinState>;
19
24
 
20
25
  /** Change part of the state. The client reads the current state, overlays the
21
- * control, and writes the whole thing back. */
26
+ * control, and writes the whole thing back. It skips the read when it holds
27
+ * a reply to `getState` or `getStateAndSensors` younger than
28
+ * `CreateDaikinClientOptions.maxStateAgeForSetStateInMs`. */
22
29
  setState(control: DaikinControl): Promise<void>;
23
30
 
24
31
  /** Read-only temperature / humidity readings. */
25
32
  getSensors(): Promise<DaikinSensors>;
26
33
 
34
+ /** What `getState` and `getSensors` resolve with, together. A DSIOT adapter
35
+ * answers both in one request, where calling the two sends the same request
36
+ * twice. A classic adapter takes one request for each. */
37
+ getStateAndSensors(): Promise<DaikinStateAndSensors>;
38
+
27
39
  /** Close the connection to the adapter. */
28
40
  close(): Promise<void>;
29
41
  }
@@ -1,4 +1,5 @@
1
1
  import { parseDsiotCapabilities } from "../codecs/dsiot/capabilities.js";
2
+ import { parseDsiotDeviceInfo } from "../codecs/dsiot/device.js";
2
3
  import {
3
4
  buildDsiotMultireqGet,
4
5
  buildDsiotMultireqUpdate,
@@ -12,10 +13,12 @@ import {
12
13
  parseDsiotState,
13
14
  } from "../codecs/dsiot/state.js";
14
15
  import type { DaikinCapabilities } from "../models/capabilities.js";
16
+ import type { DaikinDeviceInfo } from "../models/device.js";
15
17
  import type {
16
18
  DaikinControl,
17
19
  DaikinSensors,
18
20
  DaikinState,
21
+ DaikinStateAndSensors,
19
22
  } from "../models/state.js";
20
23
  import {
21
24
  type DaikinRequestOptions,
@@ -24,6 +27,7 @@ import {
24
27
  withRetry,
25
28
  } from "../transport.js";
26
29
  import type { DaikinClient } from "./client.js";
30
+ import { SetStateCache } from "./setStateCache.js";
27
31
 
28
32
  // DSIOT endpoints (BRP084-style). Climate state is a POST (read) or PUT (write)
29
33
  // of a JSON property tree to `multireq`.
@@ -35,33 +39,47 @@ const DSIOT_ROUTES = {
35
39
  /** DSIOT adapter (BRP084-style). One endpoint, `/dsiot/multireq`: POST to read
36
40
  * whole `dgc_status` trees, PUT to write one merged tree. */
37
41
  export class DsiotDaikinClient implements DaikinClient {
38
- /** `close` closes `transport`, so give each client a transport of its own. */
42
+ private readonly setStateCache: SetStateCache;
43
+
44
+ /** `close` closes `transport`, so give each client a transport of its own.
45
+ * `maxStateAgeForSetStateInMs` defaults to 0: see
46
+ * `CreateDaikinClientOptions.maxStateAgeForSetStateInMs`. */
39
47
  constructor(
40
48
  private readonly transport: DaikinTransport,
41
49
  private readonly retryOptions?: DaikinRetryOptions,
42
- ) {}
50
+ maxStateAgeForSetStateInMs = 0,
51
+ ) {
52
+ this.setStateCache = new SetStateCache(maxStateAgeForSetStateInMs);
53
+ }
43
54
 
44
55
  close(): Promise<void> {
45
56
  return this.transport.close();
46
57
  }
47
58
 
59
+ async getDeviceInfo(): Promise<DaikinDeviceInfo> {
60
+ return parseDsiotDeviceInfo(
61
+ await this.multireqGet([DSIOT_TARGETS.adapterInfo]),
62
+ );
63
+ }
64
+
48
65
  async getCapabilities(): Promise<DaikinCapabilities> {
49
66
  return parseDsiotCapabilities(
50
67
  await this.multireqGet([
51
68
  DSIOT_TARGETS.adapterInfo,
52
69
  DSIOT_TARGETS.indoorStatus,
70
+ DSIOT_TARGETS.outdoorStatus,
53
71
  ]),
54
72
  );
55
73
  }
56
74
 
57
75
  async getState(): Promise<DaikinState> {
58
- return parseDsiotState(
59
- await this.multireqGet([DSIOT_TARGETS.indoorStatus]),
60
- );
76
+ return parseDsiotState(await this.readStatus());
61
77
  }
62
78
 
63
79
  async setState(control: DaikinControl): Promise<void> {
64
- const values = await this.multireqGet([DSIOT_TARGETS.indoorStatus]);
80
+ const values =
81
+ this.setStateCache.get() ??
82
+ (await this.multireqGet([DSIOT_TARGETS.indoorStatus]));
65
83
  await this.multireqUpdate(applyDsiotControl(values, control));
66
84
  }
67
85
 
@@ -74,6 +92,14 @@ export class DsiotDaikinClient implements DaikinClient {
74
92
  );
75
93
  }
76
94
 
95
+ async getStateAndSensors(): Promise<DaikinStateAndSensors> {
96
+ const values = await this.readStatus();
97
+ return {
98
+ state: parseDsiotState(values),
99
+ sensors: parseDsiotSensors(values),
100
+ };
101
+ }
102
+
77
103
  // --- extended controls ---------------------------------------------------
78
104
 
79
105
  /** Reboot the adapter (`/config/system/reboot`). */
@@ -109,8 +135,25 @@ export class DsiotDaikinClient implements DaikinClient {
109
135
  ): Promise<Record<string, string>> {
110
136
  const send = () =>
111
137
  this.transport.send({ method, path: DSIOT_ROUTES.multireq, jsonBody });
112
- return parseDsiotMultireqReply(
113
- await (retry ? withRetry(send, this.retryOptions) : send()),
114
- );
138
+ try {
139
+ return parseDsiotMultireqReply(
140
+ await (retry ? withRetry(send, this.retryOptions) : send()),
141
+ );
142
+ } finally {
143
+ // Even a write that failed: one that timed out may still have reached
144
+ // the unit.
145
+ if (method === "PUT") {
146
+ this.setStateCache.clear();
147
+ }
148
+ }
149
+ }
150
+
151
+ private async readStatus(): Promise<Record<string, string>> {
152
+ const values = await this.multireqGet([
153
+ DSIOT_TARGETS.indoorStatus,
154
+ DSIOT_TARGETS.outdoorStatus,
155
+ ]);
156
+ this.setStateCache.set(values);
157
+ return values;
115
158
  }
116
159
  }
@@ -0,0 +1,28 @@
1
+ /** Caches the reply a client last read a unit's state from, for `setState`
2
+ * alone. A write carries more than the fields a control changes, so
3
+ * `setState` builds it from what the unit holds: from this reply while it is
4
+ * fresh, and from a read of its own otherwise. No read is answered from the
5
+ * cache: see DESIGN.md#the-read-before-a-write. */
6
+ export class SetStateCache {
7
+ private kept:
8
+ | { reply: Record<string, string>; readAtInMs: number }
9
+ | undefined;
10
+
11
+ /** `get` answers a reply younger than `maxAgeInMs`, so 0 answers none. */
12
+ constructor(private readonly maxAgeInMs: number) {}
13
+
14
+ set(reply: Record<string, string>): void {
15
+ this.kept = { reply, readAtInMs: performance.now() };
16
+ }
17
+
18
+ get(): Record<string, string> | undefined {
19
+ return this.kept !== undefined &&
20
+ performance.now() - this.kept.readAtInMs < this.maxAgeInMs
21
+ ? this.kept.reply
22
+ : undefined;
23
+ }
24
+
25
+ clear(): void {
26
+ this.kept = undefined;
27
+ }
28
+ }
@@ -9,6 +9,8 @@ import {
9
9
  HUMIDITY_SETTINGS,
10
10
  OPERATING_MODES,
11
11
  type OperatingMode,
12
+ SPECIAL_MODES,
13
+ type SpecialMode,
12
14
  SWING_MODES,
13
15
  type SwingMode,
14
16
  type TargetHumidity,
@@ -16,6 +18,14 @@ import {
16
18
  } from "../../models/state.js";
17
19
 
18
20
  const CAPABILITY_VERSIONS = ["0", "1", "2", "3"];
21
+ // `en_spmode` sets one bit for each special mode the unit takes. Bit 8 is a
22
+ // mode outside `SpecialMode`. The powerful and econo bits count only at
23
+ // `cpv=3` with a `cpv_minor` other than `00`: see UNSUPPORTED.md.
24
+ const SPECIAL_MODE_BITS: Record<SpecialMode, number> = {
25
+ powerful: 1,
26
+ econo: 2,
27
+ streamer: 4,
28
+ };
19
29
  const COOLING_ONLY_MODES: readonly OperatingMode[] = [
20
30
  "cool",
21
31
  "dehumidify",
@@ -100,6 +110,7 @@ export function parseClassicCapabilities(
100
110
  fan: null,
101
111
  },
102
112
  targetHumidities: targetHumidities(fields, modern, offeredModes),
113
+ specialModes: specialModes(fields, cpv, offeredModes),
103
114
  specialOutlet: cpv === "3" && fields.airoutlet_pv === "0",
104
115
  energyMonitoring: fields.elec === "1",
105
116
  demandControl: fields.adp_kind === "4" && fields.dmnd === "1",
@@ -108,6 +119,34 @@ export function parseClassicCapabilities(
108
119
  };
109
120
  }
110
121
 
122
+ // Lists none for a unit that reports no `en_spmode`, and econo in every mode
123
+ // but fan: see UNSUPPORTED.md.
124
+ function specialModes(
125
+ fields: Record<string, string>,
126
+ cpv: string,
127
+ offeredModes: OperatingMode[],
128
+ ): Record<OperatingMode, SpecialMode[]> {
129
+ const bits = Number.parseInt(fields.en_spmode ?? "", 10);
130
+ const followsPowerfulAndEcono =
131
+ cpv === "3" && (fields.cpv_minor ?? "00") !== "00";
132
+ const taken = SPECIAL_MODES.filter(
133
+ (specialMode) =>
134
+ (bits & SPECIAL_MODE_BITS[specialMode]) !== 0 &&
135
+ (specialMode === "streamer" || followsPowerfulAndEcono),
136
+ );
137
+ const inMode = (mode: OperatingMode) =>
138
+ offeredModes.includes(mode)
139
+ ? taken.filter((specialMode) => mode !== "fan" || specialMode !== "econo")
140
+ : [];
141
+ return {
142
+ auto: inMode("auto"),
143
+ dehumidify: inMode("dehumidify"),
144
+ cool: inMode("cool"),
145
+ heat: inMode("heat"),
146
+ fan: inMode("fan"),
147
+ };
148
+ }
149
+
111
150
  function present(fields: Record<string, string>, key: string): boolean {
112
151
  return (fields[key] ?? "") !== "";
113
152
  }
@@ -2,12 +2,14 @@ import type {
2
2
  DemandControl,
3
3
  DemandMode,
4
4
  DeviceGroup,
5
- SpecialMode,
5
+ DeviceMetadata,
6
6
  TerminalScope,
7
7
  } from "../../models/extendedControl.js";
8
+ import type { SpecialMode } from "../../models/state.js";
9
+ import { decodeClassicText } from "./keyValue.js";
8
10
 
9
- // Encodes each control outside `control_info` as the query its classic `set_*`
10
- // endpoint takes.
11
+ // Encodes each control outside `DaikinState` as the query its classic `set_*`
12
+ // endpoint takes, and reads each one back from the reply that reports it.
11
13
 
12
14
  const TERMINAL_SCOPE_CODES: Record<TerminalScope, string> = {
13
15
  self: "0",
@@ -20,8 +22,8 @@ const DEMAND_MODE_CODES: Record<DemandMode, string> = {
20
22
  auto: "2",
21
23
  };
22
24
 
23
- // `spmode_kind` on the wire; control_info's `adv` reports powerful as 2 and
24
- // econo as 12.
25
+ // `spmode_kind` on the wire, which numbers the modes differently from the
26
+ // `adv` that `parseClassicState` reads.
25
27
  const SPECIAL_MODE_KIND_CODES: Record<
26
28
  Exclude<SpecialMode, "streamer">,
27
29
  string
@@ -41,12 +43,27 @@ export function encodeClassicLed(on: boolean): Record<string, string> {
41
43
  return { led: flag(on) };
42
44
  }
43
45
 
46
+ /** Reads the LED from a `basic_info` reply: `null` when it carries no `led`. */
47
+ export function parseClassicLed(
48
+ fields: Record<string, string>,
49
+ ): boolean | null {
50
+ return parseFlag(fields.led);
51
+ }
52
+
44
53
  export function encodeClassicHolidayMode(
45
54
  enabled: boolean,
46
55
  ): Record<string, string> {
47
56
  return { en_hol: flag(enabled) };
48
57
  }
49
58
 
59
+ /** Reads holiday mode from a `basic_info` reply: `null` when it carries no
60
+ * `en_hol`. */
61
+ export function parseClassicHolidayMode(
62
+ fields: Record<string, string>,
63
+ ): boolean | null {
64
+ return parseFlag(fields.en_hol);
65
+ }
66
+
50
67
  export function encodeClassicDemandControl(
51
68
  demand: DemandControl,
52
69
  ): Record<string, string> {
@@ -67,6 +84,16 @@ export function encodeClassicSpecialMode(
67
84
  : { set_spmode: flag(enabled), spmode_kind: SPECIAL_MODE_KIND_CODES[mode] };
68
85
  }
69
86
 
87
+ /** Reads out-of-home access from a `basic_info` reply: `null` when it carries
88
+ * no `method`. */
89
+ export function parseClassicOutOfHomeAccess(
90
+ fields: Record<string, string>,
91
+ ): boolean | null {
92
+ return fields.method === undefined
93
+ ? null
94
+ : fields.method === REMOTE_METHOD_POLLING;
95
+ }
96
+
70
97
  /** Turns `current`, the `get_remote_method` reply, and `enabled` into the
71
98
  * `set_remote_method` query. The endpoint takes the intervals back unchanged,
72
99
  * so the reply's `notice_ip_int` and `notice_sync_int` are copied in. */
@@ -117,6 +144,26 @@ export function encodeClassicGroup(group: DeviceGroup): Record<string, string> {
117
144
  return { grp_name: group.name, en_grp: flag(group.enabled) };
118
145
  }
119
146
 
147
+ /** Reads the name, icon and group from a `basic_info` reply, leaving out each
148
+ * one the reply does not carry. The name and the group's name arrive
149
+ * form-encoded. */
150
+ export function parseClassicMetadata(
151
+ fields: Record<string, string>,
152
+ ): DeviceMetadata {
153
+ return {
154
+ name:
155
+ fields.name === undefined ? undefined : decodeClassicText(fields.name),
156
+ icon: fields.icon,
157
+ group:
158
+ fields.en_grp === undefined
159
+ ? undefined
160
+ : {
161
+ name: decodeClassicText(fields.grp_name ?? ""),
162
+ enabled: fields.en_grp === "1",
163
+ },
164
+ };
165
+ }
166
+
120
167
  export function encodeClassicTerminalKey(key: string): Record<string, string> {
121
168
  return { key };
122
169
  }
@@ -130,3 +177,7 @@ export function encodeClassicTerminalScope(
130
177
  function flag(on: boolean): string {
131
178
  return on ? "1" : "0";
132
179
  }
180
+
181
+ function parseFlag(value: string | undefined): boolean | null {
182
+ return value === undefined ? null : value === "1";
183
+ }
@@ -17,6 +17,16 @@ export function parseClassicKeyValue(body: string): Record<string, string> {
17
17
  return result;
18
18
  }
19
19
 
20
+ /** Decodes a form-encoded value, such as a name: `+` is a space. A value that
21
+ * is not valid percent-encoding is returned as the adapter sent it. */
22
+ export function decodeClassicText(value: string): string {
23
+ try {
24
+ return decodeURIComponent(value.replaceAll("+", " "));
25
+ } catch {
26
+ return value;
27
+ }
28
+ }
29
+
20
30
  export function isClassicOk(fields: Record<string, string>): boolean {
21
31
  return fields.ret === "OK";
22
32
  }
@@ -1,14 +1,16 @@
1
- import type {
2
- DaikinControl,
3
- DaikinSensors,
4
- DaikinState,
5
- FanSpeed,
6
- HorizontalPosition,
7
- HumiditySetting,
8
- OperatingMode,
9
- SwingMode,
10
- TargetHumidity,
11
- VerticalPosition,
1
+ import {
2
+ type DaikinControl,
3
+ type DaikinSensors,
4
+ type DaikinState,
5
+ type FanSpeed,
6
+ type HorizontalPosition,
7
+ type HumiditySetting,
8
+ type OperatingMode,
9
+ SPECIAL_MODES,
10
+ type SpecialMode,
11
+ type SwingMode,
12
+ type TargetHumidity,
13
+ type VerticalPosition,
12
14
  } from "../../models/state.js";
13
15
  import { takesClassicDirection, takesClassicHumidity } from "./capabilities.js";
14
16
 
@@ -123,6 +125,13 @@ const HUMIDITY_SETTINGS_BY_CODE: Record<string, HumiditySetting> =
123
125
  ]),
124
126
  );
125
127
 
128
+ // `adv` holds one code for each special mode that is on, separated by `/`.
129
+ const SPECIAL_MODE_ADV_CODES: Record<SpecialMode, string> = {
130
+ powerful: "2",
131
+ econo: "12",
132
+ streamer: "13",
133
+ };
134
+
126
135
  // Fan mode keeps no setpoint memory, and dehumidify runs the fan on auto only.
127
136
  const NO_SETPOINT = "--";
128
137
  const DEHUMIDIFY_FAN_SPEED = "A";
@@ -131,7 +140,8 @@ const NO_LOUVER = "-";
131
140
 
132
141
  /** Turns a `get_control_info` reply into a `DaikinState`. A `mode` or `f_rate`
133
142
  * outside the model reads as `auto`, an `f_dir` outside it as `off`, and an
134
- * `f_dir_ud` or `f_dir_lr` outside it as `null`. */
143
+ * `f_dir_ud` or `f_dir_lr` outside it as `null`. A code in `adv` outside
144
+ * `SpecialMode` is left out. */
135
145
  export function parseClassicState(fields: Record<string, string>): DaikinState {
136
146
  return {
137
147
  power: fields.pow === "1",
@@ -143,6 +153,7 @@ export function parseClassicState(fields: Record<string, string>): DaikinState {
143
153
  verticalPosition: VERTICAL_POSITIONS_BY_CODE[fields.f_dir_ud ?? ""] ?? null,
144
154
  horizontalPosition:
145
155
  HORIZONTAL_POSITIONS_BY_CODE[fields.f_dir_lr ?? ""] ?? null,
156
+ specialModes: parseSpecialModes(fields.adv),
146
157
  };
147
158
  }
148
159
 
@@ -269,6 +280,13 @@ export function applyClassicControl(
269
280
  return next;
270
281
  }
271
282
 
283
+ function parseSpecialModes(adv: string | undefined): SpecialMode[] {
284
+ const codes = (adv ?? "").split("/");
285
+ return SPECIAL_MODES.filter((mode) =>
286
+ codes.includes(SPECIAL_MODE_ADV_CODES[mode]),
287
+ );
288
+ }
289
+
272
290
  function parseTargetHumidity(shum: string | undefined): TargetHumidity | null {
273
291
  const setting = HUMIDITY_SETTINGS_BY_CODE[shum ?? ""];
274
292
  if (setting !== undefined) {