@khalido/home 0.1.2 → 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 +74 -2
- package/README.md +7 -2
- package/dist/clock.js +2 -0
- package/dist/config.d.ts +5 -3
- package/dist/config.js +5 -1
- package/dist/connector.d.ts +9 -2
- package/dist/connectors/amber/index.js +1 -0
- package/dist/connectors/cast/index.d.ts +8 -6
- package/dist/connectors/cast/index.js +30 -4
- package/dist/connectors/cast/state.d.ts +66 -0
- package/dist/connectors/cast/state.js +141 -0
- package/dist/connectors/cast/writes.d.ts +0 -2
- package/dist/connectors/cast/writes.js +25 -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 +8 -4
- package/dist/connectors/daikin/index.js +25 -4
- package/dist/connectors/enphase/index.d.ts +5 -1
- package/dist/connectors/enphase/index.js +34 -2
- 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 +21 -6
- package/dist/connectors/hue/index.js +59 -9
- 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/printers/index.d.ts +6 -2
- package/dist/connectors/printers/index.js +23 -0
- package/dist/connectors/spotify/changes.d.ts +19 -5
- package/dist/connectors/spotify/changes.js +29 -7
- package/dist/connectors/spotify/index.d.ts +7 -2
- package/dist/connectors/spotify/index.js +34 -3
- package/dist/connectors/up/index.js +1 -0
- package/dist/connectors/weather/bom.js +1 -0
- package/dist/connectors/weather/google.js +52 -9
- package/dist/connectors/weather/index.d.ts +2 -1
- package/dist/connectors/weather/index.js +7 -0
- package/dist/connectors/weather/source.d.ts +2 -0
- package/dist/devices.d.ts +51 -0
- package/dist/devices.js +115 -0
- 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/health.d.ts +2 -1
- package/dist/health.js +2 -1
- package/dist/home.d.ts +52 -5
- package/dist/home.js +114 -33
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -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 +18 -5
- package/docs/decisions.md +16 -0
- package/docs/hue.md +161 -0
- package/docs/using.md +115 -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 +10 -10
- package/src/cli/cast.ts +51 -5
- package/src/cli/context.ts +2 -0
- package/src/cli/daikin.ts +71 -0
- package/src/cli/enphase.ts +5 -1
- 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 +87 -13
- package/src/clock.ts +1 -0
- package/src/config.ts +8 -2
- package/src/connector.ts +18 -2
- package/src/connectors/amber/index.ts +1 -0
- package/src/connectors/cast/README.md +10 -3
- package/src/connectors/cast/index.ts +65 -7
- package/src/connectors/cast/state.ts +204 -0
- package/src/connectors/cast/writes.ts +29 -8
- package/src/connectors/daikin/README.md +20 -4
- package/src/connectors/daikin/client.ts +104 -3
- package/src/connectors/daikin/index.ts +49 -4
- package/src/connectors/enphase/index.ts +49 -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 +94 -12
- package/src/connectors/hue/schemas.ts +26 -0
- package/src/connectors/hue/state.ts +27 -1
- package/src/connectors/printers/README.md +4 -2
- package/src/connectors/printers/index.ts +43 -1
- package/src/connectors/spotify/README.md +21 -3
- package/src/connectors/spotify/changes.ts +38 -9
- package/src/connectors/spotify/index.ts +55 -3
- package/src/connectors/up/index.ts +1 -0
- package/src/connectors/weather/README.md +1 -1
- package/src/connectors/weather/bom.ts +1 -0
- package/src/connectors/weather/google.ts +66 -10
- package/src/connectors/weather/index.ts +16 -2
- package/src/connectors/weather/source.ts +2 -0
- package/src/devices.ts +182 -0
- package/src/discover.ts +1 -2
- package/src/finders-entry.ts +1 -1
- package/src/finders.ts +18 -6
- package/src/health.ts +4 -2
- package/src/home.ts +166 -39
- package/src/index.ts +11 -1
- package/src/node-fetch.ts +6 -2
package/src/cli/cast.ts
CHANGED
|
@@ -7,9 +7,12 @@ import { tmpdir } from "node:os";
|
|
|
7
7
|
import { extname, join } from "node:path";
|
|
8
8
|
import {
|
|
9
9
|
cast,
|
|
10
|
+
type CastDevice,
|
|
10
11
|
type CastSnapshot,
|
|
12
|
+
castText,
|
|
11
13
|
resolvedFrom,
|
|
12
14
|
} from "../connectors/cast/index.ts";
|
|
15
|
+
import { findCast } from "../connectors/cast/state.ts";
|
|
13
16
|
import { serveClip } from "../connectors/cast/serve.ts";
|
|
14
17
|
import { tlsConnect } from "../connectors/cast/tls.ts";
|
|
15
18
|
import { addressBook } from "../discover.ts";
|
|
@@ -52,7 +55,10 @@ export function castNow() {
|
|
|
52
55
|
|
|
53
56
|
/** Open every Cast socket, wait until each has answered (at most 10 s), run one write over them, then close them all. */
|
|
54
57
|
async function withCast<T>(
|
|
55
|
-
act: (
|
|
58
|
+
act: (
|
|
59
|
+
c: ReturnType<typeof castNow>,
|
|
60
|
+
state: () => CastSnapshot | undefined,
|
|
61
|
+
) => Promise<T>,
|
|
56
62
|
): Promise<T> {
|
|
57
63
|
const c = castNow();
|
|
58
64
|
const stop = new AbortController();
|
|
@@ -64,7 +70,7 @@ async function withCast<T>(
|
|
|
64
70
|
Date.now() < until && (last?.status ?? "connecting") === "connecting";
|
|
65
71
|
)
|
|
66
72
|
await Bun.sleep(50);
|
|
67
|
-
return await act(c);
|
|
73
|
+
return await act(c, () => last?.state);
|
|
68
74
|
} finally {
|
|
69
75
|
stop.abort();
|
|
70
76
|
await running;
|
|
@@ -105,19 +111,59 @@ export const CAST_OPTIONS: [string, string, { type: unknown[] }?][] = [
|
|
|
105
111
|
["--volume <0-100>", "announce: the volume for the clip"],
|
|
106
112
|
];
|
|
107
113
|
|
|
114
|
+
/** How long a write waits for the device to report the change before saying it was only sent. */
|
|
115
|
+
const SHOWN_MS = 2000;
|
|
116
|
+
|
|
117
|
+
/** One write, then the device's line once it reports what was asked, or that it has not yet. */
|
|
118
|
+
const wrote = (
|
|
119
|
+
name: string,
|
|
120
|
+
write: (c: ReturnType<typeof castNow>) => Promise<void>,
|
|
121
|
+
shown: (d: CastDevice) => boolean,
|
|
122
|
+
) =>
|
|
123
|
+
withCast(async (c, state) => {
|
|
124
|
+
await write(c);
|
|
125
|
+
const now = () => {
|
|
126
|
+
const s = state();
|
|
127
|
+
const d = s && findCast(s.devices, name);
|
|
128
|
+
return s && d ? { line: castText(d, s), ok: shown(d) } : undefined;
|
|
129
|
+
};
|
|
130
|
+
for (const until = Date.now() + SHOWN_MS; Date.now() < until;) {
|
|
131
|
+
if (now()?.ok) break;
|
|
132
|
+
await Bun.sleep(50);
|
|
133
|
+
}
|
|
134
|
+
const after = now();
|
|
135
|
+
console.log(
|
|
136
|
+
after?.ok
|
|
137
|
+
? after.line
|
|
138
|
+
: `sent; the device has not reported it yet${after ? ` (${after.line})` : ""}`,
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
108
142
|
export const castVerbs: Verbs = {
|
|
109
143
|
async volume([name, value]) {
|
|
110
144
|
if (!name) usage(CAST_USAGE);
|
|
111
145
|
const level = percentLevel(value);
|
|
112
|
-
await
|
|
146
|
+
await wrote(
|
|
147
|
+
name!,
|
|
148
|
+
(c) => c.setVolume(name!, level),
|
|
149
|
+
(d) => d.volume !== undefined && Math.abs(d.volume - level) < 0.006,
|
|
150
|
+
);
|
|
113
151
|
},
|
|
114
152
|
async mute([name, value]) {
|
|
115
153
|
if (!name || (value !== "on" && value !== "off")) usage(CAST_USAGE);
|
|
116
|
-
await
|
|
154
|
+
await wrote(
|
|
155
|
+
name!,
|
|
156
|
+
(c) => c.setMuted(name!, value === "on"),
|
|
157
|
+
(d) => d.muted === (value === "on"),
|
|
158
|
+
);
|
|
117
159
|
},
|
|
118
160
|
async stop([name]) {
|
|
119
161
|
if (!name) usage(CAST_USAGE);
|
|
120
|
-
await
|
|
162
|
+
await wrote(
|
|
163
|
+
name!,
|
|
164
|
+
(c) => c.stop(name!),
|
|
165
|
+
(d) => d.idle === true,
|
|
166
|
+
);
|
|
121
167
|
},
|
|
122
168
|
async announce(_, o) {
|
|
123
169
|
const [say, file] = [text(o.say), text(o.file)];
|
package/src/cli/context.ts
CHANGED
|
@@ -38,6 +38,8 @@ export const usage = (message: string): never => fail(message, "usage");
|
|
|
38
38
|
/** The options cac parses: the global ones, and each command's own. */
|
|
39
39
|
export type Options = {
|
|
40
40
|
json?: boolean;
|
|
41
|
+
/** `--dry-run`: Hue and Daikin writes check and say what they would send, and send nothing. */
|
|
42
|
+
dryRun?: boolean;
|
|
41
43
|
config?: string;
|
|
42
44
|
write?: string | true;
|
|
43
45
|
passes?: number | string;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// `home daikin on|off` and `home daikin cool|heat|auto|dry|fan [°C]`: one write to the AC, then what it reports.
|
|
2
|
+
// Live on KO's unit (2026-11-11): on, and dry to cool at 25. Off, heat, auto, fan and a fan speed are untried.
|
|
3
|
+
import {
|
|
4
|
+
acText,
|
|
5
|
+
type DaikinApplied,
|
|
6
|
+
daikinClient,
|
|
7
|
+
DEFAULT_TARGET_C,
|
|
8
|
+
type Mode,
|
|
9
|
+
setTarget,
|
|
10
|
+
} from "../connectors/daikin/index.ts";
|
|
11
|
+
import { findDaikins } from "../finders.ts";
|
|
12
|
+
import { env, fail, type Options, usage, type Verbs } from "./context.ts";
|
|
13
|
+
|
|
14
|
+
export const DAIKIN_USAGE = `usage: home daikin on | off | cool [°C] | heat [°C] | auto [°C] | dry | fan (cool ${DEFAULT_TARGET_C.cool} and heat ${DEFAULT_TARGET_C.heat} when no °C is given)`;
|
|
15
|
+
|
|
16
|
+
let found: Promise<string | undefined> | undefined;
|
|
17
|
+
/** The unit's address: DAIKIN_HOST, else the first to answer the UDP broadcast (asked once a run). Undefined when none does. */
|
|
18
|
+
export const daikinHost = (): Promise<string | undefined> =>
|
|
19
|
+
(found ??= env.DAIKIN_HOST
|
|
20
|
+
? Promise.resolve(env.DAIKIN_HOST)
|
|
21
|
+
: findDaikins().then((units) => units[0]?.ip));
|
|
22
|
+
|
|
23
|
+
async function unit() {
|
|
24
|
+
const host =
|
|
25
|
+
(await daikinHost()) ??
|
|
26
|
+
fail("no Daikin answered the UDP broadcast; set DAIKIN_HOST", "network");
|
|
27
|
+
return daikinClient({ host, fetch });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** What a write left, in a line: the unit as it then reported, that it has not reported the change yet, or what a dry run would send. */
|
|
31
|
+
const said = ({ state, confirmed, wouldSend }: DaikinApplied) =>
|
|
32
|
+
console.log(
|
|
33
|
+
wouldSend
|
|
34
|
+
? `dry run, nothing sent: would set ${JSON.stringify(wouldSend.sets)} (now ${acText(state)})`
|
|
35
|
+
: confirmed
|
|
36
|
+
? acText(state)
|
|
37
|
+
: `sent; the unit has not reported it yet (${acText(state)})`,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const power =
|
|
41
|
+
(on: boolean) =>
|
|
42
|
+
async ([extra]: string[], o: Options) =>
|
|
43
|
+
extra
|
|
44
|
+
? usage(DAIKIN_USAGE)
|
|
45
|
+
: said(await (await unit()).power(on, { dryRun: !!o.dryRun }));
|
|
46
|
+
|
|
47
|
+
const mode =
|
|
48
|
+
(m: Mode) =>
|
|
49
|
+
async ([value, extra]: string[], o: Options) => {
|
|
50
|
+
if (extra || (value !== undefined && !/^\d+(\.\d+)?$/.test(value)))
|
|
51
|
+
usage(DAIKIN_USAGE);
|
|
52
|
+
const c = value === undefined ? undefined : Number(value);
|
|
53
|
+
// A setpoint the mode can't take is a usage error, said before looking for the unit.
|
|
54
|
+
if (c !== undefined)
|
|
55
|
+
try {
|
|
56
|
+
setTarget(c, m);
|
|
57
|
+
} catch (e) {
|
|
58
|
+
usage(`${(e as Error).message}; ${DAIKIN_USAGE}`);
|
|
59
|
+
}
|
|
60
|
+
said(await (await unit()).mode(m, c, { dryRun: !!o.dryRun }));
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const daikinVerbs: Verbs = {
|
|
64
|
+
on: power(true),
|
|
65
|
+
off: power(false),
|
|
66
|
+
cool: mode("cool"),
|
|
67
|
+
heat: mode("heat"),
|
|
68
|
+
auto: mode("auto"),
|
|
69
|
+
dry: mode("dry"),
|
|
70
|
+
fan: mode("fan"),
|
|
71
|
+
};
|
package/src/cli/enphase.ts
CHANGED
|
@@ -23,7 +23,11 @@ export function enphaseToken(): { token: string; expiresAt: Date } | undefined {
|
|
|
23
23
|
const { token } = JSON.parse(text) as { token: string };
|
|
24
24
|
return { token, expiresAt: tokenExpiry(token) };
|
|
25
25
|
} catch (error) {
|
|
26
|
-
return fail(
|
|
26
|
+
return fail(
|
|
27
|
+
`${TOKEN_FILE}: ${(error as Error).message}`,
|
|
28
|
+
"auth",
|
|
29
|
+
"home enphase login",
|
|
30
|
+
);
|
|
27
31
|
}
|
|
28
32
|
}
|
|
29
33
|
|
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
|
}
|