@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.
Files changed (116) hide show
  1. package/.env.example +2 -2
  2. package/CHANGELOG.md +74 -2
  3. package/README.md +7 -2
  4. package/dist/clock.js +2 -0
  5. package/dist/config.d.ts +5 -3
  6. package/dist/config.js +5 -1
  7. package/dist/connector.d.ts +9 -2
  8. package/dist/connectors/amber/index.js +1 -0
  9. package/dist/connectors/cast/index.d.ts +8 -6
  10. package/dist/connectors/cast/index.js +30 -4
  11. package/dist/connectors/cast/state.d.ts +66 -0
  12. package/dist/connectors/cast/state.js +141 -0
  13. package/dist/connectors/cast/writes.d.ts +0 -2
  14. package/dist/connectors/cast/writes.js +25 -7
  15. package/dist/connectors/daikin/client.d.ts +45 -2
  16. package/dist/connectors/daikin/client.js +67 -2
  17. package/dist/connectors/daikin/index.d.ts +8 -4
  18. package/dist/connectors/daikin/index.js +25 -4
  19. package/dist/connectors/enphase/index.d.ts +5 -1
  20. package/dist/connectors/enphase/index.js +34 -2
  21. package/dist/connectors/hue/ca.d.ts +2 -0
  22. package/dist/connectors/hue/ca.js +38 -0
  23. package/dist/connectors/hue/client.d.ts +81 -6
  24. package/dist/connectors/hue/client.js +217 -6
  25. package/dist/connectors/hue/color.d.ts +30 -0
  26. package/dist/connectors/hue/color.js +92 -0
  27. package/dist/connectors/hue/index.d.ts +21 -6
  28. package/dist/connectors/hue/index.js +59 -9
  29. package/dist/connectors/hue/schemas.d.ts +27 -0
  30. package/dist/connectors/hue/schemas.js +26 -0
  31. package/dist/connectors/hue/state.d.ts +10 -1
  32. package/dist/connectors/hue/state.js +20 -1
  33. package/dist/connectors/printers/index.d.ts +6 -2
  34. package/dist/connectors/printers/index.js +23 -0
  35. package/dist/connectors/spotify/changes.d.ts +19 -5
  36. package/dist/connectors/spotify/changes.js +29 -7
  37. package/dist/connectors/spotify/index.d.ts +7 -2
  38. package/dist/connectors/spotify/index.js +34 -3
  39. package/dist/connectors/up/index.js +1 -0
  40. package/dist/connectors/weather/bom.js +1 -0
  41. package/dist/connectors/weather/google.js +52 -9
  42. package/dist/connectors/weather/index.d.ts +2 -1
  43. package/dist/connectors/weather/index.js +7 -0
  44. package/dist/connectors/weather/source.d.ts +2 -0
  45. package/dist/devices.d.ts +51 -0
  46. package/dist/devices.js +115 -0
  47. package/dist/discover.js +1 -1
  48. package/dist/finders-entry.d.ts +1 -1
  49. package/dist/finders-entry.js +1 -1
  50. package/dist/finders.d.ts +2 -1
  51. package/dist/finders.js +16 -5
  52. package/dist/health.d.ts +2 -1
  53. package/dist/health.js +2 -1
  54. package/dist/home.d.ts +52 -5
  55. package/dist/home.js +114 -33
  56. package/dist/index.d.ts +3 -2
  57. package/dist/index.js +2 -1
  58. package/dist/node-fetch.d.ts +2 -2
  59. package/dist/node-fetch.js +3 -2
  60. package/docs/cli.md +13 -7
  61. package/docs/config.md +18 -5
  62. package/docs/decisions.md +16 -0
  63. package/docs/hue.md +161 -0
  64. package/docs/using.md +115 -3
  65. package/examples/home.jsonc +2 -1
  66. package/home.schema.json +10 -3
  67. package/llms.txt +19 -6
  68. package/package.json +1 -1
  69. package/skills/draw-home/SKILL.md +1 -1
  70. package/skills/use-home/SKILL.md +10 -10
  71. package/src/cli/cast.ts +51 -5
  72. package/src/cli/context.ts +2 -0
  73. package/src/cli/daikin.ts +71 -0
  74. package/src/cli/enphase.ts +5 -1
  75. package/src/cli/hue.ts +147 -59
  76. package/src/cli/main.ts +35 -10
  77. package/src/cli/probe.ts +21 -17
  78. package/src/cli/status.ts +87 -13
  79. package/src/clock.ts +1 -0
  80. package/src/config.ts +8 -2
  81. package/src/connector.ts +18 -2
  82. package/src/connectors/amber/index.ts +1 -0
  83. package/src/connectors/cast/README.md +10 -3
  84. package/src/connectors/cast/index.ts +65 -7
  85. package/src/connectors/cast/state.ts +204 -0
  86. package/src/connectors/cast/writes.ts +29 -8
  87. package/src/connectors/daikin/README.md +20 -4
  88. package/src/connectors/daikin/client.ts +104 -3
  89. package/src/connectors/daikin/index.ts +49 -4
  90. package/src/connectors/enphase/index.ts +49 -2
  91. package/src/connectors/hue/README.md +62 -17
  92. package/src/connectors/hue/ca.ts +39 -0
  93. package/src/connectors/hue/client.ts +362 -15
  94. package/src/connectors/hue/color.ts +123 -0
  95. package/src/connectors/hue/index.ts +94 -12
  96. package/src/connectors/hue/schemas.ts +26 -0
  97. package/src/connectors/hue/state.ts +27 -1
  98. package/src/connectors/printers/README.md +4 -2
  99. package/src/connectors/printers/index.ts +43 -1
  100. package/src/connectors/spotify/README.md +21 -3
  101. package/src/connectors/spotify/changes.ts +38 -9
  102. package/src/connectors/spotify/index.ts +55 -3
  103. package/src/connectors/up/index.ts +1 -0
  104. package/src/connectors/weather/README.md +1 -1
  105. package/src/connectors/weather/bom.ts +1 -0
  106. package/src/connectors/weather/google.ts +66 -10
  107. package/src/connectors/weather/index.ts +16 -2
  108. package/src/connectors/weather/source.ts +2 -0
  109. package/src/devices.ts +182 -0
  110. package/src/discover.ts +1 -2
  111. package/src/finders-entry.ts +1 -1
  112. package/src/finders.ts +18 -6
  113. package/src/health.ts +4 -2
  114. package/src/home.ts +166 -39
  115. package/src/index.ts +11 -1
  116. 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: (c: ReturnType<typeof castNow>) => Promise<T>,
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 withCast((c) => c.setVolume(name!, level));
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 withCast((c) => c.setMuted(name!, value === "on"));
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 withCast((c) => c.stop(name!));
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)];
@@ -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
+ };
@@ -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(`${TOKEN_FILE}: ${(error as Error).message}`);
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
- groups,
7
- homeGroupedLightId,
7
+ type GroupView,
8
+ type HueApplied,
9
+ type HueRequest,
10
+ HueError,
8
11
  hue,
