@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,72 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { networkInterfaces } from "node:os";
|
|
4
|
+
import type { ServeClip } from "./writes.ts";
|
|
5
|
+
|
|
6
|
+
// Node APIs (http, os), so this sits outside the runtime-neutral build like tls.ts and is passed to `cast()` as
|
|
7
|
+
// `serve`. A Cast device fetches the clip itself, so it must be served over HTTP on the LAN, never loopback.
|
|
8
|
+
|
|
9
|
+
/** How long a clip stays served when nothing fetches it. */
|
|
10
|
+
export const SERVE_MS = 60_000;
|
|
11
|
+
|
|
12
|
+
const ipv4 = (a: string) =>
|
|
13
|
+
a.split(".").reduce((n, part) => n * 256 + Number(part), 0);
|
|
14
|
+
/** The network an address is on under a netmask: the address with its host bits cleared. */
|
|
15
|
+
const network = (address: string, netmask: string) =>
|
|
16
|
+
ipv4(address) - (ipv4(address) % (2 ** 32 - ipv4(netmask)));
|
|
17
|
+
|
|
18
|
+
/** This machine's IPv4 on the subnet that holds `host`, or with no host, its first non-internal IPv4. */
|
|
19
|
+
export function lanAddress(
|
|
20
|
+
host?: string,
|
|
21
|
+
interfaces = networkInterfaces(),
|
|
22
|
+
): string {
|
|
23
|
+
const all = Object.values(interfaces)
|
|
24
|
+
.flatMap((list) => list ?? [])
|
|
25
|
+
.filter((i) => i.family === "IPv4");
|
|
26
|
+
const pick = host
|
|
27
|
+
? all.find(
|
|
28
|
+
(i) => network(i.address, i.netmask) === network(host, i.netmask),
|
|
29
|
+
)
|
|
30
|
+
: all.find((i) => !i.internal);
|
|
31
|
+
if (!pick)
|
|
32
|
+
throw new Error(
|
|
33
|
+
host
|
|
34
|
+
? `no address of this machine on ${host}'s network`
|
|
35
|
+
: "no LAN IPv4 address on this machine",
|
|
36
|
+
);
|
|
37
|
+
return pick.address;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Serve `bytes`: a random port, an unguessable path, the given `Content-Type` and its `Content-Length`, the whole body
|
|
42
|
+
* to every GET (a player may probe before it fetches). It closes on `close()` (`announce` calls it when the clip has
|
|
43
|
+
* played), when `signal` aborts, or after `SERVE_MS`. `host` is the device that will fetch it, so the address is on its network.
|
|
44
|
+
*/
|
|
45
|
+
export const serveClip: ServeClip = (bytes, type, options = {}) =>
|
|
46
|
+
new Promise((resolve, reject) => {
|
|
47
|
+
if (options.signal?.aborted) return reject(new Error("aborted"));
|
|
48
|
+
const address = lanAddress(options.host);
|
|
49
|
+
const path = `/${randomUUID()}`;
|
|
50
|
+
const server = createServer((req, res) => {
|
|
51
|
+
if (req.url !== path || (req.method !== "GET" && req.method !== "HEAD"))
|
|
52
|
+
return res.writeHead(404).end();
|
|
53
|
+
res.writeHead(200, {
|
|
54
|
+
"Content-Type": type,
|
|
55
|
+
"Content-Length": bytes.length,
|
|
56
|
+
});
|
|
57
|
+
res.end(req.method === "HEAD" ? undefined : bytes);
|
|
58
|
+
});
|
|
59
|
+
const timer = setTimeout(() => close(), SERVE_MS);
|
|
60
|
+
function close() {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
options.signal?.removeEventListener("abort", close);
|
|
63
|
+
server.close();
|
|
64
|
+
server.closeAllConnections();
|
|
65
|
+
}
|
|
66
|
+
options.signal?.addEventListener("abort", close, { once: true });
|
|
67
|
+
server.once("error", (error) => (close(), reject(error)));
|
|
68
|
+
server.listen(0, address, () => {
|
|
69
|
+
const { port } = server.address() as { port: number };
|
|
70
|
+
resolve({ url: `http://${address}:${port}${path}`, close });
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type { CastPayload } from "./schemas.ts";
|
|
2
|
+
|
|
3
|
+
/** A Cast device or group as found by discovery: what `resolve` returns for each. */
|
|
4
|
+
export type Resolved = {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
model?: string;
|
|
8
|
+
host: string;
|
|
9
|
+
port: number;
|
|
10
|
+
group: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 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
|
|
15
|
+
* `playingVia` name other entries by id. Volume is the device's own number, unrounded.
|
|
16
|
+
*/
|
|
17
|
+
export type CastDevice = {
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
model?: string;
|
|
21
|
+
kind: "device" | "group";
|
|
22
|
+
host?: string;
|
|
23
|
+
port?: number;
|
|
24
|
+
app?: string; // the running app's display name
|
|
25
|
+
appId?: string;
|
|
26
|
+
idle?: boolean; // no app, or an idle screen (Backdrop's isIdleScreen)
|
|
27
|
+
standby?: boolean; // TVs: the TV is off or on another input
|
|
28
|
+
volume?: number;
|
|
29
|
+
muted?: boolean;
|
|
30
|
+
volumeControl?: string; // "master" | "attenuation" | "fixed"
|
|
31
|
+
playerState?: string; // "PLAYING" | "PAUSED" | "BUFFERING" | "IDLE"
|
|
32
|
+
title?: string;
|
|
33
|
+
artist?: string;
|
|
34
|
+
members?: string[]; // groups: from the group's multizone status
|
|
35
|
+
leader?: string; // groups: the member whose certificate the group's socket presents
|
|
36
|
+
groups?: string[]; // devices: the groups that list it
|
|
37
|
+
playingVia?: string; // devices: the group whose media this device shows, its own being empty
|
|
38
|
+
status: "connecting" | "connected" | "disconnected";
|
|
39
|
+
detail?: string;
|
|
40
|
+
asOf?: string; // when it last answered
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type CastSnapshot = {
|
|
44
|
+
devices: Record<string, CastDevice>;
|
|
45
|
+
asOf: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** An id as the snapshot keys it: the mDNS id (32 hex) and the certificate's and multizone's dashed UUID are one key. */
|
|
49
|
+
export const castKey = (id: string) => id.toLowerCase().replace(/-/g, "");
|
|
50
|
+
|
|
51
|
+
/** Add or update what discovery found; devices it did not find this time are kept as they were. */
|
|
52
|
+
export function found(
|
|
53
|
+
s: CastSnapshot,
|
|
54
|
+
entries: Resolved[],
|
|
55
|
+
asOf: string,
|
|
56
|
+
): CastSnapshot {
|
|
57
|
+
const devices = { ...s.devices };
|
|
58
|
+
for (const e of entries) {
|
|
59
|
+
const id = castKey(e.id);
|
|
60
|
+
const kind = e.group ? "group" : "device";
|
|
61
|
+
devices[id] = {
|
|
62
|
+
...(devices[id] ?? { status: "connecting" }),
|
|
63
|
+
id,
|
|
64
|
+
name: e.name,
|
|
65
|
+
model: e.model,
|
|
66
|
+
kind,
|
|
67
|
+
host: e.host,
|
|
68
|
+
port: e.port,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return link({ devices, asOf });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A socket opened or dropped. A group's `leader` comes with its socket. */
|
|
75
|
+
export function connection(
|
|
76
|
+
s: CastSnapshot,
|
|
77
|
+
id: string,
|
|
78
|
+
change: { status: CastDevice["status"]; detail?: string; leader?: string },
|
|
79
|
+
asOf: string,
|
|
80
|
+
): CastSnapshot {
|
|
81
|
+
const key = castKey(id);
|
|
82
|
+
const d = s.devices[key];
|
|
83
|
+
if (!d) return s;
|
|
84
|
+
const leader =
|
|
85
|
+
change.leader === undefined ? d.leader : castKey(change.leader);
|
|
86
|
+
return link({
|
|
87
|
+
devices: {
|
|
88
|
+
...s.devices,
|
|
89
|
+
[key]: { ...d, status: change.status, detail: change.detail, leader },
|
|
90
|
+
},
|
|
91
|
+
asOf,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** One payload from one entry's socket. Pure: returns a new snapshot. */
|
|
96
|
+
export function reduce(
|
|
97
|
+
s: CastSnapshot,
|
|
98
|
+
id: string,
|
|
99
|
+
p: CastPayload,
|
|
100
|
+
asOf: string,
|
|
101
|
+
): CastSnapshot {
|
|
102
|
+
const key = castKey(id);
|
|
103
|
+
const prev = s.devices[key];
|
|
104
|
+
if (!prev) return s;
|
|
105
|
+
const d: CastDevice = { ...prev, asOf };
|
|
106
|
+
if (p.type === "RECEIVER_STATUS") {
|
|
107
|
+
const app = p.status?.applications?.[0];
|
|
108
|
+
const v = p.status?.volume;
|
|
109
|
+
Object.assign(d, {
|
|
110
|
+
app: app?.displayName,
|
|
111
|
+
appId: app?.appId,
|
|
112
|
+
idle: !app || app.isIdleScreen === true,
|
|
113
|
+
standby: p.status?.isStandBy,
|
|
114
|
+
volume: v?.level,
|
|
115
|
+
muted: v?.muted,
|
|
116
|
+
volumeControl: v?.controlType,
|
|
117
|
+
});
|
|
118
|
+
if (!mediaApp(app))
|
|
119
|
+
Object.assign(d, {
|
|
120
|
+
playerState: undefined,
|
|
121
|
+
title: undefined,
|
|
122
|
+
artist: undefined,
|
|
123
|
+
});
|
|
124
|
+
} else if (p.type === "MEDIA_STATUS") {
|
|
125
|
+
const m = p.status?.[0];
|
|
126
|
+
delete d.playingVia; // its own media now: link borrows the group's again only if this is empty
|
|
127
|
+
Object.assign(d, {
|
|
128
|
+
playerState: m?.playerState,
|
|
129
|
+
title: m?.media?.metadata?.title,
|
|
130
|
+
artist: m?.media?.metadata?.artist,
|
|
131
|
+
});
|
|
132
|
+
} else if (d.kind === "device") {
|
|
133
|
+
// A device's own port lists only itself, by its current name in the Google Home app (mDNS fn; all six live devices
|
|
134
|
+
// matched, 2026-10-13): the name follows a rename without a discovery. Membership is read from a group's port alone.
|
|
135
|
+
if (p.type === "MULTIZONE_STATUS") {
|
|
136
|
+
const self = p.status?.devices?.find(
|
|
137
|
+
(m) => m.deviceId !== undefined && castKey(m.deviceId) === key,
|
|
138
|
+
);
|
|
139
|
+
if (self?.name) d.name = self.name;
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
const members = new Set(d.members ?? []);
|
|
143
|
+
if (p.type === "MULTIZONE_STATUS")
|
|
144
|
+
d.members = (p.status?.devices ?? []).flatMap((m) =>
|
|
145
|
+
m.deviceId ? [castKey(m.deviceId)] : [],
|
|
146
|
+
);
|
|
147
|
+
else {
|
|
148
|
+
const member =
|
|
149
|
+
p.type === "DEVICE_REMOVED" ? p.deviceId : p.device?.deviceId;
|
|
150
|
+
if (member)
|
|
151
|
+
p.type === "DEVICE_REMOVED"
|
|
152
|
+
? members.delete(castKey(member))
|
|
153
|
+
: members.add(castKey(member));
|
|
154
|
+
d.members = [...members];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return link({ devices: { ...s.devices, [key]: d }, asOf });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The app's transport speaks media status: connect to it to read what is playing. */
|
|
161
|
+
export const mediaApp = (app?: {
|
|
162
|
+
namespaces?: { name?: string }[];
|
|
163
|
+
transportId?: string;
|
|
164
|
+
}) =>
|
|
165
|
+
app?.transportId !== undefined &&
|
|
166
|
+
(app.namespaces ?? []).some(
|
|
167
|
+
(n) => n.name === "urn:x-cast:com.google.cast.media",
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const active = (state?: string) => state !== undefined && state !== "IDLE";
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The cross-links, recomputed from scratch on every change: each device's groups, and a member with no media of its
|
|
174
|
+
* own shows a playing group's, with `playingVia` naming the group (Home Assistant's model).
|
|
175
|
+
*/
|
|
176
|
+
function link(s: CastSnapshot): CastSnapshot {
|
|
177
|
+
const devices: Record<string, CastDevice> = {};
|
|
178
|
+
for (const [id, d] of Object.entries(s.devices)) {
|
|
179
|
+
const own = d.playingVia
|
|
180
|
+
? { playerState: undefined, title: undefined, artist: undefined }
|
|
181
|
+
: {};
|
|
182
|
+
devices[id] = { ...d, ...own, groups: undefined, playingVia: undefined };
|
|
183
|
+
}
|
|
184
|
+
for (const g of Object.values(devices).filter((d) => d.kind === "group")) {
|
|
185
|
+
for (const m of g.members ?? []) {
|
|
186
|
+
const d = devices[m];
|
|
187
|
+
if (!d || d.kind === "group") continue;
|
|
188
|
+
d.groups = [...(d.groups ?? []), g.id];
|
|
189
|
+
if (active(g.playerState) && !active(d.playerState))
|
|
190
|
+
Object.assign(d, {
|
|
191
|
+
playerState: g.playerState,
|
|
192
|
+
title: g.title,
|
|
193
|
+
artist: g.artist,
|
|
194
|
+
playingVia: g.id,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
for (const d of Object.values(devices))
|
|
199
|
+
if (d.groups === undefined) delete d.groups;
|
|
200
|
+
for (const d of Object.values(devices))
|
|
201
|
+
if (d.playingVia === undefined) delete d.playingVia;
|
|
202
|
+
return { devices, asOf: s.asOf };
|
|
203
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { connect } from "node:tls";
|
|
2
|
+
import {
|
|
3
|
+
type CastChannel,
|
|
4
|
+
type CastConnect,
|
|
5
|
+
type CastMessage,
|
|
6
|
+
encode,
|
|
7
|
+
framer,
|
|
8
|
+
} from "./codec.ts";
|
|
9
|
+
|
|
10
|
+
// The one Node API in the Cast connector, so it sits outside the runtime-neutral build (tsconfig.build.json), like
|
|
11
|
+
// weather/ftp.ts, and is passed in as `connect`. Runs in Bun (1.4.2, checked live) and, untried, Node.
|
|
12
|
+
//
|
|
13
|
+
// Verification is off: every Cast device presents a self-signed certificate that rotates every 48 hours, so there is
|
|
14
|
+
// nothing to pin. The integration instead checks the certificate's name (`peer`, the device's id) against discovery.
|
|
15
|
+
|
|
16
|
+
/** Open a Cast socket: TLS to host:port within `timeoutMs`, framed CastMessages in and out. */
|
|
17
|
+
export const tlsConnect: CastConnect = ({
|
|
18
|
+
host,
|
|
19
|
+
port,
|
|
20
|
+
signal,
|
|
21
|
+
timeoutMs = 10_000,
|
|
22
|
+
}) =>
|
|
23
|
+
new Promise<CastChannel>((resolve, reject) => {
|
|
24
|
+
const socket = connect({ host, port, rejectUnauthorized: false });
|
|
25
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
26
|
+
const fail = (error: Error) => (
|
|
27
|
+
clearTimeout(timer),
|
|
28
|
+
signal?.removeEventListener("abort", abort),
|
|
29
|
+
socket.destroy(),
|
|
30
|
+
reject(error)
|
|
31
|
+
);
|
|
32
|
+
timer = setTimeout(
|
|
33
|
+
() =>
|
|
34
|
+
fail(
|
|
35
|
+
new Error(
|
|
36
|
+
`cast: no TLS answer from ${host}:${port} in ${timeoutMs / 1000} s`,
|
|
37
|
+
),
|
|
38
|
+
),
|
|
39
|
+
timeoutMs,
|
|
40
|
+
);
|
|
41
|
+
const abort = () => fail(new Error("aborted"));
|
|
42
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
43
|
+
socket.once("error", fail);
|
|
44
|
+
socket.once("secureConnect", () => {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
signal?.removeEventListener("abort", abort);
|
|
47
|
+
socket.off("error", fail);
|
|
48
|
+
const name = socket.getPeerCertificate().subject?.CN;
|
|
49
|
+
resolve(
|
|
50
|
+
channel(
|
|
51
|
+
socket,
|
|
52
|
+
Array.isArray(name) ? name[0] : name,
|
|
53
|
+
socket.getProtocol() ?? undefined,
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/** The socket as an async stream of messages; the stream ends when the socket closes and throws on a socket error. */
|
|
60
|
+
function channel(
|
|
61
|
+
socket: ReturnType<typeof connect>,
|
|
62
|
+
peer: string | undefined,
|
|
63
|
+
protocol: string | undefined,
|
|
64
|
+
): CastChannel {
|
|
65
|
+
const split = framer();
|
|
66
|
+
const queue: CastMessage[] = [];
|
|
67
|
+
let ended = false;
|
|
68
|
+
let failure: Error | undefined;
|
|
69
|
+
let wake: (() => void) | undefined;
|
|
70
|
+
const notify = () => (wake?.(), (wake = undefined));
|
|
71
|
+
socket.on("data", (chunk: Buffer) => {
|
|
72
|
+
try {
|
|
73
|
+
queue.push(...split(new Uint8Array(chunk)));
|
|
74
|
+
} catch (error) {
|
|
75
|
+
failure = error as Error;
|
|
76
|
+
socket.destroy();
|
|
77
|
+
}
|
|
78
|
+
notify();
|
|
79
|
+
});
|
|
80
|
+
socket.on("error", (error) => ((failure ??= error), notify()));
|
|
81
|
+
socket.on("close", () => ((ended = true), notify()));
|
|
82
|
+
return {
|
|
83
|
+
peer,
|
|
84
|
+
protocol,
|
|
85
|
+
send: (m) => void (socket.writable && socket.write(encode(m))),
|
|
86
|
+
// end() flushes a CLOSE written just before; a device that does not close its side within a second is cut off.
|
|
87
|
+
close: () => (
|
|
88
|
+
socket.end(),
|
|
89
|
+
setTimeout(() => socket.destroy(), 1000).unref()
|
|
90
|
+
),
|
|
91
|
+
messages: (async function* () {
|
|
92
|
+
for (;;) {
|
|
93
|
+
while (queue.length) yield queue.shift()!;
|
|
94
|
+
if (failure) throw failure;
|
|
95
|
+
if (ended) return;
|
|
96
|
+
await new Promise<void>((r) => (wake = r));
|
|
97
|
+
}
|
|
98
|
+
})(),
|
|
99
|
+
};
|
|
100
|
+
}
|