@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,11 @@
|
|
|
1
|
+
import type { ServeClip } from "./writes.ts";
|
|
2
|
+
/** How long a clip stays served when nothing fetches it. */
|
|
3
|
+
export declare const SERVE_MS = 60000;
|
|
4
|
+
/** This machine's IPv4 on the subnet that holds `host`, or with no host, its first non-internal IPv4. */
|
|
5
|
+
export declare function lanAddress(host?: string, interfaces?: NodeJS.Dict<import("node:os").NetworkInterfaceInfo[]>): string;
|
|
6
|
+
/**
|
|
7
|
+
* Serve `bytes`: a random port, an unguessable path, the given `Content-Type` and its `Content-Length`, the whole body
|
|
8
|
+
* to every GET (a player may probe before it fetches). It closes on `close()` (`announce` calls it when the clip has
|
|
9
|
+
* played), when `signal` aborts, or after `SERVE_MS`. `host` is the device that will fetch it, so the address is on its network.
|
|
10
|
+
*/
|
|
11
|
+
export declare const serveClip: ServeClip;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { networkInterfaces } from "node:os";
|
|
4
|
+
// Node APIs (http, os), so this sits outside the runtime-neutral build like tls.ts and is passed to `cast()` as
|
|
5
|
+
// `serve`. A Cast device fetches the clip itself, so it must be served over HTTP on the LAN, never loopback.
|
|
6
|
+
/** How long a clip stays served when nothing fetches it. */
|
|
7
|
+
export const SERVE_MS = 60_000;
|
|
8
|
+
const ipv4 = (a) => a.split(".").reduce((n, part) => n * 256 + Number(part), 0);
|
|
9
|
+
/** The network an address is on under a netmask: the address with its host bits cleared. */
|
|
10
|
+
const network = (address, netmask) => ipv4(address) - (ipv4(address) % (2 ** 32 - ipv4(netmask)));
|
|
11
|
+
/** This machine's IPv4 on the subnet that holds `host`, or with no host, its first non-internal IPv4. */
|
|
12
|
+
export function lanAddress(host, interfaces = networkInterfaces()) {
|
|
13
|
+
const all = Object.values(interfaces)
|
|
14
|
+
.flatMap((list) => list ?? [])
|
|
15
|
+
.filter((i) => i.family === "IPv4");
|
|
16
|
+
const pick = host
|
|
17
|
+
? all.find((i) => network(i.address, i.netmask) === network(host, i.netmask))
|
|
18
|
+
: all.find((i) => !i.internal);
|
|
19
|
+
if (!pick)
|
|
20
|
+
throw new Error(host
|
|
21
|
+
? `no address of this machine on ${host}'s network`
|
|
22
|
+
: "no LAN IPv4 address on this machine");
|
|
23
|
+
return pick.address;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Serve `bytes`: a random port, an unguessable path, the given `Content-Type` and its `Content-Length`, the whole body
|
|
27
|
+
* to every GET (a player may probe before it fetches). It closes on `close()` (`announce` calls it when the clip has
|
|
28
|
+
* played), when `signal` aborts, or after `SERVE_MS`. `host` is the device that will fetch it, so the address is on its network.
|
|
29
|
+
*/
|
|
30
|
+
export const serveClip = (bytes, type, options = {}) => new Promise((resolve, reject) => {
|
|
31
|
+
if (options.signal?.aborted)
|
|
32
|
+
return reject(new Error("aborted"));
|
|
33
|
+
const address = lanAddress(options.host);
|
|
34
|
+
const path = `/${randomUUID()}`;
|
|
35
|
+
const server = createServer((req, res) => {
|
|
36
|
+
if (req.url !== path || (req.method !== "GET" && req.method !== "HEAD"))
|
|
37
|
+
return res.writeHead(404).end();
|
|
38
|
+
res.writeHead(200, {
|
|
39
|
+
"Content-Type": type,
|
|
40
|
+
"Content-Length": bytes.length,
|
|
41
|
+
});
|
|
42
|
+
res.end(req.method === "HEAD" ? undefined : bytes);
|
|
43
|
+
});
|
|
44
|
+
const timer = setTimeout(() => close(), SERVE_MS);
|
|
45
|
+
function close() {
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
options.signal?.removeEventListener("abort", close);
|
|
48
|
+
server.close();
|
|
49
|
+
server.closeAllConnections();
|
|
50
|
+
}
|
|
51
|
+
options.signal?.addEventListener("abort", close, { once: true });
|
|
52
|
+
server.once("error", (error) => (close(), reject(error)));
|
|
53
|
+
server.listen(0, address, () => {
|
|
54
|
+
const { port } = server.address();
|
|
55
|
+
resolve({ url: `http://${address}:${port}${path}`, close });
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { CastPayload } from "./schemas.ts";
|
|
2
|
+
/** A Cast device or group as found by discovery: what `resolve` returns for each. */
|
|
3
|
+
export type Resolved = {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
model?: string;
|
|
7
|
+
host: string;
|
|
8
|
+
port: number;
|
|
9
|
+
group: boolean;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* One Cast device or group. `name` is the device's own (mDNS fn, or a device's own multizone status), a config's only until it answers. Ids are the mDNS id, lowercase without dashes; `members`, `leader`, `groups` and
|
|
13
|
+
* `playingVia` name other entries by id. Volume is the device's own number, unrounded.
|
|
14
|
+
*/
|
|
15
|
+
export type CastDevice = {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
model?: string;
|
|
19
|
+
kind: "device" | "group";
|
|
20
|
+
host?: string;
|
|
21
|
+
port?: number;
|
|
22
|
+
app?: string;
|
|
23
|
+
appId?: string;
|
|
24
|
+
idle?: boolean;
|
|
25
|
+
standby?: boolean;
|
|
26
|
+
volume?: number;
|
|
27
|
+
muted?: boolean;
|
|
28
|
+
volumeControl?: string;
|
|
29
|
+
playerState?: string;
|
|
30
|
+
title?: string;
|
|
31
|
+
artist?: string;
|
|
32
|
+
members?: string[];
|
|
33
|
+
leader?: string;
|
|
34
|
+
groups?: string[];
|
|
35
|
+
playingVia?: string;
|
|
36
|
+
status: "connecting" | "connected" | "disconnected";
|
|
37
|
+
detail?: string;
|
|
38
|
+
asOf?: string;
|
|
39
|
+
};
|
|
40
|
+
export type CastSnapshot = {
|
|
41
|
+
devices: Record<string, CastDevice>;
|
|
42
|
+
asOf: string;
|
|
43
|
+
};
|
|
44
|
+
/** An id as the snapshot keys it: the mDNS id (32 hex) and the certificate's and multizone's dashed UUID are one key. */
|
|
45
|
+
export declare const castKey: (id: string) => string;
|
|
46
|
+
/** Add or update what discovery found; devices it did not find this time are kept as they were. */
|
|
47
|
+
export declare function found(s: CastSnapshot, entries: Resolved[], asOf: string): CastSnapshot;
|
|
48
|
+
/** A socket opened or dropped. A group's `leader` comes with its socket. */
|
|
49
|
+
export declare function connection(s: CastSnapshot, id: string, change: {
|
|
50
|
+
status: CastDevice["status"];
|
|
51
|
+
detail?: string;
|
|
52
|
+
leader?: string;
|
|
53
|
+
}, asOf: string): CastSnapshot;
|
|
54
|
+
/** One payload from one entry's socket. Pure: returns a new snapshot. */
|
|
55
|
+
export declare function reduce(s: CastSnapshot, id: string, p: CastPayload, asOf: string): CastSnapshot;
|
|
56
|
+
/** The app's transport speaks media status: connect to it to read what is playing. */
|
|
57
|
+
export declare const mediaApp: (app?: {
|
|
58
|
+
namespaces?: {
|
|
59
|
+
name?: string;
|
|
60
|
+
}[];
|
|
61
|
+
transportId?: string;
|
|
62
|
+
}) => boolean;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/** An id as the snapshot keys it: the mDNS id (32 hex) and the certificate's and multizone's dashed UUID are one key. */
|
|
2
|
+
export const castKey = (id) => id.toLowerCase().replace(/-/g, "");
|
|
3
|
+
/** Add or update what discovery found; devices it did not find this time are kept as they were. */
|
|
4
|
+
export function found(s, entries, asOf) {
|
|
5
|
+
const devices = { ...s.devices };
|
|
6
|
+
for (const e of entries) {
|
|
7
|
+
const id = castKey(e.id);
|
|
8
|
+
const kind = e.group ? "group" : "device";
|
|
9
|
+
devices[id] = {
|
|
10
|
+
...(devices[id] ?? { status: "connecting" }),
|
|
11
|
+
id,
|
|
12
|
+
name: e.name,
|
|
13
|
+
model: e.model,
|
|
14
|
+
kind,
|
|
15
|
+
host: e.host,
|
|
16
|
+
port: e.port,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
return link({ devices, asOf });
|
|
20
|
+
}
|
|
21
|
+
/** A socket opened or dropped. A group's `leader` comes with its socket. */
|
|
22
|
+
export function connection(s, id, change, asOf) {
|
|
23
|
+
const key = castKey(id);
|
|
24
|
+
const d = s.devices[key];
|
|
25
|
+
if (!d)
|
|
26
|
+
return s;
|
|
27
|
+
const leader = change.leader === undefined ? d.leader : castKey(change.leader);
|
|
28
|
+
return link({
|
|
29
|
+
devices: {
|
|
30
|
+
...s.devices,
|
|
31
|
+
[key]: { ...d, status: change.status, detail: change.detail, leader },
|
|
32
|
+
},
|
|
33
|
+
asOf,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/** One payload from one entry's socket. Pure: returns a new snapshot. */
|
|
37
|
+
export function reduce(s, id, p, asOf) {
|
|
38
|
+
const key = castKey(id);
|
|
39
|
+
const prev = s.devices[key];
|
|
40
|
+
if (!prev)
|
|
41
|
+
return s;
|
|
42
|
+
const d = { ...prev, asOf };
|
|
43
|
+
if (p.type === "RECEIVER_STATUS") {
|
|
44
|
+
const app = p.status?.applications?.[0];
|
|
45
|
+
const v = p.status?.volume;
|
|
46
|
+
Object.assign(d, {
|
|
47
|
+
app: app?.displayName,
|
|
48
|
+
appId: app?.appId,
|
|
49
|
+
idle: !app || app.isIdleScreen === true,
|
|
50
|
+
standby: p.status?.isStandBy,
|
|
51
|
+
volume: v?.level,
|
|
52
|
+
muted: v?.muted,
|
|
53
|
+
volumeControl: v?.controlType,
|
|
54
|
+
});
|
|
55
|
+
if (!mediaApp(app))
|
|
56
|
+
Object.assign(d, {
|
|
57
|
+
playerState: undefined,
|
|
58
|
+
title: undefined,
|
|
59
|
+
artist: undefined,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
else if (p.type === "MEDIA_STATUS") {
|
|
63
|
+
const m = p.status?.[0];
|
|
64
|
+
delete d.playingVia; // its own media now: link borrows the group's again only if this is empty
|
|
65
|
+
Object.assign(d, {
|
|
66
|
+
playerState: m?.playerState,
|
|
67
|
+
title: m?.media?.metadata?.title,
|
|
68
|
+
artist: m?.media?.metadata?.artist,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
else if (d.kind === "device") {
|
|
72
|
+
// A device's own port lists only itself, by its current name in the Google Home app (mDNS fn; all six live devices
|
|
73
|
+
// matched, 2026-10-13): the name follows a rename without a discovery. Membership is read from a group's port alone.
|
|
74
|
+
if (p.type === "MULTIZONE_STATUS") {
|
|
75
|
+
const self = p.status?.devices?.find((m) => m.deviceId !== undefined && castKey(m.deviceId) === key);
|
|
76
|
+
if (self?.name)
|
|
77
|
+
d.name = self.name;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
const members = new Set(d.members ?? []);
|
|
82
|
+
if (p.type === "MULTIZONE_STATUS")
|
|
83
|
+
d.members = (p.status?.devices ?? []).flatMap((m) => m.deviceId ? [castKey(m.deviceId)] : []);
|
|
84
|
+
else {
|
|
85
|
+
const member = p.type === "DEVICE_REMOVED" ? p.deviceId : p.device?.deviceId;
|
|
86
|
+
if (member)
|
|
87
|
+
p.type === "DEVICE_REMOVED"
|
|
88
|
+
? members.delete(castKey(member))
|
|
89
|
+
: members.add(castKey(member));
|
|
90
|
+
d.members = [...members];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return link({ devices: { ...s.devices, [key]: d }, asOf });
|
|
94
|
+
}
|
|
95
|
+
/** The app's transport speaks media status: connect to it to read what is playing. */
|
|
96
|
+
export const mediaApp = (app) => app?.transportId !== undefined &&
|
|
97
|
+
(app.namespaces ?? []).some((n) => n.name === "urn:x-cast:com.google.cast.media");
|
|
98
|
+
const active = (state) => state !== undefined && state !== "IDLE";
|
|
99
|
+
/**
|
|
100
|
+
* The cross-links, recomputed from scratch on every change: each device's groups, and a member with no media of its
|
|
101
|
+
* own shows a playing group's, with `playingVia` naming the group (Home Assistant's model).
|
|
102
|
+
*/
|
|
103
|
+
function link(s) {
|
|
104
|
+
const devices = {};
|
|
105
|
+
for (const [id, d] of Object.entries(s.devices)) {
|
|
106
|
+
const own = d.playingVia
|
|
107
|
+
? { playerState: undefined, title: undefined, artist: undefined }
|
|
108
|
+
: {};
|
|
109
|
+
devices[id] = { ...d, ...own, groups: undefined, playingVia: undefined };
|
|
110
|
+
}
|
|
111
|
+
for (const g of Object.values(devices).filter((d) => d.kind === "group")) {
|
|
112
|
+
for (const m of g.members ?? []) {
|
|
113
|
+
const d = devices[m];
|
|
114
|
+
if (!d || d.kind === "group")
|
|
115
|
+
continue;
|
|
116
|
+
d.groups = [...(d.groups ?? []), g.id];
|
|
117
|
+
if (active(g.playerState) && !active(d.playerState))
|
|
118
|
+
Object.assign(d, {
|
|
119
|
+
playerState: g.playerState,
|
|
120
|
+
title: g.title,
|
|
121
|
+
artist: g.artist,
|
|
122
|
+
playingVia: g.id,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
for (const d of Object.values(devices))
|
|
127
|
+
if (d.groups === undefined)
|
|
128
|
+
delete d.groups;
|
|
129
|
+
for (const d of Object.values(devices))
|
|
130
|
+
if (d.playingVia === undefined)
|
|
131
|
+
delete d.playingVia;
|
|
132
|
+
return { devices, asOf: s.asOf };
|
|
133
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { connect } from "node:tls";
|
|
2
|
+
import { encode, framer, } from "./codec.js";
|
|
3
|
+
// The one Node API in the Cast connector, so it sits outside the runtime-neutral build (tsconfig.build.json), like
|
|
4
|
+
// weather/ftp.ts, and is passed in as `connect`. Runs in Bun (1.4.2, checked live) and, untried, Node.
|
|
5
|
+
//
|
|
6
|
+
// Verification is off: every Cast device presents a self-signed certificate that rotates every 48 hours, so there is
|
|
7
|
+
// nothing to pin. The integration instead checks the certificate's name (`peer`, the device's id) against discovery.
|
|
8
|
+
/** Open a Cast socket: TLS to host:port within `timeoutMs`, framed CastMessages in and out. */
|
|
9
|
+
export const tlsConnect = ({ host, port, signal, timeoutMs = 10_000, }) => new Promise((resolve, reject) => {
|
|
10
|
+
const socket = connect({ host, port, rejectUnauthorized: false });
|
|
11
|
+
let timer;
|
|
12
|
+
const fail = (error) => (clearTimeout(timer),
|
|
13
|
+
signal?.removeEventListener("abort", abort),
|
|
14
|
+
socket.destroy(),
|
|
15
|
+
reject(error));
|
|
16
|
+
timer = setTimeout(() => fail(new Error(`cast: no TLS answer from ${host}:${port} in ${timeoutMs / 1000} s`)), timeoutMs);
|
|
17
|
+
const abort = () => fail(new Error("aborted"));
|
|
18
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
19
|
+
socket.once("error", fail);
|
|
20
|
+
socket.once("secureConnect", () => {
|
|
21
|
+
clearTimeout(timer);
|
|
22
|
+
signal?.removeEventListener("abort", abort);
|
|
23
|
+
socket.off("error", fail);
|
|
24
|
+
const name = socket.getPeerCertificate().subject?.CN;
|
|
25
|
+
resolve(channel(socket, Array.isArray(name) ? name[0] : name, socket.getProtocol() ?? undefined));
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
/** The socket as an async stream of messages; the stream ends when the socket closes and throws on a socket error. */
|
|
29
|
+
function channel(socket, peer, protocol) {
|
|
30
|
+
const split = framer();
|
|
31
|
+
const queue = [];
|
|
32
|
+
let ended = false;
|
|
33
|
+
let failure;
|
|
34
|
+
let wake;
|
|
35
|
+
const notify = () => (wake?.(), (wake = undefined));
|
|
36
|
+
socket.on("data", (chunk) => {
|
|
37
|
+
try {
|
|
38
|
+
queue.push(...split(new Uint8Array(chunk)));
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
failure = error;
|
|
42
|
+
socket.destroy();
|
|
43
|
+
}
|
|
44
|
+
notify();
|
|
45
|
+
});
|
|
46
|
+
socket.on("error", (error) => ((failure ??= error), notify()));
|
|
47
|
+
socket.on("close", () => ((ended = true), notify()));
|
|
48
|
+
return {
|
|
49
|
+
peer,
|
|
50
|
+
protocol,
|
|
51
|
+
send: (m) => void (socket.writable && socket.write(encode(m))),
|
|
52
|
+
// end() flushes a CLOSE written just before; a device that does not close its side within a second is cut off.
|
|
53
|
+
close: () => (socket.end(),
|
|
54
|
+
setTimeout(() => socket.destroy(), 1000).unref()),
|
|
55
|
+
messages: (async function* () {
|
|
56
|
+
for (;;) {
|
|
57
|
+
while (queue.length)
|
|
58
|
+
yield queue.shift();
|
|
59
|
+
if (failure)
|
|
60
|
+
throw failure;
|
|
61
|
+
if (ended)
|
|
62
|
+
return;
|
|
63
|
+
await new Promise((r) => (wake = r));
|
|
64
|
+
}
|
|
65
|
+
})(),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Clock } from "../../clock.ts";
|
|
2
|
+
import { type CastDevice } from "./state.ts";
|
|
3
|
+
/** Google's own player for a URL of audio or video. */
|
|
4
|
+
export declare const DEFAULT_MEDIA_RECEIVER = "CC1AD845";
|
|
5
|
+
/** Each request (a status, a volume, LAUNCH, LOAD, STOP) gets this long to answer. */
|
|
6
|
+
export declare const ANSWER_MS = 10000;
|
|
7
|
+
/** How long an announcement waits for its clip to finish, beyond the clip's own length when the device reports it. */
|
|
8
|
+
export declare const PLAY_MS = 60000;
|
|
9
|
+
/** 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. */
|
|
10
|
+
export declare const VOLUME_PER_MINUTE = 30;
|
|
11
|
+
/** What to play: a URL the device can fetch (type defaults to audio/mpeg), or bytes for `serve` to put on the LAN. */
|
|
12
|
+
export type CastClip = {
|
|
13
|
+
url: string;
|
|
14
|
+
type?: string;
|
|
15
|
+
} | {
|
|
16
|
+
bytes: Uint8Array;
|
|
17
|
+
type: string;
|
|
18
|
+
};
|
|
19
|
+
/** What `announce` did: whether the clip played to its end, why not, and what it put back or could not. */
|
|
20
|
+
export type AnnounceResult = {
|
|
21
|
+
played: boolean;
|
|
22
|
+
why?: string;
|
|
23
|
+
restored: string[];
|
|
24
|
+
notRestored: {
|
|
25
|
+
name: string;
|
|
26
|
+
why: string;
|
|
27
|
+
}[];
|
|
28
|
+
};
|
|
29
|
+
/** Serves bytes once over HTTP on the LAN: `serveClip` from `@khalido/home/cast/serve`. `host` is the device that will fetch it. */
|
|
30
|
+
export type ServeClip = (bytes: Uint8Array, type: string, options?: {
|
|
31
|
+
host?: string;
|
|
32
|
+
signal?: AbortSignal;
|
|
33
|
+
}) => Promise<{
|
|
34
|
+
url: string;
|
|
35
|
+
close(): void;
|
|
36
|
+
}>;
|
|
37
|
+
/** 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. */
|
|
38
|
+
export type CastActions = {
|
|
39
|
+
setVolume(target: string, level: number): Promise<void>;
|
|
40
|
+
setMuted(target: string, muted: boolean): Promise<void>;
|
|
41
|
+
/** Ends the running app (receiver STOP), which returns the device to its idle screen. Nothing running: nothing sent. */
|
|
42
|
+
stop(target: string): Promise<void>;
|
|
43
|
+
announce(target: string, clip: CastClip, options?: {
|
|
44
|
+
volume?: number;
|
|
45
|
+
timeoutMs?: number;
|
|
46
|
+
}): Promise<AnnounceResult>;
|
|
47
|
+
};
|
|
48
|
+
/** A payload as it came, before any schema. */
|
|
49
|
+
export type RawPayload = {
|
|
50
|
+
type?: unknown;
|
|
51
|
+
requestId?: unknown;
|
|
52
|
+
reason?: unknown;
|
|
53
|
+
[key: string]: unknown;
|
|
54
|
+
};
|
|
55
|
+
/** Sees every payload a socket receives, then `undefined` once when it closes. */
|
|
56
|
+
export type Listener = (raw: RawPayload | undefined) => void;
|
|
57
|
+
/** What a write needs of one live socket. */
|
|
58
|
+
export type CastSession = {
|
|
59
|
+
host: string;
|
|
60
|
+
port: number;
|
|
61
|
+
send(namespace: string, destination: string, payload: object): void;
|
|
62
|
+
nextId(): number;
|
|
63
|
+
listen(listener: Listener): () => void;
|
|
64
|
+
/** CONNECT to an app's transport, once per transport however many callers ask. */
|
|
65
|
+
connect(transport: string): void;
|
|
66
|
+
/** Close it after a discovery the caller just ran: it reconnects at the address the snapshot holds, without another discovery. */
|
|
67
|
+
end(why: string): void;
|
|
68
|
+
};
|
|
69
|
+
/** What `castActions` needs of the integration. */
|
|
70
|
+
export type WriteContext = {
|
|
71
|
+
clock: Clock;
|
|
72
|
+
devices(): Record<string, CastDevice>;
|
|
73
|
+
session(id: string): CastSession | undefined;
|
|
74
|
+
discover(): Promise<void>;
|
|
75
|
+
/** The entry's socket once it is open at the address the snapshot holds, within `ms`. */
|
|
76
|
+
connected(id: string, ms: number): Promise<CastSession>;
|
|
77
|
+
serve?: ServeClip;
|
|
78
|
+
};
|
|
79
|
+
/** Writes over the integration's sockets. */
|
|
80
|
+
export declare function castActions(ctx: WriteContext): CastActions;
|