9
12
  hueClient,
10
- pair,
11
- scenes,
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
- /** Find one room, zone or scene by name, case-insensitively; a miss lists what exists. */
54
- function byName<T extends { name?: string }>(
55
- items: T[],
56
- name: string,
57
- what: string,
58
- ): T {
59
- return (
60
- items.find((i) => i.name?.toLowerCase() === name.toLowerCase()) ??
61
- fail(`no ${what} "${name}"; have: ${items.map((i) => i.name).join(", ")}`)
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([state]) {
67
- if (state !== "off") usage("usage: home lights off");
68
- const c = await client();
69
- const id =
70
- homeGroupedLightId(snapshotFrom(await c.get(), "")) ??
71
- fail("the bridge has no home group");
72
- await c.put("grouped_light", id, { on: { on: false } });
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 room([name, value]) {
76
- if (!name || !value) usage("usage: home room <name> on|off|<1-100>");
77
- const c = await client();
78
- const room = byName(
79
- groups(snapshotFrom(await c.get(), "")),
80
- name!,
81
- "room or zone",
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 scene(words) {
95
- const name = words.join(" ") || usage("usage: home scene <name>");
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
- await c.recallScene(
98
- byName(scenes(snapshotFrom(await c.get(), "")), name, "scene").id,
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
- const { key } = await pair({
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
- }).catch((e) =>
109
- e.code === "link_button"
110
- ? fail(
111
- "press the link button on the bridge, then pair again within 30 s",
112
- "auth",
113
- "home pair",
114
- )
115
- : Promise.reject(e),
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 this in your shell environment (never in a repo):",
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
- room: "room <name> on|off|<1-100>",
29
- scene: "scene <name>",
30
- pair: "pair: press the bridge's button first; prints the env lines to set",
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
- .command(`${verb} [...args]`, `Hue: ${HUE_HELP[verb]}`)
129
- .action((args: string[], o: Options) => run(args, o));
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 { error, code: "network" };
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) Object.assign(env, envFromConfig(configPath)); // the config's addresses, for this one command
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 fetchPinned = env.HUE_CA_FILE
119
- ? bunPinnedFetch(b.id, ca())
120
- : undefined;
121
- const r = fetchPinned
122
- ? await fetchPinned(`https://${b.host}/api/0/config`).then((x) =>
123
- x.json(),
124
- )
125
- : "needs HUE_CA_FILE";
126
- out.hue =
127
- typeof r === "string"
128
- ? { status: r }
129
- : {
130
- status: "answers",
131
- ...pick(r, ["name", "modelid", "apiversion", "swversion"]),
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
  }