@khalido/home 0.1.1 → 0.1.3
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/CHANGELOG.md +60 -2
- package/README.md +12 -12
- package/dist/clock.js +2 -0
- package/dist/config.js +3 -1
- package/dist/connector.d.ts +9 -2
- package/dist/connectors/amber/index.js +1 -0
- package/dist/connectors/cast/index.d.ts +10 -5
- package/dist/connectors/cast/index.js +36 -4
- package/dist/connectors/cast/schemas.d.ts +2 -0
- package/dist/connectors/cast/schemas.js +1 -0
- package/dist/connectors/cast/state.d.ts +56 -0
- package/dist/connectors/cast/state.js +134 -0
- package/dist/connectors/cast/writes.d.ts +9 -2
- package/dist/connectors/cast/writes.js +67 -13
- package/dist/connectors/daikin/index.d.ts +6 -2
- package/dist/connectors/daikin/index.js +21 -1
- package/dist/connectors/enphase/index.d.ts +5 -1
- package/dist/connectors/enphase/index.js +34 -2
- package/dist/connectors/hue/index.d.ts +6 -2
- package/dist/connectors/hue/index.js +39 -6
- package/dist/connectors/printers/index.d.ts +6 -2
- package/dist/connectors/printers/index.js +23 -0
- package/dist/connectors/spotify/changes.d.ts +1 -1
- package/dist/connectors/spotify/changes.js +4 -2
- package/dist/connectors/spotify/client.d.ts +1 -1
- package/dist/connectors/spotify/index.d.ts +9 -2
- package/dist/connectors/spotify/index.js +130 -45
- package/dist/connectors/up/index.d.ts +4 -2
- package/dist/connectors/up/index.js +3 -1
- 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 +27 -0
- package/dist/devices.js +61 -0
- package/dist/discover.d.ts +2 -0
- package/dist/discover.js +4 -1
- package/dist/health.d.ts +2 -1
- package/dist/health.js +2 -1
- package/dist/home.d.ts +41 -3
- package/dist/home.js +24 -7
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/lane.js +24 -20
- package/docs/cast.md +1 -1
- package/docs/cli.md +3 -3
- package/docs/config.md +8 -3
- package/docs/decisions.md +5 -0
- package/docs/using.md +17 -0
- package/package.json +1 -1
- package/skills/use-home/SKILL.md +7 -7
- package/src/cli/cast.ts +19 -11
- package/src/cli/enphase.ts +5 -1
- package/src/cli/spotify.ts +2 -1
- package/src/cli/status.ts +26 -3
- package/src/clock.ts +1 -0
- package/src/config.ts +3 -1
- package/src/connector.ts +18 -2
- package/src/connectors/amber/index.ts +1 -0
- package/src/connectors/cast/README.md +10 -5
- package/src/connectors/cast/index.ts +74 -9
- package/src/connectors/cast/schemas.ts +1 -0
- package/src/connectors/cast/state.ts +189 -0
- package/src/connectors/cast/writes.ts +87 -17
- package/src/connectors/daikin/README.md +4 -0
- package/src/connectors/daikin/index.ts +41 -2
- package/src/connectors/enphase/index.ts +49 -2
- package/src/connectors/hue/index.ts +57 -8
- package/src/connectors/printers/README.md +4 -2
- package/src/connectors/printers/index.ts +43 -1
- package/src/connectors/spotify/README.md +33 -9
- package/src/connectors/spotify/changes.ts +4 -2
- package/src/connectors/spotify/client.ts +1 -1
- package/src/connectors/spotify/index.ts +157 -49
- package/src/connectors/up/README.md +1 -1
- package/src/connectors/up/index.ts +6 -2
- 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 +102 -0
- package/src/discover.ts +6 -2
- package/src/health.ts +4 -2
- package/src/home.ts +75 -8
- package/src/index.ts +5 -1
- package/src/lane.ts +23 -20
|
@@ -2,15 +2,15 @@ import { errorText } from "../../connector.js";
|
|
|
2
2
|
import { writeLane } from "../../lane.js";
|
|
3
3
|
import { NS } from "./codec.js";
|
|
4
4
|
import { MediaStatus, MultizoneStatus, ReceiverStatus, } from "./schemas.js";
|
|
5
|
-
import { castKey } from "./state.js";
|
|
5
|
+
import { castKey, DEFAULT_MEDIA_RECEIVER, findCast, } from "./state.js";
|
|
6
6
|
// Writes over the sockets `cast()` already holds: volume, mute, stop, and an announcement that plays a clip and puts
|
|
7
|
-
// the device back as it was. Never called by the integration itself;
|
|
8
|
-
/** Google's own player for a URL of audio or video. */
|
|
9
|
-
export const DEFAULT_MEDIA_RECEIVER = "CC1AD845";
|
|
7
|
+
// the device back as it was. Never called by the integration itself; announce, volume and mute have run on real devices (the README has the dates).
|
|
10
8
|
/** Each request (a status, a volume, LAUNCH, LOAD, STOP) gets this long to answer. */
|
|
11
9
|
export const ANSWER_MS = 10_000;
|
|
12
10
|
/** How long an announcement waits for its clip to finish, beyond the clip's own length when the device reports it. */
|
|
13
11
|
export const PLAY_MS = 60_000;
|
|
12
|
+
/** How long a clip gets to start playing. A clip a speaker can't play took 35–44 s to say so (live, 2026-11-09); good ones start within a few seconds. A stated guess. */
|
|
13
|
+
export const START_MS = 15_000;
|
|
14
14
|
const RECEIVER = "receiver-0";
|
|
15
15
|
/** Level writes, and mute writes, per device or group in any minute; more wait their turn, the newest winning. A guess: Cast publishes no limit. */
|
|
16
16
|
export const VOLUME_PER_MINUTE = 30;
|
|
@@ -33,6 +33,7 @@ function checkLevel(level) {
|
|
|
33
33
|
* aborts, or after `ms` (a function is read again each time it would expire, so a deadline can grow).
|
|
34
34
|
*/
|
|
35
35
|
function waitFor(clock, s, ms, what, pick, signal) {
|
|
36
|
+
const name = () => (typeof what === "string" ? what : what());
|
|
36
37
|
return new Promise((resolve, reject) => {
|
|
37
38
|
const done = new AbortController();
|
|
38
39
|
const finish = (settle) => done.signal.aborted ||
|
|
@@ -40,10 +41,10 @@ function waitFor(clock, s, ms, what, pick, signal) {
|
|
|
40
41
|
signal?.removeEventListener("abort", cancel),
|
|
41
42
|
done.abort(),
|
|
42
43
|
settle());
|
|
43
|
-
const cancel = () => finish(() => reject(new Error(`stopped waiting for ${
|
|
44
|
+
const cancel = () => finish(() => reject(new Error(`stopped waiting for ${name()}`)));
|
|
44
45
|
const off = s.listen((raw) => {
|
|
45
46
|
if (raw === undefined)
|
|
46
|
-
return finish(() => reject(new Error(`the socket closed waiting for ${
|
|
47
|
+
return finish(() => reject(new Error(`the socket closed waiting for ${name()}`)));
|
|
47
48
|
try {
|
|
48
49
|
const value = pick(raw);
|
|
49
50
|
if (value !== undefined)
|
|
@@ -59,7 +60,7 @@ function waitFor(clock, s, ms, what, pick, signal) {
|
|
|
59
60
|
void (async () => {
|
|
60
61
|
for (let left = limit(); left > 0 && !done.signal.aborted; left = start + limit() - clock.now())
|
|
61
62
|
await clock.sleep(left, done.signal);
|
|
62
|
-
finish(() => reject(new Error(`no ${
|
|
63
|
+
finish(() => reject(new Error(`no ${name()} in ${Math.round(limit() / 1000)} s`)));
|
|
63
64
|
})();
|
|
64
65
|
});
|
|
65
66
|
}
|
|
@@ -80,8 +81,7 @@ export function castActions(ctx) {
|
|
|
80
81
|
const { clock } = ctx;
|
|
81
82
|
const find = (target) => {
|
|
82
83
|
const all = Object.values(ctx.devices());
|
|
83
|
-
const d =
|
|
84
|
-
all.find((x) => x.name.toLowerCase() === target.toLowerCase());
|
|
84
|
+
const d = findCast(ctx.devices(), target);
|
|
85
85
|
if (!d)
|
|
86
86
|
throw new Error(`no Cast device or group "${target}"; have: ${all
|
|
87
87
|
.map((x) => x.name)
|
|
@@ -171,8 +171,11 @@ export function castActions(ctx) {
|
|
|
171
171
|
const loadId = s.nextId();
|
|
172
172
|
let session;
|
|
173
173
|
let durationS = 0;
|
|
174
|
+
let started = false; // it has reached PLAYING: BUFFERING is where a clip it can't play sits
|
|
174
175
|
const cancel = new AbortController();
|
|
175
|
-
const finished = waitFor(clock, s, () => timeoutMs ?? PLAY_MS + durationS * 1000
|
|
176
|
+
const finished = waitFor(clock, s, () => timeoutMs ?? (started ? PLAY_MS + durationS * 1000 : START_MS), () => started
|
|
177
|
+
? "end of the clip"
|
|
178
|
+
: `start of the clip (${media.contentType}, which this device may not play)`, (raw) => {
|
|
176
179
|
const r = MediaStatus.safeParse(raw);
|
|
177
180
|
if (!r.success)
|
|
178
181
|
return undefined;
|
|
@@ -187,11 +190,13 @@ export function castActions(ctx) {
|
|
|
187
190
|
continue;
|
|
188
191
|
if (typeof m.media?.duration === "number")
|
|
189
192
|
durationS = m.media.duration;
|
|
193
|
+
if (m.playerState === "PLAYING")
|
|
194
|
+
started = true;
|
|
190
195
|
if (m.playerState !== "IDLE" || !m.idleReason)
|
|
191
196
|
continue;
|
|
192
197
|
if (m.idleReason === "FINISHED")
|
|
193
198
|
return true;
|
|
194
|
-
throw new Error(`the clip stopped: ${m.idleReason}`);
|
|
199
|
+
throw new Error(`the clip stopped: ${m.idleReason}${m.detailedErrorCode === undefined ? "" : ` (detailed error ${m.detailedErrorCode})`}${m.idleReason === "ERROR" ? `; this device may not play ${media.contentType}` : ""}`);
|
|
195
200
|
}
|
|
196
201
|
return undefined;
|
|
197
202
|
}, cancel.signal);
|
|
@@ -216,7 +221,7 @@ export function castActions(ctx) {
|
|
|
216
221
|
.get(id)
|
|
217
222
|
.send("level" in volume ? "level" : "muted", volume, (v) => receiver(live(find(target)), { type: "SET_VOLUME", volume: v }));
|
|
218
223
|
};
|
|
219
|
-
|
|
224
|
+
const actions = {
|
|
220
225
|
async setVolume(target, level) {
|
|
221
226
|
checkLevel(level);
|
|
222
227
|
await setVolume(target, { level });
|
|
@@ -236,7 +241,7 @@ export function castActions(ctx) {
|
|
|
236
241
|
/**
|
|
237
242
|
* Read the target's state, set the volume if asked and unmute a muted target, launch the Default Media Receiver, play the clip to its end, then
|
|
238
243
|
* put back what it can, always: what the Default Media Receiver was playing is loaded again where it was, an idle
|
|
239
|
-
* device gets STOP, then the volume (a group's, member by member through each member's own socket). Another app
|
|
244
|
+
* device gets STOP, then the volume (a group's own first, then member by member through each member's own socket). Another app
|
|
240
245
|
* (Spotify, YouTube) is interrupted and cannot be resumed from here; the result says so. Throws only before anything changed.
|
|
241
246
|
*/
|
|
242
247
|
async announce(target, clip, options = {}) {
|
|
@@ -352,6 +357,27 @@ export function castActions(ctx) {
|
|
|
352
357
|
muted: before.volume?.muted,
|
|
353
358
|
},
|
|
354
359
|
];
|
|
360
|
+
// A group keeps a level of its own, which its members' levels don't bring back (live, 2026-11-10: it stayed at the
|
|
361
|
+
// announcement's 30 with every member restored). First, so whatever it does to the members, their own levels land last.
|
|
362
|
+
if (d.kind === "group" && options.volume !== undefined) {
|
|
363
|
+
const level = before.volume?.level;
|
|
364
|
+
if (level === undefined)
|
|
365
|
+
result.notRestored.push({
|
|
366
|
+
name: d.name,
|
|
367
|
+
why: "its volume before was not reported",
|
|
368
|
+
});
|
|
369
|
+
else
|
|
370
|
+
try {
|
|
371
|
+
await receiver(s, { type: "SET_VOLUME", volume: { level } });
|
|
372
|
+
result.restored.push(`${d.name}: volume`);
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
result.notRestored.push({
|
|
376
|
+
name: d.name,
|
|
377
|
+
why: `volume not restored: ${errorText(error)}`,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
355
381
|
for (const m of each) {
|
|
356
382
|
const ms = m.id === d.id ? s : ctx.session(m.id);
|
|
357
383
|
if (m.level === undefined)
|
|
@@ -386,5 +412,33 @@ export function castActions(ctx) {
|
|
|
386
412
|
}
|
|
387
413
|
}
|
|
388
414
|
},
|
|
415
|
+
async announceAll(targets, clip, options = {}) {
|
|
416
|
+
// A caller's mistake is one error, not a failure per speaker.
|
|
417
|
+
if (options.volume !== undefined)
|
|
418
|
+
checkLevel(options.volume);
|
|
419
|
+
if (!("url" in clip) && !ctx.serve)
|
|
420
|
+
throw new Error("a clip of bytes needs serve: serveClip from @khalido/home/cast/serve");
|
|
421
|
+
const id = (t) => {
|
|
422
|
+
try {
|
|
423
|
+
return find(t).id;
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
return t; // unknown: announce says so, as this target's own failure
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
const once = targets.filter((t, i) => targets.findIndex((o) => id(o) === id(t)) === i); // one device twice would save the first's volume as the one to restore
|
|
430
|
+
return Promise.all(once.map(async (target) => ({
|
|
431
|
+
target,
|
|
432
|
+
...(await actions
|
|
433
|
+
.announce(target, clip, options)
|
|
434
|
+
.catch((error) => ({
|
|
435
|
+
played: false,
|
|
436
|
+
why: errorText(error),
|
|
437
|
+
restored: [],
|
|
438
|
+
notRestored: [],
|
|
439
|
+
}))),
|
|
440
|
+
})));
|
|
441
|
+
},
|
|
389
442
|
};
|
|
443
|
+
return actions;
|
|
390
444
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { type Clock } from "../../clock.ts";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import type
|
|
3
|
+
import { type DeviceRecord, type Integration, type Part } from "../../home.ts";
|
|
4
4
|
import { type Finder } from "../../discover.ts";
|
|
5
5
|
import type { Manifest } from "../../manifest.ts";
|
|
6
|
-
import { daikinClient, type DaikinState } from "./client.ts";
|
|
6
|
+
import { daikinClient, type DaikinState, type Mode } from "./client.ts";
|
|
7
7
|
export { DaikinError, type DaikinState, daikinClient, type Fan, type Mode, setFan, setMode, setPower, setTarget, type WriteRequest, } from "./client.ts";
|
|
8
8
|
/** Poll every 120 s, as Home Assistant's daikin integration does (its coordinator's update_interval). */
|
|
9
9
|
export declare const POLL_MS = 120000;
|
|
@@ -39,3 +39,7 @@ export declare function daikin(config: DaikinConfig): Integration<DaikinSnapshot
|
|
|
39
39
|
/** The AC in a few words: "off · cool 25 °C · room 24 °C · outdoor unit 19.5 °C". */
|
|
40
40
|
export declare function acText(s: DaikinState): string;
|
|
41
41
|
export declare const manifest: Manifest<DaikinSnapshot>;
|
|
42
|
+
/** The AC as a home device: `state` is `off`, its mode while on, or `on` when it hasn't said which; `detail` its snapshot. */
|
|
43
|
+
export type DaikinHomeDevice = DeviceRecord<"daikin", "ac", "off" | "on" | Mode, DaikinSnapshot>;
|
|
44
|
+
/** The one unit, once it has been read. It reports no name or id of its own: a home config's entry names and places it. */
|
|
45
|
+
export declare function daikinHomeDevices(part: Part<DaikinSnapshot> | undefined): DaikinHomeDevice[];
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { asOf, realClock } from "../../clock.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { deviceRecords, } from "../../home.js";
|
|
3
4
|
import { lostDetail, relocator } from "../../discover.js";
|
|
4
5
|
import { poller } from "../../poll.js";
|
|
5
|
-
import { daikinClient, DaikinError } from "./client.js";
|
|
6
|
+
import { daikinClient, DaikinError, } from "./client.js";
|
|
6
7
|
export { DaikinError, daikinClient, setFan, setMode, setPower, setTarget, } from "./client.js";
|
|
7
8
|
/** Poll every 120 s, as Home Assistant's daikin integration does (its coordinator's update_interval). */
|
|
8
9
|
export const POLL_MS = 120_000;
|
|
@@ -106,3 +107,22 @@ export const manifest = {
|
|
|
106
107
|
],
|
|
107
108
|
lines: ({ state }) => (state ? [acText(state)] : []),
|
|
108
109
|
};
|
|
110
|
+
/** The one unit, once it has been read. It reports no name or id of its own: a home config's entry names and places it. */
|
|
111
|
+
export function daikinHomeDevices(part) {
|
|
112
|
+
const s = part?.state;
|
|
113
|
+
if (!s)
|
|
114
|
+
return [];
|
|
115
|
+
return deviceRecords(part, [
|
|
116
|
+
{
|
|
117
|
+
connector: "daikin",
|
|
118
|
+
id: "daikin",
|
|
119
|
+
name: manifest.title,
|
|
120
|
+
kind: "ac",
|
|
121
|
+
available: true,
|
|
122
|
+
...(s.on === undefined ? {} : { state: s.on ? (s.mode ?? "on") : "off" }),
|
|
123
|
+
asOf: s.asOf,
|
|
124
|
+
text: `${manifest.title}: ${acText(s)}`,
|
|
125
|
+
detail: s,
|
|
126
|
+
},
|
|
127
|
+
]);
|
|
128
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type Clock } from "../../clock.ts";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { type Integration } from "../../home.ts";
|
|
3
|
+
import { type DeviceRecord, type Integration, type Part } from "../../home.ts";
|
|
4
4
|
import { type Finder } from "../../discover.ts";
|
|
5
5
|
import { type Manifest } from "../../manifest.ts";
|
|
6
6
|
import { type EnphaseState } from "./client.ts";
|
|
@@ -41,3 +41,7 @@ export declare function enphase(config: EnphaseConfig): Integration<EnphaseSnaps
|
|
|
41
41
|
/** "solar 2.1 kW, using 0.6 kW · token 300 days left". The login ask near the end is the integration's, in `detail`. */
|
|
42
42
|
export declare function enphaseText(s: EnphaseSnapshot): string;
|
|
43
43
|
export declare const manifest: Manifest<EnphaseSnapshot>;
|
|
44
|
+
/** The gateway as a home device: `state` is `producing` while the solar makes power, else `idle`; `detail` its snapshot. */
|
|
45
|
+
export type EnphaseHomeDevice = DeviceRecord<"enphase", "solar", "producing" | "idle", EnphaseSnapshot>;
|
|
46
|
+
/** The one gateway, once it has been read. */
|
|
47
|
+
export declare function enphaseHomeDevices(part: Part<EnphaseSnapshot> | undefined): EnphaseHomeDevice[];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { localDay, realClock } from "../../clock.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { filePath } from "../../home.js";
|
|
3
|
+
import { deviceRecords, filePath, } from "../../home.js";
|
|
4
4
|
import { lostDetail, relocator } from "../../discover.js";
|
|
5
5
|
import { kw } from "../../manifest.js";
|
|
6
6
|
import { poller } from "../../poll.js";
|
|
@@ -51,7 +51,17 @@ export function enphase(config) {
|
|
|
51
51
|
name: "enphase",
|
|
52
52
|
refresh: () => (where.reset(), poll.nudge()),
|
|
53
53
|
async run(signal, update) {
|
|
54
|
-
|
|
54
|
+
let expiresAt;
|
|
55
|
+
try {
|
|
56
|
+
expiresAt = tokenExpiry(config.token); // a bad token fails this integration only
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return update({
|
|
60
|
+
status: "unavailable",
|
|
61
|
+
reason: "auth",
|
|
62
|
+
detail: "the token is unreadable; run `home enphase login`",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
55
65
|
let last;
|
|
56
66
|
let day;
|
|
57
67
|
let moved; // kept in `detail` so a consumer can persist the new address
|
|
@@ -106,6 +116,7 @@ export function enphase(config) {
|
|
|
106
116
|
if (error instanceof EnphaseError && error.status === 401)
|
|
107
117
|
return update({
|
|
108
118
|
status: "unavailable",
|
|
119
|
+
reason: "auth",
|
|
109
120
|
detail: "the gateway rejected the token; run `home enphase login`",
|
|
110
121
|
state: last,
|
|
111
122
|
});
|
|
@@ -172,3 +183,24 @@ export const manifest = {
|
|
|
172
183
|
files: ["token"],
|
|
173
184
|
lines: ({ state }) => (state ? [enphaseText(state)] : []),
|
|
174
185
|
};
|
|
186
|
+
/** The one gateway, once it has been read. */
|
|
187
|
+
export function enphaseHomeDevices(part) {
|
|
188
|
+
const s = part?.state;
|
|
189
|
+
if (!s)
|
|
190
|
+
return [];
|
|
191
|
+
return deviceRecords(part, [
|
|
192
|
+
{
|
|
193
|
+
connector: "enphase",
|
|
194
|
+
id: "enphase",
|
|
195
|
+
name: manifest.title,
|
|
196
|
+
kind: "solar",
|
|
197
|
+
available: true,
|
|
198
|
+
...(s.productionW === undefined
|
|
199
|
+
? {}
|
|
200
|
+
: { state: s.productionW > 0 ? "producing" : "idle" }),
|
|
201
|
+
asOf: s.asOf,
|
|
202
|
+
text: `${manifest.title}: ${enphaseText(s)}`,
|
|
203
|
+
detail: s,
|
|
204
|
+
},
|
|
205
|
+
]);
|
|
206
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { type Clock } from "../../clock.ts";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { type Finder } from "../../discover.ts";
|
|
4
|
-
import { type Integration } from "../../home.ts";
|
|
4
|
+
import { type DeviceRecord, type Integration, type Part } from "../../home.ts";
|
|
5
5
|
import type { Manifest } from "../../manifest.ts";
|
|
6
6
|
import { type HueClient } from "./client.ts";
|
|
7
|
-
import { type HueSnapshot } from "./state.ts";
|
|
7
|
+
import { type GroupView, type HueSnapshot, type LightView } from "./state.ts";
|
|
8
8
|
export { bunPinnedFetch, type HueClient, HueError, type HueOptions, hueClient, pair, } from "./client.ts";
|
|
9
9
|
export type { HueEvent, Resource } from "./schemas.ts";
|
|
10
10
|
export { type GroupView, groups, type HueSnapshot, homeGroupedLightId, type LightView, lights, scenes, } from "./state.ts";
|
|
@@ -39,4 +39,8 @@ export type HueConfig = {
|
|
|
39
39
|
export declare function hue(config: HueConfig): Integration<HueSnapshot, "hue"> & {
|
|
40
40
|
readonly client: HueClient;
|
|
41
41
|
};
|
|
42
|
+
/** A Hue light, or a room or zone, as a home device: `detail` is its `LightView` or `GroupView`. */
|
|
43
|
+
export type HueHomeDevice = DeviceRecord<"hue", "light", "on" | "off", LightView> | DeviceRecord<"hue", "group", "on" | "off", GroupView>;
|
|
44
|
+
/** Every light, then every room and zone. A light's `available` is its Zigbee link; a group's `members` are its lights. */
|
|
45
|
+
export declare function hueHomeDevices(part: Part<HueSnapshot> | undefined): HueHomeDevice[];
|
|
42
46
|
export declare const manifest: Manifest<HueSnapshot>;
|
|
@@ -2,7 +2,7 @@ import { asOf, realClock } from "../../clock.js";
|
|
|
2
2
|
import { errorText } from "../../connector.js";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { lostDetail, relocator } from "../../discover.js";
|
|
5
|
-
import { envName, filePath, } from "../../home.js";
|
|
5
|
+
import { envName, filePath, deviceRecords, } from "../../home.js";
|
|
6
6
|
import { bunPinnedFetch, hueClient } from "./client.js";
|
|
7
7
|
import { hueEvents, isRejectedKey } from "./events.js";
|
|
8
8
|
import { groups, lights, reduce, snapshotFrom, } from "./state.js";
|
|
@@ -137,6 +137,7 @@ export function hue(config) {
|
|
|
137
137
|
if (isRejectedKey(error))
|
|
138
138
|
return update({
|
|
139
139
|
status: "unavailable",
|
|
140
|
+
reason: "auth",
|
|
140
141
|
detail: "the bridge rejected the key; pair again",
|
|
141
142
|
state: snapshot,
|
|
142
143
|
});
|
|
@@ -161,6 +162,41 @@ const view = (s) => ({
|
|
|
161
162
|
brightness,
|
|
162
163
|
})),
|
|
163
164
|
});
|
|
165
|
+
/** "Kitchen: on · 40%", "Kitchen: off", or the name alone when the bridge hasn't said. */
|
|
166
|
+
const onText = (v) => `${v.name ?? v.id}${v.on === undefined ? "" : `: ${v.on ? `on${v.brightness === undefined ? "" : ` · ${Math.round(v.brightness)}%`}` : "off"}`}`;
|
|
167
|
+
/** Every light, then every room and zone. A light's `available` is its Zigbee link; a group's `members` are its lights. */
|
|
168
|
+
export function hueHomeDevices(part) {
|
|
169
|
+
const s = part?.state;
|
|
170
|
+
if (!s)
|
|
171
|
+
return [];
|
|
172
|
+
const on = (v) => v.on === undefined
|
|
173
|
+
? {}
|
|
174
|
+
: { state: v.on ? "on" : "off" };
|
|
175
|
+
return deviceRecords(part, [
|
|
176
|
+
...lights(s).map((l) => ({
|
|
177
|
+
connector: "hue",
|
|
178
|
+
id: l.id,
|
|
179
|
+
name: l.name ?? l.id,
|
|
180
|
+
kind: "light",
|
|
181
|
+
...(l.reachable === undefined ? {} : { available: l.reachable }),
|
|
182
|
+
...on(l),
|
|
183
|
+
asOf: s.asOf,
|
|
184
|
+
text: onText(l),
|
|
185
|
+
detail: l,
|
|
186
|
+
})),
|
|
187
|
+
...groups(s).map((g) => ({
|
|
188
|
+
connector: "hue",
|
|
189
|
+
id: g.id,
|
|
190
|
+
name: g.name ?? g.id,
|
|
191
|
+
kind: "group",
|
|
192
|
+
...on(g),
|
|
193
|
+
asOf: s.asOf,
|
|
194
|
+
text: onText(g),
|
|
195
|
+
members: g.lightIds,
|
|
196
|
+
detail: g,
|
|
197
|
+
})),
|
|
198
|
+
]);
|
|
199
|
+
}
|
|
164
200
|
export const manifest = {
|
|
165
201
|
name: "hue",
|
|
166
202
|
title: "Philips Hue",
|
|
@@ -195,11 +231,8 @@ export const manifest = {
|
|
|
195
231
|
lines({ state }) {
|
|
196
232
|
if (!state)
|
|
197
233
|
return [];
|
|
198
|
-
const { lightsOn
|
|
199
|
-
return [
|
|
200
|
-
`${lightsOn} lights on`,
|
|
201
|
-
...rooms.map((r) => `${r.name}: ${r.on ? `on${r.brightness === undefined ? "" : ` · ${Math.round(r.brightness)}%`}` : "off"}`),
|
|
202
|
-
];
|
|
234
|
+
const { lightsOn } = view(state);
|
|
235
|
+
return [`${lightsOn} lights on`, ...groups(state).map(onText)];
|
|
203
236
|
},
|
|
204
237
|
record: ({ state }) => (state ? view(state) : {}),
|
|
205
238
|
};
|
|
@@ -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
|
+
}
|
|
@@ -24,7 +24,7 @@ export type SpotifyEvent = {
|
|
|
24
24
|
/**
|
|
25
25
|
* The events between two reports, for `onChange`'s `before` and `after`: `offline` and `online`; `waiting` (signed out) and
|
|
26
26
|
* `picked` (someone picked the speaker); `started` (from idle), `resumed` (from paused), `paused`, `stopped` (to idle), then
|
|
27
|
-
* `track` for a new track,
|
|
27
|
+
* `track` for a new track, once it has a name, then `stalled` and `recovered` (Soloist issue #5). Buffering counts as playing. No `before`, or one
|
|
28
28
|
* 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
29
|
*/
|
|
30
30
|
export declare function spotifyChanges(before: Part<SpotifyState> | undefined, after: Part<SpotifyState> | undefined): SpotifyEvent[];
|
|
@@ -22,7 +22,7 @@ const PLAYING = new Set(["playing", "buffering"]);
|
|
|
22
22
|
/**
|
|
23
23
|
* The events between two reports, for `onChange`'s `before` and `after`: `offline` and `online`; `waiting` (signed out) and
|
|
24
24
|
* `picked` (someone picked the speaker); `started` (from idle), `resumed` (from paused), `paused`, `stopped` (to idle), then
|
|
25
|
-
* `track` for a new track,
|
|
25
|
+
* `track` for a new track, once it has a name, then `stalled` and `recovered` (Soloist issue #5). Buffering counts as playing. No `before`, or one
|
|
26
26
|
* 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
27
|
*/
|
|
28
28
|
export function spotifyChanges(before, after) {
|
|
@@ -55,7 +55,9 @@ export function spotifyChanges(before, after) {
|
|
|
55
55
|
add({ type: "paused" });
|
|
56
56
|
if (to === "idle" && from !== undefined && from !== "idle")
|
|
57
57
|
add({ type: "stopped" });
|
|
58
|
-
|
|
58
|
+
// Soloist's first report of a new track can have no name yet (seen live): the event waits for it.
|
|
59
|
+
const named = (p) => !!p?.track?.name;
|
|
60
|
+
if (a?.track && named(a) && (a.track.uri !== b?.track?.uri || !named(b)))
|
|
59
61
|
add({ type: "track", track: a.track });
|
|
60
62
|
if (a?.stalled && !b?.stalled)
|
|
61
63
|
events.push({ type: "stalled", stall: a.stalled });
|
|
@@ -158,7 +158,7 @@ export type SpotifyState = {
|
|
|
158
158
|
name?: string;
|
|
159
159
|
};
|
|
160
160
|
position?: PositionAnchor;
|
|
161
|
-
/** 0–100, as Soloist reports it. */
|
|
161
|
+
/** 0–100, as Soloist reports it, rounded. Until `active` it isn't the level that will play (it reads `--initial-volume`, seen live). */
|
|
162
162
|
volume?: number;
|
|
163
163
|
shuffle?: boolean;
|
|
164
164
|
/** off, context or track. */
|
|
@@ -1,7 +1,8 @@
|
|
|
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
8
|
export { type SpeakerState, speakerState, type SpotifyEvent, spotifyChanges, spotifyText, } from "./changes.ts";
|
|
@@ -49,9 +50,11 @@ export type Aside<T> = {
|
|
|
49
50
|
/**
|
|
50
51
|
* The writes, on the object `spotify()` returns. Each resolves once Soloist reports the change it asked for, with the state
|
|
51
52
|
* then (`confirmed: true`), or after `CONFIRM_MS` with the state as it stands (`confirmed: false`). Each rejects with a
|
|
52
|
-
* `ConnectorError` whose `code` is `offline`, `waiting` (nobody has picked the speaker), `refused`, `timeout` or `invalid`.
|
|
53
|
+
* `ConnectorError` whose `code` is `offline`, `waiting` (nobody has picked the speaker), `inactive` (`setVolume` on a speaker that isn't the active device: `activate()` first), `refused`, `timeout` or `invalid`.
|
|
53
54
|
*/
|
|
54
55
|
export type SpotifyActions = {
|
|
56
|
+
/** Make this the account's active Spotify device. Soloist drops volume changes until it is. It makes a new audio stream, so the volume can jump to the level last saved. */
|
|
57
|
+
activate(): Promise<Applied>;
|
|
55
58
|
/** Play a Spotify link or URI (track, album, playlist, artist, episode, show), or resume what is loaded when none is given. */
|
|
56
59
|
play(linkOrUri?: string): Promise<Applied>;
|
|
57
60
|
pause(): Promise<Applied>;
|
|
@@ -90,3 +93,7 @@ export type SpotifyActions = {
|
|
|
90
93
|
*/
|
|
91
94
|
export declare function spotify(config?: SpotifyConfig): Integration<SpotifyState, "spotify"> & SpotifyActions;
|
|
92
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[];
|