@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.
Files changed (217) hide show
  1. package/.env.example +25 -0
  2. package/CHANGELOG.md +47 -0
  3. package/LICENSE +21 -0
  4. package/README.md +77 -2
  5. package/dist/clock.d.ts +17 -0
  6. package/dist/clock.js +43 -0
  7. package/dist/config.d.ts +273 -0
  8. package/dist/config.js +392 -0
  9. package/dist/connector.d.ts +34 -0
  10. package/dist/connector.js +47 -0
  11. package/dist/connectors/amber/client.d.ts +61 -0
  12. package/dist/connectors/amber/client.js +106 -0
  13. package/dist/connectors/amber/index.d.ts +30 -0
  14. package/dist/connectors/amber/index.js +94 -0
  15. package/dist/connectors/cast/codec.d.ts +38 -0
  16. package/dist/connectors/cast/codec.js +102 -0
  17. package/dist/connectors/cast/index.d.ts +60 -0
  18. package/dist/connectors/cast/index.js +428 -0
  19. package/dist/connectors/cast/schemas.d.ts +167 -0
  20. package/dist/connectors/cast/schemas.js +87 -0
  21. package/dist/connectors/cast/serve.d.ts +18 -0
  22. package/dist/connectors/cast/serve.js +57 -0
  23. package/dist/connectors/cast/state.d.ts +62 -0
  24. package/dist/connectors/cast/state.js +133 -0
  25. package/dist/connectors/cast/tls.d.ts +3 -0
  26. package/dist/connectors/cast/tls.js +67 -0
  27. package/dist/connectors/cast/writes.d.ts +80 -0
  28. package/dist/connectors/cast/writes.js +390 -0
  29. package/dist/connectors/daikin/client.d.ts +65 -0
  30. package/dist/connectors/daikin/client.js +221 -0
  31. package/dist/connectors/daikin/index.d.ts +41 -0
  32. package/dist/connectors/daikin/index.js +108 -0
  33. package/dist/connectors/enphase/client.d.ts +65 -0
  34. package/dist/connectors/enphase/client.js +173 -0
  35. package/dist/connectors/enphase/index.d.ts +43 -0
  36. package/dist/connectors/enphase/index.js +174 -0
  37. package/dist/connectors/enphase/schemas.d.ts +51 -0
  38. package/dist/connectors/enphase/schemas.js +34 -0
  39. package/dist/connectors/hue/client.d.ts +48 -0
  40. package/dist/connectors/hue/client.js +95 -0
  41. package/dist/connectors/hue/events.d.ts +41 -0
  42. package/dist/connectors/hue/events.js +156 -0
  43. package/dist/connectors/hue/index.d.ts +42 -0
  44. package/dist/connectors/hue/index.js +205 -0
  45. package/dist/connectors/hue/schemas.d.ts +65 -0
  46. package/dist/connectors/hue/schemas.js +48 -0
  47. package/dist/connectors/hue/state.d.ts +43 -0
  48. package/dist/connectors/hue/state.js +84 -0
  49. package/dist/connectors/printers/client.d.ts +28 -0
  50. package/dist/connectors/printers/client.js +74 -0
  51. package/dist/connectors/printers/codec.d.ts +76 -0
  52. package/dist/connectors/printers/codec.js +246 -0
  53. package/dist/connectors/printers/found.d.ts +15 -0
  54. package/dist/connectors/printers/found.js +35 -0
  55. package/dist/connectors/printers/index.d.ts +52 -0
  56. package/dist/connectors/printers/index.js +282 -0
  57. package/dist/connectors/printers/state.d.ts +60 -0
  58. package/dist/connectors/printers/state.js +99 -0
  59. package/dist/connectors/spotify/changes.d.ts +32 -0
  60. package/dist/connectors/spotify/changes.js +84 -0
  61. package/dist/connectors/spotify/client.d.ts +199 -0
  62. package/dist/connectors/spotify/client.js +270 -0
  63. package/dist/connectors/spotify/index.d.ts +92 -0
  64. package/dist/connectors/spotify/index.js +754 -0
  65. package/dist/connectors/up/client.d.ts +183 -0
  66. package/dist/connectors/up/client.js +308 -0
  67. package/dist/connectors/up/index.d.ts +91 -0
  68. package/dist/connectors/up/index.js +213 -0
  69. package/dist/connectors/up/spend.d.ts +78 -0
  70. package/dist/connectors/up/spend.js +213 -0
  71. package/dist/connectors/up/webhook.d.ts +13 -0
  72. package/dist/connectors/up/webhook.js +32 -0
  73. package/dist/connectors/weather/blocks.d.ts +51 -0
  74. package/dist/connectors/weather/blocks.js +106 -0
  75. package/dist/connectors/weather/bom.d.ts +37 -0
  76. package/dist/connectors/weather/bom.js +237 -0
  77. package/dist/connectors/weather/ftp.d.ts +5 -0
  78. package/dist/connectors/weather/ftp.js +46 -0
  79. package/dist/connectors/weather/google.d.ts +31 -0
  80. package/dist/connectors/weather/google.js +364 -0
  81. package/dist/connectors/weather/index.d.ts +53 -0
  82. package/dist/connectors/weather/index.js +280 -0
  83. package/dist/connectors/weather/open-meteo.d.ts +15 -0
  84. package/dist/connectors/weather/open-meteo.js +285 -0
  85. package/dist/connectors/weather/rain.d.ts +19 -0
  86. package/dist/connectors/weather/rain.js +51 -0
  87. package/dist/connectors/weather/source.d.ts +149 -0
  88. package/dist/connectors/weather/source.js +77 -0
  89. package/dist/connectors/weather/sun.d.ts +48 -0
  90. package/dist/connectors/weather/sun.js +65 -0
  91. package/dist/discover.d.ts +197 -0
  92. package/dist/discover.js +452 -0
  93. package/dist/energy.d.ts +61 -0
  94. package/dist/energy.js +162 -0
  95. package/dist/finders-entry.d.ts +1 -0
  96. package/dist/finders-entry.js +2 -0
  97. package/dist/finders.d.ts +48 -0
  98. package/dist/finders.js +176 -0
  99. package/dist/health.d.ts +28 -0
  100. package/dist/health.js +35 -0
  101. package/dist/home.d.ts +81 -0
  102. package/dist/home.js +114 -0
  103. package/dist/index.d.ts +11 -0
  104. package/dist/index.js +11 -0
  105. package/dist/lane.d.ts +17 -0
  106. package/dist/lane.js +74 -0
  107. package/dist/locate.d.ts +27 -0
  108. package/dist/locate.js +59 -0
  109. package/dist/manifest.d.ts +31 -0
  110. package/dist/manifest.js +4 -0
  111. package/dist/manifests.d.ts +5 -0
  112. package/dist/manifests.js +23 -0
  113. package/dist/node-fetch.d.ts +9 -0
  114. package/dist/node-fetch.js +25 -0
  115. package/dist/poll.d.ts +10 -0
  116. package/dist/poll.js +33 -0
  117. package/docs/cast.md +133 -0
  118. package/docs/cli.md +41 -0
  119. package/docs/config.md +36 -0
  120. package/docs/decisions.md +150 -0
  121. package/docs/devices.md +128 -0
  122. package/docs/enphase-notes.md +24 -0
  123. package/docs/google.md +92 -0
  124. package/docs/ideas.md +69 -0
  125. package/docs/matter.md +55 -0
  126. package/docs/performance.md +12 -0
  127. package/docs/philosophy.md +23 -0
  128. package/docs/up.md +84 -0
  129. package/docs/using.md +65 -0
  130. package/docs/weather-probe.md +63 -0
  131. package/examples/home.jsonc +74 -0
  132. package/examples/home.svg +76 -0
  133. package/home.schema.json +474 -0
  134. package/llms.txt +32 -0
  135. package/package.json +143 -4
  136. package/skills/draw-home/README.md +25 -0
  137. package/skills/draw-home/SKILL.md +80 -0
  138. package/skills/use-home/SKILL.md +86 -0
  139. package/src/cli/cache.ts +29 -0
  140. package/src/cli/cast.ts +172 -0
  141. package/src/cli/config.ts +52 -0
  142. package/src/cli/context.ts +118 -0
  143. package/src/cli/discover.ts +182 -0
  144. package/src/cli/enphase.ts +62 -0
  145. package/src/cli/hue.ts +124 -0
  146. package/src/cli/locate.ts +47 -0
  147. package/src/cli/main.ts +182 -0
  148. package/src/cli/printers.ts +21 -0
  149. package/src/cli/probe.ts +274 -0
  150. package/src/cli/spotify.ts +76 -0
  151. package/src/cli/status.ts +228 -0
  152. package/src/cli/up.ts +350 -0
  153. package/src/clock.ts +62 -0
  154. package/src/config.ts +578 -0
  155. package/src/connector.ts +77 -0
  156. package/src/connectors/amber/README.md +40 -0
  157. package/src/connectors/amber/client.ts +157 -0
  158. package/src/connectors/amber/index.ts +118 -0
  159. package/src/connectors/cast/README.md +44 -0
  160. package/src/connectors/cast/codec.ts +126 -0
  161. package/src/connectors/cast/index.ts +552 -0
  162. package/src/connectors/cast/schemas.ts +101 -0
  163. package/src/connectors/cast/serve.ts +82 -0
  164. package/src/connectors/cast/state.ts +203 -0
  165. package/src/connectors/cast/tls.ts +100 -0
  166. package/src/connectors/cast/writes.ts +572 -0
  167. package/src/connectors/daikin/README.md +78 -0
  168. package/src/connectors/daikin/client.ts +295 -0
  169. package/src/connectors/daikin/index.ts +145 -0
  170. package/src/connectors/enphase/README.md +64 -0
  171. package/src/connectors/enphase/client.ts +244 -0
  172. package/src/connectors/enphase/index.ts +241 -0
  173. package/src/connectors/enphase/schemas.ts +45 -0
  174. package/src/connectors/hue/README.md +50 -0
  175. package/src/connectors/hue/client.ts +157 -0
  176. package/src/connectors/hue/events.ts +182 -0
  177. package/src/connectors/hue/index.ts +261 -0
  178. package/src/connectors/hue/schemas.ts +60 -0
  179. package/src/connectors/hue/state.ts +124 -0
  180. package/src/connectors/printers/README.md +46 -0
  181. package/src/connectors/printers/client.ts +114 -0
  182. package/src/connectors/printers/codec.ts +292 -0
  183. package/src/connectors/printers/found.ts +46 -0
  184. package/src/connectors/printers/index.ts +385 -0
  185. package/src/connectors/printers/state.ts +182 -0
  186. package/src/connectors/spotify/README.md +83 -0
  187. package/src/connectors/spotify/changes.ts +127 -0
  188. package/src/connectors/spotify/client.ts +387 -0
  189. package/src/connectors/spotify/index.ts +937 -0
  190. package/src/connectors/up/README.md +35 -0
  191. package/src/connectors/up/client.ts +518 -0
  192. package/src/connectors/up/index.ts +370 -0
  193. package/src/connectors/up/spend.ts +280 -0
  194. package/src/connectors/up/webhook.ts +62 -0
  195. package/src/connectors/weather/README.md +84 -0
  196. package/src/connectors/weather/blocks.ts +173 -0
  197. package/src/connectors/weather/bom.ts +344 -0
  198. package/src/connectors/weather/ftp.ts +48 -0
  199. package/src/connectors/weather/google.ts +425 -0
  200. package/src/connectors/weather/index.ts +396 -0
  201. package/src/connectors/weather/open-meteo.ts +309 -0
  202. package/src/connectors/weather/rain.ts +73 -0
  203. package/src/connectors/weather/source.ts +236 -0
  204. package/src/connectors/weather/sun.ts +114 -0
  205. package/src/discover.ts +638 -0
  206. package/src/energy.ts +221 -0
  207. package/src/finders-entry.ts +2 -0
  208. package/src/finders.ts +250 -0
  209. package/src/health.ts +74 -0
  210. package/src/home.ts +205 -0
  211. package/src/index.ts +62 -0
  212. package/src/lane.ts +110 -0
  213. package/src/locate.ts +85 -0
  214. package/src/manifest.ts +43 -0
  215. package/src/manifests.ts +27 -0
  216. package/src/node-fetch.ts +41 -0
  217. package/src/poll.ts +33 -0
