@khalido/home 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +25 -0
- package/CHANGELOG.md +47 -0
- package/LICENSE +21 -0
- package/README.md +77 -2
- package/dist/clock.d.ts +17 -0
- package/dist/clock.js +43 -0
- package/dist/config.d.ts +273 -0
- package/dist/config.js +392 -0
- package/dist/connector.d.ts +34 -0
- package/dist/connector.js +47 -0
- package/dist/connectors/amber/client.d.ts +61 -0
- package/dist/connectors/amber/client.js +106 -0
- package/dist/connectors/amber/index.d.ts +30 -0
- package/dist/connectors/amber/index.js +94 -0
- package/dist/connectors/cast/codec.d.ts +38 -0
- package/dist/connectors/cast/codec.js +102 -0
- package/dist/connectors/cast/index.d.ts +60 -0
- package/dist/connectors/cast/index.js +428 -0
- package/dist/connectors/cast/schemas.d.ts +167 -0
- package/dist/connectors/cast/schemas.js +87 -0
- package/dist/connectors/cast/serve.d.ts +18 -0
- package/dist/connectors/cast/serve.js +57 -0
- package/dist/connectors/cast/state.d.ts +62 -0
- package/dist/connectors/cast/state.js +133 -0
- package/dist/connectors/cast/tls.d.ts +3 -0
- package/dist/connectors/cast/tls.js +67 -0
- package/dist/connectors/cast/writes.d.ts +80 -0
- package/dist/connectors/cast/writes.js +390 -0
- package/dist/connectors/daikin/client.d.ts +65 -0
- package/dist/connectors/daikin/client.js +221 -0
- package/dist/connectors/daikin/index.d.ts +41 -0
- package/dist/connectors/daikin/index.js +108 -0
- package/dist/connectors/enphase/client.d.ts +65 -0
- package/dist/connectors/enphase/client.js +173 -0
- package/dist/connectors/enphase/index.d.ts +43 -0
- package/dist/connectors/enphase/index.js +174 -0
- package/dist/connectors/enphase/schemas.d.ts +51 -0
- package/dist/connectors/enphase/schemas.js +34 -0
- package/dist/connectors/hue/client.d.ts +48 -0
- package/dist/connectors/hue/client.js +95 -0
- package/dist/connectors/hue/events.d.ts +41 -0
- package/dist/connectors/hue/events.js +156 -0
- package/dist/connectors/hue/index.d.ts +42 -0
- package/dist/connectors/hue/index.js +205 -0
- package/dist/connectors/hue/schemas.d.ts +65 -0
- package/dist/connectors/hue/schemas.js +48 -0
- package/dist/connectors/hue/state.d.ts +43 -0
- package/dist/connectors/hue/state.js +84 -0
- package/dist/connectors/printers/client.d.ts +28 -0
- package/dist/connectors/printers/client.js +74 -0
- package/dist/connectors/printers/codec.d.ts +76 -0
- package/dist/connectors/printers/codec.js +246 -0
- package/dist/connectors/printers/found.d.ts +15 -0
- package/dist/connectors/printers/found.js +35 -0
- package/dist/connectors/printers/index.d.ts +52 -0
- package/dist/connectors/printers/index.js +282 -0
- package/dist/connectors/printers/state.d.ts +60 -0
- package/dist/connectors/printers/state.js +99 -0
- package/dist/connectors/spotify/changes.d.ts +32 -0
- package/dist/connectors/spotify/changes.js +84 -0
- package/dist/connectors/spotify/client.d.ts +199 -0
- package/dist/connectors/spotify/client.js +270 -0
- package/dist/connectors/spotify/index.d.ts +92 -0
- package/dist/connectors/spotify/index.js +754 -0
- package/dist/connectors/up/client.d.ts +183 -0
- package/dist/connectors/up/client.js +308 -0
- package/dist/connectors/up/index.d.ts +91 -0
- package/dist/connectors/up/index.js +213 -0
- package/dist/connectors/up/spend.d.ts +78 -0
- package/dist/connectors/up/spend.js +213 -0
- package/dist/connectors/up/webhook.d.ts +13 -0
- package/dist/connectors/up/webhook.js +32 -0
- package/dist/connectors/weather/blocks.d.ts +51 -0
- package/dist/connectors/weather/blocks.js +106 -0
- package/dist/connectors/weather/bom.d.ts +37 -0
- package/dist/connectors/weather/bom.js +237 -0
- package/dist/connectors/weather/ftp.d.ts +5 -0
- package/dist/connectors/weather/ftp.js +46 -0
- package/dist/connectors/weather/google.d.ts +31 -0
- package/dist/connectors/weather/google.js +364 -0
- package/dist/connectors/weather/index.d.ts +53 -0
- package/dist/connectors/weather/index.js +280 -0
- package/dist/connectors/weather/open-meteo.d.ts +15 -0
- package/dist/connectors/weather/open-meteo.js +285 -0
- package/dist/connectors/weather/rain.d.ts +19 -0
- package/dist/connectors/weather/rain.js +51 -0
- package/dist/connectors/weather/source.d.ts +149 -0
- package/dist/connectors/weather/source.js +77 -0
- package/dist/connectors/weather/sun.d.ts +48 -0
- package/dist/connectors/weather/sun.js +65 -0
- package/dist/discover.d.ts +197 -0
- package/dist/discover.js +452 -0
- package/dist/energy.d.ts +61 -0
- package/dist/energy.js +162 -0
- package/dist/finders-entry.d.ts +1 -0
- package/dist/finders-entry.js +2 -0
- package/dist/finders.d.ts +48 -0
- package/dist/finders.js +176 -0
- package/dist/health.d.ts +28 -0
- package/dist/health.js +35 -0
- package/dist/home.d.ts +81 -0
- package/dist/home.js +114 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/lane.d.ts +17 -0
- package/dist/lane.js +74 -0
- package/dist/locate.d.ts +27 -0
- package/dist/locate.js +59 -0
- package/dist/manifest.d.ts +31 -0
- package/dist/manifest.js +4 -0
- package/dist/manifests.d.ts +5 -0
- package/dist/manifests.js +23 -0
- package/dist/node-fetch.d.ts +9 -0
- package/dist/node-fetch.js +25 -0
- package/dist/poll.d.ts +10 -0
- package/dist/poll.js +33 -0
- package/docs/cast.md +133 -0
- package/docs/cli.md +41 -0
- package/docs/config.md +36 -0
- package/docs/decisions.md +150 -0
- package/docs/devices.md +128 -0
- package/docs/enphase-notes.md +24 -0
- package/docs/google.md +92 -0
- package/docs/ideas.md +69 -0
- package/docs/matter.md +55 -0
- package/docs/performance.md +12 -0
- package/docs/philosophy.md +23 -0
- package/docs/up.md +84 -0
- package/docs/using.md +65 -0
- package/docs/weather-probe.md +63 -0
- package/examples/home.jsonc +74 -0
- package/examples/home.svg +76 -0
- package/home.schema.json +474 -0
- package/llms.txt +32 -0
- package/package.json +143 -4
- package/skills/draw-home/README.md +25 -0
- package/skills/draw-home/SKILL.md +80 -0
- package/skills/use-home/SKILL.md +86 -0
- package/src/cli/cache.ts +29 -0
- package/src/cli/cast.ts +172 -0
- package/src/cli/config.ts +52 -0
- package/src/cli/context.ts +118 -0
- package/src/cli/discover.ts +182 -0
- package/src/cli/enphase.ts +62 -0
- package/src/cli/hue.ts +124 -0
- package/src/cli/locate.ts +47 -0
- package/src/cli/main.ts +182 -0
- package/src/cli/printers.ts +21 -0
- package/src/cli/probe.ts +274 -0
- package/src/cli/spotify.ts +76 -0
- package/src/cli/status.ts +228 -0
- package/src/cli/up.ts +350 -0
- package/src/clock.ts +62 -0
- package/src/config.ts +578 -0
- package/src/connector.ts +77 -0
- package/src/connectors/amber/README.md +40 -0
- package/src/connectors/amber/client.ts +157 -0
- package/src/connectors/amber/index.ts +118 -0
- package/src/connectors/cast/README.md +44 -0
- package/src/connectors/cast/codec.ts +126 -0
- package/src/connectors/cast/index.ts +552 -0
- package/src/connectors/cast/schemas.ts +101 -0
- package/src/connectors/cast/serve.ts +82 -0
- package/src/connectors/cast/state.ts +203 -0
- package/src/connectors/cast/tls.ts +100 -0
- package/src/connectors/cast/writes.ts +572 -0
- package/src/connectors/daikin/README.md +78 -0
- package/src/connectors/daikin/client.ts +295 -0
- package/src/connectors/daikin/index.ts +145 -0
- package/src/connectors/enphase/README.md +64 -0
- package/src/connectors/enphase/client.ts +244 -0
- package/src/connectors/enphase/index.ts +241 -0
- package/src/connectors/enphase/schemas.ts +45 -0
- package/src/connectors/hue/README.md +50 -0
- package/src/connectors/hue/client.ts +157 -0
- package/src/connectors/hue/events.ts +182 -0
- package/src/connectors/hue/index.ts +261 -0
- package/src/connectors/hue/schemas.ts +60 -0
- package/src/connectors/hue/state.ts +124 -0
- package/src/connectors/printers/README.md +46 -0
- package/src/connectors/printers/client.ts +114 -0
- package/src/connectors/printers/codec.ts +292 -0
- package/src/connectors/printers/found.ts +46 -0
- package/src/connectors/printers/index.ts +385 -0
- package/src/connectors/printers/state.ts +182 -0
- package/src/connectors/spotify/README.md +83 -0
- package/src/connectors/spotify/changes.ts +127 -0
- package/src/connectors/spotify/client.ts +387 -0
- package/src/connectors/spotify/index.ts +937 -0
- package/src/connectors/up/README.md +35 -0
- package/src/connectors/up/client.ts +518 -0
- package/src/connectors/up/index.ts +370 -0
- package/src/connectors/up/spend.ts +280 -0
- package/src/connectors/up/webhook.ts +62 -0
- package/src/connectors/weather/README.md +84 -0
- package/src/connectors/weather/blocks.ts +173 -0
- package/src/connectors/weather/bom.ts +344 -0
- package/src/connectors/weather/ftp.ts +48 -0
- package/src/connectors/weather/google.ts +425 -0
- package/src/connectors/weather/index.ts +396 -0
- package/src/connectors/weather/open-meteo.ts +309 -0
- package/src/connectors/weather/rain.ts +73 -0
- package/src/connectors/weather/source.ts +236 -0
- package/src/connectors/weather/sun.ts +114 -0
- package/src/discover.ts +638 -0
- package/src/energy.ts +221 -0
- package/src/finders-entry.ts +2 -0
- package/src/finders.ts +250 -0
- package/src/health.ts +74 -0
- package/src/home.ts +205 -0
- package/src/index.ts +62 -0
- package/src/lane.ts +110 -0
- package/src/locate.ts +85 -0
- package/src/manifest.ts +43 -0
- package/src/manifests.ts +27 -0
- package/src/node-fetch.ts +41 -0
- package/src/poll.ts +33 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { asOf, type Clock, realClock } from "../../clock.ts";
|
|
3
|
+
import { errorText } from "../../connector.ts";
|
|
4
|
+
import {
|
|
5
|
+
DAY_MS,
|
|
6
|
+
discover,
|
|
7
|
+
type Finder,
|
|
8
|
+
HOUR,
|
|
9
|
+
type Found,
|
|
10
|
+
type Lost,
|
|
11
|
+
lostDetail,
|
|
12
|
+
rescanDelay,
|
|
13
|
+
} from "../../discover.ts";
|
|
14
|
+
import type { Integration, Part, Status } from "../../home.ts";
|
|
15
|
+
import type { Manifest } from "../../manifest.ts";
|
|
16
|
+
import { poller } from "../../poll.ts";
|
|
17
|
+
import { getPrinterAttributes, printerUrl } from "./client.ts";
|
|
18
|
+
import { attributesOf } from "./codec.ts";
|
|
19
|
+
import {
|
|
20
|
+
type Printer,
|
|
21
|
+
printerKey,
|
|
22
|
+
printerText,
|
|
23
|
+
type PrintersState,
|
|
24
|
+
type Reading,
|
|
25
|
+
toState,
|
|
26
|
+
} from "./state.ts";
|
|
27
|
+
|
|
28
|
+
export {
|
|
29
|
+
type Marker,
|
|
30
|
+
type Printer,
|
|
31
|
+
type PrintersState,
|
|
32
|
+
type PrinterStatus,
|
|
33
|
+
printerText,
|
|
34
|
+
} from "./state.ts";
|
|
35
|
+
|
|
36
|
+
/** 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. */
|
|
37
|
+
export const configSchema = z.looseObject({
|
|
38
|
+
connector: z.literal("printers"),
|
|
39
|
+
name: z
|
|
40
|
+
.string()
|
|
41
|
+
.describe(
|
|
42
|
+
"the printer's name for display (mDNS `ty`, or its instance name); `home discover --write` fills it",
|
|
43
|
+
),
|
|
44
|
+
uuid: z
|
|
45
|
+
.string()
|
|
46
|
+
.describe(
|
|
47
|
+
"the printer's UUID from its mDNS TXT record, the stable identity; `home discover --write` fills it",
|
|
48
|
+
),
|
|
49
|
+
host: z
|
|
50
|
+
.string()
|
|
51
|
+
.optional()
|
|
52
|
+
.describe("the address it last answered at; discovery refreshes it"),
|
|
53
|
+
port: z
|
|
54
|
+
.number()
|
|
55
|
+
.int()
|
|
56
|
+
.min(1)
|
|
57
|
+
.max(65535)
|
|
58
|
+
.optional()
|
|
59
|
+
.describe("its IPP port (631 plain, 443 TLS); discovery fills it"),
|
|
60
|
+
offlineDays: z
|
|
61
|
+
.number()
|
|
62
|
+
.positive()
|
|
63
|
+
.optional()
|
|
64
|
+
.describe(
|
|
65
|
+
"days an unreachable printer shows as offline with its last-seen time before its entry flags it as long gone; default 14",
|
|
66
|
+
),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
export type PrinterEntry = {
|
|
70
|
+
uuid: string;
|
|
71
|
+
name?: string;
|
|
72
|
+
host?: string;
|
|
73
|
+
port?: number;
|
|
74
|
+
room?: string;
|
|
75
|
+
offlineDays?: number;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export type PrintersConfig = {
|
|
79
|
+
/** The config's printers (a `name` is the config's and stays; without one the printer's own, from discovery, is used). Discovery adds any others it finds. */
|
|
80
|
+
printers?: PrinterEntry[];
|
|
81
|
+
/** Plain HTTP and HTTPS; the global `fetch` when left out. A printer's self-signed certificate on IPPS fails here, as it should. */
|
|
82
|
+
fetch?: typeof fetch;
|
|
83
|
+
/** mDNS discovery (`FINDERS.printers`): at start, then after a failed read on the relocator's cadence (1, 5, 15 minutes, hourly), and hourly for new printers. Without it only the configured addresses are read. */
|
|
84
|
+
find?: Finder;
|
|
85
|
+
/** Each address that answered, for an address cache. */
|
|
86
|
+
seen?: (
|
|
87
|
+
uuid: string,
|
|
88
|
+
address: { host: string; port: number; name?: string },
|
|
89
|
+
) => void;
|
|
90
|
+
clock?: Clock;
|
|
91
|
+
/** Poll interval; default 120 s. */
|
|
92
|
+
pollMs?: number;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const POLL_MS = 120_000;
|
|
96
|
+
/** A printer unreachable this many days gets a note in `anomaly`, unless its config says otherwise. */
|
|
97
|
+
const OFFLINE_DAYS = 14;
|
|
98
|
+
|
|
99
|
+
/** What the integration knows about one printer between polls. */
|
|
100
|
+
type Target = {
|
|
101
|
+
key: string;
|
|
102
|
+
name: string;
|
|
103
|
+
fixedName: boolean; // the config named it
|
|
104
|
+
room?: string;
|
|
105
|
+
offlineDays: number;
|
|
106
|
+
host?: string;
|
|
107
|
+
port?: number;
|
|
108
|
+
rp?: string; // from discovery's TXT
|
|
109
|
+
secure?: boolean; // from discovery: found on _ipps._tcp alone
|
|
110
|
+
foundModel?: string;
|
|
111
|
+
good?: Reading; // the last read that worked
|
|
112
|
+
lastSeen?: number;
|
|
113
|
+
since: number; // when we first looked, the start of "no answer" for a printer never seen
|
|
114
|
+
failure?: string; // why the latest read failed; absent after a good read
|
|
115
|
+
lost: Lost; // what the latest scan learned about a failed one
|
|
116
|
+
ipps: boolean;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Every printer as one integration, state only: a Get-Printer-Attributes over IPP to each, every 120 s. An unreachable
|
|
121
|
+
* printer stays in the snapshot as `offline` with its last good values and `lastSeen`, and the part is never
|
|
122
|
+
* `unavailable` for that. Discovery is one shared scan, as Cast's is, not a relocator per printer: it finds printers
|
|
123
|
+
* the config does not list, and after a failed read it looks for the printer's new address (at 1, 5 and 15 minutes, then hourly).
|
|
124
|
+
*/
|
|
125
|
+
export function printers(
|
|
126
|
+
config: PrintersConfig = {},
|
|
127
|
+
): Integration<PrintersState, "printers"> {
|
|
128
|
+
const clock = config.clock ?? realClock;
|
|
129
|
+
const fetcher = config.fetch ?? fetch;
|
|
130
|
+
const pollMs = config.pollMs ?? POLL_MS;
|
|
131
|
+
const poll = poller(clock);
|
|
132
|
+
const targets = new Map<string, Target>();
|
|
133
|
+
const add = (
|
|
134
|
+
t: Partial<Target> & { key: string; name: string; fixedName: boolean },
|
|
135
|
+
): Target => {
|
|
136
|
+
const target: Target = {
|
|
137
|
+
offlineDays: OFFLINE_DAYS,
|
|
138
|
+
since: clock.now(),
|
|
139
|
+
lost: {},
|
|
140
|
+
ipps: false,
|
|
141
|
+
...t,
|
|
142
|
+
};
|
|
143
|
+
targets.set(t.key, target);
|
|
144
|
+
return target;
|
|
145
|
+
};
|
|
146
|
+
for (const p of config.printers ?? [])
|
|
147
|
+
add({
|
|
148
|
+
key: printerKey(p.uuid),
|
|
149
|
+
name: p.name ?? p.uuid,
|
|
150
|
+
fixedName: p.name !== undefined,
|
|
151
|
+
room: p.room,
|
|
152
|
+
host: p.host,
|
|
153
|
+
port: p.port,
|
|
154
|
+
offlineDays: p.offlineDays ?? OFFLINE_DAYS,
|
|
155
|
+
});
|
|
156
|
+
let report: ((part: Part<PrintersState>) => void) | undefined;
|
|
157
|
+
let polled = false;
|
|
158
|
+
let requestId = 0;
|
|
159
|
+
let lastScan = Number.NEGATIVE_INFINITY;
|
|
160
|
+
let nextScan = 0;
|
|
161
|
+
let attempt = 0;
|
|
162
|
+
let scanError: string | undefined;
|
|
163
|
+
|
|
164
|
+
/** The entry for the snapshot: the last good values, `offline` while the latest read has failed. */
|
|
165
|
+
function view(t: Target, now: string): Printer {
|
|
166
|
+
const { uuid: _, anomaly: oddReading, ...good } = t.good ?? {};
|
|
167
|
+
const offline = t.failure !== undefined;
|
|
168
|
+
const base = t.lastSeen ?? t.since;
|
|
169
|
+
const idle = clock.now() - base;
|
|
170
|
+
const long =
|
|
171
|
+
offline && idle > t.offlineDays * DAY_MS
|
|
172
|
+
? `no answer for ${Math.floor(idle / DAY_MS)} days`
|
|
173
|
+
: undefined;
|
|
174
|
+
return {
|
|
175
|
+
...good,
|
|
176
|
+
uuid: t.key,
|
|
177
|
+
name: t.name,
|
|
178
|
+
model: good.model ?? t.foundModel,
|
|
179
|
+
host: t.host,
|
|
180
|
+
room: t.room,
|
|
181
|
+
...(offline ? { state: "offline" as const } : {}),
|
|
182
|
+
ipps: t.ipps,
|
|
183
|
+
lastSeen:
|
|
184
|
+
t.lastSeen === undefined
|
|
185
|
+
? undefined
|
|
186
|
+
: new Date(t.lastSeen).toISOString(),
|
|
187
|
+
asOf: now,
|
|
188
|
+
detail: offline
|
|
189
|
+
? lostDetail(
|
|
190
|
+
{ ...t.lost, ...(idle >= DAY_MS ? { overADay: true } : {}) },
|
|
191
|
+
t.failure,
|
|
192
|
+
)
|
|
193
|
+
: undefined,
|
|
194
|
+
anomaly: [oddReading, long].filter(Boolean).join("; ") || undefined,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function publish() {
|
|
199
|
+
const now = asOf(clock);
|
|
200
|
+
const list = [...targets.values()].map((t) => view(t, now));
|
|
201
|
+
const down = list.filter((p) => p.state === "offline");
|
|
202
|
+
const status: Status = !polled
|
|
203
|
+
? "connecting"
|
|
204
|
+
: list.length && down.length < list.length
|
|
205
|
+
? "connected"
|
|
206
|
+
: "disconnected";
|
|
207
|
+
const detail =
|
|
208
|
+
[
|
|
209
|
+
scanError && `discovery failed: ${scanError}`,
|
|
210
|
+
list.length
|
|
211
|
+
? down.length
|
|
212
|
+
? `${down.map((p) => p.name).join(", ")} offline`
|
|
213
|
+
: ""
|
|
214
|
+
: config.find
|
|
215
|
+
? "no printers found"
|
|
216
|
+
: "no printers configured",
|
|
217
|
+
]
|
|
218
|
+
.filter(Boolean)
|
|
219
|
+
.join("; ") || undefined;
|
|
220
|
+
const anomaly =
|
|
221
|
+
list
|
|
222
|
+
.filter((p) => p.anomaly)
|
|
223
|
+
.map((p) => `${p.name}: ${p.anomaly}`)
|
|
224
|
+
.join("; ") || undefined;
|
|
225
|
+
report?.({
|
|
226
|
+
status,
|
|
227
|
+
detail,
|
|
228
|
+
anomaly,
|
|
229
|
+
state: {
|
|
230
|
+
printers: Object.fromEntries(list.map((p) => [p.uuid, p])),
|
|
231
|
+
asOf: now,
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** One read. A failure is data (`failure`), never thrown; an address that answers as another printer is a failure. */
|
|
237
|
+
async function read(t: Target, signal: AbortSignal): Promise<void> {
|
|
238
|
+
if (!t.host) {
|
|
239
|
+
t.failure = "no address yet";
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const address = { host: t.host, port: t.port, rp: t.rp, secure: t.secure };
|
|
243
|
+
t.ipps = printerUrl(address).secure;
|
|
244
|
+
try {
|
|
245
|
+
const reading = toState(
|
|
246
|
+
attributesOf(
|
|
247
|
+
await getPrinterAttributes(fetcher, address, {
|
|
248
|
+
signal,
|
|
249
|
+
requestId: ++requestId,
|
|
250
|
+
}),
|
|
251
|
+
),
|
|
252
|
+
);
|
|
253
|
+
if (
|
|
254
|
+
reading.uuid &&
|
|
255
|
+
/^[0-9a-f]{32}$/.test(t.key) &&
|
|
256
|
+
printerKey(reading.uuid) !== t.key
|
|
257
|
+
)
|
|
258
|
+
throw new Error("the address answers as another printer");
|
|
259
|
+
t.good = reading;
|
|
260
|
+
t.lastSeen = clock.now();
|
|
261
|
+
t.failure = undefined;
|
|
262
|
+
t.lost = {};
|
|
263
|
+
config.seen?.(t.key, {
|
|
264
|
+
host: t.host,
|
|
265
|
+
port: t.port ?? (t.ipps ? 443 : 631),
|
|
266
|
+
name: t.fixedName ? undefined : t.name,
|
|
267
|
+
});
|
|
268
|
+
} catch (error) {
|
|
269
|
+
if (!signal.aborted) t.failure = errorText(error);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** One discovery. Printers it finds get their address and TXT; ones the config lacks join the snapshot. Returns those that need reading again. */
|
|
274
|
+
async function scan(
|
|
275
|
+
signal: AbortSignal,
|
|
276
|
+
failed: Target[],
|
|
277
|
+
): Promise<Target[]> {
|
|
278
|
+
lastScan = clock.now();
|
|
279
|
+
let all: Found[];
|
|
280
|
+
try {
|
|
281
|
+
const wanted = failed.map((t) => t.key);
|
|
282
|
+
all = await discover(config.find!, {
|
|
283
|
+
clock,
|
|
284
|
+
signal,
|
|
285
|
+
passes: failed.length ? undefined : 1,
|
|
286
|
+
done: wanted.length
|
|
287
|
+
? (found) =>
|
|
288
|
+
wanted.every((k) => found.some((f) => printerKey(f.id) === k))
|
|
289
|
+
: undefined,
|
|
290
|
+
});
|
|
291
|
+
scanError = undefined;
|
|
292
|
+
} catch (error) {
|
|
293
|
+
scanError = errorText(error);
|
|
294
|
+
for (const t of failed) t.lost = { discoveryFailed: scanError };
|
|
295
|
+
return [];
|
|
296
|
+
}
|
|
297
|
+
const changed: Target[] = [];
|
|
298
|
+
const mine = all.filter((f) => f.connector === "printers");
|
|
299
|
+
for (const f of mine) {
|
|
300
|
+
const key = printerKey(f.id);
|
|
301
|
+
const known = targets.get(key);
|
|
302
|
+
const t = known ?? add({ key, name: f.name ?? f.id, fixedName: false });
|
|
303
|
+
const moved =
|
|
304
|
+
t.host !== f.host || (f.port !== undefined && t.port !== f.port);
|
|
305
|
+
// A printer known by host alone was read at the default path over http; the TXT may say otherwise.
|
|
306
|
+
const reroute =
|
|
307
|
+
(f.txt?.rp !== undefined && f.txt.rp !== t.rp) ||
|
|
308
|
+
(f.secure !== undefined && f.secure !== t.secure);
|
|
309
|
+
if (!t.fixedName && f.name) t.name = f.name;
|
|
310
|
+
Object.assign(t, {
|
|
311
|
+
host: f.host,
|
|
312
|
+
port: f.port ?? t.port,
|
|
313
|
+
rp: f.txt?.rp ?? t.rp,
|
|
314
|
+
secure: f.secure ?? t.secure,
|
|
315
|
+
foundModel: f.model ?? t.foundModel,
|
|
316
|
+
});
|
|
317
|
+
if (!known || moved || reroute) changed.push(t);
|
|
318
|
+
else if (failed.includes(t)) t.lost = { seenHere: true };
|
|
319
|
+
}
|
|
320
|
+
for (const t of failed)
|
|
321
|
+
if (!mine.some((f) => printerKey(f.id) === t.key))
|
|
322
|
+
t.lost = { seenHere: false };
|
|
323
|
+
return changed;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function pollOnce(signal: AbortSignal): Promise<void> {
|
|
327
|
+
const first = !polled;
|
|
328
|
+
await Promise.all([...targets.values()].map((t) => read(t, signal)));
|
|
329
|
+
if (signal.aborted || !config.find) return;
|
|
330
|
+
const now = clock.now();
|
|
331
|
+
const failed = () =>
|
|
332
|
+
[...targets.values()].filter((t) => t.failure !== undefined);
|
|
333
|
+
if (!(
|
|
334
|
+
first ||
|
|
335
|
+
(failed().length && now >= nextScan) ||
|
|
336
|
+
now >= lastScan + HOUR
|
|
337
|
+
))
|
|
338
|
+
return;
|
|
339
|
+
const changed = await scan(signal, failed());
|
|
340
|
+
await Promise.all(changed.map((t) => read(t, signal)));
|
|
341
|
+
if (failed().length) nextScan = now + rescanDelay(attempt++);
|
|
342
|
+
else ((attempt = 0), (nextScan = 0));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
name: "printers",
|
|
347
|
+
/** Read now; a printer that is down is looked for again at once. */
|
|
348
|
+
refresh() {
|
|
349
|
+
attempt = 0;
|
|
350
|
+
nextScan = 0;
|
|
351
|
+
poll.nudge();
|
|
352
|
+
},
|
|
353
|
+
async run(signal, update) {
|
|
354
|
+
report = update;
|
|
355
|
+
while (!signal.aborted) {
|
|
356
|
+
await pollOnce(signal);
|
|
357
|
+
if (signal.aborted) return;
|
|
358
|
+
polled = true;
|
|
359
|
+
publish();
|
|
360
|
+
await poll.wait(pollMs, signal);
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Printers sorted by name. */
|
|
367
|
+
export const printerList = (s: PrintersState) =>
|
|
368
|
+
Object.values(s.printers).sort((a, b) => a.name.localeCompare(b.name));
|
|
369
|
+
|
|
370
|
+
export const manifest: Manifest<PrintersState> = {
|
|
371
|
+
name: "printers",
|
|
372
|
+
title: "Printers",
|
|
373
|
+
iotClass: "local_polling",
|
|
374
|
+
pollSeconds: POLL_MS / 1000,
|
|
375
|
+
access: "local IPP over HTTP, no key; found by mDNS (AirPrint)",
|
|
376
|
+
env: [],
|
|
377
|
+
lines({ state }) {
|
|
378
|
+
if (!state) return [];
|
|
379
|
+
const list = printerList(state);
|
|
380
|
+
return [
|
|
381
|
+
`${list.length} printer${list.length === 1 ? "" : "s"}`,
|
|
382
|
+
...list.map(printerText),
|
|
383
|
+
];
|
|
384
|
+
},
|
|
385
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { idKey } from "../../discover.ts";
|
|
2
|
+
import type { IppValue } from "./codec.ts";
|
|
3
|
+
|
|
4
|
+
/** One consumable as the printer lists it: ink or toner, a drum, a fuser, waste. The level is the printer's own number, unrounded. */
|
|
5
|
+
export type Marker = {
|
|
6
|
+
name?: string;
|
|
7
|
+
level?: number;
|
|
8
|
+
type?: string;
|
|
9
|
+
color?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type PrinterStatus = "idle" | "printing" | "stopped" | "offline";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One printer. Every field the printer reports is optional; an odd reading passes through and is named in `anomaly`.
|
|
16
|
+
* `uuid` is the key: the mDNS `UUID` (or `printer-uuid`) lowercase without dashes. An unreachable printer keeps its last good
|
|
17
|
+
* values, with `state: "offline"` and `lastSeen`; `asOf` is when this entry was last checked, `lastSeen` when it last answered.
|
|
18
|
+
*/
|
|
19
|
+
export type Printer = {
|
|
20
|
+
uuid: string;
|
|
21
|
+
name: string;
|
|
22
|
+
model?: string;
|
|
23
|
+
host?: string;
|
|
24
|
+
room?: string;
|
|
25
|
+
state?: PrinterStatus; // absent when the printer sent a code outside 3 to 5; `stateCode` has it
|
|
26
|
+
stateCode?: number;
|
|
27
|
+
reasons?: string[];
|
|
28
|
+
message?: string;
|
|
29
|
+
markers?: Marker[];
|
|
30
|
+
media?: string[]; // media-ready keywords, as sent: "iso_a4_210x297mm"
|
|
31
|
+
queuedJobs?: number;
|
|
32
|
+
acceptingJobs?: boolean;
|
|
33
|
+
formats?: string[]; // document-format-supported: MIME types
|
|
34
|
+
ippVersions?: string[];
|
|
35
|
+
alerts?: string[];
|
|
36
|
+
ipps: boolean; // read over TLS
|
|
37
|
+
lastSeen?: string;
|
|
38
|
+
asOf: string;
|
|
39
|
+
detail?: string;
|
|
40
|
+
anomaly?: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type PrintersState = { printers: Record<string, Printer>; asOf: string };
|
|
44
|
+
|
|
45
|
+
/** An id as the snapshot keys it: a UUID in any form (`urn:uuid:…`, dashed, upper case) is its 32 hex digits; an instance name keeps its letters and digits. */
|
|
46
|
+
export const printerKey = idKey;
|
|
47
|
+
|
|
48
|
+
/** The fields of `Printer` that come from Get-Printer-Attributes. */
|
|
49
|
+
export type Reading = Partial<
|
|
50
|
+
Pick<
|
|
51
|
+
Printer,
|
|
52
|
+
| "model"
|
|
53
|
+
| "state"
|
|
54
|
+
| "stateCode"
|
|
55
|
+
| "reasons"
|
|
56
|
+
| "message"
|
|
57
|
+
| "markers"
|
|
58
|
+
| "media"
|
|
59
|
+
| "queuedJobs"
|
|
60
|
+
| "acceptingJobs"
|
|
61
|
+
| "formats"
|
|
62
|
+
| "ippVersions"
|
|
63
|
+
| "alerts"
|
|
64
|
+
| "anomaly"
|
|
65
|
+
>
|
|
66
|
+
> & { uuid?: string };
|
|
67
|
+
|
|
68
|
+
const strings = (v?: IppValue[]): string[] | undefined =>
|
|
69
|
+
v?.flatMap((x) =>
|
|
70
|
+
typeof x === "string"
|
|
71
|
+
? [x]
|
|
72
|
+
: x instanceof Uint8Array
|
|
73
|
+
? [new TextDecoder().decode(x)]
|
|
74
|
+
: [],
|
|
75
|
+
);
|
|
76
|
+
const numbers = (v?: IppValue[]): number[] | undefined =>
|
|
77
|
+
v?.flatMap((x) => (typeof x === "number" ? [x] : []));
|
|
78
|
+
|
|
79
|
+
const STATES: Record<number, PrinterStatus> = {
|
|
80
|
+
3: "idle",
|
|
81
|
+
4: "printing",
|
|
82
|
+
5: "stopped",
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The printer's attributes (name → values, from `attributesOf`) as a `Reading`. Nothing is clamped or corrected: a state
|
|
87
|
+
* outside 3 to 5 leaves `state` out and keeps the code; a marker level outside 0 to 100 (PWG 5100.13 gives the negative ones
|
|
88
|
+
* special meanings) stays as sent; both, and lists that disagree in length, are named in `anomaly`.
|
|
89
|
+
*/
|
|
90
|
+
export function toState(attrs: Record<string, IppValue[]>): Reading {
|
|
91
|
+
const odd: string[] = [];
|
|
92
|
+
const [stateCode] = numbers(attrs["printer-state"]) ?? [];
|
|
93
|
+
const state = stateCode === undefined ? undefined : STATES[stateCode];
|
|
94
|
+
if (stateCode !== undefined && !state)
|
|
95
|
+
odd.push(
|
|
96
|
+
`printer-state ${stateCode}, not 3 (idle), 4 (processing) or 5 (stopped)`,
|
|
97
|
+
);
|
|
98
|
+
const names = strings(attrs["marker-names"]);
|
|
99
|
+
const levels = numbers(attrs["marker-levels"]);
|
|
100
|
+
const types = strings(attrs["marker-types"]);
|
|
101
|
+
const colors = strings(attrs["marker-colors"]);
|
|
102
|
+
const count = Math.max(
|
|
103
|
+
names?.length ?? 0,
|
|
104
|
+
levels?.length ?? 0,
|
|
105
|
+
types?.length ?? 0,
|
|
106
|
+
colors?.length ?? 0,
|
|
107
|
+
);
|
|
108
|
+
const lengths = [names, levels, types, colors].flatMap((x) =>
|
|
109
|
+
x ? [x.length] : [],
|
|
110
|
+
);
|
|
111
|
+
if (new Set(lengths).size > 1)
|
|
112
|
+
odd.push(`marker lists of different lengths (${lengths.join(", ")})`);
|
|
113
|
+
const markers: Marker[] = Array.from({ length: count }, (_, i) => ({
|
|
114
|
+
name: names?.[i],
|
|
115
|
+
level: levels?.[i],
|
|
116
|
+
type: types?.[i],
|
|
117
|
+
color: colors?.[i],
|
|
118
|
+
}));
|
|
119
|
+
for (const m of markers)
|
|
120
|
+
if (m.level !== undefined && (m.level < 0 || m.level > 100))
|
|
121
|
+
odd.push(`marker ${m.name ?? "?"} level ${m.level}, outside 0 to 100`);
|
|
122
|
+
const accepting = attrs["printer-is-accepting-jobs"]?.[0];
|
|
123
|
+
const uuid = strings(attrs["printer-uuid"])?.[0];
|
|
124
|
+
return {
|
|
125
|
+
uuid,
|
|
126
|
+
model: strings(attrs["printer-make-and-model"])?.[0],
|
|
127
|
+
state,
|
|
128
|
+
stateCode,
|
|
129
|
+
reasons: strings(attrs["printer-state-reasons"]),
|
|
130
|
+
message: strings(attrs["printer-state-message"])?.[0],
|
|
131
|
+
markers: count ? markers : undefined,
|
|
132
|
+
media: strings(attrs["media-ready"]),
|
|
133
|
+
queuedJobs: numbers(attrs["queued-job-count"])?.[0],
|
|
134
|
+
acceptingJobs: typeof accepting === "boolean" ? accepting : undefined,
|
|
135
|
+
formats: strings(attrs["document-format-supported"]),
|
|
136
|
+
ippVersions: strings(attrs["ipp-versions-supported"]),
|
|
137
|
+
alerts: strings(attrs["printer-alert"]),
|
|
138
|
+
anomaly: odd.length ? odd.join("; ") : undefined,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A PWG media keyword for reading: `iso_a4_210x297mm` is "A4", `na_letter_8.5x11in` "Letter". A keyword in another shape stays as it is. */
|
|
143
|
+
export function mediaName(keyword: string): string {
|
|
144
|
+
const parts = keyword.split("_");
|
|
145
|
+
if (parts.length < 3) return keyword;
|
|
146
|
+
const name = parts[1]!;
|
|
147
|
+
return /^[a-z]\d+$/.test(name)
|
|
148
|
+
? name.toUpperCase()
|
|
149
|
+
: name.charAt(0).toUpperCase() + name.slice(1);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** "5 min ago", "2 h ago", "3 days ago" from two instants; whole units, down. */
|
|
153
|
+
export function ago(fromMs: number, toMs: number): string {
|
|
154
|
+
const min = Math.max(0, Math.floor((toMs - fromMs) / 60_000));
|
|
155
|
+
if (min < 60) return `${min} min ago`;
|
|
156
|
+
const h = Math.floor(min / 60);
|
|
157
|
+
return h < 48 ? `${h} h ago` : `${Math.floor(h / 24)} days ago`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** One printer in a few words: "Office: idle · Black Toner 40% · A4 ready", "Office: offline, last seen 2 h ago". */
|
|
161
|
+
export function printerText(p: Printer): string {
|
|
162
|
+
if (p.state === "offline") {
|
|
163
|
+
const seen =
|
|
164
|
+
p.lastSeen === undefined
|
|
165
|
+
? "never seen"
|
|
166
|
+
: `last seen ${ago(Date.parse(p.lastSeen), Date.parse(p.asOf))}`;
|
|
167
|
+
return `${p.name}: offline, ${seen}`;
|
|
168
|
+
}
|
|
169
|
+
const attention = (p.reasons ?? []).filter((r) =>
|
|
170
|
+
/-(error|warning)$/.test(r),
|
|
171
|
+
);
|
|
172
|
+
const media = [...new Set((p.media ?? []).map(mediaName))];
|
|
173
|
+
const parts = [
|
|
174
|
+
p.state ?? (p.stateCode === undefined ? "" : `state ${p.stateCode}`),
|
|
175
|
+
...(p.markers ?? []).map((m) => `${m.name ?? "?"} ${m.level ?? "?"}%`),
|
|
176
|
+
media.length ? `${media.join(", ")} ready` : "",
|
|
177
|
+
p.queuedJobs ? `${p.queuedJobs} queued` : "",
|
|
178
|
+
p.acceptingJobs === false ? "not accepting jobs" : "",
|
|
179
|
+
...attention,
|
|
180
|
+
];
|
|
181
|
+
return `${p.name}: ${parts.filter(Boolean).join(" · ")}`;
|
|
182
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Spotify (Soloist)
|
|
2
|
+
|
|
3
|
+
What a [Spotify Soloist](https://developer.spotify.com/documentation/soloist) speaker is playing (start with its [basic integration](https://developer.spotify.com/documentation/soloist/howtos/basic-integration)), and control over it, over Soloist's local WebSocket. `iot_class`: **local_push**. Reads seen live on a real Soloist (2026-11-09); writes tested against a fake socket only.
|
|
4
|
+
|
|
5
|
+
Soloist is Spotify's own headless Spotify Connect client (Linux arm64, armv7, x86_64, Apple Silicon, Windows x86_64). The machine running it is a speaker in every Spotify app on the network; this connector is how a program on the same machine sees and steers it. It doesn't search Spotify or read a library: Soloist plays URIs and reports its own session. Search belongs to the consumer (KO, 2026-11-08; `docs/ideas.md`).
|
|
6
|
+
|
|
7
|
+
## Built
|
|
8
|
+
|
|
9
|
+
**Connecting.** `spotify({ url?, maxVolume?, repairStalls?, stallCheckMs?, connect?, clock? })` holds one WebSocket to `url`, `ws://127.0.0.1:9091` by default (chota-bot's port; Soloist has none, its docs' example is 9090, so start it with `--ws 127.0.0.1:9091` or pass `url`). `connect` defaults to the runtime's `WebSocket` (Bun, Node 22+).
|
|
10
|
+
|
|
11
|
+
- No report until `auth_state` and, signed in, the first `playback_state` (at most 10 s): the part stays createHome's `connecting`, and a one-shot read sees the track. Then `connected`. Nothing answering, or a drop, is `disconnected` with the reason in `detail` and the last state kept; a reconnect stays `disconnected` until Soloist answers. A socket silent for 10 s is closed. Backoff 1 s doubling to 60 s, back to 1 s after a socket that opened. Without `SOLOIST_WS`, `home` lists the speaker as not set up.
|
|
12
|
+
- **Signed out is normal** until someone picks the speaker in a Spotify app (seen live): `connected`, `loggedIn: false`, detail "waiting for someone to pick it in the Spotify app", which also names a bad key ([#18](https://github.com/spotify/soloist/issues/18)). Writes are refused unsent with code `waiting`.
|
|
13
|
+
- `refresh()` asks again (`get_state`, or `get_auth_state` signed out; a refused query is an `anomaly`), or reconnects now while down.
|
|
14
|
+
|
|
15
|
+
**State.** Each frame passes a zod schema, every field optional, and folds into `SpotifyState`: `loggedIn`, `deviceName`, `active`, `status`, `track` (`uri`, `type`, `name`, `artists`, `album`, cover, `durationMs`), `context`, `position` (Soloist's anchor `ms`, `atMs`, `speed`; `positionMs(state, now)` moves it on, not clamped), `volume`, `shuffle`, `repeat`, `upNext` (from `queue_changed`, plus one `get_queue` with `limit: 10` per socket; an unanswered one is quiet, as an empty queue may send nothing). An empty item `uri` is nothing loaded: no `track`. Unknown frame types are skipped. The library adds `aside` while its own `duck`, `interrupt` or stall repair runs, and `stalled`. Odd readings pass through into `anomaly`: an unknown status or repeat, a volume outside 0–100, a negative speed, a position past the track's end, a frame the schema rejects (which leaves the state as it was).
|
|
16
|
+
|
|
17
|
+
**Writes**, on the integration: `play(linkOrUri?)`, `pause`, `resume`, `next`, `prev`, `seek(ms)`, `setVolume(0–100)`, `queue(track)`, `setShuffle(on)`, `setRepeat("off" | "context" | "track")`. A link is an `open.spotify.com` URL or a `spotify:` URI; `spotify.link` short links are refused.
|
|
18
|
+
|
|
19
|
+
- Each resolves `{ state, confirmed }`: after Soloist accepts (`command_result`), it waits up to 2 s for the event that shows _this caller's_ change (the volume sent, `paused` or `playing`, a new track URI, the context or track played, a position near the seek, the shuffle or repeat set, the track in `upNext`), then gives the state. No such event: `confirmed: false`, the state as it stands. A write merged into a later one confirms only if its own change shows; a write of what is already so confirms at once; a drop ends the wait. `next` also confirms on idle (end of queue) or a restart, `prev` on a restart from 5 s or more in.
|
|
20
|
+
- Errors are `ConnectorError` with `code`: `offline`, `waiting`, `refused` (Soloist's `error`), `timeout` (3 s), `invalid` (bad input, nothing sent).
|
|
21
|
+
- One write lane, 100 ms apart (a guess). `volume`, `seek`, `shuffle`, `repeat` merge to the last; `play`, `pause`, `resume` share a slot, so the last wins; each `next`, `prev`, `queue` is its own. Queries go through the lane too, at most 1 s each, since Soloist's `error` names no command.
|
|
22
|
+
- `setRepeat` is two commands in the docs' order (clear the other mode, then set this one); if the second is refused, repeat may be left off.
|
|
23
|
+
- A failed write the library made itself stays in `detail` until a write works or the socket reconnects.
|
|
24
|
+
|
|
25
|
+
**`maxVolume`** holds requests (rounded to a whole percent). Within 10 s of a start from idle (to playing or buffering, or the first such status after a sign-in; a start through buffering opens the window again at `playing`), a reported volume above it is lowered once and `anomaly` says so until the speaker goes idle: [#23](https://github.com/spotify/soloist/issues/23), the first play on PipeWire starts at 100%. `--initial-volume` is ignored there. Not on resuming from pause, not on connecting to a song already playing, and a person turning it up again keeps it.
|
|
26
|
+
|
|
27
|
+
**`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:
|
|
28
|
+
|
|
29
|
+
- `untouched`: not playing or buffering, not connected, (duck) the volume unknown or already at `to` or below, or the lowering or pause failed, so `fn` ran over it.
|
|
30
|
+
- `restored`: put back as it was.
|
|
31
|
+
- `kept`: someone changed it meanwhile (resumed, skipped, set the volume), or the pause or lower volume never showed. Theirs stays.
|
|
32
|
+
- `failed`: the restore failed or was never reported; it may be left quiet or paused.
|
|
33
|
+
|
|
34
|
+
The restore runs even if `fn` throws: the error is `fn`'s, and a failed restore goes to `detail`. `duck` restores the earlier volume even above `maxVolume`. One aside at a time: never call one inside another's `fn` (it waits forever), and put two announcements in one `fn`. With Cast: `speaker.duck(() => cast.announce("Kitchen", clip), { to: 20 })`. Cast's own `announce` restores what a Cast device was playing; these are for Soloist's output.
|
|
35
|
+
|
|
36
|
+
**Reading it.** `speakerState` takes the Part; `spotifyText` and `positionMs` take its `state`. `speakerState(part)`: `offline`, `connecting`, `waiting`, then Soloist's status (`idle`, `playing`, `paused`, `buffering`, or its own string); signed in with no status yet is `idle`. `spotifyChanges(before, after)`, for `onChange`, gives events in this order: `offline`, `online`, `waiting`, `picked`, `started` (from idle), `resumed` (from paused), `paused`, `stopped`, `track`, `stalled`, `recovered`. Buffering counts as playing. Nothing on the first report, so a restart announces nothing; no volume or queue events. Playback events during an aside carry `own: true`; connection, sign-in and stall events never do.
|
|
37
|
+
|
|
38
|
+
**Stalls** ([#5](https://github.com/spotify/soloist/issues/5): playback stops mid-track, often the first after idle, while Soloist still says `playing`). While playing, one `get_state` every 20 s (`stallCheckMs`, code only; 0 turns it off).
|
|
39
|
+
|
|
40
|
+
- `frozen`: two fresh reports at least 10 s apart by Soloist's clock, under 1 s of movement. `overrun`: the same track more than 15 s past its length (the reported symptom), not with repeat-track. A pause or drop between checks starts over. Paused, buffering, idle and the library's own asides are not looked at.
|
|
41
|
+
- Sets `state.stalled` (`track`, `ms`, `how`, `tried`), "playback stalled at 1:39 (Soloist issue #5)" in `anomaly`, and a `stalled` event; `recovered` when a later check sees it move. A new track or a stop just ends it.
|
|
42
|
+
- `repairStalls: true` (off by default: an unasked write) tries once per track: a skip for an overrun; otherwise a seek to where it stuck, then a pause and play if it hasn't moved 5 s later. It queues with `duck` and `interrupt`, runs as an aside, and stops if the music stops, changes or another aside starts. #5 reports no workaround: the nudges are guesses.
|
|
43
|
+
|
|
44
|
+
**CLI and config.** `home spotify` and the writes `home spotify pause | play [link] | next | volume <0-100>` (held to `SOLOIST_MAX_VOLUME`) need `SOLOIST_WS`, and print what Soloist then reported. Config: `{ "connector": "spotify", "url"?, "maxVolume"?, "repairStalls"?, "name"? }`, one per home.
|
|
45
|
+
|
|
46
|
+
## Protocol
|
|
47
|
+
|
|
48
|
+
JSON text frames on a plain WebSocket: no path, subprotocol, auth, TLS or origin check, so bind it to loopback (any web page on the same machine can reach it). Client frames are `{ "type": "command", "command": "…", …fields }`.
|
|
49
|
+
|
|
50
|
+
- **Server** (every frame has a `type`): `auth_state` (`logged_in`, `is_active`, `device_name`), `playback_state` (`status`, `item`, `context`, `position`, `volume`, `is_active`, `options`, `available_actions`), `track_changed`, `playback_changed`, `volume_changed`, `device_changed`, `context_changed`, `options_changed` (`shuffle`, `repeat`: off, context, track), `position_sync` (`position_ms`, `timestamp_ms` epoch ms, `speed`), `queue_changed` (`previous`, `upcoming`; broadcasts capped at 10), `command_result` (`command`), `error` (`message`).
|
|
51
|
+
- **Commands:** queries `get_auth_state`, `get_state`, `get_queue` (answered by their frame, no `command_result`); controls `play` (`uri`?), `pause`, `skip_next`, `skip_prev`, `seek` (`position_ms`), `set_volume` (`volume`, 0–100), `set_shuffle`, `set_repeat_context` and `set_repeat_track` (`enabled`), `add_to_queue` (`uri`, tracks only), `activate`, `deactivate`. Controls need a session; without one Soloist answers `error` "command requires authentication".
|
|
52
|
+
- On connect: `auth_state`, then `playback_state` only if signed in. A later sign-in is promised only `auth_state`, so the connector asks `get_state`.
|
|
53
|
+
- If `--ws` can't bind, Soloist runs without it and only logs a warning: `disconnected` while the daemon looks healthy. `--ws ADDR:0` writes the port to the data dir; this connector takes a fixed `url`.
|
|
54
|
+
|
|
55
|
+
Not built: `activate`/`deactivate`, `available_actions`. An ad (`entity_type: "ad"`) shows as a `track`.
|
|
56
|
+
|
|
57
|
+
## Unverified
|
|
58
|
+
|
|
59
|
+
Writes through this connector, and what a live read hasn't shown. In particular:
|
|
60
|
+
|
|
61
|
+
- **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.
|
|
62
|
+
- **Writes:** that each confirming event arrives within 2 s; which events a `play` of an album or playlist sends; `play` of a new URI with no phone around; seek on playback a phone started.
|
|
63
|
+
- **The cap:** the order of statuses at a start. Soloist's docs list `buffering` but not when it comes; #23 reports the 100% about 100 ms after `playing`, on the first play after the process starts.
|
|
64
|
+
- **The rest:** `get_queue` answered by `queue_changed`; `play` with nothing loaded; whole-number volumes; the order of `auth_state` and `playback_state`; the 100% first play and its lowering; Family-plan members picking the speaker; whether the session survives a Soloist restart; a USB speaker's standby dropping it mid-play.
|
|
65
|
+
|
|
66
|
+
## Live findings
|
|
67
|
+
|
|
68
|
+
**2026-11-09, Raspberry Pi 5, Bun 1.4.4, through this connector** (chota-bot's session, reads only). `home spotify` took 140 ms. In code: connected at 11 ms, second report at 55 ms, 51 MB RSS, a clean exit after abort. Track, volume and `paused` read correctly, with no anomaly. `spotifyChanges` gave nothing on the first report.
|
|
69
|
+
|
|
70
|
+
**2026-11-08, Raspberry Pi 5, Pi OS Lite Trixie arm64, via `soloist ctl`** (chota-bot's session). `soloist 1.3.10.51 build 1793988108 (20261106)` as a systemd user unit with `-w 127.0.0.1:9091`. It logs the days left on the build (88), the WebSocket listening, then "waiting for login". Builds expire 90 days after they're made (exit code 10); chota-bot's units refresh weekly.
|
|
71
|
+
|
|
72
|
+
- Not logged in until a phone picked the speaker: the normal waiting state. After: logged in, active device.
|
|
73
|
+
- Playback a phone started was controllable from the Pi: volume, pause, and `play` with no `uri` resumed in place.
|
|
74
|
+
- Soloist and avahi-daemon both bound UDP 5353 (`ss -ulpn`) and the phone found the speaker ([#1](https://github.com/spotify/soloist/issues/1) didn't bite).
|
|
75
|
+
- Under PipeWire 1.4 the stream is a node "Spotify". The USB speaker has no hardware volume, so the sink stays at 100% and Soloist's volume is the music's: `maxVolume` caps that only.
|
|
76
|
+
- Logs: `journalctl _SYSTEMD_USER_UNIT=soloist.service`.
|
|
77
|
+
- chota-bot found a client-credentials search URI that Soloist then played (outside this library).
|
|
78
|
+
|
|
79
|
+
## Sources
|
|
80
|
+
|
|
81
|
+
- [Soloist documentation](https://developer.spotify.com/documentation/soloist), [WebSocket API](https://developer.spotify.com/documentation/soloist/reference/websocket-api), [`soloist ctl`](https://developer.spotify.com/documentation/soloist/reference/soloist-ctl), [command line](https://developer.spotify.com/documentation/soloist/reference/command-line) (read 2026-11-08). Soloist is Spotify's binary, never redistributed here.
|
|
82
|
+
- [Soloist's issues](https://github.com/spotify/soloist/issues): #1, #5, #15, #18, #23, #24.
|
|
83
|
+
- chota-bot's first client (KO's, 2026-11-08), ported here. Home Assistant has no Soloist integration; its `spotify` is the cloud Web API.
|