@khalido/home 0.1.3 → 0.2.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.
- package/.env.example +2 -2
- package/CHANGELOG.md +41 -0
- package/README.md +7 -2
- package/dist/config.d.ts +5 -3
- package/dist/config.js +5 -1
- package/dist/connectors/cast/index.d.ts +1 -1
- package/dist/connectors/cast/index.js +1 -1
- package/dist/connectors/cast/state.d.ts +17 -3
- package/dist/connectors/cast/state.js +26 -7
- package/dist/connectors/daikin/client.d.ts +45 -2
- package/dist/connectors/daikin/client.js +67 -2
- package/dist/connectors/daikin/index.d.ts +2 -2
- package/dist/connectors/daikin/index.js +4 -3
- package/dist/connectors/hue/ca.d.ts +2 -0
- package/dist/connectors/hue/ca.js +38 -0
- package/dist/connectors/hue/client.d.ts +81 -6
- package/dist/connectors/hue/client.js +217 -6
- package/dist/connectors/hue/color.d.ts +30 -0
- package/dist/connectors/hue/color.js +92 -0
- package/dist/connectors/hue/index.d.ts +15 -4
- package/dist/connectors/hue/index.js +25 -8
- package/dist/connectors/hue/schemas.d.ts +27 -0
- package/dist/connectors/hue/schemas.js +26 -0
- package/dist/connectors/hue/state.d.ts +10 -1
- package/dist/connectors/hue/state.js +20 -1
- package/dist/connectors/spotify/changes.d.ts +19 -5
- package/dist/connectors/spotify/changes.js +29 -7
- package/dist/connectors/spotify/index.d.ts +1 -1
- package/dist/connectors/spotify/index.js +8 -2
- package/dist/devices.d.ts +24 -0
- package/dist/devices.js +56 -2
- package/dist/discover.js +1 -1
- package/dist/finders-entry.d.ts +1 -1
- package/dist/finders-entry.js +1 -1
- package/dist/finders.d.ts +2 -1
- package/dist/finders.js +16 -5
- package/dist/home.d.ts +11 -2
- package/dist/home.js +98 -34
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/node-fetch.d.ts +2 -2
- package/dist/node-fetch.js +3 -2
- package/docs/cli.md +13 -7
- package/docs/config.md +11 -3
- package/docs/decisions.md +12 -0
- package/docs/hue.md +161 -0
- package/docs/using.md +98 -3
- package/examples/home.jsonc +2 -1
- package/home.schema.json +10 -3
- package/llms.txt +19 -6
- package/package.json +1 -1
- package/skills/draw-home/SKILL.md +1 -1
- package/skills/use-home/SKILL.md +3 -3
- package/src/cli/cast.ts +51 -5
- package/src/cli/context.ts +2 -0
- package/src/cli/daikin.ts +71 -0
- package/src/cli/hue.ts +147 -59
- package/src/cli/main.ts +35 -10
- package/src/cli/probe.ts +21 -17
- package/src/cli/status.ts +61 -10
- package/src/config.ts +8 -2
- package/src/connectors/cast/README.md +6 -3
- package/src/connectors/cast/index.ts +7 -1
- package/src/connectors/cast/state.ts +32 -5
- package/src/connectors/daikin/README.md +16 -4
- package/src/connectors/daikin/client.ts +104 -3
- package/src/connectors/daikin/index.ts +8 -2
- package/src/connectors/hue/README.md +62 -17
- package/src/connectors/hue/ca.ts +39 -0
- package/src/connectors/hue/client.ts +362 -15
- package/src/connectors/hue/color.ts +123 -0
- package/src/connectors/hue/index.ts +44 -11
- package/src/connectors/hue/schemas.ts +26 -0
- package/src/connectors/hue/state.ts +27 -1
- package/src/connectors/spotify/README.md +8 -2
- package/src/connectors/spotify/changes.ts +38 -9
- package/src/connectors/spotify/index.ts +9 -1
- package/src/devices.ts +83 -3
- package/src/discover.ts +1 -2
- package/src/finders-entry.ts +1 -1
- package/src/finders.ts +18 -6
- package/src/home.ts +99 -39
- package/src/index.ts +7 -1
- package/src/node-fetch.ts +6 -2
package/src/cli/hue.ts
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
|
-
// `home lights off`, `home room`, `home scene`, `home pair` (also as `home hue <verb>`): one write to the bridge each
|
|
1
|
+
// `home lights off`, `home light`, `home room`, `home scene`, `home pair` (also as `home hue <verb>`): one write to the bridge each,
|
|
2
|
+
// then what the bridge reports. The name lookups and the checks are the library's (`client.light`, `room`, `scene`).
|
|
2
3
|
import { readFileSync } from "node:fs";
|
|
3
4
|
import { hostname } from "node:os";
|
|
4
5
|
import {
|
|
5
6
|
bunPinnedFetch,
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
type GroupView,
|
|
8
|
+
type HueApplied,
|
|
9
|
+
type HueRequest,
|
|
10
|
+
HueError,
|
|
8
11
|
hue,
|
|
9
12
|
hueClient,
|
|
10
|
-
|
|
11
|
-
|
|
13
|
+
hueText,
|
|
14
|
+
type LightChange,
|
|
15
|
+
type LightView,
|
|
16
|
+
pairWhenPressed,
|
|
12
17
|
} from "../connectors/hue/index.ts";
|
|
13
|
-
import { snapshotFrom } from "../connectors/hue/state.ts";
|
|
14
18
|
import { FINDERS } from "../finders.ts";
|
|
15
19
|
import { env, fail, usage, type Verbs } from "./context.ts";
|
|
16
20
|
|
|
@@ -24,12 +28,9 @@ export async function bridge(): Promise<{ host: string; id: string }> {
|
|
|
24
28
|
return { host: env.HUE_BRIDGE_HOST ?? b.host, id: env.HUE_BRIDGE_ID ?? b.id };
|
|
25
29
|
}
|
|
26
30
|
|
|
31
|
+
/** HUE_CA_FILE when set; else undefined, and the package's own copy of Signify's roots is used. */
|
|
27
32
|
export const ca = () =>
|
|
28
|
-
env.HUE_CA_FILE
|
|
29
|
-
? readFileSync(env.HUE_CA_FILE, "utf8")
|
|
30
|
-
: fail(
|
|
31
|
-
"set HUE_CA_FILE to Signify's root CA (see src/connectors/hue/README.md)",
|
|
32
|
-
);
|
|
33
|
+
env.HUE_CA_FILE ? readFileSync(env.HUE_CA_FILE, "utf8") : undefined;
|
|
33
34
|
|
|
34
35
|
const key = () =>
|
|
35
36
|
env.HUE_APPLICATION_KEY ??
|
|
@@ -50,72 +51,159 @@ async function client() {
|
|
|
50
51
|
});
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
/**
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
54
|
+
/** What a write left, in a line: the light or room as the bridge then reported it, or that it has not reported the change yet. */
|
|
55
|
+
const said = ({
|
|
56
|
+
state,
|
|
57
|
+
confirmed,
|
|
58
|
+
wouldSend,
|
|
59
|
+
}: HueApplied<LightView | GroupView>) =>
|
|
60
|
+
console.log(
|
|
61
|
+
wouldSend
|
|
62
|
+
? dryLine(wouldSend, hueText(state))
|
|
63
|
+
: confirmed
|
|
64
|
+
? hueText(state)
|
|
65
|
+
: `sent; the bridge has not reported it yet (${hueText(state)})`,
|
|
62
66
|
);
|
|
67
|
+
const dryLine = (r: HueRequest, now: string) =>
|
|
68
|
+
`dry run, nothing sent: would PUT ${r.path} ${JSON.stringify(r.body)} (now ${now})`;
|
|
69
|
+
|
|
70
|
+
export const CHANGE_USAGE = "[on|off] [<1-100>] [<kelvin>k] [<hex colour>]";
|
|
71
|
+
/** The words after a name as a change: `on`, `off`, `40` (brightness), `2700k` (white), `ff8800` (colour), in any order. */
|
|
72
|
+
export function changeFrom(words: string[], usageLine: string): LightChange {
|
|
73
|
+
const change: LightChange = {};
|
|
74
|
+
for (const w of words) {
|
|
75
|
+
if (w === "on" || w === "off") {
|
|
76
|
+
if (change.on !== undefined && change.on !== (w === "on"))
|
|
77
|
+
usage(`on or off, not both; ${usageLine}`);
|
|
78
|
+
change.on = w === "on";
|
|
79
|
+
} else if (/^\d{1,3}%?$/.test(w))
|
|
80
|
+
change.brightness = Number.parseInt(w, 10);
|
|
81
|
+
else if (/^\d{4,5}k$/i.test(w)) change.kelvin = Number.parseInt(w, 10);
|
|
82
|
+
else if (/^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.test(w)) change.color = w;
|
|
83
|
+
else
|
|
84
|
+
usage(
|
|
85
|
+
`"${w}" is not on, off, a brightness, a temperature like 2700k or a colour like ff8800; ${usageLine}`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
if (!words.length) usage(usageLine);
|
|
89
|
+
return change;
|
|
63
90
|
}
|
|
64
91
|
|
|
92
|
+
/** A change the bridge can't make (an unknown name, a value off the light's range) is a usage error; anything else passes through. */
|
|
93
|
+
const orUsage = (e: unknown): never => {
|
|
94
|
+
if (e instanceof HueError && e.code === "invalid") usage(e.message);
|
|
95
|
+
throw e;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** How long `home pair` waits for the button: time to walk to the bridge. */
|
|
99
|
+
const PAIR_WAIT_MS = 60_000;
|
|
100
|
+
|
|
65
101
|
export const hueVerbs: Verbs = {
|
|
66
|
-
async lights([
|
|
67
|
-
if (
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
102
|
+
async lights([word], o) {
|
|
103
|
+
if (word !== "off") usage("usage: home lights off");
|
|
104
|
+
const { state, confirmed, wouldSend } = await (
|
|
105
|
+
await client()
|
|
106
|
+
)
|
|
107
|
+
.allOff({ dryRun: !!o.dryRun })
|
|
108
|
+
.catch(orUsage);
|
|
109
|
+
const on = state.filter((l) => l.on && l.reachable !== false).length;
|
|
110
|
+
console.log(
|
|
111
|
+
wouldSend
|
|
112
|
+
? dryLine(wouldSend, `${on} of ${state.length} on`)
|
|
113
|
+
: confirmed
|
|
114
|
+
? `${state.length} lights off`
|
|
115
|
+
: `sent; the bridge still reports ${on} of ${state.length} on`,
|
|
116
|
+
);
|
|
73
117
|
},
|
|
74
118
|
|
|
75
|
-
async
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
119
|
+
async light([name, ...words], o) {
|
|
120
|
+
const line = `usage: home light <name> ${CHANGE_USAGE}`;
|
|
121
|
+
if (!name) usage(line);
|
|
122
|
+
const change = changeFrom(words, line);
|
|
123
|
+
said(
|
|
124
|
+
await (
|
|
125
|
+
await client()
|
|
126
|
+
)
|
|
127
|
+
.light(name!, change, { dryRun: !!o.dryRun })
|
|
128
|
+
.catch(orUsage),
|
|
82
129
|
);
|
|
83
|
-
if (!room.groupedLightId) fail(`${room.name} has no grouped light`);
|
|
84
|
-
const level = Number(value);
|
|
85
|
-
if (value !== "on" && value !== "off" && !(level >= 1 && level <= 100))
|
|
86
|
-
usage(`brightness must be 1-100, got ${value}`);
|
|
87
|
-
const body =
|
|
88
|
-
value === "on" || value === "off"
|
|
89
|
-
? { on: { on: value === "on" } }
|
|
90
|
-
: { on: { on: true }, dimming: { brightness: level } };
|
|
91
|
-
await c.put("grouped_light", room.groupedLightId!, body);
|
|
92
130
|
},
|
|
93
131
|
|
|
94
|
-
async
|
|
95
|
-
const
|
|
132
|
+
async room([name, ...words], o) {
|
|
133
|
+
const line = `usage: home room <name> ${CHANGE_USAGE}`;
|
|
134
|
+
if (!name) usage(line);
|
|
135
|
+
const change = changeFrom(words, line);
|
|
136
|
+
said(
|
|
137
|
+
await (
|
|
138
|
+
await client()
|
|
139
|
+
)
|
|
140
|
+
.room(name!, change, { dryRun: !!o.dryRun })
|
|
141
|
+
.catch(orUsage),
|
|
142
|
+
);
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
async scene(words, o) {
|
|
146
|
+
const line = "usage: home scene <name> [in <room>]";
|
|
147
|
+
if (!words.length) usage(line);
|
|
96
148
|
const c = await client();
|
|
97
|
-
|
|
98
|
-
|
|
149
|
+
// "Relax in Bedroom" is the scene Relax in that room, unless a scene is named all of it.
|
|
150
|
+
const at = words.lastIndexOf("in");
|
|
151
|
+
const dry = { dryRun: !!o.dryRun };
|
|
152
|
+
const whole = () => c.scene(words.join(" "), undefined, dry);
|
|
153
|
+
said(
|
|
154
|
+
await (
|
|
155
|
+
at > 0 && at < words.length - 1
|
|
156
|
+
? whole().catch(() =>
|
|
157
|
+
c.scene(
|
|
158
|
+
words.slice(0, at).join(" "),
|
|
159
|
+
words.slice(at + 1).join(" "),
|
|
160
|
+
dry,
|
|
161
|
+
),
|
|
162
|
+
)
|
|
163
|
+
: whole()
|
|
164
|
+
).catch(orUsage),
|
|
99
165
|
);
|
|
100
166
|
},
|
|
101
167
|
|
|
102
|
-
async pair() {
|
|
168
|
+
async pair(_, o) {
|
|
169
|
+
if (o.dryRun) usage("home pair has no dry run");
|
|
103
170
|
const b = await bridge();
|
|
104
|
-
|
|
171
|
+
console.error(`Hue bridge ${b.id.slice(-6).toUpperCase()} at ${b.host}.`);
|
|
172
|
+
// The countdown redraws one line on a terminal; piped, it is one line said once.
|
|
173
|
+
const live = process.stderr.isTTY;
|
|
174
|
+
const until = Date.now() + PAIR_WAIT_MS;
|
|
175
|
+
const prompt = () =>
|
|
176
|
+
`Press the link button on the bridge: ${Math.max(0, Math.ceil((until - Date.now()) / 1000))} s left`;
|
|
177
|
+
let tick: ReturnType<typeof setInterval> | undefined;
|
|
178
|
+
const { key } = await pairWhenPressed({
|
|
105
179
|
host: b.host,
|
|
106
180
|
fetch: bunPinnedFetch(b.id, ca()),
|
|
107
181
|
deviceType: `home#${hostname().slice(0, 19)}`,
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
182
|
+
timeoutMs: PAIR_WAIT_MS,
|
|
183
|
+
onWaiting() {
|
|
184
|
+
if (!live) return console.error(prompt());
|
|
185
|
+
process.stderr.write(prompt());
|
|
186
|
+
tick = setInterval(
|
|
187
|
+
() => process.stderr.write(`\r\x1b[K${prompt()}`),
|
|
188
|
+
1000,
|
|
189
|
+
);
|
|
190
|
+
},
|
|
191
|
+
})
|
|
192
|
+
.finally(() => {
|
|
193
|
+
clearInterval(tick);
|
|
194
|
+
if (tick) process.stderr.write("\r\x1b[K");
|
|
195
|
+
})
|
|
196
|
+
.catch((e) =>
|
|
197
|
+
e.code === "link_button"
|
|
198
|
+
? fail(
|
|
199
|
+
`no button press detected in ${PAIR_WAIT_MS / 1000} s; nothing was changed`,
|
|
200
|
+
"auth",
|
|
201
|
+
"home pair",
|
|
202
|
+
)
|
|
203
|
+
: Promise.reject(e),
|
|
204
|
+
);
|
|
117
205
|
console.error(
|
|
118
|
-
"Paired. Put
|
|
206
|
+
"Paired. Put these in your shell environment or .env (never in a repo):",
|
|
119
207
|
);
|
|
120
208
|
console.log(
|
|
121
209
|
`HUE_APPLICATION_KEY=${key}\nHUE_BRIDGE_HOST=${b.host}\nHUE_BRIDGE_ID=${b.id}`,
|
package/src/cli/main.ts
CHANGED
|
@@ -14,23 +14,31 @@ import {
|
|
|
14
14
|
usage,
|
|
15
15
|
type Verbs,
|
|
16
16
|
} from "./context.ts";
|
|
17
|
+
import { DAIKIN_USAGE, daikinVerbs } from "./daikin.ts";
|
|
17
18
|
import { discover } from "./discover.ts";
|
|
18
19
|
import { enphaseVerbs } from "./enphase.ts";
|
|
19
|
-
import { hueVerbs } from "./hue.ts";
|
|
20
|
+
import { CHANGE_USAGE, hueVerbs } from "./hue.ts";
|
|
20
21
|
import { locate } from "./locate.ts";
|
|
21
22
|
import { probe } from "./probe.ts";
|
|
22
23
|
import { SPOTIFY_USAGE, spotifyVerbs } from "./spotify.ts";
|
|
23
|
-
import { status } from "./status.ts";
|
|
24
|
+
import { devices, status } from "./status.ts";
|
|
24
25
|
import { UP_OPTIONS, UP_USAGE, upVerbs } from "./up.ts";
|
|
25
26
|
|
|
26
27
|
const HUE_HELP: Record<string, string> = {
|
|
27
28
|
lights: "lights off: one write to the whole-home group",
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
light: `light <name> ${CHANGE_USAGE}`,
|
|
30
|
+
room: `room <name> ${CHANGE_USAGE}: every light in a room or zone, one write`,
|
|
31
|
+
scene: "scene <name> [in <room>]",
|
|
32
|
+
pair: "pair: waits 60 s for the bridge's button, counting down; prints the env lines to set",
|
|
31
33
|
};
|
|
32
34
|
|
|
33
35
|
/** Each connector's verbs beyond its status, the usage a wrong one prints, and any options of its own. */
|
|
36
|
+
/** Only where a write reads it: on any other command cac refuses it, so `--dry-run` never lets a real write through. */
|
|
37
|
+
const DRY_RUN: [string, string] = [
|
|
38
|
+
"--dry-run",
|
|
39
|
+
"check the write and print what would be sent; send nothing",
|
|
40
|
+
];
|
|
41
|
+
|
|
34
42
|
const VERBS: Record<
|
|
35
43
|
string,
|
|
36
44
|
{
|
|
@@ -41,10 +49,12 @@ const VERBS: Record<
|
|
|
41
49
|
> = {
|
|
42
50
|
hue: {
|
|
43
51
|
verbs: hueVerbs,
|
|
52
|
+
options: [DRY_RUN],
|
|
44
53
|
usage: `usage: home hue ${Object.values(HUE_HELP)
|
|
45
54
|
.map((h) => h.split(":")[0])
|
|
46
55
|
.join(" | ")}`,
|
|
47
56
|
},
|
|
57
|
+
daikin: { verbs: daikinVerbs, usage: DAIKIN_USAGE, options: [DRY_RUN] },
|
|
48
58
|
enphase: { verbs: enphaseVerbs, usage: "usage: home enphase login" },
|
|
49
59
|
cast: { verbs: castVerbs, usage: CAST_USAGE, options: CAST_OPTIONS },
|
|
50
60
|
up: { verbs: upVerbs, usage: UP_USAGE, options: UP_OPTIONS },
|
|
@@ -66,6 +76,12 @@ const statusCommand = cli
|
|
|
66
76
|
.alias("doctor")
|
|
67
77
|
.alias("!") // cac's marker for the default command: `home` alone runs status
|
|
68
78
|
.action((o: Options) => status(o));
|
|
79
|
+
cli
|
|
80
|
+
.command(
|
|
81
|
+
"devices",
|
|
82
|
+
"every device the connectors read, one line each: its state, its room, and why when it isn't available",
|
|
83
|
+
)
|
|
84
|
+
.action((o: Options) => devices(o));
|
|
69
85
|
cli
|
|
70
86
|
.command(
|
|
71
87
|
"discover",
|
|
@@ -123,10 +139,11 @@ for (const m of MANIFESTS) {
|
|
|
123
139
|
);
|
|
124
140
|
}
|
|
125
141
|
// Hue's verbs also stand alone, as typed before the CLI had `home hue`: `home lights off`, `home room …`, `home scene …`, `home pair`.
|
|
126
|
-
for (const [verb, run] of Object.entries(hueVerbs))
|
|
127
|
-
cli
|
|
128
|
-
|
|
129
|
-
|
|
142
|
+
for (const [verb, run] of Object.entries(hueVerbs)) {
|
|
143
|
+
const c = cli.command(`${verb} [...args]`, `Hue: ${HUE_HELP[verb]}`);
|
|
144
|
+
if (verb !== "pair") c.option(...DRY_RUN);
|
|
145
|
+
c.action((args: string[], o: Options) => run(args, o));
|
|
146
|
+
}
|
|
130
147
|
|
|
131
148
|
cli.help();
|
|
132
149
|
|
|
@@ -150,7 +167,15 @@ function describe(e: unknown): {
|
|
|
150
167
|
e instanceof Error &&
|
|
151
168
|
(e.name === "TimeoutError" || e.name === "AbortError" || socket)
|
|
152
169
|
)
|
|
153
|
-
return {
|
|
170
|
+
return {
|
|
171
|
+
// macOS answers "no route to host" for a LAN address when the terminal app lacks the Local Network permission.
|
|
172
|
+
error:
|
|
173
|
+
(e as { code?: string }).code === "EHOSTUNREACH" &&
|
|
174
|
+
process.platform === "darwin"
|
|
175
|
+
? `${error} If the device is up, allow this terminal under System Settings > Privacy & Security > Local Network.`
|
|
176
|
+
: error,
|
|
177
|
+
code: "network",
|
|
178
|
+
};
|
|
154
179
|
return { error, code: "error" };
|
|
155
180
|
}
|
|
156
181
|
|
package/src/cli/probe.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
printerList,
|
|
7
7
|
} from "../connectors/printers/index.ts";
|
|
8
8
|
import { gatewayHost } from "../connectors/enphase/client.ts";
|
|
9
|
+
import { fetchTimeout } from "../connector.ts";
|
|
9
10
|
import { bunPinnedFetch } from "../connectors/hue/index.ts";
|
|
10
11
|
import { type WeatherSource } from "../connectors/weather/index.ts";
|
|
11
12
|
import {
|
|
@@ -39,7 +40,7 @@ function envFromConfig(path: string): Record<string, string> {
|
|
|
39
40
|
Object.assign(out, {
|
|
40
41
|
HUE_BRIDGE_HOST: d.host,
|
|
41
42
|
HUE_BRIDGE_ID: d.bridgeId,
|
|
42
|
-
HUE_CA_FILE: fromConfig(path, d.ca),
|
|
43
|
+
HUE_CA_FILE: d.ca && fromConfig(path, d.ca),
|
|
43
44
|
HUE_APPLICATION_KEY: env[d.key],
|
|
44
45
|
});
|
|
45
46
|
if (d.connector === "daikin") out.DAIKIN_HOST = d.host;
|
|
@@ -111,25 +112,28 @@ const jsonOr = (body: string) => {
|
|
|
111
112
|
|
|
112
113
|
export async function probe(o: Options): Promise<void> {
|
|
113
114
|
const configPath = configPathOf(o);
|
|
114
|
-
if (configPath)
|
|
115
|
+
if (configPath) {
|
|
116
|
+
delete env.HUE_CA_FILE; // the config's `ca` or the package's roots, never the shell's file
|
|
117
|
+
Object.assign(env, envFromConfig(configPath)); // the config's addresses, for this one command
|
|
118
|
+
}
|
|
115
119
|
const out: Record<string, unknown> = {};
|
|
116
120
|
try {
|
|
117
121
|
const b = await bridge();
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
: "
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
122
|
+
const r = await fetchTimeout(
|
|
123
|
+
bunPinnedFetch(b.id, ca()),
|
|
124
|
+
`https://${b.host}/api/0/config`,
|
|
125
|
+
{},
|
|
126
|
+
{ timeoutMs: 5000 },
|
|
127
|
+
).then((x) => x.json());
|
|
128
|
+
out.hue = {
|
|
129
|
+
status: "answers",
|
|
130
|
+
...pick(r as Record<string, unknown>, [
|
|
131
|
+
"name",
|
|
132
|
+
"modelid",
|
|
133
|
+
"apiversion",
|
|
134
|
+
"swversion",
|
|
135
|
+
]),
|
|
136
|
+
};
|
|
133
137
|
} catch (error) {
|
|
134
138
|
out.hue = { status: "unavailable", detail: (error as Error).message };
|
|
135
139
|
}
|
package/src/cli/status.ts
CHANGED
|
@@ -12,13 +12,15 @@ import {
|
|
|
12
12
|
type WeatherSource,
|
|
13
13
|
} from "../connectors/weather/index.ts";
|
|
14
14
|
import { ftpFetch } from "../connectors/weather/ftp.ts";
|
|
15
|
-
import { loadHome } from "../config.ts";
|
|
15
|
+
import { HomeConfig, loadHome } from "../config.ts";
|
|
16
|
+
import { DEVICES, type HomeDevice, homeDevices } from "../devices.ts";
|
|
16
17
|
import { energyNow, gridText } from "../energy.ts";
|
|
17
18
|
import { FINDERS } from "../finders.ts";
|
|
18
19
|
import { type Integration, type Part, type Status, settle } from "../home.ts";
|
|
19
20
|
import { kw, type Manifest } from "../manifest.ts";
|
|
20
21
|
import { MANIFESTS, manifestOf } from "../manifests.ts";
|
|
21
22
|
import { castNow } from "./cast.ts";
|
|
23
|
+
import { daikinHost } from "./daikin.ts";
|
|
22
24
|
import { printersNow } from "./printers.ts";
|
|
23
25
|
import { spotifyFromEnv } from "./spotify.ts";
|
|
24
26
|
import {
|
|
@@ -62,17 +64,25 @@ export const weatherSources = (): WeatherSource[] => [
|
|
|
62
64
|
|
|
63
65
|
/**
|
|
64
66
|
* Each connector from the shell's env names, once the manifest's non-optional names are set. `needs` adds a step that
|
|
65
|
-
* is not an env name (Enphase's token file). Addresses come from the env
|
|
67
|
+
* is not an env name (Enphase's token file). Addresses come from the env, except the Daikin's and the Hue bridge's, which
|
|
68
|
+
* are searched for when unset; `home probe` and `home discover` search for the rest.
|
|
66
69
|
*/
|
|
67
70
|
const FROM_ENV: Record<
|
|
68
71
|
string,
|
|
69
72
|
{
|
|
70
|
-
needs?: () => string[]
|
|
73
|
+
needs?: () => string[] | Promise<string[]>;
|
|
71
74
|
build: () => Integration<any> | Promise<Integration<any>>;
|
|
72
75
|
}
|
|
73
76
|
> = {
|
|
74
77
|
hue: { build: hueFromEnv },
|
|
75
|
-
daikin: {
|
|
78
|
+
daikin: {
|
|
79
|
+
// No address set: ask the network. A home with no unit reads "not set up", not as a failure.
|
|
80
|
+
needs: async () =>
|
|
81
|
+
(await daikinHost())
|
|
82
|
+
? []
|
|
83
|
+
: ["DAIKIN_HOST (no unit answered the UDP broadcast)"],
|
|
84
|
+
build: async () => daikin({ host: (await daikinHost())!, fetch }),
|
|
85
|
+
},
|
|
76
86
|
enphase: {
|
|
77
87
|
needs: () => (existsSync(TOKEN_FILE) ? [] : ["`home enphase login`"]),
|
|
78
88
|
build: enphaseFromEnv,
|
|
@@ -106,7 +116,7 @@ async function statusFromEnv(
|
|
|
106
116
|
const from = FROM_ENV[m.name]!;
|
|
107
117
|
const needs = [
|
|
108
118
|
...m.env.filter((e) => !e.optional && !env[e.name]).map((e) => e.name),
|
|
109
|
-
...(from.needs?.() ?? []),
|
|
119
|
+
...((await from.needs?.()) ?? []),
|
|
110
120
|
];
|
|
111
121
|
rows[m.name] = {
|
|
112
122
|
status: needs.length ? "not set up" : "connecting",
|
|
@@ -216,13 +226,54 @@ function rowLines(name: string, r: Row): string[] {
|
|
|
216
226
|
];
|
|
217
227
|
}
|
|
218
228
|
|
|
229
|
+
/** Every connector's row (or the one named), from the config at `path`, else from the shell's env names. */
|
|
230
|
+
const rowsOf = (path: string | undefined, only?: string) =>
|
|
231
|
+
path
|
|
232
|
+
? statusFromConfig(path, only)
|
|
233
|
+
: statusFromEnv(only === undefined ? MANIFESTS : [manifestOf(only)!]);
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* `home devices`: every device the connectors read, one line each from `homeDevices`, with its room and name from the
|
|
237
|
+
* config when there is one. A connector that isn't connected is named on stderr.
|
|
238
|
+
*/
|
|
239
|
+
export async function devices(o: Options): Promise<void> {
|
|
240
|
+
const path = configPathOf(o);
|
|
241
|
+
const rows = await rowsOf(path);
|
|
242
|
+
const parts: Record<string, Part> = {};
|
|
243
|
+
for (const [name, r] of Object.entries(rows))
|
|
244
|
+
if (r.status !== "not set up") parts[name] = r as Part;
|
|
245
|
+
// loadHome has already accepted the config, so a parse that fails here is not expected: list without rooms.
|
|
246
|
+
const config = path ? HomeConfig.safeParse(readConfig(path)).data : undefined;
|
|
247
|
+
const list = homeDevices(parts, config);
|
|
248
|
+
// A connector that isn't connected may have no device to list: say so, off stdout.
|
|
249
|
+
for (const [name, r] of Object.entries(rows))
|
|
250
|
+
if (name in DEVICES && r.status !== "connected")
|
|
251
|
+
console.error(
|
|
252
|
+
[
|
|
253
|
+
`${name}: ${r.status}`,
|
|
254
|
+
r.needs.length ? `needs ${r.needs.join(", ")}` : "",
|
|
255
|
+
r.detail ?? "",
|
|
256
|
+
]
|
|
257
|
+
.filter(Boolean)
|
|
258
|
+
.join(" · "),
|
|
259
|
+
);
|
|
260
|
+
if (o.json) return printJson(list);
|
|
261
|
+
for (const d of list) console.log(deviceLine(d));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** A device's line: "connector text · in room · not available: reason". */
|
|
265
|
+
export const deviceLine = (d: HomeDevice): string =>
|
|
266
|
+
[
|
|
267
|
+
`${d.connector.padEnd(9)}${d.text}`,
|
|
268
|
+
d.room ? `in ${d.room}` : "",
|
|
269
|
+
d.available === false ? `not available: ${d.reason}` : "",
|
|
270
|
+
]
|
|
271
|
+
.filter(Boolean)
|
|
272
|
+
.join(" · ");
|
|
273
|
+
|
|
219
274
|
/** `home status`: every connector, with the energy now on top. With `only` (`home <connector>`), that connector alone. */
|
|
220
275
|
export async function status(o: Options, only?: string): Promise<void> {
|
|
221
|
-
const
|
|
222
|
-
const manifests = only === undefined ? MANIFESTS : [manifestOf(only)!];
|
|
223
|
-
const rows = path
|
|
224
|
-
? await statusFromConfig(path, only)
|
|
225
|
-
: await statusFromEnv(manifests);
|
|
276
|
+
const rows = await rowsOf(configPathOf(o), only);
|
|
226
277
|
if (only !== undefined) {
|
|
227
278
|
const row = rows[only]!;
|
|
228
279
|
if (row.status !== "connected") process.exitCode = 1; // `home <connector>` is a check a script can test; `home` alone lists every one and exits 0
|
package/src/config.ts
CHANGED
|
@@ -70,6 +70,12 @@ export const Room = z.looseObject({
|
|
|
70
70
|
.describe(
|
|
71
71
|
"the Hue room, zone or grouped_light id whose lights this room's marker shows",
|
|
72
72
|
),
|
|
73
|
+
photos: z
|
|
74
|
+
.array(filePath)
|
|
75
|
+
.optional()
|
|
76
|
+
.describe(
|
|
77
|
+
"photos of the room, as paths relative to the config or from ~/; the library never reads them, a drawing agent or an app does",
|
|
78
|
+
),
|
|
73
79
|
});
|
|
74
80
|
|
|
75
81
|
/** JSONC (comments and trailing commas) through jsonc-parser, which runs on Node too; a syntax error throws rather than parsing to part of the file. */
|
|
@@ -323,7 +329,7 @@ export type LoadOptions = {
|
|
|
323
329
|
fetch?: typeof fetch;
|
|
324
330
|
/** Hue's pinned fetch and Enphase's unverified one: Bun's by default, `NODE_TLS` from `@khalido/home/node` under Node. */
|
|
325
331
|
tls?: {
|
|
326
|
-
hue(bridgeId: string, ca
|
|
332
|
+
hue(bridgeId: string, ca?: string): typeof fetch;
|
|
327
333
|
enphase(): typeof fetch;
|
|
328
334
|
};
|
|
329
335
|
/** BOM's transport: `ftpFetch()` from `@khalido/home/weather/ftp`. Without it a listed `bom` source stops and says why. */
|
|
@@ -445,7 +451,7 @@ export function loadHome(
|
|
|
445
451
|
): HomeIntegration {
|
|
446
452
|
switch (d.connector) {
|
|
447
453
|
case "hue": {
|
|
448
|
-
const ca = file(d.ca);
|
|
454
|
+
const ca = d.ca === undefined ? undefined : file(d.ca);
|
|
449
455
|
return hue({
|
|
450
456
|
...start("hue", d.bridgeId, d.host),
|
|
451
457
|
bridgeId: d.bridgeId,
|
|
@@ -10,9 +10,10 @@ Nest speakers, Nest Hubs, Chromecasts, Google TV and speaker groups, read over C
|
|
|
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
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
|
-
- `castStatus(
|
|
14
|
-
- `castFree(
|
|
13
|
+
- `castStatus(state)`: what each device and group is doing, for a caller choosing where to play something. `state` is `idle`, `restorable` (the Default Media Receiver, which `announce` puts back), `busy` (another app, playing or paused: an announce ends it) or `offline` (not connected, or not heard from yet); with the app, title, artist, volume, `via` (the group whose media a member is playing), `standby`, and a group's `members`. A group is as free as its least free connected member.
|
|
14
|
+
- `castFree(state, targets)`: the targets an announcement would not disturb, as names for `announceAll`, and `skipped` with why (`busy`, `offline`, `standby`, `unknown`, `form`). With `{ forms: ["speaker", "display"] }` a TV, or a model whose form isn't known, is skipped as `form`, and a group holding one becomes its other free members. A TV in standby is skipped, since a clip may wake it. A group stays a group while every connected member is free; one playing another app is skipped whole; otherwise a group with a busy member becomes its free members one by one (they then play out of step). A device is never named beside a free group that holds it. Both functions are pure, over the snapshot, and tested on built snapshots only: what a group's members report while it plays Spotify hasn't been checked live.
|
|
15
15
|
- 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.
|
|
16
|
+
- `castFreeSpeakers(state)`: the one call for "where can I announce now": `castFree` over every device and group, with `forms: ["speaker", "display"]`. Read live 2026-11-11 (Bun 1.4.4, reads only): 8 devices and groups gave one free group of four, with two TVs and a device whose model was not known skipped as `form`.
|
|
16
17
|
- `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 its own level first, then 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.
|
|
17
18
|
- `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.
|
|
18
19
|
- 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`.
|
|
@@ -31,7 +32,9 @@ CastV2 is TLS to port 8009 (a group: its own port), carrying length-prefixed pro
|
|
|
31
32
|
- Media status first seen live 2026-11-09 (Bun 1.4.4): a paused title and artist on a TV.
|
|
32
33
|
- **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.
|
|
33
34
|
- **On 0.1.2, 2026-11-10, from the Pi (Bun 1.4.4; chota-bot's session, KO listening).** MP3 at volume 30 on one audio-only speaker: played and restored in 2 s. To a group of four: played in 9.5 s, each member back at its own level. `setVolume` and `setMuted` on one speaker read back as set. Over Spotify playing on the speaker: played, the volume restored, Spotify listed in `notRestored`, and KO had to pick the speaker again in the Spotify app.
|
|
34
|
-
- **A group's own level was not restored** (same run; fixed
|
|
35
|
+
- **A group's own level was not restored** (same run; fixed in 0.1.3, confirmed below): after the group announce at 30 the group read 30 (it had been 53) though every member was back; setting the group to 53 afterwards did not move the members. `restoreVolume` restored members only; fixed in 0.1.3: the group's own level goes back first, then each member's.
|
|
36
|
+
- **From a Mac, 2026-11-11 (Bun 1.4.4).** `home cast announce --to <a Nest Hub> --say … --volume 40`: played, idle and volume restored, 6 s in all; KO heard it.
|
|
37
|
+
- **On 0.1.3, 2026-11-11, from the Pi (Bun 1.4.4; chota-bot's session).** A group announce at volume 30: played, and the group and all four members read their earlier levels straight after and 10 s later. With Spotify cast to the group of four: every member read the group's track "via" the group at its own level, `castStatus` gave each member and the group `busy`, and `castFree` over every target left only the idle TV free. So what `castFree` assumes members report is what they report. `castFree` over every target at idle: the group and an idle TV free, a TV with Spotify paused skipped as busy, a device not in discovery skipped as unknown.
|
|
35
38
|
- Not seen yet: `stop`, an announce over a Default Media Receiver stream, `announceAll`, a leader change, member changes, and Node's `tls` (no Node caller yet).
|
|
36
39
|
|
|
37
40
|
## TLS: verification off, the name checked
|
|
@@ -37,7 +37,13 @@ import {
|
|
|
37
37
|
} from "./writes.ts";
|
|
38
38
|
|
|
39
39
|
export type { CastConnect } from "./codec.ts";
|
|
40
|
-
export {
|
|
40
|
+
export {
|
|
41
|
+
castDevices,
|
|
42
|
+
castForm,
|
|
43
|
+
castFree,
|
|
44
|
+
castFreeSpeakers,
|
|
45
|
+
castStatus,
|
|
46
|
+
} from "./state.ts";
|
|
41
47
|
export type {
|
|
42
48
|
CastDevice,
|
|
43
49
|
CastForm,
|