@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.
Files changed (217) hide show
  1. package/.env.example +25 -0
  2. package/CHANGELOG.md +25 -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 +426 -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 +11 -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 +745 -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 +267 -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 +195 -0
  92. package/dist/discover.js +446 -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 +45 -0
  98. package/dist/finders.js +173 -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 +39 -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 +26 -0
  137. package/skills/draw-home/SKILL.md +82 -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 +175 -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 +43 -0
  160. package/src/connectors/cast/codec.ts +126 -0
  161. package/src/connectors/cast/index.ts +551 -0
  162. package/src/connectors/cast/schemas.ts +101 -0
  163. package/src/connectors/cast/serve.ts +72 -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 +80 -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 +929 -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 +380 -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 +634 -0
  206. package/src/energy.ts +221 -0
  207. package/src/finders-entry.ts +2 -0
  208. package/src/finders.ts +247 -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
package/src/energy.ts ADDED
@@ -0,0 +1,221 @@
1
+ import type { AmberState, PriceInterval } from "./connectors/amber/client.ts";
2
+ import type { EnphaseState } from "./connectors/enphase/client.ts";
3
+ import type { EnphaseSnapshot } from "./connectors/enphase/index.ts";
4
+
5
+ /** The home's energy right now: power from Enphase, prices from Amber, and what the grid flow costs per hour. */
6
+ export type EnergyNow = {
7
+ solarW?: number;
8
+ solarPeakTodayW?: number; // the highest solarW read so far today (Enphase's day, since its peakSince)
9
+ usingW?: number;
10
+ gridW?: number; // positive importing, negative exporting
11
+ importCents?: number;
12
+ exportCents?: number; // what you are paid per kWh; below zero, exporting costs you
13
+ centsPerHour?: number; // positive costs you, negative earns you
14
+ renewables?: number;
15
+ priceDescriptor?: string; // Amber's descriptor for the import price ("extremelyLow" … "spike")
16
+ spike?: string; // Amber's spikeStatus: "none" | "potential" | "spike"
17
+ estimate?: boolean; // the current price can still move
18
+ negativePrice?: "paid to use" | "exporting at a loss";
19
+ flow?: "importing" | "exporting" | "balanced"; // undefined when gridW is; balanced within BALANCED_W, a display rule
20
+ surplusW?: number; // power going out to the grid, positive: -gridW when exporting, else 0
21
+ surplusCentsPerHour?: number; // what that export earns now at the export price (negative when exporting costs you)
22
+ };
23
+
24
+ /**
25
+ * Within this many watts either way the grid flow reads as "balanced": a display rule for the word and the animation,
26
+ * so a meter wobbling around zero does not flip between importing and exporting. `gridW` and `surplusW` stay as read.
27
+ */
28
+ export const BALANCED_W = 20;
29
+
30
+ /** The grid flow in a word, from the raw `gridW`. */
31
+ function flowOf(gridW?: number): EnergyNow["flow"] {
32
+ if (gridW === undefined) return undefined;
33
+ return Math.abs(gridW) <= BALANCED_W
34
+ ? "balanced"
35
+ : gridW > 0
36
+ ? "importing"
37
+ : "exporting";
38
+ }
39
+
40
+ /**
41
+ * `"paid to use"` when the import price is below zero, else `"exporting at a loss"` when the export price is (Amber's
42
+ * feed-in `perKwh` above zero), else undefined. Both can hold in one interval; the import side wins.
43
+ */
44
+ function negativePrice(prices?: AmberState): EnergyNow["negativePrice"] {
45
+ if (prices?.importCents !== undefined && prices.importCents < 0)
46
+ return "paid to use";
47
+ if (prices?.exportCents !== undefined && prices.exportCents < 0)
48
+ return "exporting at a loss";
49
+ return undefined;
50
+ }
51
+
52
+ /** One owner for the combined number, so every view (CLI, jaldi, the bot) shows the same thing. */
53
+ export function energyNow(
54
+ power?: EnphaseState & Partial<Pick<EnphaseSnapshot, "productionWPeakToday">>,
55
+ prices?: AmberState,
56
+ ): EnergyNow {
57
+ const gridW = power?.netW;
58
+ const surplusW = gridW === undefined ? undefined : gridW < 0 ? -gridW : 0;
59
+ const rate =
60
+ gridW === undefined
61
+ ? undefined
62
+ : gridW >= 0
63
+ ? prices?.importCents
64
+ : prices?.exportCents;
65
+ return {
66
+ solarW: power?.productionW,
67
+ solarPeakTodayW: power?.productionWPeakToday,
68
+ usingW: power?.consumptionW,
69
+ gridW,
70
+ importCents: prices?.importCents,
71
+ exportCents: prices?.exportCents,
72
+ centsPerHour:
73
+ gridW === undefined || rate === undefined
74
+ ? undefined
75
+ : (gridW / 1000) * rate, // export: negative kW × earned c/kWh = negative cost
76
+ renewables: prices?.renewables,
77
+ priceDescriptor: prices?.priceDescriptor,
78
+ spike: prices?.spike,
79
+ estimate: prices?.estimate,
80
+ negativePrice: negativePrice(prices),
81
+ flow: flowOf(gridW),
82
+ surplusW,
83
+ surplusCentsPerHour:
84
+ surplusW === undefined || prices?.exportCents === undefined
85
+ ? undefined
86
+ : (surplusW / 1000) * prices.exportCents,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * How brightly to draw the panels: `solarW` as a fraction of the array's rated `capacityW` when the config has it,
92
+ * else of today's peak so far, with which scale it is (they mean different things, so a drawing says which).
93
+ * Never clamped: above 1 is above the scale. Undefined when there is no solar reading or nothing to scale by.
94
+ */
95
+ export function solarLevel(
96
+ now: EnergyNow,
97
+ capacityW?: number,
98
+ ): { fraction: number; scale: "capacity" | "today's peak" } | undefined {
99
+ if (now.solarW === undefined) return undefined;
100
+ if (capacityW !== undefined && capacityW > 0)
101
+ return { fraction: now.solarW / capacityW, scale: "capacity" };
102
+ if (now.solarPeakTodayW !== undefined && now.solarPeakTodayW > 0)
103
+ return {
104
+ fraction: now.solarW / now.solarPeakTodayW,
105
+ scale: "today's peak",
106
+ };
107
+ return undefined;
108
+ }
109
+
110
+ /**
111
+ * The grid flow in words every view shares: "exporting 1.2 kW, earning 42 c/h", "importing 0.8 kW, costing 33 c/h",
112
+ * "balanced (-10 W)", or "" with no grid reading. Rounded for reading only; the numbers above stay as read.
113
+ */
114
+ export function gridText(now: EnergyNow): string {
115
+ const kw = (w: number) => `${(w / 1000).toFixed(1)} kW`;
116
+ const rate = (c: number | undefined, positive: string, negative: string) =>
117
+ c === undefined
118
+ ? ""
119
+ : `, ${c >= 0 ? positive : negative} ${Math.round(Math.abs(c))} c/h`;
120
+ if (now.flow === "exporting" && now.surplusW !== undefined)
121
+ return `exporting ${kw(now.surplusW)}${rate(now.surplusCentsPerHour, "earning", "costing")}`;
122
+ if (now.flow === "importing" && now.gridW !== undefined)
123
+ return `importing ${kw(now.gridW)}${rate(now.centsPerHour, "costing", "earning")}`;
124
+ return now.flow === "balanced" ? `balanced (${now.gridW} W)` : "";
125
+ }
126
+
127
+ /** A run of the forecast and its time-weighted averages: import price, feed-in price and the grid's renewable share. */
128
+ export type PriceWindow = {
129
+ start: string;
130
+ end: string;
131
+ importCents?: number;
132
+ exportCents?: number;
133
+ renewables?: number;
134
+ };
135
+
136
+ const HOUR_MS = 3_600_000;
137
+
138
+ /**
139
+ * The best `hours` to run something in, from Amber's forecast: the cheapest by import price (Amber's `perKwh`), or the
140
+ * greenest by the grid's renewable share. The window runs over unbroken intervals that each have the number it is judged by,
141
+ * starting no earlier than `from` (pass the clock's now; it defaults to the forecast's first interval, which began up to
142
+ * 5 minutes ago). Prices are flat within an interval, so the best window starts or ends on an interval's edge: both are
143
+ * tried. Averages weigh each interval by the time it covers; the earliest wins a tie. Undefined when nothing fits.
144
+ */
145
+ export function bestWindow(
146
+ forecast: PriceInterval[] | undefined,
147
+ hours: number,
148
+ options: { by?: "price" | "renewables"; from?: number } = {},
149
+ ): PriceWindow | undefined {
150
+ if (!(hours > 0 && Number.isFinite(hours)))
151
+ throw new RangeError(`bestWindow: hours must be above 0, got ${hours}`);
152
+ const by = options.by ?? "price";
153
+ const rows = (forecast ?? []).map((r) => ({
154
+ ...r,
155
+ t0: Date.parse(r.start),
156
+ t1: Date.parse(r.end),
157
+ }));
158
+ const from = options.from ?? rows[0]?.t0 ?? 0;
159
+ const length = Math.round(hours * HOUR_MS); // whole ms, so the covered time adds up to it exactly
160
+ const starts = [
161
+ ...new Set(
162
+ rows
163
+ .flatMap((r) => [Math.max(r.t0, from), r.t1 - length])
164
+ .filter((t) => t >= from),
165
+ ),
166
+ ].sort((a, b) => a - b);
167
+ let best: PriceWindow | undefined;
168
+ let bestScore = Infinity;
169
+ for (const start of starts) {
170
+ const window = windowAt(rows, start, length, by);
171
+ const score =
172
+ window && (by === "price" ? window.importCents! : -window.renewables!);
173
+ if (window && score! < bestScore) ((best = window), (bestScore = score!));
174
+ }
175
+ return best;
176
+ }
177
+
178
+ /** The window of `length` ms from `start`, or undefined when an interval in it is missing, or lacks the number `by` needs. Any other average is left out unless every interval has it. */
179
+ function windowAt(
180
+ rows: (PriceInterval & { t0: number; t1: number })[],
181
+ start: number,
182
+ length: number,
183
+ by: "price" | "renewables",
184
+ ): PriceWindow | undefined {
185
+ const end = start + length;
186
+ const sum = {
187
+ importCents: 0,
188
+ exportCents: 0,
189
+ renewables: 0,
190
+ importMs: 0,
191
+ exportMs: 0,
192
+ renewablesMs: 0,
193
+ };
194
+ let at = start;
195
+ for (const r of rows) {
196
+ if (r.t1 <= at) continue;
197
+ if (at >= end) break;
198
+ if (
199
+ r.t0 > at ||
200
+ (by === "price" ? r.importCents : r.renewables) === undefined
201
+ )
202
+ return undefined; // a gap, or no number to judge by
203
+ const ms = Math.min(r.t1, end) - at;
204
+ if (r.importCents !== undefined)
205
+ ((sum.importCents += r.importCents * ms), (sum.importMs += ms));
206
+ if (r.exportCents !== undefined)
207
+ ((sum.exportCents += r.exportCents * ms), (sum.exportMs += ms));
208
+ if (r.renewables !== undefined)
209
+ ((sum.renewables += r.renewables * ms), (sum.renewablesMs += ms));
210
+ at += ms;
211
+ }
212
+ if (at < end) return undefined;
213
+ return {
214
+ start: new Date(start).toISOString(),
215
+ end: new Date(end).toISOString(),
216
+ importCents: sum.importMs === length ? sum.importCents / length : undefined,
217
+ exportCents: sum.exportMs === length ? sum.exportCents / length : undefined,
218
+ renewables:
219
+ sum.renewablesMs === length ? sum.renewables / length : undefined,
220
+ };
221
+ }
@@ -0,0 +1,2 @@
1
+ // `@khalido/home/finders`: what a Node consumer passes to `loadHome`. The rest of finders.ts is the CLI's.
2
+ export { FINDERS, fileCache } from "./finders.ts";
package/src/finders.ts ADDED
@@ -0,0 +1,247 @@
1
+ import { spawn } from "node:child_process";
2
+ import {
3
+ chmodSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ renameSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import { dirname } from "node:path";
11
+ import { createSocket } from "node:dgram";
12
+ import { lookup } from "node:dns/promises";
13
+ import { printersFound } from "./connectors/printers/found.ts";
14
+ import {
15
+ type AddressCache,
16
+ type CachedAddress,
17
+ type Connector,
18
+ type Finder,
19
+ type Found,
20
+ parseAvahi,
21
+ parseAvahiBrowse,
22
+ parseBrowse,
23
+ parseResolve,
24
+ } from "./discover.ts";
25
+
26
+ // Each connector's discovery on the network: mDNS through the system's own daemon (macOS `dns-sd`, Linux `avahi-browse`),
27
+ // and Daikin's UDP broadcast. Node APIs (child_process, dgram, dns), so this file sits outside the runtime-neutral build like
28
+ // weather/ftp.ts and cast/tls.ts; `home`, jaldi's sidecar and chota-bot import it from `@khalido/home/finders` and pass `FINDERS`
29
+ // to `loadHome`. Runs in Bun and Node. The system daemon keeps an mDNS cache, so answers come from memory in milliseconds and
30
+ // 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)
31
+ // does not slow a scan. Without the tool the mDNS finders throw, naming it, rather than "nothing answered".
32
+ // `fileCache` keeps the addresses discovery found in a file between runs, so the next run connects straight away.
33
+
34
+ /**
35
+ * 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`
36
+ * says the answer is in, or `quietMs` after the last output, or at `maxMs`. The system's mDNS cache usually answers in
37
+ * 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.
38
+ * `command` is for tests.
39
+ */
40
+ export function dnsSd(
41
+ argv: string[],
42
+ options: {
43
+ maxMs?: number;
44
+ quietMs?: number;
45
+ done?: (out: string) => boolean;
46
+ command?: string;
47
+ install?: string;
48
+ } = {},
49
+ ): Promise<string> {
50
+ const { maxMs = 1500, quietMs, done, command = "dns-sd", install } = options;
51
+ return new Promise((resolve, reject) => {
52
+ let out = "";
53
+ const proc = spawn(command, argv, { stdio: ["ignore", "pipe", "ignore"] });
54
+ const stop = () => proc.kill();
55
+ const timer = setTimeout(stop, maxMs);
56
+ let quiet: ReturnType<typeof setTimeout> | undefined;
57
+ proc.stdout.setEncoding("utf8").on("data", (d: string) => {
58
+ out += d;
59
+ if (done?.(out)) return stop();
60
+ if (quietMs !== undefined)
61
+ (clearTimeout(quiet), (quiet = setTimeout(stop, quietMs)));
62
+ });
63
+ const finish = () => (
64
+ clearTimeout(timer),
65
+ clearTimeout(quiet),
66
+ resolve(out)
67
+ );
68
+ proc.on("error", (error: NodeJS.ErrnoException) => {
69
+ (clearTimeout(timer), clearTimeout(quiet));
70
+ reject(
71
+ new Error(
72
+ error.code === "ENOENT"
73
+ ? `${command} not found; mDNS discovery needs it${install ? ` (${install})` : ""}`
74
+ : `${command}: ${error.message}`,
75
+ ),
76
+ );
77
+ });
78
+ proc.on("close", finish);
79
+ });
80
+ }
81
+
82
+ /** Linux asks avahi-daemon, which Raspberry Pi OS runs by default; its command-line tool is a separate package. */
83
+ const LINUX = process.platform === "linux";
84
+ /** `avahi-browse` prints what avahi-daemon's cache holds, resolves it, and exits (`--terminate`); `maxMs` caps a slow resolve. */
85
+ const avahi = (args: string[]) =>
86
+ dnsSd(["--parsable", "--terminate", ...args], {
87
+ command: "avahi-browse",
88
+ install: "apt install avahi-utils",
89
+ maxMs: 3000,
90
+ });
91
+
92
+ /** Instance names of one mDNS service type: what has answered, once answers stop for 200 ms. */
93
+ export const browse = async (type: string) =>
94
+ LINUX
95
+ ? parseAvahiBrowse(await avahi([type]))
96
+ : parseBrowse(await dnsSd(["-B", type], { quietMs: 200 }));
97
+
98
+ /** A resolve is in once its TXT line (the line after "can be reached at") is complete. */
99
+ const resolved = (out: string) =>
100
+ /can be reached at [^\n]*\n[^\n]*\n/.test(out);
101
+
102
+ /** Every instance of one mDNS type, resolved to an IPv4 address (the config keeps addresses, not names), its port and its TXT record. */
103
+ export async function mdnsResolve(type: string) {
104
+ if (LINUX) return parseAvahi(await avahi(["--resolve", type]));
105
+ const found = await Promise.all(
106
+ (await browse(type)).map(async (name) => {
107
+ const r = parseResolve(
108
+ await dnsSd(["-L", name, type], { done: resolved }),
109
+ );
110
+ const host = r
111
+ ? await lookup(r.hostname, { family: 4 }).then(
112
+ (x) => x.address,
113
+ () => undefined,
114
+ )
115
+ : undefined;
116
+ return r && host ? [{ ...r, host, instance: name }] : [];
117
+ }),
118
+ );
119
+ return found.flat();
120
+ }
121
+
122
+ /** Every device of one mDNS type with its identity from a TXT key, named by its mDNS instance. */
123
+ const mdnsFind = async (
124
+ type: string,
125
+ txtKey: string,
126
+ connector: Connector,
127
+ ): Promise<Found[]> =>
128
+ (await mdnsResolve(type)).flatMap((r) =>
129
+ r.txt[txtKey]
130
+ ? [{ connector, id: r.txt[txtKey]!, host: r.host, name: r.instance }]
131
+ : [],
132
+ );
133
+
134
+ /** Daikin's `key=value,key=value` answers, percent-decoded. */
135
+ export const daikinFields = (text: string) =>
136
+ Object.fromEntries(
137
+ text
138
+ .trim()
139
+ .split(",")
140
+ .map((kv) => kv.split("="))
141
+ .map(([k, v = ""]) => [k, decoded(v)]),
142
+ );
143
+
144
+ /** A percent-encoded value, or as sent when it is not valid encoding (a stray "%"): one odd field never fails the read. */
145
+ function decoded(v: string): string {
146
+ try {
147
+ return decodeURIComponent(v);
148
+ } catch {
149
+ return v;
150
+ }
151
+ }
152
+
153
+ /** Broadcast pydaikin's discovery packet (UDP 30000 → 30050) and collect who answers within `ms`. */
154
+ export function findDaikins(
155
+ ms = 1500,
156
+ ): Promise<{ ip: string; fields: Record<string, string> }[]> {
157
+ return new Promise((resolve) => {
158
+ const found: { ip: string; fields: Record<string, string> }[] = [];
159
+ const sock = createSocket({ type: "udp4", reuseAddr: true });
160
+ sock.on("message", (msg, from) =>
161
+ found.push({ ip: from.address, fields: daikinFields(msg.toString()) }),
162
+ );
163
+ sock.on("error", () => (sock.close(), resolve(found)));
164
+ sock.bind(30000, () => {
165
+ sock.setBroadcast(true);
166
+ sock.send("DAIKIN_UDP/common/basic_info", 30050, "255.255.255.255");
167
+ setTimeout(() => (sock.close(), resolve(found)), ms);
168
+ });
169
+ });
170
+ }
171
+
172
+ /** A Daikin's broadcast answer as a found device, by its MAC. */
173
+ export const daikinFound = (d: {
174
+ ip: string;
175
+ fields: Record<string, string>;
176
+ }): Found[] =>
177
+ d.fields.mac
178
+ ? [
179
+ {
180
+ connector: "daikin",
181
+ id: d.fields.mac,
182
+ host: d.ip,
183
+ name: d.fields.name,
184
+ },
185
+ ]
186
+ : [];
187
+
188
+ /** Each connector's discovery, as `loadHome` (rescans, and every Cast address) and `home discover` use it. */
189
+ export const FINDERS: Record<Connector, Finder> = {
190
+ hue: () => mdnsFind("_hue._tcp", "bridgeid", "hue"),
191
+ enphase: () => mdnsFind("_enphase-envoy._tcp", "serialnum", "enphase"),
192
+ daikin: async () => (await findDaikins()).flatMap(daikinFound),
193
+ cast: async () =>
194
+ (await mdnsResolve("_googlecast._tcp")).flatMap(({ txt, host, port }) =>
195
+ txt.id
196
+ ? [
197
+ {
198
+ connector: "cast" as const,
199
+ id: txt.id,
200
+ host,
201
+ port,
202
+ name: txt.fn,
203
+ model: txt.md,
204
+ group: txt.md === "Google Cast Group",
205
+ },
206
+ ]
207
+ : [],
208
+ ),
209
+ printers: async () => {
210
+ const [ipp, ipps] = await Promise.all([
211
+ mdnsResolve("_ipp._tcp"),
212
+ mdnsResolve("_ipps._tcp"),
213
+ ]);
214
+ return printersFound(ipp, ipps);
215
+ },
216
+ };
217
+
218
+ /**
219
+ * An address cache in one JSON file, owner-only (0600, its folder 0700), replaced atomically on each write. A missing
220
+ * file is an empty cache; so is a file that does not parse, which the next write replaces (a cache, not KO's data).
221
+ */
222
+ export function fileCache(path: string): AddressCache {
223
+ return {
224
+ read(): Record<string, CachedAddress> {
225
+ try {
226
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
227
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
228
+ ? (parsed as Record<string, CachedAddress>)
229
+ : {};
230
+ } catch {
231
+ return {};
232
+ }
233
+ },
234
+ write: (entries) =>
235
+ writePrivate(path, `${JSON.stringify(entries, null, 2)}\n`),
236
+ };
237
+ }
238
+
239
+ /** Write a file only its owner can read (0600, its folder 0700), replaced atomically: the address cache, the Enphase token. */
240
+ export function writePrivate(path: string, text: string): void {
241
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
242
+ const tmp = `${path}.tmp`;
243
+ rmSync(tmp, { force: true }); // so the mode applies to a fresh file
244
+ writeFileSync(tmp, text, { mode: 0o600 });
245
+ chmodSync(tmp, 0o600);
246
+ renameSync(tmp, path);
247
+ }
package/src/health.ts ADDED
@@ -0,0 +1,74 @@
1
+ import type { Part, Status } from "./home.ts";
2
+ import { MANIFESTS } from "./manifests.ts";
3
+
4
+ /**
5
+ * How one integration is doing, judged by time: `ok`, `stale` (no good read for a while, last values still shown) or `down`.
6
+ * The thresholds come with it, so a page can say "stale after 4 min". Push connectors (Hue, Cast) have no poll to be late
7
+ * for: never stale, down when not connected for `PUSH_DOWN_MS`.
8
+ */
9
+ export type Health = {
10
+ level: "ok" | "stale" | "down";
11
+ status: Status;
12
+ lastGoodAt?: string;
13
+ downSince?: string;
14
+ staleAfterMs?: number;
15
+ downAfterMs: number;
16
+ detail?: string;
17
+ };
18
+
19
+ /** 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, */
20
+ export const STALE_POLLS = 2.5;
21
+ /** and down after this many polls not connected. */
22
+ export const DOWN_POLLS = 3;
23
+ /** A push connector is down after this long not connected: a reconnect takes seconds, a backoff at most a minute. */
24
+ export const PUSH_DOWN_MS = 120_000;
25
+
26
+ const POLL_SECONDS: Record<string, number | undefined> = Object.fromEntries(
27
+ MANIFESTS.map((m) => [m.name, m.pollSeconds]),
28
+ );
29
+
30
+ /**
31
+ * Each integration's health at `now` (ms), from the times `createHome` stamps on its part. `pollSeconds` overrides a
32
+ * 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.
33
+ */
34
+ export function health(
35
+ snapshot: Record<string, Part | undefined>,
36
+ now: number,
37
+ options: { pollSeconds?: Record<string, number> } = {},
38
+ ): Record<string, Health> {
39
+ const poll = (name: string) =>
40
+ options.pollSeconds?.[name] ?? POLL_SECONDS[name];
41
+ return Object.fromEntries(
42
+ Object.entries(snapshot).flatMap(([name, part]) =>
43
+ part ? [[name, healthOf(part, now, poll(name))]] : [],
44
+ ),
45
+ );
46
+ }
47
+
48
+ function healthOf(
49
+ part: Part,
50
+ now: number,
51
+ pollSeconds: number | undefined,
52
+ ): Health {
53
+ const staleAfterMs =
54
+ pollSeconds === undefined ? undefined : STALE_POLLS * pollSeconds * 1000;
55
+ const downAfterMs =
56
+ pollSeconds === undefined ? PUSH_DOWN_MS : DOWN_POLLS * pollSeconds * 1000;
57
+ const age = (iso?: string) =>
58
+ iso === undefined ? undefined : now - Date.parse(iso);
59
+ const down =
60
+ part.status === "unavailable" ||
61
+ (part.status !== "connected" && (age(part.downSince) ?? 0) >= downAfterMs);
62
+ const goodAge = age(part.lastGoodAt) ?? age(part.downSince); // never good: as old as the wait for it
63
+ const stale = staleAfterMs !== undefined && (goodAge ?? 0) >= staleAfterMs;
64
+ const { status, lastGoodAt, downSince, detail } = part;
65
+ return {
66
+ level: down ? "down" : stale ? "stale" : "ok",
67
+ status,
68
+ lastGoodAt,
69
+ downSince,
70
+ staleAfterMs,
71
+ downAfterMs,
72
+ detail,
73
+ };
74
+ }