@khalido/home 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +2 -2
- package/CHANGELOG.md +74 -2
- package/README.md +7 -2
- package/dist/clock.js +2 -0
- package/dist/config.d.ts +5 -3
- package/dist/config.js +5 -1
- package/dist/connector.d.ts +9 -2
- package/dist/connectors/amber/index.js +1 -0
- package/dist/connectors/cast/index.d.ts +8 -6
- package/dist/connectors/cast/index.js +30 -4
- package/dist/connectors/cast/state.d.ts +66 -0
- package/dist/connectors/cast/state.js +141 -0
- package/dist/connectors/cast/writes.d.ts +0 -2
- package/dist/connectors/cast/writes.js +25 -7
- package/dist/connectors/daikin/client.d.ts +45 -2
- package/dist/connectors/daikin/client.js +67 -2
- package/dist/connectors/daikin/index.d.ts +8 -4
- package/dist/connectors/daikin/index.js +25 -4
- package/dist/connectors/enphase/index.d.ts +5 -1
- package/dist/connectors/enphase/index.js +34 -2
- package/dist/connectors/hue/ca.d.ts +2 -0
- package/dist/connectors/hue/ca.js +38 -0
- package/dist/connectors/hue/client.d.ts +81 -6
- package/dist/connectors/hue/client.js +217 -6
- package/dist/connectors/hue/color.d.ts +30 -0
- package/dist/connectors/hue/color.js +92 -0
- package/dist/connectors/hue/index.d.ts +21 -6
- package/dist/connectors/hue/index.js +59 -9
- package/dist/connectors/hue/schemas.d.ts +27 -0
- package/dist/connectors/hue/schemas.js +26 -0
- package/dist/connectors/hue/state.d.ts +10 -1
- package/dist/connectors/hue/state.js +20 -1
- package/dist/connectors/printers/index.d.ts +6 -2
- package/dist/connectors/printers/index.js +23 -0
- package/dist/connectors/spotify/changes.d.ts +19 -5
- package/dist/connectors/spotify/changes.js +29 -7
- package/dist/connectors/spotify/index.d.ts +7 -2
- package/dist/connectors/spotify/index.js +34 -3
- package/dist/connectors/up/index.js +1 -0
- package/dist/connectors/weather/bom.js +1 -0
- package/dist/connectors/weather/google.js +52 -9
- package/dist/connectors/weather/index.d.ts +2 -1
- package/dist/connectors/weather/index.js +7 -0
- package/dist/connectors/weather/source.d.ts +2 -0
- package/dist/devices.d.ts +51 -0
- package/dist/devices.js +115 -0
- package/dist/discover.js +1 -1
- package/dist/finders-entry.d.ts +1 -1
- package/dist/finders-entry.js +1 -1
- package/dist/finders.d.ts +2 -1
- package/dist/finders.js +16 -5
- package/dist/health.d.ts +2 -1
- package/dist/health.js +2 -1
- package/dist/home.d.ts +52 -5
- package/dist/home.js +114 -33
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/node-fetch.d.ts +2 -2
- package/dist/node-fetch.js +3 -2
- package/docs/cli.md +13 -7
- package/docs/config.md +18 -5
- package/docs/decisions.md +16 -0
- package/docs/hue.md +161 -0
- package/docs/using.md +115 -3
- package/examples/home.jsonc +2 -1
- package/home.schema.json +10 -3
- package/llms.txt +19 -6
- package/package.json +1 -1
- package/skills/draw-home/SKILL.md +1 -1
- package/skills/use-home/SKILL.md +10 -10
- package/src/cli/cast.ts +51 -5
- package/src/cli/context.ts +2 -0
- package/src/cli/daikin.ts +71 -0
- package/src/cli/enphase.ts +5 -1
- package/src/cli/hue.ts +147 -59
- package/src/cli/main.ts +35 -10
- package/src/cli/probe.ts +21 -17
- package/src/cli/status.ts +87 -13
- package/src/clock.ts +1 -0
- package/src/config.ts +8 -2
- package/src/connector.ts +18 -2
- package/src/connectors/amber/index.ts +1 -0
- package/src/connectors/cast/README.md +10 -3
- package/src/connectors/cast/index.ts +65 -7
- package/src/connectors/cast/state.ts +204 -0
- package/src/connectors/cast/writes.ts +29 -8
- package/src/connectors/daikin/README.md +20 -4
- package/src/connectors/daikin/client.ts +104 -3
- package/src/connectors/daikin/index.ts +49 -4
- package/src/connectors/enphase/index.ts +49 -2
- package/src/connectors/hue/README.md +62 -17
- package/src/connectors/hue/ca.ts +39 -0
- package/src/connectors/hue/client.ts +362 -15
- package/src/connectors/hue/color.ts +123 -0
- package/src/connectors/hue/index.ts +94 -12
- package/src/connectors/hue/schemas.ts +26 -0
- package/src/connectors/hue/state.ts +27 -1
- package/src/connectors/printers/README.md +4 -2
- package/src/connectors/printers/index.ts +43 -1
- package/src/connectors/spotify/README.md +21 -3
- package/src/connectors/spotify/changes.ts +38 -9
- package/src/connectors/spotify/index.ts +55 -3
- package/src/connectors/up/index.ts +1 -0
- package/src/connectors/weather/README.md +1 -1
- package/src/connectors/weather/bom.ts +1 -0
- package/src/connectors/weather/google.ts +66 -10
- package/src/connectors/weather/index.ts +16 -2
- package/src/connectors/weather/source.ts +2 -0
- package/src/devices.ts +182 -0
- package/src/discover.ts +1 -2
- package/src/finders-entry.ts +1 -1
- package/src/finders.ts +18 -6
- package/src/health.ts +4 -2
- package/src/home.ts +166 -39
- package/src/index.ts +11 -1
- package/src/node-fetch.ts +6 -2
|
@@ -284,6 +284,7 @@ export function bom(options: BomOptions = {}): WeatherSource {
|
|
|
284
284
|
if (!options.fetch && base.startsWith("ftp:"))
|
|
285
285
|
return {
|
|
286
286
|
stop: "bom: plain fetch cannot read ftp://; pass fetch: ftpFetch() from @khalido/home/weather/ftp",
|
|
287
|
+
stopReason: "config",
|
|
287
288
|
nextAt: Infinity,
|
|
288
289
|
};
|
|
289
290
|
if (force || at >= obsDue) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import { ConnectorError, fetchTimeout } from "../../connector.ts";
|
|
3
3
|
import { RAIN_INTENSITIES } from "./rain.ts";
|
|
4
4
|
import { sunDisagrees } from "./sun.ts";
|
|
5
5
|
import {
|
|
@@ -324,6 +324,33 @@ export type GoogleOptions = {
|
|
|
324
324
|
export const MIN_POLL_MINUTES = 15;
|
|
325
325
|
export const CURRENT_MINUTES = 30;
|
|
326
326
|
|
|
327
|
+
/**
|
|
328
|
+
* Why Google refused: the `reason` of its error body's ErrorInfo ("API_KEY_INVALID"), since the status alone doesn't say.
|
|
329
|
+
* A bad key and a bad latitude are both HTTP 400 INVALID_ARGUMENT; only the key's carries a reason (live, 2026-11-10).
|
|
330
|
+
*/
|
|
331
|
+
async function refusal(res: Response): Promise<string | undefined> {
|
|
332
|
+
try {
|
|
333
|
+
const body = (await res.json()) as {
|
|
334
|
+
error?: { details?: { reason?: unknown }[] };
|
|
335
|
+
};
|
|
336
|
+
const reason = body.error?.details?.find((d) => d.reason)?.reason;
|
|
337
|
+
return typeof reason === "string" ? reason : undefined;
|
|
338
|
+
} catch {
|
|
339
|
+
return undefined; // not JSON: the status is all there is
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Google's refusal: the HTTP status, and `why`, the reason its error body gave. */
|
|
344
|
+
class GoogleError extends ConnectorError {
|
|
345
|
+
constructor(
|
|
346
|
+
message: string,
|
|
347
|
+
status: number,
|
|
348
|
+
readonly why?: string,
|
|
349
|
+
) {
|
|
350
|
+
super(message, { status });
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
327
354
|
/**
|
|
328
355
|
* Google Weather as a source: modelled `now`, `forecast` (10 days available, 2 read), `sky` without radiation, and `rain` from the
|
|
329
356
|
* minute forecast (Experimental at Google, so its failure is a `problem` on `rain` and never stops the other slices).
|
|
@@ -353,12 +380,28 @@ export function google(options: GoogleOptions): WeatherSource {
|
|
|
353
380
|
signal,
|
|
354
381
|
}): Promise<SourceReading> {
|
|
355
382
|
const q = (extra: string) =>
|
|
356
|
-
`
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
383
|
+
`location.latitude=${place.latitude}&location.longitude=${place.longitude}${extra}`;
|
|
384
|
+
// The key goes in a header, never the URL, so no error, log or proxy that shows a URL can show it (all four lookups answered 200 this way, live 2026-11-10).
|
|
385
|
+
const json = async (path: string): Promise<unknown> => {
|
|
386
|
+
const res = await fetchTimeout(
|
|
387
|
+
fetcher,
|
|
388
|
+
`${base}/${path}`,
|
|
389
|
+
{
|
|
390
|
+
headers: {
|
|
391
|
+
"user-agent": USER_AGENT,
|
|
392
|
+
"x-goog-api-key": options.key,
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
{ signal },
|
|
396
|
+
);
|
|
397
|
+
if (res.ok) return res.json();
|
|
398
|
+
const why = await refusal(res);
|
|
399
|
+
throw new GoogleError(
|
|
400
|
+
`HTTP ${res.status}${why ? ` ${why}` : ""} on GET /${path.split("?")[0]}`,
|
|
401
|
+
res.status,
|
|
402
|
+
why,
|
|
403
|
+
);
|
|
404
|
+
};
|
|
362
405
|
// Started first and never throws: a failed minute forecast must not sink the rest.
|
|
363
406
|
const minutes = json(`forecast/minutes:lookup?${q("")}`).then(
|
|
364
407
|
(value) => ({ value }),
|
|
@@ -385,10 +428,23 @@ export function google(options: GoogleOptions): WeatherSource {
|
|
|
385
428
|
slowDue = nextPoll(at, Math.max(pollMinutes, CURRENT_MINUTES));
|
|
386
429
|
}
|
|
387
430
|
} catch (error) {
|
|
388
|
-
|
|
389
|
-
|
|
431
|
+
// Only a GoogleError is Google's answer; a timeout or an abort is a DOMException, with fields of its own.
|
|
432
|
+
const { status, why } =
|
|
433
|
+
error instanceof GoogleError
|
|
434
|
+
? error
|
|
435
|
+
: ({} as { status?: number; why?: string });
|
|
436
|
+
const said = `HTTP ${status}${why ? ` ${why}` : ""}`;
|
|
437
|
+
if (status === 401 || status === 403 || why?.startsWith("API_KEY_"))
|
|
438
|
+
return {
|
|
439
|
+
stop: `google: the key was rejected (${said})`,
|
|
440
|
+
stopReason: "auth",
|
|
441
|
+
nextAt: Infinity,
|
|
442
|
+
};
|
|
443
|
+
// A 400 with a good key is the request itself, and here that is the location: asking again won't change it.
|
|
444
|
+
if (status === 400)
|
|
390
445
|
return {
|
|
391
|
-
stop: `google:
|
|
446
|
+
stop: `google: the request was refused (${said}); check the latitude and longitude`,
|
|
447
|
+
stopReason: "config",
|
|
392
448
|
nextAt: Infinity,
|
|
393
449
|
};
|
|
394
450
|
problem = `google: ${(error as Error).message}`; // the old slices stay; the minute forecast below is still used
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Clock, localTime, realClock } from "../../clock.ts";
|
|
2
|
-
import type { Integration, Status } from "../../home.ts";
|
|
2
|
+
import type { Integration, Reason, Status } from "../../home.ts";
|
|
3
3
|
import { field, type Manifest } from "../../manifest.ts";
|
|
4
4
|
import { poller } from "../../poll.ts";
|
|
5
5
|
import {
|
|
@@ -88,7 +88,13 @@ export type WeatherState = Slices & {
|
|
|
88
88
|
timeZone?: string; // the config's, which the blocks and the text use
|
|
89
89
|
sources: Record<
|
|
90
90
|
string,
|
|
91
|
-
{
|
|
91
|
+
{
|
|
92
|
+
read: boolean;
|
|
93
|
+
problem?: string;
|
|
94
|
+
stopped?: string; // it will not be read again, and why
|
|
95
|
+
reason?: Exclude<Reason, "crashed">; // with `stopped`, for code: a dead key shows here even while another source keeps the part connected
|
|
96
|
+
anomaly?: string;
|
|
97
|
+
}
|
|
92
98
|
>;
|
|
93
99
|
};
|
|
94
100
|
|
|
@@ -149,6 +155,7 @@ export function weather(
|
|
|
149
155
|
read: r !== undefined,
|
|
150
156
|
problem: r?.problem,
|
|
151
157
|
stopped: r?.stop,
|
|
158
|
+
reason: r?.stop ? (r.stopReason ?? "config") : undefined,
|
|
152
159
|
anomaly: r?.anomaly,
|
|
153
160
|
},
|
|
154
161
|
];
|
|
@@ -174,6 +181,13 @@ export function weather(
|
|
|
174
181
|
: undefined;
|
|
175
182
|
update({
|
|
176
183
|
status,
|
|
184
|
+
// Every source has stopped: a rejected key outranks a wrong setting, since it needs a person.
|
|
185
|
+
reason:
|
|
186
|
+
status !== "unavailable"
|
|
187
|
+
? undefined
|
|
188
|
+
: Object.values(report).some((r) => r.reason === "auth")
|
|
189
|
+
? "auth"
|
|
190
|
+
: "config",
|
|
177
191
|
detail,
|
|
178
192
|
anomaly: notes("anomaly"),
|
|
179
193
|
lastGoodAt,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ConnectorError, fetchTimeout } from "../../connector.ts";
|
|
2
|
+
import type { Reason } from "../../home.ts";
|
|
2
3
|
|
|
3
4
|
// The one place a weather provider plugs in: a `WeatherSource` returns slices, and `mergeWeather` picks one per slice.
|
|
4
5
|
|
|
@@ -142,6 +143,7 @@ export type SourceReading = Slices & {
|
|
|
142
143
|
problem?: string; // a failed product, a missing option; the source keeps going
|
|
143
144
|
anomaly?: string; // a value the source sent that could not be read as given
|
|
144
145
|
stop?: string; // a terminal problem (a rejected key): the integration stops calling this source
|
|
146
|
+
stopReason?: Exclude<Reason, "crashed">; // with `stop`: a person must re-key, or fix a setting (the default)
|
|
145
147
|
};
|
|
146
148
|
|
|
147
149
|
/**
|
package/src/devices.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CastHomeDevice,
|
|
3
|
+
type CastSnapshot,
|
|
4
|
+
castHomeDevices,
|
|
5
|
+
} from "./connectors/cast/index.ts";
|
|
6
|
+
import {
|
|
7
|
+
type DaikinHomeDevice,
|
|
8
|
+
daikinHomeDevices,
|
|
9
|
+
} from "./connectors/daikin/index.ts";
|
|
10
|
+
import {
|
|
11
|
+
type EnphaseHomeDevice,
|
|
12
|
+
enphaseHomeDevices,
|
|
13
|
+
} from "./connectors/enphase/index.ts";
|
|
14
|
+
import { type HueHomeDevice, hueHomeDevices } from "./connectors/hue/index.ts";
|
|
15
|
+
import {
|
|
16
|
+
type PrinterHomeDevice,
|
|
17
|
+
printersHomeDevices,
|
|
18
|
+
} from "./connectors/printers/index.ts";
|
|
19
|
+
import {
|
|
20
|
+
playingElsewhere,
|
|
21
|
+
type SpotifyHomeDevice,
|
|
22
|
+
type SpotifyState,
|
|
23
|
+
spotifyHomeDevices,
|
|
24
|
+
spotifyText,
|
|
25
|
+
} from "./connectors/spotify/index.ts";
|
|
26
|
+
import type { HomeConfig } from "./config.ts";
|
|
27
|
+
import { IDENTITY, sameId } from "./discover.ts";
|
|
28
|
+
import type { DeviceRecord, Part } from "./home.ts";
|
|
29
|
+
import { manifestOf } from "./manifests.ts";
|
|
30
|
+
|
|
31
|
+
// Every device in the home in one list, each answering the same few questions (`DeviceRecord`), with its connector's own
|
|
32
|
+
// typed row in `detail`. A connector with devices exports `<name>HomeDevices(part)` and is listed here, once in the union
|
|
33
|
+
// and once in DEVICES; test/devices.test.ts fails for a manifest that is in neither DEVICES nor SERVICES.
|
|
34
|
+
|
|
35
|
+
/** Any device this library reads. `connector`, then `kind` where a connector has more than one, narrows `detail` and `state`. */
|
|
36
|
+
export type HomeDevice =
|
|
37
|
+
| HueHomeDevice
|
|
38
|
+
| DaikinHomeDevice
|
|
39
|
+
| EnphaseHomeDevice
|
|
40
|
+
| CastHomeDevice
|
|
41
|
+
| PrinterHomeDevice
|
|
42
|
+
| SpotifyHomeDevice;
|
|
43
|
+
|
|
44
|
+
/** Each connector's devices from its part, in the order `home status` lists the connectors. */
|
|
45
|
+
export const DEVICES = {
|
|
46
|
+
hue: hueHomeDevices,
|
|
47
|
+
daikin: daikinHomeDevices,
|
|
48
|
+
enphase: enphaseHomeDevices,
|
|
49
|
+
cast: castHomeDevices,
|
|
50
|
+
printers: printersHomeDevices,
|
|
51
|
+
spotify: spotifyHomeDevices,
|
|
52
|
+
} satisfies Record<string, (part: Part<any> | undefined) => DeviceRecord[]>;
|
|
53
|
+
|
|
54
|
+
/** Connectors that read a service, not a device: nothing to list. */
|
|
55
|
+
export const SERVICES: readonly string[] = ["amber", "up", "weather"];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Every device in the snapshot. With the home `config`, a device takes the `room` its entry gives it, a Hue room or zone
|
|
59
|
+
* (and its lights) the room whose `hueRoom` names it (a light in a room and a zone, the room's), and a device that reports
|
|
60
|
+
* no name of its own (the Daikin, the Enphase gateway) the entry's `name`. A device's own name is never replaced.
|
|
61
|
+
*/
|
|
62
|
+
export function homeDevices(
|
|
63
|
+
snapshot: Record<string, Part | undefined>,
|
|
64
|
+
config?: Pick<HomeConfig, "devices" | "rooms">,
|
|
65
|
+
): HomeDevice[] {
|
|
66
|
+
// Soloist doesn't say where its music went; a Cast device playing that track does, so the speaker's line names it.
|
|
67
|
+
const on = spotifyElsewhere(snapshot);
|
|
68
|
+
const all = Object.entries(DEVICES)
|
|
69
|
+
.flatMap(([name, devices]): HomeDevice[] =>
|
|
70
|
+
devices(snapshot[name] as never),
|
|
71
|
+
)
|
|
72
|
+
.map((d) =>
|
|
73
|
+
d.connector === "spotify" && on.length
|
|
74
|
+
? { ...d, text: spotifyText(d.detail, on) }
|
|
75
|
+
: d,
|
|
76
|
+
);
|
|
77
|
+
if (!config) return all;
|
|
78
|
+
const entries = config.devices ?? [];
|
|
79
|
+
const hueRooms = new Map<string, string>(); // a Hue group's or light's id → the config room showing it
|
|
80
|
+
for (const d of all)
|
|
81
|
+
if (d.connector === "hue" && d.kind === "group") {
|
|
82
|
+
const room = config.rooms?.find(
|
|
83
|
+
(r) =>
|
|
84
|
+
r.hueRoom !== undefined &&
|
|
85
|
+
(r.hueRoom === d.id || r.hueRoom === d.detail.groupedLightId),
|
|
86
|
+
)?.id;
|
|
87
|
+
// A light in a room and a zone takes the room's: rooms come first, and the first one stays.
|
|
88
|
+
if (room)
|
|
89
|
+
for (const id of [d.id, ...d.detail.lightIds])
|
|
90
|
+
if (!hueRooms.has(id)) hueRooms.set(id, room);
|
|
91
|
+
}
|
|
92
|
+
return all.map((d) => {
|
|
93
|
+
const mine = entries.filter((e) => e.connector === d.connector);
|
|
94
|
+
const field = (IDENTITY as Record<string, string>)[d.connector];
|
|
95
|
+
// One device for the connector: its entry is this device. Many: the entry whose identity is this device's id.
|
|
96
|
+
const entry =
|
|
97
|
+
d.id === d.connector
|
|
98
|
+
? mine[0]
|
|
99
|
+
: mine.find((e) => {
|
|
100
|
+
const id = field && (e as Record<string, unknown>)[field];
|
|
101
|
+
return typeof id === "string" && sameId(id, d.id);
|
|
102
|
+
});
|
|
103
|
+
const room = d.room ?? entry?.room ?? hueRooms.get(d.id);
|
|
104
|
+
// A device with no name of its own carries its connector's title: only then does the entry's name replace it.
|
|
105
|
+
const unnamed = d.name === manifestOf(d.connector)?.title;
|
|
106
|
+
const name = unnamed ? (entry?.name ?? d.name) : d.name;
|
|
107
|
+
// Its line opens with its name, so a new name opens the line too.
|
|
108
|
+
const text =
|
|
109
|
+
name !== d.name && d.text.startsWith(`${d.name}: `)
|
|
110
|
+
? name + d.text.slice(d.name.length)
|
|
111
|
+
: d.text;
|
|
112
|
+
return { ...d, name, text, ...(room ? { room } : {}) };
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Where the account's Spotify is playing when the Soloist speaker says it is on another device: the names of the Cast
|
|
118
|
+
* devices and groups playing a track of that name in Spotify. A group is named, not its members. Empty when the music is
|
|
119
|
+
* on the speaker itself, nothing is playing, or it is on something Cast can't see (a phone, a laptop). Matched by the
|
|
120
|
+
* track's name, since neither side names the other.
|
|
121
|
+
*/
|
|
122
|
+
export function spotifyElsewhere(
|
|
123
|
+
snapshot: Record<string, Part | undefined>,
|
|
124
|
+
): string[] {
|
|
125
|
+
const s = snapshot.spotify?.state as SpotifyState | undefined;
|
|
126
|
+
const cast = snapshot.cast?.state as CastSnapshot | undefined;
|
|
127
|
+
const name = s?.track?.name;
|
|
128
|
+
if (!playingElsewhere(s) || !name || !cast) return [];
|
|
129
|
+
return Object.values(cast.devices)
|
|
130
|
+
.filter(
|
|
131
|
+
(d) =>
|
|
132
|
+
d.status === "connected" &&
|
|
133
|
+
!d.playingVia && // its group's music: the group is named
|
|
134
|
+
d.app === "Spotify" &&
|
|
135
|
+
d.title === name &&
|
|
136
|
+
(d.playerState === "PLAYING" || d.playerState === "BUFFERING"),
|
|
137
|
+
)
|
|
138
|
+
.map((d) => d.name);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A room of the home config with the devices placed in it. `noData`: device connectors the config places here that listed nothing in it (never reached, not set up, or a `hueRoom` the bridge doesn't have), so the room isn't empty, it is unread. Per connector, not per device. */
|
|
142
|
+
export type RoomDevices = {
|
|
143
|
+
id: string;
|
|
144
|
+
name: string;
|
|
145
|
+
floor?: string;
|
|
146
|
+
devices: HomeDevice[];
|
|
147
|
+
noData: string[];
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* `homeDevices` room by room, in the config's order, for a map of the home. A connector that is down but has read before
|
|
152
|
+
* still lists its devices, `available: false`; `noData` names one the config places in the room (a device's `room`, or
|
|
153
|
+
* `hueRoom`) that has nothing to list. `unplaced`: devices with no room, or one the config doesn't have.
|
|
154
|
+
*/
|
|
155
|
+
export function byRoom(
|
|
156
|
+
snapshot: Record<string, Part | undefined>,
|
|
157
|
+
config: Pick<HomeConfig, "devices" | "rooms">,
|
|
158
|
+
): { rooms: RoomDevices[]; unplaced: HomeDevice[] } {
|
|
159
|
+
const all = homeDevices(snapshot, config);
|
|
160
|
+
const ids = new Set((config.rooms ?? []).map((r) => r.id));
|
|
161
|
+
return {
|
|
162
|
+
rooms: (config.rooms ?? []).map((r) => {
|
|
163
|
+
const devices = all.filter((d) => d.room === r.id);
|
|
164
|
+
const expected = new Set([
|
|
165
|
+
...(config.devices ?? [])
|
|
166
|
+
.filter((d) => d.room === r.id)
|
|
167
|
+
.map((d) => d.connector),
|
|
168
|
+
...(r.hueRoom === undefined ? [] : ["hue"]),
|
|
169
|
+
]);
|
|
170
|
+
return {
|
|
171
|
+
id: r.id,
|
|
172
|
+
name: r.name,
|
|
173
|
+
...(r.floor === undefined ? {} : { floor: r.floor }),
|
|
174
|
+
devices,
|
|
175
|
+
noData: [...expected].filter(
|
|
176
|
+
(c) => c in DEVICES && !devices.some((d) => d.connector === c),
|
|
177
|
+
),
|
|
178
|
+
};
|
|
179
|
+
}),
|
|
180
|
+
unplaced: all.filter((d) => !d.room || !ids.has(d.room)),
|
|
181
|
+
};
|
|
182
|
+
}
|
package/src/discover.ts
CHANGED
|
@@ -134,8 +134,7 @@ function newEntry(f: Found): Record<string, string | number> {
|
|
|
134
134
|
host: f.host,
|
|
135
135
|
[IDENTITY[f.connector]]: f.id,
|
|
136
136
|
};
|
|
137
|
-
if (f.connector === "hue")
|
|
138
|
-
return { ...base, key: "HUE_APPLICATION_KEY", ca: "hue-root-ca.pem" };
|
|
137
|
+
if (f.connector === "hue") return { ...base, key: "HUE_APPLICATION_KEY" };
|
|
139
138
|
if (f.connector === "enphase")
|
|
140
139
|
return { ...base, token: "~/.config/home/enphase-token.json" };
|
|
141
140
|
return base;
|
package/src/finders-entry.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
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";
|
|
2
|
+
export { FINDERS, fileCache, MissingTool } from "./finders.ts";
|
package/src/finders.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
// `fileCache` keeps the addresses discovery found in a file between runs, so the next run connects straight away.
|
|
33
33
|
|
|
34
34
|
/** The system's mDNS tool is not installed: discovery can't run until it is. */
|
|
35
|
+
/** The discovery tool isn't installed (`dns-sd` on macOS, `avahi-browse` from avahi-utils on Linux): the message names it. */
|
|
35
36
|
export class MissingTool extends Error {}
|
|
36
37
|
|
|
37
38
|
/**
|
|
@@ -153,21 +154,32 @@ function decoded(v: string): string {
|
|
|
153
154
|
}
|
|
154
155
|
}
|
|
155
156
|
|
|
156
|
-
/** Broadcast pydaikin's discovery packet (UDP 30000 → 30050) and collect who answers
|
|
157
|
+
/** Broadcast pydaikin's discovery packet (UDP 30000 → 30050), three times across `ms`, and collect who answers, each unit once. */
|
|
157
158
|
export function findDaikins(
|
|
158
159
|
ms = 1500,
|
|
159
160
|
): Promise<{ ip: string; fields: Record<string, string> }[]> {
|
|
160
161
|
return new Promise((resolve) => {
|
|
161
162
|
const found: { ip: string; fields: Record<string, string> }[] = [];
|
|
162
163
|
const sock = createSocket({ type: "udp4", reuseAddr: true });
|
|
163
|
-
sock.on("message", (msg, from) =>
|
|
164
|
-
found.
|
|
164
|
+
sock.on("message", (msg, from) => {
|
|
165
|
+
if (!found.some((f) => f.ip === from.address))
|
|
166
|
+
found.push({ ip: from.address, fields: daikinFields(msg.toString()) });
|
|
167
|
+
});
|
|
168
|
+
const asks: ReturnType<typeof setTimeout>[] = [];
|
|
169
|
+
const done = () => (
|
|
170
|
+
asks.forEach(clearTimeout),
|
|
171
|
+
sock.close(),
|
|
172
|
+
resolve(found)
|
|
165
173
|
);
|
|
166
|
-
sock.on("error",
|
|
174
|
+
sock.on("error", done);
|
|
167
175
|
sock.bind(30000, () => {
|
|
168
176
|
sock.setBroadcast(true);
|
|
169
|
-
|
|
170
|
-
|
|
177
|
+
// UDP drops packets, more so while mDNS is busy beside it: one ask missed KO's unit in 2 runs of 3 (2026-11-11). Ask three times.
|
|
178
|
+
const ask = () =>
|
|
179
|
+
sock.send("DAIKIN_UDP/common/basic_info", 30050, "255.255.255.255");
|
|
180
|
+
ask();
|
|
181
|
+
asks.push(setTimeout(ask, ms / 3), setTimeout(ask, (2 * ms) / 3));
|
|
182
|
+
setTimeout(done, ms);
|
|
171
183
|
});
|
|
172
184
|
});
|
|
173
185
|
}
|
package/src/health.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Part, Status } from "./home.ts";
|
|
1
|
+
import type { Part, Reason, Status } from "./home.ts";
|
|
2
2
|
import { MANIFESTS } from "./manifests.ts";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -14,6 +14,7 @@ export type Health = {
|
|
|
14
14
|
staleAfterMs?: number;
|
|
15
15
|
downAfterMs: number;
|
|
16
16
|
detail?: string;
|
|
17
|
+
reason?: Reason;
|
|
17
18
|
};
|
|
18
19
|
|
|
19
20
|
/** 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, */
|
|
@@ -61,7 +62,7 @@ function healthOf(
|
|
|
61
62
|
(part.status !== "connected" && (age(part.downSince) ?? 0) >= downAfterMs);
|
|
62
63
|
const goodAge = age(part.lastGoodAt) ?? age(part.downSince); // never good: as old as the wait for it
|
|
63
64
|
const stale = staleAfterMs !== undefined && (goodAge ?? 0) >= staleAfterMs;
|
|
64
|
-
const { status, lastGoodAt, downSince, detail } = part;
|
|
65
|
+
const { status, lastGoodAt, downSince, detail, reason } = part;
|
|
65
66
|
return {
|
|
66
67
|
level: down ? "down" : stale ? "stale" : "ok",
|
|
67
68
|
status,
|
|
@@ -70,5 +71,6 @@ function healthOf(
|
|
|
70
71
|
staleAfterMs,
|
|
71
72
|
downAfterMs,
|
|
72
73
|
detail,
|
|
74
|
+
reason,
|
|
73
75
|
};
|
|
74
76
|
}
|