@khalido/home 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/.env.example +25 -0
  2. package/CHANGELOG.md +47 -0
  3. package/LICENSE +21 -0
  4. package/README.md +77 -2
  5. package/dist/clock.d.ts +17 -0
  6. package/dist/clock.js +43 -0
  7. package/dist/config.d.ts +273 -0
  8. package/dist/config.js +392 -0
  9. package/dist/connector.d.ts +34 -0
  10. package/dist/connector.js +47 -0
  11. package/dist/connectors/amber/client.d.ts +61 -0
  12. package/dist/connectors/amber/client.js +106 -0
  13. package/dist/connectors/amber/index.d.ts +30 -0
  14. package/dist/connectors/amber/index.js +94 -0
  15. package/dist/connectors/cast/codec.d.ts +38 -0
  16. package/dist/connectors/cast/codec.js +102 -0
  17. package/dist/connectors/cast/index.d.ts +60 -0
  18. package/dist/connectors/cast/index.js +428 -0
  19. package/dist/connectors/cast/schemas.d.ts +167 -0
  20. package/dist/connectors/cast/schemas.js +87 -0
  21. package/dist/connectors/cast/serve.d.ts +18 -0
  22. package/dist/connectors/cast/serve.js +57 -0
  23. package/dist/connectors/cast/state.d.ts +62 -0
  24. package/dist/connectors/cast/state.js +133 -0
  25. package/dist/connectors/cast/tls.d.ts +3 -0
  26. package/dist/connectors/cast/tls.js +67 -0
  27. package/dist/connectors/cast/writes.d.ts +80 -0
  28. package/dist/connectors/cast/writes.js +390 -0
  29. package/dist/connectors/daikin/client.d.ts +65 -0
  30. package/dist/connectors/daikin/client.js +221 -0
  31. package/dist/connectors/daikin/index.d.ts +41 -0
  32. package/dist/connectors/daikin/index.js +108 -0
  33. package/dist/connectors/enphase/client.d.ts +65 -0
  34. package/dist/connectors/enphase/client.js +173 -0
  35. package/dist/connectors/enphase/index.d.ts +43 -0
  36. package/dist/connectors/enphase/index.js +174 -0
  37. package/dist/connectors/enphase/schemas.d.ts +51 -0
  38. package/dist/connectors/enphase/schemas.js +34 -0
  39. package/dist/connectors/hue/client.d.ts +48 -0
  40. package/dist/connectors/hue/client.js +95 -0
  41. package/dist/connectors/hue/events.d.ts +41 -0
  42. package/dist/connectors/hue/events.js +156 -0
  43. package/dist/connectors/hue/index.d.ts +42 -0
  44. package/dist/connectors/hue/index.js +205 -0
  45. package/dist/connectors/hue/schemas.d.ts +65 -0
  46. package/dist/connectors/hue/schemas.js +48 -0
  47. package/dist/connectors/hue/state.d.ts +43 -0
  48. package/dist/connectors/hue/state.js +84 -0
  49. package/dist/connectors/printers/client.d.ts +28 -0
  50. package/dist/connectors/printers/client.js +74 -0
  51. package/dist/connectors/printers/codec.d.ts +76 -0
  52. package/dist/connectors/printers/codec.js +246 -0
  53. package/dist/connectors/printers/found.d.ts +15 -0
  54. package/dist/connectors/printers/found.js +35 -0
  55. package/dist/connectors/printers/index.d.ts +52 -0
  56. package/dist/connectors/printers/index.js +282 -0
  57. package/dist/connectors/printers/state.d.ts +60 -0
  58. package/dist/connectors/printers/state.js +99 -0
  59. package/dist/connectors/spotify/changes.d.ts +32 -0
  60. package/dist/connectors/spotify/changes.js +84 -0
  61. package/dist/connectors/spotify/client.d.ts +199 -0
  62. package/dist/connectors/spotify/client.js +270 -0
  63. package/dist/connectors/spotify/index.d.ts +92 -0
  64. package/dist/connectors/spotify/index.js +754 -0
  65. package/dist/connectors/up/client.d.ts +183 -0
  66. package/dist/connectors/up/client.js +308 -0
  67. package/dist/connectors/up/index.d.ts +91 -0
  68. package/dist/connectors/up/index.js +213 -0
  69. package/dist/connectors/up/spend.d.ts +78 -0
  70. package/dist/connectors/up/spend.js +213 -0
  71. package/dist/connectors/up/webhook.d.ts +13 -0
  72. package/dist/connectors/up/webhook.js +32 -0
  73. package/dist/connectors/weather/blocks.d.ts +51 -0
  74. package/dist/connectors/weather/blocks.js +106 -0
  75. package/dist/connectors/weather/bom.d.ts +37 -0
  76. package/dist/connectors/weather/bom.js +237 -0
  77. package/dist/connectors/weather/ftp.d.ts +5 -0
  78. package/dist/connectors/weather/ftp.js +46 -0
  79. package/dist/connectors/weather/google.d.ts +31 -0
  80. package/dist/connectors/weather/google.js +364 -0
  81. package/dist/connectors/weather/index.d.ts +53 -0
  82. package/dist/connectors/weather/index.js +280 -0
  83. package/dist/connectors/weather/open-meteo.d.ts +15 -0
  84. package/dist/connectors/weather/open-meteo.js +285 -0
  85. package/dist/connectors/weather/rain.d.ts +19 -0
  86. package/dist/connectors/weather/rain.js +51 -0
  87. package/dist/connectors/weather/source.d.ts +149 -0
  88. package/dist/connectors/weather/source.js +77 -0
  89. package/dist/connectors/weather/sun.d.ts +48 -0
  90. package/dist/connectors/weather/sun.js +65 -0
  91. package/dist/discover.d.ts +197 -0
  92. package/dist/discover.js +452 -0
  93. package/dist/energy.d.ts +61 -0
  94. package/dist/energy.js +162 -0
  95. package/dist/finders-entry.d.ts +1 -0
  96. package/dist/finders-entry.js +2 -0
  97. package/dist/finders.d.ts +48 -0
  98. package/dist/finders.js +176 -0
  99. package/dist/health.d.ts +28 -0
  100. package/dist/health.js +35 -0
  101. package/dist/home.d.ts +81 -0
  102. package/dist/home.js +114 -0
  103. package/dist/index.d.ts +11 -0
  104. package/dist/index.js +11 -0
  105. package/dist/lane.d.ts +17 -0
  106. package/dist/lane.js +74 -0
  107. package/dist/locate.d.ts +27 -0
  108. package/dist/locate.js +59 -0
  109. package/dist/manifest.d.ts +31 -0
  110. package/dist/manifest.js +4 -0
  111. package/dist/manifests.d.ts +5 -0
  112. package/dist/manifests.js +23 -0
  113. package/dist/node-fetch.d.ts +9 -0
  114. package/dist/node-fetch.js +25 -0
  115. package/dist/poll.d.ts +10 -0
  116. package/dist/poll.js +33 -0
  117. package/docs/cast.md +133 -0
  118. package/docs/cli.md +41 -0
  119. package/docs/config.md +36 -0
  120. package/docs/decisions.md +150 -0
  121. package/docs/devices.md +128 -0
  122. package/docs/enphase-notes.md +24 -0
  123. package/docs/google.md +92 -0
  124. package/docs/ideas.md +69 -0
  125. package/docs/matter.md +55 -0
  126. package/docs/performance.md +12 -0
  127. package/docs/philosophy.md +23 -0
  128. package/docs/up.md +84 -0
  129. package/docs/using.md +65 -0
  130. package/docs/weather-probe.md +63 -0
  131. package/examples/home.jsonc +74 -0
  132. package/examples/home.svg +76 -0
  133. package/home.schema.json +474 -0
  134. package/llms.txt +32 -0
  135. package/package.json +143 -4
  136. package/skills/draw-home/README.md +25 -0
  137. package/skills/draw-home/SKILL.md +80 -0
  138. package/skills/use-home/SKILL.md +86 -0
  139. package/src/cli/cache.ts +29 -0
  140. package/src/cli/cast.ts +172 -0
  141. package/src/cli/config.ts +52 -0
  142. package/src/cli/context.ts +118 -0
  143. package/src/cli/discover.ts +182 -0
  144. package/src/cli/enphase.ts +62 -0
  145. package/src/cli/hue.ts +124 -0
  146. package/src/cli/locate.ts +47 -0
  147. package/src/cli/main.ts +182 -0
  148. package/src/cli/printers.ts +21 -0
  149. package/src/cli/probe.ts +274 -0
  150. package/src/cli/spotify.ts +76 -0
  151. package/src/cli/status.ts +228 -0
  152. package/src/cli/up.ts +350 -0
  153. package/src/clock.ts +62 -0
  154. package/src/config.ts +578 -0
  155. package/src/connector.ts +77 -0
  156. package/src/connectors/amber/README.md +40 -0
  157. package/src/connectors/amber/client.ts +157 -0
  158. package/src/connectors/amber/index.ts +118 -0
  159. package/src/connectors/cast/README.md +44 -0
  160. package/src/connectors/cast/codec.ts +126 -0
  161. package/src/connectors/cast/index.ts +552 -0
  162. package/src/connectors/cast/schemas.ts +101 -0
  163. package/src/connectors/cast/serve.ts +82 -0
  164. package/src/connectors/cast/state.ts +203 -0
  165. package/src/connectors/cast/tls.ts +100 -0
  166. package/src/connectors/cast/writes.ts +572 -0
  167. package/src/connectors/daikin/README.md +78 -0
  168. package/src/connectors/daikin/client.ts +295 -0
  169. package/src/connectors/daikin/index.ts +145 -0
  170. package/src/connectors/enphase/README.md +64 -0
  171. package/src/connectors/enphase/client.ts +244 -0
  172. package/src/connectors/enphase/index.ts +241 -0
  173. package/src/connectors/enphase/schemas.ts +45 -0
  174. package/src/connectors/hue/README.md +50 -0
  175. package/src/connectors/hue/client.ts +157 -0
  176. package/src/connectors/hue/events.ts +182 -0
  177. package/src/connectors/hue/index.ts +261 -0
  178. package/src/connectors/hue/schemas.ts +60 -0
  179. package/src/connectors/hue/state.ts +124 -0
  180. package/src/connectors/printers/README.md +46 -0
  181. package/src/connectors/printers/client.ts +114 -0
  182. package/src/connectors/printers/codec.ts +292 -0
  183. package/src/connectors/printers/found.ts +46 -0
  184. package/src/connectors/printers/index.ts +385 -0
  185. package/src/connectors/printers/state.ts +182 -0
  186. package/src/connectors/spotify/README.md +83 -0
  187. package/src/connectors/spotify/changes.ts +127 -0
  188. package/src/connectors/spotify/client.ts +387 -0
  189. package/src/connectors/spotify/index.ts +937 -0
  190. package/src/connectors/up/README.md +35 -0
  191. package/src/connectors/up/client.ts +518 -0
  192. package/src/connectors/up/index.ts +370 -0
  193. package/src/connectors/up/spend.ts +280 -0
  194. package/src/connectors/up/webhook.ts +62 -0
  195. package/src/connectors/weather/README.md +84 -0
  196. package/src/connectors/weather/blocks.ts +173 -0
  197. package/src/connectors/weather/bom.ts +344 -0
  198. package/src/connectors/weather/ftp.ts +48 -0
  199. package/src/connectors/weather/google.ts +425 -0
  200. package/src/connectors/weather/index.ts +396 -0
  201. package/src/connectors/weather/open-meteo.ts +309 -0
  202. package/src/connectors/weather/rain.ts +73 -0
  203. package/src/connectors/weather/source.ts +236 -0
  204. package/src/connectors/weather/sun.ts +114 -0
  205. package/src/discover.ts +638 -0
  206. package/src/energy.ts +221 -0
  207. package/src/finders-entry.ts +2 -0
  208. package/src/finders.ts +250 -0
  209. package/src/health.ts +74 -0
  210. package/src/home.ts +205 -0
  211. package/src/index.ts +62 -0
  212. package/src/lane.ts +110 -0
  213. package/src/locate.ts +85 -0
  214. package/src/manifest.ts +43 -0
  215. package/src/manifests.ts +27 -0
  216. package/src/node-fetch.ts +41 -0
  217. package/src/poll.ts +33 -0
