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.
- package/DESIGN.md +47 -4
- package/README.md +154 -46
- package/UNSUPPORTED.md +27 -2
- package/dist/clients/airbase.d.ts +64 -0
- package/dist/clients/airbase.d.ts.map +1 -0
- package/dist/clients/airbase.js +175 -0
- package/dist/clients/airbase.js.map +1 -0
- package/dist/clients/client.d.ts +3 -2
- package/dist/clients/client.d.ts.map +1 -1
- package/dist/codecs/airbase/capabilities.d.ts +23 -0
- package/dist/codecs/airbase/capabilities.d.ts.map +1 -0
- package/dist/codecs/airbase/capabilities.js +108 -0
- package/dist/codecs/airbase/capabilities.js.map +1 -0
- package/dist/codecs/airbase/state.d.ts +29 -0
- package/dist/codecs/airbase/state.d.ts.map +1 -0
- package/dist/codecs/airbase/state.js +166 -0
- package/dist/codecs/airbase/state.js.map +1 -0
- package/dist/codecs/airbase/tables.d.ts +7 -0
- package/dist/codecs/airbase/tables.d.ts.map +1 -0
- package/dist/codecs/airbase/tables.js +27 -0
- package/dist/codecs/airbase/tables.js.map +1 -0
- package/dist/codecs/airbase/zone.d.ts +12 -0
- package/dist/codecs/airbase/zone.d.ts.map +1 -0
- package/dist/codecs/airbase/zone.js +50 -0
- package/dist/codecs/airbase/zone.js.map +1 -0
- package/dist/codecs/classic/tables.d.ts +2 -0
- package/dist/codecs/classic/tables.d.ts.map +1 -1
- package/dist/codecs/classic/tables.js +2 -0
- package/dist/codecs/classic/tables.js.map +1 -1
- package/dist/codecs/device.d.ts +7 -1
- package/dist/codecs/device.d.ts.map +1 -1
- package/dist/codecs/device.js +14 -2
- package/dist/codecs/device.js.map +1 -1
- package/dist/codecs/dsiot/tables.d.ts +3 -0
- package/dist/codecs/dsiot/tables.d.ts.map +1 -1
- package/dist/codecs/dsiot/tables.js +3 -0
- package/dist/codecs/dsiot/tables.js.map +1 -1
- package/dist/discovery.d.ts +20 -10
- package/dist/discovery.d.ts.map +1 -1
- package/dist/discovery.js +96 -30
- package/dist/discovery.js.map +1 -1
- package/dist/factory.d.ts +8 -5
- package/dist/factory.d.ts.map +1 -1
- package/dist/factory.js +13 -3
- package/dist/factory.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/models/device.d.ts +3 -2
- package/dist/models/device.d.ts.map +1 -1
- package/dist/models/device.js +1 -1
- package/dist/models/device.js.map +1 -1
- package/dist/models/zone.d.ts +9 -0
- package/dist/models/zone.d.ts.map +1 -0
- package/dist/models/zone.js +2 -0
- package/dist/models/zone.js.map +1 -0
- package/dist/simulators/airbase.d.ts +82 -0
- package/dist/simulators/airbase.d.ts.map +1 -0
- package/dist/simulators/airbase.js +352 -0
- package/dist/simulators/airbase.js.map +1 -0
- package/dist/simulators/classic.d.ts.map +1 -1
- package/dist/simulators/classic.js +2 -2
- package/dist/simulators/classic.js.map +1 -1
- package/dist/simulators/index.d.ts +1 -0
- package/dist/simulators/index.d.ts.map +1 -1
- package/dist/simulators/index.js +1 -0
- package/dist/simulators/index.js.map +1 -1
- package/dist/simulators/simulator.d.ts +11 -10
- package/dist/simulators/simulator.d.ts.map +1 -1
- package/dist/simulators/simulator.js +2 -2
- package/dist/simulators/simulator.js.map +1 -1
- package/dist/transport.d.ts +3 -2
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +4 -1
- package/dist/transport.js.map +1 -1
- package/package.json +7 -3
- package/src/clients/airbase.ts +274 -0
- package/src/clients/client.ts +3 -2
- package/src/codecs/airbase/capabilities.ts +150 -0
- package/src/codecs/airbase/state.ts +223 -0
- package/src/codecs/airbase/tables.ts +32 -0
- package/src/codecs/airbase/zone.ts +62 -0
- package/src/codecs/classic/tables.ts +3 -0
- package/src/codecs/device.ts +18 -2
- package/src/codecs/dsiot/tables.ts +4 -0
- package/src/discovery.ts +101 -32
- package/src/factory.ts +21 -5
- package/src/index.ts +6 -0
- package/src/models/device.ts +3 -2
- package/src/models/zone.ts +8 -0
- package/src/simulators/airbase.ts +495 -0
- package/src/simulators/classic.ts +2 -1
- package/src/simulators/index.ts +1 -0
- package/src/simulators/simulator.ts +13 -12
- package/src/transport.ts +7 -3
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AIRBASE_FAN_SPEED_CODES,
|
|
3
|
+
AIRBASE_NO_SETPOINT,
|
|
4
|
+
AIRBASE_OPERATING_MODE_CODES,
|
|
5
|
+
AIRBASE_SWING_MODE_CODES,
|
|
6
|
+
} from "../codecs/airbase/tables.js";
|
|
7
|
+
import { CLASSIC_ADAPTER_TYPE } from "../codecs/classic/tables.js";
|
|
8
|
+
import type { DaikinDeviceInfo } from "../models/device.js";
|
|
9
|
+
import type { DaikinSensors, DaikinState } from "../models/state.js";
|
|
10
|
+
import {
|
|
11
|
+
type DaikinSimulator,
|
|
12
|
+
type DaikinSimulatorRequest,
|
|
13
|
+
type DaikinSimulatorTemperatures,
|
|
14
|
+
formatKeyValue,
|
|
15
|
+
type SimulatorReply,
|
|
16
|
+
type StartSimulatorOptions,
|
|
17
|
+
startSimulator,
|
|
18
|
+
unanswered,
|
|
19
|
+
} from "./simulator.js";
|
|
20
|
+
|
|
21
|
+
// An Airbase adapter (BRP15B61) as an HTTP server. It reads the adapter's
|
|
22
|
+
// codes from the codec's tables and shares none of its functions: see
|
|
23
|
+
// DESIGN.md#simulators.
|
|
24
|
+
|
|
25
|
+
// Every endpoint is below this path, and any other path is answered HTTP 404.
|
|
26
|
+
const ROOT = "/skyfi";
|
|
27
|
+
|
|
28
|
+
// An Airbase adapter replies to the discovery probe as a classic adapter of
|
|
29
|
+
// an air conditioner does.
|
|
30
|
+
const DEFAULT_BASIC_INFO: Record<string, string> = {
|
|
31
|
+
type: CLASSIC_ADAPTER_TYPE,
|
|
32
|
+
reg: "au",
|
|
33
|
+
ver: "1_1_8",
|
|
34
|
+
name: "Simulator",
|
|
35
|
+
mac: "02DA1C000002",
|
|
36
|
+
lpw_flag: "0",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const DEFAULT_MODEL_INFO: Record<string, string> = {
|
|
40
|
+
model: "NOTSUPPORT",
|
|
41
|
+
en_auto: "1",
|
|
42
|
+
en_dry: "1",
|
|
43
|
+
en_frate: "1",
|
|
44
|
+
en_frate_auto: "1",
|
|
45
|
+
frate_steps: "3",
|
|
46
|
+
en_fdir: "1",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const { cool: COOL, heat: HEAT } = AIRBASE_OPERATING_MODE_CODES;
|
|
50
|
+
|
|
51
|
+
const DEFAULT_CONTROL_INFO: Record<string, string> = {
|
|
52
|
+
pow: "0",
|
|
53
|
+
mode: COOL,
|
|
54
|
+
bk_auto: COOL,
|
|
55
|
+
stemp: "24.0",
|
|
56
|
+
f_rate: AIRBASE_FAN_SPEED_CODES.auto,
|
|
57
|
+
f_auto: "0",
|
|
58
|
+
f_airside: "0",
|
|
59
|
+
f_dir: AIRBASE_SWING_MODE_CODES.off,
|
|
60
|
+
dt1: "25.0",
|
|
61
|
+
dt2: "24.0",
|
|
62
|
+
dfr1: AIRBASE_FAN_SPEED_CODES.auto,
|
|
63
|
+
dfr2: AIRBASE_FAN_SPEED_CODES.auto,
|
|
64
|
+
dfd1: AIRBASE_SWING_MODE_CODES.off,
|
|
65
|
+
dfd2: AIRBASE_SWING_MODE_CODES.off,
|
|
66
|
+
auto1: "0",
|
|
67
|
+
auto2: "0",
|
|
68
|
+
airside1: "0",
|
|
69
|
+
airside2: "0",
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const DEFAULT_SENSOR_INFO: Record<string, string> = {
|
|
73
|
+
htemp: "26.0",
|
|
74
|
+
otemp: "30.0",
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// A `set_control_info` without one of these is answered `PARAM NG`.
|
|
78
|
+
const REQUIRED_CONTROL_PARAMETERS = [
|
|
79
|
+
"pow",
|
|
80
|
+
"mode",
|
|
81
|
+
"stemp",
|
|
82
|
+
"shum",
|
|
83
|
+
"f_rate",
|
|
84
|
+
"f_auto",
|
|
85
|
+
"f_airside",
|
|
86
|
+
"f_dir",
|
|
87
|
+
];
|
|
88
|
+
// The parameters `set_control_info` stores: the control fields, and what the
|
|
89
|
+
// unit remembers for cool and for heat, such as `dt2` and `auto1`.
|
|
90
|
+
const CONTROL_PARAMETER_PATTERN =
|
|
91
|
+
/^(pow|mode|bk_auto|stemp|shum|f_rate|f_auto|f_airside|f_dir|(dt|dfr|dfd|auto|airside)\w)$/;
|
|
92
|
+
// The control field each memory prefix remembers: `dfr2` holds the `f_rate`
|
|
93
|
+
// of cool.
|
|
94
|
+
const MEMORY_FIELDS = {
|
|
95
|
+
dt: "stemp",
|
|
96
|
+
dfr: "f_rate",
|
|
97
|
+
dfd: "f_dir",
|
|
98
|
+
auto: "f_auto",
|
|
99
|
+
airside: "f_airside",
|
|
100
|
+
} as const;
|
|
101
|
+
|
|
102
|
+
// The lists of `get_zone_setting` are joined by this, percent-encoded.
|
|
103
|
+
const ZONE_SEPARATOR = ";";
|
|
104
|
+
const ZONE_PARAMETERS = ["zone_name", "zone_onoff"];
|
|
105
|
+
// A unit that sets a temperature for each zone holds these as well.
|
|
106
|
+
const ZONE_TEMPERATURE_PARAMETERS = ["lztemp_c", "lztemp_h"];
|
|
107
|
+
|
|
108
|
+
// A sensor field the unit has no reading for.
|
|
109
|
+
const NO_READING = "-";
|
|
110
|
+
|
|
111
|
+
/** What a simulated Airbase unit has. Anything else about it is set in the
|
|
112
|
+
* adapter's own terms, in `modelInfo`. */
|
|
113
|
+
export interface AirbaseSimulatorUnit {
|
|
114
|
+
/** The names of the unit's zones, each open at the start. Defaults to none:
|
|
115
|
+
* a unit without zones. A unit with zones takes fan auto, and neither the
|
|
116
|
+
* `auto` fan speed nor a direction. */
|
|
117
|
+
zones?: readonly string[];
|
|
118
|
+
/** Whether the unit offers auto. Defaults to true. */
|
|
119
|
+
auto?: boolean;
|
|
120
|
+
/** Whether the unit offers dehumidify. Defaults to true. */
|
|
121
|
+
dehumidify?: boolean;
|
|
122
|
+
/** Whether the fan speed can be set. Defaults to true. */
|
|
123
|
+
fan?: boolean;
|
|
124
|
+
/** How many fan levels the unit takes: `level1` and `level5` for 2, and
|
|
125
|
+
* `level3` as well for 3. Defaults to 3. */
|
|
126
|
+
fanLevels?: 2 | 3;
|
|
127
|
+
/** Whether a unit with zones takes airside. Defaults to false. */
|
|
128
|
+
airside?: boolean;
|
|
129
|
+
/** Whether a unit without zones takes a direction. Defaults to true. */
|
|
130
|
+
swing?: boolean;
|
|
131
|
+
/** The setpoints cool takes, in whole degrees. Defaults to none
|
|
132
|
+
* reported. */
|
|
133
|
+
coolTemperaturesInC?: DaikinSimulatorTemperatures;
|
|
134
|
+
/** The setpoints heat takes, in whole degrees. Defaults to none
|
|
135
|
+
* reported. */
|
|
136
|
+
heatTemperaturesInC?: DaikinSimulatorTemperatures;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface StartAirbaseSimulatorOptions extends StartSimulatorOptions {
|
|
140
|
+
/** What the unit has. Defaults to a unit without zones that offers every
|
|
141
|
+
* mode, three fan levels and the `auto` fan speed, and takes a direction.
|
|
142
|
+
* A field of `modelInfo` replaces what this sets. */
|
|
143
|
+
unit?: AirbaseSimulatorUnit;
|
|
144
|
+
/** Fields added to the `/skyfi/common/basic_info` reply, each replacing a
|
|
145
|
+
* default of the same name. Defaults to none. */
|
|
146
|
+
basicInfo?: Record<string, string>;
|
|
147
|
+
/** Fields added to the `/skyfi/aircon/get_model_info` reply, each replacing
|
|
148
|
+
* a default of the same name. Defaults to none. */
|
|
149
|
+
modelInfo?: Record<string, string>;
|
|
150
|
+
/** Fields added to the `/skyfi/aircon/get_control_info` reply, each
|
|
151
|
+
* replacing a default of the same name. Defaults to none. */
|
|
152
|
+
controlInfo?: Record<string, string>;
|
|
153
|
+
/** Fields added to the `/skyfi/aircon/get_sensor_info` reply, each replacing
|
|
154
|
+
* a default of the same name. Defaults to none. */
|
|
155
|
+
sensorInfo?: Record<string, string>;
|
|
156
|
+
/** Fields added to the `/skyfi/aircon/get_zone_setting` reply, each
|
|
157
|
+
* replacing what `unit.zones` sets. Defaults to none. */
|
|
158
|
+
zoneSetting?: Record<string, string>;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** An Airbase adapter and the unit behind it. Each field map is what its
|
|
162
|
+
* endpoint answers, in the adapter's own names and encodings, without the
|
|
163
|
+
* `ret`. A write through the adapter changes them, and so do `setState`,
|
|
164
|
+
* `setSensors` and `setZone`. Change a field directly for what those do not
|
|
165
|
+
* cover, such as `controlInfo.f_auto` for fan auto. */
|
|
166
|
+
export interface AirbaseDaikinSimulator extends DaikinSimulator {
|
|
167
|
+
/** What `/skyfi/common/basic_info` answers. `name` is held percent-encoded,
|
|
168
|
+
* as the reply carries it. */
|
|
169
|
+
readonly basicInfo: Record<string, string>;
|
|
170
|
+
/** What `/skyfi/aircon/get_model_info` answers. */
|
|
171
|
+
readonly modelInfo: Record<string, string>;
|
|
172
|
+
/** What `/skyfi/aircon/get_control_info` answers. */
|
|
173
|
+
readonly controlInfo: Record<string, string>;
|
|
174
|
+
/** What `/skyfi/aircon/get_sensor_info` answers. */
|
|
175
|
+
readonly sensorInfo: Record<string, string>;
|
|
176
|
+
/** What `/skyfi/aircon/get_zone_setting` answers, each list held
|
|
177
|
+
* percent-encoded, as the reply carries it. Empty on a unit without
|
|
178
|
+
* zones. */
|
|
179
|
+
readonly zoneSetting: Record<string, string>;
|
|
180
|
+
/** Opens or closes the zone at `index`, counted from 0, as the unit's wall
|
|
181
|
+
* controller would. It throws an `Error` for an index the unit has no zone
|
|
182
|
+
* at. */
|
|
183
|
+
setZone(index: number, enabled: boolean): void;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Starts an Airbase adapter on a free port, as a unit without zones that is
|
|
187
|
+
* off, in cool at 24 °C. It answers the endpoints `AirbaseDaikinClient` has a
|
|
188
|
+
* typed method for, below `/skyfi`, and takes no terminal uuid and no local
|
|
189
|
+
* password. It answers HTTP 404 to a path outside `/skyfi`, as an Airbase
|
|
190
|
+
* adapter does, and HTTP 501 to any other request it has no answer for:
|
|
191
|
+
* UNSUPPORTED.md lists what it leaves out and where its answer is not known
|
|
192
|
+
* to be an adapter's. Call `stop` when done with it. */
|
|
193
|
+
export async function startAirbaseSimulator(
|
|
194
|
+
options: StartAirbaseSimulatorOptions = {},
|
|
195
|
+
): Promise<AirbaseDaikinSimulator> {
|
|
196
|
+
const unit = options.unit ?? {};
|
|
197
|
+
const basicInfo = { ...DEFAULT_BASIC_INFO, ...options.basicInfo };
|
|
198
|
+
const modelInfo = {
|
|
199
|
+
...DEFAULT_MODEL_INFO,
|
|
200
|
+
...encodeUnit(unit),
|
|
201
|
+
...options.modelInfo,
|
|
202
|
+
};
|
|
203
|
+
const controlInfo = { ...DEFAULT_CONTROL_INFO, ...options.controlInfo };
|
|
204
|
+
const sensorInfo = { ...DEFAULT_SENSOR_INFO, ...options.sensorInfo };
|
|
205
|
+
const zoneSetting = {
|
|
206
|
+
...encodeZones(unit.zones ?? []),
|
|
207
|
+
...options.zoneSetting,
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const setState = (state: Partial<DaikinState>) =>
|
|
211
|
+
applyState(controlInfo, state);
|
|
212
|
+
const setSensors = (sensors: Partial<DaikinSensors>) =>
|
|
213
|
+
Object.assign(sensorInfo, encodeSensors(sensors));
|
|
214
|
+
const setZone = (index: number, enabled: boolean) => {
|
|
215
|
+
const flags = decodeList(zoneSetting.zone_onoff);
|
|
216
|
+
if (!Number.isInteger(index) || index < 0 || index >= flags.length) {
|
|
217
|
+
throw new Error(`The simulated unit has no zone ${index}`);
|
|
218
|
+
}
|
|
219
|
+
flags[index] = flag(enabled);
|
|
220
|
+
zoneSetting.zone_onoff = encodeList(flags);
|
|
221
|
+
};
|
|
222
|
+
// Before the server starts, so a state that throws leaves nothing
|
|
223
|
+
// listening.
|
|
224
|
+
Object.assign(basicInfo, encodeDeviceInfo(options.deviceInfo ?? {}));
|
|
225
|
+
setState(options.state ?? {});
|
|
226
|
+
setSensors(options.sensors ?? {});
|
|
227
|
+
|
|
228
|
+
const hasZones = () => "zone_name" in zoneSetting;
|
|
229
|
+
const reads: Record<string, () => Record<string, string> | undefined> = {
|
|
230
|
+
"/common/basic_info": () => basicInfo,
|
|
231
|
+
"/aircon/get_model_info": () => modelInfo,
|
|
232
|
+
"/aircon/get_control_info": () => controlInfo,
|
|
233
|
+
"/aircon/get_sensor_info": () => sensorInfo,
|
|
234
|
+
// What a unit without zones answers is not known.
|
|
235
|
+
"/aircon/get_zone_setting": () => (hasZones() ? zoneSetting : undefined),
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
// Each write returns false for a request that lacks a parameter, and
|
|
239
|
+
// `undefined` for one the simulator has no answer for.
|
|
240
|
+
const writes: Record<
|
|
241
|
+
string,
|
|
242
|
+
(query: Record<string, string>) => boolean | undefined
|
|
243
|
+
> = {
|
|
244
|
+
"/aircon/set_control_info": (query) => {
|
|
245
|
+
if (!hasEvery(query, REQUIRED_CONTROL_PARAMETERS)) {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
for (const [name, value] of Object.entries(query)) {
|
|
249
|
+
if (CONTROL_PARAMETER_PATTERN.test(name) && name in controlInfo) {
|
|
250
|
+
controlInfo[name] = value;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
remember(controlInfo);
|
|
254
|
+
return true;
|
|
255
|
+
},
|
|
256
|
+
"/aircon/set_zone_setting": (query) => {
|
|
257
|
+
if (!hasZones()) {
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
if (!hasEvery(query, ZONE_PARAMETERS)) {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
for (const name of [...ZONE_PARAMETERS, ...ZONE_TEMPERATURE_PARAMETERS]) {
|
|
264
|
+
const value = query[name];
|
|
265
|
+
if (value !== undefined && name in zoneSetting) {
|
|
266
|
+
zoneSetting[name] = encodeURIComponent(value);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return true;
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const answer = (request: DaikinSimulatorRequest): SimulatorReply => {
|
|
274
|
+
if (!request.path.startsWith(`${ROOT}/`)) {
|
|
275
|
+
return NOT_FOUND;
|
|
276
|
+
}
|
|
277
|
+
if (request.method !== "GET") {
|
|
278
|
+
return unanswered(request);
|
|
279
|
+
}
|
|
280
|
+
const path = request.path.slice(ROOT.length);
|
|
281
|
+
const fields = reads[path]?.();
|
|
282
|
+
if (fields !== undefined) {
|
|
283
|
+
return reply("OK", fields);
|
|
284
|
+
}
|
|
285
|
+
const written = writes[path]?.(request.query);
|
|
286
|
+
return written === undefined
|
|
287
|
+
? unanswered(request)
|
|
288
|
+
: reply(written ? "OK" : "PARAM NG");
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
// The discovery reply carries the fields of `basic_info`.
|
|
292
|
+
const server = await startSimulator(options, answer, () => basicInfo);
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
...server,
|
|
296
|
+
basicInfo,
|
|
297
|
+
modelInfo,
|
|
298
|
+
controlInfo,
|
|
299
|
+
sensorInfo,
|
|
300
|
+
zoneSetting,
|
|
301
|
+
setState,
|
|
302
|
+
setSensors,
|
|
303
|
+
setZone,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// The `get_model_info` fields that say what `unit` has. A field `unit` leaves
|
|
308
|
+
// out is left to the defaults.
|
|
309
|
+
function encodeUnit(unit: AirbaseSimulatorUnit): Record<string, string> {
|
|
310
|
+
const fields: Record<string, string> = {};
|
|
311
|
+
if (unit.zones !== undefined && unit.zones.length > 0) {
|
|
312
|
+
fields.en_zone = String(unit.zones.length);
|
|
313
|
+
}
|
|
314
|
+
if (unit.auto !== undefined) {
|
|
315
|
+
fields.en_auto = flag(unit.auto);
|
|
316
|
+
}
|
|
317
|
+
if (unit.dehumidify !== undefined) {
|
|
318
|
+
fields.en_dry = flag(unit.dehumidify);
|
|
319
|
+
}
|
|
320
|
+
if (unit.fan !== undefined) {
|
|
321
|
+
fields.en_frate = flag(unit.fan);
|
|
322
|
+
}
|
|
323
|
+
if (unit.fanLevels !== undefined) {
|
|
324
|
+
fields.frate_steps = String(unit.fanLevels);
|
|
325
|
+
}
|
|
326
|
+
if (unit.airside !== undefined) {
|
|
327
|
+
fields.en_airside = flag(unit.airside);
|
|
328
|
+
}
|
|
329
|
+
if (unit.swing !== undefined) {
|
|
330
|
+
fields.en_fdir = flag(unit.swing);
|
|
331
|
+
}
|
|
332
|
+
if (unit.coolTemperaturesInC !== undefined) {
|
|
333
|
+
fields.cool_l = unit.coolTemperaturesInC.min.toFixed(0);
|
|
334
|
+
fields.cool_h = unit.coolTemperaturesInC.max.toFixed(0);
|
|
335
|
+
}
|
|
336
|
+
if (unit.heatTemperaturesInC !== undefined) {
|
|
337
|
+
fields.heat_l = unit.heatTemperaturesInC.min.toFixed(0);
|
|
338
|
+
fields.heat_h = unit.heatTemperaturesInC.max.toFixed(0);
|
|
339
|
+
}
|
|
340
|
+
return fields;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// The `get_zone_setting` fields of a unit with these zones, each open. A unit
|
|
344
|
+
// without zones has none.
|
|
345
|
+
function encodeZones(names: readonly string[]): Record<string, string> {
|
|
346
|
+
return names.length === 0
|
|
347
|
+
? {}
|
|
348
|
+
: {
|
|
349
|
+
zone_name: encodeList(names),
|
|
350
|
+
zone_onoff: encodeList(names.map(() => flag(true))),
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function encodeList(values: readonly string[]): string {
|
|
355
|
+
return encodeURIComponent(values.join(ZONE_SEPARATOR));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function decodeList(value: string | undefined): string[] {
|
|
359
|
+
return value === undefined
|
|
360
|
+
? []
|
|
361
|
+
: decodeURIComponent(value).split(ZONE_SEPARATOR);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// The `basic_info` fields: the name percent-encoded, the MAC address without
|
|
365
|
+
// its colons, and the version with underscores for its dots.
|
|
366
|
+
function encodeDeviceInfo(info: DaikinDeviceInfo): Record<string, string> {
|
|
367
|
+
return {
|
|
368
|
+
...(info.name === undefined ? {} : { name: encodeURIComponent(info.name) }),
|
|
369
|
+
...(info.macAddress === undefined
|
|
370
|
+
? {}
|
|
371
|
+
: { mac: info.macAddress.replaceAll(":", "") }),
|
|
372
|
+
...(info.firmwareVersion === undefined
|
|
373
|
+
? {}
|
|
374
|
+
: { ver: info.firmwareVersion.replaceAll(".", "_") }),
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function encodeSensors(
|
|
379
|
+
sensors: Partial<DaikinSensors>,
|
|
380
|
+
): Record<string, string> {
|
|
381
|
+
const fields: Record<string, string> = {};
|
|
382
|
+
const {
|
|
383
|
+
indoorTemperatureInC,
|
|
384
|
+
outdoorTemperatureInC,
|
|
385
|
+
indoorHumidityInPercent,
|
|
386
|
+
} = sensors;
|
|
387
|
+
if (indoorTemperatureInC !== undefined) {
|
|
388
|
+
fields.htemp = indoorTemperatureInC?.toFixed(1) ?? NO_READING;
|
|
389
|
+
}
|
|
390
|
+
if (outdoorTemperatureInC !== undefined) {
|
|
391
|
+
fields.otemp = outdoorTemperatureInC?.toFixed(1) ?? NO_READING;
|
|
392
|
+
}
|
|
393
|
+
if (indoorHumidityInPercent !== undefined) {
|
|
394
|
+
fields.hhum = indoorHumidityInPercent?.toFixed(0) ?? NO_READING;
|
|
395
|
+
}
|
|
396
|
+
return fields;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Changes `controlInfo` as the unit's wall controller would: see
|
|
400
|
+
// `DaikinSimulator.setState`.
|
|
401
|
+
function applyState(
|
|
402
|
+
controlInfo: Record<string, string>,
|
|
403
|
+
state: Partial<DaikinState>,
|
|
404
|
+
): void {
|
|
405
|
+
for (const field of [
|
|
406
|
+
"targetHumidity",
|
|
407
|
+
"verticalPosition",
|
|
408
|
+
"horizontalPosition",
|
|
409
|
+
] as const) {
|
|
410
|
+
if (state[field] != null) {
|
|
411
|
+
throw new Error(`The simulated unit has no ${field} to set`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (state.specialModes != null && state.specialModes.length > 0) {
|
|
415
|
+
throw new Error("The simulated unit has no specialModes to set");
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (state.power != null) {
|
|
419
|
+
controlInfo.pow = flag(state.power);
|
|
420
|
+
}
|
|
421
|
+
if (
|
|
422
|
+
state.mode != null &&
|
|
423
|
+
AIRBASE_OPERATING_MODE_CODES[state.mode] !== controlInfo.mode
|
|
424
|
+
) {
|
|
425
|
+
controlInfo.mode = AIRBASE_OPERATING_MODE_CODES[state.mode];
|
|
426
|
+
recall(controlInfo);
|
|
427
|
+
}
|
|
428
|
+
if (state.targetTemperatureInC != null) {
|
|
429
|
+
controlInfo.stemp = state.targetTemperatureInC.toFixed(1);
|
|
430
|
+
}
|
|
431
|
+
if (state.fanSpeed != null) {
|
|
432
|
+
controlInfo.f_rate = AIRBASE_FAN_SPEED_CODES[state.fanSpeed];
|
|
433
|
+
}
|
|
434
|
+
if (state.swingMode != null) {
|
|
435
|
+
controlInfo.f_dir = AIRBASE_SWING_MODE_CODES[state.swingMode];
|
|
436
|
+
}
|
|
437
|
+
remember(controlInfo);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// The unit remembers a setpoint, a fan and a direction for cool and for heat
|
|
441
|
+
// alone, and every other mode runs on the one `bk_auto` names. A unit set to
|
|
442
|
+
// cool or to heat names that mode there.
|
|
443
|
+
function memoryOf(controlInfo: Record<string, string>): string {
|
|
444
|
+
if (controlInfo.mode === COOL || controlInfo.mode === HEAT) {
|
|
445
|
+
controlInfo.bk_auto = controlInfo.mode;
|
|
446
|
+
}
|
|
447
|
+
return controlInfo.bk_auto === HEAT ? HEAT : COOL;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Takes up what the unit remembers for the mode it is now in. Dehumidify has
|
|
451
|
+
// no setpoint.
|
|
452
|
+
function recall(controlInfo: Record<string, string>): void {
|
|
453
|
+
const memory = memoryOf(controlInfo);
|
|
454
|
+
for (const [prefix, field] of Object.entries(MEMORY_FIELDS)) {
|
|
455
|
+
const remembered = controlInfo[`${prefix}${memory}`];
|
|
456
|
+
if (remembered !== undefined) {
|
|
457
|
+
controlInfo[field] = remembered;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (controlInfo.mode === AIRBASE_OPERATING_MODE_CODES.dehumidify) {
|
|
461
|
+
controlInfo.stemp = AIRBASE_NO_SETPOINT;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Keeps what the unit remembers for the mode it runs on equal to what it
|
|
466
|
+
// holds. A setpoint is remembered in cool and in heat alone.
|
|
467
|
+
function remember(controlInfo: Record<string, string>): void {
|
|
468
|
+
const memory = memoryOf(controlInfo);
|
|
469
|
+
for (const [prefix, field] of Object.entries(MEMORY_FIELDS)) {
|
|
470
|
+
const value = controlInfo[field];
|
|
471
|
+
const isSetpoint = prefix === "dt";
|
|
472
|
+
if (
|
|
473
|
+
`${prefix}${memory}` in controlInfo &&
|
|
474
|
+
value !== undefined &&
|
|
475
|
+
(!isSetpoint || controlInfo.mode === memory)
|
|
476
|
+
) {
|
|
477
|
+
controlInfo[`${prefix}${memory}`] = value;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const NOT_FOUND: SimulatorReply = { statusCode: 404, body: "" };
|
|
483
|
+
|
|
484
|
+
// An Airbase adapter answers HTTP 200 whatever the `ret`.
|
|
485
|
+
function reply(ret: string, fields?: Record<string, string>): SimulatorReply {
|
|
486
|
+
return { statusCode: 200, body: formatKeyValue(ret, fields) };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function flag(on: boolean): string {
|
|
490
|
+
return on ? "1" : "0";
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function hasEvery(query: Record<string, string>, names: string[]): boolean {
|
|
494
|
+
return names.every((name) => name in query);
|
|
495
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
CLASSIC_ADAPTER_TYPE,
|
|
2
3
|
CLASSIC_COOLING_ONLY_TYPE,
|
|
3
4
|
CLASSIC_FAN_SPEED_CODES,
|
|
4
5
|
CLASSIC_HORIZONTAL_POSITION_CODES,
|
|
@@ -33,7 +34,7 @@ import {
|
|
|
33
34
|
// DESIGN.md#simulators.
|
|
34
35
|
|
|
35
36
|
const DEFAULT_BASIC_INFO: Record<string, string> = {
|
|
36
|
-
type:
|
|
37
|
+
type: CLASSIC_ADAPTER_TYPE,
|
|
37
38
|
reg: "th",
|
|
38
39
|
ver: "1_2_51",
|
|
39
40
|
name: "Simulator",
|
package/src/simulators/index.ts
CHANGED
|
@@ -9,8 +9,8 @@ import type {
|
|
|
9
9
|
SpecialMode,
|
|
10
10
|
} from "../models/state.js";
|
|
11
11
|
|
|
12
|
-
// What the
|
|
13
|
-
//
|
|
12
|
+
// What the simulators share: the HTTP server, and the reply to a discovery
|
|
13
|
+
// probe, which every adapter sends in the same form.
|
|
14
14
|
|
|
15
15
|
const DISCOVERY_PROBE = "DAIKIN_UDP/common/basic_info";
|
|
16
16
|
const DISCOVERY_PORT = 30050;
|
|
@@ -28,7 +28,7 @@ export interface DaikinSimulatorRequest {
|
|
|
28
28
|
body: string;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
/** What
|
|
31
|
+
/** What every simulator resolves with. */
|
|
32
32
|
export interface DaikinSimulator {
|
|
33
33
|
/** `<address>:<port>`, as `DaikinDevice.host` takes it: `127.0.0.1`, unless
|
|
34
34
|
* the simulator was started at another `address`, and a port that was free
|
|
@@ -59,9 +59,10 @@ export interface DaikinSimulatorTemperatures {
|
|
|
59
59
|
max: number;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
/** What a simulated unit has, where
|
|
63
|
-
* about a unit is set in the adapter's own terms:
|
|
64
|
-
* simulator, and `leaves` on a DSIOT one.
|
|
62
|
+
/** What a simulated unit has, where the classic and the DSIOT protocol can
|
|
63
|
+
* both say it. Anything else about a unit is set in the adapter's own terms:
|
|
64
|
+
* `modelInfo` on a classic simulator, and `leaves` on a DSIOT one. An Airbase
|
|
65
|
+
* simulator takes an `AirbaseSimulatorUnit` instead. */
|
|
65
66
|
export interface DaikinSimulatorUnit {
|
|
66
67
|
/** A unit that only cools offers dehumidify, cool and fan. Defaults to
|
|
67
68
|
* false: a heat pump, which offers auto and heat as well. */
|
|
@@ -83,10 +84,9 @@ export interface DaikinSimulatorUnit {
|
|
|
83
84
|
heatTemperaturesInC?: DaikinSimulatorTemperatures;
|
|
84
85
|
}
|
|
85
86
|
|
|
86
|
-
/** The options
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
* ways. */
|
|
87
|
+
/** The options every simulator takes. A simulator applies `deviceInfo`,
|
|
88
|
+
* `state` and `sensors` after the fields given in the adapter's own terms, so
|
|
89
|
+
* they replace a field given both ways. */
|
|
90
90
|
export interface StartSimulatorOptions {
|
|
91
91
|
/** The adapter's name, MAC address and firmware version, as
|
|
92
92
|
* `getDeviceInfo` reads them back. A field left out keeps its default.
|
|
@@ -98,7 +98,8 @@ export interface StartSimulatorOptions {
|
|
|
98
98
|
state?: Partial<DaikinState>;
|
|
99
99
|
/** What the unit's sensors read at the start: see
|
|
100
100
|
* `DaikinSimulator.setSensors`. Defaults to 26 °C and 55 % indoors, and
|
|
101
|
-
* 30 °C outdoors.
|
|
101
|
+
* 30 °C outdoors. An Airbase simulator reports no humidity unless it is
|
|
102
|
+
* given one. */
|
|
102
103
|
sensors?: Partial<DaikinSensors>;
|
|
103
104
|
/** How long, in milliseconds, the adapter takes to answer a request. It
|
|
104
105
|
* answers one request at a time, in the order they arrive, so a request
|
|
@@ -117,7 +118,7 @@ export interface StartSimulatorOptions {
|
|
|
117
118
|
}
|
|
118
119
|
|
|
119
120
|
/** What `startSimulator` resolves with: the part of a `DaikinSimulator` that
|
|
120
|
-
* is the same for
|
|
121
|
+
* is the same for every protocol. */
|
|
121
122
|
export type SimulatorServer = Pick<
|
|
122
123
|
DaikinSimulator,
|
|
123
124
|
"host" | "requests" | "stop"
|
package/src/transport.ts
CHANGED
|
@@ -72,8 +72,9 @@ export interface DaikinTransportOptions {
|
|
|
72
72
|
/** HTTPS (a Marvell adapter in secure mode). */
|
|
73
73
|
usesHttps?: boolean;
|
|
74
74
|
/** The terminal uuid, sent as `X-Daikin-uuid` on every call: 32 lowercase
|
|
75
|
-
* hexadecimal digits, a UUID without its dashes.
|
|
76
|
-
|
|
75
|
+
* hexadecimal digits, a UUID without its dashes. Without one the header is
|
|
76
|
+
* left out, as an Airbase adapter takes it. */
|
|
77
|
+
terminalUuid?: string;
|
|
77
78
|
authentication?: DaikinAuthentication;
|
|
78
79
|
/** How long, in milliseconds, to wait for each of the connection, the
|
|
79
80
|
* reply's headers and the reply's body before `send` throws a
|
|
@@ -110,7 +111,10 @@ export class DaikinTransport {
|
|
|
110
111
|
* `DaikinTransportOptions.terminalUuid` describes. */
|
|
111
112
|
constructor(private readonly options: DaikinTransportOptions) {
|
|
112
113
|
// Before the Client, so a uuid that throws leaves no connection open.
|
|
113
|
-
this.terminalUuidHeaders =
|
|
114
|
+
this.terminalUuidHeaders =
|
|
115
|
+
options.terminalUuid === undefined
|
|
116
|
+
? {}
|
|
117
|
+
: encodeTerminalUuid(options.terminalUuid);
|
|
114
118
|
|
|
115
119
|
const scheme = options.usesHttps ? "https" : "http";
|
|
116
120
|
this.origin = `${scheme}://${options.host.replace(/\/+$/, "")}`;
|