@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,149 @@
1
+ /** Sent on every request, so each service can see who is calling rather than a runtime's default string. */
2
+ export declare const USER_AGENT = "home (+https://github.com/khalido/home.ts)";
3
+ export type Place = {
4
+ latitude: number;
5
+ longitude: number;
6
+ };
7
+ /** The one polling number for observations and sky: every 15 minutes, KO's call. Forecasts follow their issue times. */
8
+ export declare const DEFAULT_POLL_MINUTES = 15;
9
+ /** The next read slot after `at`: every `pollMinutes` from 5 minutes past the hour when `pollMinutes` divides 60 (else from the epoch, plus 5 minutes). */
10
+ export declare function nextPoll(at: number, pollMinutes?: number): number;
11
+ /** What a source is asked to read: where, at what instant (epoch ms), the polling interval to schedule by, and `force` to read everything now. */
12
+ export type ReadRequest = {
13
+ place: Place;
14
+ at: number;
15
+ pollMinutes: number;
16
+ force?: boolean;
17
+ signal?: AbortSignal;
18
+ };
19
+ /** Every slice says who made it, whether it is a model rather than an instrument, the time it is for, and when it was fetched. */
20
+ export type SliceMeta = {
21
+ source: string;
22
+ modelled: boolean;
23
+ at: string;
24
+ fetchedAt: string;
25
+ };
26
+ /** Outside now. Speeds in km/h, direction where the wind comes from. */
27
+ export type WeatherNow = SliceMeta & {
28
+ tempC?: number;
29
+ apparentC?: number;
30
+ humidity?: number;
31
+ windKmh?: number;
32
+ windDir?: string;
33
+ windDeg?: number;
34
+ gustKmh?: number;
35
+ rainMm?: number;
36
+ rainFrom?: string;
37
+ rainTo?: string;
38
+ station?: {
39
+ name: string;
40
+ distanceKm: number;
41
+ };
42
+ };
43
+ /** One forecast day. Any field can be missing: after BOM's afternoon issue, today has no min, max or rain range. */
44
+ export type ForecastDay = {
45
+ date: string;
46
+ minC?: number;
47
+ maxC?: number;
48
+ rainChance?: number;
49
+ rainMm?: number;
50
+ rainRangeMm?: [number, number];
51
+ text?: string;
52
+ code?: string;
53
+ };
54
+ export type WeatherForecast = SliceMeta & {
55
+ issuedAt?: string;
56
+ today?: ForecastDay;
57
+ tomorrow?: ForecastDay;
58
+ };
59
+ /** Cloud, UV and sunlight on the ground. `radiationWm2` is shortwave (global horizontal) irradiance. */
60
+ export type SkyHour = {
61
+ at: string;
62
+ cloudPct?: number;
63
+ uvIndex?: number;
64
+ radiationWm2?: number;
65
+ };
66
+ export type WeatherSky = SliceMeta & Omit<SkyHour, "at"> & {
67
+ hours: SkyHour[];
68
+ };
69
+ /** One stretch of the minute-by-minute rain forecast. `from` and `to` are UTC ISO; `type` is the source's own (`NONE`, `RAIN`, ...). */
70
+ export type RainSegment = {
71
+ from: string;
72
+ to: string;
73
+ type?: string;
74
+ chance?: number;
75
+ mm?: number;
76
+ intensity?: string;
77
+ };
78
+ /** Rain minute by minute, up to `RAIN_HORIZON_MINUTES` ahead. `at` is when the forecast starts. Ask `rainSoon` what it means. */
79
+ export type WeatherRain = SliceMeta & {
80
+ segments: RainSegment[];
81
+ };
82
+ /** How far ahead the rain forecast is kept and read: two hours, KO's call. */
83
+ export declare const RAIN_HORIZON_MINUTES = 120;
84
+ /** Weather in words, mildest first, so an hour from any source reads the same and a stretch of hours takes its worst. */
85
+ export declare const CONDITIONS: readonly ["clear", "partly cloudy", "cloudy", "fog", "drizzle", "showers", "rain", "snow", "storm"];
86
+ export type Condition = (typeof CONDITIONS)[number];
87
+ /** One forecast hour, starting at `at`. `rainChance` in percent, `rainMm` over the hour, `code` the source's own, `condition` it in a word. */
88
+ export type ForecastHour = {
89
+ at: string;
90
+ tempC?: number;
91
+ apparentC?: number;
92
+ rainChance?: number;
93
+ rainMm?: number;
94
+ windKmh?: number;
95
+ gustKmh?: number;
96
+ cloudPct?: number;
97
+ uvIndex?: number;
98
+ code?: string;
99
+ condition?: Condition;
100
+ };
101
+ /** The hourly forecast for today and tomorrow, from the home's midnight. `at` is when it was read. Ask `dayBlocks` for mornings and evenings. */
102
+ export type WeatherHourly = SliceMeta & {
103
+ hours: ForecastHour[];
104
+ };
105
+ export type SliceName = "now" | "forecast" | "hourly" | "sky" | "rain";
106
+ export type Slices = {
107
+ now?: WeatherNow;
108
+ forecast?: WeatherForecast;
109
+ hourly?: WeatherHourly;
110
+ sky?: WeatherSky;
111
+ rain?: WeatherRain;
112
+ };
113
+ /** One read of a source: the slices it has (last good ones included), when to call again, and anything to report. */
114
+ export type SourceReading = Slices & {
115
+ nextAt: number;
116
+ problem?: string;
117
+ anomaly?: string;
118
+ stop?: string;
119
+ };
120
+ /**
121
+ * A weather provider. `owns` names the slices it can fill and how old each may get (by its `at`, beyond one polling
122
+ * interval) before the next source's is used. `read` fetches what is due and returns every slice it holds; it throws only on a bug.
123
+ */
124
+ export type WeatherSource = {
125
+ name: string;
126
+ owns: Partial<Record<SliceName, number>>;
127
+ minPollMinutes?: number;
128
+ read(request: ReadRequest): Promise<SourceReading>;
129
+ };
130
+ /** The interval a source actually reads at: the shared `pollMinutes`, or its own floor if that is longer. */
131
+ export declare const sourcePollMinutes: (source: WeatherSource, pollMinutes: number) => number;
132
+ /** Hourly: how often forecast and hourly-sky reads happen where nothing documented changes faster. */
133
+ export declare const HOURLY_MINUTES = 60;
134
+ /**
135
+ * One owner for which source fills each slice, so the integration and `home status` show the same thing.
136
+ * `readings` are in preference order; a slice older than its source's `owns` age plus one polling interval falls through.
137
+ */
138
+ export declare function mergeWeather(readings: {
139
+ source: WeatherSource;
140
+ reading?: SourceReading;
141
+ }[], at: number, pollMinutes?: number): Slices;
142
+ /** GET a URL as text (BOM's XML), with the timeout and user agent every source sends. A non-ok status throws `ConnectorError` with it. */
143
+ export declare function getText(fetcher: typeof fetch, url: string, signal?: AbortSignal): Promise<string>;
144
+ /** A number from a string the source sent, or undefined plus a note in `odd`. Never rounds or clamps. */
145
+ export declare function num(value: unknown, what: string, odd: string[]): number | undefined;
146
+ /** A source's timestamp as UTC ISO, or undefined when it does not parse: one odd time never sinks a whole read. */
147
+ export declare const iso: (s: string | undefined) => string | undefined;
148
+ /** The local calendar date (YYYY-MM-DD) of an instant at a UTC offset in seconds. */
149
+ export declare const localDate: (at: number, offsetSeconds: number) => string;
@@ -0,0 +1,77 @@
1
+ import { ConnectorError, fetchTimeout } from "../../connector.js";
2
+ // The one place a weather provider plugs in: a `WeatherSource` returns slices, and `mergeWeather` picks one per slice.
3
+ /** Sent on every request, so each service can see who is calling rather than a runtime's default string. */
4
+ export const USER_AGENT = "home (+https://github.com/khalido/home.ts)";
5
+ /** The one polling number for observations and sky: every 15 minutes, KO's call. Forecasts follow their issue times. */
6
+ export const DEFAULT_POLL_MINUTES = 15;
7
+ /** Reads land this far past each slot, so BOM's 10-minutely file (sent about 3 minutes past) is there: :05, :20, :35, :50. */
8
+ const SLOT_OFFSET_MS = 5 * 60_000;
9
+ /** The next read slot after `at`: every `pollMinutes` from 5 minutes past the hour when `pollMinutes` divides 60 (else from the epoch, plus 5 minutes). */
10
+ export function nextPoll(at, pollMinutes = DEFAULT_POLL_MINUTES) {
11
+ const step = pollMinutes * 60_000;
12
+ return (Math.floor((at - SLOT_OFFSET_MS) / step) * step + step + SLOT_OFFSET_MS);
13
+ }
14
+ /** How far ahead the rain forecast is kept and read: two hours, KO's call. */
15
+ export const RAIN_HORIZON_MINUTES = 120;
16
+ /** Weather in words, mildest first, so an hour from any source reads the same and a stretch of hours takes its worst. */
17
+ export const CONDITIONS = [
18
+ "clear",
19
+ "partly cloudy",
20
+ "cloudy",
21
+ "fog",
22
+ "drizzle",
23
+ "showers",
24
+ "rain",
25
+ "snow",
26
+ "storm",
27
+ ];
28
+ /** The interval a source actually reads at: the shared `pollMinutes`, or its own floor if that is longer. */
29
+ export const sourcePollMinutes = (source, pollMinutes) => Math.max(pollMinutes, source.minPollMinutes ?? 0);
30
+ /** Hourly: how often forecast and hourly-sky reads happen where nothing documented changes faster. */
31
+ export const HOURLY_MINUTES = 60;
32
+ /**
33
+ * One owner for which source fills each slice, so the integration and `home status` show the same thing.
34
+ * `readings` are in preference order; a slice older than its source's `owns` age plus one polling interval falls through.
35
+ */
36
+ export function mergeWeather(readings, at, pollMinutes = DEFAULT_POLL_MINUTES) {
37
+ const pick = (name) => {
38
+ for (const { source, reading } of readings) {
39
+ const slice = reading?.[name];
40
+ const maxAge = source.owns[name];
41
+ if (slice &&
42
+ maxAge !== undefined &&
43
+ at - Date.parse(slice.at) <=
44
+ maxAge + sourcePollMinutes(source, pollMinutes) * 60_000)
45
+ return slice;
46
+ }
47
+ return undefined;
48
+ };
49
+ return {
50
+ now: pick("now"),
51
+ forecast: pick("forecast"),
52
+ hourly: pick("hourly"),
53
+ sky: pick("sky"),
54
+ rain: pick("rain"),
55
+ };
56
+ }
57
+ /** GET a URL as text (BOM's XML), with the timeout and user agent every source sends. A non-ok status throws `ConnectorError` with it. */
58
+ export async function getText(fetcher, url, signal) {
59
+ const res = await fetchTimeout(fetcher, url, { headers: { "user-agent": USER_AGENT } }, { signal });
60
+ if (!res.ok)
61
+ throw new ConnectorError(`HTTP ${res.status} on GET ${new URL(url).pathname}`, { status: res.status });
62
+ return res.text();
63
+ }
64
+ /** A number from a string the source sent, or undefined plus a note in `odd`. Never rounds or clamps. */
65
+ export function num(value, what, odd) {
66
+ if (value === undefined || value === null || value === "")
67
+ return undefined;
68
+ const n = typeof value === "number" ? value : Number(value);
69
+ if (Number.isFinite(n))
70
+ return n;
71
+ odd.push(`${what} is not a number: ${String(value)}`);
72
+ return undefined;
73
+ }
74
+ /** A source's timestamp as UTC ISO, or undefined when it does not parse: one odd time never sinks a whole read. */
75
+ export const iso = (s) => s && !Number.isNaN(Date.parse(s)) ? new Date(s).toISOString() : undefined;
76
+ /** The local calendar date (YYYY-MM-DD) of an instant at a UTC offset in seconds. */
77
+ export const localDate = (at, offsetSeconds) => new Date(at + offsetSeconds * 1000).toISOString().slice(0, 10);
@@ -0,0 +1,48 @@
1
+ /** The next of each event after `at`: today's while it is still ahead, else tomorrow's; missing when that day has none (polar). */
2
+ export type SunNext = {
3
+ dawn?: string;
4
+ sunrise?: string;
5
+ sunset?: string;
6
+ dusk?: string;
7
+ };
8
+ /**
9
+ * The sun at a place and instant, computed locally (suncalc 2: degrees, azimuth clockwise from north). No network.
10
+ * Times are ISO instants for the local solar day containing `at` (the one whose solar noon is nearest); any can be
11
+ * missing at high latitudes when the sun never reaches that altitude.
12
+ *
13
+ * `sunNow(latitude, longitude, now)` is the owner. The weather snapshot holds one recomputed every 5 minutes, so its
14
+ * position, `rising`, `daylight` and `next` can be that old; a consumer that shows them calls `sunNow` with its own clock,
15
+ * as `home status` does.
16
+ */
17
+ export type SunNow = {
18
+ at: string;
19
+ nightEnd?: string;
20
+ dawn?: string;
21
+ sunrise?: string;
22
+ goldenHourEnd?: string;
23
+ solarNoon: string;
24
+ goldenHour?: string;
25
+ sunset?: string;
26
+ dusk?: string;
27
+ night?: string;
28
+ azimuthDeg: number;
29
+ altitudeDeg: number;
30
+ rising: boolean;
31
+ daylight: boolean;
32
+ next: SunNext;
33
+ alwaysUp?: boolean;
34
+ alwaysDown?: boolean;
35
+ };
36
+ /** One owner for the sun's numbers. `at` is epoch ms. */
37
+ export declare function sunNow(latitude: number, longitude: number, at: number): SunNow;
38
+ /** How far a source's sunrise or sunset may be from the computed one before it is worth saying. */
39
+ export declare const SUN_TOLERANCE_MS: number;
40
+ /**
41
+ * A source's sunrise and sunset checked against the computed sun, which stays the owner: a note per event more than
42
+ * two minutes out, else undefined. Each is compared with the nearest computed event of the solar days either side of it,
43
+ * so near solar midnight at high latitude, where the solar day containing it can be the neighbouring one, it still pairs.
44
+ */
45
+ export declare function sunDisagrees(source: string, latitude: number, longitude: number, reported: {
46
+ sunrise?: string;
47
+ sunset?: string;
48
+ }): string | undefined;
@@ -0,0 +1,65 @@
1
+ import { getPosition, getTimes } from "suncalc";
2
+ const DAY_MS = 86_400_000;
3
+ const isoOf = (d) => d?.toISOString();
4
+ /** One owner for the sun's numbers. `at` is epoch ms. */
5
+ export function sunNow(latitude, longitude, at) {
6
+ const date = new Date(at);
7
+ const times = getTimes(date, latitude, longitude);
8
+ const tomorrow = getTimes(new Date(at + DAY_MS), latitude, longitude);
9
+ const { azimuth, altitude } = getPosition(date, latitude, longitude);
10
+ const next = (name) => {
11
+ const today = times[name];
12
+ return today && today.getTime() > at
13
+ ? today.toISOString()
14
+ : isoOf(tomorrow[name]);
15
+ };
16
+ return {
17
+ at: date.toISOString(),
18
+ nightEnd: isoOf(times.nightEnd),
19
+ dawn: isoOf(times.dawn),
20
+ sunrise: isoOf(times.sunrise),
21
+ goldenHourEnd: isoOf(times.goldenHourEnd),
22
+ solarNoon: times.solarNoon.toISOString(),
23
+ goldenHour: isoOf(times.goldenHour),
24
+ sunset: isoOf(times.sunset),
25
+ dusk: isoOf(times.dusk),
26
+ night: isoOf(times.night),
27
+ azimuthDeg: azimuth,
28
+ altitudeDeg: altitude,
29
+ rising: at < times.solarNoon.getTime(),
30
+ daylight: altitude > 0,
31
+ next: {
32
+ dawn: next("dawn"),
33
+ sunrise: next("sunrise"),
34
+ sunset: next("sunset"),
35
+ dusk: next("dusk"),
36
+ },
37
+ alwaysUp: times.alwaysUp,
38
+ alwaysDown: times.alwaysDown,
39
+ };
40
+ }
41
+ /** How far a source's sunrise or sunset may be from the computed one before it is worth saying. */
42
+ export const SUN_TOLERANCE_MS = 2 * 60_000;
43
+ /**
44
+ * A source's sunrise and sunset checked against the computed sun, which stays the owner: a note per event more than
45
+ * two minutes out, else undefined. Each is compared with the nearest computed event of the solar days either side of it,
46
+ * so near solar midnight at high latitude, where the solar day containing it can be the neighbouring one, it still pairs.
47
+ */
48
+ export function sunDisagrees(source, latitude, longitude, reported) {
49
+ const notes = ["sunrise", "sunset"].flatMap((name) => {
50
+ const t = Date.parse(reported[name] ?? "");
51
+ if (Number.isNaN(t))
52
+ return [];
53
+ const nearest = [t - DAY_MS, t, t + DAY_MS]
54
+ .map((day) => sunNow(latitude, longitude, day)[name])
55
+ .filter((c) => c !== undefined)
56
+ .map((c) => ({ computed: c, offMs: Math.abs(t - Date.parse(c)) }))
57
+ .sort((a, b) => a.offMs - b.offMs)[0];
58
+ if (nearest && nearest.offMs <= SUN_TOLERANCE_MS)
59
+ return [];
60
+ return [
61
+ `${source} ${name} ${reported[name]} ${nearest ? `is ${(nearest.offMs / 60_000).toFixed(1)} min from the computed ${nearest.computed}` : "on a day the computed sun has none"}`,
62
+ ];
63
+ });
64
+ return notes.length ? notes.join("; ") : undefined;
65
+ }
@@ -0,0 +1,197 @@
1
+ import type { Clock } from "./clock.ts";
2
+ import type { HomeConfig } from "./config.ts";
3
+ /** mDNS service types worth looking for at home, and what this package does with each. Extend the list as needed. */
4
+ export declare const KNOWN_SERVICES: Record<string, {
5
+ what: string;
6
+ connector?: string;
7
+ }>;
8
+ /** A device a discovery answer named: its connector, the identity it reported, and where it answered; Cast adds its name, model, port and whether it is a group; a printer adds its port, whether it answered on `_ipps._tcp`, and its TXT record. */
9
+ export type Found = {
10
+ connector: Connector;
11
+ id: string;
12
+ host: string;
13
+ name?: string;
14
+ model?: string;
15
+ port?: number;
16
+ group?: boolean;
17
+ secure?: boolean;
18
+ txt?: Record<string, string>;
19
+ };
20
+ /** One connector's discovery, as the caller can run it (mDNS, UDP): every device of that kind that answered. */
21
+ export type Finder = () => Promise<Found[]>;
22
+ /** Which config field holds each connector's identity, as its discovery reports it. */
23
+ export declare const IDENTITY: {
24
+ readonly hue: "bridgeId";
25
+ readonly daikin: "mac";
26
+ readonly enphase: "serial";
27
+ readonly cast: "castId";
28
+ readonly printers: "uuid";
29
+ };
30
+ export type Connector = keyof typeof IDENTITY;
31
+ /** Connectors with many devices in one integration (every Cast device and group, every printer); the rest allow one device for now. */
32
+ export declare const MANY: ReadonlySet<string>;
33
+ /** An identity without case, separators or a `urn:uuid:` prefix: "00:17:88:B0" is "001788b0", "urn:uuid:AB-CD" is "abcd". */
34
+ export declare const idKey: (id: string) => string;
35
+ /** Identities compared by `idKey`. */
36
+ export declare const sameId: (a: string, b: string) => boolean;
37
+ export type Match = {
38
+ moved: {
39
+ index: number;
40
+ id: string;
41
+ connector: Connector;
42
+ from: string;
43
+ to: string;
44
+ }[];
45
+ renamed: {
46
+ index: number;
47
+ id: string;
48
+ connector: Connector;
49
+ from: string;
50
+ to: string;
51
+ }[];
52
+ added: {
53
+ found: Found;
54
+ entry: Record<string, string | number>;
55
+ }[];
56
+ skipped: {
57
+ found: Found;
58
+ why: string;
59
+ }[];
60
+ known: {
61
+ id: string;
62
+ connector: Connector;
63
+ hint?: string;
64
+ }[];
65
+ };
66
+ /**
67
+ * Match what discovery found against the config by identity. An entry with no identity yet is matched by host, and a
68
+ * device at an entry's host with a different identity is a conflict, not a match. New entries get ids no entry has.
69
+ */
70
+ export declare function matchFound(config: HomeConfig, found: Found[]): Match;
71
+ /**
72
+ * The config's text with moved hosts and renamed Cast entries updated and new devices appended, through jsonc-parser's edits: comments, unknown
73
+ * keys and every other line stay as they were. A new device goes on its own line at its neighbour's indent.
74
+ */
75
+ export declare function applyFound(text: string, match: Match, hasDevices: boolean): string;
76
+ /** mDNS and Daikin's broadcast drop answers now and then, so discovery runs this many passes by default… */
77
+ export declare const DISCOVERY_PASSES = 2;
78
+ /** …this far apart. */
79
+ export declare const DISCOVERY_GAP_MS = 5000;
80
+ /** A device's key, across discovery passes and in the address cache: its connector and identity, without case or separators. */
81
+ export declare const addressKey: (connector: Connector, id: string) => string;
82
+ export declare const foundKey: (f: Found) => string;
83
+ /** Where a device last answered, and when that was last confirmed. `name` is kept for Cast, whose config entry may be absent. */
84
+ export type CachedAddress = {
85
+ host: string;
86
+ port?: number;
87
+ name?: string;
88
+ seenAt: string;
89
+ };
90
+ /**
91
+ * Addresses kept between runs, so discovery stays rare. The caller persists them (the CLI: a file); the library writes
92
+ * no files. `write` gets the whole map each time an address is new or has changed.
93
+ */
94
+ export type AddressCache = {
95
+ read(): Record<string, CachedAddress>;
96
+ write(entries: Record<string, CachedAddress>): void;
97
+ };
98
+ /**
99
+ * One run's view of an address cache. `fresh` is a cached address confirmed since the config was edited (`configAt`,
100
+ * the config file's time; a config edited since wins); `seen` records an address that answered, writing through when it
101
+ * changed or was not fresh, so an address confirmed after a config edit is trusted again. A Cast name, when given, is the
102
+ * device's own (discovery's fn, or its own multizone status, never a config's), so a new one is written too; a call
103
+ * without a name keeps the one cached.
104
+ */
105
+ export declare function addressBook(cache: AddressCache | undefined, options?: {
106
+ configAt?: number;
107
+ now?: () => number;
108
+ }): {
109
+ fresh(connector: Connector, id?: string): CachedAddress | undefined;
110
+ seen(connector: Connector, id: string | undefined, address: {
111
+ host: string;
112
+ port?: number;
113
+ name?: string;
114
+ }): void;
115
+ entries: () => {
116
+ [x: string]: CachedAddress;
117
+ };
118
+ };
119
+ /**
120
+ * Run one discovery `passes` times, `gapMs` apart, and return the union (first answer wins). `onPass` sees each pass's
121
+ * additions as they come; `done` ends it early once what was looked for has answered. A pass that throws adds nothing;
122
+ * when every pass threw, discovery never ran, so it throws the last error rather than reporting nothing found. An error
123
+ * `stopOn` accepts is thrown at once: no later pass could do better.
124
+ */
125
+ export declare function discover<T = Found>(pass: () => Promise<T[]>, options: {
126
+ clock: Clock;
127
+ passes?: number;
128
+ gapMs?: number;
129
+ key?: (t: T) => string;
130
+ done?: (all: T[]) => boolean;
131
+ stopOn?: (error: unknown) => boolean;
132
+ onPass?: (n: number, added: T[]) => void;
133
+ signal?: AbortSignal;
134
+ }): Promise<T[]>;
135
+ /** Discovery runs after this many failed reads in a row. */
136
+ export declare const RESCAN_AFTER_FAILURES = 3;
137
+ /** Then again after these delays, then hourly for as long as reads fail. */
138
+ export declare const RESCAN_DELAYS_MS: number[];
139
+ export declare const HOUR = 3600000;
140
+ /** The wait before the next scan after `attempt` scans have missed: 1, 5 and 15 minutes, then hourly. The relocator and Cast's re-discovery share it. */
141
+ export declare const rescanDelay: (attempt: number) => number;
142
+ /** After this long without a read, the detail says a day has passed; the scans carry on hourly. */
143
+ export declare const DAY_MS: number;
144
+ /** What `failed()` learned: a move, that a day has passed since the scans began, whether the last scan found it at its address, or why the last scan could not run. */
145
+ export type Lost = {
146
+ moved?: string;
147
+ overADay?: boolean;
148
+ seenHere?: boolean;
149
+ discoveryFailed?: string;
150
+ };
151
+ /**
152
+ * A polling connector's address, rescanned only on failure. `failed()` after each failed read runs `find` once reads
153
+ * have failed `RESCAN_AFTER_FAILURES` times in a row, then on the backoff, then hourly with no end, so a device that comes
154
+ * back is found without a restart. `ok()` after a good read, `reset()` on a refresh (the next failure scans at once).
155
+ * Without `find` or an identity it never scans. `cached`: the starting host came from an address cache, so the first
156
+ * failure scans at once. `failed(signal)` takes the run's signal, so a shutdown does not wait out a scan.
157
+ */
158
+ export declare function relocator(options: {
159
+ host: string;
160
+ id?: string;
161
+ find?: Finder;
162
+ clock: Clock;
163
+ cached?: boolean;
164
+ }): {
165
+ host: () => string;
166
+ ok: () => undefined;
167
+ reset: () => undefined;
168
+ failed(signal?: AbortSignal): Promise<Lost>;
169
+ };
170
+ /** A failed read's detail, saying what discovery found: never "not found" unless a scan looked and missed it. */
171
+ export declare function lostDetail(lost: Lost, error: unknown): string;
172
+ /** Instance names from `dns-sd -B`, each once. */
173
+ export declare const parseBrowse: (text: string) => string[];
174
+ /**
175
+ * `avahi-browse --parsable --resolve` (Linux): each resolved instance once, IPv4 only (the config keeps IPv4 addresses).
176
+ * A resolved line is `=;interface;IPv4;name;type;domain;hostname;address;port;"txt" "txt"`; names and TXT strings use
177
+ * the same escapes as `dns-sd` (`\059` for a `;` in a name, `\"` inside TXT). An instance seen on two interfaces is kept
178
+ * once, the first; loopback is skipped. Checked against avahi-utils on Debian trixie, 2026-10-16.
179
+ */
180
+ export declare function parseAvahi(text: string): {
181
+ instance: string;
182
+ hostname: string;
183
+ host: string;
184
+ port: number;
185
+ txt: Record<string, string>;
186
+ }[];
187
+ /** Instance names from `avahi-browse --parsable` (a `+` line per instance and interface), each once. */
188
+ export declare const parseAvahiBrowse: (text: string) => string[];
189
+ /**
190
+ * One `dns-sd -L` answer: the host and port it can be reached at, and its TXT record. TXT values come escaped (`\ ` for
191
+ * a space, `\226\128\153` for each byte of a UTF-8 character); these are undone, so `fn` reads as the Google Home app shows it.
192
+ */
193
+ export declare function parseResolve(text: string): {
194
+ hostname: string;
195
+ port: number;
196
+ txt: Record<string, string>;
197
+ } | undefined;