@@ -0,0 +1,390 @@
1
+ import { errorText } from "../../connector.js";
2
+ import { writeLane } from "../../lane.js";
3
+ import { NS } from "./codec.js";
4
+ import { MediaStatus, MultizoneStatus, ReceiverStatus, } from "./schemas.js";
5
+ import { castKey } from "./state.js";
6
+ // Writes over the sockets `cast()` already holds: volume, mute, stop, and an announcement that plays a clip and puts
7
+ // the device back as it was. Never called by the integration itself; tried on a scripted fake socket, not yet live.
8
+ /** Google's own player for a URL of audio or video. */
9
+ export const DEFAULT_MEDIA_RECEIVER = "CC1AD845";
10
+ /** Each request (a status, a volume, LAUNCH, LOAD, STOP) gets this long to answer. */
11
+ export const ANSWER_MS = 10_000;
12
+ /** How long an announcement waits for its clip to finish, beyond the clip's own length when the device reports it. */
13
+ export const PLAY_MS = 60_000;
14
+ const RECEIVER = "receiver-0";
15
+ /** 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. */
16
+ export const VOLUME_PER_MINUTE = 30;
17
+ /** Replies that refuse a request. */
18
+ const REFUSALS = new Set([
19
+ "LAUNCH_ERROR",
20
+ "LOAD_FAILED",
21
+ "LOAD_CANCELLED",
22
+ "INVALID_REQUEST",
23
+ "INVALID_PLAYER_STATE",
24
+ "INVALID_MEDIA_SESSION_ID",
25
+ ]);
26
+ const idleApp = (app) => !app || app.isIdleScreen === true;
27
+ function checkLevel(level) {
28
+ if (!(level >= 0 && level <= 1))
29
+ throw new RangeError(`volume is 0 to 1, got ${level}`);
30
+ }
31
+ /**
32
+ * The first value `pick` returns for a payload on `s`. Rejects when `pick` throws, when the socket closes, when `signal`
33
+ * aborts, or after `ms` (a function is read again each time it would expire, so a deadline can grow).
34
+ */
35
+ function waitFor(clock, s, ms, what, pick, signal) {
36
+ return new Promise((resolve, reject) => {
37
+ const done = new AbortController();
38
+ const finish = (settle) => done.signal.aborted ||
39
+ (off(),
40
+ signal?.removeEventListener("abort", cancel),
41
+ done.abort(),
42
+ settle());
43
+ const cancel = () => finish(() => reject(new Error(`stopped waiting for ${what}`)));
44
+ const off = s.listen((raw) => {
45
+ if (raw === undefined)
46
+ return finish(() => reject(new Error(`the socket closed waiting for ${what}`)));
47
+ try {
48
+ const value = pick(raw);
49
+ if (value !== undefined)
50
+ finish(() => resolve(value));
51
+ }
52
+ catch (error) {
53
+ finish(() => reject(error));
54
+ }
55
+ });
56
+ signal?.addEventListener("abort", cancel, { once: true });
57
+ const start = clock.now();
58
+ const limit = typeof ms === "number" ? () => ms : ms;
59
+ void (async () => {
60
+ for (let left = limit(); left > 0 && !done.signal.aborted; left = start + limit() - clock.now())
61
+ await clock.sleep(left, done.signal);
62
+ finish(() => reject(new Error(`no ${what} in ${Math.round(limit() / 1000)} s`)));
63
+ })();
64
+ });
65
+ }
66
+ /** Send a request and take its reply by requestId; a refusal rejects, naming it. */
67
+ function request(clock, s, namespace, destination, payload, id = s.nextId()) {
68
+ const reply = waitFor(clock, s, ANSWER_MS, `answer to ${payload.type}`, (raw) => {
69
+ if (raw.requestId !== id)
70
+ return undefined;
71
+ if (typeof raw.type === "string" && REFUSALS.has(raw.type))
72
+ throw new Error(`${payload.type} refused: ${raw.type}${typeof raw.reason === "string" ? ` (${raw.reason})` : ""}`);
73
+ return raw;
74
+ });
75
+ s.send(namespace, destination, { ...payload, requestId: id });
76
+ return reply;
77
+ }
78
+ /** Writes over the integration's sockets. */
79
+ export function castActions(ctx) {
80
+ const { clock } = ctx;
81
+ const find = (target) => {
82
+ const all = Object.values(ctx.devices());
83
+ const d = all.find((x) => x.id === castKey(target)) ??
84
+ all.find((x) => x.name.toLowerCase() === target.toLowerCase());
85
+ if (!d)
86
+ throw new Error(`no Cast device or group "${target}"; have: ${all
87
+ .map((x) => x.name)
88
+ .sort()
89
+ .join(", ") || "none"}`);
90
+ return d;
91
+ };
92
+ const live = (d) => {
93
+ const s = ctx.session(d.id);
94
+ if (!s)
95
+ throw new Error(`${d.name} is not connected${d.detail ? ` (${d.detail})` : ""}`);
96
+ return s;
97
+ };
98
+ /** A receiver request whose reply is a receiver status (GET_STATUS, SET_VOLUME, STOP). */
99
+ const receiver = async (s, payload = { type: "GET_STATUS" }) => {
100
+ const r = ReceiverStatus.safeParse(await request(clock, s, NS.receiver, RECEIVER, payload));
101
+ if (!r.success)
102
+ throw new Error(`an answer to ${payload.type} that is not a receiver status`);
103
+ return r.data.status ?? {};
104
+ };
105
+ /** A group's socket at the address discovery gives now: a group lives on its leader, which can move. */
106
+ const regroup = async (d) => {
107
+ await ctx.discover();
108
+ const now = ctx.devices()[d.id];
109
+ const s = ctx.session(d.id);
110
+ if (s && now && (s.host !== now.host || s.port !== now.port))
111
+ s.end("the group's leader moved");
112
+ return ctx.connected(d.id, ANSWER_MS);
113
+ };
114
+ /** What the Default Media Receiver is playing on `transport`, enough to load it again where it was. */
115
+ const playing = async (s, transport) => {
116
+ s.connect(transport);
117
+ const r = MediaStatus.safeParse(await request(clock, s, NS.media, transport, { type: "GET_STATUS" }));
118
+ const m = r.success ? r.data.status?.[0] : undefined;
119
+ return m?.media?.contentId
120
+ ? {
121
+ media: m.media,
122
+ currentTime: m.currentTime ?? 0,
123
+ autoplay: m.playerState !== "PAUSED",
124
+ }
125
+ : undefined;
126
+ };
127
+ /** Each member's volume and mute, from the group's multizone status. */
128
+ const memberLevels = async (s) => {
129
+ const r = MultizoneStatus.safeParse(await request(clock, s, NS.multizone, RECEIVER, { type: "GET_STATUS" }));
130
+ if (!r.success)
131
+ throw new Error("an answer to the group's multizone status that is not one");
132
+ return (r.data.status?.devices ?? []).flatMap((m) => {
133
+ if (!m.deviceId)
134
+ return [];
135
+ const id = castKey(m.deviceId);
136
+ return [
137
+ {
138
+ id,
139
+ name: ctx.devices()[id]?.name ?? m.name ?? m.deviceId,
140
+ level: m.volume?.level,
141
+ muted: m.volume?.muted,
142
+ },
143
+ ];
144
+ });
145
+ };
146
+ /** LAUNCH the Default Media Receiver and wait for a receiver status that shows it running with a transport. */
147
+ const launch = (s) => {
148
+ const id = s.nextId();
149
+ const app = waitFor(clock, s, ANSWER_MS, "Default Media Receiver", (raw) => {
150
+ if (raw.requestId === id &&
151
+ typeof raw.type === "string" &&
152
+ REFUSALS.has(raw.type))
153
+ throw new Error(`LAUNCH refused: ${raw.type}${typeof raw.reason === "string" ? ` (${raw.reason})` : ""}`);
154
+ const r = ReceiverStatus.safeParse(raw);
155
+ return r.success
156
+ ? r.data.status?.applications?.find((a) => a.appId === DEFAULT_MEDIA_RECEIVER && a.transportId)
157
+ : undefined;
158
+ });
159
+ s.send(NS.receiver, RECEIVER, {
160
+ type: "LAUNCH",
161
+ appId: DEFAULT_MEDIA_RECEIVER,
162
+ requestId: id,
163
+ });
164
+ return app;
165
+ };
166
+ /**
167
+ * LOAD the clip and wait until its media session goes IDLE: true when FINISHED, an error for any other reason. The media
168
+ * session comes from LOAD's own reply, read in the same listener, so an IDLE from before the LOAD never counts.
169
+ */
170
+ const play = async (s, transport, media, timeoutMs) => {
171
+ const loadId = s.nextId();
172
+ let session;
173
+ let durationS = 0;
174
+ const cancel = new AbortController();
175
+ const finished = waitFor(clock, s, () => timeoutMs ?? PLAY_MS + durationS * 1000, "end of the clip", (raw) => {
176
+ const r = MediaStatus.safeParse(raw);
177
+ if (!r.success)
178
+ return undefined;
179
+ if (raw.requestId === loadId)
180
+ session = r.data.status?.[0]?.mediaSessionId ?? "any";
181
+ if (session === undefined)
182
+ return undefined;
183
+ for (const m of r.data.status ?? []) {
184
+ if (session !== "any" &&
185
+ m.mediaSessionId !== undefined &&
186
+ m.mediaSessionId !== session)
187
+ continue;
188
+ if (typeof m.media?.duration === "number")
189
+ durationS = m.media.duration;
190
+ if (m.playerState !== "IDLE" || !m.idleReason)
191
+ continue;
192
+ if (m.idleReason === "FINISHED")
193
+ return true;
194
+ throw new Error(`the clip stopped: ${m.idleReason}`);
195
+ }
196
+ return undefined;
197
+ }, cancel.signal);
198
+ finished.catch(() => { }); // awaited below; until then a cancel must not surface as unhandled
199
+ try {
200
+ await request(clock, s, NS.media, transport, { type: "LOAD", media, autoplay: true }, loadId);
201
+ }
202
+ catch (error) {
203
+ cancel.abort();
204
+ throw error;
205
+ }
206
+ return finished;
207
+ };
208
+ // A lane per device, so a speaker that stopped answering holds up only itself. Level and mute are separate slots:
209
+ // a SET_VOLUME carries one or the other, never both.
210
+ const lanes = new Map();
211
+ const setVolume = (target, volume) => {
212
+ const id = find(target).id;
213
+ if (!lanes.has(id))
214
+ lanes.set(id, writeLane({ gapMs: 0, perMinute: VOLUME_PER_MINUTE, clock }));
215
+ return lanes
216
+ .get(id)
217
+ .send("level" in volume ? "level" : "muted", volume, (v) => receiver(live(find(target)), { type: "SET_VOLUME", volume: v }));
218
+ };
219
+ return {
220
+ async setVolume(target, level) {
221
+ checkLevel(level);
222
+ await setVolume(target, { level });
223
+ },
224
+ async setMuted(target, muted) {
225
+ await setVolume(target, { muted });
226
+ },
227
+ async stop(target) {
228
+ const s = live(find(target));
229
+ const app = (await receiver(s)).applications?.[0];
230
+ if (app && !app.isIdleScreen)
231
+ await receiver(s, {
232
+ type: "STOP",
233
+ ...(app.sessionId ? { sessionId: app.sessionId } : {}),
234
+ });
235
+ },
236
+ /**
237
+ * 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
238
+ * put back what it can, always: what the Default Media Receiver was playing is loaded again where it was, an idle
239
+ * device gets STOP, then the volume (a group's, member by member through each member's own socket). Another app
240
+ * (Spotify, YouTube) is interrupted and cannot be resumed from here; the result says so. Throws only before anything changed.
241
+ */
242
+ async announce(target, clip, options = {}) {
243
+ if (options.volume !== undefined)
244
+ checkLevel(options.volume);
245
+ if (!("url" in clip) && !ctx.serve)
246
+ throw new Error("a clip of bytes needs serve: serveClip from @khalido/home/cast/serve");
247
+ let d = find(target);
248
+ const s = d.kind === "group" ? await regroup(d) : live(d);
249
+ d = ctx.devices()[d.id] ?? d; // a group's host as discovery gives it now, for serving the clip on its network
250
+ const before = await receiver(s);
251
+ const prior = before.applications?.[0];
252
+ const priorMedia = prior?.appId === DEFAULT_MEDIA_RECEIVER && prior.transportId
253
+ ? await playing(s, prior.transportId)
254
+ : undefined;
255
+ const unmute = before.volume?.muted === true; // a muted target would play the clip silently
256
+ const members = d.kind === "group" && (options.volume !== undefined || unmute)
257
+ ? await memberLevels(s)
258
+ : [];
259
+ const result = {
260
+ played: false,
261
+ restored: [],
262
+ notRestored: [],
263
+ };
264
+ const serving = new AbortController();
265
+ let served;
266
+ let volumeSet = false;
267
+ let launched = false;
268
+ let ours;
269
+ try {
270
+ let url;
271
+ if ("url" in clip)
272
+ url = clip.url;
273
+ else
274
+ ({ url } = served = await ctx.serve(clip.bytes, clip.type, {
275
+ host: d.host,
276
+ signal: serving.signal,
277
+ }));
278
+ // Level and mute as two requests: Google's receiver docs take one or the other in a SET_VOLUME, not both.
279
+ if (options.volume !== undefined || unmute)
280
+ volumeSet = true;
281
+ if (options.volume !== undefined)
282
+ await receiver(s, {
283
+ type: "SET_VOLUME",
284
+ volume: { level: options.volume },
285
+ });
286
+ if (unmute)
287
+ await receiver(s, { type: "SET_VOLUME", volume: { muted: false } });
288
+ launched = true;
289
+ ours = await launch(s);
290
+ s.connect(ours.transportId);
291
+ result.played = await play(s, ours.transportId, {
292
+ contentId: url,
293
+ contentType: clip.type ?? "audio/mpeg",
294
+ streamType: "BUFFERED",
295
+ }, options.timeoutMs);
296
+ }
297
+ catch (error) {
298
+ result.why = errorText(error);
299
+ }
300
+ finally {
301
+ serving.abort();
302
+ served?.close();
303
+ if (launched)
304
+ await restoreApp();
305
+ if (volumeSet)
306
+ await restoreVolume();
307
+ }
308
+ return result;
309
+ async function restoreApp() {
310
+ try {
311
+ // A LAUNCH that timed out may still have started: look before deciding nothing of ours runs.
312
+ const now = ours ??
313
+ (await receiver(s)).applications?.find((a) => a.appId === DEFAULT_MEDIA_RECEIVER && a.transportId);
314
+ if (!now)
315
+ return;
316
+ if (priorMedia) {
317
+ s.connect(now.transportId);
318
+ await request(clock, s, NS.media, now.transportId, {
319
+ type: "LOAD",
320
+ ...priorMedia,
321
+ });
322
+ result.restored.push(`${d.name}: what was playing`);
323
+ return;
324
+ }
325
+ await receiver(s, {
326
+ type: "STOP",
327
+ ...(now.sessionId ? { sessionId: now.sessionId } : {}),
328
+ });
329
+ if (idleApp(prior) || prior?.appId === DEFAULT_MEDIA_RECEIVER)
330
+ result.restored.push(`${d.name}: idle`);
331
+ else
332
+ result.notRestored.push({
333
+ name: d.name,
334
+ why: `${prior?.displayName ?? prior?.appId ?? "an app"} was interrupted and cannot be resumed from here`,
335
+ });
336
+ }
337
+ catch (error) {
338
+ result.notRestored.push({
339
+ name: d.name,
340
+ why: `${priorMedia ? "what was playing" : "idle"} not restored: ${errorText(error)}`,
341
+ });
342
+ }
343
+ }
344
+ async function restoreVolume() {
345
+ const each = d.kind === "group"
346
+ ? members
347
+ : [
348
+ {
349
+ id: d.id,
350
+ name: d.name,
351
+ level: before.volume?.level,
352
+ muted: before.volume?.muted,
353
+ },
354
+ ];
355
+ for (const m of each) {
356
+ const ms = m.id === d.id ? s : ctx.session(m.id);
357
+ if (m.level === undefined)
358
+ result.notRestored.push({
359
+ name: m.name,
360
+ why: "its volume before was not reported",
361
+ });
362
+ else if (!ms)
363
+ result.notRestored.push({
364
+ name: m.name,
365
+ why: "not connected, so its volume stays as the announcement set it",
366
+ });
367
+ else
368
+ try {
369
+ await receiver(ms, {
370
+ type: "SET_VOLUME",
371
+ volume: { level: m.level },
372
+ });
373
+ if (m.muted)
374
+ await receiver(ms, {
375
+ type: "SET_VOLUME",
376
+ volume: { muted: true },
377
+ });
378
+ result.restored.push(`${m.name}: volume${m.muted ? ", muted" : ""}`);
379
+ }
380
+ catch (error) {
381
+ result.notRestored.push({
382
+ name: m.name,
383
+ why: `volume not restored: ${errorText(error)}`,
384
+ });
385
+ }
386
+ }
387
+ }
388
+ },
389
+ };
390
+ }
@@ -0,0 +1,65 @@
1
+ import { z } from "zod";
2
+ import type { Clock } from "../../clock.ts";
3
+ import { ConnectorError } from "../../connector.ts";
4
+ /** A failed Daikin call. `status` is the HTTP status, or the dsiot `rsc` when HTTP succeeded. */
5
+ export declare class DaikinError extends ConnectorError {
6
+ }
7
+ export type Mode = "auto" | "cool" | "heat" | "fan" | "dry";
8
+ export type Fan = "1" | "2" | "3" | "4" | "5" | "auto" | "quiet";
9
+ /** The AC now. Temperatures in °C as the unit reports them. */
10
+ export type DaikinState = {
11
+ on?: boolean;
12
+ mode?: Mode;
13
+ targetC?: number;
14
+ roomC?: number;
15
+ humidity?: number;
16
+ fan?: Fan;
17
+ outsideC?: number;
18
+ anomaly?: string;
19
+ };
20
+ type Node = {
21
+ pn?: string;
22
+ pv?: unknown;
23
+ pch?: Node[];
24
+ };
25
+ declare const Node: z.ZodType<Node>;
26
+ /** Little-endian hex to an integer, two's complement when `signed`: "2700" → 39, "EEFF" → -18. */
27
+ export declare function hexLE(hex: string, signed?: boolean): number | undefined;
28
+ /** Map the indoor and outdoor `dgc_status` trees to a state. Unknown codes go to `anomaly`, never to a guess. */
29
+ export declare function toState(response: unknown): DaikinState;
30
+ /** A multireq body that writes one or more `e_1002` fields of the indoor unit (op 3), merged into one tree. */
31
+ export type WriteRequest = {
32
+ requests: {
33
+ op: 3;
34
+ to: string;
35
+ pc: Node;
36
+ }[];
37
+ };
38
+ /** Two writes as one, field by field, the newer value winning: `setMode("cool")` then `setPower(false)` sends mode cool, power off. */
39
+ export declare function mergeWrites(older: WriteRequest, newer: WriteRequest): WriteRequest;
40
+ /** Writes to the unit at most this often, and at most `WRITE_PER_MINUTE` a minute; a guess, the BRP084's limits are unpublished. */
41
+ export declare const WRITE_GAP_MS = 1000;
42
+ export declare const WRITE_PER_MINUTE = 20;
43
+ /** Turn the unit on or off. */
44
+ export declare const setPower: (on: boolean) => WriteRequest;
45
+ /** Select a mode and turn the unit on, as pydaikin and the Alira integration both do. */
46
+ export declare const setMode: (mode: Mode) => WriteRequest;
47
+ /** Set the setpoint of `mode` (usually the current one), one byte of °C × 2. Throws off the unit's range or 0.5 steps; never rounds. */
48
+ export declare function setTarget(c: number, mode: Mode): WriteRequest;
49
+ /** Set the fan speed of `mode` (usually the current one). */
50
+ export declare function setFan(fan: Fan, mode: Mode): WriteRequest;
51
+ /** A client for one unit. Reads are op 2; `write` sends a body built by setPower, setMode, setTarget or setFan. */
52
+ export declare function daikinClient(options: {
53
+ host: string;
54
+ fetch: typeof fetch;
55
+ clock?: Clock;
56
+ }): {
57
+ /** The indoor and outdoor status in one request. A failed outdoor read leaves `outsideC` empty. */
58
+ read(signal?: AbortSignal): Promise<DaikinState>;
59
+ /**
60
+ * Send a write. Only when KO asks: this changes the AC. Needs an answer for the indoor unit below 4000 (success codes
61
+ * unconfirmed; re-read to be sure). Writes waiting their turn merge (`mergeWrites`) and resolve with the send that carried them.
62
+ */
63
+ write(request: WriteRequest): Promise<void>;
64
+ };
65
+ export {};
@@ -0,0 +1,221 @@
1
+ import { z } from "zod";
2
+ import { ConnectorError, getJson } from "../../connector.js";
3
+ import { writeLane } from "../../lane.js";
4
+ // Daikin's JSON dsiot API (the BRP084 family): POST /dsiot/multireq, plain HTTP, no key. Field paths and value
5
+ // maps are protocol facts from pydaikin (GPL-3.0, read only; see README.md), checked against KO's unit 2026-10-13.
6
+ /** A failed Daikin call. `status` is the HTTP status, or the dsiot `rsc` when HTTP succeeded. */
7
+ export class DaikinError extends ConnectorError {
8
+ }
9
+ const Node = z.looseObject({
10
+ pn: z.string().optional(),
11
+ pv: z.unknown().optional(),
12
+ get pch() {
13
+ return z.array(Node).optional();
14
+ },
15
+ });
16
+ const Multi = z.looseObject({
17
+ responses: z.array(z.looseObject({
18
+ fr: z.string().optional(),
19
+ rsc: z.number().optional(),
20
+ pc: Node.optional(),
21
+ })),
22
+ });
23
+ const INDOOR = "/dsiot/edge/adr_0100.dgc_status";
24
+ const OUTDOOR = "/dsiot/edge/adr_0200.dgc_status";
25
+ const MODES = {
26
+ "0000": "fan",
27
+ "0100": "heat",
28
+ "0200": "cool",
29
+ "0300": "auto",
30
+ "0500": "dry",
31
+ };
32
+ const FANS = {
33
+ "0300": "1",
34
+ "0400": "2",
35
+ "0500": "3",
36
+ "0600": "4",
37
+ "0700": "5",
38
+ "0A00": "auto",
39
+ "0B00": "quiet",
40
+ };
41
+ /** Each mode keeps its own setpoint and fan speed under `e_3001`. */
42
+ const TARGET = {
43
+ cool: "p_02",
44
+ heat: "p_03",
45
+ auto: "p_1D",
46
+ };
47
+ const FAN = {
48
+ cool: "p_09",
49
+ heat: "p_0A",
50
+ auto: "p_26",
51
+ fan: "p_28",
52
+ };
53
+ /** The setpoint range per mode, from the unit's own `md.mi`/`md.mx` (KO's unit, 2026-10-13). */
54
+ const TARGET_RANGE = {
55
+ cool: [18, 32],
56
+ heat: [10, 30],
57
+ auto: [18, 30],
58
+ };
59
+ /** Little-endian hex to an integer, two's complement when `signed`: "2700" → 39, "EEFF" → -18. */
60
+ export function hexLE(hex, signed = false) {
61
+ if (!/^(?:[0-9a-f]{2})+$/i.test(hex))
62
+ return undefined;
63
+ const bytes = hex.match(/../g).reverse().join("");
64
+ const n = Number.parseInt(bytes, 16);
65
+ return signed && n >= 2 ** (hex.length * 4 - 1)
66
+ ? n - 2 ** (hex.length * 4)
67
+ : n;
68
+ }
69
+ /** The `pv` at a path of `pn` names under one response's tree, by name (never by position). */
70
+ function value(m, fr, path) {
71
+ let node = m.responses.find((r) => r.fr === fr)?.pc;
72
+ for (const pn of path)
73
+ node = node?.pch?.find((c) => c.pn === pn);
74
+ return typeof node?.pv === "string" ? node.pv : undefined;
75
+ }
76
+ /** Map the indoor and outdoor `dgc_status` trees to a state. Unknown codes go to `anomaly`, never to a guess. */
77
+ export function toState(response) {
78
+ const m = Multi.parse(response);
79
+ const odd = [];
80
+ const indoor = (...path) => value(m, INDOOR, ["e_1002", ...path]);
81
+ const code = (map, hex, what) => {
82
+ if (hex !== undefined && !(hex in map))
83
+ odd.push(`unknown ${what} code ${hex}`);
84
+ return hex === undefined ? undefined : map[hex];
85
+ };
86
+ const temp = (hex, divisor, what) => {
87
+ if (hex === undefined)
88
+ return undefined;
89
+ const n = hexLE(hex, true);
90
+ if (n === undefined)
91
+ odd.push(`${what} is not hex: ${hex}`);
92
+ return n === undefined ? undefined : n / divisor;
93
+ };
94
+ const power = indoor("e_A002", "p_01");
95
+ if (power !== undefined && power !== "00" && power !== "01")
96
+ odd.push(`unknown power code ${power}`);
97
+ const mode = code(MODES, indoor("e_3001", "p_01"), "mode");
98
+ const target = mode && TARGET[mode];
99
+ const fan = mode && FAN[mode];
100
+ const humidityHex = indoor("e_A00B", "p_02");
101
+ const humidity = humidityHex === undefined ? undefined : hexLE(humidityHex);
102
+ if (humidityHex !== undefined && humidity === undefined)
103
+ odd.push(`humidity is not hex: ${humidityHex}`);
104
+ return {
105
+ on: power === "01" ? true : power === "00" ? false : undefined,
106
+ mode,
107
+ targetC: target ? temp(indoor("e_3001", target), 2, "target") : undefined,
108
+ roomC: temp(indoor("e_A00B", "p_01"), 1, "room temperature"),
109
+ humidity,
110
+ fan: fan ? code(FANS, indoor("e_3001", fan), "fan") : undefined,
111
+ outsideC: temp(value(m, OUTDOOR, ["e_1003", "e_A00D", "p_01"]), 2, "outside temperature"),
112
+ anomaly: odd.length ? odd.join("; ") : undefined,
113
+ };
114
+ }
115
+ const write = (fields) => ({
116
+ requests: [
117
+ {
118
+ op: 3,
119
+ to: INDOOR,
120
+ pc: {
121
+ pn: "dgc_status",
122
+ pch: [
123
+ {
124
+ pn: "e_1002",
125
+ pch: fields.map(([e, pn, pv]) => ({ pn: e, pch: [{ pn, pv }] })),
126
+ },
127
+ ],
128
+ },
129
+ },
130
+ ],
131
+ });
132
+ const fields = (r) => r.requests.flatMap((q) => (q.pc.pch ?? []).flatMap((e1002) => (e1002.pch ?? []).flatMap((e) => (e.pch ?? []).map((f) => [
133
+ e.pn,
134
+ f.pn,
135
+ String(f.pv),
136
+ ]))));
137
+ /** Two writes as one, field by field, the newer value winning: `setMode("cool")` then `setPower(false)` sends mode cool, power off. */
138
+ export function mergeWrites(older, newer) {
139
+ const merged = new Map(fields(older).map((f) => [`${f[0]}/${f[1]}`, f]));
140
+ for (const f of fields(newer))
141
+ merged.set(`${f[0]}/${f[1]}`, f);
142
+ return write([...merged.values()]);
143
+ }
144
+ /** Writes to the unit at most this often, and at most `WRITE_PER_MINUTE` a minute; a guess, the BRP084's limits are unpublished. */
145
+ export const WRITE_GAP_MS = 1000;
146
+ export const WRITE_PER_MINUTE = 20;
147
+ const key = (map, v) => Object.keys(map).find((k) => map[k] === v);
148
+ /** Turn the unit on or off. */
149
+ export const setPower = (on) => write([["e_A002", "p_01", on ? "01" : "00"]]);
150
+ /** Select a mode and turn the unit on, as pydaikin and the Alira integration both do. */
151
+ export const setMode = (mode) => write([
152
+ ["e_A002", "p_01", "01"],
153
+ ["e_3001", "p_01", key(MODES, mode)],
154
+ ]);
155
+ /** Set the setpoint of `mode` (usually the current one), one byte of °C × 2. Throws off the unit's range or 0.5 steps; never rounds. */
156
+ export function setTarget(c, mode) {
157
+ const range = TARGET_RANGE[mode];
158
+ if (!range)
159
+ throw new DaikinError(`${mode} mode has no setpoint`);
160
+ if (!Number.isInteger(c * 2) || c < range[0] || c > range[1])
161
+ throw new DaikinError(`${c} °C is not a ${mode} setpoint (${range[0]}–${range[1]} in 0.5 steps)`);
162
+ return write([
163
+ [
164
+ "e_3001",
165
+ TARGET[mode],
166
+ (c * 2).toString(16).toUpperCase().padStart(2, "0"),
167
+ ],
168
+ ]);
169
+ }
170
+ /** Set the fan speed of `mode` (usually the current one). */
171
+ export function setFan(fan, mode) {
172
+ if (!FAN[mode])
173
+ throw new DaikinError(`${mode} mode has no fan speed`);
174
+ return write([["e_3001", FAN[mode], key(FANS, fan)]]);
175
+ }
176
+ /** A client for one unit. Reads are op 2; `write` sends a body built by setPower, setMode, setTarget or setFan. */
177
+ export function daikinClient(options) {
178
+ const lane = writeLane({
179
+ gapMs: WRITE_GAP_MS,
180
+ perMinute: WRITE_PER_MINUTE,
181
+ clock: options.clock,
182
+ });
183
+ const multireq = (body, signal) => getJson(options.fetch, `http://${options.host}/dsiot/multireq`, {
184
+ init: {
185
+ method: "POST",
186
+ headers: { "content-type": "application/json" },
187
+ body: JSON.stringify(body),
188
+ },
189
+ signal,
190
+ schema: Multi,
191
+ error: DaikinError,
192
+ });
193
+ return {
194
+ /** The indoor and outdoor status in one request. A failed outdoor read leaves `outsideC` empty. */
195
+ async read(signal) {
196
+ const m = await multireq({
197
+ requests: [INDOOR, OUTDOOR].map((to) => ({
198
+ op: 2,
199
+ to: `${to}?filter=pv,pt,md`,
200
+ })),
201
+ }, signal);
202
+ const indoor = m.responses.find((r) => r.fr === INDOOR);
203
+ if (indoor?.rsc !== 2000)
204
+ throw new DaikinError(`no indoor status (rsc ${indoor?.rsc})`, {
205
+ status: indoor?.rsc,
206
+ });
207
+ return toState(m);
208
+ },
209
+ /**
210
+ * Send a write. Only when KO asks: this changes the AC. Needs an answer for the indoor unit below 4000 (success codes
211
+ * unconfirmed; re-read to be sure). Writes waiting their turn merge (`mergeWrites`) and resolve with the send that carried them.
212
+ */
213
+ write(request) {
214
+ return lane.send("unit", request, async (r) => {
215
+ const answer = (await multireq(r)).responses.find((x) => x.fr === INDOOR);
216
+ if (!answer || answer.rsc === undefined || answer.rsc >= 4000)
217
+ throw new DaikinError(`no accepted answer from the indoor unit (rsc ${answer?.rsc})`, { status: answer?.rsc });
218
+ }, mergeWrites);
219
+ },
220
+ };
221
+ }