@khalido/home 0.2.0 → 0.2.2
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 +24 -2
- package/dist/connector.d.ts +2 -0
- package/dist/connector.js +3 -0
- package/dist/connectors/amber/client.d.ts +1 -1
- package/dist/connectors/amber/client.js +1 -1
- package/dist/connectors/amber/index.js +2 -2
- package/dist/connectors/cast/index.d.ts +2 -1
- package/dist/connectors/cast/index.js +1 -0
- package/dist/connectors/cast/wav.d.ts +31 -0
- package/dist/connectors/cast/wav.js +149 -0
- package/dist/connectors/cast/writes.d.ts +25 -6
- package/dist/connectors/cast/writes.js +155 -62
- package/dist/connectors/enphase/client.d.ts +1 -1
- package/dist/connectors/enphase/client.js +1 -1
- package/dist/connectors/enphase/index.js +2 -1
- package/dist/connectors/hue/events.d.ts +0 -2
- package/dist/connectors/hue/events.js +1 -2
- package/dist/connectors/hue/index.js +2 -2
- package/dist/connectors/printers/client.d.ts +41 -6
- package/dist/connectors/printers/client.js +81 -8
- package/dist/connectors/printers/codec.d.ts +17 -3
- package/dist/connectors/printers/codec.js +32 -8
- package/dist/connectors/printers/index.d.ts +2 -0
- package/dist/connectors/printers/index.js +4 -0
- package/dist/connectors/printers/raster.d.ts +30 -0
- package/dist/connectors/printers/raster.js +175 -0
- package/dist/connectors/printers/state.d.ts +2 -0
- package/dist/connectors/spotify/changes.d.ts +1 -1
- package/dist/connectors/spotify/changes.js +1 -1
- package/dist/connectors/spotify/index.d.ts +28 -14
- package/dist/connectors/spotify/index.js +89 -48
- package/dist/connectors/up/client.d.ts +1 -1
- package/dist/connectors/up/client.js +1 -1
- package/dist/connectors/up/index.js +2 -2
- package/dist/connectors/weather/google.js +2 -2
- package/docs/cast.md +1 -1
- package/docs/cli.md +3 -1
- package/docs/decisions.md +7 -0
- package/docs/ideas.md +2 -0
- package/docs/using.md +1 -1
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/skills/draw-home/README.md +2 -0
- package/skills/use-home/SKILL.md +1 -1
- package/src/cli/cast.ts +41 -11
- package/src/cli/context.ts +1 -1
- package/src/cli/enphase.ts +31 -5
- package/src/cli/main.ts +14 -5
- package/src/cli/printers.ts +254 -3
- package/src/cli/spotify.ts +16 -12
- package/src/connector.ts +5 -0
- package/src/connectors/amber/README.md +1 -1
- package/src/connectors/amber/client.ts +1 -1
- package/src/connectors/amber/index.ts +2 -2
- package/src/connectors/cast/README.md +2 -0
- package/src/connectors/cast/index.ts +4 -0
- package/src/connectors/cast/wav.ts +181 -0
- package/src/connectors/cast/writes.ts +211 -71
- package/src/connectors/enphase/README.md +3 -1
- package/src/connectors/enphase/client.ts +1 -1
- package/src/connectors/enphase/index.ts +2 -1
- package/src/connectors/hue/events.ts +1 -4
- package/src/connectors/hue/index.ts +2 -2
- package/src/connectors/printers/README.md +9 -4
- package/src/connectors/printers/client.ts +186 -12
- package/src/connectors/printers/codec.ts +44 -10
- package/src/connectors/printers/index.ts +17 -0
- package/src/connectors/printers/raster.ts +215 -0
- package/src/connectors/printers/state.ts +2 -0
- package/src/connectors/spotify/README.md +5 -1
- package/src/connectors/spotify/changes.ts +1 -1
- package/src/connectors/spotify/index.ts +233 -106
- package/src/connectors/up/README.md +1 -1
- package/src/connectors/up/client.ts +1 -1
- package/src/connectors/up/index.ts +2 -2
- package/src/connectors/weather/google.ts +6 -2
|
@@ -26,6 +26,8 @@ Soloist is Spotify's own headless Spotify Connect client (Linux arm64, armv7, x8
|
|
|
26
26
|
|
|
27
27
|
**`maxVolume`** holds requests (rounded to a whole percent). For 10 s after a start signal, a reported volume above it is lowered: a start from idle (to playing or buffering), the first such status after a sign-in, `playing` after a start through buffering, or the speaker going active. Event order differs between runs, so no one pattern is relied on. It waits until the speaker is active, retries until Soloist reports the lower volume, and only then says so in `anomaly`, until the speaker goes idle; if it never shows or Soloist refuses it, `detail` says so. A reported volume within 1 of the cap is left alone. Not on resuming from pause while active, not on connecting to a song already playing, and a person turning it up after the lowering keeps it (before Soloist has reported the lowering, a person's raise looks like the start's own jump and is lowered too). `--initial-volume` is ignored on PipeWire ([#23](https://github.com/spotify/soloist/issues/23)).
|
|
28
28
|
|
|
29
|
+
**Dry run.** Every write takes `{ dryRun: true }` last: the checks run (connected, signed in, the input, `inactive` for `setVolume`, `maxVolume`), nothing is sent, and it resolves `confirmed: false` with `wouldSend`, the commands in order (an inactive speaker's `play` lists `activate` first). `duck` and `interrupt` with `dryRun` run `fn`, touch nothing, and say in `why` what they would have done; they don't wait behind a real `duck` or `interrupt`. Tested on a fake socket only.
|
|
30
|
+
|
|
29
31
|
**`duck(fn, { to })` and `interrupt(fn)`** turn the music down, or pause it, around `fn`, then put it back. Both resolve `{ value, music, why? }`, `music` one of:
|
|
30
32
|
|
|
31
33
|
- `untouched`: not playing or buffering, not connected, not the active device, (duck) the volume unknown or already at `to` or below, or the lowering or pause failed, so `fn` ran over it.
|
|
@@ -58,6 +60,8 @@ Not built: `deactivate`, `available_actions`. An ad (`entity_type: "ad"`) shows
|
|
|
58
60
|
|
|
59
61
|
## Unverified
|
|
60
62
|
|
|
63
|
+
What Soloist does that this connector works around, and the upstream issues to watch: [khalido/home.ts#1](https://github.com/khalido/home.ts/issues/1).
|
|
64
|
+
|
|
61
65
|
Writes through this connector, and what a live read hasn't shown. In particular:
|
|
62
66
|
|
|
63
67
|
- **Stalls:** whether the reported position freezes in one (if not, only an overrun is caught), whether `get_state` re-anchors the position, and whether a seek or a pause and play restarts it.
|
|
@@ -70,7 +74,7 @@ Writes through this connector, and what a live read hasn't shown. In particular:
|
|
|
70
74
|
|
|
71
75
|
**2026-11-11, Raspberry Pi 5, Bun 1.4.4, on 0.1.3** (chota-bot's session; `maxVolume: 40` watching, writes from the CLI in another process). Two gaps in the cap, not fixed:
|
|
72
76
|
|
|
73
|
-
- From inactive and paused at 60, `play`: lowered to 40 within 2 ms of going active, `playing` at 40, then 60 again 90 ms after `playing`, and it played at 60. The cap takes its first confirmed lowering as done, so nothing lowered it again, and `anomaly` still said "lowered it". Soloist puts its own level back after `playing`.
|
|
77
|
+
- From inactive and paused at 60, `play`: lowered to 40 within 2 ms of going active, `playing` at 40, then 60 again 90 ms after `playing`, and it played at 60. The cap takes its first confirmed lowering as done, so nothing lowered it again, and `anomaly` still said "lowered it". After 0.2.0 the note adds "it is back at 60"; the level is left alone, since it is the one the speaker was playing at. Soloist puts its own level back after `playing`.
|
|
74
78
|
- The connector started while the speaker was already active and paused at 60: `play` resumed at 60, nothing lowered it, no anomaly. This is the documented "not on resuming from pause while active".
|
|
75
79
|
- A volume set by hand (35) held through pause and play, so the put-back belongs to activation, not to pause.
|
|
76
80
|
|
|
@@ -138,7 +138,7 @@ export function spotifyChanges(
|
|
|
138
138
|
/**
|
|
139
139
|
* The speaker in a few words: `Kitchen speaker: playing "Song" by Band · 35%`, `…: waiting for someone to pick it`, or,
|
|
140
140
|
* 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).
|
|
141
|
-
* `on` names where, when the caller knows (`spotifyElsewhere`): `… is playing on
|
|
141
|
+
* `on` names where, when the caller knows (`spotifyElsewhere`): `… is playing on Den speaker`.
|
|
142
142
|
*/
|
|
143
143
|
export function spotifyText(s: SpotifyState, on: string[] = []): string {
|
|
144
144
|
const who = s.deviceName ?? "Soloist";
|
|
@@ -96,8 +96,23 @@ export type SpotifyConfig = {
|
|
|
96
96
|
/** Soloist's repeat modes. */
|
|
97
97
|
export type Repeat = "off" | "context" | "track";
|
|
98
98
|
|
|
99
|
-
/**
|
|
100
|
-
export type
|
|
99
|
+
/** One WebSocket command, as a dry run reports it: Soloist's command name and its fields. */
|
|
100
|
+
export type SpotifyRequest = {
|
|
101
|
+
command: string;
|
|
102
|
+
fields?: Record<string, unknown>;
|
|
103
|
+
};
|
|
104
|
+
/** `dryRun`: run every check a real write runs (connected, signed in, the input), send nothing, and say what would go. */
|
|
105
|
+
export type SpotifyWriteOptions = { dryRun?: boolean };
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* What a write left: the state once Soloist reported the change (`confirmed`), or the state as it stood when `CONFIRM_MS`
|
|
109
|
+
* ran out without it. A dry run resolves `confirmed: false` with `wouldSend`, the commands in order; an `activate` already done resolves `confirmed: true` with `wouldSend: []`.
|
|
110
|
+
*/
|
|
111
|
+
export type Applied = {
|
|
112
|
+
state: SpotifyState;
|
|
113
|
+
confirmed: boolean;
|
|
114
|
+
wouldSend?: SpotifyRequest[];
|
|
115
|
+
};
|
|
101
116
|
|
|
102
117
|
/**
|
|
103
118
|
* What happened to the music around `duck` or `interrupt`'s `fn`: `untouched` (it was not playing, not connected, already
|
|
@@ -115,34 +130,41 @@ export type Aside<T> = { value: T; music: Music; why?: string };
|
|
|
115
130
|
*/
|
|
116
131
|
export type SpotifyActions = {
|
|
117
132
|
/** 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. */
|
|
118
|
-
activate(): Promise<Applied>;
|
|
133
|
+
activate(options?: SpotifyWriteOptions): Promise<Applied>;
|
|
119
134
|
/** Play a Spotify link or URI (track, album, playlist, artist, episode, show), or resume what is loaded when none is given. */
|
|
120
|
-
play(linkOrUri?: string): Promise<Applied>;
|
|
121
|
-
pause(): Promise<Applied>;
|
|
122
|
-
resume(): Promise<Applied>;
|
|
123
|
-
next(): Promise<Applied>;
|
|
124
|
-
prev(): Promise<Applied>;
|
|
135
|
+
play(linkOrUri?: string, options?: SpotifyWriteOptions): Promise<Applied>;
|
|
136
|
+
pause(options?: SpotifyWriteOptions): Promise<Applied>;
|
|
137
|
+
resume(options?: SpotifyWriteOptions): Promise<Applied>;
|
|
138
|
+
next(options?: SpotifyWriteOptions): Promise<Applied>;
|
|
139
|
+
prev(options?: SpotifyWriteOptions): Promise<Applied>;
|
|
125
140
|
/** Jump to a position in the current track, in ms. */
|
|
126
|
-
seek(ms: number): Promise<Applied>;
|
|
141
|
+
seek(ms: number, options?: SpotifyWriteOptions): Promise<Applied>;
|
|
127
142
|
/** Set the volume 0–100, held to `maxVolume` and rounded to a whole percent: `state.volume` is what Soloist then reports. */
|
|
128
|
-
setVolume(volume: number): Promise<Applied>;
|
|
143
|
+
setVolume(volume: number, options?: SpotifyWriteOptions): Promise<Applied>;
|
|
129
144
|
/** Add a track to the queue (Soloist queues tracks only); confirmed when it shows in `upNext`. */
|
|
130
|
-
queue(track: string): Promise<Applied>;
|
|
131
|
-
setShuffle(on: boolean): Promise<Applied>;
|
|
145
|
+
queue(track: string, options?: SpotifyWriteOptions): Promise<Applied>;
|
|
146
|
+
setShuffle(on: boolean, options?: SpotifyWriteOptions): Promise<Applied>;
|
|
132
147
|
/** Repeat off, the context (playlist, album) or the track: two commands, in the order Soloist's docs give, as one write. */
|
|
133
|
-
setRepeat(mode: Repeat): Promise<Applied>;
|
|
148
|
+
setRepeat(mode: Repeat, options?: SpotifyWriteOptions): Promise<Applied>;
|
|
134
149
|
/**
|
|
135
150
|
* Pause if playing, run `fn`, then resume, unless someone changed it meanwhile (resumed, stopped or skipped it from a
|
|
136
151
|
* phone): then it is theirs and stays. The resume runs even if `fn` throws. Not playing or not connected: just `fn`.
|
|
137
152
|
* One `interrupt` or `duck` at a time; a second waits for the first, so never call either from inside `fn` (it would wait
|
|
138
153
|
* forever). Two announcements back to back belong in one `fn`, or the music comes back between them.
|
|
139
154
|
*/
|
|
140
|
-
interrupt<T>(
|
|
155
|
+
interrupt<T>(
|
|
156
|
+
fn: () => Promise<T> | T,
|
|
157
|
+
options?: SpotifyWriteOptions,
|
|
158
|
+
): Promise<Aside<T>>;
|
|
141
159
|
/**
|
|
142
160
|
* Lower the volume to `to` while `fn` runs, then put the earlier volume back, unless someone set the volume meanwhile.
|
|
143
161
|
* For a short announcement over the music. Not playing, not connected or already at `to` or below: just `fn`.
|
|
162
|
+
* With `dryRun`, `interrupt` and `duck` run `fn`, touch nothing, and say in `why` what they would have done.
|
|
144
163
|
*/
|
|
145
|
-
duck<T>(
|
|
164
|
+
duck<T>(
|
|
165
|
+
fn: () => Promise<T> | T,
|
|
166
|
+
options: { to: number } & SpotifyWriteOptions,
|
|
167
|
+
): Promise<Aside<T>>;
|
|
146
168
|
};
|
|
147
169
|
|
|
148
170
|
/** How long a command waits for Soloist's answer. Loopback answers in milliseconds; a stated guess. */
|
|
@@ -332,12 +354,34 @@ export function spotify(
|
|
|
332
354
|
* Soloist accepted the command that carried it (a merged write's may be another's: a pause merged into a later play never
|
|
333
355
|
* confirms). A drop ends the wait early, unconfirmed.
|
|
334
356
|
*/
|
|
335
|
-
const change = async (
|
|
357
|
+
const change = async (
|
|
358
|
+
slot: string,
|
|
359
|
+
body: Body,
|
|
360
|
+
options: SpotifyWriteOptions = {},
|
|
361
|
+
): Promise<Applied> => {
|
|
362
|
+
if (options.dryRun) return dry(body);
|
|
336
363
|
const sent = await write(slot, body);
|
|
337
364
|
const took = () => body.took?.(state, sent.before, sent.at) ?? true;
|
|
338
365
|
await waitFor(() => took() || !isOpen(), CONFIRM_MS);
|
|
339
366
|
return { state, confirmed: took() };
|
|
340
367
|
};
|
|
368
|
+
/** A dry run: `command`'s own checks (connected, signed in), then the commands `send` would send, in order. */
|
|
369
|
+
const dry = (body: Body): Applied => {
|
|
370
|
+
if (!socket || !isOpen() || socket.readyState !== OPEN) throw offline();
|
|
371
|
+
if (state.loggedIn === false)
|
|
372
|
+
throw new ConnectorError(WAITING, { code: "waiting" });
|
|
373
|
+
const one = (command: string, fields?: Record<string, unknown>) =>
|
|
374
|
+
fields ? { command, fields } : { command };
|
|
375
|
+
return {
|
|
376
|
+
state,
|
|
377
|
+
confirmed: false,
|
|
378
|
+
wouldSend: [
|
|
379
|
+
...(body.activeFirst && inactive() ? [one("activate")] : []),
|
|
380
|
+
one(body.command, body.fields),
|
|
381
|
+
...(body.then ? [one(body.then.command, body.then.fields)] : []),
|
|
382
|
+
],
|
|
383
|
+
};
|
|
384
|
+
};
|
|
341
385
|
/** The track restarted: a new anchor near 0 when it had been 5 s or more in (a prev past the first seconds, a next on repeat-track). */
|
|
342
386
|
const restarted = (s: SpotifyState, b: SpotifyState, at: number) =>
|
|
343
387
|
s.position !== b.position &&
|
|
@@ -349,12 +393,16 @@ export function spotify(
|
|
|
349
393
|
new ConnectorError(message, { code: "invalid" });
|
|
350
394
|
const near = (volume: number | undefined, v: number) =>
|
|
351
395
|
volume !== undefined && Math.abs(volume - v) <= VOLUME_SLACK;
|
|
352
|
-
const volumeTo = (v: number) =>
|
|
353
|
-
change(
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
396
|
+
const volumeTo = (v: number, options?: SpotifyWriteOptions) =>
|
|
397
|
+
change(
|
|
398
|
+
"volume",
|
|
399
|
+
{
|
|
400
|
+
command: "set_volume",
|
|
401
|
+
fields: { volume: v },
|
|
402
|
+
took: (s) => near(s.volume, v),
|
|
403
|
+
},
|
|
404
|
+
options,
|
|
405
|
+
);
|
|
358
406
|
/** Soloist accepts a `set_volume` while it isn't the active device and drops it (three times live, 2026-11-09). */
|
|
359
407
|
const inactive = () =>
|
|
360
408
|
isOpen() && state.loggedIn !== false && state.active === false;
|
|
@@ -390,6 +438,17 @@ export function spotify(
|
|
|
390
438
|
* one raised before it is reported can't be told from the start's own jump, so it is lowered too.
|
|
391
439
|
*/
|
|
392
440
|
const applyCap = () => {
|
|
441
|
+
// Lowered, then above the cap again in the same window: Soloist putting its own level back (seen live) or a person.
|
|
442
|
+
// The note says where it is now, not only that it was lowered.
|
|
443
|
+
if (capped && capNote && cap !== undefined && state.volume !== undefined) {
|
|
444
|
+
const base = capNote.replace(/; it is back at \d+$/, "");
|
|
445
|
+
const note =
|
|
446
|
+
state.volume > cap + VOLUME_SLACK
|
|
447
|
+
? `${base}; it is back at ${state.volume}`
|
|
448
|
+
: base;
|
|
449
|
+
if (note !== capNote) ((capNote = note), publish());
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
393
452
|
if (
|
|
394
453
|
cap === undefined ||
|
|
395
454
|
capped ||
|
|
@@ -410,7 +469,7 @@ export function spotify(
|
|
|
410
469
|
if (confirmed) {
|
|
411
470
|
capped = true;
|
|
412
471
|
if (state.status === "idle") return;
|
|
413
|
-
capNote = `started at volume ${from}, above maxVolume ${cap}; lowered it (Soloist issue #23)`;
|
|
472
|
+
capNote = `started at volume ${from}, above maxVolume ${cap}; lowered it to ${cap} (Soloist issue #23)`;
|
|
414
473
|
note = undefined;
|
|
415
474
|
return publish();
|
|
416
475
|
}
|
|
@@ -626,6 +685,33 @@ export function spotify(
|
|
|
626
685
|
return p;
|
|
627
686
|
};
|
|
628
687
|
type Left = Omit<Aside<never>, "value">;
|
|
688
|
+
/** Why `interrupt` would leave the music alone, if it would. */
|
|
689
|
+
const interruptSkip = () =>
|
|
690
|
+
notPlaying() ??
|
|
691
|
+
// "playing" while inactive is the account playing on another speaker (live, 2026-11-10): a pause would stop that one.
|
|
692
|
+
(inactive()
|
|
693
|
+
? "it is not the active device, so the music playing is on another speaker"
|
|
694
|
+
: undefined);
|
|
695
|
+
/** Why `duck` would leave the volume alone, if it would. */
|
|
696
|
+
const duckSkip = (to: number) =>
|
|
697
|
+
notPlaying() ??
|
|
698
|
+
(inactive()
|
|
699
|
+
? "it is not the active device, so its volume can't be changed"
|
|
700
|
+
: state.volume === undefined
|
|
701
|
+
? "its volume is not known"
|
|
702
|
+
: state.volume <= to
|
|
703
|
+
? `it was already at ${state.volume}`
|
|
704
|
+
: undefined);
|
|
705
|
+
/** A dry `interrupt` or `duck`: `fn` runs, the music is not touched, and `why` says what would have happened to it. */
|
|
706
|
+
const dryAside = async <T>(
|
|
707
|
+
fn: () => Promise<T> | T,
|
|
708
|
+
skip: string | undefined,
|
|
709
|
+
would: string,
|
|
710
|
+
): Promise<Aside<T>> => ({
|
|
711
|
+
value: await fn(),
|
|
712
|
+
music: "untouched",
|
|
713
|
+
why: `dry run: ${skip ? `${skip}, so the music would be left alone` : `would ${would}`}`,
|
|
714
|
+
});
|
|
629
715
|
const putBack = (r: Applied, what: string): Left =>
|
|
630
716
|
r.confirmed
|
|
631
717
|
? { music: "restored" }
|
|
@@ -635,69 +721,96 @@ export function spotify(
|
|
|
635
721
|
};
|
|
636
722
|
|
|
637
723
|
const actions: SpotifyActions = {
|
|
638
|
-
async activate() {
|
|
724
|
+
async activate(options) {
|
|
639
725
|
if (isOpen() && state.loggedIn !== false && state.active === true)
|
|
640
|
-
return {
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
726
|
+
return {
|
|
727
|
+
state,
|
|
728
|
+
confirmed: true,
|
|
729
|
+
...(options?.dryRun ? { wouldSend: [] } : {}),
|
|
730
|
+
}; // a second activate might make a new stream
|
|
731
|
+
return change(
|
|
732
|
+
"activate",
|
|
733
|
+
{ command: "activate", took: (s) => s.active === true },
|
|
734
|
+
options,
|
|
735
|
+
);
|
|
645
736
|
},
|
|
646
737
|
// A `play` while inactive didn't take live; going active first is untested for this. `resume` worked inactive, so it doesn't.
|
|
647
|
-
async play(linkOrUri) {
|
|
738
|
+
async play(linkOrUri, options) {
|
|
648
739
|
if (linkOrUri === undefined)
|
|
649
|
-
return change(
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
740
|
+
return change(
|
|
741
|
+
"transport",
|
|
742
|
+
{
|
|
743
|
+
command: "play",
|
|
744
|
+
activeFirst: true,
|
|
745
|
+
took: (s) => s.status === "playing",
|
|
746
|
+
},
|
|
747
|
+
options,
|
|
748
|
+
);
|
|
654
749
|
const uri = toSpotifyUri(linkOrUri);
|
|
655
750
|
if (!uri) throw invalid(`not a Spotify link or URI: ${linkOrUri}`);
|
|
656
|
-
return change(
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
(s
|
|
663
|
-
|
|
751
|
+
return change(
|
|
752
|
+
"transport",
|
|
753
|
+
{
|
|
754
|
+
command: "play",
|
|
755
|
+
fields: { uri },
|
|
756
|
+
activeFirst: true,
|
|
757
|
+
took: (s) =>
|
|
758
|
+
s.status === "playing" &&
|
|
759
|
+
(s.track?.uri === uri || s.context?.uri === uri),
|
|
760
|
+
},
|
|
761
|
+
options,
|
|
762
|
+
);
|
|
664
763
|
},
|
|
665
|
-
pause: () =>
|
|
666
|
-
change(
|
|
667
|
-
|
|
668
|
-
took: (s) => s.status === "paused",
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
764
|
+
pause: (options) =>
|
|
765
|
+
change(
|
|
766
|
+
"transport",
|
|
767
|
+
{ command: "pause", took: (s) => s.status === "paused" },
|
|
768
|
+
options,
|
|
769
|
+
),
|
|
770
|
+
resume: (options) =>
|
|
771
|
+
change(
|
|
772
|
+
"transport",
|
|
773
|
+
{ command: "play", took: (s) => s.status === "playing" },
|
|
774
|
+
options,
|
|
775
|
+
),
|
|
675
776
|
// The end of the queue goes idle; repeat-track restarts it.
|
|
676
|
-
next: () =>
|
|
677
|
-
change(
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
(s
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
777
|
+
next: (options) =>
|
|
778
|
+
change(
|
|
779
|
+
`once-${++once}`,
|
|
780
|
+
{
|
|
781
|
+
command: "skip_next",
|
|
782
|
+
took: (s, b, at) =>
|
|
783
|
+
newTrack(s, b) ||
|
|
784
|
+
(s.status === "idle" && b.status !== "idle") ||
|
|
785
|
+
restarted(s, b, at),
|
|
786
|
+
},
|
|
787
|
+
options,
|
|
788
|
+
),
|
|
789
|
+
prev: (options) =>
|
|
790
|
+
change(
|
|
791
|
+
`once-${++once}`,
|
|
792
|
+
{
|
|
793
|
+
command: "skip_prev",
|
|
794
|
+
took: (s, b, at) => newTrack(s, b) || restarted(s, b, at),
|
|
795
|
+
},
|
|
796
|
+
options,
|
|
797
|
+
),
|
|
798
|
+
async seek(ms, options) {
|
|
690
799
|
if (!Number.isFinite(ms) || ms < 0)
|
|
691
800
|
throw invalid(`a position in ms, 0 or more, not ${ms}`);
|
|
692
|
-
return change(
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
801
|
+
return change(
|
|
802
|
+
"seek",
|
|
803
|
+
{
|
|
804
|
+
command: "seek",
|
|
805
|
+
fields: { position_ms: ms },
|
|
806
|
+
took: (s, b) =>
|
|
807
|
+
s.position !== b.position &&
|
|
808
|
+
Math.abs((s.position?.ms ?? -Infinity) - ms) < 2000,
|
|
809
|
+
},
|
|
810
|
+
options,
|
|
811
|
+
);
|
|
699
812
|
},
|
|
700
|
-
async setVolume(volume) {
|
|
813
|
+
async setVolume(volume, options) {
|
|
701
814
|
if (!Number.isFinite(volume) || volume < 0 || volume > 100)
|
|
702
815
|
throw invalid(`volume is 0–100, not ${volume}`);
|
|
703
816
|
if (inactive())
|
|
@@ -707,48 +820,60 @@ export function spotify(
|
|
|
707
820
|
);
|
|
708
821
|
return volumeTo(
|
|
709
822
|
Math.round(cap === undefined ? volume : Math.min(volume, cap)),
|
|
823
|
+
options,
|
|
710
824
|
);
|
|
711
825
|
},
|
|
712
|
-
async queue(track) {
|
|
826
|
+
async queue(track, options) {
|
|
713
827
|
const uri = toSpotifyUri(track);
|
|
714
828
|
if (!uri?.startsWith("spotify:track:"))
|
|
715
829
|
throw invalid(`only a Spotify track can be queued, not ${track}`);
|
|
716
|
-
return change(
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
830
|
+
return change(
|
|
831
|
+
`once-${++once}`,
|
|
832
|
+
{
|
|
833
|
+
command: "add_to_queue",
|
|
834
|
+
fields: { uri },
|
|
835
|
+
took: (s) => !!s.upNext?.some((t) => t.uri === uri),
|
|
836
|
+
},
|
|
837
|
+
options,
|
|
838
|
+
);
|
|
721
839
|
},
|
|
722
|
-
setShuffle: (on) =>
|
|
723
|
-
change(
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
840
|
+
setShuffle: (on, options) =>
|
|
841
|
+
change(
|
|
842
|
+
"shuffle",
|
|
843
|
+
{
|
|
844
|
+
command: "set_shuffle",
|
|
845
|
+
fields: { enabled: on },
|
|
846
|
+
took: (s) => s.shuffle === on,
|
|
847
|
+
},
|
|
848
|
+
options,
|
|
849
|
+
),
|
|
850
|
+
async setRepeat(mode, options) {
|
|
729
851
|
// developer.spotify.com/documentation/soloist/reference/websocket-api: clear the other mode first, then set this one.
|
|
730
852
|
if (!Object.hasOwn(REPEAT_ORDER, mode))
|
|
731
853
|
throw invalid(`repeat is off, context or track, not ${mode}`);
|
|
732
854
|
const [first, second] = REPEAT_ORDER[mode];
|
|
733
|
-
return change(
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
855
|
+
return change(
|
|
856
|
+
"repeat",
|
|
857
|
+
{
|
|
858
|
+
command: first[0],
|
|
859
|
+
fields: { enabled: first[1] },
|
|
860
|
+
then: { command: second[0], fields: { enabled: second[1] } },
|
|
861
|
+
took: (s) => s.repeat === mode,
|
|
862
|
+
},
|
|
863
|
+
options,
|
|
864
|
+
);
|
|
739
865
|
},
|
|
740
|
-
interrupt(fn) {
|
|
866
|
+
interrupt(fn, options) {
|
|
867
|
+
if (options?.dryRun)
|
|
868
|
+
return dryAside(fn, interruptSkip(), "pause the music, then resume it");
|
|
741
869
|
let track: string | undefined;
|
|
742
870
|
let shown = false; // the pause showed
|
|
743
871
|
return aside(
|
|
744
872
|
"interrupt",
|
|
745
873
|
fn,
|
|
746
874
|
async () => {
|
|
747
|
-
const no =
|
|
875
|
+
const no = interruptSkip();
|
|
748
876
|
if (no) return no;
|
|
749
|
-
// "playing" while inactive is the account playing on another speaker (live, 2026-11-10): a pause would stop that one.
|
|
750
|
-
if (inactive())
|
|
751
|
-
return "it is not the active device, so the music playing is on another speaker";
|
|
752
877
|
track = state.track?.uri;
|
|
753
878
|
try {
|
|
754
879
|
shown = (await actions.pause()).confirmed; // a pause that never shows still runs fn: what interrupts matters more
|
|
@@ -777,22 +902,24 @@ export function spotify(
|
|
|
777
902
|
},
|
|
778
903
|
);
|
|
779
904
|
},
|
|
780
|
-
duck(fn, { to }) {
|
|
905
|
+
duck(fn, { to, dryRun }) {
|
|
781
906
|
if (!Number.isFinite(to) || to < 0 || to > 100)
|
|
782
907
|
return Promise.reject(invalid(`duck's volume is 0–100, not ${to}`));
|
|
908
|
+
if (dryRun)
|
|
909
|
+
return dryAside(
|
|
910
|
+
fn,
|
|
911
|
+
duckSkip(to),
|
|
912
|
+
`lower the volume from ${state.volume} to ${Math.round(to)}, then put it back`,
|
|
913
|
+
);
|
|
783
914
|
let earlier = 0;
|
|
784
915
|
let shown = false; // the lowered volume showed
|
|
785
916
|
return aside(
|
|
786
917
|
"duck",
|
|
787
918
|
fn,
|
|
788
919
|
async () => {
|
|
789
|
-
const no =
|
|
920
|
+
const no = duckSkip(to);
|
|
790
921
|
if (no) return no;
|
|
791
|
-
|
|
792
|
-
return "it is not the active device, so its volume can't be changed";
|
|
793
|
-
if (state.volume === undefined) return "its volume is not known";
|
|
794
|
-
if (state.volume <= to) return `it was already at ${state.volume}`;
|
|
795
|
-
earlier = state.volume;
|
|
922
|
+
earlier = state.volume!;
|
|
796
923
|
try {
|
|
797
924
|
shown = (await volumeTo(Math.round(to))).confirmed;
|
|
798
925
|
} catch (error) {
|
|
@@ -7,7 +7,7 @@ Reads the household's spending from Up Bank's API: two months of transactions, s
|
|
|
7
7
|
- **`client.ts`**: `upClient({ token, fetch })`. Reads: `ping`, `accounts(filter)`, `transactions(query)` following cursors to `limit` (default 1000) and `pageCap` (20) and returning `{ transactions, truncated }` so a cut-off list never looks complete, `transaction(id)`, `categories`, `tags`, `webhooks`, `webhook(id)`, `webhookLogs(id)`. Writes: `setCategory(id, categoryId | null)`, `addTags(id, labels)`, `removeTags(id, labels)`, each re-reading and returning the transaction as Up now holds it; `createWebhook`, `deleteWebhook`, `pingWebhook`. A transaction is flattened to cents (Up's `valueInBaseUnits`) and ids.
|
|
8
8
|
- **`spend.ts`**, pure, one owner per number. `notSpend(t)` is the rule and says why: held, money in, `transferAccount` set (a movement between your own accounts), or a description Up gives its own movements ("Cover from", "Forward to", "Round Up", "Quick save transfer"). "Transfer to …" is not on the list: paying someone else is spend. `isSpend`, `spendByCategory` (parents and children, excluded movements counted and summed so a total can be checked), `spendByMerchant`, `uncategorised` (spend with no category; money in is categorisable too but left out), `recurring` (a heuristic: three or more months within 20% of the median), `unusual` (over twice the median of three or more earlier purchases at that merchant), `dollars` for display.
|
|
9
9
|
- **`webhook.ts`**: `verifyWebhook(rawBody, signature, secret)`, the SHA-256 HMAC of the raw body in hex, Web Crypto, compared in constant time; `parseWebhookEvent(body)` gives the event type and the transaction id. A consumer's handler verifies, then calls `home.refresh("up")`.
|
|
10
|
-
- **The integration** (`up({ token, account?, timeZone?, fetch })`, with `client` on it for a consumer's writes): every 15 minutes it reads the categories, the accounts the filter names, and 63 days of transactions, then cuts this month and last in the home's time zone. The snapshot holds each month's spend by category and top five merchants, the ids still to categorise, and the latest 20 purchases trimmed to id, time, description, amount, category, status and tags (movements between your own accounts are left out: their descriptions name the accounts). Never kept: raw text, message, note, who spent (`performingCustomer`), account ids, balances. A 401 is `unavailable`; a read that hit its page cap is an `anomaly`, as is an amount missing or not in AUD. It never writes.
|
|
10
|
+
- **The integration** (`up({ token, account?, timeZone?, fetch })`, with `client` on it for a consumer's writes): every 15 minutes it reads the categories, the accounts the filter names, and 63 days of transactions, then cuts this month and last in the home's time zone. The snapshot holds each month's spend by category and top five merchants, the ids still to categorise, and the latest 20 purchases trimmed to id, time, description, amount, category, status and tags (movements between your own accounts are left out: their descriptions name the accounts). Never kept: raw text, message, note, who spent (`performingCustomer`), account ids, balances. A 401 or 403 is `unavailable`; a read that hit its page cap is an `anomaly`, as is an amount missing or not in AUD. It never writes.
|
|
11
11
|
- **Config**: `{ connector: "up", key: "UP_TOKEN", account: { ownershipType: "JOINT", accountType: "TRANSACTIONAL" } }`. Accounts are chosen by type and ownership, never by id. Without `account`, every account is read; transfers between them are excluded by `transferAccount`.
|
|
12
12
|
- **CLI**: `home up` (this month, last month, to categorise, top five categories), `home up transactions [--since] [--until] [--category] [--tag] [--status] [--limit] [--json|--csv]`, `categories`, `uncategorised`, `spend [--by category|merchant|recurring]`, and the writes `categorise <id> <category|none>`, `tag <id> <label> [--remove]`, `webhooks [get|logs|ping|create|delete]`. `UP_ACCOUNT` ("JOINT TRANSACTIONAL") is the filter's env form.
|
|
13
13
|
- **Household filter** (`household: { categories, tags }` in the config, `UP_HOUSEHOLD="home tag:home"` in env): the totals, top merchants and recent rows count only purchases in a listed category or its parent, or with a listed tag, so a family dashboard shows groceries and utilities, not personal spending. Tag anything else `home` in the Up app to bring it in. "To categorise" still looks at every purchase, since a category is how one joins. A listed category Up does not have is an `anomaly`, not a silent zero. `inHousehold` is the one owner of the rule; `home up spend` applies it too. `webhooks create` writes the secret Up returns once to `~/.config/home/up-webhook-<id>.secret` (0600); it is never printed.
|
|
@@ -6,7 +6,7 @@ import { ConnectorError, fetchTimeout, getJson } from "../../connector.ts";
|
|
|
6
6
|
|
|
7
7
|
const BASE = "https://api.up.com.au/api/v1";
|
|
8
8
|
|
|
9
|
-
/** A failed Up call. `status` 401 means the token is wrong or revoked; 429 the rate limit (unpublished: read `X-RateLimit-Remaining`). */
|
|
9
|
+
/** A failed Up call. `status` 401 or 403 (`isRejectedKey`) means the token is wrong or revoked; 429 the rate limit (unpublished: read `X-RateLimit-Remaining`). */
|
|
10
10
|
export class UpError extends ConnectorError {}
|
|
11
11
|
|
|
12
12
|
const Money = z.looseObject({
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { asOf, type Clock, realClock } from "../../clock.ts";
|
|
3
|
-
import { errorText } from "../../connector.ts";
|
|
3
|
+
import { errorText, isRejectedKey } from "../../connector.ts";
|
|
4
4
|
import { envName, type Integration } from "../../home.ts";
|
|
5
5
|
import type { Manifest } from "../../manifest.ts";
|
|
6
6
|
import { poller } from "../../poll.ts";
|
|
@@ -288,7 +288,7 @@ export function up(config: UpConfig): Integration<UpSnapshot, "up"> & {
|
|
|
288
288
|
});
|
|
289
289
|
} catch (error) {
|
|
290
290
|
if (signal.aborted) return; // stopped mid-read: not a failure
|
|
291
|
-
if (error
|
|
291
|
+
if (isRejectedKey(error))
|
|
292
292
|
return update({
|
|
293
293
|
status: "unavailable",
|
|
294
294
|
reason: "auth",
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
ConnectorError,
|
|
4
|
+
fetchTimeout,
|
|
5
|
+
isRejectedKey,
|
|
6
|
+
} from "../../connector.ts";
|
|
3
7
|
import { RAIN_INTENSITIES } from "./rain.ts";
|
|
4
8
|
import { sunDisagrees } from "./sun.ts";
|
|
5
9
|
import {
|
|
@@ -434,7 +438,7 @@ export function google(options: GoogleOptions): WeatherSource {
|
|
|
434
438
|
? error
|
|
435
439
|
: ({} as { status?: number; why?: string });
|
|
436
440
|
const said = `HTTP ${status}${why ? ` ${why}` : ""}`;
|
|
437
|
-
if (
|
|
441
|
+
if (isRejectedKey(error) || why?.startsWith("API_KEY_"))
|
|
438
442
|
return {
|
|
439
443
|
stop: `google: the key was rejected (${said})`,
|
|
440
444
|
stopReason: "auth",
|