@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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { mirekToKelvin, xyToHex } from "./color.js";
|
|
1
2
|
/** A snapshot from one full `get()`. */
|
|
2
3
|
export function snapshotFrom(resources, asOf) {
|
|
3
4
|
return {
|
|
@@ -32,7 +33,7 @@ const ofType = (s, type) => Object.values(s.resources).filter((r) => r.type ===
|
|
|
32
33
|
const groupedLightOf = (s, r) => r.services
|
|
33
34
|
?.map((x) => s.resources[x.rid])
|
|
34
35
|
.find((x) => x?.type === "grouped_light");
|
|
35
|
-
/** Every light, with on, brightness and
|
|
36
|
+
/** Every light, with on, brightness, reachability, and its white temperature and colour where it has them. */
|
|
36
37
|
export function lights(s) {
|
|
37
38
|
const zigbee = ofType(s, "zigbee_connectivity");
|
|
38
39
|
return ofType(s, "light").map((l) => {
|
|
@@ -43,9 +44,27 @@ export function lights(s) {
|
|
|
43
44
|
on: l.on?.on,
|
|
44
45
|
brightness: l.dimming?.brightness,
|
|
45
46
|
reachable: link ? link.status === "connected" : undefined,
|
|
47
|
+
...colorOf(l),
|
|
46
48
|
};
|
|
47
49
|
});
|
|
48
50
|
}
|
|
51
|
+
/** A light's temperature and colour fields, only those the bridge reported. */
|
|
52
|
+
function colorOf(l) {
|
|
53
|
+
const out = {};
|
|
54
|
+
const ct = l.color_temperature;
|
|
55
|
+
if (typeof ct?.mirek === "number")
|
|
56
|
+
out.kelvin = mirekToKelvin(ct.mirek);
|
|
57
|
+
const { mirek_minimum: min, mirek_maximum: max } = ct?.mirek_schema ?? {};
|
|
58
|
+
if (min !== undefined && max !== undefined)
|
|
59
|
+
out.kelvinRange = [mirekToKelvin(max), mirekToKelvin(min)];
|
|
60
|
+
if (l.color?.xy) {
|
|
61
|
+
out.xy = l.color.xy;
|
|
62
|
+
out.hex = xyToHex(l.color.xy);
|
|
63
|
+
}
|
|
64
|
+
if (l.color?.gamut)
|
|
65
|
+
out.gamut = l.color.gamut;
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
49
68
|
/** Rooms and zones. A room's children are devices; a zone's are lights. */
|
|
50
69
|
export function groups(s) {
|
|
51
70
|
const all = ofType(s, "light");
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { type Clock } from "../../clock.ts";
|
|
3
3
|
import { type Finder } from "../../discover.ts";
|
|
4
|
-
import type
|
|
4
|
+
import { type DeviceRecord, type Integration, type Part } from "../../home.ts";
|
|
5
5
|
import type { Manifest } from "../../manifest.ts";
|
|
6
|
-
import { type Printer, type PrintersState } from "./state.ts";
|
|
6
|
+
import { type Printer, type PrinterStatus, type PrintersState } from "./state.ts";
|
|
7
7
|
export { type Marker, type Printer, type PrintersState, type PrinterStatus, printerText, } from "./state.ts";
|
|
8
8
|
/** One printer in a home config. Everything the printer reports about itself (model, markers, state) comes live; `host` and `port` are only where it last answered. */
|
|
9
9
|
export declare const configSchema: z.ZodObject<{
|
|
@@ -50,3 +50,7 @@ export declare function printers(config?: PrintersConfig): Integration<PrintersS
|
|
|
50
50
|
/** Printers sorted by name. */
|
|
51
51
|
export declare const printerList: (s: PrintersState) => Printer[];
|
|
52
52
|
export declare const manifest: Manifest<PrintersState>;
|
|
53
|
+
/** A printer as a home device: `state` is the printer's own (`idle`, `printing`, `stopped`, `offline`); `detail` its `Printer`. */
|
|
54
|
+
export type PrinterHomeDevice = DeviceRecord<"printers", "printer", PrinterStatus, Printer>;
|
|
55
|
+
/** Every printer, by name. One that has stopped answering is not `available`, and keeps its last values. */
|
|
56
|
+
export declare function printersHomeDevices(part: Part<PrintersState> | undefined): PrinterHomeDevice[];
|
|
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { asOf, realClock } from "../../clock.js";
|
|
3
3
|
import { errorText } from "../../connector.js";
|
|
4
4
|
import { DAY_MS, discover, HOUR, lostDetail, rescanDelay, } from "../../discover.js";
|
|
5
|
+
import { deviceRecords, } from "../../home.js";
|
|
5
6
|
import { poller } from "../../poll.js";
|
|
6
7
|
import { getPrinterAttributes, printerUrl } from "./client.js";
|
|
7
8
|
import { attributesOf } from "./codec.js";
|
|
@@ -280,3 +281,25 @@ export const manifest = {
|
|
|
280
281
|
];
|
|
281
282
|
},
|
|
282
283
|
};
|
|
284
|
+
/** Every printer, by name. One that has stopped answering is not `available`, and keeps its last values. */
|
|
285
|
+
export function printersHomeDevices(part) {
|
|
286
|
+
if (!part?.state)
|
|
287
|
+
return [];
|
|
288
|
+
return deviceRecords(part, printerList(part.state).map((p) => ({
|
|
289
|
+
connector: "printers",
|
|
290
|
+
id: p.uuid,
|
|
291
|
+
name: p.name,
|
|
292
|
+
kind: "printer",
|
|
293
|
+
...(p.state === undefined ? {} : { available: p.state !== "offline" }),
|
|
294
|
+
...(p.state === undefined ? {} : { state: p.state }),
|
|
295
|
+
// An offline printer's values are from when it last answered, not from this check.
|
|
296
|
+
...(p.state === "offline"
|
|
297
|
+
? p.lastSeen === undefined
|
|
298
|
+
? {}
|
|
299
|
+
: { asOf: p.lastSeen }
|
|
300
|
+
: { asOf: p.asOf }),
|
|
301
|
+
text: printerText(p),
|
|
302
|
+
...(p.room ? { room: p.room } : {}),
|
|
303
|
+
detail: p,
|
|
304
|
+
})));
|
|
305
|
+
}
|
|
@@ -2,8 +2,16 @@ import type { Part } from "../../home.ts";
|
|
|
2
2
|
import type { SpotifyState, Stall, Track } from "./client.ts";
|
|
3
3
|
/** A stall in a few words: `playback stalled at 1:39 (Soloist issue #5); tried a seek`. */
|
|
4
4
|
export declare function stallText(s: Stall | undefined): string | undefined;
|
|
5
|
-
/**
|
|
6
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Soloist says playing while it is not the account's active device: the music is on another Spotify device (a phone,
|
|
7
|
+
* another speaker) and this one is silent (live, 2026-11-10). Soloist doesn't say which. Unknown `active` is not elsewhere.
|
|
8
|
+
*/
|
|
9
|
+
export declare const playingElsewhere: (s: SpotifyState | undefined) => boolean;
|
|
10
|
+
/**
|
|
11
|
+
* The speaker in one word, to switch on. `offline`: not reached (or no part); `waiting`: running, nobody has picked it;
|
|
12
|
+
* `elsewhere`: the account's music is playing on another Spotify device, not this one. A status Soloist sends beyond its four passes through.
|
|
13
|
+
*/
|
|
14
|
+
export type SpeakerState = "offline" | "connecting" | "waiting" | "elsewhere" | "idle" | "playing" | "paused" | "buffering" | (string & {});
|
|
7
15
|
export declare function speakerState(part: Part<SpotifyState> | undefined): SpeakerState;
|
|
8
16
|
/**
|
|
9
17
|
* A change worth telling someone about, from two reports of the speaker. `own`, on playback events only: it happened while
|
|
@@ -24,9 +32,15 @@ export type SpotifyEvent = {
|
|
|
24
32
|
/**
|
|
25
33
|
* The events between two reports, for `onChange`'s `before` and `after`: `offline` and `online`; `waiting` (signed out) and
|
|
26
34
|
* `picked` (someone picked the speaker); `started` (from idle), `resumed` (from paused), `paused`, `stopped` (to idle), then
|
|
27
|
-
* `track` for a new track, once it has a name, then `stalled` and `recovered` (Soloist issue #5). Buffering counts as playing.
|
|
35
|
+
* `track` for a new track, once it has a name, then `stalled` and `recovered` (Soloist issue #5). Buffering counts as playing.
|
|
36
|
+
* Music on another Spotify device (`playingElsewhere`) is not playing here: it moving away is `stopped`, it arriving is `started`
|
|
37
|
+
* then `track`, and nothing fires for its tracks meanwhile. No `before`, or one
|
|
28
38
|
* with no state (createHome's placeholder before the first report, as at a restart): nothing, since nothing was seen to change. No volume or queue events: read the state.
|
|
29
39
|
*/
|
|
30
40
|
export declare function spotifyChanges(before: Part<SpotifyState> | undefined, after: Part<SpotifyState> | undefined): SpotifyEvent[];
|
|
31
|
-
/**
|
|
32
|
-
|
|
41
|
+
/**
|
|
42
|
+
* The speaker in a few words: `Kitchen speaker: playing "Song" by Band · 35%`, `…: waiting for someone to pick it`, or,
|
|
43
|
+
* for music on another Spotify device, `…: silent · "Song" by Band is playing on another Spotify device` (no volume: it isn't this speaker's playing level).
|
|
44
|
+
* `on` names where, when the caller knows (`spotifyElsewhere`): `… is playing on Front room speaker`.
|
|
45
|
+
*/
|
|
46
|
+
export declare function spotifyText(s: SpotifyState, on?: string[]): string;
|
|
@@ -9,6 +9,12 @@ export function stallText(s) {
|
|
|
9
9
|
: `playback stalled at ${at} (Soloist issue #5)`;
|
|
10
10
|
return s.tried ? `${what}; tried ${s.tried}` : what;
|
|
11
11
|
}
|
|
12
|
+
const PLAYING = new Set(["playing", "buffering"]);
|
|
13
|
+
/**
|
|
14
|
+
* Soloist says playing while it is not the account's active device: the music is on another Spotify device (a phone,
|
|
15
|
+
* another speaker) and this one is silent (live, 2026-11-10). Soloist doesn't say which. Unknown `active` is not elsewhere.
|
|
16
|
+
*/
|
|
17
|
+
export const playingElsewhere = (s) => s?.active === false && PLAYING.has(s.status ?? "");
|
|
12
18
|
export function speakerState(part) {
|
|
13
19
|
if (!part || part.status === "disconnected" || part.status === "unavailable")
|
|
14
20
|
return "offline";
|
|
@@ -16,13 +22,16 @@ export function speakerState(part) {
|
|
|
16
22
|
return "connecting";
|
|
17
23
|
if (part.state?.loggedIn === false)
|
|
18
24
|
return "waiting";
|
|
25
|
+
if (playingElsewhere(part.state))
|
|
26
|
+
return "elsewhere";
|
|
19
27
|
return part.state?.status ?? "idle"; // signed in, nothing reported yet: nothing is playing
|
|
20
28
|
}
|
|
21
|
-
const PLAYING = new Set(["playing", "buffering"]);
|
|
22
29
|
/**
|
|
23
30
|
* The events between two reports, for `onChange`'s `before` and `after`: `offline` and `online`; `waiting` (signed out) and
|
|
24
31
|
* `picked` (someone picked the speaker); `started` (from idle), `resumed` (from paused), `paused`, `stopped` (to idle), then
|
|
25
|
-
* `track` for a new track, once it has a name, then `stalled` and `recovered` (Soloist issue #5). Buffering counts as playing.
|
|
32
|
+
* `track` for a new track, once it has a name, then `stalled` and `recovered` (Soloist issue #5). Buffering counts as playing.
|
|
33
|
+
* Music on another Spotify device (`playingElsewhere`) is not playing here: it moving away is `stopped`, it arriving is `started`
|
|
34
|
+
* then `track`, and nothing fires for its tracks meanwhile. No `before`, or one
|
|
26
35
|
* with no state (createHome's placeholder before the first report, as at a restart): nothing, since nothing was seen to change. No volume or queue events: read the state.
|
|
27
36
|
*/
|
|
28
37
|
export function spotifyChanges(before, after) {
|
|
@@ -45,8 +54,11 @@ export function spotifyChanges(before, after) {
|
|
|
45
54
|
events.push({ type: "waiting" });
|
|
46
55
|
if (b?.loggedIn === false && a?.loggedIn === true)
|
|
47
56
|
events.push({ type: "picked" });
|
|
48
|
-
|
|
49
|
-
const
|
|
57
|
+
// What this speaker itself is doing: music on another device is silence here.
|
|
58
|
+
const here = (s) => (playingElsewhere(s) ? "idle" : s?.status);
|
|
59
|
+
const from = here(b);
|
|
60
|
+
// Still inactive after playing elsewhere: a pause or stop there is the other device's, not this one's.
|
|
61
|
+
const to = playingElsewhere(b) && a?.active === false ? "idle" : here(a);
|
|
50
62
|
const playingNow = to !== undefined && PLAYING.has(to);
|
|
51
63
|
const playingThen = from !== undefined && PLAYING.has(from);
|
|
52
64
|
if (playingNow && !playingThen)
|
|
@@ -57,7 +69,11 @@ export function spotifyChanges(before, after) {
|
|
|
57
69
|
add({ type: "stopped" });
|
|
58
70
|
// Soloist's first report of a new track can have no name yet (seen live): the event waits for it.
|
|
59
71
|
const named = (p) => !!p?.track?.name;
|
|
60
|
-
|
|
72
|
+
const heard = playingElsewhere(b) && playingNow ? undefined : b; // a track that was on another device is new here once it plays here
|
|
73
|
+
if (a?.track &&
|
|
74
|
+
named(a) &&
|
|
75
|
+
!playingElsewhere(a) &&
|
|
76
|
+
(a.track.uri !== heard?.track?.uri || !named(heard)))
|
|
61
77
|
add({ type: "track", track: a.track });
|
|
62
78
|
if (a?.stalled && !b?.stalled)
|
|
63
79
|
events.push({ type: "stalled", stall: a.stalled });
|
|
@@ -68,8 +84,12 @@ export function spotifyChanges(before, after) {
|
|
|
68
84
|
events.push({ type: "recovered" });
|
|
69
85
|
return events;
|
|
70
86
|
}
|
|
71
|
-
/**
|
|
72
|
-
|
|
87
|
+
/**
|
|
88
|
+
* The speaker in a few words: `Kitchen speaker: playing "Song" by Band · 35%`, `…: waiting for someone to pick it`, or,
|
|
89
|
+
* for music on another Spotify device, `…: silent · "Song" by Band is playing on another Spotify device` (no volume: it isn't this speaker's playing level).
|
|
90
|
+
* `on` names where, when the caller knows (`spotifyElsewhere`): `… is playing on Front room speaker`.
|
|
91
|
+
*/
|
|
92
|
+
export function spotifyText(s, on = []) {
|
|
73
93
|
const who = s.deviceName ?? "Soloist";
|
|
74
94
|
if (s.loggedIn === false)
|
|
75
95
|
return `${who}: waiting for someone to pick it`;
|
|
@@ -77,6 +97,8 @@ export function spotifyText(s) {
|
|
|
77
97
|
const what = t
|
|
78
98
|
? `${t.name ? ` "${t.name}"` : ` ${t.uri}`}${t.artists.length ? ` by ${t.artists.join(", ")}` : ""}`
|
|
79
99
|
: "";
|
|
100
|
+
if (playingElsewhere(s))
|
|
101
|
+
return `${who}: silent ·${what || " music"} is playing on ${on.length ? on.join(", ") : "another Spotify device"}`;
|
|
80
102
|
const parts = [
|
|
81
103
|
`${s.status ?? "status unknown"}${what}`,
|
|
82
104
|
s.volume === undefined ? "" : `${s.volume}%`,
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { type Clock } from "../../clock.ts";
|
|
3
|
-
import type
|
|
3
|
+
import { type DeviceRecord, type Integration, type Part } from "../../home.ts";
|
|
4
4
|
import type { Manifest } from "../../manifest.ts";
|
|
5
|
+
import { type SpeakerState } from "./changes.ts";
|
|
5
6
|
import { type SocketFactory, type SpotifyState } from "./client.ts";
|
|
6
7
|
export { positionMs, type PositionAnchor, type SocketFactory, type SocketLike, type SpotifyState, type Stall, toSpotifyUri, type Track, } from "./client.ts";
|
|
7
|
-
export { type SpeakerState, speakerState, type SpotifyEvent, spotifyChanges, spotifyText, } from "./changes.ts";
|
|
8
|
+
export { type SpeakerState, playingElsewhere, speakerState, type SpotifyEvent, spotifyChanges, spotifyText, } from "./changes.ts";
|
|
8
9
|
/** Soloist has no default port (`--ws ADDR:PORT` opens it); this library assumes this one, the port in chota-bot's unit. */
|
|
9
10
|
export declare const DEFAULT_URL = "ws://127.0.0.1:9091";
|
|
10
11
|
/** A Soloist speaker in a home config. No key: Soloist's API key is the daemon's, never the library's. */
|
|
@@ -92,3 +93,7 @@ export type SpotifyActions = {
|
|
|
92
93
|
*/
|
|
93
94
|
export declare function spotify(config?: SpotifyConfig): Integration<SpotifyState, "spotify"> & SpotifyActions;
|
|
94
95
|
export declare const manifest: Manifest<SpotifyState>;
|
|
96
|
+
/** The Soloist speaker as a home device: `state` is `speakerState`'s word; `detail` its `SpotifyState`. */
|
|
97
|
+
export type SpotifyHomeDevice = DeviceRecord<"spotify", "speaker", SpeakerState, SpotifyState>;
|
|
98
|
+
/** The one speaker, once Soloist has said something. Its name is Soloist's `--device-name`, the one phones show. */
|
|
99
|
+
export declare function spotifyHomeDevices(part: Part<SpotifyState> | undefined): SpotifyHomeDevice[];
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { asOf, realClock } from "../../clock.js";
|
|
3
3
|
import { ConnectorError, errorText } from "../../connector.js";
|
|
4
|
+
import { deviceRecords, } from "../../home.js";
|
|
4
5
|
import { writeLane } from "../../lane.js";
|
|
5
|
-
import { spotifyText, stallText } from "./changes.js";
|
|
6
|
+
import { speakerState, spotifyText, stallText, } from "./changes.js";
|
|
6
7
|
import { commandFrame, fold, Frame, FRAME_TYPES, OPEN, oddities, positionMs, toSpotifyUri, } from "./client.js";
|
|
7
8
|
export { positionMs, toSpotifyUri, } from "./client.js";
|
|
8
|
-
export { speakerState, spotifyChanges, spotifyText, } from "./changes.js";
|
|
9
|
+
export { playingElsewhere, speakerState, spotifyChanges, spotifyText, } from "./changes.js";
|
|
9
10
|
/** Soloist has no default port (`--ws ADDR:PORT` opens it); this library assumes this one, the port in chota-bot's unit. */
|
|
10
11
|
export const DEFAULT_URL = "ws://127.0.0.1:9091";
|
|
11
12
|
/** A Soloist speaker in a home config. No key: Soloist's API key is the daemon's, never the library's. */
|
|
@@ -259,6 +260,8 @@ export function spotify(config = {}) {
|
|
|
259
260
|
capping = false;
|
|
260
261
|
if (error instanceof ConnectorError && error.code === "offline")
|
|
261
262
|
return; // the status says it
|
|
263
|
+
if (capUntil !== window)
|
|
264
|
+
return applyCap(); // a new start while it went: this failure doesn't speak for that one
|
|
262
265
|
capped = true; // refused or unanswered: not again this window
|
|
263
266
|
note = `lowering the volume to ${cap} failed: ${errorText(error)}`;
|
|
264
267
|
publish();
|
|
@@ -563,6 +566,9 @@ export function spotify(config = {}) {
|
|
|
563
566
|
const no = notPlaying();
|
|
564
567
|
if (no)
|
|
565
568
|
return no;
|
|
569
|
+
// "playing" while inactive is the account playing on another speaker (live, 2026-11-10): a pause would stop that one.
|
|
570
|
+
if (inactive())
|
|
571
|
+
return "it is not the active device, so the music playing is on another speaker";
|
|
566
572
|
track = state.track?.uri;
|
|
567
573
|
try {
|
|
568
574
|
shown = (await actions.pause()).confirmed; // a pause that never shows still runs fn: what interrupts matters more
|
|
@@ -645,6 +651,7 @@ export function spotify(config = {}) {
|
|
|
645
651
|
if (!st ||
|
|
646
652
|
state.aside === "repair" ||
|
|
647
653
|
(state.track?.uri === st.track &&
|
|
654
|
+
state.active !== false && // moved to another device: no longer this speaker's stall
|
|
648
655
|
(state.status === "playing" || state.status === "buffering")))
|
|
649
656
|
return;
|
|
650
657
|
const { stalled: _, ...rest } = state;
|
|
@@ -672,7 +679,11 @@ export function spotify(config = {}) {
|
|
|
672
679
|
a.track === b.track &&
|
|
673
680
|
b.atMs - a.atMs >= gap &&
|
|
674
681
|
Math.abs(b.ms - a.ms) < 1000;
|
|
675
|
-
|
|
682
|
+
// Not while inactive: "playing" then is another device's music, and a repair's seek or pause would land on it.
|
|
683
|
+
const playing = () => isOpen() &&
|
|
684
|
+
state.status === "playing" &&
|
|
685
|
+
state.active !== false &&
|
|
686
|
+
!state.aside;
|
|
676
687
|
/** One look while playing: a fresh position, then frozen, overrun, recovered or fine (Soloist issue #5). */
|
|
677
688
|
const checkStall = async () => {
|
|
678
689
|
if (!playing())
|
|
@@ -714,6 +725,7 @@ export function spotify(config = {}) {
|
|
|
714
725
|
// Still ours to fix: playing this track, stalled, and no duck, interrupt or person in between.
|
|
715
726
|
const ours = () => isOpen() &&
|
|
716
727
|
state.status === "playing" &&
|
|
728
|
+
state.active !== false &&
|
|
717
729
|
state.track?.uri === track &&
|
|
718
730
|
state.stalled?.track === track &&
|
|
719
731
|
(!state.aside || state.aside === "repair");
|
|
@@ -812,3 +824,22 @@ export const manifest = {
|
|
|
812
824
|
? [spotifyText(state)]
|
|
813
825
|
: [],
|
|
814
826
|
};
|
|
827
|
+
/** The one speaker, once Soloist has said something. Its name is Soloist's `--device-name`, the one phones show. */
|
|
828
|
+
export function spotifyHomeDevices(part) {
|
|
829
|
+
const s = part?.state;
|
|
830
|
+
if (!s || (s.loggedIn === undefined && s.status === undefined))
|
|
831
|
+
return [];
|
|
832
|
+
return deviceRecords(part, [
|
|
833
|
+
{
|
|
834
|
+
connector: "spotify",
|
|
835
|
+
id: "spotify",
|
|
836
|
+
name: s.deviceName ?? "Soloist",
|
|
837
|
+
kind: "speaker",
|
|
838
|
+
available: true,
|
|
839
|
+
state: speakerState(part),
|
|
840
|
+
asOf: s.asOf,
|
|
841
|
+
text: spotifyText(s),
|
|
842
|
+
detail: s,
|
|
843
|
+
},
|
|
844
|
+
]);
|
|
845
|
+
}
|
|
@@ -185,6 +185,7 @@ export function bom(options = {}) {
|
|
|
185
185
|
if (!options.fetch && base.startsWith("ftp:"))
|
|
186
186
|
return {
|
|
187
187
|
stop: "bom: plain fetch cannot read ftp://; pass fetch: ftpFetch() from @khalido/home/weather/ftp",
|
|
188
|
+
stopReason: "config",
|
|
188
189
|
nextAt: Infinity,
|
|
189
190
|
};
|
|
190
191
|
if (force || at >= obsDue) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import { ConnectorError, fetchTimeout } from "../../connector.js";
|
|
3
3
|
import { RAIN_INTENSITIES } from "./rain.js";
|
|
4
4
|
import { sunDisagrees } from "./sun.js";
|
|
5
5
|
import { HOURLY_MINUTES, iso, nextPoll, num, RAIN_HORIZON_MINUTES, USER_AGENT, } from "./source.js";
|
|
@@ -273,6 +273,28 @@ export function toRain(minutes, fetchedAt) {
|
|
|
273
273
|
*/
|
|
274
274
|
export const MIN_POLL_MINUTES = 15;
|
|
275
275
|
export const CURRENT_MINUTES = 30;
|
|
276
|
+
/**
|
|
277
|
+
* Why Google refused: the `reason` of its error body's ErrorInfo ("API_KEY_INVALID"), since the status alone doesn't say.
|
|
278
|
+
* A bad key and a bad latitude are both HTTP 400 INVALID_ARGUMENT; only the key's carries a reason (live, 2026-11-10).
|
|
279
|
+
*/
|
|
280
|
+
async function refusal(res) {
|
|
281
|
+
try {
|
|
282
|
+
const body = (await res.json());
|
|
283
|
+
const reason = body.error?.details?.find((d) => d.reason)?.reason;
|
|
284
|
+
return typeof reason === "string" ? reason : undefined;
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
return undefined; // not JSON: the status is all there is
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/** Google's refusal: the HTTP status, and `why`, the reason its error body gave. */
|
|
291
|
+
class GoogleError extends ConnectorError {
|
|
292
|
+
why;
|
|
293
|
+
constructor(message, status, why) {
|
|
294
|
+
super(message, { status });
|
|
295
|
+
this.why = why;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
276
298
|
/**
|
|
277
299
|
* Google Weather as a source: modelled `now`, `forecast` (10 days available, 2 read), `sky` without radiation, and `rain` from the
|
|
278
300
|
* minute forecast (Experimental at Google, so its failure is a `problem` on `rain` and never stops the other slices).
|
|
@@ -295,11 +317,20 @@ export function google(options) {
|
|
|
295
317
|
},
|
|
296
318
|
minPollMinutes: MIN_POLL_MINUTES,
|
|
297
319
|
async read({ place, at, pollMinutes, force, signal, }) {
|
|
298
|
-
const q = (extra) => `
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
320
|
+
const q = (extra) => `location.latitude=${place.latitude}&location.longitude=${place.longitude}${extra}`;
|
|
321
|
+
// 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).
|
|
322
|
+
const json = async (path) => {
|
|
323
|
+
const res = await fetchTimeout(fetcher, `${base}/${path}`, {
|
|
324
|
+
headers: {
|
|
325
|
+
"user-agent": USER_AGENT,
|
|
326
|
+
"x-goog-api-key": options.key,
|
|
327
|
+
},
|
|
328
|
+
}, { signal });
|
|
329
|
+
if (res.ok)
|
|
330
|
+
return res.json();
|
|
331
|
+
const why = await refusal(res);
|
|
332
|
+
throw new GoogleError(`HTTP ${res.status}${why ? ` ${why}` : ""} on GET /${path.split("?")[0]}`, res.status, why);
|
|
333
|
+
};
|
|
303
334
|
// Started first and never throws: a failed minute forecast must not sink the rest.
|
|
304
335
|
const minutes = json(`forecast/minutes:lookup?${q("")}`).then((value) => ({ value }), (error) => ({ error: error }));
|
|
305
336
|
let problem;
|
|
@@ -324,10 +355,22 @@ export function google(options) {
|
|
|
324
355
|
}
|
|
325
356
|
}
|
|
326
357
|
catch (error) {
|
|
327
|
-
|
|
328
|
-
|
|
358
|
+
// Only a GoogleError is Google's answer; a timeout or an abort is a DOMException, with fields of its own.
|
|
359
|
+
const { status, why } = error instanceof GoogleError
|
|
360
|
+
? error
|
|
361
|
+
: {};
|
|
362
|
+
const said = `HTTP ${status}${why ? ` ${why}` : ""}`;
|
|
363
|
+
if (status === 401 || status === 403 || why?.startsWith("API_KEY_"))
|
|
364
|
+
return {
|
|
365
|
+
stop: `google: the key was rejected (${said})`,
|
|
366
|
+
stopReason: "auth",
|
|
367
|
+
nextAt: Infinity,
|
|
368
|
+
};
|
|
369
|
+
// A 400 with a good key is the request itself, and here that is the location: asking again won't change it.
|
|
370
|
+
if (status === 400)
|
|
329
371
|
return {
|
|
330
|
-
stop: `google:
|
|
372
|
+
stop: `google: the request was refused (${said}); check the latitude and longitude`,
|
|
373
|
+
stopReason: "config",
|
|
331
374
|
nextAt: Infinity,
|
|
332
375
|
};
|
|
333
376
|
problem = `google: ${error.message}`; // the old slices stay; the minute forecast below is still used
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Clock } from "../../clock.ts";
|
|
2
|
-
import type { Integration } from "../../home.ts";
|
|
2
|
+
import type { Integration, Reason } from "../../home.ts";
|
|
3
3
|
import { type Manifest } from "../../manifest.ts";
|
|
4
4
|
import { type BlockDef, type DayBlocks } from "./blocks.ts";
|
|
5
5
|
import { type RainSoon } from "./rain.ts";
|
|
@@ -38,6 +38,7 @@ export type WeatherState = Slices & {
|
|
|
38
38
|
read: boolean;
|
|
39
39
|
problem?: string;
|
|
40
40
|
stopped?: string;
|
|
41
|
+
reason?: Exclude<Reason, "crashed">;
|
|
41
42
|
anomaly?: string;
|
|
42
43
|
}>;
|
|
43
44
|
};
|
|
@@ -66,6 +66,7 @@ export function weather(config) {
|
|
|
66
66
|
read: r !== undefined,
|
|
67
67
|
problem: r?.problem,
|
|
68
68
|
stopped: r?.stop,
|
|
69
|
+
reason: r?.stop ? (r.stopReason ?? "config") : undefined,
|
|
69
70
|
anomaly: r?.anomaly,
|
|
70
71
|
},
|
|
71
72
|
];
|
|
@@ -87,6 +88,12 @@ export function weather(config) {
|
|
|
87
88
|
: undefined;
|
|
88
89
|
update({
|
|
89
90
|
status,
|
|
91
|
+
// Every source has stopped: a rejected key outranks a wrong setting, since it needs a person.
|
|
92
|
+
reason: status !== "unavailable"
|
|
93
|
+
? undefined
|
|
94
|
+
: Object.values(report).some((r) => r.reason === "auth")
|
|
95
|
+
? "auth"
|
|
96
|
+
: "config",
|
|
90
97
|
detail,
|
|
91
98
|
anomaly: notes("anomaly"),
|
|
92
99
|
lastGoodAt,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Reason } from "../../home.ts";
|
|
1
2
|
/** Sent on every request, so each service can see who is calling rather than a runtime's default string. */
|
|
2
3
|
export declare const USER_AGENT = "home (+https://github.com/khalido/home.ts)";
|
|
3
4
|
export type Place = {
|
|
@@ -116,6 +117,7 @@ export type SourceReading = Slices & {
|
|
|
116
117
|
problem?: string;
|
|
117
118
|
anomaly?: string;
|
|
118
119
|
stop?: string;
|
|
120
|
+
stopReason?: Exclude<Reason, "crashed">;
|
|
119
121
|
};
|
|
120
122
|
/**
|
|
121
123
|
* A weather provider. `owns` names the slices it can fill and how old each may get (by its `at`, beyond one polling
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type CastHomeDevice, castHomeDevices } from "./connectors/cast/index.ts";
|
|
2
|
+
import { type DaikinHomeDevice, daikinHomeDevices } from "./connectors/daikin/index.ts";
|
|
3
|
+
import { type EnphaseHomeDevice, enphaseHomeDevices } from "./connectors/enphase/index.ts";
|
|
4
|
+
import { type HueHomeDevice, hueHomeDevices } from "./connectors/hue/index.ts";
|
|
5
|
+
import { type PrinterHomeDevice, printersHomeDevices } from "./connectors/printers/index.ts";
|
|
6
|
+
import { type SpotifyHomeDevice, spotifyHomeDevices } from "./connectors/spotify/index.ts";
|
|
7
|
+
import type { HomeConfig } from "./config.ts";
|
|
8
|
+
import type { Part } from "./home.ts";
|
|
9
|
+
/** Any device this library reads. `connector`, then `kind` where a connector has more than one, narrows `detail` and `state`. */
|
|
10
|
+
export type HomeDevice = HueHomeDevice | DaikinHomeDevice | EnphaseHomeDevice | CastHomeDevice | PrinterHomeDevice | SpotifyHomeDevice;
|
|
11
|
+
/** Each connector's devices from its part, in the order `home status` lists the connectors. */
|
|
12
|
+
export declare const DEVICES: {
|
|
13
|
+
hue: typeof hueHomeDevices;
|
|
14
|
+
daikin: typeof daikinHomeDevices;
|
|
15
|
+
enphase: typeof enphaseHomeDevices;
|
|
16
|
+
cast: typeof castHomeDevices;
|
|
17
|
+
printers: typeof printersHomeDevices;
|
|
18
|
+
spotify: typeof spotifyHomeDevices;
|
|
19
|
+
};
|
|
20
|
+
/** Connectors that read a service, not a device: nothing to list. */
|
|
21
|
+
export declare const SERVICES: readonly string[];
|
|
22
|
+
/**
|
|
23
|
+
* Every device in the snapshot. With the home `config`, a device takes the `room` its entry gives it, a Hue room or zone
|
|
24
|
+
* (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
|
|
25
|
+
* no name of its own (the Daikin, the Enphase gateway) the entry's `name`. A device's own name is never replaced.
|
|
26
|
+
*/
|
|
27
|
+
export declare function homeDevices(snapshot: Record<string, Part | undefined>, config?: Pick<HomeConfig, "devices" | "rooms">): HomeDevice[];
|
|
28
|
+
/**
|
|
29
|
+
* Where the account's Spotify is playing when the Soloist speaker says it is on another device: the names of the Cast
|
|
30
|
+
* devices and groups playing a track of that name in Spotify. A group is named, not its members. Empty when the music is
|
|
31
|
+
* on the speaker itself, nothing is playing, or it is on something Cast can't see (a phone, a laptop). Matched by the
|
|
32
|
+
* track's name, since neither side names the other.
|
|
33
|
+
*/
|
|
34
|
+
export declare function spotifyElsewhere(snapshot: Record<string, Part | undefined>): string[];
|
|
35
|
+
/** 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. */
|
|
36
|
+
export type RoomDevices = {
|
|
37
|
+
id: string;
|
|
38
|
+
name: string;
|
|
39
|
+
floor?: string;
|
|
40
|
+
devices: HomeDevice[];
|
|
41
|
+
noData: string[];
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* `homeDevices` room by room, in the config's order, for a map of the home. A connector that is down but has read before
|
|
45
|
+
* still lists its devices, `available: false`; `noData` names one the config places in the room (a device's `room`, or
|
|
46
|
+
* `hueRoom`) that has nothing to list. `unplaced`: devices with no room, or one the config doesn't have.
|
|
47
|
+
*/
|
|
48
|
+
export declare function byRoom(snapshot: Record<string, Part | undefined>, config: Pick<HomeConfig, "devices" | "rooms">): {
|
|
49
|
+
rooms: RoomDevices[];
|
|
50
|
+
unplaced: HomeDevice[];
|
|
51
|
+
};
|