@khalido/home 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/.env.example +25 -0
  2. package/CHANGELOG.md +25 -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 +426 -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 +11 -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 +745 -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 +267 -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 +195 -0
  92. package/dist/discover.js +446 -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 +45 -0
  98. package/dist/finders.js +173 -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 +39 -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 +26 -0
  137. package/skills/draw-home/SKILL.md +82 -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 +175 -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 +43 -0
  160. package/src/connectors/cast/codec.ts +126 -0
  161. package/src/connectors/cast/index.ts +551 -0
  162. package/src/connectors/cast/schemas.ts +101 -0
  163. package/src/connectors/cast/serve.ts +72 -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 +80 -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 +929 -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 +380 -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 +634 -0
  206. package/src/energy.ts +221 -0
  207. package/src/finders-entry.ts +2 -0
  208. package/src/finders.ts +247 -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,35 @@
1
+ /** TXT keys are case-insensitive in DNS-SD; AirPrint's are written `UUID`, `ty`, `rp`. */
2
+ const lower = (txt) => Object.fromEntries(Object.entries(txt).map(([k, v]) => [k.toLowerCase(), v]));
3
+ /**
4
+ * AirPrint printers as found devices: the identity is TXT `UUID` (the instance name when absent), the name TXT `ty` (else
5
+ * the instance name), the model `ty`. `txt` keeps the whole record for the connector (`rp`, `pdl`, `URF`, `product`,
6
+ * `adminurl`, `note`). A printer on both types is one entry, the plain `_ipp._tcp` one (`ipp` listed first), so a
7
+ * self-signed certificate on `_ipps._tcp` never decides; a printer on `_ipps._tcp` alone is `secure`.
8
+ */
9
+ export function printersFound(ipp, ipps) {
10
+ const seen = new Set();
11
+ const out = [];
12
+ for (const [secure, answers] of [
13
+ [false, ipp],
14
+ [true, ipps],
15
+ ])
16
+ for (const a of answers) {
17
+ const txt = lower(a.txt);
18
+ const id = txt.uuid || a.instance;
19
+ const key = id.toLowerCase().replace(/[^0-9a-z]/g, "");
20
+ if (seen.has(key))
21
+ continue;
22
+ seen.add(key);
23
+ out.push({
24
+ connector: "printers",
25
+ id,
26
+ host: a.host,
27
+ port: a.port,
28
+ name: txt.ty || a.instance,
29
+ model: txt.ty,
30
+ secure,
31
+ txt,
32
+ });
33
+ }
34
+ return out;
35
+ }
@@ -0,0 +1,52 @@
1
+ import { z } from "zod";
2
+ import { type Clock } from "../../clock.ts";
3
+ import { type Finder } from "../../discover.ts";
4
+ import type { Integration } from "../../home.ts";
5
+ import type { Manifest } from "../../manifest.ts";
6
+ import { type Printer, type PrintersState } from "./state.ts";
7
+ export { type Marker, type Printer, type PrintersState, type PrinterStatus, printerText, } from "./state.ts";
8
+ /** One printer in a home config. Everything the printer reports about itself (model, markers, state) comes live; `host` and `port` are only where it last answered. */
9
+ export declare const configSchema: z.ZodObject<{
10
+ connector: z.ZodLiteral<"printers">;
11
+ name: z.ZodString;
12
+ uuid: z.ZodString;
13
+ host: z.ZodOptional<z.ZodString>;
14
+ port: z.ZodOptional<z.ZodNumber>;
15
+ offlineDays: z.ZodOptional<z.ZodNumber>;
16
+ }, z.core.$loose>;
17
+ export type PrinterEntry = {
18
+ uuid: string;
19
+ name?: string;
20
+ host?: string;
21
+ port?: number;
22
+ room?: string;
23
+ offlineDays?: number;
24
+ };
25
+ export type PrintersConfig = {
26
+ /** 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. */
27
+ printers?: PrinterEntry[];
28
+ /** Plain HTTP and HTTPS; the global `fetch` when left out. A printer's self-signed certificate on IPPS fails here, as it should. */
29
+ fetch?: typeof fetch;
30
+ /** 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. */
31
+ find?: Finder;
32
+ /** Each address that answered, for an address cache. */
33
+ seen?: (uuid: string, address: {
34
+ host: string;
35
+ port: number;
36
+ name?: string;
37
+ }) => void;
38
+ clock?: Clock;
39
+ /** Poll interval; default 120 s. */
40
+ pollMs?: number;
41
+ };
42
+ export declare const POLL_MS = 120000;
43
+ /**
44
+ * Every printer as one integration, state only: a Get-Printer-Attributes over IPP to each, every 120 s. An unreachable
45
+ * printer stays in the snapshot as `offline` with its last good values and `lastSeen`, and the part is never
46
+ * `unavailable` for that. Discovery is one shared scan, as Cast's is, not a relocator per printer: it finds printers
47
+ * 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).
48
+ */
49
+ export declare function printers(config?: PrintersConfig): Integration<PrintersState, "printers">;
50
+ /** Printers sorted by name. */
51
+ export declare const printerList: (s: PrintersState) => Printer[];
52
+ export declare const manifest: Manifest<PrintersState>;
@@ -0,0 +1,282 @@
1
+ import { z } from "zod";
2
+ import { asOf, realClock } from "../../clock.js";
3
+ import { errorText } from "../../connector.js";
4
+ import { DAY_MS, discover, HOUR, lostDetail, rescanDelay, } from "../../discover.js";
5
+ import { poller } from "../../poll.js";
6
+ import { getPrinterAttributes, printerUrl } from "./client.js";
7
+ import { attributesOf } from "./codec.js";
8
+ import { printerKey, printerText, toState, } from "./state.js";
9
+ export { printerText, } from "./state.js";
10
+ /** 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. */
11
+ export const configSchema = z.looseObject({
12
+ connector: z.literal("printers"),
13
+ name: z
14
+ .string()
15
+ .describe("the printer's name for display (mDNS `ty`, or its instance name); `home discover --write` fills it"),
16
+ uuid: z
17
+ .string()
18
+ .describe("the printer's UUID from its mDNS TXT record, the stable identity; `home discover --write` fills it"),
19
+ host: z
20
+ .string()
21
+ .optional()
22
+ .describe("the address it last answered at; discovery refreshes it"),
23
+ port: z
24
+ .number()
25
+ .int()
26
+ .min(1)
27
+ .max(65535)
28
+ .optional()
29
+ .describe("its IPP port (631 plain, 443 TLS); discovery fills it"),
30
+ offlineDays: z
31
+ .number()
32
+ .positive()
33
+ .optional()
34
+ .describe("days an unreachable printer shows as offline with its last-seen time before its entry flags it as long gone; default 14"),
35
+ });
36
+ export const POLL_MS = 120_000;
37
+ /** A printer unreachable this many days gets a note in `anomaly`, unless its config says otherwise. */
38
+ const OFFLINE_DAYS = 14;
39
+ /**
40
+ * Every printer as one integration, state only: a Get-Printer-Attributes over IPP to each, every 120 s. An unreachable
41
+ * printer stays in the snapshot as `offline` with its last good values and `lastSeen`, and the part is never
42
+ * `unavailable` for that. Discovery is one shared scan, as Cast's is, not a relocator per printer: it finds printers
43
+ * 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).
44
+ */
45
+ export function printers(config = {}) {
46
+ const clock = config.clock ?? realClock;
47
+ const fetcher = config.fetch ?? fetch;
48
+ const pollMs = config.pollMs ?? POLL_MS;
49
+ const poll = poller(clock);
50
+ const targets = new Map();
51
+ const add = (t) => {
52
+ const target = {
53
+ offlineDays: OFFLINE_DAYS,
54
+ since: clock.now(),
55
+ lost: {},
56
+ ipps: false,
57
+ ...t,
58
+ };
59
+ targets.set(t.key, target);
60
+ return target;
61
+ };
62
+ for (const p of config.printers ?? [])
63
+ add({
64
+ key: printerKey(p.uuid),
65
+ name: p.name ?? p.uuid,
66
+ fixedName: p.name !== undefined,
67
+ room: p.room,
68
+ host: p.host,
69
+ port: p.port,
70
+ offlineDays: p.offlineDays ?? OFFLINE_DAYS,
71
+ });
72
+ let report;
73
+ let polled = false;
74
+ let requestId = 0;
75
+ let lastScan = Number.NEGATIVE_INFINITY;
76
+ let nextScan = 0;
77
+ let attempt = 0;
78
+ let scanError;
79
+ /** The entry for the snapshot: the last good values, `offline` while the latest read has failed. */
80
+ function view(t, now) {
81
+ const { uuid: _, anomaly: oddReading, ...good } = t.good ?? {};
82
+ const offline = t.failure !== undefined;
83
+ const base = t.lastSeen ?? t.since;
84
+ const idle = clock.now() - base;
85
+ const long = offline && idle > t.offlineDays * DAY_MS
86
+ ? `no answer for ${Math.floor(idle / DAY_MS)} days`
87
+ : undefined;
88
+ return {
89
+ ...good,
90
+ uuid: t.key,
91
+ name: t.name,
92
+ model: good.model ?? t.foundModel,
93
+ host: t.host,
94
+ room: t.room,
95
+ ...(offline ? { state: "offline" } : {}),
96
+ ipps: t.ipps,
97
+ lastSeen: t.lastSeen === undefined
98
+ ? undefined
99
+ : new Date(t.lastSeen).toISOString(),
100
+ asOf: now,
101
+ detail: offline
102
+ ? lostDetail({ ...t.lost, ...(idle >= DAY_MS ? { overADay: true } : {}) }, t.failure)
103
+ : undefined,
104
+ anomaly: [oddReading, long].filter(Boolean).join("; ") || undefined,
105
+ };
106
+ }
107
+ function publish() {
108
+ const now = asOf(clock);
109
+ const list = [...targets.values()].map((t) => view(t, now));
110
+ const down = list.filter((p) => p.state === "offline");
111
+ const status = !polled
112
+ ? "connecting"
113
+ : list.length && down.length < list.length
114
+ ? "connected"
115
+ : "disconnected";
116
+ const detail = [
117
+ scanError && `discovery failed: ${scanError}`,
118
+ list.length
119
+ ? down.length
120
+ ? `${down.map((p) => p.name).join(", ")} offline`
121
+ : ""
122
+ : config.find
123
+ ? "no printers found"
124
+ : "no printers configured",
125
+ ]
126
+ .filter(Boolean)
127
+ .join("; ") || undefined;
128
+ const anomaly = list
129
+ .filter((p) => p.anomaly)
130
+ .map((p) => `${p.name}: ${p.anomaly}`)
131
+ .join("; ") || undefined;
132
+ report?.({
133
+ status,
134
+ detail,
135
+ anomaly,
136
+ state: {
137
+ printers: Object.fromEntries(list.map((p) => [p.uuid, p])),
138
+ asOf: now,
139
+ },
140
+ });
141
+ }
142
+ /** One read. A failure is data (`failure`), never thrown; an address that answers as another printer is a failure. */
143
+ async function read(t, signal) {
144
+ if (!t.host) {
145
+ t.failure = "no address yet";
146
+ return;
147
+ }
148
+ const address = { host: t.host, port: t.port, rp: t.rp, secure: t.secure };
149
+ t.ipps = printerUrl(address).secure;
150
+ try {
151
+ const reading = toState(attributesOf(await getPrinterAttributes(fetcher, address, {
152
+ signal,
153
+ requestId: ++requestId,
154
+ })));
155
+ if (reading.uuid &&
156
+ /^[0-9a-f]{32}$/.test(t.key) &&
157
+ printerKey(reading.uuid) !== t.key)
158
+ throw new Error("the address answers as another printer");
159
+ t.good = reading;
160
+ t.lastSeen = clock.now();
161
+ t.failure = undefined;
162
+ t.lost = {};
163
+ config.seen?.(t.key, {
164
+ host: t.host,
165
+ port: t.port ?? (t.ipps ? 443 : 631),
166
+ name: t.fixedName ? undefined : t.name,
167
+ });
168
+ }
169
+ catch (error) {
170
+ if (!signal.aborted)
171
+ t.failure = errorText(error);
172
+ }
173
+ }
174
+ /** One discovery. Printers it finds get their address and TXT; ones the config lacks join the snapshot. Returns those that need reading again. */
175
+ async function scan(signal, failed) {
176
+ lastScan = clock.now();
177
+ let all;
178
+ try {
179
+ const wanted = failed.map((t) => t.key);
180
+ all = await discover(config.find, {
181
+ clock,
182
+ signal,
183
+ passes: failed.length ? undefined : 1,
184
+ done: wanted.length
185
+ ? (found) => wanted.every((k) => found.some((f) => printerKey(f.id) === k))
186
+ : undefined,
187
+ });
188
+ scanError = undefined;
189
+ }
190
+ catch (error) {
191
+ scanError = errorText(error);
192
+ for (const t of failed)
193
+ t.lost = { discoveryFailed: scanError };
194
+ return [];
195
+ }
196
+ const changed = [];
197
+ const mine = all.filter((f) => f.connector === "printers");
198
+ for (const f of mine) {
199
+ const key = printerKey(f.id);
200
+ const known = targets.get(key);
201
+ const t = known ?? add({ key, name: f.name ?? f.id, fixedName: false });
202
+ const moved = t.host !== f.host || (f.port !== undefined && t.port !== f.port);
203
+ // A printer known by host alone was read at the default path over http; the TXT may say otherwise.
204
+ const reroute = (f.txt?.rp !== undefined && f.txt.rp !== t.rp) ||
205
+ (f.secure !== undefined && f.secure !== t.secure);
206
+ if (!t.fixedName && f.name)
207
+ t.name = f.name;
208
+ Object.assign(t, {
209
+ host: f.host,
210
+ port: f.port ?? t.port,
211
+ rp: f.txt?.rp ?? t.rp,
212
+ secure: f.secure ?? t.secure,
213
+ foundModel: f.model ?? t.foundModel,
214
+ });
215
+ if (!known || moved || reroute)
216
+ changed.push(t);
217
+ else if (failed.includes(t))
218
+ t.lost = { seenHere: true };
219
+ }
220
+ for (const t of failed)
221
+ if (!mine.some((f) => printerKey(f.id) === t.key))
222
+ t.lost = { seenHere: false };
223
+ return changed;
224
+ }
225
+ async function pollOnce(signal) {
226
+ const first = !polled;
227
+ await Promise.all([...targets.values()].map((t) => read(t, signal)));
228
+ if (signal.aborted || !config.find)
229
+ return;
230
+ const now = clock.now();
231
+ const failed = () => [...targets.values()].filter((t) => t.failure !== undefined);
232
+ if (!(first ||
233
+ (failed().length && now >= nextScan) ||
234
+ now >= lastScan + HOUR))
235
+ return;
236
+ const changed = await scan(signal, failed());
237
+ await Promise.all(changed.map((t) => read(t, signal)));
238
+ if (failed().length)
239
+ nextScan = now + rescanDelay(attempt++);
240
+ else
241
+ ((attempt = 0), (nextScan = 0));
242
+ }
243
+ return {
244
+ name: "printers",
245
+ /** Read now; a printer that is down is looked for again at once. */
246
+ refresh() {
247
+ attempt = 0;
248
+ nextScan = 0;
249
+ poll.nudge();
250
+ },
251
+ async run(signal, update) {
252
+ report = update;
253
+ while (!signal.aborted) {
254
+ await pollOnce(signal);
255
+ if (signal.aborted)
256
+ return;
257
+ polled = true;
258
+ publish();
259
+ await poll.wait(pollMs, signal);
260
+ }
261
+ },
262
+ };
263
+ }
264
+ /** Printers sorted by name. */
265
+ export const printerList = (s) => Object.values(s.printers).sort((a, b) => a.name.localeCompare(b.name));
266
+ export const manifest = {
267
+ name: "printers",
268
+ title: "Printers",
269
+ iotClass: "local_polling",
270
+ pollSeconds: POLL_MS / 1000,
271
+ access: "local IPP over HTTP, no key; found by mDNS (AirPrint)",
272
+ env: [],
273
+ lines({ state }) {
274
+ if (!state)
275
+ return [];
276
+ const list = printerList(state);
277
+ return [
278
+ `${list.length} printer${list.length === 1 ? "" : "s"}`,
279
+ ...list.map(printerText),
280
+ ];
281
+ },
282
+ };
@@ -0,0 +1,60 @@
1
+ import { idKey } from "../../discover.ts";
2
+ import type { IppValue } from "./codec.ts";
3
+ /** One consumable as the printer lists it: ink or toner, a drum, a fuser, waste. The level is the printer's own number, unrounded. */
4
+ export type Marker = {
5
+ name?: string;
6
+ level?: number;
7
+ type?: string;
8
+ color?: string;
9
+ };
10
+ export type PrinterStatus = "idle" | "printing" | "stopped" | "offline";
11
+ /**
12
+ * One printer. Every field the printer reports is optional; an odd reading passes through and is named in `anomaly`.
13
+ * `uuid` is the key: the mDNS `UUID` (or `printer-uuid`) lowercase without dashes. An unreachable printer keeps its last good
14
+ * values, with `state: "offline"` and `lastSeen`; `asOf` is when this entry was last checked, `lastSeen` when it last answered.
15
+ */
16
+ export type Printer = {
17
+ uuid: string;
18
+ name: string;
19
+ model?: string;
20
+ host?: string;
21
+ room?: string;
22
+ state?: PrinterStatus;
23
+ stateCode?: number;
24
+ reasons?: string[];
25
+ message?: string;
26
+ markers?: Marker[];
27
+ media?: string[];
28
+ queuedJobs?: number;
29
+ acceptingJobs?: boolean;
30
+ formats?: string[];
31
+ ippVersions?: string[];
32
+ alerts?: string[];
33
+ ipps: boolean;
34
+ lastSeen?: string;
35
+ asOf: string;
36
+ detail?: string;
37
+ anomaly?: string;
38
+ };
39
+ export type PrintersState = {
40
+ printers: Record<string, Printer>;
41
+ asOf: string;
42
+ };
43
+ /** 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. */
44
+ export declare const printerKey: typeof idKey;
45
+ /** The fields of `Printer` that come from Get-Printer-Attributes. */
46
+ export type Reading = Partial<Pick<Printer, "model" | "state" | "stateCode" | "reasons" | "message" | "markers" | "media" | "queuedJobs" | "acceptingJobs" | "formats" | "ippVersions" | "alerts" | "anomaly">> & {
47
+ uuid?: string;
48
+ };
49
+ /**
50
+ * The printer's attributes (name → values, from `attributesOf`) as a `Reading`. Nothing is clamped or corrected: a state
51
+ * 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
52
+ * special meanings) stays as sent; both, and lists that disagree in length, are named in `anomaly`.
53
+ */
54
+ export declare function toState(attrs: Record<string, IppValue[]>): Reading;
55
+ /** 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. */
56
+ export declare function mediaName(keyword: string): string;
57
+ /** "5 min ago", "2 h ago", "3 days ago" from two instants; whole units, down. */
58
+ export declare function ago(fromMs: number, toMs: number): string;
59
+ /** One printer in a few words: "Office: idle · Black Toner 40% · A4 ready", "Office: offline, last seen 2 h ago". */
60
+ export declare function printerText(p: Printer): string;
@@ -0,0 +1,99 @@
1
+ import { idKey } from "../../discover.js";
2
+ /** 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. */
3
+ export const printerKey = idKey;
4
+ const strings = (v) => v?.flatMap((x) => typeof x === "string"
5
+ ? [x]
6
+ : x instanceof Uint8Array
7
+ ? [new TextDecoder().decode(x)]
8
+ : []);
9
+ const numbers = (v) => v?.flatMap((x) => (typeof x === "number" ? [x] : []));
10
+ const STATES = {
11
+ 3: "idle",
12
+ 4: "printing",
13
+ 5: "stopped",
14
+ };
15
+ /**
16
+ * The printer's attributes (name → values, from `attributesOf`) as a `Reading`. Nothing is clamped or corrected: a state
17
+ * 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
18
+ * special meanings) stays as sent; both, and lists that disagree in length, are named in `anomaly`.
19
+ */
20
+ export function toState(attrs) {
21
+ const odd = [];
22
+ const [stateCode] = numbers(attrs["printer-state"]) ?? [];
23
+ const state = stateCode === undefined ? undefined : STATES[stateCode];
24
+ if (stateCode !== undefined && !state)
25
+ odd.push(`printer-state ${stateCode}, not 3 (idle), 4 (processing) or 5 (stopped)`);
26
+ const names = strings(attrs["marker-names"]);
27
+ const levels = numbers(attrs["marker-levels"]);
28
+ const types = strings(attrs["marker-types"]);
29
+ const colors = strings(attrs["marker-colors"]);
30
+ const count = Math.max(names?.length ?? 0, levels?.length ?? 0, types?.length ?? 0, colors?.length ?? 0);
31
+ const lengths = [names, levels, types, colors].flatMap((x) => x ? [x.length] : []);
32
+ if (new Set(lengths).size > 1)
33
+ odd.push(`marker lists of different lengths (${lengths.join(", ")})`);
34
+ const markers = Array.from({ length: count }, (_, i) => ({
35
+ name: names?.[i],
36
+ level: levels?.[i],
37
+ type: types?.[i],
38
+ color: colors?.[i],
39
+ }));
40
+ for (const m of markers)
41
+ if (m.level !== undefined && (m.level < 0 || m.level > 100))
42
+ odd.push(`marker ${m.name ?? "?"} level ${m.level}, outside 0 to 100`);
43
+ const accepting = attrs["printer-is-accepting-jobs"]?.[0];
44
+ const uuid = strings(attrs["printer-uuid"])?.[0];
45
+ return {
46
+ uuid,
47
+ model: strings(attrs["printer-make-and-model"])?.[0],
48
+ state,
49
+ stateCode,
50
+ reasons: strings(attrs["printer-state-reasons"]),
51
+ message: strings(attrs["printer-state-message"])?.[0],
52
+ markers: count ? markers : undefined,
53
+ media: strings(attrs["media-ready"]),
54
+ queuedJobs: numbers(attrs["queued-job-count"])?.[0],
55
+ acceptingJobs: typeof accepting === "boolean" ? accepting : undefined,
56
+ formats: strings(attrs["document-format-supported"]),
57
+ ippVersions: strings(attrs["ipp-versions-supported"]),
58
+ alerts: strings(attrs["printer-alert"]),
59
+ anomaly: odd.length ? odd.join("; ") : undefined,
60
+ };
61
+ }
62
+ /** 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. */
63
+ export function mediaName(keyword) {
64
+ const parts = keyword.split("_");
65
+ if (parts.length < 3)
66
+ return keyword;
67
+ const name = parts[1];
68
+ return /^[a-z]\d+$/.test(name)
69
+ ? name.toUpperCase()
70
+ : name.charAt(0).toUpperCase() + name.slice(1);
71
+ }
72
+ /** "5 min ago", "2 h ago", "3 days ago" from two instants; whole units, down. */
73
+ export function ago(fromMs, toMs) {
74
+ const min = Math.max(0, Math.floor((toMs - fromMs) / 60_000));
75
+ if (min < 60)
76
+ return `${min} min ago`;
77
+ const h = Math.floor(min / 60);
78
+ return h < 48 ? `${h} h ago` : `${Math.floor(h / 24)} days ago`;
79
+ }
80
+ /** One printer in a few words: "Office: idle · Black Toner 40% · A4 ready", "Office: offline, last seen 2 h ago". */
81
+ export function printerText(p) {
82
+ if (p.state === "offline") {
83
+ const seen = p.lastSeen === undefined
84
+ ? "never seen"
85
+ : `last seen ${ago(Date.parse(p.lastSeen), Date.parse(p.asOf))}`;
86
+ return `${p.name}: offline, ${seen}`;
87
+ }
88
+ const attention = (p.reasons ?? []).filter((r) => /-(error|warning)$/.test(r));
89
+ const media = [...new Set((p.media ?? []).map(mediaName))];
90
+ const parts = [
91
+ p.state ?? (p.stateCode === undefined ? "" : `state ${p.stateCode}`),
92
+ ...(p.markers ?? []).map((m) => `${m.name ?? "?"} ${m.level ?? "?"}%`),
93
+ media.length ? `${media.join(", ")} ready` : "",
94
+ p.queuedJobs ? `${p.queuedJobs} queued` : "",
95
+ p.acceptingJobs === false ? "not accepting jobs" : "",
96
+ ...attention,
97
+ ];
98
+ return `${p.name}: ${parts.filter(Boolean).join(" · ")}`;
99
+ }
@@ -0,0 +1,32 @@
1
+ import type { Part } from "../../home.ts";
2
+ import type { SpotifyState, Stall, Track } from "./client.ts";
3
+ /** A stall in a few words: `playback stalled at 1:39 (Soloist issue #5); tried a seek`. */
4
+ export declare function stallText(s: Stall | undefined): string | undefined;
5
+ /** The speaker in one word, to switch on. `offline`: not reached (or no part); `waiting`: running, nobody has picked it. A status Soloist sends beyond its four passes through. */
6
+ export type SpeakerState = "offline" | "connecting" | "waiting" | "idle" | "playing" | "paused" | "buffering" | (string & {});
7
+ export declare function speakerState(part: Part<SpotifyState> | undefined): SpeakerState;
8
+ /**
9
+ * A change worth telling someone about, from two reports of the speaker. `own`, on playback events only: it happened while
10
+ * this library's `duck`, `interrupt` or stall repair ran (its own writes, or a person's change in that time), so a consumer
11
+ * needn't react to it. `offline`, `online`, `waiting`, `picked`, `stalled` and `recovered` are never `own`.
12
+ */
13
+ export type SpotifyEvent = {
14
+ own?: true;
15
+ } & ({
16
+ type: "offline" | "online" | "waiting" | "picked" | "started" | "resumed" | "paused" | "stopped" | "recovered";
17
+ } | {
18
+ type: "stalled";
19
+ stall: Stall;
20
+ } | {
21
+ type: "track";
22
+ track: Track;
23
+ });
24
+ /**
25
+ * The events between two reports, for `onChange`'s `before` and `after`: `offline` and `online`; `waiting` (signed out) and
26
+ * `picked` (someone picked the speaker); `started` (from idle), `resumed` (from paused), `paused`, `stopped` (to idle), then
27
+ * `track` for a new track, so "started" and the track come as a pair, then `stalled` and `recovered` (Soloist issue #5). Buffering counts as playing. No `before`, or one
28
+ * with no state (createHome's placeholder before the first report, as at a restart): nothing, since nothing was seen to change. No volume or queue events: read the state.
29
+ */
30
+ export declare function spotifyChanges(before: Part<SpotifyState> | undefined, after: Part<SpotifyState> | undefined): SpotifyEvent[];
31
+ /** The speaker in a few words: `Kitchen speaker: playing "Song" by Band · 35%`, or `…: waiting for someone to pick it`. */
32
+ export declare function spotifyText(s: SpotifyState): string;
@@ -0,0 +1,84 @@
1
+ // What a consumer reads off the speaker's parts: one word, the events between two reports, a line of text. Pure: no socket, no clock.
2
+ /** A stall in a few words: `playback stalled at 1:39 (Soloist issue #5); tried a seek`. */
3
+ export function stallText(s) {
4
+ if (!s)
5
+ return undefined;
6
+ const at = `${Math.floor(s.ms / 60_000)}:${String(Math.floor((s.ms % 60_000) / 1000)).padStart(2, "0")}`;
7
+ const what = s.how === "overrun"
8
+ ? `playback stuck past the end of the track (Soloist issue #5)`
9
+ : `playback stalled at ${at} (Soloist issue #5)`;
10
+ return s.tried ? `${what}; tried ${s.tried}` : what;
11
+ }
12
+ export function speakerState(part) {
13
+ if (!part || part.status === "disconnected" || part.status === "unavailable")
14
+ return "offline";
15
+ if (part.status === "connecting")
16
+ return "connecting";
17
+ if (part.state?.loggedIn === false)
18
+ return "waiting";
19
+ return part.state?.status ?? "idle"; // signed in, nothing reported yet: nothing is playing
20
+ }
21
+ const PLAYING = new Set(["playing", "buffering"]);
22
+ /**
23
+ * The events between two reports, for `onChange`'s `before` and `after`: `offline` and `online`; `waiting` (signed out) and
24
+ * `picked` (someone picked the speaker); `started` (from idle), `resumed` (from paused), `paused`, `stopped` (to idle), then
25
+ * `track` for a new track, so "started" and the track come as a pair, then `stalled` and `recovered` (Soloist issue #5). Buffering counts as playing. No `before`, or one
26
+ * with no state (createHome's placeholder before the first report, as at a restart): nothing, since nothing was seen to change. No volume or queue events: read the state.
27
+ */
28
+ export function spotifyChanges(before, after) {
29
+ if (!before?.state)
30
+ return []; // createHome's first `before` is a bare connecting part: a boot is no change
31
+ const own = before.state?.aside || after?.state?.aside ? { own: true } : {}; // playback events only: a drop or a sign-out is never the library's
32
+ const down = (p) => ["offline", "connecting"].includes(speakerState(p)); // a reconnect passes through connecting
33
+ const was = down(before);
34
+ if (speakerState(after) === "offline")
35
+ return was ? [] : [{ type: "offline" }];
36
+ if (after?.status === "connecting")
37
+ return [];
38
+ const events = [];
39
+ const add = (e) => events.push({ ...e, ...own });
40
+ if (was)
41
+ events.push({ type: "online" });
42
+ const b = before.state;
43
+ const a = after?.state;
44
+ if (b?.loggedIn !== false && a?.loggedIn === false)
45
+ events.push({ type: "waiting" });
46
+ if (b?.loggedIn === false && a?.loggedIn === true)
47
+ events.push({ type: "picked" });
48
+ const from = b?.status;
49
+ const to = a?.status;
50
+ const playingNow = to !== undefined && PLAYING.has(to);
51
+ const playingThen = from !== undefined && PLAYING.has(from);
52
+ if (playingNow && !playingThen)
53
+ add({ type: from === "paused" ? "resumed" : "started" });
54
+ if (to === "paused" && from !== "paused")
55
+ add({ type: "paused" });
56
+ if (to === "idle" && from !== undefined && from !== "idle")
57
+ add({ type: "stopped" });
58
+ if (a?.track && a.track.uri !== b?.track?.uri)
59
+ add({ type: "track", track: a.track });
60
+ if (a?.stalled && !b?.stalled)
61
+ events.push({ type: "stalled", stall: a.stalled });
62
+ if (b?.stalled &&
63
+ !a?.stalled &&
64
+ a?.status === "playing" &&
65
+ a.track?.uri === b.stalled.track)
66
+ events.push({ type: "recovered" });
67
+ return events;
68
+ }
69
+ /** The speaker in a few words: `Kitchen speaker: playing "Song" by Band · 35%`, or `…: waiting for someone to pick it`. */
70
+ export function spotifyText(s) {
71
+ const who = s.deviceName ?? "Soloist";
72
+ if (s.loggedIn === false)
73
+ return `${who}: waiting for someone to pick it`;
74
+ const t = s.track;
75
+ const what = t
76
+ ? `${t.name ? ` "${t.name}"` : ` ${t.uri}`}${t.artists.length ? ` by ${t.artists.join(", ")}` : ""}`
77
+ : "";
78
+ const parts = [
79
+ `${s.status ?? "status unknown"}${what}`,
80
+ s.volume === undefined ? "" : `${s.volume}%`,
81
+ s.shuffle ? "shuffle" : "",
82
+ ];
83
+ return `${who}: ${parts.filter(Boolean).join(" · ")}`;
84
+ }