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.
- package/DESIGN.md +113 -2
- package/README.md +131 -13
- package/UNSUPPORTED.md +42 -4
- package/dist/clients/classic.d.ts +2 -2
- package/dist/clients/classic.d.ts.map +1 -1
- package/dist/clients/classic.js +4 -4
- package/dist/clients/classic.js.map +1 -1
- package/dist/clients/client.d.ts +6 -2
- package/dist/clients/client.d.ts.map +1 -1
- package/dist/clients/dsiot.d.ts +3 -1
- package/dist/clients/dsiot.d.ts.map +1 -1
- package/dist/clients/dsiot.js +11 -3
- package/dist/clients/dsiot.js.map +1 -1
- package/dist/codecs/classic/capabilities.d.ts.map +1 -1
- package/dist/codecs/classic/capabilities.js +5 -11
- package/dist/codecs/classic/capabilities.js.map +1 -1
- package/dist/codecs/classic/extendedControl.d.ts.map +1 -1
- package/dist/codecs/classic/extendedControl.js +10 -19
- package/dist/codecs/classic/extendedControl.js.map +1 -1
- package/dist/codecs/classic/state.d.ts.map +1 -1
- package/dist/codecs/classic/state.js +36 -95
- package/dist/codecs/classic/state.js.map +1 -1
- package/dist/codecs/classic/tables.d.ts +32 -0
- package/dist/codecs/classic/tables.d.ts.map +1 -0
- package/dist/codecs/classic/tables.js +94 -0
- package/dist/codecs/classic/tables.js.map +1 -0
- package/dist/codecs/credentials.d.ts +3 -2
- package/dist/codecs/credentials.d.ts.map +1 -1
- package/dist/codecs/credentials.js +8 -3
- package/dist/codecs/credentials.js.map +1 -1
- package/dist/codecs/device.d.ts +2 -1
- package/dist/codecs/device.d.ts.map +1 -1
- package/dist/codecs/device.js +7 -6
- package/dist/codecs/device.js.map +1 -1
- package/dist/codecs/dsiot/capabilities.d.ts.map +1 -1
- package/dist/codecs/dsiot/capabilities.js +8 -15
- package/dist/codecs/dsiot/capabilities.js.map +1 -1
- package/dist/codecs/dsiot/device.d.ts +4 -2
- package/dist/codecs/dsiot/device.d.ts.map +1 -1
- package/dist/codecs/dsiot/device.js +8 -8
- package/dist/codecs/dsiot/device.js.map +1 -1
- package/dist/codecs/dsiot/multireq.d.ts +0 -18
- package/dist/codecs/dsiot/multireq.d.ts.map +1 -1
- package/dist/codecs/dsiot/multireq.js +2 -24
- package/dist/codecs/dsiot/multireq.js.map +1 -1
- package/dist/codecs/dsiot/state.d.ts +6 -62
- package/dist/codecs/dsiot/state.d.ts.map +1 -1
- package/dist/codecs/dsiot/state.js +29 -148
- package/dist/codecs/dsiot/state.js.map +1 -1
- package/dist/codecs/dsiot/tables.d.ts +116 -0
- package/dist/codecs/dsiot/tables.d.ts.map +1 -0
- package/dist/codecs/dsiot/tables.js +185 -0
- package/dist/codecs/dsiot/tables.js.map +1 -0
- package/dist/discovery.d.ts +2 -1
- package/dist/discovery.d.ts.map +1 -1
- package/dist/discovery.js +9 -5
- package/dist/discovery.js.map +1 -1
- package/dist/factory.d.ts +7 -4
- package/dist/factory.d.ts.map +1 -1
- package/dist/factory.js +4 -2
- package/dist/factory.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/models/device.d.ts +3 -1
- package/dist/models/device.d.ts.map +1 -1
- package/dist/models/state.d.ts +2 -3
- package/dist/models/state.d.ts.map +1 -1
- package/dist/simulators/classic.d.ts +61 -0
- package/dist/simulators/classic.d.ts.map +1 -0
- package/dist/simulators/classic.js +473 -0
- package/dist/simulators/classic.js.map +1 -0
- package/dist/simulators/dsiot.d.ts +49 -0
- package/dist/simulators/dsiot.d.ts.map +1 -0
- package/dist/simulators/dsiot.js +519 -0
- package/dist/simulators/dsiot.js.map +1 -0
- package/dist/simulators/index.d.ts +4 -0
- package/dist/simulators/index.d.ts.map +1 -0
- package/dist/simulators/index.js +3 -0
- package/dist/simulators/index.js.map +1 -0
- package/dist/simulators/simulator.d.ts +116 -0
- package/dist/simulators/simulator.d.ts.map +1 -0
- package/dist/simulators/simulator.js +141 -0
- package/dist/simulators/simulator.js.map +1 -0
- package/dist/transport.d.ts +6 -2
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +7 -2
- package/dist/transport.js.map +1 -1
- package/package.json +5 -2
- package/src/clients/classic.ts +8 -8
- package/src/clients/client.ts +7 -1
- package/src/clients/dsiot.ts +12 -2
- package/src/codecs/classic/capabilities.ts +8 -11
- package/src/codecs/classic/extendedControl.ts +14 -25
- package/src/codecs/classic/state.ts +49 -101
- package/src/codecs/classic/tables.ts +123 -0
- package/src/codecs/credentials.ts +11 -3
- package/src/codecs/device.ts +8 -7
- package/src/codecs/dsiot/capabilities.ts +10 -17
- package/src/codecs/dsiot/device.ts +8 -9
- package/src/codecs/dsiot/multireq.ts +2 -24
- package/src/codecs/dsiot/state.ts +57 -193
- package/src/codecs/dsiot/tables.ts +249 -0
- package/src/discovery.ts +9 -5
- package/src/factory.ts +7 -4
- package/src/index.ts +2 -0
- package/src/models/device.ts +3 -1
- package/src/models/state.ts +2 -3
- package/src/simulators/classic.ts +635 -0
- package/src/simulators/dsiot.ts +736 -0
- package/src/simulators/index.ts +9 -0
- package/src/simulators/simulator.ts +281 -0
- package/src/transport.ts +10 -3
package/src/models/device.ts
CHANGED
|
@@ -9,6 +9,9 @@ export type DaikinProtocol = (typeof DAIKIN_PROTOCOLS)[number];
|
|
|
9
9
|
* each `DaikinDevice`, and `DaikinClient.getDeviceInfo` reads it for a device
|
|
10
10
|
* written by hand. */
|
|
11
11
|
export interface DaikinDeviceInfo {
|
|
12
|
+
/** The name the adapter was given, such as `Bedroom`. Left out when the
|
|
13
|
+
* adapter reports none. */
|
|
14
|
+
name?: string;
|
|
12
15
|
/** In pairs joined by colons, in the letter case the adapter sends, such as
|
|
13
16
|
* `A4:08:EA:CC:91:D4`. Left out when the adapter reports none. */
|
|
14
17
|
macAddress?: string;
|
|
@@ -25,7 +28,6 @@ export interface DaikinDevice extends DaikinDeviceInfo {
|
|
|
25
28
|
/** Address, with `:port` when the adapter announces one. */
|
|
26
29
|
host: string;
|
|
27
30
|
protocol: DaikinProtocol;
|
|
28
|
-
name?: string;
|
|
29
31
|
/** The adapter answers a terminal uuid only once it is registered with the
|
|
30
32
|
* KEY on the adapter's label (`registerTerminal`). Classic only. */
|
|
31
33
|
requiresRegistration?: boolean;
|
package/src/models/state.ts
CHANGED
|
@@ -106,9 +106,8 @@ export interface DaikinState {
|
|
|
106
106
|
* fields to change. The client overlays the control on the state the unit
|
|
107
107
|
* holds, and writes the whole state back. It drops a field the unit does not take in
|
|
108
108
|
* the target mode, such as a louver position outside
|
|
109
|
-
* `DaikinCapabilities.verticalPositions`. It takes no `specialModes`:
|
|
110
|
-
*
|
|
111
|
-
* UNSUPPORTED.md says what is not known about the rest. */
|
|
109
|
+
* `DaikinCapabilities.verticalPositions`. It takes no `specialModes`:
|
|
110
|
+
* `DaikinClient.setSpecialMode` turns one on or off. */
|
|
112
111
|
export type DaikinControl = Partial<Omit<DaikinState, "specialModes">>;
|
|
113
112
|
|
|
114
113
|
/** A reading is `null` when the unit does not report it. */
|
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CLASSIC_COOLING_ONLY_TYPE,
|
|
3
|
+
CLASSIC_FAN_SPEED_CODES,
|
|
4
|
+
CLASSIC_HORIZONTAL_POSITION_CODES,
|
|
5
|
+
CLASSIC_HUMIDITY_SETTING_CODES,
|
|
6
|
+
CLASSIC_LOUVER_STOP,
|
|
7
|
+
CLASSIC_LOUVER_SWING,
|
|
8
|
+
CLASSIC_NO_SETPOINT,
|
|
9
|
+
CLASSIC_OPERATING_MODE_CODES,
|
|
10
|
+
CLASSIC_REMOTE_METHODS,
|
|
11
|
+
CLASSIC_SPECIAL_MODE_ADV_CODES,
|
|
12
|
+
CLASSIC_SPECIAL_MODE_BITS,
|
|
13
|
+
CLASSIC_SPECIAL_MODE_KIND_CODES,
|
|
14
|
+
CLASSIC_SWING_MODE_CODES,
|
|
15
|
+
CLASSIC_TERMINAL_SCOPE_CODES,
|
|
16
|
+
CLASSIC_VERTICAL_POSITION_CODES,
|
|
17
|
+
} from "../codecs/classic/tables.js";
|
|
18
|
+
import type { DaikinDeviceInfo } from "../models/device.js";
|
|
19
|
+
import type { DaikinSensors, DaikinState } from "../models/state.js";
|
|
20
|
+
import {
|
|
21
|
+
type DaikinSimulator,
|
|
22
|
+
type DaikinSimulatorRequest,
|
|
23
|
+
type DaikinSimulatorUnit,
|
|
24
|
+
formatKeyValue,
|
|
25
|
+
type SimulatorReply,
|
|
26
|
+
type StartSimulatorOptions,
|
|
27
|
+
startSimulator,
|
|
28
|
+
unanswered,
|
|
29
|
+
} from "./simulator.js";
|
|
30
|
+
|
|
31
|
+
// A classic adapter (BRP069, BRP072) as an HTTP server. It reads the adapter's
|
|
32
|
+
// codes from the codecs' tables and shares none of their functions: see
|
|
33
|
+
// DESIGN.md#simulators.
|
|
34
|
+
|
|
35
|
+
const DEFAULT_BASIC_INFO: Record<string, string> = {
|
|
36
|
+
type: "aircon",
|
|
37
|
+
reg: "th",
|
|
38
|
+
ver: "1_2_51",
|
|
39
|
+
name: "Simulator",
|
|
40
|
+
icon: "0",
|
|
41
|
+
method: CLASSIC_REMOTE_METHODS.homeOnly,
|
|
42
|
+
adp_kind: "4",
|
|
43
|
+
cpv: "3",
|
|
44
|
+
cpv_minor: "20",
|
|
45
|
+
led: "1",
|
|
46
|
+
mac: "02DA1C000001",
|
|
47
|
+
en_hol: "0",
|
|
48
|
+
grp_name: "",
|
|
49
|
+
en_grp: "0",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const DEFAULT_MODEL_INFO: Record<string, string> = {
|
|
53
|
+
model: "0ABB",
|
|
54
|
+
type: "N",
|
|
55
|
+
en_frate: "1",
|
|
56
|
+
en_frate_auto: "1",
|
|
57
|
+
en_frate_silent: "1",
|
|
58
|
+
frate_steps: "5",
|
|
59
|
+
en_fdir: "1",
|
|
60
|
+
s_fdir: "3",
|
|
61
|
+
s_humd: "0",
|
|
62
|
+
en_spmode: "7",
|
|
63
|
+
elec: "1",
|
|
64
|
+
dmnd: "1",
|
|
65
|
+
en_scdltmr: "1",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const DEFAULT_CONTROL_INFO: Record<string, string> = {
|
|
69
|
+
pow: "0",
|
|
70
|
+
mode: "3",
|
|
71
|
+
adv: "",
|
|
72
|
+
stemp: "24.0",
|
|
73
|
+
shum: "0",
|
|
74
|
+
dt1: "25.0",
|
|
75
|
+
dt2: "M",
|
|
76
|
+
dt3: "24.0",
|
|
77
|
+
dt4: "25.0",
|
|
78
|
+
dh1: "0",
|
|
79
|
+
dh2: "0",
|
|
80
|
+
dh3: "0",
|
|
81
|
+
dh4: "0",
|
|
82
|
+
f_rate: "A",
|
|
83
|
+
f_dir: "0",
|
|
84
|
+
dfr1: "A",
|
|
85
|
+
dfr2: "A",
|
|
86
|
+
dfr3: "A",
|
|
87
|
+
dfr4: "A",
|
|
88
|
+
dfr6: "A",
|
|
89
|
+
dfd1: "0",
|
|
90
|
+
dfd2: "0",
|
|
91
|
+
dfd3: "0",
|
|
92
|
+
dfd4: "0",
|
|
93
|
+
dfd6: "0",
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const DEFAULT_SENSOR_INFO: Record<string, string> = {
|
|
97
|
+
htemp: "26.0",
|
|
98
|
+
hhum: "55",
|
|
99
|
+
otemp: "30.0",
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const DEFAULT_REMOTE_METHOD: Record<string, string> = {
|
|
103
|
+
notice_ip_int: "3600",
|
|
104
|
+
notice_sync_int: "60",
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const REGISTER_TERMINAL_PATH = "/common/register_terminal";
|
|
108
|
+
|
|
109
|
+
// A `set_control_info` without one of these is answered `PARAM NG`. `shum`
|
|
110
|
+
// joins them on a unit whose `get_control_info` reports it.
|
|
111
|
+
const REQUIRED_CONTROL_PARAMETERS = ["pow", "mode", "stemp", "f_rate"];
|
|
112
|
+
// The parameters `set_control_info` stores: the control fields, and the
|
|
113
|
+
// memory of a mode by name, such as `dt3` and `dfd4`.
|
|
114
|
+
const CONTROL_PARAMETER_PATTERN =
|
|
115
|
+
/^(pow|mode|stemp|shum|f_rate|f_dir|f_dir_ud|f_dir_lr|(dt|dh|dfd)\w)$/;
|
|
116
|
+
// The control field each memory prefix remembers: `dt3` holds the `stemp` of
|
|
117
|
+
// mode 3.
|
|
118
|
+
const MODE_MEMORY_FIELDS = {
|
|
119
|
+
dt: "stemp",
|
|
120
|
+
dh: "shum",
|
|
121
|
+
dfr: "f_rate",
|
|
122
|
+
dfd: "f_dir",
|
|
123
|
+
} as const;
|
|
124
|
+
|
|
125
|
+
// `spmode_kind` 4 is heat plus, which `adv` reports as 4. It is outside
|
|
126
|
+
// `SpecialMode`: see UNSUPPORTED.md.
|
|
127
|
+
const HEAT_PLUS_KIND_CODE = "4";
|
|
128
|
+
const HEAT_PLUS_ADV_CODE = "4";
|
|
129
|
+
// Powerful, econo and heat plus replace one another in `adv`.
|
|
130
|
+
const ADV_CODES_BY_SPECIAL_MODE_KIND: Record<string, string> = {
|
|
131
|
+
[CLASSIC_SPECIAL_MODE_KIND_CODES.powerful]:
|
|
132
|
+
CLASSIC_SPECIAL_MODE_ADV_CODES.powerful,
|
|
133
|
+
[CLASSIC_SPECIAL_MODE_KIND_CODES.econo]: CLASSIC_SPECIAL_MODE_ADV_CODES.econo,
|
|
134
|
+
[HEAT_PLUS_KIND_CODE]: HEAT_PLUS_ADV_CODE,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// A sensor field the unit has no reading for.
|
|
138
|
+
const NO_READING = "-";
|
|
139
|
+
|
|
140
|
+
// `type` in `get_model_info` on a heat pump.
|
|
141
|
+
const HEAT_PUMP_TYPE = "N";
|
|
142
|
+
// `s_fdir`: the axes the louvers swing on.
|
|
143
|
+
const SWING_AXES_CODES = { none: "0", vertical: "1", both: "3" } as const;
|
|
144
|
+
|
|
145
|
+
/** What a simulated classic unit has. */
|
|
146
|
+
export interface ClassicSimulatorUnit extends DaikinSimulatorUnit {
|
|
147
|
+
/** How many fan levels the unit takes: `level1` and `level5` for 2,
|
|
148
|
+
* `level3` as well for 3, and all five for 5. Defaults to 5. */
|
|
149
|
+
fanLevels?: 2 | 3 | 5;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface StartClassicSimulatorOptions extends StartSimulatorOptions {
|
|
153
|
+
/** What the unit has. Defaults to a heat pump with every fan speed, swing
|
|
154
|
+
* on both axes and all three special modes. A field of `modelInfo`
|
|
155
|
+
* replaces what this sets. */
|
|
156
|
+
unit?: ClassicSimulatorUnit;
|
|
157
|
+
/** Fields added to the `/common/basic_info` reply, each replacing a default
|
|
158
|
+
* of the same name. Defaults to none. */
|
|
159
|
+
basicInfo?: Record<string, string>;
|
|
160
|
+
/** Fields added to the `/aircon/get_model_info` reply, each replacing a
|
|
161
|
+
* default of the same name. Defaults to none. */
|
|
162
|
+
modelInfo?: Record<string, string>;
|
|
163
|
+
/** Fields added to the `/aircon/get_control_info` reply, each replacing a
|
|
164
|
+
* default of the same name. Defaults to none. */
|
|
165
|
+
controlInfo?: Record<string, string>;
|
|
166
|
+
/** Fields added to the `/aircon/get_sensor_info` reply, each replacing a
|
|
167
|
+
* default of the same name. Defaults to none. */
|
|
168
|
+
sensorInfo?: Record<string, string>;
|
|
169
|
+
/** The KEY on the adapter's label. With one, the adapter requires
|
|
170
|
+
* registration: it reports `en_secure=1`, and answers a terminal uuid with
|
|
171
|
+
* HTTP 403 until `/common/register_terminal` is sent this key. Defaults to
|
|
172
|
+
* none: every terminal is answered. */
|
|
173
|
+
key?: string;
|
|
174
|
+
/** The child lock code. With one, the adapter reports `lpw_flag=1`, and
|
|
175
|
+
* answers `ret=LPW NG` to a request whose `lpw` is not this code. Defaults
|
|
176
|
+
* to none: no request needs an `lpw`. */
|
|
177
|
+
localPassword?: string;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** A classic adapter and the unit behind it. Each field map is what its
|
|
181
|
+
* endpoint answers, in the adapter's own names and encodings, without the
|
|
182
|
+
* `ret`. A write through the adapter changes them, and so do `setState` and
|
|
183
|
+
* `setSensors`. Change a field directly for what those two do not cover. */
|
|
184
|
+
export interface ClassicDaikinSimulator extends DaikinSimulator {
|
|
185
|
+
/** What `/common/basic_info` answers. `name` and `grp_name` are held
|
|
186
|
+
* percent-encoded, as the reply carries them. */
|
|
187
|
+
readonly basicInfo: Record<string, string>;
|
|
188
|
+
/** What `/aircon/get_model_info` answers. */
|
|
189
|
+
readonly modelInfo: Record<string, string>;
|
|
190
|
+
/** What `/aircon/get_control_info` answers. */
|
|
191
|
+
readonly controlInfo: Record<string, string>;
|
|
192
|
+
/** What `/aircon/get_sensor_info` answers. */
|
|
193
|
+
readonly sensorInfo: Record<string, string>;
|
|
194
|
+
/** What `/common/get_remote_method` answers. */
|
|
195
|
+
readonly remoteMethod: Record<string, string>;
|
|
196
|
+
/** The terminal uuids `/common/register_terminal` has registered. Only an
|
|
197
|
+
* adapter started with a `key` checks them. */
|
|
198
|
+
readonly registeredTerminalUuids: Set<string>;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Starts a classic adapter on a free port, as a heat pump that is off, in
|
|
202
|
+
* cool at 24 °C. It answers the endpoints `ClassicDaikinClient` has a typed
|
|
203
|
+
* method for, over HTTP, and answers any other request with HTTP 501:
|
|
204
|
+
* UNSUPPORTED.md lists what it leaves out and where its answer is not known
|
|
205
|
+
* to be an adapter's. Call `stop` when done with it. */
|
|
206
|
+
export async function startClassicSimulator(
|
|
207
|
+
options: StartClassicSimulatorOptions = {},
|
|
208
|
+
): Promise<ClassicDaikinSimulator> {
|
|
209
|
+
const basicInfo: Record<string, string> = {
|
|
210
|
+
...DEFAULT_BASIC_INFO,
|
|
211
|
+
lpw_flag: flag(options.localPassword !== undefined),
|
|
212
|
+
...(options.key === undefined ? {} : { en_secure: "1" }),
|
|
213
|
+
...options.basicInfo,
|
|
214
|
+
};
|
|
215
|
+
const modelInfo = {
|
|
216
|
+
...DEFAULT_MODEL_INFO,
|
|
217
|
+
...encodeUnit(options.unit ?? {}),
|
|
218
|
+
...options.modelInfo,
|
|
219
|
+
};
|
|
220
|
+
const controlInfo = { ...DEFAULT_CONTROL_INFO, ...options.controlInfo };
|
|
221
|
+
const sensorInfo = { ...DEFAULT_SENSOR_INFO, ...options.sensorInfo };
|
|
222
|
+
const remoteMethod = { ...DEFAULT_REMOTE_METHOD };
|
|
223
|
+
const registeredTerminalUuids = new Set<string>();
|
|
224
|
+
|
|
225
|
+
const setState = (state: Partial<DaikinState>) =>
|
|
226
|
+
applyState(controlInfo, state);
|
|
227
|
+
const setSensors = (sensors: Partial<DaikinSensors>) =>
|
|
228
|
+
Object.assign(sensorInfo, encodeSensors(sensors));
|
|
229
|
+
// Before the server starts, so a state that throws leaves nothing
|
|
230
|
+
// listening.
|
|
231
|
+
Object.assign(basicInfo, encodeDeviceInfo(options.deviceInfo ?? {}));
|
|
232
|
+
setState(options.state ?? {});
|
|
233
|
+
setSensors(options.sensors ?? {});
|
|
234
|
+
|
|
235
|
+
const reads: Record<string, Record<string, string>> = {
|
|
236
|
+
"/common/basic_info": basicInfo,
|
|
237
|
+
"/common/get_remote_method": remoteMethod,
|
|
238
|
+
"/aircon/get_model_info": modelInfo,
|
|
239
|
+
"/aircon/get_control_info": controlInfo,
|
|
240
|
+
"/aircon/get_sensor_info": sensorInfo,
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// Each write returns false for a request that lacks a parameter.
|
|
244
|
+
const writes: Record<
|
|
245
|
+
string,
|
|
246
|
+
(query: Record<string, string>, terminalUuid: string | undefined) => boolean
|
|
247
|
+
> = {
|
|
248
|
+
"/aircon/set_control_info": (query) => {
|
|
249
|
+
const required =
|
|
250
|
+
"shum" in controlInfo
|
|
251
|
+
? [...REQUIRED_CONTROL_PARAMETERS, "shum"]
|
|
252
|
+
: REQUIRED_CONTROL_PARAMETERS;
|
|
253
|
+
if (!hasEvery(query, required)) {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
for (const [name, value] of Object.entries(query)) {
|
|
257
|
+
if (CONTROL_PARAMETER_PATTERN.test(name) && name in controlInfo) {
|
|
258
|
+
controlInfo[name] = value;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
rememberMode(controlInfo);
|
|
262
|
+
return true;
|
|
263
|
+
},
|
|
264
|
+
"/aircon/set_special_mode": (query) => {
|
|
265
|
+
const codes = new Set(controlInfo.adv?.split("/").filter(Boolean));
|
|
266
|
+
const kindCode = ADV_CODES_BY_SPECIAL_MODE_KIND[query.spmode_kind ?? ""];
|
|
267
|
+
if (query.en_streamer !== undefined) {
|
|
268
|
+
codes.delete(CLASSIC_SPECIAL_MODE_ADV_CODES.streamer);
|
|
269
|
+
if (query.en_streamer === "1") {
|
|
270
|
+
codes.add(CLASSIC_SPECIAL_MODE_ADV_CODES.streamer);
|
|
271
|
+
}
|
|
272
|
+
} else if (query.set_spmode !== undefined && kindCode !== undefined) {
|
|
273
|
+
for (const code of Object.values(ADV_CODES_BY_SPECIAL_MODE_KIND)) {
|
|
274
|
+
codes.delete(code);
|
|
275
|
+
}
|
|
276
|
+
if (query.set_spmode === "1") {
|
|
277
|
+
codes.add(kindCode);
|
|
278
|
+
}
|
|
279
|
+
} else {
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
controlInfo.adv = [...codes]
|
|
283
|
+
.sort((first, second) => Number(first) - Number(second))
|
|
284
|
+
.join("/");
|
|
285
|
+
return true;
|
|
286
|
+
},
|
|
287
|
+
"/aircon/set_demand_control": (query) =>
|
|
288
|
+
hasEvery(query, ["demand_type", "en_demand", "mode", "max_pow"]),
|
|
289
|
+
"/aircon/set_price": (query) => hasEvery(query, ["price_int", "price_dec"]),
|
|
290
|
+
"/aircon/set_target": (query) => hasEvery(query, ["target"]),
|
|
291
|
+
"/common/set_led": (query) => copy(query, basicInfo, ["led"]),
|
|
292
|
+
"/common/set_holiday": (query) => copy(query, basicInfo, ["en_hol"]),
|
|
293
|
+
"/common/set_icon": (query) => copy(query, basicInfo, ["icon"]),
|
|
294
|
+
"/common/set_name": (query) => {
|
|
295
|
+
if (query.name === undefined) {
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
basicInfo.name = encodeURIComponent(query.name);
|
|
299
|
+
return true;
|
|
300
|
+
},
|
|
301
|
+
"/common/set_group": (query) => {
|
|
302
|
+
if (query.grp_name === undefined || query.en_grp === undefined) {
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
basicInfo.grp_name = encodeURIComponent(query.grp_name);
|
|
306
|
+
basicInfo.en_grp = query.en_grp;
|
|
307
|
+
return true;
|
|
308
|
+
},
|
|
309
|
+
"/common/set_remote_method": (query) => {
|
|
310
|
+
if (!copy(query, basicInfo, ["method"])) {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
for (const name of Object.keys(remoteMethod)) {
|
|
314
|
+
const value = query[name];
|
|
315
|
+
if (value !== undefined) {
|
|
316
|
+
remoteMethod[name] = value;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return true;
|
|
320
|
+
},
|
|
321
|
+
"/common/reboot": () => true,
|
|
322
|
+
"/common/unregister_terminal": (query, terminalUuid) => {
|
|
323
|
+
const others = [...registeredTerminalUuids].filter(
|
|
324
|
+
(uuid) => uuid !== terminalUuid,
|
|
325
|
+
);
|
|
326
|
+
if (
|
|
327
|
+
query.type === CLASSIC_TERMINAL_SCOPE_CODES.self &&
|
|
328
|
+
terminalUuid !== undefined
|
|
329
|
+
) {
|
|
330
|
+
registeredTerminalUuids.delete(terminalUuid);
|
|
331
|
+
} else if (query.type === CLASSIC_TERMINAL_SCOPE_CODES.others) {
|
|
332
|
+
for (const uuid of others) {
|
|
333
|
+
registeredTerminalUuids.delete(uuid);
|
|
334
|
+
}
|
|
335
|
+
} else if (query.type === CLASSIC_TERMINAL_SCOPE_CODES.all) {
|
|
336
|
+
registeredTerminalUuids.clear();
|
|
337
|
+
} else {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
return true;
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const answer = (request: DaikinSimulatorRequest): SimulatorReply => {
|
|
345
|
+
const fields = reads[request.path];
|
|
346
|
+
const write = writes[request.path];
|
|
347
|
+
const registers = request.path === REGISTER_TERMINAL_PATH;
|
|
348
|
+
if (
|
|
349
|
+
request.method !== "GET" ||
|
|
350
|
+
(fields === undefined && write === undefined && !registers)
|
|
351
|
+
) {
|
|
352
|
+
return unanswered(request);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const terminalUuid = terminalUuidOf(request);
|
|
356
|
+
if (registers) {
|
|
357
|
+
if (options.key === undefined) {
|
|
358
|
+
return unanswered(request);
|
|
359
|
+
}
|
|
360
|
+
if (request.query.key !== options.key || terminalUuid === undefined) {
|
|
361
|
+
return FORBIDDEN;
|
|
362
|
+
}
|
|
363
|
+
registeredTerminalUuids.add(terminalUuid);
|
|
364
|
+
return reply("OK");
|
|
365
|
+
}
|
|
366
|
+
if (
|
|
367
|
+
options.key !== undefined &&
|
|
368
|
+
(terminalUuid === undefined || !registeredTerminalUuids.has(terminalUuid))
|
|
369
|
+
) {
|
|
370
|
+
return FORBIDDEN;
|
|
371
|
+
}
|
|
372
|
+
if (
|
|
373
|
+
options.localPassword !== undefined &&
|
|
374
|
+
request.query.lpw !== options.localPassword
|
|
375
|
+
) {
|
|
376
|
+
return reply("LPW NG");
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (fields !== undefined) {
|
|
380
|
+
return reply("OK", fields);
|
|
381
|
+
}
|
|
382
|
+
return reply(write?.(request.query, terminalUuid) ? "OK" : "PARAM NG");
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
// The discovery reply carries the fields of `basic_info`.
|
|
386
|
+
const server = await startSimulator(options, answer, () => basicInfo);
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
...server,
|
|
390
|
+
basicInfo,
|
|
391
|
+
modelInfo,
|
|
392
|
+
controlInfo,
|
|
393
|
+
sensorInfo,
|
|
394
|
+
remoteMethod,
|
|
395
|
+
registeredTerminalUuids,
|
|
396
|
+
setState,
|
|
397
|
+
setSensors,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// The `get_model_info` fields that say what `unit` has. A field `unit` leaves
|
|
402
|
+
// out is left to the defaults.
|
|
403
|
+
function encodeUnit(unit: ClassicSimulatorUnit): Record<string, string> {
|
|
404
|
+
const fields: Record<string, string> = {};
|
|
405
|
+
if (unit.coolingOnly !== undefined) {
|
|
406
|
+
fields.type = unit.coolingOnly ? CLASSIC_COOLING_ONLY_TYPE : HEAT_PUMP_TYPE;
|
|
407
|
+
}
|
|
408
|
+
if (unit.fan !== undefined) {
|
|
409
|
+
fields.en_frate = flag(unit.fan);
|
|
410
|
+
}
|
|
411
|
+
if (unit.fanLevels !== undefined) {
|
|
412
|
+
fields.frate_steps = String(unit.fanLevels);
|
|
413
|
+
}
|
|
414
|
+
if (unit.swing !== undefined) {
|
|
415
|
+
fields.en_fdir = flag(unit.swing !== "none");
|
|
416
|
+
fields.s_fdir = SWING_AXES_CODES[unit.swing];
|
|
417
|
+
}
|
|
418
|
+
if (unit.specialModes !== undefined) {
|
|
419
|
+
fields.en_spmode = String(
|
|
420
|
+
unit.specialModes.reduce(
|
|
421
|
+
(bits, mode) => bits | CLASSIC_SPECIAL_MODE_BITS[mode],
|
|
422
|
+
0,
|
|
423
|
+
),
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
if (unit.coolTemperaturesInC !== undefined) {
|
|
427
|
+
fields.cmlmt_l = unit.coolTemperaturesInC.min.toFixed(1);
|
|
428
|
+
fields.cmlmt_h = unit.coolTemperaturesInC.max.toFixed(1);
|
|
429
|
+
}
|
|
430
|
+
if (unit.heatTemperaturesInC !== undefined) {
|
|
431
|
+
fields.hmlmt_l = unit.heatTemperaturesInC.min.toFixed(1);
|
|
432
|
+
fields.hmlmt_h = unit.heatTemperaturesInC.max.toFixed(1);
|
|
433
|
+
}
|
|
434
|
+
return fields;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// The `basic_info` fields: the name percent-encoded, the MAC address without
|
|
438
|
+
// its colons, and the version with underscores for its dots.
|
|
439
|
+
function encodeDeviceInfo(info: DaikinDeviceInfo): Record<string, string> {
|
|
440
|
+
return {
|
|
441
|
+
...(info.name === undefined ? {} : { name: encodeURIComponent(info.name) }),
|
|
442
|
+
...(info.macAddress === undefined
|
|
443
|
+
? {}
|
|
444
|
+
: { mac: info.macAddress.replaceAll(":", "") }),
|
|
445
|
+
...(info.firmwareVersion === undefined
|
|
446
|
+
? {}
|
|
447
|
+
: { ver: info.firmwareVersion.replaceAll(".", "_") }),
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function encodeSensors(
|
|
452
|
+
sensors: Partial<DaikinSensors>,
|
|
453
|
+
): Record<string, string> {
|
|
454
|
+
const fields: Record<string, string> = {};
|
|
455
|
+
const {
|
|
456
|
+
indoorTemperatureInC,
|
|
457
|
+
outdoorTemperatureInC,
|
|
458
|
+
indoorHumidityInPercent,
|
|
459
|
+
} = sensors;
|
|
460
|
+
if (indoorTemperatureInC !== undefined) {
|
|
461
|
+
fields.htemp = indoorTemperatureInC?.toFixed(1) ?? NO_READING;
|
|
462
|
+
}
|
|
463
|
+
if (outdoorTemperatureInC !== undefined) {
|
|
464
|
+
fields.otemp = outdoorTemperatureInC?.toFixed(1) ?? NO_READING;
|
|
465
|
+
}
|
|
466
|
+
if (indoorHumidityInPercent !== undefined) {
|
|
467
|
+
fields.hhum = indoorHumidityInPercent?.toFixed(0) ?? NO_READING;
|
|
468
|
+
}
|
|
469
|
+
return fields;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Changes `controlInfo` as the unit's remote control would: see
|
|
473
|
+
// `DaikinSimulator.setState`.
|
|
474
|
+
function applyState(
|
|
475
|
+
controlInfo: Record<string, string>,
|
|
476
|
+
state: Partial<DaikinState>,
|
|
477
|
+
): void {
|
|
478
|
+
const hold = (field: string, value: string) => {
|
|
479
|
+
if (!(field in controlInfo)) {
|
|
480
|
+
throw new Error(`The simulated unit reports no ${field} to set`);
|
|
481
|
+
}
|
|
482
|
+
controlInfo[field] = value;
|
|
483
|
+
};
|
|
484
|
+
const hasLouvers = "f_dir_ud" in controlInfo && "f_dir_lr" in controlInfo;
|
|
485
|
+
|
|
486
|
+
if (state.power != null) {
|
|
487
|
+
hold("pow", flag(state.power));
|
|
488
|
+
}
|
|
489
|
+
if (
|
|
490
|
+
state.mode != null &&
|
|
491
|
+
CLASSIC_OPERATING_MODE_CODES[state.mode] !== controlInfo.mode
|
|
492
|
+
) {
|
|
493
|
+
controlInfo.mode = CLASSIC_OPERATING_MODE_CODES[state.mode];
|
|
494
|
+
recallMode(controlInfo);
|
|
495
|
+
}
|
|
496
|
+
if (state.targetTemperatureInC != null) {
|
|
497
|
+
hold("stemp", state.targetTemperatureInC.toFixed(1));
|
|
498
|
+
}
|
|
499
|
+
if (state.targetHumidity != null) {
|
|
500
|
+
hold(
|
|
501
|
+
"shum",
|
|
502
|
+
typeof state.targetHumidity === "string"
|
|
503
|
+
? CLASSIC_HUMIDITY_SETTING_CODES[state.targetHumidity]
|
|
504
|
+
: String(state.targetHumidity.percent),
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
if (state.fanSpeed != null) {
|
|
508
|
+
hold("f_rate", CLASSIC_FAN_SPEED_CODES[state.fanSpeed]);
|
|
509
|
+
}
|
|
510
|
+
if (state.swingMode != null) {
|
|
511
|
+
const swings = {
|
|
512
|
+
f_dir_ud: state.swingMode === "vertical" || state.swingMode === "both",
|
|
513
|
+
f_dir_lr: state.swingMode === "horizontal" || state.swingMode === "both",
|
|
514
|
+
};
|
|
515
|
+
if (hasLouvers) {
|
|
516
|
+
// An axis that stops swinging stops, and one at a fixed position
|
|
517
|
+
// keeps it.
|
|
518
|
+
for (const [field, swing] of Object.entries(swings)) {
|
|
519
|
+
if (swing) {
|
|
520
|
+
controlInfo[field] = CLASSIC_LOUVER_SWING;
|
|
521
|
+
} else if (controlInfo[field] === CLASSIC_LOUVER_SWING) {
|
|
522
|
+
controlInfo[field] = CLASSIC_LOUVER_STOP;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
} else {
|
|
526
|
+
hold("f_dir", CLASSIC_SWING_MODE_CODES[state.swingMode]);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (state.verticalPosition != null) {
|
|
530
|
+
hold("f_dir_ud", CLASSIC_VERTICAL_POSITION_CODES[state.verticalPosition]);
|
|
531
|
+
}
|
|
532
|
+
if (state.horizontalPosition != null) {
|
|
533
|
+
hold(
|
|
534
|
+
"f_dir_lr",
|
|
535
|
+
CLASSIC_HORIZONTAL_POSITION_CODES[state.horizontalPosition],
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
// `f_dir` sets bit 1 while the vertical louver swings and bit 2 while the
|
|
539
|
+
// horizontal one does.
|
|
540
|
+
if (hasLouvers && "f_dir" in controlInfo) {
|
|
541
|
+
controlInfo.f_dir = String(
|
|
542
|
+
(controlInfo.f_dir_ud === CLASSIC_LOUVER_SWING ? 1 : 0) |
|
|
543
|
+
(controlInfo.f_dir_lr === CLASSIC_LOUVER_SWING ? 2 : 0),
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
if (state.specialModes != null) {
|
|
547
|
+
hold(
|
|
548
|
+
"adv",
|
|
549
|
+
state.specialModes
|
|
550
|
+
.map((mode) => Number(CLASSIC_SPECIAL_MODE_ADV_CODES[mode]))
|
|
551
|
+
.sort((first, second) => first - second)
|
|
552
|
+
.join("/"),
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
rememberMode(controlInfo);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Takes up what the unit remembers for the mode it is now in. A mode without
|
|
559
|
+
// a `dt` or a `dh` has no setpoint or humidity setting.
|
|
560
|
+
function recallMode(controlInfo: Record<string, string>): void {
|
|
561
|
+
const mode = controlInfo.mode ?? "";
|
|
562
|
+
for (const [prefix, field] of Object.entries(MODE_MEMORY_FIELDS)) {
|
|
563
|
+
if (field in controlInfo) {
|
|
564
|
+
controlInfo[field] =
|
|
565
|
+
controlInfo[`${prefix}${mode}`] ??
|
|
566
|
+
(prefix === "dt" || prefix === "dh"
|
|
567
|
+
? CLASSIC_NO_SETPOINT
|
|
568
|
+
: (controlInfo[field] ?? ""));
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
const [vertical, horizontal] = controlInfo[`ndfd${mode}`] ?? "";
|
|
572
|
+
if ("f_dir_ud" in controlInfo && vertical !== undefined) {
|
|
573
|
+
controlInfo.f_dir_ud = vertical;
|
|
574
|
+
}
|
|
575
|
+
if ("f_dir_lr" in controlInfo && horizontal !== undefined) {
|
|
576
|
+
controlInfo.f_dir_lr = horizontal;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const FORBIDDEN: SimulatorReply = { statusCode: 403, body: "" };
|
|
581
|
+
|
|
582
|
+
// A classic adapter answers HTTP 200 whatever the `ret`.
|
|
583
|
+
function reply(ret: string, fields?: Record<string, string>): SimulatorReply {
|
|
584
|
+
return { statusCode: 200, body: formatKeyValue(ret, fields) };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function terminalUuidOf(request: DaikinSimulatorRequest): string | undefined {
|
|
588
|
+
const header = request.headers["x-daikin-uuid"];
|
|
589
|
+
return typeof header === "string" ? header : undefined;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function flag(on: boolean): string {
|
|
593
|
+
return on ? "1" : "0";
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function hasEvery(query: Record<string, string>, names: string[]): boolean {
|
|
597
|
+
return names.every((name) => name in query);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function copy(
|
|
601
|
+
query: Record<string, string>,
|
|
602
|
+
fields: Record<string, string>,
|
|
603
|
+
names: string[],
|
|
604
|
+
): boolean {
|
|
605
|
+
if (!hasEvery(query, names)) {
|
|
606
|
+
return false;
|
|
607
|
+
}
|
|
608
|
+
for (const name of names) {
|
|
609
|
+
fields[name] = query[name] ?? "";
|
|
610
|
+
}
|
|
611
|
+
return true;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// An adapter remembers the setpoint, humidity, fan speed and direction of
|
|
615
|
+
// each mode, and answers them as `dt3`, `dh3`, `dfr3` and `dfd3` for mode 3.
|
|
616
|
+
// It takes `dt`, `dh` and `dfd` as parameters and no `dfr`, so it keeps the
|
|
617
|
+
// memory of the mode the unit is in equal to what the unit holds. `ndfd3`
|
|
618
|
+
// holds the `f_dir_ud` value of mode 3, then its `f_dir_lr` value.
|
|
619
|
+
function rememberMode(controlInfo: Record<string, string>): void {
|
|
620
|
+
const mode = controlInfo.mode ?? "";
|
|
621
|
+
for (const [prefix, field] of Object.entries(MODE_MEMORY_FIELDS)) {
|
|
622
|
+
const value = controlInfo[field];
|
|
623
|
+
if (`${prefix}${mode}` in controlInfo && value !== undefined) {
|
|
624
|
+
controlInfo[`${prefix}${mode}`] = value;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
const { f_dir_ud: vertical, f_dir_lr: horizontal } = controlInfo;
|
|
628
|
+
if (
|
|
629
|
+
`ndfd${mode}` in controlInfo &&
|
|
630
|
+
vertical !== undefined &&
|
|
631
|
+
horizontal !== undefined
|
|
632
|
+
) {
|
|
633
|
+
controlInfo[`ndfd${mode}`] = `${vertical}${horizontal}`;
|
|
634
|
+
}
|
|
635
|
+
}
|