@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,124 @@
1
+ import type { HueEvent, Resource } from "./schemas.ts";
2
+
3
+ /** Every resource on the bridge by id, from the last full read plus every event since. `asOf` is local time it was last known fresh. */
4
+ export type HueSnapshot = {
5
+ resources: Record<string, Resource>;
6
+ asOf: string;
7
+ lastEventId?: string;
8
+ lastError?: HueEvent;
9
+ };
10
+
11
+ /** A snapshot from one full `get()`. */
12
+ export function snapshotFrom(resources: Resource[], asOf: string): HueSnapshot {
13
+ return {
14
+ resources: Object.fromEntries(resources.map((r) => [r.id, r])),
15
+ asOf,
16
+ };
17
+ }
18
+
19
+ /** Apply one event batch. Pure: returns a new snapshot. Updates carry only changed fields, so they merge; unknown types are ignored. */
20
+ export function reduce(snapshot: HueSnapshot, event: HueEvent): HueSnapshot {
21
+ if (event.type === "error") return { ...snapshot, lastError: event };
22
+ if (!["add", "update", "delete"].includes(event.type)) return snapshot;
23
+ const resources = { ...snapshot.resources };
24
+ for (const item of event.data) {
25
+ const prev = resources[item.id];
26
+ if (event.type === "delete") delete resources[item.id];
27
+ else
28
+ resources[item.id] = (
29
+ event.type === "update" && prev ? merge(prev, item) : item
30
+ ) as Resource;
31
+ }
32
+ return { ...snapshot, resources };
33
+ }
34
+
35
+ type Plain = Record<string, unknown>;
36
+ const isPlain = (v: unknown): v is Plain =>
37
+ typeof v === "object" && v !== null && !Array.isArray(v);
38
+ function merge(a: Plain, b: Plain): Plain {
39
+ const out = { ...a };
40
+ for (const [k, v] of Object.entries(b))
41
+ out[k] = isPlain(v) && isPlain(a[k]) ? merge(a[k], v) : v;
42
+ return out;
43
+ }
44
+
45
+ const ofType = (s: HueSnapshot, type: string) =>
46
+ Object.values(s.resources).filter((r) => r.type === type);
47
+ const groupedLightOf = (s: HueSnapshot, r: Resource) =>
48
+ r.services
49
+ ?.map((x) => s.resources[x.rid])
50
+ .find((x) => x?.type === "grouped_light");
51
+
52
+ /** A light as a row shows it. `reachable` is undefined when the bridge has no connectivity record for it. */
53
+ export type LightView = {
54
+ id: string;
55
+ name?: string;
56
+ on?: boolean;
57
+ brightness?: number;
58
+ reachable?: boolean;
59
+ };
60
+
61
+ /** Every light, with on, brightness and reachability. */
62
+ export function lights(s: HueSnapshot): LightView[] {
63
+ const zigbee = ofType(s, "zigbee_connectivity");
64
+ return ofType(s, "light").map((l) => {
65
+ const link = zigbee.find((z) => z.owner?.rid === l.owner?.rid);
66
+ return {
67
+ id: l.id,
68
+ name: l.metadata?.name,
69
+ on: l.on?.on,
70
+ brightness: l.dimming?.brightness,
71
+ reachable: link ? link.status === "connected" : undefined,
72
+ };
73
+ });
74
+ }
75
+
76
+ /** A room or zone as a row shows it, with its grouped light's state. */
77
+ export type GroupView = {
78
+ id: string;
79
+ type: "room" | "zone";
80
+ name?: string;
81
+ groupedLightId?: string;
82
+ on?: boolean;
83
+ brightness?: number;
84
+ lightIds: string[];
85
+ };
86
+
87
+ /** Rooms and zones. A room's children are devices; a zone's are lights. */
88
+ export function groups(s: HueSnapshot): GroupView[] {
89
+ const all = ofType(s, "light");
90
+ return [...ofType(s, "room"), ...ofType(s, "zone")].map((g) => {
91
+ const kids = new Set(g.children?.map((c) => c.rid));
92
+ const grouped = groupedLightOf(s, g);
93
+ return {
94
+ id: g.id,
95
+ type: g.type as "room" | "zone",
96
+ name: g.metadata?.name,
97
+ groupedLightId: grouped?.id,
98
+ on: grouped?.on?.on,
99
+ brightness: grouped?.dimming?.brightness,
100
+ lightIds: all
101
+ .filter((l) => kids.has(l.id) || kids.has(l.owner?.rid ?? ""))
102
+ .map((l) => l.id),
103
+ };
104
+ });
105
+ }
106
+
107
+ /** The grouped light for the whole home: one PUT `{ on: { on: false } }` to it is "Lights off". */
108
+ export function homeGroupedLightId(s: HueSnapshot): string | undefined {
109
+ const home = ofType(s, "bridge_home")[0];
110
+ return home && groupedLightOf(s, home)?.id;
111
+ }
112
+
113
+ /** Scenes with their room or zone and whether one is active (`status.active` other than "inactive"). */
114
+ export function scenes(s: HueSnapshot) {
115
+ return ofType(s, "scene").map((sc) => {
116
+ const active = (sc.status as { active?: string } | undefined)?.active;
117
+ return {
118
+ id: sc.id,
119
+ name: sc.metadata?.name,
120
+ groupId: sc.group?.rid,
121
+ active: active !== undefined && active !== "inactive",
122
+ };
123
+ });
124
+ }
@@ -0,0 +1,46 @@
1
+ # Printers
2
+
3
+ AirPrint printers, found by mDNS and read over IPP on the local network. `iot_class`: **local_polling** (one Get-Printer-Attributes per printer every 120 s). Built read-only; **untried on a live printer** (the Brother KO has did not answer mDNS on 2026-10-14, see Live findings).
4
+
5
+ Try it: `home printers`
6
+
7
+ ## Built
8
+
9
+ - `printers({ printers, fetch, find, seen })` keeps every printer current as one integration. A config entry per printer (`uuid`, `name`, last known `host` and `port`, `room`, `offlineDays`); discovery adds any AirPrint printer the config does not list.
10
+ - Per poll: one `POST` to each printer. The snapshot (`PrintersState`) holds one `Printer` per UUID (lowercase, no dashes): `name`, `model`, `host`, `state` (`idle`, `printing`, `stopped`, `offline`), `stateCode`, `reasons`, `message`, `markers` (`name`, `level`, `type`, `color`), `media` (`media-ready`), `queuedJobs`, `acceptingJobs`, `formats`, `ippVersions`, `alerts`, `ipps`, `lastSeen`, `asOf`, `detail`, `anomaly`.
11
+ - **Offline.** An unreachable printer stays in the snapshot as `offline`, keeping its last good values and `lastSeen`. The part is `connected` while any printer answers and `disconnected` otherwise, never `unavailable` for being unreachable (printers go on and off). After `offlineDays` (default 14) without an answer the entry says so in `anomaly`; after a day its `detail` says "no answer for a day". `lastSeen` lives in memory: a restart starts the count again.
12
+ - **Odd values pass through.** A `printer-state` outside 3 to 5 leaves `state` out, keeps `stateCode`, and is named in `anomaly`. A marker level outside 0 to 100 stays as sent and is named (PWG 5100.13 reserves negative levels for "unknown" kinds; read it before treating one as empty). Marker lists of different lengths are named too.
13
+ - **Identity check.** The printer's `printer-uuid` must match the entry's UUID, or the read fails with "the address answers as another printer", so a recycled address never shows one printer's toner under another's name.
14
+ - **Discovery** is one shared scan, as Cast's is, not a relocator per printer (the relocator is one device and one scan each; three printers down would be three `dns-sd` runs). It runs at start, after a failed read at 1, 5 and 15 minutes then hourly (`rescanDelay`), hourly for new printers, and a `refresh()` resets the backoff. A moved printer is found by its UUID and read again at once; `detail` says "not found by discovery" only after a scan looked and missed it (`lostDetail`).
15
+ - `home discover` lists printers and `--write` adds them (`uuid`, `name`, `host`, `port`); `home probe` reads each once. No writes. The place for them is marked `TODO(writes…)` in `client.ts`.
16
+
17
+ ## Protocol
18
+
19
+ IPP is a binary message in an HTTP `POST` with `content-type: application/ipp`, to `http://host:port/<rp>`; `ipps` is the same over TLS. The path `rp` is TXT `rp` (most printers: `ipp/print`; the default here). The request names the printer by an `ipp://` or `ipps://` URI.
20
+
21
+ The Get-Printer-Attributes request (`codec.ts`), RFC 8010 section 3: version 2.0, operation `0x000B`, a request id, then the operation-attributes group with `attributes-charset` `utf-8`, `attributes-natural-language` `en`, `printer-uri`, and `requested-attributes`: `printer-state`, `printer-state-reasons`, `printer-state-message`, `marker-names`, `marker-levels`, `marker-types`, `marker-colors`, `media-ready`, `queued-job-count`, `printer-alert`, `printer-make-and-model`, `printer-uuid`, `document-format-supported`, `ipp-versions-supported`, `printer-is-accepting-jobs`.
22
+
23
+ The codec is about 200 lines by hand, like Cast's, in place of the `ipp` npm package. It decodes integer, enum, boolean, every string tag, dateTime, resolution, rangeOfInteger and octetString; any other tag, a wrong length for a known tag, and a whole collection (skipped by depth) come back as `{ tag, bytes }`, never thrown. Only a message cut short throws. A response status of `0x0100` or more, or a non-2xx HTTP status, is a `ConnectorError` naming it.
24
+
25
+ Discovery is mDNS `_ipp._tcp` and `_ipps._tcp` ([Apple's AirPrint TXT keys](https://developer.apple.com/bonjour/printing-specification/bonjourprinting-1.2.pdf)): `UUID` is the identity (the instance name when absent), `ty` the name and model, `rp` the path; `pdl`, `URF`, `product`, `adminurl` and `note` are kept in the found record's `txt`. A printer on both types is one entry, the plain `_ipp._tcp` one, so a self-signed certificate on `_ipps._tcp` never decides; a printer on `_ipps._tcp` alone is read over TLS.
26
+
27
+ **TLS.** Printers sign their own certificates. `fetch` verifies them, and the read fails with its error in `detail`; nothing here turns verification off. If a printer only speaks IPPS, the fix is the caller's `fetch` (pin the certificate, as Hue's does), not this package's.
28
+
29
+ **Node.** Node's `fetch` refuses port 631 (it is on the WHATWG bad-port list, as IPP's well-known port). Bun does not. A Node caller would need a `fetch` that allows it, or the IPPS entry on 443. Not tried.
30
+
31
+ ## Unverified
32
+
33
+ Everything, until a real printer answers: the request bytes (the codec is checked against RFC 8010's layout and a fixture of invented values, not a printer), the default path `ipp/print`, that `printer-uuid` equals TXT `UUID` on a Brother, that a sleeping printer wakes for the request, what a Brother sends for `marker-levels` and `printer-alert` (the sample uses invented values), Bun's `fetch` on port 631, and the macOS local-network prompt on first use.
34
+
35
+ ## Live findings
36
+
37
+ None yet. 2026-10-14 (Bun 1.4.3): `home discover` (two passes) and `home printers` found no `_ipp._tcp` or `_ipps._tcp` instance on the network, so the Brother was not advertising (off, asleep, or on another network).
38
+
39
+ ## Sources
40
+
41
+ - [RFC 8010](https://www.rfc-editor.org/rfc/rfc8010) (IPP/1.1: Encoding and Transport) and [RFC 8011](https://www.rfc-editor.org/rfc/rfc8011) (Model and Semantics): the layout, the tags, `Get-Printer-Attributes`, `printer-state`. IETF documents; facts and layouts used, no text copied.
42
+ - [PWG 5100.13](https://ftp.pwg.org/pub/pwg/candidates/cs-ippjobprinterext3v10-20120727-5100.13.pdf) (IPP Job and Printer Extensions Set 3): `marker-*`. [PWG 5101.1](https://ftp.pwg.org/pub/pwg/candidates/cs-pwgmsn20-20130328-5101.1.pdf): media keywords such as `iso_a4_210x297mm`. PWG documents, facts only.
43
+ - [CUPS](https://www.cups.org/doc/spec-ipp.html) documentation of the IPP attributes it uses (Apache-2.0 with exceptions); read, not copied.
44
+ - Home Assistant's [`ipp` integration](https://www.home-assistant.io/integrations/ipp/) (Apache-2.0) and its library [pyipp](https://github.com/ctalkington/python-ipp) (MIT): read for which attributes to ask for and how states and markers are interpreted.
45
+ - Apple's [Bonjour Printing Specification](https://developer.apple.com/bonjour/printing-specification/) for the TXT keys.
46
+ - The fixture (`test/fixtures/printers/get-printer-attributes.hex`) is an invented printer built with this codec: documentation-style values, a made-up UUID, nothing from a real device.
@@ -0,0 +1,114 @@
1
+ import { ConnectorError, fetchTimeout } from "../../connector.ts";
2
+ import {
3
+ attributesOf,
4
+ decodeMessage,
5
+ getPrinterAttributesRequest,
6
+ type IppMessage,
7
+ } from "./codec.ts";
8
+
9
+ // Pure reads: a `fetch`, an address and a path come in, the printer's attributes go out. No writes here yet.
10
+ // TODO(writes, behind KO's hand): Identify-Printer (0x003C, a beep) first, then Print-Job (0x0002) with application/pdf
11
+ // where `document-format-supported` lists it. Each is one more request builder in codec.ts and one function here.
12
+
13
+ /** Everything the integration reads from one printer. */
14
+ export const REQUESTED = [
15
+ "printer-state",
16
+ "printer-state-reasons",
17
+ "printer-state-message",
18
+ "marker-names",
19
+ "marker-levels",
20
+ "marker-types",
21
+ "marker-colors",
22
+ "media-ready",
23
+ "queued-job-count",
24
+ "printer-alert",
25
+ "printer-make-and-model",
26
+ "printer-uuid",
27
+ "document-format-supported",
28
+ "ipp-versions-supported",
29
+ "printer-is-accepting-jobs",
30
+ ];
31
+
32
+ /** Where a printer answers: IPP is HTTP POST to `/<rp>`; port 443 (or a found `_ipps._tcp`) is TLS. */
33
+ export type PrinterAddress = {
34
+ host: string;
35
+ port?: number;
36
+ rp?: string;
37
+ secure?: boolean;
38
+ };
39
+
40
+ /** Most printers answer on this path (IPP Everywhere); the TXT `rp` says when theirs differs. */
41
+ export const DEFAULT_RP = "ipp/print";
42
+
43
+ /** The URL to POST to and the `ipp(s)://` URI the request names. A TXT `rp` may or may not start with a slash. */
44
+ export function printerUrl(a: PrinterAddress): {
45
+ http: string;
46
+ ipp: string;
47
+ secure: boolean;
48
+ path: string;
49
+ } {
50
+ const secure = a.secure ?? a.port === 443;
51
+ const port = a.port ?? (secure ? 443 : 631);
52
+ const path = (a.rp || DEFAULT_RP).replace(/^\/+/, "");
53
+ const host = a.host.includes(":") ? `[${a.host}]` : a.host;
54
+ return {
55
+ http: `${secure ? "https" : "http"}://${host}:${port}/${path}`,
56
+ ipp: `${secure ? "ipps" : "ipp"}://${host}:${port}/${path}`,
57
+ secure,
58
+ path,
59
+ };
60
+ }
61
+
62
+ /** A few status codes by name, for the error text; any other shows as hex. */
63
+ const STATUS: Record<number, string> = {
64
+ 0x0400: "client-error-bad-request",
65
+ 0x0401: "client-error-forbidden",
66
+ 0x0402: "client-error-not-authenticated",
67
+ 0x0403: "client-error-not-authorized",
68
+ 0x0404: "client-error-not-possible",
69
+ 0x0406: "client-error-not-found",
70
+ 0x040a: "client-error-document-format-not-supported",
71
+ 0x0500: "server-error-internal-error",
72
+ 0x0501: "server-error-operation-not-supported",
73
+ 0x0503: "server-error-service-unavailable",
74
+ 0x0507: "server-error-busy",
75
+ };
76
+
77
+ /**
78
+ * Get-Printer-Attributes over HTTP, through `fetchTimeout`. A non-ok HTTP status or an IPP status of 0x0100 and up throws a
79
+ * ConnectorError saying which. Returns the whole response; `attributesOf` gives the printer's attributes.
80
+ */
81
+ export async function getPrinterAttributes(
82
+ fetcher: typeof fetch,
83
+ address: PrinterAddress,
84
+ options: {
85
+ requestId?: number;
86
+ signal?: AbortSignal;
87
+ timeoutMs?: number;
88
+ } = {},
89
+ ): Promise<IppMessage> {
90
+ const url = printerUrl(address);
91
+ const res = await fetchTimeout(
92
+ fetcher,
93
+ url.http,
94
+ {
95
+ method: "POST",
96
+ headers: { "content-type": "application/ipp", accept: "application/ipp" },
97
+ body: getPrinterAttributesRequest(url.ipp, REQUESTED, options.requestId),
98
+ },
99
+ options,
100
+ );
101
+ if (!res.ok)
102
+ throw new ConnectorError(`HTTP ${res.status} on POST /${url.path}`, {
103
+ status: res.status,
104
+ });
105
+ const message = decodeMessage(new Uint8Array(await res.arrayBuffer()));
106
+ if (message.code >= 0x0100) {
107
+ const why = attributesOf(message, 1)["status-message"]?.[0];
108
+ throw new ConnectorError(
109
+ `IPP ${STATUS[message.code] ?? `status 0x${message.code.toString(16).padStart(4, "0")}`}${typeof why === "string" ? `: ${why}` : ""}`,
110
+ { status: message.code, code: "ipp" },
111
+ );
112
+ }
113
+ return message;
114
+ }
@@ -0,0 +1,292 @@
1
+ // IPP's binary encoding (RFC 8010) by hand, enough for Get-Printer-Attributes: a request out, a response in. A message is
2
+ // version (2 bytes), operation or status code (2), request id (4), then attribute groups, then 0x03, then any document data.
3
+ // Each attribute is a value tag, a name (length + bytes) and a value (length + bytes); a further value of the same attribute
4
+ // repeats the tag with a zero-length name. Both directions share the one layout, so one encoder and one decoder.
5
+
6
+ /** Delimiter tags open a group; value tags (0x10 and up) carry one value. Only the ones this package reads or writes are named. */
7
+ export const TAG = {
8
+ operation: 0x01,
9
+ job: 0x02,
10
+ end: 0x03,
11
+ printer: 0x04,
12
+ unsupported: 0x05,
13
+ integer: 0x21,
14
+ boolean: 0x22,
15
+ enum: 0x23,
16
+ octetString: 0x30,
17
+ dateTime: 0x31,
18
+ resolution: 0x32,
19
+ rangeOfInteger: 0x33,
20
+ begCollection: 0x34,
21
+ endCollection: 0x37,
22
+ textWithoutLanguage: 0x41,
23
+ nameWithoutLanguage: 0x42,
24
+ keyword: 0x44,
25
+ uri: 0x45,
26
+ uriScheme: 0x46,
27
+ charset: 0x47,
28
+ naturalLanguage: 0x48,
29
+ mimeMediaType: 0x49,
30
+ } as const;
31
+
32
+ /** The operation ids this package sends. Identify-Printer (0x003C) and Print-Job (0x0002) are the writes that come later. */
33
+ export const OPERATION = { getPrinterAttributes: 0x000b } as const;
34
+
35
+ const STRING_TAGS: ReadonlySet<number> = new Set([
36
+ TAG.textWithoutLanguage,
37
+ TAG.nameWithoutLanguage,
38
+ TAG.keyword,
39
+ TAG.uri,
40
+ TAG.uriScheme,
41
+ TAG.charset,
42
+ TAG.naturalLanguage,
43
+ TAG.mimeMediaType,
44
+ ]);
45
+
46
+ export type IppRange = { lower: number; upper: number };
47
+ export type IppResolution = {
48
+ x: number;
49
+ y: number;
50
+ units: "dpi" | "dpcm" | number;
51
+ };
52
+ /** A value whose tag this decoder does not read (a language-tagged string, a collection, an out-of-band marker), or whose length is wrong for its tag: the bytes, unchanged. */
53
+ export type IppUnknown = { tag: number; bytes: Uint8Array };
54
+ /**
55
+ * One decoded value. Integers and enums are numbers; every string tag is a string; a dateTime is an ISO 8601 string with
56
+ * its offset; an octetString is bytes. A collection decodes to one `IppUnknown` with no bytes, its members skipped.
57
+ */
58
+ export type IppValue =
59
+ | number
60
+ | boolean
61
+ | string
62
+ | Uint8Array
63
+ | IppRange
64
+ | IppResolution
65
+ | IppUnknown;
66
+
67
+ export type IppAttribute = { name: string; tag: number; values: IppValue[] };
68
+ export type IppGroup = { tag: number; attributes: IppAttribute[] };
69
+ /** A request or a response: `code` is the operation id of a request or the status code of a response. */
70
+ export type IppMessage = {
71
+ version: [number, number];
72
+ code: number;
73
+ requestId: number;
74
+ groups: IppGroup[];
75
+ data?: Uint8Array;
76
+ };
77
+
78
+ const utf8 = new TextEncoder();
79
+ const text = new TextDecoder();
80
+
81
+ const u16 = (n: number) => [(n >> 8) & 255, n & 255];
82
+ const u32 = (n: number) => [
83
+ (n >>> 24) & 255,
84
+ (n >>> 16) & 255,
85
+ (n >>> 8) & 255,
86
+ n & 255,
87
+ ];
88
+
89
+ function encodeValue(tag: number, value: IppValue): number[] {
90
+ if (STRING_TAGS.has(tag)) return [...utf8.encode(String(value))];
91
+ if (tag === TAG.integer || tag === TAG.enum) return u32(Number(value));
92
+ if (tag === TAG.boolean) return [value ? 1 : 0];
93
+ if (tag === TAG.rangeOfInteger)
94
+ return [
95
+ ...u32((value as IppRange).lower),
96
+ ...u32((value as IppRange).upper),
97
+ ];
98
+ if (tag === TAG.resolution) {
99
+ const r = value as IppResolution;
100
+ return [
101
+ ...u32(r.x),
102
+ ...u32(r.y),
103
+ r.units === "dpi" ? 3 : r.units === "dpcm" ? 4 : Number(r.units),
104
+ ];
105
+ }
106
+ if (tag === TAG.dateTime) {
107
+ const m = String(value).match(
108
+ /^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)\.(\d)([+-])(\d\d):(\d\d)$/,
109
+ );
110
+ if (!m)
111
+ throw new Error(
112
+ `ipp: a dateTime is YYYY-MM-DDTHH:MM:SS.d+HH:MM, got ${String(value)}`,
113
+ );
114
+ const n = m.slice(1).map((x, i) => (i === 7 ? x.charCodeAt(0) : Number(x)));
115
+ return [...u16(n[0]!), ...n.slice(1)];
116
+ }
117
+ if (value instanceof Uint8Array) return [...value];
118
+ if (typeof value === "object" && "bytes" in value) return [...value.bytes];
119
+ throw new Error(`ipp: cannot encode tag 0x${tag.toString(16)}`);
120
+ }
121
+
122
+ /** A message as bytes. Collections are not encoded (a Get-Printer-Attributes request has none). */
123
+ export function encodeMessage(m: IppMessage): Uint8Array<ArrayBuffer> {
124
+ const out: number[] = [...m.version, ...u16(m.code), ...u32(m.requestId)];
125
+ for (const g of m.groups) {
126
+ out.push(g.tag);
127
+ for (const a of g.attributes)
128
+ a.values.forEach((v, i) => {
129
+ const name = i === 0 ? utf8.encode(a.name) : new Uint8Array();
130
+ const value = encodeValue(a.tag, v);
131
+ out.push(
132
+ a.tag,
133
+ ...u16(name.length),
134
+ ...name,
135
+ ...u16(value.length),
136
+ ...value,
137
+ );
138
+ });
139
+ }
140
+ out.push(TAG.end);
141
+ return Uint8Array.from([...out, ...(m.data ?? [])]);
142
+ }
143
+
144
+ /** The Get-Printer-Attributes request: IPP 2.0, utf-8, English, the printer's `ipp://` URI, and the attributes wanted. */
145
+ export function getPrinterAttributesRequest(
146
+ printerUri: string,
147
+ requested: string[],
148
+ requestId = 1,
149
+ ): Uint8Array<ArrayBuffer> {
150
+ return encodeMessage({
151
+ version: [2, 0],
152
+ code: OPERATION.getPrinterAttributes,
153
+ requestId,
154
+ groups: [
155
+ {
156
+ tag: TAG.operation,
157
+ attributes: [
158
+ { name: "attributes-charset", tag: TAG.charset, values: ["utf-8"] },
159
+ {
160
+ name: "attributes-natural-language",
161
+ tag: TAG.naturalLanguage,
162
+ values: ["en"],
163
+ },
164
+ { name: "printer-uri", tag: TAG.uri, values: [printerUri] },
165
+ { name: "requested-attributes", tag: TAG.keyword, values: requested },
166
+ ],
167
+ },
168
+ ],
169
+ });
170
+ }
171
+
172
+ const fail = (why: string): never => {
173
+ throw new Error(`ipp: ${why}`);
174
+ };
175
+
176
+ function decodeValue(tag: number, bytes: Uint8Array): IppValue {
177
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.length);
178
+ const unknown = { tag, bytes };
179
+ if (STRING_TAGS.has(tag)) return text.decode(bytes);
180
+ if (tag === TAG.octetString) return bytes;
181
+ if (tag === TAG.integer || tag === TAG.enum)
182
+ return bytes.length === 4 ? view.getInt32(0) : unknown;
183
+ if (tag === TAG.boolean) return bytes.length === 1 ? bytes[0] !== 0 : unknown;
184
+ if (tag === TAG.rangeOfInteger)
185
+ return bytes.length === 8
186
+ ? { lower: view.getInt32(0), upper: view.getInt32(4) }
187
+ : unknown;
188
+ if (tag === TAG.resolution) {
189
+ if (bytes.length !== 9) return unknown;
190
+ const units = bytes[8]!;
191
+ return {
192
+ x: view.getInt32(0),
193
+ y: view.getInt32(4),
194
+ units: units === 3 ? "dpi" : units === 4 ? "dpcm" : units,
195
+ };
196
+ }
197
+ if (tag === TAG.dateTime) {
198
+ if (bytes.length !== 11) return unknown;
199
+ const p = (n: number) => String(n).padStart(2, "0");
200
+ const [mo, d, h, mi, s, ds, dir, uh, um] = [2, 3, 4, 5, 6, 7, 8, 9, 10].map(
201
+ (i) => bytes[i]!,
202
+ );
203
+ return `${view.getUint16(0)}-${p(mo!)}-${p(d!)}T${p(h!)}:${p(mi!)}:${p(s!)}.${ds}${String.fromCharCode(dir!)}${p(uh!)}:${p(um!)}`;
204
+ }
205
+ return unknown;
206
+ }
207
+
208
+ /**
209
+ * A message's bytes as groups of attributes. An unknown or malformed value is kept as `IppUnknown`, never thrown; only a
210
+ * message that cannot be walked (cut short, a value before any attribute) throws. A collection and its members are skipped.
211
+ */
212
+ export function decodeMessage(bytes: Uint8Array): IppMessage {
213
+ if (bytes.length < 9) fail("a message is at least 9 bytes");
214
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.length);
215
+ let i = 8;
216
+ const need = (n: number) =>
217
+ i + n <= bytes.length || fail("the message is cut short");
218
+ /** One attribute element at `i`: tag, name, value bytes. */
219
+ const element = () => {
220
+ need(3);
221
+ const tag = bytes[i++]!;
222
+ const nameLength = view.getUint16(i);
223
+ i += 2;
224
+ need(nameLength + 2);
225
+ const name = text.decode(bytes.subarray(i, i + nameLength));
226
+ i += nameLength;
227
+ const valueLength = view.getUint16(i);
228
+ i += 2;
229
+ need(valueLength);
230
+ const value = bytes.subarray(i, i + valueLength);
231
+ i += valueLength;
232
+ return { tag, name, value };
233
+ };
234
+ const groups: IppGroup[] = [];
235
+ let group: IppGroup | undefined;
236
+ let last: IppAttribute | undefined;
237
+ for (;;) {
238
+ need(1);
239
+ const tag = bytes[i]!;
240
+ if (tag === TAG.end) {
241
+ i++;
242
+ break;
243
+ }
244
+ if (tag < 0x10) {
245
+ i++;
246
+ groups.push((group = { tag, attributes: [] }));
247
+ last = undefined;
248
+ continue;
249
+ }
250
+ const e = element();
251
+ let value: IppValue;
252
+ if (e.tag === TAG.begCollection) {
253
+ for (let depth = 1; depth > 0;) {
254
+ const inner = element();
255
+ if (inner.tag === TAG.begCollection) depth++;
256
+ else if (inner.tag === TAG.endCollection) depth--;
257
+ }
258
+ value = { tag: e.tag, bytes: new Uint8Array() };
259
+ } else value = decodeValue(e.tag, e.value.slice());
260
+ if (!group) fail("an attribute before any group");
261
+ if (e.name === "") {
262
+ if (!last) fail("a further value before any attribute");
263
+ last!.values.push(value);
264
+ } else {
265
+ last = group!.attributes.find((a) => a.name === e.name);
266
+ if (last) last.values.push(value);
267
+ else
268
+ group!.attributes.push(
269
+ (last = { name: e.name, tag: e.tag, values: [value] }),
270
+ );
271
+ }
272
+ }
273
+ return {
274
+ version: [bytes[0]!, bytes[1]!],
275
+ code: view.getUint16(2),
276
+ requestId: view.getUint32(4),
277
+ groups,
278
+ ...(i < bytes.length ? { data: bytes.slice(i) } : {}),
279
+ };
280
+ }
281
+
282
+ /** Every attribute of the groups with this tag (default: the printer's), by name, repeats merged. */
283
+ export function attributesOf(
284
+ m: IppMessage,
285
+ groupTag: number = TAG.printer,
286
+ ): Record<string, IppValue[]> {
287
+ const out: Record<string, IppValue[]> = {};
288
+ for (const g of m.groups)
289
+ if (g.tag === groupTag)
290
+ for (const a of g.attributes) (out[a.name] ??= []).push(...a.values);
291
+ return out;
292
+ }
@@ -0,0 +1,46 @@
1
+ import type { Found } from "../../discover.ts";
2
+
3
+ /** One resolved mDNS instance, as `mdnsResolve` in src/finders.ts returns it. */
4
+ export type Answer = {
5
+ host: string;
6
+ port: number;
7
+ instance: string;
8
+ txt: Record<string, string>;
9
+ };
10
+
11
+ /** TXT keys are case-insensitive in DNS-SD; AirPrint's are written `UUID`, `ty`, `rp`. */
12
+ const lower = (txt: Record<string, string>) =>
13
+ Object.fromEntries(Object.entries(txt).map(([k, v]) => [k.toLowerCase(), v]));
14
+
15
+ /**
16
+ * AirPrint printers as found devices: the identity is TXT `UUID` (the instance name when absent), the name TXT `ty` (else
17
+ * the instance name), the model `ty`. `txt` keeps the whole record for the connector (`rp`, `pdl`, `URF`, `product`,
18
+ * `adminurl`, `note`). A printer on both types is one entry, the plain `_ipp._tcp` one (`ipp` listed first), so a
19
+ * self-signed certificate on `_ipps._tcp` never decides; a printer on `_ipps._tcp` alone is `secure`.
20
+ */
21
+ export function printersFound(ipp: Answer[], ipps: Answer[]): Found[] {
22
+ const seen = new Set<string>();
23
+ const out: Found[] = [];
24
+ for (const [secure, answers] of [
25
+ [false, ipp],
26
+ [true, ipps],
27
+ ] as const)
28
+ for (const a of answers) {
29
+ const txt = lower(a.txt);
30
+ const id = txt.uuid || a.instance;
31
+ const key = id.toLowerCase().replace(/[^0-9a-z]/g, "");
32
+ if (seen.has(key)) continue;
33
+ seen.add(key);
34
+ out.push({
35
+ connector: "printers",
36
+ id,
37
+ host: a.host,
38
+ port: a.port,
39
+ name: txt.ty || a.instance,
40
+ model: txt.ty,
41
+ secure,
42
+ txt,
43
+ });
44
+ }
45
+ return out;
46
+ }