@@ -0,0 +1,199 @@
1
+ import { z } from "zod";
2
+ /** The WHATWG WebSocket surface used here: the runtime's global `WebSocket` (Bun, Node 22+, browsers), or a test's fake. */
3
+ export interface SocketLike {
4
+ readonly readyState: number;
5
+ onopen: ((ev: unknown) => void) | null;
6
+ onmessage: ((ev: {
7
+ data: unknown;
8
+ }) => void) | null;
9
+ onclose: ((ev: unknown) => void) | null;
10
+ onerror: ((ev: unknown) => void) | null;
11
+ send(data: string): void;
12
+ close(): void;
13
+ }
14
+ /** Opens a socket to Soloist. The default is the runtime's `WebSocket`. */
15
+ export type SocketFactory = (url: string) => SocketLike;
16
+ export declare const OPEN = 1;
17
+ /** The statuses Soloist documents. Anything else is passed through and named in `anomaly`. */
18
+ export declare const STATUSES: ReadonlySet<string>;
19
+ export declare const REPEATS: ReadonlySet<string>;
20
+ type Entity = {
21
+ uri?: string;
22
+ entity_type?: string;
23
+ decorations?: Decorations;
24
+ };
25
+ type Decorations = {
26
+ identity?: {
27
+ name?: string;
28
+ };
29
+ visual_identity?: {
30
+ cover?: {
31
+ url?: string;
32
+ size?: string;
33
+ }[];
34
+ };
35
+ parent?: {
36
+ entity?: Entity;
37
+ };
38
+ creators?: {
39
+ entity?: Entity;
40
+ }[];
41
+ playback?: {
42
+ duration_ms?: number;
43
+ };
44
+ };
45
+ declare const Entity: z.ZodType<Entity>;
46
+ /** Soloist's frames by type, those this library reads. Anything else is skipped. */
47
+ export declare const Frame: z.ZodDiscriminatedUnion<[z.ZodObject<{
48
+ type: z.ZodLiteral<"auth_state">;
49
+ logged_in: z.ZodOptional<z.ZodBoolean>;
50
+ is_active: z.ZodOptional<z.ZodBoolean>;
51
+ device_name: z.ZodOptional<z.ZodString>;
52
+ }, z.core.$loose>, z.ZodObject<{
53
+ type: z.ZodLiteral<"playback_state">;
54
+ status: z.ZodOptional<z.ZodString>;
55
+ item: z.ZodOptional<z.ZodType<Entity, unknown, z.core.$ZodTypeInternals<Entity, unknown>>>;
56
+ context: z.ZodOptional<z.ZodType<Entity, unknown, z.core.$ZodTypeInternals<Entity, unknown>>>;
57
+ position: z.ZodOptional<z.ZodObject<{
58
+ position_ms: z.ZodOptional<z.ZodNumber>;
59
+ timestamp_ms: z.ZodOptional<z.ZodNumber>;
60
+ speed: z.ZodOptional<z.ZodNumber>;
61
+ }, z.core.$loose>>;
62
+ volume: z.ZodOptional<z.ZodNumber>;
63
+ is_active: z.ZodOptional<z.ZodBoolean>;
64
+ options: z.ZodOptional<z.ZodObject<{
65
+ shuffle: z.ZodOptional<z.ZodBoolean>;
66
+ repeat: z.ZodOptional<z.ZodString>;
67
+ }, z.core.$loose>>;
68
+ }, z.core.$loose>, z.ZodObject<{
69
+ type: z.ZodLiteral<"track_changed">;
70
+ item: z.ZodOptional<z.ZodType<Entity, unknown, z.core.$ZodTypeInternals<Entity, unknown>>>;
71
+ }, z.core.$loose>, z.ZodObject<{
72
+ type: z.ZodLiteral<"playback_changed">;
73
+ status: z.ZodOptional<z.ZodString>;
74
+ }, z.core.$loose>, z.ZodObject<{
75
+ type: z.ZodLiteral<"volume_changed">;
76
+ volume: z.ZodOptional<z.ZodNumber>;
77
+ }, z.core.$loose>, z.ZodObject<{
78
+ type: z.ZodLiteral<"device_changed">;
79
+ is_active: z.ZodOptional<z.ZodBoolean>;
80
+ device_name: z.ZodOptional<z.ZodString>;
81
+ }, z.core.$loose>, z.ZodObject<{
82
+ type: z.ZodLiteral<"context_changed">;
83
+ context: z.ZodOptional<z.ZodType<Entity, unknown, z.core.$ZodTypeInternals<Entity, unknown>>>;
84
+ }, z.core.$loose>, z.ZodObject<{
85
+ type: z.ZodLiteral<"options_changed">;
86
+ options: z.ZodOptional<z.ZodObject<{
87
+ shuffle: z.ZodOptional<z.ZodBoolean>;
88
+ repeat: z.ZodOptional<z.ZodString>;
89
+ }, z.core.$loose>>;
90
+ }, z.core.$loose>, z.ZodObject<{
91
+ type: z.ZodLiteral<"position_sync">;
92
+ position: z.ZodOptional<z.ZodObject<{
93
+ position_ms: z.ZodOptional<z.ZodNumber>;
94
+ timestamp_ms: z.ZodOptional<z.ZodNumber>;
95
+ speed: z.ZodOptional<z.ZodNumber>;
96
+ }, z.core.$loose>>;
97
+ }, z.core.$loose>, z.ZodObject<{
98
+ type: z.ZodLiteral<"queue_changed">;
99
+ previous: z.ZodOptional<z.ZodArray<z.ZodObject<{
100
+ uid: z.ZodOptional<z.ZodString>;
101
+ source: z.ZodOptional<z.ZodString>;
102
+ item: z.ZodOptional<z.ZodType<Entity, unknown, z.core.$ZodTypeInternals<Entity, unknown>>>;
103
+ }, z.core.$loose>>>;
104
+ upcoming: z.ZodOptional<z.ZodArray<z.ZodObject<{
105
+ uid: z.ZodOptional<z.ZodString>;
106
+ source: z.ZodOptional<z.ZodString>;
107
+ item: z.ZodOptional<z.ZodType<Entity, unknown, z.core.$ZodTypeInternals<Entity, unknown>>>;
108
+ }, z.core.$loose>>>;
109
+ }, z.core.$loose>, z.ZodObject<{
110
+ type: z.ZodLiteral<"command_result">;
111
+ command: z.ZodOptional<z.ZodString>;
112
+ }, z.core.$loose>, z.ZodObject<{
113
+ type: z.ZodLiteral<"error">;
114
+ message: z.ZodOptional<z.ZodString>;
115
+ }, z.core.$loose>], "type">;
116
+ export type Frame = z.infer<typeof Frame>;
117
+ export declare const FRAME_TYPES: ReadonlySet<string>;
118
+ /** A stall the library noticed: see `SpotifyState.stalled`. */
119
+ export type Stall = {
120
+ track: string;
121
+ ms: number;
122
+ how: "frozen" | "overrun";
123
+ tried?: string;
124
+ };
125
+ /** What is loaded: a track or an episode. */
126
+ export type Track = {
127
+ uri: string;
128
+ type?: string;
129
+ name?: string;
130
+ artists: string[];
131
+ album?: string;
132
+ cover?: string;
133
+ durationMs?: number;
134
+ };
135
+ /** Where the position was at a moment: `atMs` is Soloist's wall clock (epoch ms, the same box's), `speed` 1 while playing. */
136
+ export type PositionAnchor = {
137
+ ms?: number;
138
+ atMs?: number;
139
+ speed?: number;
140
+ };
141
+ /**
142
+ * The speaker as Soloist reports it. Every field is optional: none is known until Soloist says it. `loggedIn: false` is a
143
+ * Soloist with no Spotify session (a bad key never signs in, issue #18). No `track`: nothing loaded.
144
+ */
145
+ export type SpotifyState = {
146
+ loggedIn?: boolean;
147
+ /** Soloist's `--device-name`, the name phones show. */
148
+ deviceName?: string;
149
+ /** Whether this is the account's active Spotify Connect device. */
150
+ active?: boolean;
151
+ /** idle, playing, paused or buffering; anything else Soloist sends, passed through. */
152
+ status?: string;
153
+ track?: Track;
154
+ /** The playlist, album or show playing from. */
155
+ context?: {
156
+ uri: string;
157
+ type?: string;
158
+ name?: string;
159
+ };
160
+ position?: PositionAnchor;
161
+ /** 0–100, as Soloist reports it. */
162
+ volume?: number;
163
+ shuffle?: boolean;
164
+ /** off, context or track. */
165
+ repeat?: string;
166
+ /** What plays next, next first: Soloist's `queue_changed` (at most 10) or its answer to `get_queue`. Queued tracks, then the context's. */
167
+ upNext?: Track[];
168
+ /** Set by this library while its own `duck`, `interrupt` or stall repair runs, so changes then can be told from a person's. Not Soloist's. */
169
+ aside?: "duck" | "interrupt" | "repair";
170
+ /**
171
+ * Set by this library when playback stalls while Soloist still says playing (Soloist issue #5): `frozen` (its reported
172
+ * position stopped moving) or `overrun` (still on a track well past its end). `ms` the track position where it stuck, `tried` the repair.
173
+ * Cleared when the track moves again, changes or stops. Not Soloist's.
174
+ */
175
+ stalled?: Stall;
176
+ asOf: string;
177
+ };
178
+ /** An item as a Track, or undefined for an empty `uri` (Soloist's "nothing loaded"). */
179
+ export declare function toTrack(item: Entity | undefined): Track | undefined;
180
+ /**
181
+ * One frame folded into the state. `playback_state` is a full snapshot and replaces what it covers; the others patch one
182
+ * field. `auth_state` with `logged_in: false` clears the playback (nothing plays without a session).
183
+ */
184
+ export declare function fold(s: SpotifyState, f: Frame, asOf: string): SpotifyState;
185
+ /** What in the state is outside what Soloist documents, in a few words, or undefined. Passed through, never corrected. */
186
+ export declare function oddities(s: SpotifyState): string | undefined;
187
+ /**
188
+ * The position now, in ms: the last anchor moved on by the time since, at its speed, while playing. Not held to the track's
189
+ * length (a reading past the end is the device's to explain), not rounded. Undefined without an anchor or a track.
190
+ */
191
+ export declare function positionMs(s: SpotifyState, now: number): number | undefined;
192
+ /**
193
+ * A Spotify open link or URI as `spotify:<type>:<id>`, or undefined if it is neither. Short `spotify.link/…` links need a
194
+ * network redirect, so they are not taken.
195
+ */
196
+ export declare function toSpotifyUri(input: string): string | undefined;
197
+ /** A command frame: `{ type: "command", command, ...fields }`. */
198
+ export declare const commandFrame: (command: string, fields?: Record<string, unknown>) => string;
199
+ export {};
@@ -0,0 +1,270 @@
1
+ import { z } from "zod";
2
+ export const OPEN = 1;
3
+ /** The statuses Soloist documents. Anything else is passed through and named in `anomaly`. */
4
+ export const STATUSES = new Set([
5
+ "idle",
6
+ "playing",
7
+ "paused",
8
+ "buffering",
9
+ ]);
10
+ export const REPEATS = new Set([
11
+ "off",
12
+ "context",
13
+ "track",
14
+ ]);
15
+ const Entity = z.lazy(() => z.looseObject({
16
+ uri: z.string().optional(),
17
+ entity_type: z.string().optional(),
18
+ decorations: z
19
+ .looseObject({
20
+ identity: z.looseObject({ name: z.string().optional() }).optional(),
21
+ visual_identity: z
22
+ .looseObject({
23
+ cover: z
24
+ .array(z.looseObject({
25
+ url: z.string().optional(),
26
+ size: z.string().optional(),
27
+ }))
28
+ .optional(),
29
+ })
30
+ .optional(),
31
+ parent: z.looseObject({ entity: Entity.optional() }).optional(),
32
+ creators: z
33
+ .array(z.looseObject({ entity: Entity.optional() }))
34
+ .optional(),
35
+ playback: z
36
+ .looseObject({ duration_ms: z.number().optional() })
37
+ .optional(),
38
+ })
39
+ .optional(),
40
+ }));
41
+ const Position = z.looseObject({
42
+ position_ms: z.number().optional(),
43
+ timestamp_ms: z.number().optional(),
44
+ speed: z.number().optional(),
45
+ });
46
+ const Options = z.looseObject({
47
+ shuffle: z.boolean().optional(),
48
+ repeat: z.string().optional(),
49
+ });
50
+ const QueueEntry = z.looseObject({
51
+ uid: z.string().optional(),
52
+ source: z.string().optional(),
53
+ item: Entity.optional(),
54
+ });
55
+ /** Soloist's frames by type, those this library reads. Anything else is skipped. */
56
+ export const Frame = z.discriminatedUnion("type", [
57
+ z.looseObject({
58
+ type: z.literal("auth_state"),
59
+ logged_in: z.boolean().optional(),
60
+ is_active: z.boolean().optional(),
61
+ device_name: z.string().optional(),
62
+ }),
63
+ z.looseObject({
64
+ type: z.literal("playback_state"),
65
+ status: z.string().optional(),
66
+ item: Entity.optional(),
67
+ context: Entity.optional(),
68
+ position: Position.optional(),
69
+ volume: z.number().optional(),
70
+ is_active: z.boolean().optional(),
71
+ options: Options.optional(),
72
+ }),
73
+ z.looseObject({ type: z.literal("track_changed"), item: Entity.optional() }),
74
+ z.looseObject({
75
+ type: z.literal("playback_changed"),
76
+ status: z.string().optional(),
77
+ }),
78
+ z.looseObject({
79
+ type: z.literal("volume_changed"),
80
+ volume: z.number().optional(),
81
+ }),
82
+ z.looseObject({
83
+ type: z.literal("device_changed"),
84
+ is_active: z.boolean().optional(),
85
+ device_name: z.string().optional(),
86
+ }),
87
+ z.looseObject({
88
+ type: z.literal("context_changed"),
89
+ context: Entity.optional(),
90
+ }),
91
+ z.looseObject({
92
+ type: z.literal("options_changed"),
93
+ options: Options.optional(),
94
+ }),
95
+ z.looseObject({
96
+ type: z.literal("position_sync"),
97
+ position: Position.optional(),
98
+ }),
99
+ z.looseObject({
100
+ type: z.literal("queue_changed"),
101
+ previous: z.array(QueueEntry).optional(),
102
+ upcoming: z.array(QueueEntry).optional(),
103
+ }),
104
+ z.looseObject({
105
+ type: z.literal("command_result"),
106
+ command: z.string().optional(),
107
+ }),
108
+ z.looseObject({ type: z.literal("error"), message: z.string().optional() }),
109
+ ]);
110
+ export const FRAME_TYPES = new Set(Frame.options.map((o) => o.shape.type.value));
111
+ const name = (e) => e?.decorations?.identity?.name;
112
+ /** An item as a Track, or undefined for an empty `uri` (Soloist's "nothing loaded"). */
113
+ export function toTrack(item) {
114
+ if (!item?.uri)
115
+ return undefined;
116
+ const d = item.decorations ?? {};
117
+ const covers = d.visual_identity?.cover ?? [];
118
+ const cover = covers.find((c) => c.size === "default") ?? covers[0];
119
+ const artists = (d.creators ?? [])
120
+ .map((c) => name(c.entity))
121
+ .filter((n) => !!n);
122
+ return dropUndefined({
123
+ uri: item.uri,
124
+ type: item.entity_type || undefined,
125
+ name: name(item),
126
+ artists,
127
+ album: name(d.parent?.entity),
128
+ cover: cover?.url,
129
+ durationMs: d.playback?.duration_ms,
130
+ });
131
+ }
132
+ const toContext = (e) => e?.uri
133
+ ? dropUndefined({
134
+ uri: e.uri,
135
+ type: e.entity_type || undefined,
136
+ name: name(e),
137
+ })
138
+ : undefined;
139
+ const toAnchor = (p) => dropUndefined({ ms: p.position_ms, atMs: p.timestamp_ms, speed: p.speed });
140
+ const toOptions = (o) => dropUndefined({ shuffle: o.shuffle, repeat: o.repeat });
141
+ /** An object without its undefined fields, so a snapshot never carries `key: undefined`. */
142
+ function dropUndefined(o) {
143
+ return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
144
+ }
145
+ /**
146
+ * One frame folded into the state. `playback_state` is a full snapshot and replaces what it covers; the others patch one
147
+ * field. `auth_state` with `logged_in: false` clears the playback (nothing plays without a session).
148
+ */
149
+ export function fold(s, f, asOf) {
150
+ switch (f.type) {
151
+ case "auth_state": {
152
+ const next = {
153
+ ...s,
154
+ ...dropUndefined({
155
+ loggedIn: f.logged_in,
156
+ active: f.is_active,
157
+ deviceName: f.device_name || undefined,
158
+ }),
159
+ asOf,
160
+ };
161
+ // signed out: nothing plays, nothing is queued
162
+ return f.logged_in === false
163
+ ? {
164
+ loggedIn: false,
165
+ ...dropUndefined({
166
+ deviceName: next.deviceName,
167
+ active: next.active,
168
+ }),
169
+ asOf,
170
+ }
171
+ : next;
172
+ }
173
+ case "playback_state": {
174
+ const { track: _t, context: _c, position: _p, ...rest } = s;
175
+ return dropUndefined({
176
+ ...rest,
177
+ loggedIn: true, // Soloist sends it only to a signed-in session
178
+ status: f.status ?? s.status,
179
+ track: toTrack(f.item),
180
+ context: toContext(f.context),
181
+ position: f.position ? toAnchor(f.position) : undefined,
182
+ volume: f.volume ?? s.volume,
183
+ active: f.is_active ?? s.active,
184
+ ...(f.options ? toOptions(f.options) : {}),
185
+ asOf,
186
+ });
187
+ }
188
+ case "track_changed": {
189
+ const { track: _t, ...rest } = s;
190
+ const track = toTrack(f.item);
191
+ return track ? { ...rest, track, asOf } : { ...rest, asOf };
192
+ }
193
+ case "playback_changed":
194
+ return f.status === undefined ? s : { ...s, status: f.status, asOf };
195
+ case "volume_changed":
196
+ return f.volume === undefined ? s : { ...s, volume: f.volume, asOf };
197
+ case "device_changed":
198
+ return {
199
+ ...s,
200
+ ...dropUndefined({
201
+ active: f.is_active,
202
+ deviceName: f.device_name || undefined,
203
+ }),
204
+ asOf,
205
+ };
206
+ case "context_changed": {
207
+ const { context: _c, ...rest } = s;
208
+ const context = toContext(f.context);
209
+ return context ? { ...rest, context, asOf } : { ...rest, asOf };
210
+ }
211
+ case "options_changed":
212
+ return f.options ? { ...s, ...toOptions(f.options), asOf } : s;
213
+ case "position_sync":
214
+ return f.position ? { ...s, position: toAnchor(f.position), asOf } : s;
215
+ case "queue_changed":
216
+ return f.upcoming
217
+ ? {
218
+ ...s,
219
+ upNext: f.upcoming
220
+ .map((e) => toTrack(e.item))
221
+ .filter((t) => !!t),
222
+ asOf,
223
+ }
224
+ : s;
225
+ default:
226
+ return s; // command_result and error answer a command; they change no state
227
+ }
228
+ }
229
+ /** What in the state is outside what Soloist documents, in a few words, or undefined. Passed through, never corrected. */
230
+ export function oddities(s) {
231
+ const odd = [];
232
+ if (s.status !== undefined && !STATUSES.has(s.status))
233
+ odd.push(`status "${s.status}"`);
234
+ if (s.volume !== undefined && !(s.volume >= 0 && s.volume <= 100))
235
+ odd.push(`volume ${s.volume}, outside 0–100`);
236
+ if (s.repeat !== undefined && !REPEATS.has(s.repeat))
237
+ odd.push(`repeat "${s.repeat}"`);
238
+ if (s.position?.speed !== undefined && !(s.position.speed >= 0))
239
+ odd.push(`position speed ${s.position.speed}`);
240
+ if (s.position?.ms !== undefined &&
241
+ s.track?.durationMs !== undefined &&
242
+ s.position.ms > s.track.durationMs)
243
+ odd.push(`position ${s.position.ms} ms past the track's ${s.track.durationMs} ms`);
244
+ return odd.length ? odd.join("; ") : undefined;
245
+ }
246
+ /**
247
+ * The position now, in ms: the last anchor moved on by the time since, at its speed, while playing. Not held to the track's
248
+ * length (a reading past the end is the device's to explain), not rounded. Undefined without an anchor or a track.
249
+ */
250
+ export function positionMs(s, now) {
251
+ const p = s.position;
252
+ if (p?.ms === undefined || !s.track)
253
+ return undefined;
254
+ if (s.status !== "playing" || p.atMs === undefined)
255
+ return p.ms;
256
+ return p.ms + (now - p.atMs) * (p.speed ?? 1); // `now` before the anchor (clock skew) gives less, not clamped
257
+ }
258
+ /**
259
+ * A Spotify open link or URI as `spotify:<type>:<id>`, or undefined if it is neither. Short `spotify.link/…` links need a
260
+ * network redirect, so they are not taken.
261
+ */
262
+ export function toSpotifyUri(input) {
263
+ const s = input.trim();
264
+ if (/^spotify:(track|album|playlist|artist|episode|show):[A-Za-z0-9]+$/.test(s))
265
+ return s;
266
+ const link = /^https?:\/\/open\.spotify\.com\/(?:intl-[a-z-]+\/)?(?:embed\/)?(track|album|playlist|artist|episode|show)\/([A-Za-z0-9]+)/.exec(s);
267
+ return link ? `spotify:${link[1]}:${link[2]}` : undefined;
268
+ }
269
+ /** A command frame: `{ type: "command", command, ...fields }`. */
270
+ export const commandFrame = (command, fields = {}) => JSON.stringify({ type: "command", command, ...fields });
@@ -0,0 +1,92 @@
1
+ import { z } from "zod";
2
+ import { type Clock } from "../../clock.ts";
3
+ import type { Integration } from "../../home.ts";
4
+ import type { Manifest } from "../../manifest.ts";
5
+ import { type SocketFactory, type SpotifyState } from "./client.ts";
6
+ export { positionMs, type PositionAnchor, type SocketFactory, type SocketLike, type SpotifyState, type Stall, toSpotifyUri, type Track, } from "./client.ts";
7
+ export { type SpeakerState, speakerState, type SpotifyEvent, spotifyChanges, spotifyText, } from "./changes.ts";
8
+ /** Soloist has no default port (`--ws ADDR:PORT` opens it); this library assumes this one, the port in chota-bot's unit. */
9
+ export declare const DEFAULT_URL = "ws://127.0.0.1:9091";
10
+ /** A Soloist speaker in a home config. No key: Soloist's API key is the daemon's, never the library's. */
11
+ export declare const configSchema: z.ZodObject<{
12
+ connector: z.ZodLiteral<"spotify">;
13
+ url: z.ZodOptional<z.ZodURL>;
14
+ maxVolume: z.ZodOptional<z.ZodNumber>;
15
+ repairStalls: z.ZodOptional<z.ZodBoolean>;
16
+ }, z.core.$loose>;
17
+ export type SpotifyConfig = {
18
+ /** Soloist's WebSocket; `DEFAULT_URL` when left out. */
19
+ url?: string;
20
+ /** Volume ceiling 0–100 for writes, and for a first play that starts louder (issue #23). */
21
+ maxVolume?: number;
22
+ /** Try once per track to restart playback that stalled (issue #5): a write the library makes unasked, so off unless set. */
23
+ repairStalls?: boolean;
24
+ /** How often to look for a stall while playing, in ms; `STALL_CHECK_MS` by default, 0 to never look. */
25
+ stallCheckMs?: number;
26
+ /** Opens the socket; the runtime's `WebSocket` by default. Tests pass a fake. */
27
+ connect?: SocketFactory;
28
+ clock?: Clock;
29
+ };
30
+ /** Soloist's repeat modes. */
31
+ export type Repeat = "off" | "context" | "track";
32
+ /** What a write left: the state once Soloist reported the change (`confirmed`), or the state as it stood when `CONFIRM_MS` ran out without it. */
33
+ export type Applied = {
34
+ state: SpotifyState;
35
+ confirmed: boolean;
36
+ };
37
+ /**
38
+ * What happened to the music around `duck` or `interrupt`'s `fn`: `untouched` (it was not playing, not connected, already
39
+ * quiet, or the pause or lowering failed, so `fn` ran over it), `restored` (put back as it was), `kept` (someone changed it
40
+ * meanwhile, or the pause never showed: left as it is) or `failed` (the resume or restore failed or Soloist has not
41
+ * reported it, so the music may be left paused or quiet). `why` says which, except for `restored`.
42
+ */
43
+ export type Music = "untouched" | "restored" | "kept" | "failed";
44
+ export type Aside<T> = {
45
+ value: T;
46
+ music: Music;
47
+ why?: string;
48
+ };
49
+ /**
50
+ * The writes, on the object `spotify()` returns. Each resolves once Soloist reports the change it asked for, with the state
51
+ * 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
+ */
54
+ export type SpotifyActions = {
55
+ /** Play a Spotify link or URI (track, album, playlist, artist, episode, show), or resume what is loaded when none is given. */
56
+ play(linkOrUri?: string): Promise<Applied>;
57
+ pause(): Promise<Applied>;
58
+ resume(): Promise<Applied>;
59
+ next(): Promise<Applied>;
60
+ prev(): Promise<Applied>;
61
+ /** Jump to a position in the current track, in ms. */
62
+ seek(ms: number): Promise<Applied>;
63
+ /** Set the volume 0–100, held to `maxVolume` and rounded to a whole percent: `state.volume` is what Soloist then reports. */
64
+ setVolume(volume: number): Promise<Applied>;
65
+ /** Add a track to the queue (Soloist queues tracks only); confirmed when it shows in `upNext`. */
66
+ queue(track: string): Promise<Applied>;
67
+ setShuffle(on: boolean): Promise<Applied>;
68
+ /** Repeat off, the context (playlist, album) or the track: two commands, in the order Soloist's docs give, as one write. */
69
+ setRepeat(mode: Repeat): Promise<Applied>;
70
+ /**
71
+ * Pause if playing, run `fn`, then resume, unless someone changed it meanwhile (resumed, stopped or skipped it from a
72
+ * phone): then it is theirs and stays. The resume runs even if `fn` throws. Not playing or not connected: just `fn`.
73
+ * One `interrupt` or `duck` at a time; a second waits for the first, so never call either from inside `fn` (it would wait
74
+ * forever). Two announcements back to back belong in one `fn`, or the music comes back between them.
75
+ */
76
+ interrupt<T>(fn: () => Promise<T> | T): Promise<Aside<T>>;
77
+ /**
78
+ * Lower the volume to `to` while `fn` runs, then put the earlier volume back, unless someone set the volume meanwhile.
79
+ * For a short announcement over the music. Not playing, not connected or already at `to` or below: just `fn`.
80
+ */
81
+ duck<T>(fn: () => Promise<T> | T, options: {
82
+ to: number;
83
+ }): Promise<Aside<T>>;
84
+ };
85
+ /**
86
+ * A Spotify Soloist speaker as one integration: one WebSocket to Soloist, its `auth_state` and `playback_state` on connect,
87
+ * then its events folded in. A drop reconnects with backoff (1 s doubling to 60 s). Unreachable is `disconnected`;
88
+ * running without a Spotify session is `connected` with `loggedIn: false` and the detail saying so. The writes are on the
89
+ * same object, through one write lane, over the same socket.
90
+ */
91
+ export declare function spotify(config?: SpotifyConfig): Integration<SpotifyState, "spotify"> & SpotifyActions;
92
+ export declare const manifest: Manifest<SpotifyState>;