@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,26 @@
1
+ # draw-home: references
2
+
3
+ Good drawing references to read before drawing a home, newest idea first. All are skills or kits for coding agents; read for craft, keep this skill's contract (`SKILL.md` section 3) where they disagree. Local clones live in `~/code/refs/`.
4
+
5
+ | Reference | What it is | Use it for | Licence |
6
+ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
7
+ | [iso-glow](https://isoglow.dev/) (April Zhu; local `~/code/refs/iso-glow/iso-glow/`) | A skill that turns a description of an object, device or building into one HTML file: a fine-line isometric drawing where active parts glow and interactive parts respond, with optional orbitable 3D | A live home view: devices that glow when on, pulses when a reading lands; "Glow kit" and "Lessons" sections | MIT |
8
+ | [Anatomy](https://skills.wheresryan.sh/anatomy) ([wheresryan22/anatomy](https://github.com/wheresryan22/anatomy); local `~/code/refs/anatomy/`) | Solid-figure isometric explanatory drawings, with an iso kit and six worked examples | An isometric cutaway of the house: craft rules, `kit/iso-kit.mjs` (in the Anatomy repo), `examples/desk-computer/` (in the Anatomy repo) | MIT |
9
+ | [Hairline](https://github.com/lucasmarkes/hairline) (local `~/code/refs/hairline/`) | Line-sketch drawings with a small kernel and a validator | A light, hand-drawn plan style | MIT |
10
+ | [ai-iso-skill](https://github.com/MrBongoC/ai-iso-skill) `iso-figure` (local `~/code/refs/ai-iso-skill/`) | Flat-faced isometric figures on a box, ~10 KB pages | The lightest isometric option | MIT |
11
+ | [casa-serra](https://github.com/Sam1983Aing/casa-serra) (Sam1983Aing; live demo linked from the README, three.js + GSAP ScrollTrigger + Lenis, no build step) | "A flat floor plan that becomes a house as you scroll": the plan "is not a picture. It is the real 3D model, cut at 1.2 m and seen from straight above through a very long lens"; scrolling raises the cut so the walls rise, rooms stay a line drawing until the camera reaches them, geometry built in code, a still and a clip for weak devices | The strongest version of the "SVG with a camera" option (`SKILL.md`): one model in metres, the flat plan as the top view of it, zoom and orbit as a redraw. Added by KO 2026-10-13. | No licence file (checked 2026-10-13): approach only, never paste |
12
+ | sfl-mes `device-drawing` (`~/code/sfl-mes/.claude/skills/device-drawing/`, private) | KO's skill for drawing one machine that moves with its live reading | Sources-first discipline, the live contract (no data, never clamp, fit the host), the review step | KO's |
13
+
14
+ Default stays a flat plan (`examples/home.svg`): it is the fastest to draw, reads at card size, and is easy for an app to draw state on. Pick iso-glow or Anatomy when the home view is the point rather than a panel beside it.
15
+
16
+ ## The casa-serra starting prompt (the author's, for the camera option)
17
+
18
+ The casa-serra author's own starting prompt, kept here because its last paragraph is the working method this skill also uses: propose before building, checkpoints with a recording, nothing finished that KO did not choose. Swap the brackets for the home.
19
+
20
+ > I want to build a scroll-driven landing page for [a house]. The idea: the page opens on a flat floor plan, like a sheet an architect prints. Black ink on paper, room names, dimensions. Nothing looks 3D. When the visitor scrolls, the walls rise out of the drawing, the camera comes down to the front door and walks through [the rooms you want, in order] at eye height. Each room stays a line drawing until the camera gets there, then the real materials sweep across it.
21
+ >
22
+ > What I care about: the floor plan is not a picture. It is the real 3D model, cut at about 1.2 m and seen from straight above through a very long lens. The drawing and the building are the same thing. The camera travels deep into the space, room after room; it does not orbit the building from outside. It should feel like [your level of ambition]: big rooms, real furniture, late afternoon light. three.js, GSAP ScrollTrigger and Lenis, plain files, no build step. Free CC0 materials for the textures.
23
+ >
24
+ > Before you write any code, propose the layout of the building with sizes I can picture, and the camera path stop by stop. Give me two or three directions to choose from and wait for my answer. Then build in checkpoints. First checkpoint only: the plan sheet, the walls rising, and the walk to the first room. Record it and show me before you go further.
25
+
26
+ For a home with live state, the plan sheet is `plan.svg` from the config and the walk's stops are the rooms with devices; the camera option in `SKILL.md` says how the state is drawn at each stop.
@@ -0,0 +1,82 @@
1
+ ---
2
+ name: draw-home
3
+ description: Draw a simple, themeable SVG plan of a home from photos, a floor plan or a description, plus the rooms in home.jsonc (the home config) that place devices, so an app can show each device's live state on it. Use when asked to draw, trace or update a house plan or home view for @khalido/home. One home per run.
4
+ ---
5
+
6
+ # Draw a home
7
+
8
+ The library does not draw. This guide is for the coding agent that does: turn what the person has (a floor plan PDF or photo, phone photos of rooms, a sketch, a description) into a drawing plus its entries in the home config, private when they describe a real house:
9
+
10
+ - `home.svg`: a plain plan, one group and one empty marker per room.
11
+ - `home.jsonc`: the home config (`src/config.ts`, `home.schema.json`). This skill writes its `plan.svg` and `rooms`, and a device's `room` or `marker`; the connectors' fields (addresses, ids, key names) belong to whoever sets the device up. `checkPlan` checks the result.
12
+
13
+ Start from `examples/home.svg` and `examples/home.jsonc`. Never start from a blank file. If the person already has a `home.jsonc`, edit its `rooms` and `plan` in place, keeping its comments, and leave every other field alone.
14
+
15
+ This is the home-sized version of the sfl-mes `device-drawing` skill (`~/code/sfl-mes/.claude/skills/device-drawing/`), which draws single machines; read it when a rule here seems thin.
16
+
17
+ ## 1. Sources, before any geometry
18
+
19
+ Make a **room table** first, in the SVG's header comment: each room, its floor, its name as the person says it, and the source (plan sheet and page, or photo). Nothing is drawn that has no row.
20
+
21
+ - **The plan first, at full resolution**: a builder's or renovation plan beats photos for layout. Photos settle names, doors and where devices sit.
22
+ - **Unknown stays unknown.** A room the source leaves unlabelled gets a neutral id (`room-unnamed-a`) and a question to the person. No furniture, fittings, dimensions or decoration to make it look rich.
23
+ - **Devices from the person or from the connectors** (`home discover`, Hue's room list), never guessed from a photo of a lamp.
24
+ - **Not to scale is fine**; say so. If a plan is old, say the house as built may differ.
25
+
26
+ ## 2. Draw
27
+
28
+ - Rooms as rectangles, or simple paths for L-shapes; walls, the front door, stairs. Front of the house in the same place on every floor; one floor per band of the canvas, labelled, ground below.
29
+ - **Style: a flat plan by default.** `README.md` beside this file lists the drawing references (iso-glow, Anatomy, Hairline, iso-figure) with what each is good for.
30
+ - **Isometric options.** A glowing fine-line drawing in the [iso-glow](https://isoglow.dev/) style suits a live view; an isometric cutaway in the **Anatomy** style (solid figures, MIT, `~/code/refs/anatomy/`, [skills.wheresryan.sh/anatomy](https://skills.wheresryan.sh/anatomy)) is an option when the person asks for it: read its `SKILL.md` craft rules and `examples/desk-computer/` (in the Anatomy repo), keep this contract, and draw rooms as low boxes with the markers on their floors.
31
+
32
+ ## 3. The contract (an app draws on this; keep it exact)
33
+
34
+ 1. Each room is `<g class="room" id="room-…" data-floor="…" data-name="…">` with its shape and label. Ids are lowercase, stable, never renamed once devices refer to them.
35
+ 2. Each room holds one empty `<g class="marker" id="marker-…" transform="translate(x,y)">` at its top-right inner corner. Apps draw state there at the origin and never move it.
36
+ 3. `currentColor` and low-alpha fills, so a page that inlines the SVG themes it; a `<style>` on `svg:root` colours it when opened alone; `prefers-color-scheme` for dark. One file, no external loads.
37
+ 4. A header comment: source, date, not to scale, the room table, and this contract. `role="img"` and an `aria-label` on the root.
38
+ 5. The SVG fills its host (`width: 100%; height: 100%`; the viewBox holds everything).
39
+ 6. **Zoomable**: one root `<g id="plan">` holding every floor, no fixed pixel sizes, labels sized so they still read when a single room fills the screen. Each room group's `getBBox()` is its zoom target, so keep a room's label and marker inside its shape.
40
+
41
+ **What an app does with it** (from sfl-mes's live contract): it draws each device's state into its room's marker from `home.snapshot()`. A missing part is "no data": grey marker, no value, never a stale number shown as live. Values are shown as read, never clamped; odd ones (`Part.anomaly`) get a flag. Hover names the device. Controls live in the app's side panel, not in the SVG.
42
+
43
+ **Energy on the plan** (the numbers are the library's, the drawing and animation are the app's):
44
+
45
+ - **Solar.** The Enphase device's `marker` in `home.jsonc` names a `<g class="marker" id="marker-…">` outside any room, where the array is drawn on the roof (the panels as a group of low-alpha rects, the marker at its top-right corner). The app glows the panels from `solarLevel(energyNow(…), capacityW)`: against the device's `capacityW` (rated watts, from the config, never guessed) when set, else against today's highest reading so far (`solarPeakTodayW`). The two scales mean different things, so the drawing says which ("of 6.6 kW" or "of today's peak"). The level is never clamped: above 1 is drawn as full and labelled as read. "No data" when `solarW` is undefined.
46
+ - **Flow.** Lines run panels → house while `solarW` is above zero, and house ↔ grid from the outer wall to the plan's edge, animated in the direction of `energyNow.flow`: outward while `"exporting"`, inward while `"importing"`, still while `"balanced"` (the library's 20 W display band). The speed or density scales with `surplusW` when exporting and `gridW` when importing. "No data" (no animation, grey line) when `gridW` is undefined; never a last value shown as live.
47
+ - Labels beside the lines come from the same object: "exporting 1.2 kW, earning 42 c/h" from `surplusW` and `surplusCentsPerHour`, never recomputed in the app.
48
+
49
+ **Interactive, in the app**: wheel or pinch zooms and drag pans (animate the `viewBox`, or a transform on `#plan`); clicking a room zooms to its bounding box and opens that room's devices in the side panel; Escape or double-click returns to the whole house; arrow keys move between rooms. Respect reduced motion (jump, do not animate). A flat plan only zooms and pans.
50
+
51
+ **SVG with a camera** (the interactive option, Anatomy's approach and sfl-mes's "spin"): build the house once as a model in metres (each room a box: x, y, width, depth, floor height; walls and doors on it), and render it to SVG through a camera `{ azimuth, elevation, target, zoom }` with Anatomy's `fitProjection` (`~/code/refs/anatomy/kit/iso-kit.mjs`). The scene is a pure function of the camera, so zooming, orbiting and "fly to this room" are a redraw with a new camera, eased; drag orbits, wheel zooms, click a room sets the target to its centre. The rendered SVG still meets the contract above: one `<g class="room">` per room and a marker projected to the room's top corner on every redraw. Keep the model in its own file named by `plan.json` in the config (sizes are data, not hard-coded in the drawing), and spring back to the default camera on Escape.
52
+
53
+ ## 4. home.jsonc
54
+
55
+ ```jsonc
56
+ {
57
+ "$schema": "<path to home.schema.json>",
58
+ "plan": { "svg": "home.svg" },
59
+ "rooms": [
60
+ // id matches <g class="room" id="…">; hueRoom: the Hue room whose lights this room's marker shows, once known
61
+ {
62
+ "id": "room-living",
63
+ "name": "Living",
64
+ "floor": "ground",
65
+ "hueRoom": "<hue room id>",
66
+ },
67
+ ],
68
+ // devices: set `room` on one that already exists; adding a device is the connector's job, not this skill's
69
+ }
70
+ ```
71
+
72
+ Room ids start with `room-`; names are what the person calls them (an unlabelled room keeps `""` until they say). No `room` on whole-house devices (solar, prices). The solar array is drawn on the roof with its own marker; set the Enphase device's `marker` to its id, and `capacityW` only when the person gives the rated size. Lights are placed through the room's `hueRoom`, not as devices. `marker` in the config always means an SVG marker id.
73
+
74
+ ## 5. Check before hand-over
75
+
76
+ - [ ] Every room-table row is drawn or listed as left out; every room has a marker; ids match the config's `rooms`; `checkPlan` reports nothing and `home config check` runs clean (`test/config.test.ts` shows the SVG check).
77
+ - [ ] Open it in a browser, light and dark, at 320 px and 900 px wide: labels readable, nothing overlaps, no scroll.
78
+ - [ ] Side by side with the source plan: same rooms, same order, same doors.
79
+ - [ ] **Review**: a second agent with fresh context gets the source, the room table and the SVG (not your report), checks each row, and reports `wrong`, `guessed`, `ok`. Open three of its findings yourself.
80
+ - [ ] Private files stay private: a real `home.svg` and `home.jsonc` live beside the person's settings, never in this repo or any public one.
81
+
82
+ Hand over: the two paths, the room table's open questions, and what could not be verified.
@@ -0,0 +1,86 @@
1
+ ---
2
+ name: use-home
3
+ description: Use the `home` command and the @khalido/home library to see and run a home - lights, AC, solar, prices, weather, speakers, printers, household spending. Covers connecting (keys, addresses, discovery on macOS and Linux), reading safely, the JSON and exit codes, what each status means, and the rules for writes. Use when asked about the house ("is the AC on?", "what's solar doing?", "will it rain at pickup?"), when running a scheduled check of the home, or when setting `home` up on a new machine.
4
+ ---
5
+
6
+ # Use home
7
+
8
+ `home` reads the house from the machine it runs on: devices on the LAN directly, cloud services (Amber, Up, weather) over HTTPS. There is no server and no cloud of ours in between. Read freely; write only when a person asks.
9
+
10
+ ## Rules
11
+
12
+ - **Reads are free.** `home`, `home <connector>`, `home discover`, `home probe`, `home up spend` and every `--json` read touch nothing.
13
+ - **Writes only on request.** Lights, AC, speakers, Up categories and tags, webhooks: run one only when the person asked for that change, then read back and report the state it left. Never write to "test" something.
14
+ - **Never print a key, token or password**, not even part of one. Say "set" or "unset". Env names are fine; values are not.
15
+ - **Nothing private into a public repo**: no IPs, MACs, serials, device or room names, Up transactions or balances. A real `home.jsonc`, `home.svg` and floor plan live in a private folder.
16
+ - **Show values as read.** A missing field is unknown, not zero. An `anomaly` is something odd the device said: report it, never smooth it.
17
+
18
+ ## Reading
19
+
20
+ ```sh
21
+ home # every connector, one line each, energy now on top; exits 0
22
+ home weather # one connector; exits 1 when it is not connected
23
+ home weather --json | jq .blocks.today
24
+ home up spend --json # household spending this window, by category
25
+ home --help # every command and verb
26
+ ```
27
+
28
+ The contract (in `docs/cli.md`): stdout is data, stderr everything else. `--json` errors are `{"error", "code", "fix"}` on stderr; `fix` is the one command that resolves it. Exit 0 success (empty included), 1 a failure or a connector not connected, 2 a wrong command or argument.
29
+
30
+ What a status means:
31
+
32
+ | Status | Meaning | What to do |
33
+ | -------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------- |
34
+ | `connected` | read and current | use it |
35
+ | `connecting` | still starting (a 15 s cap in the CLI) | wait, or run again |
36
+ | `disconnected` | the device or service did not answer; the last good state is kept, with its `asOf` | say so; check the device is on and at its address |
37
+ | `unavailable` | stopped for good: a key rejected, a setting wrong | the `detail` names it; fix the key or setting |
38
+ | `not set up` | env names or a file missing (`needs` lists them) | set them, or say what is needed |
39
+
40
+ Weather: `blocks` holds morning, afternoon and evening for today and tomorrow in the home's time zone; `rainSoon` says when rain starts in the next two hours (needs the Google key). Energy: `now` in `home --json` is the one owner of solar, use, grid flow and cost per hour; never recompute it.
41
+
42
+ ## Connecting
43
+
44
+ **Keys** come from environment variables by name (`.env.example` lists them; Bun loads `.env` from the working directory). A real environment variable beats `.env`. Some keys come from a command, not a website:
45
+
46
+ - Hue: press the bridge's button, then `home pair` within 30 s; it prints the env lines. Set `HUE_CA_FILE` to Signify's root CA (`src/connectors/hue/README.md`).
47
+ - Enphase: `home enphase login` once a year writes the owner token to `~/.config/home/enphase-token.json` (0600).
48
+ - Amber, Up, Google Weather: a token from each service's website.
49
+ - Spotify (Soloist): no key in `home`; Soloist's own API key is the daemon's. Set `SOLOIST_WS` to the address its `--ws` opens. Signed out is normal until someone picks the speaker in the Spotify app.
50
+
51
+ Copying a working `.env` (and the CA file) to another machine works: the keys are not tied to the machine.
52
+
53
+ **A config instead of env**: `--config home.jsonc` or `HOME_CONFIG`. It names devices by identity, rooms and the location; secrets stay env names. `home config check` shows what it would set up and every problem, secrets only as set or unset.
54
+
55
+ ## Addresses
56
+
57
+ - **Reserve them.** Give the Hue bridge, the Enphase gateway, the AC and the printers a DHCP reservation in the router. Then the config's or env's addresses stay right and discovery is only a fallback.
58
+ - **Devices are matched by identity, not address**: Hue's bridge id, the Daikin's MAC, the gateway's serial, a Cast id. A device that moves is found again by discovery after failed reads; the detail says "moved from a to b".
59
+ - **The address cache** (`~/.cache/home/addresses.json`, `home cache`, `home cache clear`) keeps what discovery found between runs.
60
+ - `home discover` lists what answers on the network and which `home` supports; with a config, `home discover --write` updates moved addresses and new devices in it.
61
+
62
+ ## Discovery (mDNS and DNS-SD)
63
+
64
+ Hue, Enphase, Cast and printers announce themselves over mDNS. `home` asks the system's own mDNS daemon rather than listening itself:
65
+
66
+ - **macOS**: `dns-sd`, built in.
67
+ - **Linux** (Raspberry Pi OS): `avahi-browse`. avahi-daemon runs by default; the command is a package: `sudo apt install avahi-utils`. Without it, discovery says "avahi-browse not found".
68
+ - **Daikin** answers a UDP broadcast on port 30050, on both.
69
+
70
+ If discovery finds nothing: the machine must be on the same network and subnet as the devices (not a guest network, not a separate VLAN), multicast must pass (no "multicast optimisation" or client isolation on the access point), and a firewall must allow mDNS (UDP 5353). `home probe` checks each device read-only at its address, with no keys.
71
+
72
+ ## From code
73
+
74
+ ```ts
75
+ import { createHome, loadHome } from "@khalido/home";
76
+ const home = createHome(loadHome(config, options));
77
+ await home.start(signal); // runs until signal aborts
78
+ home.part("weather")?.state?.blocks; // typed by name; never touches a device
79
+ await home.refresh("daikin"); // read now; resolves when it has reported
80
+ ```
81
+
82
+ `docs/using.md` covers wiring it into an app (Bun and Node). Automations ("if X then Y") belong in the app, not the library: the library gives the facts (`sunNow`, `rainSoon`, `dayBlocks`, `energyNow`) and the writes.
83
+
84
+ ## A scheduled check
85
+
86
+ A routine that runs `home --json` periodically should: report what changed or looks wrong (a connector down, an anomaly, a token close to expiry, rain soon); stay silent when all is well; never write; never quote keys or addresses into a message.
@@ -0,0 +1,29 @@
1
+ // `home cache [clear]`: the addresses kept between runs, or delete them so the next run discovers afresh.
2
+ import { rmSync } from "node:fs";
3
+ import {
4
+ addresses,
5
+ CACHE_FILE,
6
+ type Options,
7
+ printJson,
8
+ usage,
9
+ } from "./context.ts";
10
+
11
+ export async function cacheCommand(
12
+ verb: string | undefined,
13
+ o: Options,
14
+ ): Promise<void> {
15
+ if (verb === "clear")
16
+ return (
17
+ rmSync(CACHE_FILE, { force: true }),
18
+ console.log(`deleted ${CACHE_FILE}`)
19
+ );
20
+ if (verb !== undefined) usage("usage: home cache [clear]");
21
+ const entries = addresses.cache.read();
22
+ if (o.json) return printJson(entries);
23
+ console.log(CACHE_FILE);
24
+ for (const [key, c] of Object.entries(entries))
25
+ console.log(
26
+ ` ${key}${c.name ? ` (${c.name})` : ""}: ${c.host}${c.port === undefined ? "" : `:${c.port}`} · seen ${c.seenAt}`,
27
+ );
28
+ if (!Object.keys(entries).length) console.log(" empty");
29
+ }
@@ -0,0 +1,172 @@
1
+ // `home cast announce|volume|mute|stop …`: one write to a Cast device or group, by its name or castId. `announce` plays a
2
+ // clip (macOS `say`, or a file) and puts back what was playing and the volume; another app it interrupts cannot be resumed, and it says so.
3
+ import { spawnSync } from "node:child_process";
4
+ import { randomUUID } from "node:crypto";
5
+ import { readFileSync, rmSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { extname, join } from "node:path";
8
+ import {
9
+ cast,
10
+ type CastSnapshot,
11
+ resolvedFrom,
12
+ } from "../connectors/cast/index.ts";
13
+ import { serveClip } from "../connectors/cast/serve.ts";
14
+ import { tlsConnect } from "../connectors/cast/tls.ts";
15
+ import { addressBook } from "../discover.ts";
16
+ import { FINDERS } from "../finders.ts";
17
+ import type { Part } from "../home.ts";
18
+ import {
19
+ addresses,
20
+ expandHome,
21
+ fail,
22
+ printJson,
23
+ text,
24
+ usage,
25
+ type Verbs,
26
+ } from "./context.ts";
27
+
28
+ /** Every Cast device and group, read once: what `home status` and `home probe` run without a config. Cached addresses first; discovery when there are none or one fails. */
29
+ export function castNow() {
30
+ const book = addressBook(addresses.cache);
31
+ const expect = Object.entries(book.entries()).flatMap(([key, c]) =>
32
+ key.startsWith("cast:") && c.port !== undefined
33
+ ? [
34
+ {
35
+ castId: key.slice(5),
36
+ name: c.name ?? key.slice(5),
37
+ host: c.host,
38
+ port: c.port,
39
+ },
40
+ ]
41
+ : [],
42
+ );
43
+ return cast({
44
+ resolve: async () => resolvedFrom(await FINDERS.cast()),
45
+ connect: tlsConnect,
46
+ expect,
47
+ seen: (id, a) => book.seen("cast", id, a),
48
+ serve: serveClip,
49
+ });
50
+ }
51
+
52
+ /** Open every Cast socket, wait until each has answered (at most 10 s), run one write over them, then close them all. */
53
+ async function withCast<T>(
54
+ act: (c: ReturnType<typeof castNow>) => Promise<T>,
55
+ ): Promise<T> {
56
+ const c = castNow();
57
+ const stop = new AbortController();
58
+ let last: Part<CastSnapshot> | undefined;
59
+ const running = c.run(stop.signal, (p) => (last = p));
60
+ try {
61
+ for (
62
+ const until = Date.now() + 10_000;
63
+ Date.now() < until && (last?.status ?? "connecting") === "connecting";
64
+ )
65
+ await Bun.sleep(50);
66
+ return await act(c);
67
+ } finally {
68
+ stop.abort();
69
+ await running;
70
+ }
71
+ }
72
+
73
+ export const CAST_USAGE =
74
+ 'usage: home cast announce --to <name> (--say "<text>" | --file <path>) [--volume 0-100] | volume <name> <0-100> | mute <name> on|off | stop <name>';
75
+
76
+ /** A 0–100 argument as the 0–1 a Cast device takes. */
77
+ function percentLevel(value: string | undefined): number {
78
+ const n = Number(value);
79
+ if (value === undefined || value.trim() === "" || !(n >= 0 && n <= 100))
80
+ usage(CAST_USAGE);
81
+ return n / 100;
82
+ }
83
+
84
+ /** What a clip file plays as, by its extension; the Default Media Receiver plays these, not AIFF. */
85
+ const AUDIO_TYPES: Record<string, string> = {
86
+ ".mp3": "audio/mpeg",
87
+ ".m4a": "audio/mp4",
88
+ ".mp4": "audio/mp4",
89
+ ".aac": "audio/aac",
90
+ ".wav": "audio/wav",
91
+ ".ogg": "audio/ogg",
92
+ ".flac": "audio/flac",
93
+ };
94
+
95
+ /** The options `home cast` takes, beyond the global ones. Strings stay strings: "007" is a name, not 7. */
96
+ export const CAST_OPTIONS: [string, string, { type: unknown[] }?][] = [
97
+ [
98
+ "--to <name>",
99
+ "announce: the device or group, by name or castId",
100
+ { type: [String] },
101
+ ],
102
+ ["--say <text>", "announce: words spoken by macOS `say`", { type: [String] }],
103
+ ["--file <path>", "announce: an audio file to play", { type: [String] }],
104
+ ["--volume <0-100>", "announce: the volume for the clip"],
105
+ ];
106
+
107
+ export const castVerbs: Verbs = {
108
+ async volume([name, value]) {
109
+ if (!name) usage(CAST_USAGE);
110
+ const level = percentLevel(value);
111
+ await withCast((c) => c.setVolume(name!, level));
112
+ },
113
+ async mute([name, value]) {
114
+ if (!name || (value !== "on" && value !== "off")) usage(CAST_USAGE);
115
+ await withCast((c) => c.setMuted(name!, value === "on"));
116
+ },
117
+ async stop([name]) {
118
+ if (!name) usage(CAST_USAGE);
119
+ await withCast((c) => c.stop(name!));
120
+ },
121
+ async announce(_, o) {
122
+ const [to, say, file] = [text(o.to), text(o.say), text(o.file)];
123
+ // cac gives "true" for a flag with no value: `--say` alone must not speak the word "true", so each flag needs a value after it.
124
+ const bare = ["--to", "--say", "--file"].some((f) =>
125
+ process.argv.some(
126
+ (a, i) =>
127
+ a === f &&
128
+ (process.argv[i + 1] === undefined ||
129
+ process.argv[i + 1]!.startsWith("--")),
130
+ ),
131
+ );
132
+ if (bare) usage(CAST_USAGE);
133
+ if (!to || !say === !file) usage(CAST_USAGE);
134
+ const volume =
135
+ o.volume === undefined ? undefined : percentLevel(text(o.volume));
136
+ let type: string;
137
+ let bytes: Uint8Array;
138
+ if (file) {
139
+ type =
140
+ AUDIO_TYPES[extname(file).toLowerCase()] ??
141
+ fail(
142
+ `${file}: not a type the Default Media Receiver plays (${Object.keys(AUDIO_TYPES).join(" ")})`,
143
+ );
144
+ bytes = readFileSync(expandHome(file));
145
+ } else {
146
+ // AAC in an MP4 container, which the Default Media Receiver plays (say's default AIFF it does not). Without
147
+ // --data-format=aac, m4af holds uncompressed PCM (afinfo, macOS 27.0).
148
+ const tmp = join(tmpdir(), `home-say-${randomUUID()}.m4a`);
149
+ try {
150
+ const said = spawnSync(
151
+ "say",
152
+ ["-o", tmp, "--file-format=m4af", "--data-format=aac", say!],
153
+ { stdio: ["ignore", "ignore", "pipe"] },
154
+ );
155
+ if (said.error || said.status !== 0)
156
+ fail(`say: ${said.error?.message ?? said.stderr.toString().trim()}`);
157
+ bytes = readFileSync(tmp);
158
+ } finally {
159
+ rmSync(tmp, { force: true });
160
+ }
161
+ type = "audio/mp4";
162
+ }
163
+ const r = await withCast((c) =>
164
+ c.announce(to!, { bytes, type }, { volume }),
165
+ );
166
+ if (o.json) return printJson(r);
167
+ console.log(r.played ? "played" : `not played: ${r.why}`);
168
+ if (r.restored.length) console.log(`restored: ${r.restored.join(", ")}`);
169
+ for (const n of r.notRestored)
170
+ console.log(`not restored: ${n.name}: ${n.why}`);
171
+ },
172
+ };
@@ -0,0 +1,52 @@
1
+ // `home config check [path]`: validate a home config and print what it would set up, secrets as set or unset by name.
2
+ import { existsSync } from "node:fs";
3
+ import { checkHome } from "../config.ts";
4
+ import { manifestOf } from "../manifests.ts";
5
+ import {
6
+ configPathOf,
7
+ env,
8
+ fromConfig,
9
+ type Options,
10
+ printJson,
11
+ readConfig,
12
+ usage,
13
+ } from "./context.ts";
14
+
15
+ export async function configCommand(
16
+ verb: string | undefined,
17
+ file: string | undefined,
18
+ o: Options,
19
+ ): Promise<void> {
20
+ const path = file ?? configPathOf(o);
21
+ if (verb !== "check" || !path)
22
+ usage(
23
+ "usage: home config check [path] (or set HOME_CONFIG, or pass --config)",
24
+ );
25
+ const { config, setup, problems } = checkHome(readConfig(path!), env);
26
+ const files = [
27
+ ...Object.entries(config?.plan ?? {}).flatMap(([k, v]) =>
28
+ typeof v === "string" ? [[`plan.${k}`, v]] : [],
29
+ ),
30
+ ...setup.flatMap((s) =>
31
+ (manifestOf(s.name)?.files ?? [])
32
+ .filter((k) => typeof s.settings[k] === "string")
33
+ .map((k) => [`${s.from}.${k}`, s.settings[k] as string]),
34
+ ),
35
+ ];
36
+ for (const [field, p] of files)
37
+ if (!existsSync(fromConfig(path!, p!)))
38
+ problems.push(`${field}: no file ${p}`);
39
+ if (o.json) return printJson({ setup, problems });
40
+ for (const s of setup) {
41
+ const parts = [
42
+ `from ${s.from}`,
43
+ ...Object.entries(s.settings).map(([k, v]) => `${k} ${v}`),
44
+ ...Object.entries(s.secrets).map(([k, v]) => `${k} ${v}`),
45
+ ];
46
+ console.log(`${s.name.padEnd(9)}${parts.join(" · ")}`);
47
+ }
48
+ console.log(
49
+ problems.length ? problems.map((p) => `problem: ${p}`).join("\n") : "ok",
50
+ );
51
+ if (problems.length) process.exitCode = 1;
52
+ }
@@ -0,0 +1,118 @@
1
+ // What every `home` command shares: the shell's environment, the config path, the address cache, and how to fail.
2
+ // src/cli/ is the one place in src/ that reads the environment (names in .env.example); the library takes parameters.
3
+ import { readFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import type { AddressCache, CachedAddress } from "../discover.ts";
7
+ import { parseJsonc } from "../config.ts";
8
+ import { isTimeZone } from "../clock.ts";
9
+ import { fileCache } from "../finders.ts";
10
+
11
+ export const env = process.env;
12
+
13
+ /** What went wrong, coarsely, as `--json` errors carry it: `usage` exits 2, the rest 1. */
14
+ export type ErrorCode = "usage" | "auth" | "network" | "error";
15
+
16
+ /** An error `home` reports in one line, or as `{ error, code, fix }` under `--json`. `fix` is the one command that resolves it. */
17
+ export class CliError extends Error {
18
+ constructor(
19
+ message: string,
20
+ readonly code: ErrorCode = "error",
21
+ readonly fix?: string,
22
+ ) {
23
+ super(message);
24
+ }
25
+ }
26
+
27
+ /** Stop the command. `status` catches it per integration, so one failing connector never hides the others. */
28
+ export const fail = (
29
+ message: string,
30
+ code?: ErrorCode,
31
+ fix?: string,
32
+ ): never => {
33
+ throw new CliError(message, code, fix);
34
+ };
35
+ /** A wrong command, verb or argument: the usage line, exit 2. */
36
+ export const usage = (message: string): never => fail(message, "usage");
37
+
38
+ /** The options cac parses: the global ones, and each command's own. */
39
+ export type Options = {
40
+ json?: boolean;
41
+ config?: string;
42
+ write?: string | true;
43
+ passes?: number | string;
44
+ to?: unknown;
45
+ say?: unknown;
46
+ file?: unknown;
47
+ volume?: number | string;
48
+ };
49
+
50
+ /** An option's text as typed: cac turns a number-like value into a number, and `type: [String]` into an array. */
51
+ export const text = (v: unknown): string | undefined =>
52
+ v === undefined ? undefined : String(Array.isArray(v) ? v.at(-1) : v);
53
+
54
+ /** `--write [path]`: undefined when absent, "" when bare (the config from --config or HOME_CONFIG), else the path. */
55
+ export const writeTo = (o: Options) =>
56
+ o.write === undefined ? undefined : o.write === true ? "" : String(o.write);
57
+
58
+ /** `~/` as the home folder: neither Bun's .env loading nor the shell (inside quotes) expands it. */
59
+ export const expandHome = (p: string) => p.replace(/^~(?=\/)/, homedir());
60
+
61
+ /** The home config (JSONC) from --config or HOME_CONFIG; without one, `status` and `probe` read env names. */
62
+ export const configPathOf = (o: Options) => {
63
+ const p = o.config ?? env.HOME_CONFIG;
64
+ return p ? expandHome(p) : undefined;
65
+ };
66
+
67
+ /** A path from the config, against the config's folder; `~/` is the home folder. */
68
+ export const fromConfig = (path: string, p: string) =>
69
+ resolve(dirname(path), expandHome(p));
70
+
71
+ export const readConfig = (path: string): unknown =>
72
+ parseJsonc(readFileSync(path, "utf8"));
73
+
74
+ /** The owner token's cache: written only by `home enphase login`, owner-only. */
75
+ export const TOKEN_FILE = join(
76
+ homedir(),
77
+ ".config",
78
+ "home",
79
+ "enphase-token.json",
80
+ );
81
+
82
+ /** Where discovered addresses are kept between runs (`home cache`), owner-only. */
83
+ export const CACHE_FILE = join(
84
+ env.XDG_CACHE_HOME || join(homedir(), ".cache"),
85
+ "home",
86
+ "addresses.json",
87
+ );
88
+
89
+ /** The file cache, written once as `home` exits, with the latest addresses. */
90
+ export const addresses = (() => {
91
+ const file = fileCache(CACHE_FILE);
92
+ let pending: Record<string, CachedAddress> | undefined;
93
+ const cache: AddressCache = {
94
+ read: () => file.read(),
95
+ write: (entries) => void (pending = entries),
96
+ };
97
+ return { cache, flush: () => pending && file.write(pending) };
98
+ })();
99
+
100
+ /** HOME_TIMEZONE, the env form of the config's `location.timezone`; this machine's zone when unset. */
101
+ export function homeTimeZone(): string | undefined {
102
+ const zone = env.HOME_TIMEZONE;
103
+ return zone === undefined || isTimeZone(zone)
104
+ ? zone
105
+ : fail(
106
+ `HOME_TIMEZONE: an IANA time zone, e.g. Australia/Sydney, not ${zone}`,
107
+ );
108
+ }
109
+
110
+ /** Print as JSON, two-space indented. */
111
+ export const printJson = (value: unknown) =>
112
+ console.log(JSON.stringify(value, null, 2));
113
+
114
+ /** A connector's verbs beyond its status (`home cast announce …`): each takes the words after the verb and the options. */
115
+ export type Verbs = Record<
116
+ string,
117
+ (args: string[], o: Options) => Promise<unknown>
118
+ >;