@khalido/home 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -1
- package/README.md +11 -11
- package/dist/config.js +3 -1
- package/dist/connectors/cast/index.d.ts +4 -1
- package/dist/connectors/cast/index.js +10 -2
- package/dist/connectors/cast/schemas.d.ts +2 -0
- package/dist/connectors/cast/schemas.js +1 -0
- package/dist/connectors/cast/serve.d.ts +8 -1
- package/dist/connectors/cast/state.d.ts +4 -0
- package/dist/connectors/cast/state.js +12 -0
- package/dist/connectors/cast/tls.js +1 -1
- package/dist/connectors/cast/writes.d.ts +9 -0
- package/dist/connectors/cast/writes.js +42 -6
- package/dist/connectors/spotify/changes.d.ts +1 -1
- package/dist/connectors/spotify/changes.js +4 -2
- package/dist/connectors/spotify/client.d.ts +1 -1
- package/dist/connectors/spotify/index.d.ts +3 -1
- package/dist/connectors/spotify/index.js +114 -45
- package/dist/connectors/up/index.d.ts +4 -2
- package/dist/connectors/up/index.js +2 -1
- package/dist/connectors/weather/index.js +16 -3
- package/dist/discover.d.ts +5 -1
- package/dist/discover.js +12 -3
- package/dist/energy.js +1 -1
- package/dist/finders.d.ts +3 -0
- package/dist/finders.js +6 -3
- package/dist/lane.js +24 -20
- package/docs/cast.md +1 -1
- package/docs/cli.md +5 -3
- package/docs/config.md +2 -2
- package/docs/decisions.md +1 -0
- package/docs/google.md +1 -1
- package/package.json +2 -2
- package/skills/draw-home/README.md +5 -6
- package/skills/draw-home/SKILL.md +3 -5
- package/src/cli/cast.ts +19 -11
- package/src/cli/discover.ts +8 -1
- package/src/cli/spotify.ts +2 -1
- package/src/config.ts +3 -1
- package/src/connectors/cast/README.md +7 -5
- package/src/connectors/cast/index.ts +19 -5
- package/src/connectors/cast/schemas.ts +1 -0
- package/src/connectors/cast/serve.ts +11 -1
- package/src/connectors/cast/state.ts +12 -0
- package/src/connectors/cast/tls.ts +1 -1
- package/src/connectors/cast/writes.ts +58 -9
- package/src/connectors/daikin/README.md +1 -1
- package/src/connectors/spotify/README.md +23 -8
- package/src/connectors/spotify/changes.ts +4 -2
- package/src/connectors/spotify/client.ts +1 -1
- package/src/connectors/spotify/index.ts +120 -48
- package/src/connectors/up/README.md +1 -1
- package/src/connectors/up/index.ts +5 -2
- package/src/connectors/weather/README.md +1 -1
- package/src/connectors/weather/index.ts +19 -3
- package/src/discover.ts +14 -6
- package/src/energy.ts +1 -1
- package/src/finders.ts +8 -5
- package/src/lane.ts +23 -20
package/docs/cli.md
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
```sh
|
|
4
4
|
home <connector> [--json] # one connector's lines: amber, cast, daikin, enphase, hue, printers, spotify, up, weather
|
|
5
5
|
home [status|doctor] [--json] # each integration: connected or not, which env names it still needs, rooms
|
|
6
|
+
home up transactions | categories | uncategorised | spend | categorise | tag | webhooks # Up Bank's lists and reports; categorise and tag write
|
|
6
7
|
home lights off # one write to the whole-home group
|
|
7
8
|
home room <name> on|off|<1-100>
|
|
8
9
|
home scene <name>
|
|
@@ -12,18 +13,19 @@ home discover [--json] [--passes n] # which smart-home devices answer here (mDN
|
|
|
12
13
|
home cache [clear] # the addresses kept between runs, or delete them so the next run discovers afresh
|
|
13
14
|
home config check [path] # validate a home config: what it would set up, secrets as set or unset by name, every problem
|
|
14
15
|
home discover --write [path] # with a config (the path, else --config or HOME_CONFIG): match what answers by identity, add new devices, update moved hosts and renamed Cast names in place
|
|
15
|
-
home probe # read-only, no keys: Hue's public config, Daikin by UDP broadcast, the IQ Gateway's /info, each weather source, each Cast device's status
|
|
16
|
+
home probe # read-only, no keys: Hue's public config, Daikin by UDP broadcast, the IQ Gateway's /info, each weather source, each Cast device's and printer's status
|
|
16
17
|
home locate "<street address>" [--write [path]] # latitude and longitude from OpenStreetMap's Nominatim; --write sets them in the config's location (and timezone, when absent, to this machine's)
|
|
17
|
-
home cast announce --to <name> (--say "<text>" | --file <path>) [--volume 0-100] # play a clip, then put back what was playing and the volume
|
|
18
|
+
home cast announce --to <name> [--to <name>…] (--say "<text>" | --file <path>) [--volume 0-100] # exit 1 if any didn't play; play a clip, then put back what was playing and the volume
|
|
18
19
|
home cast volume <name> <0-100>
|
|
19
20
|
home cast mute <name> on|off
|
|
20
21
|
home cast stop <name> # end the running app; the device returns to its idle screen
|
|
21
|
-
home spotify pause | play [link] | next | volume <0-100> # the Soloist speaker at SOLOIST_WS; volume held to SOLOIST_MAX_VOLUME; prints what Soloist then reported
|
|
22
|
+
home spotify activate | pause | play [link] | next | volume <0-100> # the Soloist speaker at SOLOIST_WS; volume held to SOLOIST_MAX_VOLUME; prints what Soloist then reported
|
|
22
23
|
```
|
|
23
24
|
|
|
24
25
|
## Output
|
|
25
26
|
|
|
26
27
|
- **stdout is data, stderr everything else.** Notes such as "nothing to categorise" or "no webhooks" go to stderr, so `home … | jq` never sees them.
|
|
28
|
+
- **Bun on PATH.** The command starts with `#!/usr/bin/env bun`. A non-login SSH shell may not have `~/.bun/bin` on its PATH: run `PATH="$HOME/.bun/bin:$PATH" home`.
|
|
27
29
|
- **`--json` shapes.** `home --json` is one object keyed by connector, with `now` (`energyNow`) first. `home <connector> --json` is that connector's object: `status`, `needs`, `access`, `detail`, `anomaly`, then its state (the weather adds `line`, its text). Lists (`home up transactions`, `uncategorised`, `spend --by merchant`) are arrays, `[]` when empty.
|
|
28
30
|
- **Errors under `--json`** are one object on stderr: `{"error": "…", "code": "usage" | "auth" | "network" | "error", "fix": "home enphase login"}`; `fix` is there only when one command resolves it. In text: `home: <error> (run \`<fix>\`)`.
|
|
29
31
|
- **Exit codes.** 0 success, empty results included. 1 a failure, and `home <connector>` when that connector is not `connected`, so a script can test one. 2 a wrong command, verb or argument. `home` alone lists every connector and exits 0 whatever their state.
|
package/docs/config.md
CHANGED
|
@@ -24,11 +24,11 @@ const home = createHome(
|
|
|
24
24
|
- **A device entry is only how to reach it and where it is**: its identity (the Hue `bridgeId`, a Daikin's `mac`, the Enphase `serial`, a Cast `castId`), its last known `host`, a key's env name, a file's path, its `room`. What a device reports about itself (model, firmware) comes live; a note about it is a comment.
|
|
25
25
|
- **Secrets are env variable names** (`"key": "AMBER_KEY"`), resolved against the `env` the caller passes; a pasted key fails the schema. Files (`ca`, `token`) are paths the caller reads, relative to the config; the library reads nothing.
|
|
26
26
|
- **Problems name the field**: an unknown connector, a missing env name or a device in a missing room is an error, never a skip. `checkHome` returns them all; `loadHome` throws them all. Unknown keys pass through for a consumer's own settings.
|
|
27
|
-
- **One device per connector** for now (each integration is named by its connector), except Cast: one entry per device and speaker group (`name`, the Google Home name, and `castId`, the mDNS id), all read by one `cast` integration that finds every address by discovery, since a group's moves with its leader. A `spotify` entry is only its Soloist `url` (and `maxVolume`): a socket on the same machine, with nothing to discover.
|
|
27
|
+
- **One device per connector** for now (each integration is named by its connector), except printers (one entry each, by `uuid`) and Cast: one entry per device and speaker group (`name`, the Google Home name, and `castId`, the mDNS id), all read by one `cast` integration that finds every address by discovery, since a group's moves with its leader. A `spotify` entry is only its Soloist `url` (and `maxVolume`): a socket on the same machine, with nothing to discover.
|
|
28
28
|
- **Identity, not address.** A device is recognised by the id its own discovery reports; `host` is a cache. `home discover` lists what answers (mDNS, Daikin's UDP broadcast), in two passes 5 s apart since both drop answers now and then: the first pass prints at once, the second only what it adds (`--passes 1` for a quick look; `--json` waits for both); with a config (`--config` or `HOME_CONFIG`) it then matches those against it by identity and prints new devices, moved addresses and conflicts (a different device at an entry's host). `home discover --write <file>` adds the new ones (each with an id no entry has), updates moved hosts and refreshes the `name` of a Cast entry renamed in the Google Home app through `jsonc-parser`'s edits, every comment and other line left as it was, and writes only a result that passes `checkPlan`. Nothing else writes the file; the library never does. Two devices with one id are a config problem.
|
|
29
29
|
- **A device that moves is found again, without periodic scans**, when the caller passes `finders` to `loadHome` (`FINDERS` from `@khalido/home/finders`, Node-side like `weather/ftp`; the CLI does, and jaldi and chota-bot can). After three failed reads in a row (Hue: three failed reconnects of its event stream), Hue, Daikin and Enphase run their discovery (two passes, the second skipped when the first finds the device), switch to the address their identity now answers at and say "moved from a to b" in `detail` (persist it with `home discover --write`); otherwise they look again at 1, 5 and 15 minutes, then hourly for as long as reads fail, so a device back from the wall socket reads again without a restart. The detail says what discovery found ("not found by discovery", or "discovery finds it at its address but reads fail", or "discovery failed: …" when it could not run), and "no answer for a day" once a day has passed; the part stays `disconnected`, since `unavailable` is for a rejected key. `refresh()` resets the wait. Amber and the weather have no discovery and do not take part. Cast always needs `finders.cast`: discovery is how it finds every address.
|
|
30
30
|
- **Discovery is rare: addresses are cached between runs.** `loadHome(config, { cache, configAt })` takes an `AddressCache` (`read()` and `write(entries)`, keyed by connector and identity, each entry `{ host, port?, name?, seenAt }`). A cached address newer than the config (`configAt`, the file's modified time) is tried first, a failure there scans at once rather than after three, and each address that answers is written back when it changed or predates the config, so a config edit is re-trusted at the first good read. Nothing evicts an entry but `home cache clear`: a device gone for good keeps its entry, and `home status` without a config, which reads every Cast entry from the cache, shows it as not found until then. The consumer persists it: the CLI keeps `$XDG_CACHE_HOME/home/addresses.json` (default `~/.cache/home/addresses.json`, 0600) through `fileCache` from `@khalido/home/finders`, written at the end of `status`, `probe` and `discover`; jaldi's sidecar and chota-bot pass `FINDERS` and their own cache the same way. The config's `host` stays the human-set fallback and `home discover --write` the way to change the config. `home status` from a Cast config: 0.33–0.44 s cold, 0.13 s with a warm cache, 1.4 s once when a cached address has gone stale (Bun 1.4.2, measured 2026-10-13).
|
|
31
|
-
- **Parsing** is
|
|
31
|
+
- **Parsing** is through `jsonc-parser` (comments and trailing commas), on Bun and Node alike; a syntax error throws. `bun run schema` rewrites `home.schema.json` from the zod schema, and a test fails if they drift.
|
|
32
32
|
- `createHome` stays for code-first use.
|
|
33
33
|
|
|
34
34
|
## Drawing the home
|
package/docs/decisions.md
CHANGED
|
@@ -148,3 +148,4 @@ KO's decisions on this package, newest block last. They override every other doc
|
|
|
148
148
|
- **Signed out reads as waiting**, everywhere: `speakerState` says `waiting`, the detail says "waiting for someone to pick it", the write error's code is `waiting`.
|
|
149
149
|
- **Stalls (Soloist issue #5) are noticed by default and repaired only when asked** (`repairStalls: true`): the repair is the library's first write nobody asked for, so a consumer opts in. Two signals, since the issue doesn't say whether the reported position freezes: a frozen position across fresh reports, and still playing well past the track's end, the symptom actually reported. One repair per track, never a loop. The nudges (seek, then pause and play; a skip past the end) are guesses until a stall is seen live.
|
|
150
150
|
- **The project is home.ts; the npm package stays `@khalido/home`** (KO, 2026-11-08), so imports stay short (`@khalido/home/spotify`). It is home automation for TypeScript, starting with the devices in KO's house, and is meant to grow into more of the basics.
|
|
151
|
+
- **Spotify: inactive is refused, and activating is the caller's choice** (KO, 2026-11-09). Soloist accepts and drops `set_volume` while it isn't the active device. `setVolume` refuses with code `inactive`, and `activate()` is separate, because activating makes a new stream and can jump the volume to the level last saved. `duck` runs `fn` anyway, with `music: "untouched"`, because the announcement matters more. `play` goes active itself, since someone asking for music wants it to start; `resume` doesn't, since it worked inactive. The `maxVolume` cap says "lowered" only once Soloist reports it.
|
package/docs/google.md
CHANGED
|
@@ -61,7 +61,7 @@ chota's morning print needs an hourly slice (rain chance, mm, condition) for lin
|
|
|
61
61
|
|
|
62
62
|
**Auth.** The caller passes `getToken(): Promise<string>`. The library stores no token. chota passes its better-auth token. A `home calendar login` in the CLI (loopback OAuth, token file in `~/.config/home/`, 0600, like `home enphase login`) comes later and needs an OAuth client in Google Cloud.
|
|
63
63
|
|
|
64
|
-
**State.** The next 14 days per calendar, trimmed to id, title, start, end, all-day, location. Not kept by default: description, attendees, links. All-day events are dates, not instants; they use the home's `timeZone` from the config, never the machine's. Weather
|
|
64
|
+
**State.** The next 14 days per calendar, trimmed to id, title, start, end, all-day, location. Not kept by default: description, attendees, links. All-day events are dates, not instants; they use the home's `timeZone` from the config, never the machine's. Weather's `hhmmIn` does the same.
|
|
65
65
|
|
|
66
66
|
**Helpers**, pure: `eventsOn(day)`, `next()`, `search(text)`. One owner for "what is today".
|
|
67
67
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@khalido/home",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "home.ts: small, typed home automation for TypeScript: Hue, Daikin, Enphase, Google Cast, AirPrint printers and a Spotify Soloist speaker on the local network; Amber prices, Up Bank spending and the weather from the cloud.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Khalid Omar",
|
|
@@ -98,7 +98,7 @@
|
|
|
98
98
|
}
|
|
99
99
|
},
|
|
100
100
|
"bin": {
|
|
101
|
-
"home": "
|
|
101
|
+
"home": "src/cli/main.ts"
|
|
102
102
|
},
|
|
103
103
|
"files": [
|
|
104
104
|
"CHANGELOG.md",
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
# draw-home: references
|
|
2
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.
|
|
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.
|
|
4
4
|
|
|
5
5
|
| Reference | What it is | Use it for | Licence |
|
|
6
6
|
| ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
|
|
7
|
-
| [iso-glow](https://isoglow.dev/) (April Zhu
|
|
8
|
-
| [Anatomy](https://skills.wheresryan.sh/anatomy) ([wheresryan22/anatomy](https://github.com/wheresryan22/anatomy)
|
|
9
|
-
| [Hairline](https://github.com/lucasmarkes/hairline)
|
|
10
|
-
| [ai-iso-skill](https://github.com/MrBongoC/ai-iso-skill) `iso-figure`
|
|
7
|
+
| [iso-glow](https://isoglow.dev/) (April Zhu) | 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)) | 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) | 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` | Flat-faced isometric figures on a box, ~10 KB pages | The lightest isometric option | MIT |
|
|
11
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
12
|
|
|
14
13
|
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
14
|
|
|
@@ -12,8 +12,6 @@ The library does not draw. This guide is for the coding agent that does: turn wh
|
|
|
12
12
|
|
|
13
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
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
15
|
## 1. Sources, before any geometry
|
|
18
16
|
|
|
19
17
|
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.
|
|
@@ -27,7 +25,7 @@ Make a **room table** first, in the SVG's header comment: each room, its floor,
|
|
|
27
25
|
|
|
28
26
|
- 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
27
|
- **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,
|
|
28
|
+
- **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, [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
29
|
|
|
32
30
|
## 3. The contract (an app draws on this; keep it exact)
|
|
33
31
|
|
|
@@ -38,7 +36,7 @@ Make a **room table** first, in the SVG's header comment: each room, its floor,
|
|
|
38
36
|
5. The SVG fills its host (`width: 100%; height: 100%`; the viewBox holds everything).
|
|
39
37
|
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
38
|
|
|
41
|
-
**What an app does with it
|
|
39
|
+
**What an app does with it:** 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
40
|
|
|
43
41
|
**Energy on the plan** (the numbers are the library's, the drawing and animation are the app's):
|
|
44
42
|
|
|
@@ -48,7 +46,7 @@ Make a **room table** first, in the SVG's header comment: each room, its floor,
|
|
|
48
46
|
|
|
49
47
|
**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
48
|
|
|
51
|
-
**SVG with a camera** (the interactive option, Anatomy's approach
|
|
49
|
+
**SVG with a camera** (the interactive option, Anatomy's approach): 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` (`kit/iso-kit.mjs` in the Anatomy repo). 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
50
|
|
|
53
51
|
## 4. home.jsonc
|
|
54
52
|
|
package/src/cli/cast.ts
CHANGED
|
@@ -34,6 +34,7 @@ export function castNow() {
|
|
|
34
34
|
{
|
|
35
35
|
castId: key.slice(5),
|
|
36
36
|
name: c.name ?? key.slice(5),
|
|
37
|
+
model: c.model,
|
|
37
38
|
host: c.host,
|
|
38
39
|
port: c.port,
|
|
39
40
|
},
|
|
@@ -71,7 +72,7 @@ async function withCast<T>(
|
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
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
|
+
'usage: home cast announce --to <name> [--to <name>…] (--say "<text>" | --file <path>) [--volume 0-100] | volume <name> <0-100> | mute <name> on|off | stop <name>';
|
|
75
76
|
|
|
76
77
|
/** A 0–100 argument as the 0–1 a Cast device takes. */
|
|
77
78
|
function percentLevel(value: string | undefined): number {
|
|
@@ -96,7 +97,7 @@ const AUDIO_TYPES: Record<string, string> = {
|
|
|
96
97
|
export const CAST_OPTIONS: [string, string, { type: unknown[] }?][] = [
|
|
97
98
|
[
|
|
98
99
|
"--to <name>",
|
|
99
|
-
"announce: the device or group, by name or castId",
|
|
100
|
+
"announce: the device or group, by name or castId; repeat for several",
|
|
100
101
|
{ type: [String] },
|
|
101
102
|
],
|
|
102
103
|
["--say <text>", "announce: words spoken by macOS `say`", { type: [String] }],
|
|
@@ -119,7 +120,8 @@ export const castVerbs: Verbs = {
|
|
|
119
120
|
await withCast((c) => c.stop(name!));
|
|
120
121
|
},
|
|
121
122
|
async announce(_, o) {
|
|
122
|
-
const [
|
|
123
|
+
const [say, file] = [text(o.say), text(o.file)];
|
|
124
|
+
const to = [o.to].flat().filter(Boolean).map(String); // --to can repeat
|
|
123
125
|
// 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
126
|
const bare = ["--to", "--say", "--file"].some((f) =>
|
|
125
127
|
process.argv.some(
|
|
@@ -130,7 +132,7 @@ export const castVerbs: Verbs = {
|
|
|
130
132
|
),
|
|
131
133
|
);
|
|
132
134
|
if (bare) usage(CAST_USAGE);
|
|
133
|
-
if (!to || !say === !file) usage(CAST_USAGE);
|
|
135
|
+
if (!to.length || !say === !file) usage(CAST_USAGE);
|
|
134
136
|
const volume =
|
|
135
137
|
o.volume === undefined ? undefined : percentLevel(text(o.volume));
|
|
136
138
|
let type: string;
|
|
@@ -160,13 +162,19 @@ export const castVerbs: Verbs = {
|
|
|
160
162
|
}
|
|
161
163
|
type = "audio/mp4";
|
|
162
164
|
}
|
|
163
|
-
const
|
|
164
|
-
c.
|
|
165
|
+
const results = await withCast((c) =>
|
|
166
|
+
c.announceAll(to, { bytes, type }, { volume }),
|
|
165
167
|
);
|
|
166
|
-
if (
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
for (const
|
|
170
|
-
|
|
168
|
+
if (results.some((r) => !r.played)) process.exitCode = 1; // docs/cli.md: 1 is a failure, so a script can tell
|
|
169
|
+
if (o.json) return printJson(to.length === 1 ? results[0] : results);
|
|
170
|
+
const named = results.length > 1;
|
|
171
|
+
for (const r of results) {
|
|
172
|
+
const who = named ? `${r.target}: ` : "";
|
|
173
|
+
console.log(`${who}${r.played ? "played" : `not played: ${r.why}`}`);
|
|
174
|
+
if (r.restored.length)
|
|
175
|
+
console.log(`${who}restored: ${r.restored.join(", ")}`);
|
|
176
|
+
for (const n of r.notRestored)
|
|
177
|
+
console.log(`${who}not restored: ${n.name}: ${n.why}`);
|
|
178
|
+
}
|
|
171
179
|
},
|
|
172
180
|
};
|
package/src/cli/discover.ts
CHANGED
|
@@ -14,7 +14,13 @@ import {
|
|
|
14
14
|
KNOWN_SERVICES,
|
|
15
15
|
matchFound,
|
|
16
16
|
} from "../discover.ts";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
browse,
|
|
19
|
+
daikinFound,
|
|
20
|
+
FINDERS,
|
|
21
|
+
findDaikins,
|
|
22
|
+
MissingTool,
|
|
23
|
+
} from "../finders.ts";
|
|
18
24
|
import {
|
|
19
25
|
addresses,
|
|
20
26
|
configPathOf,
|
|
@@ -83,6 +89,7 @@ export async function discover(o: Options): Promise<void> {
|
|
|
83
89
|
clock: realClock,
|
|
84
90
|
passes,
|
|
85
91
|
key: (x) => (x.found ? foundKey(x.found) : `${x.what}:${x.name}`),
|
|
92
|
+
stopOn: (error) => error instanceof MissingTool,
|
|
86
93
|
onPass: (n, added) => {
|
|
87
94
|
if (o.json) return;
|
|
88
95
|
const tty = process.stderr.isTTY;
|
package/src/cli/spotify.ts
CHANGED
|
@@ -31,7 +31,7 @@ export const spotifyFromEnv = () =>
|
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
export const SPOTIFY_USAGE =
|
|
34
|
-
"usage: home spotify pause | play [link] | next | volume <0-100>";
|
|
34
|
+
"usage: home spotify activate | pause | play [link] | next | volume <0-100>";
|
|
35
35
|
|
|
36
36
|
async function withSpotify<T>(
|
|
37
37
|
act: (s: ReturnType<typeof spotify>) => Promise<T>,
|
|
@@ -64,6 +64,7 @@ const said = ({ state, confirmed }: Applied) =>
|
|
|
64
64
|
);
|
|
65
65
|
|
|
66
66
|
export const spotifyVerbs: Verbs = {
|
|
67
|
+
activate: () => withSpotify((s) => s.activate()).then(said),
|
|
67
68
|
pause: () => withSpotify((s) => s.pause()).then(said),
|
|
68
69
|
next: () => withSpotify((s) => s.next()).then(said),
|
|
69
70
|
play: ([link]) => withSpotify((s) => s.play(link)).then(said),
|
package/src/config.ts
CHANGED
|
@@ -509,7 +509,9 @@ export function loadHome(
|
|
|
509
509
|
{
|
|
510
510
|
castId: x.castId,
|
|
511
511
|
name: x.name,
|
|
512
|
-
...(c?.port === undefined
|
|
512
|
+
...(c?.port === undefined
|
|
513
|
+
? {}
|
|
514
|
+
: { host: c.host, port: c.port, model: c.model }),
|
|
513
515
|
},
|
|
514
516
|
];
|
|
515
517
|
});
|
|
@@ -4,16 +4,16 @@ Nest speakers, Nest Hubs, Chromecasts, Google TV and speaker groups, read over C
|
|
|
4
4
|
|
|
5
5
|
## Built
|
|
6
6
|
|
|
7
|
-
- `cast({ resolve, connect })` keeps every Cast device and group current as one integration. `resolve()` returns what mDNS `_googlecast._tcp` found (`id`, `name` from TXT `fn`, `model` from `md`, host, port, `group`); the CLI runs `dns-sd`. `connect` is `tlsConnect` from `@khalido/home/cast/tls`. `expect` lists the config's entries, so one discovery misses shows as disconnected rather than vanishing. From a config, `resolve` is the shared two-pass discovery, done after the first pass once every entry has answered. An entry with a cached address (the address cache, `loadHome`'s `cache`) connects at once with a 1 s timeout; discovery runs only when an entry has none or a cached address fails, so a stale one still reads as `connecting` until discovery has had its say. A cached port other than 8009 marks a group, since devices always listen on 8009.
|
|
7
|
+
- `cast({ resolve, connect })` keeps every Cast device and group current as one integration. `resolve()` returns what mDNS `_googlecast._tcp` found (`id`, `name` from TXT `fn`, `model` from `md`, host, port, `group`); the CLI runs `dns-sd`. `connect` is `tlsConnect` from `@khalido/home/cast/tls`. `expect` lists the config's entries, so one discovery misses shows as disconnected rather than vanishing. From a config, `resolve` is the shared two-pass discovery, done after the first pass once every entry has answered. An entry with a cached address (the address cache, `loadHome`'s `cache`) connects at once with a 1 s timeout; discovery runs only when an entry has none or a cached address fails, so a stale one still reads as `connecting` until discovery has had its say. If discovery can't find the device either, it shows as disconnected at once rather than waiting on the same address again; the backoff still retries. A cached port other than 8009 marks a group, since devices always listen on 8009.
|
|
8
8
|
- Per socket: `CONNECT` to `receiver-0`, receiver `GET_STATUS`, multizone `GET_STATUS` (a group's members; on a device's own port, the device alone, by its current name), then whatever the device pushes. A running app with the media namespace gets a `CONNECT` and media `GET_STATUS` on its `transportId`, as Home Assistant does. A device's `PING` is answered with `PONG`; a socket silent for three heartbeats (15 s), or without a receiver status 10 s after connecting, is closed and the detail says which.
|
|
9
9
|
- A dropped socket reconnects with backoff (1 s doubling to 60 s) at whatever address discovery last gave. The drop starts one shared `resolve()` at once (a group's address changes with its leader); while anything stays down or missing, that one discovery repeats at 1, 5 and 15 minutes, then hourly, as the relocator does, never once per socket. `refresh()` re-asks every open socket and nudges the discovery, which also finds devices when nothing was found at start.
|
|
10
10
|
- The part is `connecting` until every device has answered or failed, then `connected` while any is up; `detail` names the ones down. A payload of a known type the schema rejects is an `anomaly`, never thrown.
|
|
11
|
-
- State (`CastSnapshot`, by mDNS id): app, `idle` (no app, or Backdrop's `isIdleScreen`), `standby`, volume (unrounded), muted, `volumeControl`, player state, title, artist; groups carry `members` and `leader`; devices carry `groups`, and `playingVia` when they show a playing group's media because their own is empty (Home Assistant's model, not yet seen live).
|
|
11
|
+
- State (`CastSnapshot`, by mDNS id): `form` (`speaker`, `display` or `tv`, from `castForm(model)`; none for a model it doesn't know; the address cache keeps the model), app, `idle` (no app, or Backdrop's `isIdleScreen`), `standby`, volume (unrounded), muted, `volumeControl`, player state, title, artist; groups carry `members` and `leader`; devices carry `groups`, and `playingVia` when they show a playing group's media because their own is empty (Home Assistant's model, not yet seen live).
|
|
12
12
|
- Names: a device's `name` in the snapshot is its own, from discovery's `fn` or its own multizone status (the same text on all six live devices, 2026-10-13), so a rename in the Google Home app shows once the device answers, even when every address came from the cache and discovery never ran; the config's name is only the placeholder until then. A group's multizone status lists its members, not itself, so a group's new name shows at the next discovery.
|
|
13
13
|
- Writes, on the object `cast()` returns (`CastActions`), over the sockets it already holds, never a second connection. A target is a name (any case) or a castId; volume is 0 to 1, as the snapshot's. `setVolume`, `setMuted` (receiver `SET_VOLUME`; a group's goes to the group's socket; per device, levels and mutes each at most 30 a minute, the newest waiting one winning; one device's lane never waits on another's) and `stop` (receiver `STOP` of the running app; nothing when idle). Each request waits 10 s for its reply and a refusal (`LAUNCH_ERROR`, `LOAD_FAILED`, …) rejects, naming it.
|
|
14
|
-
- `announce(target, clip, { volume?, timeoutMs? })`: `clip` is `{ url, type? }` or `{ bytes, type }`, bytes served through `serve` (`serveClip` from `@khalido/home/cast/serve`, passed to `cast()`). It reads the receiver status (and a Default Media Receiver's media status), for a group re-runs discovery first and reconnects if the leader moved, sets the volume if given and unmutes a muted target (as two requests: a `SET_VOLUME` takes a level or a mute, not both), launches the Default Media Receiver (`CC1AD845`), `LOAD`s the clip and waits for its media session to go `IDLE` with `FINISHED` (60 s plus the clip's length when the device reports it, or `timeoutMs`). Then, always: what the Default Media Receiver was playing is loaded again at its `currentTime`, an idle device gets `STOP`, then the volume and mute (a group's member by member, through each member's own socket, from the group's multizone status). Another app (Spotify, YouTube) is interrupted, as Google's own announcements do, and the result names it in `notRestored`. It returns `{ played, why?, restored, notRestored }` and throws only before anything changed (an unknown target, a socket down).
|
|
14
|
+
- `announce(target, clip, { volume?, timeoutMs? })`: `clip` is `{ url, type? }` or `{ bytes, type }`, bytes served through `serve` (`serveClip` from `@khalido/home/cast/serve`, passed to `cast()`). It reads the receiver status (and a Default Media Receiver's media status), for a group re-runs discovery first and reconnects if the leader moved, sets the volume if given and unmutes a muted target (as two requests: a `SET_VOLUME` takes a level or a mute, not both), launches the Default Media Receiver (`CC1AD845`), `LOAD`s the clip and waits for its media session to go `IDLE` with `FINISHED` (60 s plus the clip's length when the device reports it, or `timeoutMs`). Then, always: what the Default Media Receiver was playing is loaded again at its `currentTime`, an idle device gets `STOP`, then the volume and mute (a group's member by member, through each member's own socket, from the group's multizone status). Another app (Spotify, YouTube) is interrupted, as Google's own announcements do, and the result names it in `notRestored`. It returns `{ played, why?, restored, notRestored }` and throws only before anything changed (an unknown target, a socket down). A clip gets 15 s to reach `PLAYING`, then the name of its type goes in the failure, since a device that can't play it took 35-44 s to say so; a receiver error sooner carries its reason and code. `timeoutMs` replaces both waits. `announceAll(targets, clip, options)` does several at once, one result per device, an unreachable target its own `played: false`; a group and one of its members would clash, untried.
|
|
15
15
|
- `serveClip(bytes, type, { host?, signal? })` (`node:http`, Node-only like `tls.ts`): an address of this machine on the target's network (else the first non-internal IPv4), a random port and an unguessable path, `Content-Type` and `Content-Length`; the whole body to every GET and HEAD (a `Range` probe gets a full 200, which HTTP allows), so a player that probes before it fetches still gets the clip. It closes on `close()`, which `announce` calls once the clip has played, when `signal` aborts, or after 60 s. Not yet seen live: how the Default Media Receiver fetches, and the macOS local-network prompt the first serve may raise.
|
|
16
|
-
- CLI: `home cast announce --to <name> (--say "<text>" | --file <path>) [--volume 0-100]`, `home cast volume <name> <0-100>`, `home cast mute <name> on|off`, `home cast stop <name>`. `--say` runs `say -o <tmp>.m4a --file-format=m4af --data-format=aac` (without the data format, m4af holds uncompressed PCM), serves it as `audio/mp4` and deletes the file. The library never runs `say`.
|
|
16
|
+
- CLI: `home cast announce --to <name> [--to <name>…] (--say "<text>" | --file <path>) [--volume 0-100]`, `home cast volume <name> <0-100>`, `home cast mute <name> on|off`, `home cast stop <name>`. `--say` runs `say -o <tmp>.m4a --file-format=m4af --data-format=aac` (without the data format, m4af holds uncompressed PCM), serves it as `audio/mp4` and deletes the file. The library never runs `say`.
|
|
17
17
|
- Config: one entry per device or group, `{ "connector": "cast", "name": "…", "castId": "…" }`, all folded into one `cast` integration; `home discover --write` fills them and refreshes a renamed entry's `name` (the same `castId` with a new `fn`), printed as `renamed`. No host: discovery gives every address.
|
|
18
18
|
|
|
19
19
|
## Protocol
|
|
@@ -26,7 +26,9 @@ CastV2 is TLS to port 8009 (a group: its own port), carrying length-prefixed pro
|
|
|
26
26
|
- **Groups.** A group is its own mDNS entry, at its leader's address on a high port, and presents **its leader's certificate**. Multizone status on the group's port lists the members (by `deviceId`, the same dashed id) with their volumes; on a device's own port it lists only that device, so membership is read from group sockets only. TXT `rm` does not mark membership. `GET_CASTING_GROUPS` is refused.
|
|
27
27
|
- **Receiver status**: hubs and the Chromecast Ultra run Backdrop (`E8C28D3C`) with `isIdleScreen: true`; the speakers report no application; the TV Streamer reports `isStandBy: true`, `isActiveInput: false` and a `fixed` volume; the Ultra's volume is `attenuation`.
|
|
28
28
|
- No device sent a `PING` in 12 s of listening, so our 5 s ping is what keeps the socket's silence check honest.
|
|
29
|
-
-
|
|
29
|
+
- Media status first seen live 2026-11-09 (Bun 1.4.4): a paused title and artist on a TV.
|
|
30
|
+
- **First announcements, 2026-11-09, from a Pi on 0.1.1.** MP3 played on two audio-only speakers and two displays, 7-8 s each, the clip fetched from the Pi's LAN address, idle and volume restored. A 24 kHz mono WAV played on the displays and failed on both audio-only speakers, "the clip stopped: ERROR" after 35-44 s. One WAV format tried: use MP3 or AAC.
|
|
31
|
+
- Not seen yet: a group playing, a leader change, member changes, and Node's `tls` (no Node caller yet).
|
|
30
32
|
|
|
31
33
|
## TLS: verification off, the name checked
|
|
32
34
|
|
|
@@ -27,7 +27,8 @@ import {
|
|
|
27
27
|
} from "./writes.ts";
|
|
28
28
|
|
|
29
29
|
export type { CastConnect } from "./codec.ts";
|
|
30
|
-
export
|
|
30
|
+
export { castForm } from "./state.ts";
|
|
31
|
+
export type { CastDevice, CastForm, CastSnapshot, Resolved } from "./state.ts";
|
|
31
32
|
export type {
|
|
32
33
|
AnnounceResult,
|
|
33
34
|
CastActions,
|
|
@@ -77,11 +78,17 @@ export type CastConfig = {
|
|
|
77
78
|
* `host` and `port` (an address cache) an entry connects at once; discovery runs only when an entry has none, or a
|
|
78
79
|
* cached address fails. A cached port other than 8009 is a group's (devices always listen on 8009).
|
|
79
80
|
*/
|
|
80
|
-
expect?: {
|
|
81
|
+
expect?: {
|
|
82
|
+
castId: string;
|
|
83
|
+
name: string;
|
|
84
|
+
model?: string;
|
|
85
|
+
host?: string;
|
|
86
|
+
port?: number;
|
|
87
|
+
}[];
|
|
81
88
|
/** Each address that connected and passed the certificate check, for an address cache; again with the device's new name when it reports a rename. `name` is the device's own, never a config's. */
|
|
82
89
|
seen?: (
|
|
83
90
|
id: string,
|
|
84
|
-
address: { host: string; port: number; name?: string },
|
|
91
|
+
address: { host: string; port: number; name?: string; model?: string },
|
|
85
92
|
) => void;
|
|
86
93
|
clock?: Clock;
|
|
87
94
|
/** Heartbeat interval; a socket silent for three of them is dropped. */
|
|
@@ -304,6 +311,7 @@ export function cast(
|
|
|
304
311
|
host: d.host!,
|
|
305
312
|
port: d.port!,
|
|
306
313
|
name: named.has(id) ? d.name : undefined,
|
|
314
|
+
model: d.model,
|
|
307
315
|
}); // a config's name never reaches the cache
|
|
308
316
|
publish();
|
|
309
317
|
void (async () => {
|
|
@@ -401,7 +409,8 @@ export function cast(
|
|
|
401
409
|
// A cached address that fails is only stale: discover now and try where it answers, still "connecting".
|
|
402
410
|
cached = false;
|
|
403
411
|
await discover();
|
|
404
|
-
|
|
412
|
+
// Unless discovery can't find it: then the address is the one that just failed, and a second try only waits out the timeout. The backoff still retries.
|
|
413
|
+
if (!lastFound || lastFound.has(id)) continue;
|
|
405
414
|
}
|
|
406
415
|
const why = errorText(error);
|
|
407
416
|
// A cached address the last discovery did not find says so, as a polling connector's does.
|
|
@@ -467,14 +476,19 @@ export function cast(
|
|
|
467
476
|
report = update;
|
|
468
477
|
running = signal;
|
|
469
478
|
const expect = config.expect ?? [];
|
|
479
|
+
// A device with no model (a cache from before 0.1.2) goes through discovery once, or it would never get a `form`.
|
|
470
480
|
const cached = expect.filter(
|
|
471
|
-
(e) =>
|
|
481
|
+
(e) =>
|
|
482
|
+
e.host !== undefined &&
|
|
483
|
+
e.port !== undefined &&
|
|
484
|
+
(e.port !== 8009 || e.model !== undefined),
|
|
472
485
|
);
|
|
473
486
|
snapshot = found(
|
|
474
487
|
snapshot,
|
|
475
488
|
cached.map((e) => ({
|
|
476
489
|
id: e.castId,
|
|
477
490
|
name: e.name,
|
|
491
|
+
model: e.model,
|
|
478
492
|
host: e.host!,
|
|
479
493
|
port: e.port!,
|
|
480
494
|
group: e.port !== 8009,
|
|
@@ -43,6 +43,7 @@ export const MediaStatus = z.looseObject({
|
|
|
43
43
|
mediaSessionId: z.number().optional(),
|
|
44
44
|
playerState: z.string().optional(), // "PLAYING" | "PAUSED" | "BUFFERING" | "IDLE"
|
|
45
45
|
idleReason: z.string().optional(), // "FINISHED" | "CANCELLED" | "INTERRUPTED" | "ERROR"
|
|
46
|
+
detailedErrorCode: z.number().optional(),
|
|
46
47
|
currentTime: z.number().optional(), // seconds into the media
|
|
47
48
|
media: z
|
|
48
49
|
.looseObject({
|
|
@@ -15,10 +15,20 @@ const ipv4 = (a: string) =>
|
|
|
15
15
|
const network = (address: string, netmask: string) =>
|
|
16
16
|
ipv4(address) - (ipv4(address) % (2 ** 32 - ipv4(netmask)));
|
|
17
17
|
|
|
18
|
+
type NetworkAddress = {
|
|
19
|
+
family: string;
|
|
20
|
+
address: string;
|
|
21
|
+
netmask: string;
|
|
22
|
+
internal: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
18
25
|
/** This machine's IPv4 on the subnet that holds `host`, or with no host, its first non-internal IPv4. */
|
|
19
26
|
export function lanAddress(
|
|
20
27
|
host?: string,
|
|
21
|
-
interfaces
|
|
28
|
+
interfaces: Record<
|
|
29
|
+
string,
|
|
30
|
+
NetworkAddress[] | undefined
|
|
31
|
+
> = networkInterfaces(),
|
|
22
32
|
): string {
|
|
23
33
|
const all = Object.values(interfaces)
|
|
24
34
|
.flatMap((list) => list ?? [])
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import type { CastPayload } from "./schemas.ts";
|
|
2
2
|
|
|
3
|
+
/** What a device is for, read from its model name; a model it doesn't know has none. A group is `kind: "group"`. */
|
|
4
|
+
export type CastForm = "speaker" | "display" | "tv";
|
|
5
|
+
export function castForm(model?: string): CastForm | undefined {
|
|
6
|
+
if (!model) return undefined;
|
|
7
|
+
if (/hub|tablet|display/i.test(model)) return "display";
|
|
8
|
+
if (/audio|speaker|mini|google home/i.test(model)) return "speaker";
|
|
9
|
+
if (/chromecast|streamer|\btv\b/i.test(model)) return "tv";
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
3
13
|
/** A Cast device or group as found by discovery: what `resolve` returns for each. */
|
|
4
14
|
export type Resolved = {
|
|
5
15
|
id: string;
|
|
@@ -18,6 +28,7 @@ export type CastDevice = {
|
|
|
18
28
|
id: string;
|
|
19
29
|
name: string;
|
|
20
30
|
model?: string;
|
|
31
|
+
form?: CastForm; // devices: speaker, display or tv, when the model is one it knows
|
|
21
32
|
kind: "device" | "group";
|
|
22
33
|
host?: string;
|
|
23
34
|
port?: number;
|
|
@@ -63,6 +74,7 @@ export function found(
|
|
|
63
74
|
id,
|
|
64
75
|
name: e.name,
|
|
65
76
|
model: e.model,
|
|
77
|
+
form: kind === "device" ? castForm(e.model) : undefined,
|
|
66
78
|
kind,
|
|
67
79
|
host: e.host,
|
|
68
80
|
port: e.port,
|