@khalido/home 0.0.0-stage → 0.1.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/.env.example +25 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +77 -2
- package/dist/clock.d.ts +17 -0
- package/dist/clock.js +43 -0
- package/dist/config.d.ts +273 -0
- package/dist/config.js +392 -0
- package/dist/connector.d.ts +34 -0
- package/dist/connector.js +47 -0
- package/dist/connectors/amber/client.d.ts +61 -0
- package/dist/connectors/amber/client.js +106 -0
- package/dist/connectors/amber/index.d.ts +30 -0
- package/dist/connectors/amber/index.js +94 -0
- package/dist/connectors/cast/codec.d.ts +38 -0
- package/dist/connectors/cast/codec.js +102 -0
- package/dist/connectors/cast/index.d.ts +60 -0
- package/dist/connectors/cast/index.js +426 -0
- package/dist/connectors/cast/schemas.d.ts +167 -0
- package/dist/connectors/cast/schemas.js +87 -0
- package/dist/connectors/cast/serve.d.ts +11 -0
- package/dist/connectors/cast/serve.js +57 -0
- package/dist/connectors/cast/state.d.ts +62 -0
- package/dist/connectors/cast/state.js +133 -0
- package/dist/connectors/cast/tls.d.ts +3 -0
- package/dist/connectors/cast/tls.js +67 -0
- package/dist/connectors/cast/writes.d.ts +80 -0
- package/dist/connectors/cast/writes.js +390 -0
- package/dist/connectors/daikin/client.d.ts +65 -0
- package/dist/connectors/daikin/client.js +221 -0
- package/dist/connectors/daikin/index.d.ts +41 -0
- package/dist/connectors/daikin/index.js +108 -0
- package/dist/connectors/enphase/client.d.ts +65 -0
- package/dist/connectors/enphase/client.js +173 -0
- package/dist/connectors/enphase/index.d.ts +43 -0
- package/dist/connectors/enphase/index.js +174 -0
- package/dist/connectors/enphase/schemas.d.ts +51 -0
- package/dist/connectors/enphase/schemas.js +34 -0
- package/dist/connectors/hue/client.d.ts +48 -0
- package/dist/connectors/hue/client.js +95 -0
- package/dist/connectors/hue/events.d.ts +41 -0
- package/dist/connectors/hue/events.js +156 -0
- package/dist/connectors/hue/index.d.ts +42 -0
- package/dist/connectors/hue/index.js +205 -0
- package/dist/connectors/hue/schemas.d.ts +65 -0
- package/dist/connectors/hue/schemas.js +48 -0
- package/dist/connectors/hue/state.d.ts +43 -0
- package/dist/connectors/hue/state.js +84 -0
- package/dist/connectors/printers/client.d.ts +28 -0
- package/dist/connectors/printers/client.js +74 -0
- package/dist/connectors/printers/codec.d.ts +76 -0
- package/dist/connectors/printers/codec.js +246 -0
- package/dist/connectors/printers/found.d.ts +15 -0
- package/dist/connectors/printers/found.js +35 -0
- package/dist/connectors/printers/index.d.ts +52 -0
- package/dist/connectors/printers/index.js +282 -0
- package/dist/connectors/printers/state.d.ts +60 -0
- package/dist/connectors/printers/state.js +99 -0
- package/dist/connectors/spotify/changes.d.ts +32 -0
- package/dist/connectors/spotify/changes.js +84 -0
- package/dist/connectors/spotify/client.d.ts +199 -0
- package/dist/connectors/spotify/client.js +270 -0
- package/dist/connectors/spotify/index.d.ts +92 -0
- package/dist/connectors/spotify/index.js +745 -0
- package/dist/connectors/up/client.d.ts +183 -0
- package/dist/connectors/up/client.js +308 -0
- package/dist/connectors/up/index.d.ts +91 -0
- package/dist/connectors/up/index.js +213 -0
- package/dist/connectors/up/spend.d.ts +78 -0
- package/dist/connectors/up/spend.js +213 -0
- package/dist/connectors/up/webhook.d.ts +13 -0
- package/dist/connectors/up/webhook.js +32 -0
- package/dist/connectors/weather/blocks.d.ts +51 -0
- package/dist/connectors/weather/blocks.js +106 -0
- package/dist/connectors/weather/bom.d.ts +37 -0
- package/dist/connectors/weather/bom.js +237 -0
- package/dist/connectors/weather/ftp.d.ts +5 -0
- package/dist/connectors/weather/ftp.js +46 -0
- package/dist/connectors/weather/google.d.ts +31 -0
- package/dist/connectors/weather/google.js +364 -0
- package/dist/connectors/weather/index.d.ts +53 -0
- package/dist/connectors/weather/index.js +267 -0
- package/dist/connectors/weather/open-meteo.d.ts +15 -0
- package/dist/connectors/weather/open-meteo.js +285 -0
- package/dist/connectors/weather/rain.d.ts +19 -0
- package/dist/connectors/weather/rain.js +51 -0
- package/dist/connectors/weather/source.d.ts +149 -0
- package/dist/connectors/weather/source.js +77 -0
- package/dist/connectors/weather/sun.d.ts +48 -0
- package/dist/connectors/weather/sun.js +65 -0
- package/dist/discover.d.ts +195 -0
- package/dist/discover.js +446 -0
- package/dist/energy.d.ts +61 -0
- package/dist/energy.js +162 -0
- package/dist/finders-entry.d.ts +1 -0
- package/dist/finders-entry.js +2 -0
- package/dist/finders.d.ts +45 -0
- package/dist/finders.js +173 -0
- package/dist/health.d.ts +28 -0
- package/dist/health.js +35 -0
- package/dist/home.d.ts +81 -0
- package/dist/home.js +114 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/lane.d.ts +17 -0
- package/dist/lane.js +74 -0
- package/dist/locate.d.ts +27 -0
- package/dist/locate.js +59 -0
- package/dist/manifest.d.ts +31 -0
- package/dist/manifest.js +4 -0
- package/dist/manifests.d.ts +5 -0
- package/dist/manifests.js +23 -0
- package/dist/node-fetch.d.ts +9 -0
- package/dist/node-fetch.js +25 -0
- package/dist/poll.d.ts +10 -0
- package/dist/poll.js +33 -0
- package/docs/cast.md +133 -0
- package/docs/cli.md +39 -0
- package/docs/config.md +36 -0
- package/docs/decisions.md +150 -0
- package/docs/devices.md +128 -0
- package/docs/enphase-notes.md +24 -0
- package/docs/google.md +92 -0
- package/docs/ideas.md +69 -0
- package/docs/matter.md +55 -0
- package/docs/performance.md +12 -0
- package/docs/philosophy.md +23 -0
- package/docs/up.md +84 -0
- package/docs/using.md +65 -0
- package/docs/weather-probe.md +63 -0
- package/examples/home.jsonc +74 -0
- package/examples/home.svg +76 -0
- package/home.schema.json +474 -0
- package/llms.txt +32 -0
- package/package.json +143 -4
- package/skills/draw-home/README.md +26 -0
- package/skills/draw-home/SKILL.md +82 -0
- package/skills/use-home/SKILL.md +86 -0
- package/src/cli/cache.ts +29 -0
- package/src/cli/cast.ts +172 -0
- package/src/cli/config.ts +52 -0
- package/src/cli/context.ts +118 -0
- package/src/cli/discover.ts +175 -0
- package/src/cli/enphase.ts +62 -0
- package/src/cli/hue.ts +124 -0
- package/src/cli/locate.ts +47 -0
- package/src/cli/main.ts +182 -0
- package/src/cli/printers.ts +21 -0
- package/src/cli/probe.ts +274 -0
- package/src/cli/spotify.ts +76 -0
- package/src/cli/status.ts +228 -0
- package/src/cli/up.ts +350 -0
- package/src/clock.ts +62 -0
- package/src/config.ts +578 -0
- package/src/connector.ts +77 -0
- package/src/connectors/amber/README.md +40 -0
- package/src/connectors/amber/client.ts +157 -0
- package/src/connectors/amber/index.ts +118 -0
- package/src/connectors/cast/README.md +43 -0
- package/src/connectors/cast/codec.ts +126 -0
- package/src/connectors/cast/index.ts +551 -0
- package/src/connectors/cast/schemas.ts +101 -0
- package/src/connectors/cast/serve.ts +72 -0
- package/src/connectors/cast/state.ts +203 -0
- package/src/connectors/cast/tls.ts +100 -0
- package/src/connectors/cast/writes.ts +572 -0
- package/src/connectors/daikin/README.md +78 -0
- package/src/connectors/daikin/client.ts +295 -0
- package/src/connectors/daikin/index.ts +145 -0
- package/src/connectors/enphase/README.md +64 -0
- package/src/connectors/enphase/client.ts +244 -0
- package/src/connectors/enphase/index.ts +241 -0
- package/src/connectors/enphase/schemas.ts +45 -0
- package/src/connectors/hue/README.md +50 -0
- package/src/connectors/hue/client.ts +157 -0
- package/src/connectors/hue/events.ts +182 -0
- package/src/connectors/hue/index.ts +261 -0
- package/src/connectors/hue/schemas.ts +60 -0
- package/src/connectors/hue/state.ts +124 -0
- package/src/connectors/printers/README.md +46 -0
- package/src/connectors/printers/client.ts +114 -0
- package/src/connectors/printers/codec.ts +292 -0
- package/src/connectors/printers/found.ts +46 -0
- package/src/connectors/printers/index.ts +385 -0
- package/src/connectors/printers/state.ts +182 -0
- package/src/connectors/spotify/README.md +80 -0
- package/src/connectors/spotify/changes.ts +127 -0
- package/src/connectors/spotify/client.ts +387 -0
- package/src/connectors/spotify/index.ts +929 -0
- package/src/connectors/up/README.md +35 -0
- package/src/connectors/up/client.ts +518 -0
- package/src/connectors/up/index.ts +370 -0
- package/src/connectors/up/spend.ts +280 -0
- package/src/connectors/up/webhook.ts +62 -0
- package/src/connectors/weather/README.md +84 -0
- package/src/connectors/weather/blocks.ts +173 -0
- package/src/connectors/weather/bom.ts +344 -0
- package/src/connectors/weather/ftp.ts +48 -0
- package/src/connectors/weather/google.ts +425 -0
- package/src/connectors/weather/index.ts +380 -0
- package/src/connectors/weather/open-meteo.ts +309 -0
- package/src/connectors/weather/rain.ts +73 -0
- package/src/connectors/weather/source.ts +236 -0
- package/src/connectors/weather/sun.ts +114 -0
- package/src/discover.ts +634 -0
- package/src/energy.ts +221 -0
- package/src/finders-entry.ts +2 -0
- package/src/finders.ts +247 -0
- package/src/health.ts +74 -0
- package/src/home.ts +205 -0
- package/src/index.ts +62 -0
- package/src/lane.ts +110 -0
- package/src/locate.ts +85 -0
- package/src/manifest.ts +43 -0
- package/src/manifests.ts +27 -0
- package/src/node-fetch.ts +41 -0
- package/src/poll.ts +33 -0
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
import type { Clock } from "../../clock.ts";
|
|
2
|
+
import { errorText } from "../../connector.ts";
|
|
3
|
+
import { type WriteLane, writeLane } from "../../lane.ts";
|
|
4
|
+
import { NS } from "./codec.ts";
|
|
5
|
+
import {
|
|
6
|
+
type Application,
|
|
7
|
+
MediaStatus,
|
|
8
|
+
MultizoneStatus,
|
|
9
|
+
ReceiverStatus,
|
|
10
|
+
} from "./schemas.ts";
|
|
11
|
+
import { type CastDevice, castKey } from "./state.ts";
|
|
12
|
+
|
|
13
|
+
// Writes over the sockets `cast()` already holds: volume, mute, stop, and an announcement that plays a clip and puts
|
|
14
|
+
// the device back as it was. Never called by the integration itself; tried on a scripted fake socket, not yet live.
|
|
15
|
+
|
|
16
|
+
/** Google's own player for a URL of audio or video. */
|
|
17
|
+
export const DEFAULT_MEDIA_RECEIVER = "CC1AD845";
|
|
18
|
+
/** Each request (a status, a volume, LAUNCH, LOAD, STOP) gets this long to answer. */
|
|
19
|
+
export const ANSWER_MS = 10_000;
|
|
20
|
+
/** How long an announcement waits for its clip to finish, beyond the clip's own length when the device reports it. */
|
|
21
|
+
export const PLAY_MS = 60_000;
|
|
22
|
+
|
|
23
|
+
const RECEIVER = "receiver-0";
|
|
24
|
+
/** Level writes, and mute writes, per device or group in any minute; more wait their turn, the newest winning. A guess: Cast publishes no limit. */
|
|
25
|
+
export const VOLUME_PER_MINUTE = 30;
|
|
26
|
+
|
|
27
|
+
/** Replies that refuse a request. */
|
|
28
|
+
const REFUSALS = new Set([
|
|
29
|
+
"LAUNCH_ERROR",
|
|
30
|
+
"LOAD_FAILED",
|
|
31
|
+
"LOAD_CANCELLED",
|
|
32
|
+
"INVALID_REQUEST",
|
|
33
|
+
"INVALID_PLAYER_STATE",
|
|
34
|
+
"INVALID_MEDIA_SESSION_ID",
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
/** What to play: a URL the device can fetch (type defaults to audio/mpeg), or bytes for `serve` to put on the LAN. */
|
|
38
|
+
export type CastClip =
|
|
39
|
+
{ url: string; type?: string } | { bytes: Uint8Array; type: string };
|
|
40
|
+
|
|
41
|
+
/** What `announce` did: whether the clip played to its end, why not, and what it put back or could not. */
|
|
42
|
+
export type AnnounceResult = {
|
|
43
|
+
played: boolean;
|
|
44
|
+
why?: string;
|
|
45
|
+
restored: string[];
|
|
46
|
+
notRestored: { name: string; why: string }[];
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Serves bytes once over HTTP on the LAN: `serveClip` from `@khalido/home/cast/serve`. `host` is the device that will fetch it. */
|
|
50
|
+
export type ServeClip = (
|
|
51
|
+
bytes: Uint8Array,
|
|
52
|
+
type: string,
|
|
53
|
+
options?: { host?: string; signal?: AbortSignal },
|
|
54
|
+
) => Promise<{ url: string; close(): void }>;
|
|
55
|
+
|
|
56
|
+
/** The writes on `cast()`'s object. A target is a device's or group's name (any case) or its castId; volume is 0 to 1, as the snapshot's. */
|
|
57
|
+
export type CastActions = {
|
|
58
|
+
setVolume(target: string, level: number): Promise<void>;
|
|
59
|
+
setMuted(target: string, muted: boolean): Promise<void>;
|
|
60
|
+
/** Ends the running app (receiver STOP), which returns the device to its idle screen. Nothing running: nothing sent. */
|
|
61
|
+
stop(target: string): Promise<void>;
|
|
62
|
+
announce(
|
|
63
|
+
target: string,
|
|
64
|
+
clip: CastClip,
|
|
65
|
+
options?: { volume?: number; timeoutMs?: number },
|
|
66
|
+
): Promise<AnnounceResult>;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** A payload as it came, before any schema. */
|
|
70
|
+
export type RawPayload = {
|
|
71
|
+
type?: unknown;
|
|
72
|
+
requestId?: unknown;
|
|
73
|
+
reason?: unknown;
|
|
74
|
+
[key: string]: unknown;
|
|
75
|
+
};
|
|
76
|
+
/** Sees every payload a socket receives, then `undefined` once when it closes. */
|
|
77
|
+
export type Listener = (raw: RawPayload | undefined) => void;
|
|
78
|
+
|
|
79
|
+
/** What a write needs of one live socket. */
|
|
80
|
+
export type CastSession = {
|
|
81
|
+
host: string;
|
|
82
|
+
port: number;
|
|
83
|
+
send(namespace: string, destination: string, payload: object): void;
|
|
84
|
+
nextId(): number;
|
|
85
|
+
listen(listener: Listener): () => void;
|
|
86
|
+
/** CONNECT to an app's transport, once per transport however many callers ask. */
|
|
87
|
+
connect(transport: string): void;
|
|
88
|
+
/** Close it after a discovery the caller just ran: it reconnects at the address the snapshot holds, without another discovery. */
|
|
89
|
+
end(why: string): void;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** What `castActions` needs of the integration. */
|
|
93
|
+
export type WriteContext = {
|
|
94
|
+
clock: Clock;
|
|
95
|
+
devices(): Record<string, CastDevice>;
|
|
96
|
+
session(id: string): CastSession | undefined;
|
|
97
|
+
discover(): Promise<void>;
|
|
98
|
+
/** The entry's socket once it is open at the address the snapshot holds, within `ms`. */
|
|
99
|
+
connected(id: string, ms: number): Promise<CastSession>;
|
|
100
|
+
serve?: ServeClip;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const idleApp = (app?: Application) => !app || app.isIdleScreen === true;
|
|
104
|
+
|
|
105
|
+
function checkLevel(level: number) {
|
|
106
|
+
if (!(level >= 0 && level <= 1))
|
|
107
|
+
throw new RangeError(`volume is 0 to 1, got ${level}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The first value `pick` returns for a payload on `s`. Rejects when `pick` throws, when the socket closes, when `signal`
|
|
112
|
+
* aborts, or after `ms` (a function is read again each time it would expire, so a deadline can grow).
|
|
113
|
+
*/
|
|
114
|
+
function waitFor<T>(
|
|
115
|
+
clock: Clock,
|
|
116
|
+
s: CastSession,
|
|
117
|
+
ms: number | (() => number),
|
|
118
|
+
what: string,
|
|
119
|
+
pick: (raw: RawPayload) => T | undefined,
|
|
120
|
+
signal?: AbortSignal,
|
|
121
|
+
): Promise<T> {
|
|
122
|
+
return new Promise<T>((resolve, reject) => {
|
|
123
|
+
const done = new AbortController();
|
|
124
|
+
const finish = (settle: () => void) =>
|
|
125
|
+
done.signal.aborted ||
|
|
126
|
+
(off(),
|
|
127
|
+
signal?.removeEventListener("abort", cancel),
|
|
128
|
+
done.abort(),
|
|
129
|
+
settle());
|
|
130
|
+
const cancel = () =>
|
|
131
|
+
finish(() => reject(new Error(`stopped waiting for ${what}`)));
|
|
132
|
+
const off = s.listen((raw) => {
|
|
133
|
+
if (raw === undefined)
|
|
134
|
+
return finish(() =>
|
|
135
|
+
reject(new Error(`the socket closed waiting for ${what}`)),
|
|
136
|
+
);
|
|
137
|
+
try {
|
|
138
|
+
const value = pick(raw);
|
|
139
|
+
if (value !== undefined) finish(() => resolve(value));
|
|
140
|
+
} catch (error) {
|
|
141
|
+
finish(() => reject(error));
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
145
|
+
const start = clock.now();
|
|
146
|
+
const limit = typeof ms === "number" ? () => ms : ms;
|
|
147
|
+
void (async () => {
|
|
148
|
+
for (
|
|
149
|
+
let left = limit();
|
|
150
|
+
left > 0 && !done.signal.aborted;
|
|
151
|
+
left = start + limit() - clock.now()
|
|
152
|
+
)
|
|
153
|
+
await clock.sleep(left, done.signal);
|
|
154
|
+
finish(() =>
|
|
155
|
+
reject(new Error(`no ${what} in ${Math.round(limit() / 1000)} s`)),
|
|
156
|
+
);
|
|
157
|
+
})();
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Send a request and take its reply by requestId; a refusal rejects, naming it. */
|
|
162
|
+
function request(
|
|
163
|
+
clock: Clock,
|
|
164
|
+
s: CastSession,
|
|
165
|
+
namespace: string,
|
|
166
|
+
destination: string,
|
|
167
|
+
payload: { type: string; [key: string]: unknown },
|
|
168
|
+
id = s.nextId(),
|
|
169
|
+
): Promise<RawPayload> {
|
|
170
|
+
const reply = waitFor(
|
|
171
|
+
clock,
|
|
172
|
+
s,
|
|
173
|
+
ANSWER_MS,
|
|
174
|
+
`answer to ${payload.type}`,
|
|
175
|
+
(raw) => {
|
|
176
|
+
if (raw.requestId !== id) return undefined;
|
|
177
|
+
if (typeof raw.type === "string" && REFUSALS.has(raw.type))
|
|
178
|
+
throw new Error(
|
|
179
|
+
`${payload.type} refused: ${raw.type}${typeof raw.reason === "string" ? ` (${raw.reason})` : ""}`,
|
|
180
|
+
);
|
|
181
|
+
return raw;
|
|
182
|
+
},
|
|
183
|
+
);
|
|
184
|
+
s.send(namespace, destination, { ...payload, requestId: id });
|
|
185
|
+
return reply;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Writes over the integration's sockets. */
|
|
189
|
+
export function castActions(ctx: WriteContext): CastActions {
|
|
190
|
+
const { clock } = ctx;
|
|
191
|
+
|
|
192
|
+
const find = (target: string): CastDevice => {
|
|
193
|
+
const all = Object.values(ctx.devices());
|
|
194
|
+
const d =
|
|
195
|
+
all.find((x) => x.id === castKey(target)) ??
|
|
196
|
+
all.find((x) => x.name.toLowerCase() === target.toLowerCase());
|
|
197
|
+
if (!d)
|
|
198
|
+
throw new Error(
|
|
199
|
+
`no Cast device or group "${target}"; have: ${
|
|
200
|
+
all
|
|
201
|
+
.map((x) => x.name)
|
|
202
|
+
.sort()
|
|
203
|
+
.join(", ") || "none"
|
|
204
|
+
}`,
|
|
205
|
+
);
|
|
206
|
+
return d;
|
|
207
|
+
};
|
|
208
|
+
const live = (d: CastDevice): CastSession => {
|
|
209
|
+
const s = ctx.session(d.id);
|
|
210
|
+
if (!s)
|
|
211
|
+
throw new Error(
|
|
212
|
+
`${d.name} is not connected${d.detail ? ` (${d.detail})` : ""}`,
|
|
213
|
+
);
|
|
214
|
+
return s;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/** A receiver request whose reply is a receiver status (GET_STATUS, SET_VOLUME, STOP). */
|
|
218
|
+
const receiver = async (
|
|
219
|
+
s: CastSession,
|
|
220
|
+
payload: { type: string; [key: string]: unknown } = { type: "GET_STATUS" },
|
|
221
|
+
) => {
|
|
222
|
+
const r = ReceiverStatus.safeParse(
|
|
223
|
+
await request(clock, s, NS.receiver, RECEIVER, payload),
|
|
224
|
+
);
|
|
225
|
+
if (!r.success)
|
|
226
|
+
throw new Error(
|
|
227
|
+
`an answer to ${payload.type} that is not a receiver status`,
|
|
228
|
+
);
|
|
229
|
+
return r.data.status ?? {};
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/** A group's socket at the address discovery gives now: a group lives on its leader, which can move. */
|
|
233
|
+
const regroup = async (d: CastDevice): Promise<CastSession> => {
|
|
234
|
+
await ctx.discover();
|
|
235
|
+
const now = ctx.devices()[d.id];
|
|
236
|
+
const s = ctx.session(d.id);
|
|
237
|
+
if (s && now && (s.host !== now.host || s.port !== now.port))
|
|
238
|
+
s.end("the group's leader moved");
|
|
239
|
+
return ctx.connected(d.id, ANSWER_MS);
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
/** What the Default Media Receiver is playing on `transport`, enough to load it again where it was. */
|
|
243
|
+
const playing = async (s: CastSession, transport: string) => {
|
|
244
|
+
s.connect(transport);
|
|
245
|
+
const r = MediaStatus.safeParse(
|
|
246
|
+
await request(clock, s, NS.media, transport, { type: "GET_STATUS" }),
|
|
247
|
+
);
|
|
248
|
+
const m = r.success ? r.data.status?.[0] : undefined;
|
|
249
|
+
return m?.media?.contentId
|
|
250
|
+
? {
|
|
251
|
+
media: m.media,
|
|
252
|
+
currentTime: m.currentTime ?? 0,
|
|
253
|
+
autoplay: m.playerState !== "PAUSED",
|
|
254
|
+
}
|
|
255
|
+
: undefined;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/** Each member's volume and mute, from the group's multizone status. */
|
|
259
|
+
const memberLevels = async (s: CastSession) => {
|
|
260
|
+
const r = MultizoneStatus.safeParse(
|
|
261
|
+
await request(clock, s, NS.multizone, RECEIVER, { type: "GET_STATUS" }),
|
|
262
|
+
);
|
|
263
|
+
if (!r.success)
|
|
264
|
+
throw new Error(
|
|
265
|
+
"an answer to the group's multizone status that is not one",
|
|
266
|
+
);
|
|
267
|
+
return (r.data.status?.devices ?? []).flatMap((m) => {
|
|
268
|
+
if (!m.deviceId) return [];
|
|
269
|
+
const id = castKey(m.deviceId);
|
|
270
|
+
return [
|
|
271
|
+
{
|
|
272
|
+
id,
|
|
273
|
+
name: ctx.devices()[id]?.name ?? m.name ?? m.deviceId,
|
|
274
|
+
level: m.volume?.level,
|
|
275
|
+
muted: m.volume?.muted,
|
|
276
|
+
},
|
|
277
|
+
];
|
|
278
|
+
});
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
/** LAUNCH the Default Media Receiver and wait for a receiver status that shows it running with a transport. */
|
|
282
|
+
const launch = (s: CastSession): Promise<Application> => {
|
|
283
|
+
const id = s.nextId();
|
|
284
|
+
const app = waitFor(
|
|
285
|
+
clock,
|
|
286
|
+
s,
|
|
287
|
+
ANSWER_MS,
|
|
288
|
+
"Default Media Receiver",
|
|
289
|
+
(raw) => {
|
|
290
|
+
if (
|
|
291
|
+
raw.requestId === id &&
|
|
292
|
+
typeof raw.type === "string" &&
|
|
293
|
+
REFUSALS.has(raw.type)
|
|
294
|
+
)
|
|
295
|
+
throw new Error(
|
|
296
|
+
`LAUNCH refused: ${raw.type}${typeof raw.reason === "string" ? ` (${raw.reason})` : ""}`,
|
|
297
|
+
);
|
|
298
|
+
const r = ReceiverStatus.safeParse(raw);
|
|
299
|
+
return r.success
|
|
300
|
+
? r.data.status?.applications?.find(
|
|
301
|
+
(a) => a.appId === DEFAULT_MEDIA_RECEIVER && a.transportId,
|
|
302
|
+
)
|
|
303
|
+
: undefined;
|
|
304
|
+
},
|
|
305
|
+
);
|
|
306
|
+
s.send(NS.receiver, RECEIVER, {
|
|
307
|
+
type: "LAUNCH",
|
|
308
|
+
appId: DEFAULT_MEDIA_RECEIVER,
|
|
309
|
+
requestId: id,
|
|
310
|
+
});
|
|
311
|
+
return app;
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* LOAD the clip and wait until its media session goes IDLE: true when FINISHED, an error for any other reason. The media
|
|
316
|
+
* session comes from LOAD's own reply, read in the same listener, so an IDLE from before the LOAD never counts.
|
|
317
|
+
*/
|
|
318
|
+
const play = async (
|
|
319
|
+
s: CastSession,
|
|
320
|
+
transport: string,
|
|
321
|
+
media: object,
|
|
322
|
+
timeoutMs?: number,
|
|
323
|
+
): Promise<boolean> => {
|
|
324
|
+
const loadId = s.nextId();
|
|
325
|
+
let session: number | "any" | undefined;
|
|
326
|
+
let durationS = 0;
|
|
327
|
+
const cancel = new AbortController();
|
|
328
|
+
const finished = waitFor(
|
|
329
|
+
clock,
|
|
330
|
+
s,
|
|
331
|
+
() => timeoutMs ?? PLAY_MS + durationS * 1000,
|
|
332
|
+
"end of the clip",
|
|
333
|
+
(raw) => {
|
|
334
|
+
const r = MediaStatus.safeParse(raw);
|
|
335
|
+
if (!r.success) return undefined;
|
|
336
|
+
if (raw.requestId === loadId)
|
|
337
|
+
session = r.data.status?.[0]?.mediaSessionId ?? "any";
|
|
338
|
+
if (session === undefined) return undefined;
|
|
339
|
+
for (const m of r.data.status ?? []) {
|
|
340
|
+
if (
|
|
341
|
+
session !== "any" &&
|
|
342
|
+
m.mediaSessionId !== undefined &&
|
|
343
|
+
m.mediaSessionId !== session
|
|
344
|
+
)
|
|
345
|
+
continue;
|
|
346
|
+
if (typeof m.media?.duration === "number")
|
|
347
|
+
durationS = m.media.duration;
|
|
348
|
+
if (m.playerState !== "IDLE" || !m.idleReason) continue;
|
|
349
|
+
if (m.idleReason === "FINISHED") return true;
|
|
350
|
+
throw new Error(`the clip stopped: ${m.idleReason}`);
|
|
351
|
+
}
|
|
352
|
+
return undefined;
|
|
353
|
+
},
|
|
354
|
+
cancel.signal,
|
|
355
|
+
);
|
|
356
|
+
finished.catch(() => {}); // awaited below; until then a cancel must not surface as unhandled
|
|
357
|
+
try {
|
|
358
|
+
await request(
|
|
359
|
+
clock,
|
|
360
|
+
s,
|
|
361
|
+
NS.media,
|
|
362
|
+
transport,
|
|
363
|
+
{ type: "LOAD", media, autoplay: true },
|
|
364
|
+
loadId,
|
|
365
|
+
);
|
|
366
|
+
} catch (error) {
|
|
367
|
+
cancel.abort();
|
|
368
|
+
throw error;
|
|
369
|
+
}
|
|
370
|
+
return finished;
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
// A lane per device, so a speaker that stopped answering holds up only itself. Level and mute are separate slots:
|
|
374
|
+
// a SET_VOLUME carries one or the other, never both.
|
|
375
|
+
const lanes = new Map<string, WriteLane>();
|
|
376
|
+
const setVolume = (
|
|
377
|
+
target: string,
|
|
378
|
+
volume: { level: number } | { muted: boolean },
|
|
379
|
+
) => {
|
|
380
|
+
const id = find(target).id;
|
|
381
|
+
if (!lanes.has(id))
|
|
382
|
+
lanes.set(
|
|
383
|
+
id,
|
|
384
|
+
writeLane({ gapMs: 0, perMinute: VOLUME_PER_MINUTE, clock }),
|
|
385
|
+
);
|
|
386
|
+
return lanes
|
|
387
|
+
.get(id)!
|
|
388
|
+
.send("level" in volume ? "level" : "muted", volume, (v) =>
|
|
389
|
+
receiver(live(find(target)), { type: "SET_VOLUME", volume: v }),
|
|
390
|
+
);
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
async setVolume(target, level) {
|
|
395
|
+
checkLevel(level);
|
|
396
|
+
await setVolume(target, { level });
|
|
397
|
+
},
|
|
398
|
+
async setMuted(target, muted) {
|
|
399
|
+
await setVolume(target, { muted });
|
|
400
|
+
},
|
|
401
|
+
async stop(target) {
|
|
402
|
+
const s = live(find(target));
|
|
403
|
+
const app = (await receiver(s)).applications?.[0];
|
|
404
|
+
if (app && !app.isIdleScreen)
|
|
405
|
+
await receiver(s, {
|
|
406
|
+
type: "STOP",
|
|
407
|
+
...(app.sessionId ? { sessionId: app.sessionId } : {}),
|
|
408
|
+
});
|
|
409
|
+
},
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Read the target's state, set the volume if asked and unmute a muted target, launch the Default Media Receiver, play the clip to its end, then
|
|
413
|
+
* put back what it can, always: what the Default Media Receiver was playing is loaded again where it was, an idle
|
|
414
|
+
* device gets STOP, then the volume (a group's, member by member through each member's own socket). Another app
|
|
415
|
+
* (Spotify, YouTube) is interrupted and cannot be resumed from here; the result says so. Throws only before anything changed.
|
|
416
|
+
*/
|
|
417
|
+
async announce(target, clip, options = {}) {
|
|
418
|
+
if (options.volume !== undefined) checkLevel(options.volume);
|
|
419
|
+
if (!("url" in clip) && !ctx.serve)
|
|
420
|
+
throw new Error(
|
|
421
|
+
"a clip of bytes needs serve: serveClip from @khalido/home/cast/serve",
|
|
422
|
+
);
|
|
423
|
+
let d = find(target);
|
|
424
|
+
const s = d.kind === "group" ? await regroup(d) : live(d);
|
|
425
|
+
d = ctx.devices()[d.id] ?? d; // a group's host as discovery gives it now, for serving the clip on its network
|
|
426
|
+
const before = await receiver(s);
|
|
427
|
+
const prior = before.applications?.[0];
|
|
428
|
+
const priorMedia =
|
|
429
|
+
prior?.appId === DEFAULT_MEDIA_RECEIVER && prior.transportId
|
|
430
|
+
? await playing(s, prior.transportId)
|
|
431
|
+
: undefined;
|
|
432
|
+
const unmute = before.volume?.muted === true; // a muted target would play the clip silently
|
|
433
|
+
const members =
|
|
434
|
+
d.kind === "group" && (options.volume !== undefined || unmute)
|
|
435
|
+
? await memberLevels(s)
|
|
436
|
+
: [];
|
|
437
|
+
const result: AnnounceResult = {
|
|
438
|
+
played: false,
|
|
439
|
+
restored: [],
|
|
440
|
+
notRestored: [],
|
|
441
|
+
};
|
|
442
|
+
const serving = new AbortController();
|
|
443
|
+
let served: { close(): void } | undefined;
|
|
444
|
+
let volumeSet = false;
|
|
445
|
+
let launched = false;
|
|
446
|
+
let ours: Application | undefined;
|
|
447
|
+
try {
|
|
448
|
+
let url: string;
|
|
449
|
+
if ("url" in clip) url = clip.url;
|
|
450
|
+
else
|
|
451
|
+
({ url } = served = await ctx.serve!(clip.bytes, clip.type, {
|
|
452
|
+
host: d.host,
|
|
453
|
+
signal: serving.signal,
|
|
454
|
+
}));
|
|
455
|
+
// Level and mute as two requests: Google's receiver docs take one or the other in a SET_VOLUME, not both.
|
|
456
|
+
if (options.volume !== undefined || unmute) volumeSet = true;
|
|
457
|
+
if (options.volume !== undefined)
|
|
458
|
+
await receiver(s, {
|
|
459
|
+
type: "SET_VOLUME",
|
|
460
|
+
volume: { level: options.volume },
|
|
461
|
+
});
|
|
462
|
+
if (unmute)
|
|
463
|
+
await receiver(s, { type: "SET_VOLUME", volume: { muted: false } });
|
|
464
|
+
launched = true;
|
|
465
|
+
ours = await launch(s);
|
|
466
|
+
s.connect(ours.transportId!);
|
|
467
|
+
result.played = await play(
|
|
468
|
+
s,
|
|
469
|
+
ours.transportId!,
|
|
470
|
+
{
|
|
471
|
+
contentId: url,
|
|
472
|
+
contentType: clip.type ?? "audio/mpeg",
|
|
473
|
+
streamType: "BUFFERED",
|
|
474
|
+
},
|
|
475
|
+
options.timeoutMs,
|
|
476
|
+
);
|
|
477
|
+
} catch (error) {
|
|
478
|
+
result.why = errorText(error);
|
|
479
|
+
} finally {
|
|
480
|
+
serving.abort();
|
|
481
|
+
served?.close();
|
|
482
|
+
if (launched) await restoreApp();
|
|
483
|
+
if (volumeSet) await restoreVolume();
|
|
484
|
+
}
|
|
485
|
+
return result;
|
|
486
|
+
|
|
487
|
+
async function restoreApp() {
|
|
488
|
+
try {
|
|
489
|
+
// A LAUNCH that timed out may still have started: look before deciding nothing of ours runs.
|
|
490
|
+
const now =
|
|
491
|
+
ours ??
|
|
492
|
+
(await receiver(s)).applications?.find(
|
|
493
|
+
(a) => a.appId === DEFAULT_MEDIA_RECEIVER && a.transportId,
|
|
494
|
+
);
|
|
495
|
+
if (!now) return;
|
|
496
|
+
if (priorMedia) {
|
|
497
|
+
s.connect(now.transportId!);
|
|
498
|
+
await request(clock, s, NS.media, now.transportId!, {
|
|
499
|
+
type: "LOAD",
|
|
500
|
+
...priorMedia,
|
|
501
|
+
});
|
|
502
|
+
result.restored.push(`${d.name}: what was playing`);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
await receiver(s, {
|
|
506
|
+
type: "STOP",
|
|
507
|
+
...(now.sessionId ? { sessionId: now.sessionId } : {}),
|
|
508
|
+
});
|
|
509
|
+
if (idleApp(prior) || prior?.appId === DEFAULT_MEDIA_RECEIVER)
|
|
510
|
+
result.restored.push(`${d.name}: idle`);
|
|
511
|
+
else
|
|
512
|
+
result.notRestored.push({
|
|
513
|
+
name: d.name,
|
|
514
|
+
why: `${prior?.displayName ?? prior?.appId ?? "an app"} was interrupted and cannot be resumed from here`,
|
|
515
|
+
});
|
|
516
|
+
} catch (error) {
|
|
517
|
+
result.notRestored.push({
|
|
518
|
+
name: d.name,
|
|
519
|
+
why: `${priorMedia ? "what was playing" : "idle"} not restored: ${errorText(error)}`,
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async function restoreVolume() {
|
|
525
|
+
const each =
|
|
526
|
+
d.kind === "group"
|
|
527
|
+
? members
|
|
528
|
+
: [
|
|
529
|
+
{
|
|
530
|
+
id: d.id,
|
|
531
|
+
name: d.name,
|
|
532
|
+
level: before.volume?.level,
|
|
533
|
+
muted: before.volume?.muted,
|
|
534
|
+
},
|
|
535
|
+
];
|
|
536
|
+
for (const m of each) {
|
|
537
|
+
const ms = m.id === d.id ? s : ctx.session(m.id);
|
|
538
|
+
if (m.level === undefined)
|
|
539
|
+
result.notRestored.push({
|
|
540
|
+
name: m.name,
|
|
541
|
+
why: "its volume before was not reported",
|
|
542
|
+
});
|
|
543
|
+
else if (!ms)
|
|
544
|
+
result.notRestored.push({
|
|
545
|
+
name: m.name,
|
|
546
|
+
why: "not connected, so its volume stays as the announcement set it",
|
|
547
|
+
});
|
|
548
|
+
else
|
|
549
|
+
try {
|
|
550
|
+
await receiver(ms, {
|
|
551
|
+
type: "SET_VOLUME",
|
|
552
|
+
volume: { level: m.level },
|
|
553
|
+
});
|
|
554
|
+
if (m.muted)
|
|
555
|
+
await receiver(ms, {
|
|
556
|
+
type: "SET_VOLUME",
|
|
557
|
+
volume: { muted: true },
|
|
558
|
+
});
|
|
559
|
+
result.restored.push(
|
|
560
|
+
`${m.name}: volume${m.muted ? ", muted" : ""}`,
|
|
561
|
+
);
|
|
562
|
+
} catch (error) {
|
|
563
|
+
result.notRestored.push({
|
|
564
|
+
name: m.name,
|
|
565
|
+
why: `volume not restored: ${errorText(error)}`,
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
},
|
|
571
|
+
};
|
|
572
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Daikin
|
|
2
|
+
|
|
3
|
+
Reads one Daikin AC over its local JSON `dsiot` API. `iot_class`: **local_polling**. KO's unit is a Daikin Australia **Alira X** with built-in Wi-Fi (likely; it reports a blank model string).
|
|
4
|
+
|
|
5
|
+
- `client.ts`: `daikinClient({ host, fetch }).read()` returns `DaikinState` (power, mode, the selected mode's setpoint and fan speed, room °C, humidity, outside °C, `anomaly`). `setPower`, `setMode`, `setTarget`, `setFan` build write requests; `client.write(request)` sends one, through a write lane: 1 s apart and at most 20 a minute (guesses), a waiting write merging the next one field by field (`mergeWrites`). Every request times out at 10 s.
|
|
6
|
+
- `index.ts`: `daikin({ host, fetch, clock })` reads now, then every **120 s** (Home Assistant's `update_interval`). A failed poll is `disconnected` and keeps the last reading with `asOf`; a 404 on `/dsiot/multireq` is a failed read too, saying "not a dsiot Daikin": the address may now be another device's, so discovery looks for the unit. There is no key, so nothing is terminal. Writes go through `daikin().client.write(…)`: one client for the integration's life, at the unit's current address.
|
|
7
|
+
- `home status` reads `DAIKIN_HOST`, else the first unit that answers the UDP broadcast. There is no CLI command that writes.
|
|
8
|
+
|
|
9
|
+
## The protocol
|
|
10
|
+
|
|
11
|
+
Plain HTTP on port 80, no key, no registration. Everything is one endpoint, `POST /dsiot/multireq`, with `{"requests": [...]}`.
|
|
12
|
+
|
|
13
|
+
- **Discovery**: a UDP broadcast of `DAIKIN_UDP/common/basic_info` to port 30050 from port 30000 gets a `key=value` answer (KO's unit: `type=GPF`, `protocol=DGC`, `ver=3_12_3`, `api_ver=2_2`). Home Assistant also matches the zeroconf type `_dkapi._tcp`, which `home discover` now looks for.
|
|
14
|
+
- **Read** (`op: 2`): `/dsiot/edge/adr_0100.dgc_status?filter=pv,pt,md` (indoor unit, tree under `e_1002`) and `/dsiot/edge/adr_0200.dgc_status?filter=pv,pt,md` (outdoor unit, `e_1003`), in one request. Each response carries `rsc`; `2000` is OK. Every value is a hex string `pv`, and `md` gives the unit's own step (`st`), min (`mi`) and max (`mx`).
|
|
15
|
+
- **Values are read by name**, never by position in the child list. Multi-byte values are little-endian; temperatures are signed.
|
|
16
|
+
|
|
17
|
+
| Path | Meaning | Decoding |
|
|
18
|
+
| -------------------------------------------- | ----------------------------- | ------------------------------------------------------------- |
|
|
19
|
+
| `e_1002/e_A002/p_01` | power | `00` off, `01` on |
|
|
20
|
+
| `e_1002/e_3001/p_01` | mode | `0000` fan, `0100` heat, `0200` cool, `0300` auto, `0500` dry |
|
|
21
|
+
| `e_1002/e_3001/p_02`, `p_03`, `p_1D` | setpoint for cool, heat, auto | one byte, °C × 2 |
|
|
22
|
+
| `e_1002/e_3001/p_09`, `p_0A`, `p_26`, `p_28` | fan for cool, heat, auto, fan | `0300`–`0700` speeds 1–5, `0A00` auto, `0B00` quiet |
|
|
23
|
+
| `e_1002/e_A00B/p_01` | room °C | signed byte, whole degrees (`mi F7` is −9) |
|
|
24
|
+
| `e_1002/e_A00B/p_02` | humidity % | byte |
|
|
25
|
+
| `e_1003/e_A00D/p_01` | outside °C | signed little-endian, °C × 2 (`mi EEFF` is −9 °C) |
|
|
26
|
+
|
|
27
|
+
Dry mode has no setpoint or fan field in this map, so both read as undefined there rather than a default. An unknown mode, fan or power code is left undefined and named in `anomaly`.
|
|
28
|
+
|
|
29
|
+
- **Write** (`op: 3`): the same `to` without the filter, and a `pc` tree `dgc_status → e_1002 → <entity> → {pn, pv}`; several fields can share one tree. `setMode` writes power `01` with the mode, as both sources do. `setTarget` writes one uppercase byte (25 °C is `32`) to the given mode's field and throws, never rounds, off 0.5 steps or outside that field's `mi`–`mx` on KO's unit (cool 18–32, heat 10–30, auto 18–30). `write` fails on an `rsc` of 4000 or more.
|
|
30
|
+
|
|
31
|
+
## What KO's unit answered (2026-10-13)
|
|
32
|
+
|
|
33
|
+
One read-only request, saved with the MAC, SSID, `edid` and the unit's ASCII ids removed as `test/fixtures/daikin/status.json`. All three responses `rsc 2000`. Decoded: **off, cool, target 25 °C, room 24 °C, humidity 70%, fan 3, outside 19.5 °C** (`2700`). `adp_i`: `ver 3_12_3`, `api_ver 2_2`, `reg th`. `home status` printed the same line live.
|
|
34
|
+
|
|
35
|
+
## Energy (2026-10-15)
|
|
36
|
+
|
|
37
|
+
`POST /dsiot/multireq` with `op: 2` to `/dsiot/edge/adr_0100.i_power.week_power` answered `rsc` 2000 with `week_power/today_runtime` (0 while the unit was off), `week_power/datas` (seven numbers) and `week_power/datas2` (seven numbers); the values were multiples of 100. The same path under `adr_0200`, and `adr_0100.i_power` and `.today`, answered 4041. **Unconfirmed**: the unit of the numbers, which end of the array is today, what `datas2` splits from `datas`, and whether they are measured or estimated. Compare them with the Daikin app's weekly graph before reading them as kWh. Not built.
|
|
38
|
+
|
|
39
|
+
## Unconfirmed
|
|
40
|
+
|
|
41
|
+
- **The write lane's rates** (1 s apart, 20 a minute) are guesses. One client lives as long as the integration and reads the unit's address at each send, so a move keeps the lane's count, and a write still waiting goes to the new address.
|
|
42
|
+
- **Writes other than power.** Only `setPower(true)` has been sent to KO's unit (2026-10-16, Bun 1.4.3, see below); mode, setpoint and fan writes are untried. The encodings come from pydaikin, which reports verified live writes on other BRP084 units. The two sources disagree in three places, plus one choice of our own, settled here as follows:
|
|
43
|
+
- Setpoint width: pydaikin sends one byte, Alira two (`3200`). One byte, because the live field is one byte (`p_02` `mi 24`, `mx 40`).
|
|
44
|
+
- Method: pydaikin POSTs, Alira PUTs. POST, the method reads already use.
|
|
45
|
+
- Field: Alira always writes `p_03` and `p_0A`. Those are pydaikin's heat-mode setpoint and fan, and Alira reads by list position (its `[2]` and `[8]` are `p_03` and `p_0A`), so it appears written on a unit in heat. Per-mode fields here.
|
|
46
|
+
- Hex case: pydaikin sends lowercase (`32`), this sends uppercase, matching what the unit reports (`EEFF`).
|
|
47
|
+
- The success `rsc` for a write: the indoor unit's answer must be under 4000. pydaikin does not check it; it re-reads the state, which the first watched write should do too.
|
|
48
|
+
- Whether firmware updates move these paths, and what `reg=th` means.
|
|
49
|
+
|
|
50
|
+
## First write (2026-10-16)
|
|
51
|
+
|
|
52
|
+
`setPower(true)` at KO's request: the unit answered under 4000, and a re-read 8 s later showed `on: true` (before: off, cool 25 °C, room 20 °C, outside 15 °C, 75 % humidity; after: room 21 °C). The POST, one-byte payload and uppercase hex were accepted as built. Mode, setpoint and fan writes remain untried.
|
|
53
|
+
|
|
54
|
+
All setpoint and fan fields, read from `e_3001` with `md` (setpoint is one byte, °C × 2, and `md.st` is 245 on all of them):
|
|
55
|
+
|
|
56
|
+
| Field | Meaning | Value | Range |
|
|
57
|
+
| -------------- | ------------------ | --------------- | ----- |
|
|
58
|
+
| `p_02` | cool setpoint | 25 °C | 18–32 |
|
|
59
|
+
| `p_03` | heat setpoint | 25 °C | 10–30 |
|
|
60
|
+
| `p_1D` | auto setpoint | 24 °C | 18–30 |
|
|
61
|
+
| `p_09`, `p_0A` | cool, heat fan | `0500`, speed 3 | |
|
|
62
|
+
| `p_26`, `p_28` | auto, fan-mode fan | auto, speed 5 | |
|
|
63
|
+
|
|
64
|
+
Unmapped and unread: `p_27` (`0A00`, like auto), `p_34` (one signed byte, `00`, `mi FA`, `mx 00`), and the `p_05`–`p_08`, `p_20`–`p_25` fields (3- and 4-byte values, mostly `0F…`, likely schedules or limits). Meaning unknown.
|
|
65
|
+
|
|
66
|
+
## Sources
|
|
67
|
+
|
|
68
|
+
Both Daikin protocol sources are GPL-3.0, so they are read for facts only (endpoints, field paths, value maps) and no code is copied; that keeps this package MIT.
|
|
69
|
+
|
|
70
|
+
| Source | Licence | Used for |
|
|
71
|
+
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
|
72
|
+
| [Home Assistant `daikin` integration](https://github.com/home-assistant/core/tree/dev/homeassistant/components/daikin) ([docs](https://www.home-assistant.io/integrations/daikin/)) | Apache-2.0 | `iot_class` `local_polling`, the 120 s poll, zeroconf `_dkapi._tcp`; built on `pydaikin==2.20.0` |
|
|
73
|
+
| [fredrike/pydaikin](https://github.com/fredrike/pydaikin), `daikin_brp084.py` | GPL-3.0, facts only | UDP discovery packet, the `dsiot` paths, mode and fan maps, per-mode setpoint and fan fields, write tree, value encodings |
|
|
74
|
+
| [cambot1901/ha-daikin-alira](https://github.com/cambot1901/ha-daikin-alira) | GPL-3.0, facts only | a second account of the read and write requests on an Alira; its sensor polls every 30 s with a 10 s timeout |
|
|
75
|
+
|
|
76
|
+
## Energy, second read (2026-10-16)
|
|
77
|
+
|
|
78
|
+
After the unit ran 15 minutes in cool: `today_runtime` 15, `datas` `[0,0,600,100,0,0,0]`, `datas2` `[0,0,100,0,0,0,100]`. The 2026-10-15 read was `datas` `[100,0,0,600,100,0,0]`, `datas2` `[0,0,0,100,0,0,0]`. So both lists shift one place left per day: oldest first, last is today, and `today_runtime` is minutes. Today's `datas2` rose to 100 while `datas` stayed 0, so `datas2` may be cooling and `datas` heating, in steps of 100 (Wh?). One data point; the units are still unconfirmed, and the figures may be the unit's estimate rather than a metered reading.
|