@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,48 @@
1
+ import { type AddressCache, type Connector, type Finder, type Found } from "./discover.ts";
2
+ /** The system's mDNS tool is not installed: discovery can't run until it is. */
3
+ export declare class MissingTool extends Error {
4
+ }
5
+ /**
6
+ * Run a long-lived `dns-sd` query (or `avahi-browse`, by `command`) and return what it printed. It never exits by itself, so it is stopped once `done`
7
+ * says the answer is in, or `quietMs` after the last output, or at `maxMs`. The system's mDNS cache usually answers in
8
+ * 4–13 ms (Bun 1.4.2, measured 2026-10-13), so the wait is for a cold cache only. A missing binary rejects, naming it.
9
+ * `command` is for tests.
10
+ */
11
+ export declare function dnsSd(argv: string[], options?: {
12
+ maxMs?: number;
13
+ quietMs?: number;
14
+ done?: (out: string) => boolean;
15
+ command?: string;
16
+ install?: string;
17
+ }): Promise<string>;
18
+ /** Instance names of one mDNS service type: what has answered, once answers stop for 200 ms. */
19
+ export declare const browse: (type: string) => Promise<string[]>;
20
+ /** Every instance of one mDNS type, resolved to an IPv4 address (the config keeps addresses, not names), its port and its TXT record. */
21
+ export declare function mdnsResolve(type: string): Promise<{
22
+ instance: string;
23
+ hostname: string;
24
+ host: string;
25
+ port: number;
26
+ txt: Record<string, string>;
27
+ }[]>;
28
+ /** Daikin's `key=value,key=value` answers, percent-decoded. */
29
+ export declare const daikinFields: (text: string) => any;
30
+ /** Broadcast pydaikin's discovery packet (UDP 30000 → 30050) and collect who answers within `ms`. */
31
+ export declare function findDaikins(ms?: number): Promise<{
32
+ ip: string;
33
+ fields: Record<string, string>;
34
+ }[]>;
35
+ /** A Daikin's broadcast answer as a found device, by its MAC. */
36
+ export declare const daikinFound: (d: {
37
+ ip: string;
38
+ fields: Record<string, string>;
39
+ }) => Found[];
40
+ /** Each connector's discovery, as `loadHome` (rescans, and every Cast address) and `home discover` use it. */
41
+ export declare const FINDERS: Record<Connector, Finder>;
42
+ /**
43
+ * An address cache in one JSON file, owner-only (0600, its folder 0700), replaced atomically on each write. A missing
44
+ * file is an empty cache; so is a file that does not parse, which the next write replaces (a cache, not KO's data).
45
+ */
46
+ export declare function fileCache(path: string): AddressCache;
47
+ /** Write a file only its owner can read (0600, its folder 0700), replaced atomically: the address cache, the Enphase token. */
48
+ export declare function writePrivate(path: string, text: string): void;
@@ -0,0 +1,176 @@
1
+ import { spawn } from "node:child_process";
2
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import { createSocket } from "node:dgram";
5
+ import { lookup } from "node:dns/promises";
6
+ import { printersFound } from "./connectors/printers/found.js";
7
+ import { parseAvahi, parseAvahiBrowse, parseBrowse, parseResolve, } from "./discover.js";
8
+ // Each connector's discovery on the network: mDNS through the system's own daemon (macOS `dns-sd`, Linux `avahi-browse`),
9
+ // and Daikin's UDP broadcast. Node APIs (child_process, dgram, dns), so this file sits outside the runtime-neutral build like
10
+ // weather/ftp.ts and cast/tls.ts; `home`, jaldi's sidecar and chota-bot import it from `@khalido/home/finders` and pass `FINDERS`
11
+ // to `loadHome`. Runs in Bun and Node. The system daemon keeps an mDNS cache, so answers come from memory in milliseconds and
12
+ // nothing here binds UDP 5353 beside it; a slow device (the Enphase gateway took 4 s to answer a fresh query; Bun 1.4.3, 2026-10-16)
13
+ // does not slow a scan. Without the tool the mDNS finders throw, naming it, rather than "nothing answered".
14
+ // `fileCache` keeps the addresses discovery found in a file between runs, so the next run connects straight away.
15
+ /** The system's mDNS tool is not installed: discovery can't run until it is. */
16
+ export class MissingTool extends Error {
17
+ }
18
+ /**
19
+ * Run a long-lived `dns-sd` query (or `avahi-browse`, by `command`) and return what it printed. It never exits by itself, so it is stopped once `done`
20
+ * says the answer is in, or `quietMs` after the last output, or at `maxMs`. The system's mDNS cache usually answers in
21
+ * 4–13 ms (Bun 1.4.2, measured 2026-10-13), so the wait is for a cold cache only. A missing binary rejects, naming it.
22
+ * `command` is for tests.
23
+ */
24
+ export function dnsSd(argv, options = {}) {
25
+ const { maxMs = 1500, quietMs, done, command = "dns-sd", install } = options;
26
+ return new Promise((resolve, reject) => {
27
+ let out = "";
28
+ const proc = spawn(command, argv, { stdio: ["ignore", "pipe", "ignore"] });
29
+ const stop = () => proc.kill();
30
+ const timer = setTimeout(stop, maxMs);
31
+ let quiet;
32
+ proc.stdout.setEncoding("utf8").on("data", (d) => {
33
+ out += d;
34
+ if (done?.(out))
35
+ return stop();
36
+ if (quietMs !== undefined)
37
+ (clearTimeout(quiet), (quiet = setTimeout(stop, quietMs)));
38
+ });
39
+ const finish = () => (clearTimeout(timer),
40
+ clearTimeout(quiet),
41
+ resolve(out));
42
+ proc.on("error", (error) => {
43
+ (clearTimeout(timer), clearTimeout(quiet));
44
+ reject(error.code === "ENOENT"
45
+ ? new MissingTool(`${command} not found; mDNS discovery needs it${install ? ` (${install})` : ""}`)
46
+ : new Error(`${command}: ${error.message}`));
47
+ });
48
+ proc.on("close", finish);
49
+ });
50
+ }
51
+ /** Linux asks avahi-daemon, which Raspberry Pi OS runs by default; its command-line tool is a separate package. */
52
+ const LINUX = process.platform === "linux";
53
+ /** `avahi-browse` prints what avahi-daemon's cache holds, resolves it, and exits (`--terminate`); `maxMs` caps a slow resolve. */
54
+ const avahi = (args) => dnsSd(["--parsable", "--terminate", ...args], {
55
+ command: "avahi-browse",
56
+ install: "apt install avahi-utils",
57
+ maxMs: 3000,
58
+ });
59
+ /** Instance names of one mDNS service type: what has answered, once answers stop for 200 ms. */
60
+ export const browse = async (type) => LINUX
61
+ ? parseAvahiBrowse(await avahi([type]))
62
+ : parseBrowse(await dnsSd(["-B", type], { quietMs: 200 }));
63
+ /** A resolve is in once its TXT line (the line after "can be reached at") is complete. */
64
+ const resolved = (out) => /can be reached at [^\n]*\n[^\n]*\n/.test(out);
65
+ /** Every instance of one mDNS type, resolved to an IPv4 address (the config keeps addresses, not names), its port and its TXT record. */
66
+ export async function mdnsResolve(type) {
67
+ if (LINUX)
68
+ return parseAvahi(await avahi(["--resolve", type]));
69
+ const found = await Promise.all((await browse(type)).map(async (name) => {
70
+ const r = parseResolve(await dnsSd(["-L", name, type], { done: resolved }));
71
+ const host = r
72
+ ? await lookup(r.hostname, { family: 4 }).then((x) => x.address, () => undefined)
73
+ : undefined;
74
+ return r && host ? [{ ...r, host, instance: name }] : [];
75
+ }));
76
+ return found.flat();
77
+ }
78
+ /** Every device of one mDNS type with its identity from a TXT key, named by its mDNS instance. */
79
+ const mdnsFind = async (type, txtKey, connector) => (await mdnsResolve(type)).flatMap((r) => r.txt[txtKey]
80
+ ? [{ connector, id: r.txt[txtKey], host: r.host, name: r.instance }]
81
+ : []);
82
+ /** Daikin's `key=value,key=value` answers, percent-decoded. */
83
+ export const daikinFields = (text) => Object.fromEntries(text
84
+ .trim()
85
+ .split(",")
86
+ .map((kv) => kv.split("="))
87
+ .map(([k, v = ""]) => [k, decoded(v)]));
88
+ /** A percent-encoded value, or as sent when it is not valid encoding (a stray "%"): one odd field never fails the read. */
89
+ function decoded(v) {
90
+ try {
91
+ return decodeURIComponent(v);
92
+ }
93
+ catch {
94
+ return v;
95
+ }
96
+ }
97
+ /** Broadcast pydaikin's discovery packet (UDP 30000 → 30050) and collect who answers within `ms`. */
98
+ export function findDaikins(ms = 1500) {
99
+ return new Promise((resolve) => {
100
+ const found = [];
101
+ const sock = createSocket({ type: "udp4", reuseAddr: true });
102
+ sock.on("message", (msg, from) => found.push({ ip: from.address, fields: daikinFields(msg.toString()) }));
103
+ sock.on("error", () => (sock.close(), resolve(found)));
104
+ sock.bind(30000, () => {
105
+ sock.setBroadcast(true);
106
+ sock.send("DAIKIN_UDP/common/basic_info", 30050, "255.255.255.255");
107
+ setTimeout(() => (sock.close(), resolve(found)), ms);
108
+ });
109
+ });
110
+ }
111
+ /** A Daikin's broadcast answer as a found device, by its MAC. */
112
+ export const daikinFound = (d) => d.fields.mac
113
+ ? [
114
+ {
115
+ connector: "daikin",
116
+ id: d.fields.mac,
117
+ host: d.ip,
118
+ name: d.fields.name,
119
+ },
120
+ ]
121
+ : [];
122
+ /** Each connector's discovery, as `loadHome` (rescans, and every Cast address) and `home discover` use it. */
123
+ export const FINDERS = {
124
+ hue: () => mdnsFind("_hue._tcp", "bridgeid", "hue"),
125
+ enphase: () => mdnsFind("_enphase-envoy._tcp", "serialnum", "enphase"),
126
+ daikin: async () => (await findDaikins()).flatMap(daikinFound),
127
+ cast: async () => (await mdnsResolve("_googlecast._tcp")).flatMap(({ txt, host, port }) => txt.id
128
+ ? [
129
+ {
130
+ connector: "cast",
131
+ id: txt.id,
132
+ host,
133
+ port,
134
+ name: txt.fn,
135
+ model: txt.md,
136
+ group: txt.md === "Google Cast Group",
137
+ },
138
+ ]
139
+ : []),
140
+ printers: async () => {
141
+ const [ipp, ipps] = await Promise.all([
142
+ mdnsResolve("_ipp._tcp"),
143
+ mdnsResolve("_ipps._tcp"),
144
+ ]);
145
+ return printersFound(ipp, ipps);
146
+ },
147
+ };
148
+ /**
149
+ * An address cache in one JSON file, owner-only (0600, its folder 0700), replaced atomically on each write. A missing
150
+ * file is an empty cache; so is a file that does not parse, which the next write replaces (a cache, not KO's data).
151
+ */
152
+ export function fileCache(path) {
153
+ return {
154
+ read() {
155
+ try {
156
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
157
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
158
+ ? parsed
159
+ : {};
160
+ }
161
+ catch {
162
+ return {};
163
+ }
164
+ },
165
+ write: (entries) => writePrivate(path, `${JSON.stringify(entries, null, 2)}\n`),
166
+ };
167
+ }
168
+ /** Write a file only its owner can read (0600, its folder 0700), replaced atomically: the address cache, the Enphase token. */
169
+ export function writePrivate(path, text) {
170
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
171
+ const tmp = `${path}.tmp`;
172
+ rmSync(tmp, { force: true }); // so the mode applies to a fresh file
173
+ writeFileSync(tmp, text, { mode: 0o600 });
174
+ chmodSync(tmp, 0o600);
175
+ renameSync(tmp, path);
176
+ }
@@ -0,0 +1,28 @@
1
+ import type { Part, Status } from "./home.ts";
2
+ /**
3
+ * How one integration is doing, judged by time: `ok`, `stale` (no good read for a while, last values still shown) or `down`.
4
+ * The thresholds come with it, so a page can say "stale after 4 min". Push connectors (Hue, Cast) have no poll to be late
5
+ * for: never stale, down when not connected for `PUSH_DOWN_MS`.
6
+ */
7
+ export type Health = {
8
+ level: "ok" | "stale" | "down";
9
+ status: Status;
10
+ lastGoodAt?: string;
11
+ downSince?: string;
12
+ staleAfterMs?: number;
13
+ downAfterMs: number;
14
+ detail?: string;
15
+ };
16
+ /** A polling connector is stale after this many polls with no good read: two missed, plus half of one so a slow read that ends in a timeout does not flap it, */
17
+ export declare const STALE_POLLS = 2.5;
18
+ /** and down after this many polls not connected. */
19
+ export declare const DOWN_POLLS = 3;
20
+ /** A push connector is down after this long not connected: a reconnect takes seconds, a backoff at most a minute. */
21
+ export declare const PUSH_DOWN_MS = 120000;
22
+ /**
23
+ * Each integration's health at `now` (ms), from the times `createHome` stamps on its part. `pollSeconds` overrides a
24
+ * connector's own interval (its manifest's, so pass the weather's when the config changes it), or names one the library does not know.
25
+ */
26
+ export declare function health(snapshot: Record<string, Part | undefined>, now: number, options?: {
27
+ pollSeconds?: Record<string, number>;
28
+ }): Record<string, Health>;
package/dist/health.js ADDED
@@ -0,0 +1,35 @@
1
+ import { MANIFESTS } from "./manifests.js";
2
+ /** A polling connector is stale after this many polls with no good read: two missed, plus half of one so a slow read that ends in a timeout does not flap it, */
3
+ export const STALE_POLLS = 2.5;
4
+ /** and down after this many polls not connected. */
5
+ export const DOWN_POLLS = 3;
6
+ /** A push connector is down after this long not connected: a reconnect takes seconds, a backoff at most a minute. */
7
+ export const PUSH_DOWN_MS = 120_000;
8
+ const POLL_SECONDS = Object.fromEntries(MANIFESTS.map((m) => [m.name, m.pollSeconds]));
9
+ /**
10
+ * Each integration's health at `now` (ms), from the times `createHome` stamps on its part. `pollSeconds` overrides a
11
+ * connector's own interval (its manifest's, so pass the weather's when the config changes it), or names one the library does not know.
12
+ */
13
+ export function health(snapshot, now, options = {}) {
14
+ const poll = (name) => options.pollSeconds?.[name] ?? POLL_SECONDS[name];
15
+ return Object.fromEntries(Object.entries(snapshot).flatMap(([name, part]) => part ? [[name, healthOf(part, now, poll(name))]] : []));
16
+ }
17
+ function healthOf(part, now, pollSeconds) {
18
+ const staleAfterMs = pollSeconds === undefined ? undefined : STALE_POLLS * pollSeconds * 1000;
19
+ const downAfterMs = pollSeconds === undefined ? PUSH_DOWN_MS : DOWN_POLLS * pollSeconds * 1000;
20
+ const age = (iso) => iso === undefined ? undefined : now - Date.parse(iso);
21
+ const down = part.status === "unavailable" ||
22
+ (part.status !== "connected" && (age(part.downSince) ?? 0) >= downAfterMs);
23
+ const goodAge = age(part.lastGoodAt) ?? age(part.downSince); // never good: as old as the wait for it
24
+ const stale = staleAfterMs !== undefined && (goodAge ?? 0) >= staleAfterMs;
25
+ const { status, lastGoodAt, downSince, detail } = part;
26
+ return {
27
+ level: down ? "down" : stale ? "stale" : "ok",
28
+ status,
29
+ lastGoodAt,
30
+ downSince,
31
+ staleAfterMs,
32
+ downAfterMs,
33
+ detail,
34
+ };
35
+ }
package/dist/home.d.ts ADDED
@@ -0,0 +1,81 @@
1
+ import { z } from "zod";
2
+ import { type Clock } from "./clock.ts";
3
+ /** A secret in a home config is the name of an environment variable, never the secret: a pasted key fails here. */
4
+ export declare const envName: z.ZodString;
5
+ /** A file a connector needs (a CA, a token), relative to the config file. The caller reads it; the library reads no files. */
6
+ export declare const filePath: z.ZodString;
7
+ /** How an integration is doing. "unavailable" means it stopped and will not retry by itself. */
8
+ export type Status = "connecting" | "connected" | "disconnected" | "unavailable";
9
+ /**
10
+ * One integration's slice of the home: its status, a human detail, anything odd the device reported, and its state.
11
+ * `createHome` stamps the times (UTC ISO). `lastGoodAt`: its last report while connected, or the read time a connected report
12
+ * gives when it repeats an older read (weather's 5-minute sun tick). `downSince`: when it stopped being connected (or started,
13
+ * if it never has); none while connected. Any other time an integration sets is ignored.
14
+ */
15
+ export type Part<S = unknown> = {
16
+ status: Status;
17
+ detail?: string;
18
+ anomaly?: string;
19
+ state?: S;
20
+ lastGoodAt?: string;
21
+ downSince?: string;
22
+ };
23
+ /**
24
+ * An integration is a folder in `src/` that exports a factory returning this.
25
+ * `run` keeps its state current until `signal` aborts, reporting each change through `update`.
26
+ * `refresh` asks for a fresh read now, ahead of the integration's schedule; it returns at once and the read reports through `update`.
27
+ */
28
+ export type Integration<S = unknown, N extends string = string> = {
29
+ name: N;
30
+ run(signal: AbortSignal, update: (part: Part<S>) => void): Promise<void>;
31
+ refresh(): void;
32
+ };
33
+ /** The state an integration reports. */
34
+ export type StateOf<I> = I extends Integration<infer S, string> ? S : never;
35
+ /**
36
+ * The whole home: one `Part` per integration, by name, each typed by its integration (`snapshot.weather?.state` is a
37
+ * `WeatherState`). A name can be missing: a config need not have every connector. This is the cache every read comes from.
38
+ */
39
+ export type HomeSnapshot<I extends Integration<any, string> = Integration<unknown, string>> = {
40
+ [X in I as X["name"]]?: Part<StateOf<X>>;
41
+ };
42
+ /** One report: which integration, its part before and after. Typed by name, so `change.after.state` is that integration's state. */
43
+ export type Change<I extends Integration<any, string> = Integration<unknown, string>> = {
44
+ [X in I as X["name"]]: {
45
+ name: X["name"];
46
+ before: Part<StateOf<X>>;
47
+ after: Part<StateOf<X>>;
48
+ };
49
+ }[I["name"]];
50
+ /**
51
+ * Load a home: run each integration, keep one snapshot, call `onChange` with it and what changed after every report.
52
+ * `clock` stamps each part's `lastGoodAt` and `downSince`.
53
+ */
54
+ export declare function createHome<I extends Integration<any, string>>(integrations: readonly I[], onChange?: (home: HomeSnapshot<I>, change: Change<I>) => void, options?: {
55
+ clock?: Clock;
56
+ }): {
57
+ /** The latest snapshot. Never touches a device. */
58
+ snapshot: () => HomeSnapshot<I>;
59
+ /** One integration's part, typed by its name: `home.part("weather")?.state?.blocks`. */
60
+ part: <N extends I["name"]>(name: N) => HomeSnapshot<I>[N];
61
+ /** One integration by name, for its writes: `home.integration("daikin")?.client.write(setPower(true))`. */
62
+ integration: <N extends I["name"]>(name: N) => Extract<I, {
63
+ name: N;
64
+ }> | undefined;
65
+ /**
66
+ * Read now: one integration by name, or every one. Results arrive through the snapshot and `onChange` as usual; the
67
+ * promise resolves once each target has reported (its next update, good or bad), or at once for one whose run has ended.
68
+ */
69
+ refresh(name?: I["name"]): Promise<void>;
70
+ /** Run every integration until `signal` aborts. One failing marks itself unavailable; the rest carry on. */
71
+ start(signal: AbortSignal): Promise<void>;
72
+ };
73
+ /**
74
+ * One read of everything, for a script, a test or a one-shot command: run the integrations until each has answered
75
+ * (connected or not), at most `ms` (15 s by default) or until `signal` aborts, then stop them and return the snapshot.
76
+ */
77
+ export declare function settle<I extends Integration<any, string>>(integrations: readonly I[], options?: {
78
+ ms?: number;
79
+ clock?: Clock;
80
+ signal?: AbortSignal;
81
+ }): Promise<HomeSnapshot<I>>;
package/dist/home.js ADDED
@@ -0,0 +1,114 @@
1
+ import { z } from "zod";
2
+ import { asOf, realClock } from "./clock.js";
3
+ import { errorText } from "./connector.js";
4
+ /** A secret in a home config is the name of an environment variable, never the secret: a pasted key fails here. */
5
+ export const envName = z
6
+ .string()
7
+ .regex(/^[A-Z_][A-Z0-9_]*$/, "an environment variable name (e.g. AMBER_KEY), not the secret itself");
8
+ /** A file a connector needs (a CA, a token), relative to the config file. The caller reads it; the library reads no files. */
9
+ export const filePath = z.string().min(1);
10
+ /** The times `createHome` keeps on a part, from the one before it: when it was last good, and when it stopped being. */
11
+ function stamped(part, before, now) {
12
+ if (part.status === "connected")
13
+ return {
14
+ ...part,
15
+ lastGoodAt: part.lastGoodAt ?? now,
16
+ downSince: undefined,
17
+ };
18
+ return {
19
+ ...part,
20
+ lastGoodAt: before?.lastGoodAt,
21
+ downSince: before?.downSince ?? now,
22
+ };
23
+ }
24
+ /**
25
+ * Load a home: run each integration, keep one snapshot, call `onChange` with it and what changed after every report.
26
+ * `clock` stamps each part's `lastGoodAt` and `downSince`.
27
+ */
28
+ export function createHome(integrations, onChange, options = {}) {
29
+ const clock = options.clock ?? realClock;
30
+ let snapshot = Object.fromEntries(integrations.map((i) => [i.name, { status: "connecting" }]));
31
+ const waiting = new Map(); // refresh() callers, resolved by the integration's next report
32
+ const finished = new Set(); // integrations whose run has ended: a refresh of one resolves at once, nothing will report
33
+ const set = (name, reported) => {
34
+ const before = snapshot[name];
35
+ const part = stamped(reported, before, asOf(clock));
36
+ snapshot = { ...snapshot, [name]: part };
37
+ onChange?.(snapshot, { name, before, after: part });
38
+ const w = waiting.get(name);
39
+ if (w) {
40
+ waiting.delete(name);
41
+ for (const resolve of w)
42
+ resolve();
43
+ }
44
+ };
45
+ return {
46
+ /** The latest snapshot. Never touches a device. */
47
+ snapshot: () => snapshot,
48
+ /** One integration's part, typed by its name: `home.part("weather")?.state?.blocks`. */
49
+ part: (name) => snapshot[name],
50
+ /** One integration by name, for its writes: `home.integration("daikin")?.client.write(setPower(true))`. */
51
+ integration: (name) => integrations.find((i) => i.name === name),
52
+ /**
53
+ * Read now: one integration by name, or every one. Results arrive through the snapshot and `onChange` as usual; the
54
+ * promise resolves once each target has reported (its next update, good or bad), or at once for one whose run has ended.
55
+ */
56
+ refresh(name) {
57
+ const targets = name === undefined
58
+ ? integrations
59
+ : integrations.filter((i) => i.name === name);
60
+ if (!targets.length)
61
+ throw new Error(`no integration "${name}"; have ${integrations.map((i) => i.name).join(", ")}`);
62
+ const reported = targets.map((i) => new Promise((resolve) => {
63
+ if (finished.has(i.name))
64
+ return resolve();
65
+ waiting.set(i.name, [...(waiting.get(i.name) ?? []), resolve]);
66
+ }));
67
+ for (const i of targets)
68
+ i.refresh();
69
+ return Promise.all(reported).then(() => undefined);
70
+ },
71
+ /** Run every integration until `signal` aborts. One failing marks itself unavailable; the rest carry on. */
72
+ async start(signal) {
73
+ finished.clear(); // a second start, after the first has stopped, runs them all again
74
+ const now = asOf(clock); // the wait for a first report counts from here, not from createHome
75
+ snapshot = Object.fromEntries(Object.entries(snapshot).map(([name, p]) => [
76
+ name,
77
+ p.status === "connecting"
78
+ ? { ...p, downSince: p.downSince ?? now }
79
+ : p,
80
+ ]));
81
+ await Promise.all(integrations.map((i) => i
82
+ .run(signal, (part) => set(i.name, part))
83
+ .catch((error) => set(i.name, { status: "unavailable", detail: errorText(error) }))
84
+ .finally(() => {
85
+ finished.add(i.name);
86
+ for (const resolve of waiting.get(i.name) ?? [])
87
+ resolve(); // nothing more will report
88
+ waiting.delete(i.name);
89
+ })));
90
+ },
91
+ };
92
+ }
93
+ /** Answered: past connecting, and when the state lists sources (the weather), every source has read. */
94
+ const answered = (p) => !!p &&
95
+ p.status !== "connecting" &&
96
+ Object.values(p.state?.sources ?? {}).every((s) => s.read);
97
+ /**
98
+ * One read of everything, for a script, a test or a one-shot command: run the integrations until each has answered
99
+ * (connected or not), at most `ms` (15 s by default) or until `signal` aborts, then stop them and return the snapshot.
100
+ */
101
+ export async function settle(integrations, options = {}) {
102
+ const { ms = 15_000, clock = realClock, signal } = options;
103
+ const stop = new AbortController();
104
+ const run = signal ? AbortSignal.any([signal, stop.signal]) : stop.signal; // the caller's signal stops it too, with nothing left on it after
105
+ const home = createHome(integrations, (snapshot) => {
106
+ if (Object.values(snapshot).every(answered))
107
+ stop.abort();
108
+ }, { clock });
109
+ const running = home.start(run);
110
+ await Promise.race([clock.sleep(ms, run), running]);
111
+ stop.abort();
112
+ await running;
113
+ return home.snapshot();
114
+ }
@@ -0,0 +1,11 @@
1
+ export { type Change, createHome, envName, filePath, settle, type HomeSnapshot, type Integration, type Part, type StateOf, type Status, } from "./home.ts";
2
+ export { DOWN_POLLS, type Health, health, PUSH_DOWN_MS, STALE_POLLS, } from "./health.ts";
3
+ export { asOf, type Clock, realClock } from "./clock.ts";
4
+ export { ConnectorError } from "./connector.ts";
5
+ export { bestWindow, type EnergyNow, energyNow, gridText, type PriceWindow, solarLevel, } from "./energy.ts";
6
+ export { checkHome, checkPlan, Device, HomeConfig, HomeConfigError, type HomeIntegration, type LoadOptions, loadHome, parseJsonc, Room, type Setup, } from "./config.ts";
7
+ export type { AddressCache, CachedAddress, Connector, Finder, Found, } from "./discover.ts";
8
+ export { type SunNow, sunNow } from "./connectors/weather/sun.ts";
9
+ export { type RainSoon, rainSoon } from "./connectors/weather/rain.ts";
10
+ export { type BlockDef, BLOCKS, blockText, type DayBlock, type DayBlocks, dayBlocks, } from "./connectors/weather/blocks.ts";
11
+ export type { EnvVar, IotClass, Manifest } from "./manifest.ts";
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ // The root entry: the home, its config, discovery types, the clock, energy, and the sun, rain and day-block helpers. The weather types are in `./weather`.
2
+ // Each connector has its own entry point (`@khalido/home/hue` and so on). Every export is named; test/exports.test.ts lists them.
3
+ export { createHome, envName, filePath, settle, } from "./home.js";
4
+ export { DOWN_POLLS, health, PUSH_DOWN_MS, STALE_POLLS, } from "./health.js";
5
+ export { asOf, realClock } from "./clock.js";
6
+ export { ConnectorError } from "./connector.js";
7
+ export { bestWindow, energyNow, gridText, solarLevel, } from "./energy.js";
8
+ export { checkHome, checkPlan, Device, HomeConfig, HomeConfigError, loadHome, parseJsonc, Room, } from "./config.js";
9
+ export { sunNow } from "./connectors/weather/sun.js";
10
+ export { rainSoon } from "./connectors/weather/rain.js";
11
+ export { BLOCKS, blockText, dayBlocks, } from "./connectors/weather/blocks.js";
package/dist/lane.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { type Clock } from "./clock.ts";
2
+ export type LaneOptions = {
3
+ /** Minimum time between two sends on the lane, across slots. */
4
+ gapMs: number;
5
+ /** At most this many sends per slot in any minute; more wait their turn, still coalescing. None: no cap. */
6
+ perMinute?: number;
7
+ clock?: Clock;
8
+ };
9
+ /**
10
+ * A write lane for one device or bridge. `send(slot, body, write, merge)` resolves with the result of the send that
11
+ * carried its body: a write merged into a later one resolves (or rejects) with that later one. `merge(older, newer)`
12
+ * defaults to the newer body alone.
13
+ */
14
+ export declare function writeLane(options: LaneOptions): {
15
+ send<B, T>(slot: string, body: B, write: (body: B) => Promise<T>, merge?: (older: B, newer: B) => B): Promise<T>;
16
+ };
17
+ export type WriteLane = ReturnType<typeof writeLane>;
package/dist/lane.js ADDED
@@ -0,0 +1,74 @@
1
+ import { realClock } from "./clock.js";
2
+ const MINUTE = 60_000;
3
+ /**
4
+ * A write lane for one device or bridge. `send(slot, body, write, merge)` resolves with the result of the send that
5
+ * carried its body: a write merged into a later one resolves (or rejects) with that later one. `merge(older, newer)`
6
+ * defaults to the newer body alone.
7
+ */
8
+ export function writeLane(options) {
9
+ if (options.perMinute !== undefined && !(options.perMinute >= 1))
10
+ throw new RangeError(`writeLane: perMinute must be at least 1, got ${options.perMinute}`);
11
+ const clock = options.clock ?? realClock;
12
+ const queue = []; // in order of each slot's first waiting write
13
+ const sent = new Map(); // send times per slot, the last minute's
14
+ let lastAt = -Infinity;
15
+ let running = false;
16
+ let wake = new AbortController();
17
+ const recent = (slot) => (sent.get(slot) ?? []).filter((t) => t > clock.now() - MINUTE);
18
+ function readyAt(slot) {
19
+ const times = recent(slot);
20
+ const capped = options.perMinute !== undefined && times.length >= options.perMinute
21
+ ? times[times.length - options.perMinute] + MINUTE
22
+ : -Infinity;
23
+ return Math.max(lastAt + options.gapMs, capped);
24
+ }
25
+ async function run() {
26
+ running = true;
27
+ while (queue.length) {
28
+ const now = clock.now();
29
+ const next = queue.find((p) => readyAt(p.slot) <= now);
30
+ if (!next) {
31
+ wake = new AbortController();
32
+ await clock.sleep(Math.min(...queue.map((p) => readyAt(p.slot))) - now, wake.signal);
33
+ continue;
34
+ }
35
+ queue.splice(queue.indexOf(next), 1);
36
+ lastAt = now;
37
+ sent.set(next.slot, [...recent(next.slot), now]);
38
+ try {
39
+ const result = await next.write(next.body);
40
+ for (const w of next.waiters)
41
+ w.resolve(result);
42
+ }
43
+ catch (error) {
44
+ for (const w of next.waiters)
45
+ w.reject(error);
46
+ }
47
+ }
48
+ running = false;
49
+ }
50
+ return {
51
+ send(slot, body, write, merge = (_, newer) => newer) {
52
+ return new Promise((resolve, reject) => {
53
+ const waiter = { resolve, reject };
54
+ const waiting = queue.find((p) => p.slot === slot);
55
+ if (waiting) {
56
+ waiting.body = merge(waiting.body, body);
57
+ waiting.write = write;
58
+ waiting.waiters.push(waiter);
59
+ return;
60
+ }
61
+ queue.push({
62
+ slot,
63
+ body,
64
+ write: write,
65
+ waiters: [waiter],
66
+ });
67
+ if (running)
68
+ wake.abort(); // a sleeping lane looks again: this slot may be ready sooner
69
+ else
70
+ void run();
71
+ });
72
+ },
73
+ };
74
+ }
@@ -0,0 +1,27 @@
1
+ export type Located = {
2
+ name: string;
3
+ latitude: number;
4
+ longitude: number;
5
+ };
6
+ /**
7
+ * The best match for `address`, or undefined when Nominatim finds none. Its usage policy requires a User-Agent that
8
+ * names the application, and allows one request a second: this is for a one-off lookup, not a loop.
9
+ */
10
+ export declare function geocode(address: string, options: {
11
+ fetch?: typeof fetch;
12
+ userAgent: string;
13
+ signal?: AbortSignal;
14
+ }): Promise<Located | undefined>;
15
+ /**
16
+ * A config's text with `location.latitude` and `.longitude` set and, when it has none, `.timezone`, through
17
+ * jsonc-parser's edits: comments and every other key stay. With no `location` (absent or commented out) a fresh one is
18
+ * added, and `fresh` says so; a commented-out one is left as it was.
19
+ */
20
+ export declare function applyLocation(text: string, at: {
21
+ latitude: number;
22
+ longitude: number;
23
+ }, timeZone: string): {
24
+ text: string;
25
+ fresh: boolean;
26
+ timezoneSet: boolean;
27
+ };