@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,82 @@
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
+ type NetworkAddress = {
19
+ family: string;
20
+ address: string;
21
+ netmask: string;
22
+ internal: boolean;
23
+ };
24
+
25
+ /** This machine's IPv4 on the subnet that holds `host`, or with no host, its first non-internal IPv4. */
26
+ export function lanAddress(
27
+ host?: string,
28
+ interfaces: Record<
29
+ string,
30
+ NetworkAddress[] | undefined
31
+ > = networkInterfaces(),
32
+ ): string {
33
+ const all = Object.values(interfaces)
34
+ .flatMap((list) => list ?? [])
35
+ .filter((i) => i.family === "IPv4");
36
+ const pick = host
37
+ ? all.find(
38
+ (i) => network(i.address, i.netmask) === network(host, i.netmask),
39
+ )
40
+ : all.find((i) => !i.internal);
41
+ if (!pick)
42
+ throw new Error(
43
+ host
44
+ ? `no address of this machine on ${host}'s network`
45
+ : "no LAN IPv4 address on this machine",
46
+ );
47
+ return pick.address;
48
+ }
49
+
50
+ /**
51
+ * Serve `bytes`: a random port, an unguessable path, the given `Content-Type` and its `Content-Length`, the whole body
52
+ * to every GET (a player may probe before it fetches). It closes on `close()` (`announce` calls it when the clip has
53
+ * played), when `signal` aborts, or after `SERVE_MS`. `host` is the device that will fetch it, so the address is on its network.
54
+ */
55
+ export const serveClip: ServeClip = (bytes, type, options = {}) =>
56
+ new Promise((resolve, reject) => {
57
+ if (options.signal?.aborted) return reject(new Error("aborted"));
58
+ const address = lanAddress(options.host);
59
+ const path = `/${randomUUID()}`;
60
+ const server = createServer((req, res) => {
61
+ if (req.url !== path || (req.method !== "GET" && req.method !== "HEAD"))
62
+ return res.writeHead(404).end();
63
+ res.writeHead(200, {
64
+ "Content-Type": type,
65
+ "Content-Length": bytes.length,
66
+ });
67
+ res.end(req.method === "HEAD" ? undefined : bytes);
68
+ });
69
+ const timer = setTimeout(() => close(), SERVE_MS);
70
+ function close() {
71
+ clearTimeout(timer);
72
+ options.signal?.removeEventListener("abort", close);
73
+ server.close();
74
+ server.closeAllConnections();
75
+ }
76
+ options.signal?.addEventListener("abort", close, { once: true });
77
+ server.once("error", (error) => (close(), reject(error)));
78
+ server.listen(0, address, () => {
79
+ const { port } = server.address() as { port: number };
80
+ resolve({ url: `http://${address}:${port}${path}`, close });
81
+ });
82
+ });
@@ -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 = 3000,
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
+ }