@khalido/home 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +25 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +77 -2
- package/dist/clock.d.ts +17 -0
- package/dist/clock.js +43 -0
- package/dist/config.d.ts +273 -0
- package/dist/config.js +392 -0
- package/dist/connector.d.ts +34 -0
- package/dist/connector.js +47 -0
- package/dist/connectors/amber/client.d.ts +61 -0
- package/dist/connectors/amber/client.js +106 -0
- package/dist/connectors/amber/index.d.ts +30 -0
- package/dist/connectors/amber/index.js +94 -0
- package/dist/connectors/cast/codec.d.ts +38 -0
- package/dist/connectors/cast/codec.js +102 -0
- package/dist/connectors/cast/index.d.ts +60 -0
- package/dist/connectors/cast/index.js +426 -0
- package/dist/connectors/cast/schemas.d.ts +167 -0
- package/dist/connectors/cast/schemas.js +87 -0
- package/dist/connectors/cast/serve.d.ts +11 -0
- package/dist/connectors/cast/serve.js +57 -0
- package/dist/connectors/cast/state.d.ts +62 -0
- package/dist/connectors/cast/state.js +133 -0
- package/dist/connectors/cast/tls.d.ts +3 -0
- package/dist/connectors/cast/tls.js +67 -0
- package/dist/connectors/cast/writes.d.ts +80 -0
- package/dist/connectors/cast/writes.js +390 -0
- package/dist/connectors/daikin/client.d.ts +65 -0
- package/dist/connectors/daikin/client.js +221 -0
- package/dist/connectors/daikin/index.d.ts +41 -0
- package/dist/connectors/daikin/index.js +108 -0
- package/dist/connectors/enphase/client.d.ts +65 -0
- package/dist/connectors/enphase/client.js +173 -0
- package/dist/connectors/enphase/index.d.ts +43 -0
- package/dist/connectors/enphase/index.js +174 -0
- package/dist/connectors/enphase/schemas.d.ts +51 -0
- package/dist/connectors/enphase/schemas.js +34 -0
- package/dist/connectors/hue/client.d.ts +48 -0
- package/dist/connectors/hue/client.js +95 -0
- package/dist/connectors/hue/events.d.ts +41 -0
- package/dist/connectors/hue/events.js +156 -0
- package/dist/connectors/hue/index.d.ts +42 -0
- package/dist/connectors/hue/index.js +205 -0
- package/dist/connectors/hue/schemas.d.ts +65 -0
- package/dist/connectors/hue/schemas.js +48 -0
- package/dist/connectors/hue/state.d.ts +43 -0
- package/dist/connectors/hue/state.js +84 -0
- package/dist/connectors/printers/client.d.ts +28 -0
- package/dist/connectors/printers/client.js +74 -0
- package/dist/connectors/printers/codec.d.ts +76 -0
- package/dist/connectors/printers/codec.js +246 -0
- package/dist/connectors/printers/found.d.ts +15 -0
- package/dist/connectors/printers/found.js +35 -0
- package/dist/connectors/printers/index.d.ts +52 -0
- package/dist/connectors/printers/index.js +282 -0
- package/dist/connectors/printers/state.d.ts +60 -0
- package/dist/connectors/printers/state.js +99 -0
- package/dist/connectors/spotify/changes.d.ts +32 -0
- package/dist/connectors/spotify/changes.js +84 -0
- package/dist/connectors/spotify/client.d.ts +199 -0
- package/dist/connectors/spotify/client.js +270 -0
- package/dist/connectors/spotify/index.d.ts +92 -0
- package/dist/connectors/spotify/index.js +745 -0
- package/dist/connectors/up/client.d.ts +183 -0
- package/dist/connectors/up/client.js +308 -0
- package/dist/connectors/up/index.d.ts +91 -0
- package/dist/connectors/up/index.js +213 -0
- package/dist/connectors/up/spend.d.ts +78 -0
- package/dist/connectors/up/spend.js +213 -0
- package/dist/connectors/up/webhook.d.ts +13 -0
- package/dist/connectors/up/webhook.js +32 -0
- package/dist/connectors/weather/blocks.d.ts +51 -0
- package/dist/connectors/weather/blocks.js +106 -0
- package/dist/connectors/weather/bom.d.ts +37 -0
- package/dist/connectors/weather/bom.js +237 -0
- package/dist/connectors/weather/ftp.d.ts +5 -0
- package/dist/connectors/weather/ftp.js +46 -0
- package/dist/connectors/weather/google.d.ts +31 -0
- package/dist/connectors/weather/google.js +364 -0
- package/dist/connectors/weather/index.d.ts +53 -0
- package/dist/connectors/weather/index.js +267 -0
- package/dist/connectors/weather/open-meteo.d.ts +15 -0
- package/dist/connectors/weather/open-meteo.js +285 -0
- package/dist/connectors/weather/rain.d.ts +19 -0
- package/dist/connectors/weather/rain.js +51 -0
- package/dist/connectors/weather/source.d.ts +149 -0
- package/dist/connectors/weather/source.js +77 -0
- package/dist/connectors/weather/sun.d.ts +48 -0
- package/dist/connectors/weather/sun.js +65 -0
- package/dist/discover.d.ts +195 -0
- package/dist/discover.js +446 -0
- package/dist/energy.d.ts +61 -0
- package/dist/energy.js +162 -0
- package/dist/finders-entry.d.ts +1 -0
- package/dist/finders-entry.js +2 -0
- package/dist/finders.d.ts +45 -0
- package/dist/finders.js +173 -0
- package/dist/health.d.ts +28 -0
- package/dist/health.js +35 -0
- package/dist/home.d.ts +81 -0
- package/dist/home.js +114 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/lane.d.ts +17 -0
- package/dist/lane.js +74 -0
- package/dist/locate.d.ts +27 -0
- package/dist/locate.js +59 -0
- package/dist/manifest.d.ts +31 -0
- package/dist/manifest.js +4 -0
- package/dist/manifests.d.ts +5 -0
- package/dist/manifests.js +23 -0
- package/dist/node-fetch.d.ts +9 -0
- package/dist/node-fetch.js +25 -0
- package/dist/poll.d.ts +10 -0
- package/dist/poll.js +33 -0
- package/docs/cast.md +133 -0
- package/docs/cli.md +39 -0
- package/docs/config.md +36 -0
- package/docs/decisions.md +150 -0
- package/docs/devices.md +128 -0
- package/docs/enphase-notes.md +24 -0
- package/docs/google.md +92 -0
- package/docs/ideas.md +69 -0
- package/docs/matter.md +55 -0
- package/docs/performance.md +12 -0
- package/docs/philosophy.md +23 -0
- package/docs/up.md +84 -0
- package/docs/using.md +65 -0
- package/docs/weather-probe.md +63 -0
- package/examples/home.jsonc +74 -0
- package/examples/home.svg +76 -0
- package/home.schema.json +474 -0
- package/llms.txt +32 -0
- package/package.json +143 -4
- package/skills/draw-home/README.md +26 -0
- package/skills/draw-home/SKILL.md +82 -0
- package/skills/use-home/SKILL.md +86 -0
- package/src/cli/cache.ts +29 -0
- package/src/cli/cast.ts +172 -0
- package/src/cli/config.ts +52 -0
- package/src/cli/context.ts +118 -0
- package/src/cli/discover.ts +175 -0
- package/src/cli/enphase.ts +62 -0
- package/src/cli/hue.ts +124 -0
- package/src/cli/locate.ts +47 -0
- package/src/cli/main.ts +182 -0
- package/src/cli/printers.ts +21 -0
- package/src/cli/probe.ts +274 -0
- package/src/cli/spotify.ts +76 -0
- package/src/cli/status.ts +228 -0
- package/src/cli/up.ts +350 -0
- package/src/clock.ts +62 -0
- package/src/config.ts +578 -0
- package/src/connector.ts +77 -0
- package/src/connectors/amber/README.md +40 -0
- package/src/connectors/amber/client.ts +157 -0
- package/src/connectors/amber/index.ts +118 -0
- package/src/connectors/cast/README.md +43 -0
- package/src/connectors/cast/codec.ts +126 -0
- package/src/connectors/cast/index.ts +551 -0
- package/src/connectors/cast/schemas.ts +101 -0
- package/src/connectors/cast/serve.ts +72 -0
- package/src/connectors/cast/state.ts +203 -0
- package/src/connectors/cast/tls.ts +100 -0
- package/src/connectors/cast/writes.ts +572 -0
- package/src/connectors/daikin/README.md +78 -0
- package/src/connectors/daikin/client.ts +295 -0
- package/src/connectors/daikin/index.ts +145 -0
- package/src/connectors/enphase/README.md +64 -0
- package/src/connectors/enphase/client.ts +244 -0
- package/src/connectors/enphase/index.ts +241 -0
- package/src/connectors/enphase/schemas.ts +45 -0
- package/src/connectors/hue/README.md +50 -0
- package/src/connectors/hue/client.ts +157 -0
- package/src/connectors/hue/events.ts +182 -0
- package/src/connectors/hue/index.ts +261 -0
- package/src/connectors/hue/schemas.ts +60 -0
- package/src/connectors/hue/state.ts +124 -0
- package/src/connectors/printers/README.md +46 -0
- package/src/connectors/printers/client.ts +114 -0
- package/src/connectors/printers/codec.ts +292 -0
- package/src/connectors/printers/found.ts +46 -0
- package/src/connectors/printers/index.ts +385 -0
- package/src/connectors/printers/state.ts +182 -0
- package/src/connectors/spotify/README.md +80 -0
- package/src/connectors/spotify/changes.ts +127 -0
- package/src/connectors/spotify/client.ts +387 -0
- package/src/connectors/spotify/index.ts +929 -0
- package/src/connectors/up/README.md +35 -0
- package/src/connectors/up/client.ts +518 -0
- package/src/connectors/up/index.ts +370 -0
- package/src/connectors/up/spend.ts +280 -0
- package/src/connectors/up/webhook.ts +62 -0
- package/src/connectors/weather/README.md +84 -0
- package/src/connectors/weather/blocks.ts +173 -0
- package/src/connectors/weather/bom.ts +344 -0
- package/src/connectors/weather/ftp.ts +48 -0
- package/src/connectors/weather/google.ts +425 -0
- package/src/connectors/weather/index.ts +380 -0
- package/src/connectors/weather/open-meteo.ts +309 -0
- package/src/connectors/weather/rain.ts +73 -0
- package/src/connectors/weather/source.ts +236 -0
- package/src/connectors/weather/sun.ts +114 -0
- package/src/discover.ts +634 -0
- package/src/energy.ts +221 -0
- package/src/finders-entry.ts +2 -0
- package/src/finders.ts +247 -0
- package/src/health.ts +74 -0
- package/src/home.ts +205 -0
- package/src/index.ts +62 -0
- package/src/lane.ts +110 -0
- package/src/locate.ts +85 -0
- package/src/manifest.ts +43 -0
- package/src/manifests.ts +27 -0
- package/src/node-fetch.ts +41 -0
- package/src/poll.ts +33 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// `home discover [--passes n] [--write [path]]`: what smart-home devices answer on this network, and which this package
|
|
2
|
+
// supports. mDNS (macOS dns-sd) plus Daikin's UDP broadcast; with a config, how they match it.
|
|
3
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { realClock } from "../clock.ts";
|
|
5
|
+
import { checkPlan, parseJsonc } from "../config.ts";
|
|
6
|
+
import {
|
|
7
|
+
addressBook,
|
|
8
|
+
applyFound,
|
|
9
|
+
discover as discoverAll,
|
|
10
|
+
DISCOVERY_GAP_MS,
|
|
11
|
+
DISCOVERY_PASSES,
|
|
12
|
+
type Found,
|
|
13
|
+
foundKey,
|
|
14
|
+
KNOWN_SERVICES,
|
|
15
|
+
matchFound,
|
|
16
|
+
} from "../discover.ts";
|
|
17
|
+
import { browse, daikinFound, FINDERS, findDaikins } from "../finders.ts";
|
|
18
|
+
import {
|
|
19
|
+
addresses,
|
|
20
|
+
configPathOf,
|
|
21
|
+
fail,
|
|
22
|
+
type Options,
|
|
23
|
+
printJson,
|
|
24
|
+
writeTo,
|
|
25
|
+
} from "./context.ts";
|
|
26
|
+
|
|
27
|
+
export async function discover(o: Options): Promise<void> {
|
|
28
|
+
const write = writeTo(o);
|
|
29
|
+
const path = write || configPathOf(o);
|
|
30
|
+
if (write !== undefined && !path)
|
|
31
|
+
fail("--write needs a path, --config or HOME_CONFIG");
|
|
32
|
+
const text = path ? readFileSync(path, "utf8") : undefined;
|
|
33
|
+
const plan = text === undefined ? undefined : checkPlan(parseJsonc(text));
|
|
34
|
+
if (plan && !plan.config) fail(`${path}: ${plan.problems.join("; ")}`);
|
|
35
|
+
const passes = o.passes === undefined ? undefined : Number(o.passes);
|
|
36
|
+
if (passes !== undefined && !(Number.isInteger(passes) && passes >= 1))
|
|
37
|
+
fail(`--passes must be a whole number from 1, got ${o.passes}`);
|
|
38
|
+
// One pass: every connector's discovery (Daikin's broadcast once), and the instances of every other known mDNS type.
|
|
39
|
+
type Seen = { what: string; connector?: string; name: string; found?: Found };
|
|
40
|
+
const what = (c: string) =>
|
|
41
|
+
c === "daikin"
|
|
42
|
+
? "Daikin AC (UDP broadcast)"
|
|
43
|
+
: Object.values(KNOWN_SERVICES).find((s) => s.connector === c)!.what;
|
|
44
|
+
const pass = async (): Promise<Seen[]> => {
|
|
45
|
+
const others = Object.entries(KNOWN_SERVICES).filter(
|
|
46
|
+
([, info]) => !info.connector,
|
|
47
|
+
);
|
|
48
|
+
const [services, daikins, mdns] = await Promise.all([
|
|
49
|
+
Promise.all(
|
|
50
|
+
others.map(async ([type, info]) =>
|
|
51
|
+
(await browse(type)).map((name) => ({ what: info.what, name })),
|
|
52
|
+
),
|
|
53
|
+
).then((all) => all.flat()),
|
|
54
|
+
findDaikins(),
|
|
55
|
+
Promise.all([
|
|
56
|
+
FINDERS.hue(),
|
|
57
|
+
FINDERS.enphase(),
|
|
58
|
+
FINDERS.cast(),
|
|
59
|
+
FINDERS.printers(),
|
|
60
|
+
]).then((all) => all.flat()),
|
|
61
|
+
]);
|
|
62
|
+
return [
|
|
63
|
+
...[...mdns, ...daikins.flatMap(daikinFound)].map((f) => ({
|
|
64
|
+
what: what(f.connector),
|
|
65
|
+
connector: f.connector,
|
|
66
|
+
name: f.name ?? f.id,
|
|
67
|
+
found: f,
|
|
68
|
+
})),
|
|
69
|
+
...services,
|
|
70
|
+
];
|
|
71
|
+
};
|
|
72
|
+
const listing = (seen: Seen[]) => {
|
|
73
|
+
for (const w of [...new Set(seen.map((x) => x.what))]) {
|
|
74
|
+
const rows = seen.filter((x) => x.what === w);
|
|
75
|
+
const connector = rows[0]!.connector;
|
|
76
|
+
console.log(
|
|
77
|
+
`${w} · ${rows.length} found · ${connector ? `supported: ${connector}` : "not supported"}`,
|
|
78
|
+
);
|
|
79
|
+
if (connector) for (const r of rows) console.log(` ${r.name}`);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
const seen = await discoverAll(pass, {
|
|
83
|
+
clock: realClock,
|
|
84
|
+
passes,
|
|
85
|
+
key: (x) => (x.found ? foundKey(x.found) : `${x.what}:${x.name}`),
|
|
86
|
+
onPass: (n, added) => {
|
|
87
|
+
if (o.json) return;
|
|
88
|
+
const tty = process.stderr.isTTY;
|
|
89
|
+
const label = (k: number) => (k === 2 ? "second pass" : `pass ${k}`);
|
|
90
|
+
if (tty && n > 1) process.stderr.write("\r\x1b[K");
|
|
91
|
+
if (n === 1) listing(added);
|
|
92
|
+
else
|
|
93
|
+
console.log(
|
|
94
|
+
added.length
|
|
95
|
+
? added
|
|
96
|
+
.map((x) => `${label(n)}: found ${x.what}: ${x.name}`)
|
|
97
|
+
.join("\n")
|
|
98
|
+
: `${label(n)}: nothing new`,
|
|
99
|
+
);
|
|
100
|
+
// The wait before the next pass is otherwise silent: say so on stderr, where a pipe does not see it.
|
|
101
|
+
if (tty && n < (passes ?? DISCOVERY_PASSES))
|
|
102
|
+
process.stderr.write(
|
|
103
|
+
`${label(n + 1)} in ${DISCOVERY_GAP_MS / 1000} s…`,
|
|
104
|
+
);
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
const found = seen.flatMap((x) => (x.found ? [x.found] : []));
|
|
108
|
+
const rows = [...new Set(seen.map((x) => x.what))].map((w) => {
|
|
109
|
+
const these = seen.filter((x) => x.what === w);
|
|
110
|
+
return {
|
|
111
|
+
what: w,
|
|
112
|
+
connector: these[0]!.connector,
|
|
113
|
+
names: these.map((x) => x.name),
|
|
114
|
+
};
|
|
115
|
+
});
|
|
116
|
+
// With a config: match what answers by identity; write moved hosts and new devices only with --write, and only a result that checks.
|
|
117
|
+
const match = plan?.config && matchFound(plan.config, found);
|
|
118
|
+
let wrote: string | undefined;
|
|
119
|
+
const changes = match
|
|
120
|
+
? match.moved.length + match.renamed.length + match.added.length
|
|
121
|
+
: 0;
|
|
122
|
+
if (match && write !== undefined && changes) {
|
|
123
|
+
const out = applyFound(text!, match, !!plan!.config!.devices?.length);
|
|
124
|
+
let after: string[];
|
|
125
|
+
try {
|
|
126
|
+
after = checkPlan(parseJsonc(out)).problems;
|
|
127
|
+
} catch (error) {
|
|
128
|
+
after = [String(error)];
|
|
129
|
+
}
|
|
130
|
+
if (after.length)
|
|
131
|
+
fail(
|
|
132
|
+
`${path} not written: the result would have problems: ${after.join("; ")}`,
|
|
133
|
+
);
|
|
134
|
+
writeFileSync(path!, out);
|
|
135
|
+
wrote = `wrote ${path}: ${match.moved.length} moved, ${match.renamed.length} renamed, ${match.added.length} added; check it with \`home config check\``;
|
|
136
|
+
}
|
|
137
|
+
// Into the cache after the config is written, against its new time, so every address just confirmed is fresh.
|
|
138
|
+
const book = addressBook(addresses.cache, {
|
|
139
|
+
configAt: path && existsSync(path) ? statSync(path).mtimeMs : undefined,
|
|
140
|
+
});
|
|
141
|
+
for (const f of found)
|
|
142
|
+
book.seen(f.connector, f.id, {
|
|
143
|
+
host: f.host,
|
|
144
|
+
port: f.port,
|
|
145
|
+
name: f.connector === "cast" ? f.name : undefined,
|
|
146
|
+
});
|
|
147
|
+
if (o.json) return printJson(match ? { services: rows, match, wrote } : rows);
|
|
148
|
+
if (!match) return;
|
|
149
|
+
console.log(`\n${path}:`);
|
|
150
|
+
for (const k of match.known)
|
|
151
|
+
console.log(
|
|
152
|
+
`known ${k.connector} ${k.id}${k.hint ? ` · ${k.hint}` : ""}`,
|
|
153
|
+
);
|
|
154
|
+
for (const m of match.moved)
|
|
155
|
+
console.log(`moved ${m.connector} ${m.id}: ${m.from} → ${m.to}`);
|
|
156
|
+
for (const r of match.renamed)
|
|
157
|
+
console.log(`renamed ${r.connector} ${r.id}: "${r.from}" → "${r.to}"`);
|
|
158
|
+
for (const a of match.added)
|
|
159
|
+
console.log(
|
|
160
|
+
`new ${a.found.connector} ${a.found.name ? `"${a.found.name}" ` : ""}${a.found.id} at ${a.found.host}`,
|
|
161
|
+
);
|
|
162
|
+
for (const s of match.skipped)
|
|
163
|
+
console.log(
|
|
164
|
+
`skipped ${s.found.connector} ${s.found.id} at ${s.found.host} · ${s.why}`,
|
|
165
|
+
);
|
|
166
|
+
if (!found.length)
|
|
167
|
+
console.error("home: nothing answered (mDNS and the Daikin broadcast)");
|
|
168
|
+
else if (write === undefined)
|
|
169
|
+
console.error(
|
|
170
|
+
changes
|
|
171
|
+
? `home: run with --write ${path} to update it`
|
|
172
|
+
: "home: the config is current",
|
|
173
|
+
);
|
|
174
|
+
else console.error(`home: ${wrote ?? "nothing to write"}`);
|
|
175
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// `home enphase login`: mint the owner token once a year and keep it owner-only.
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
bunGatewayFetch,
|
|
5
|
+
enphase,
|
|
6
|
+
mintToken,
|
|
7
|
+
tokenExpiry,
|
|
8
|
+
} from "../connectors/enphase/index.ts";
|
|
9
|
+
import { daysLeft } from "../connectors/enphase/client.ts";
|
|
10
|
+
import { writePrivate } from "../finders.ts";
|
|
11
|
+
import { env, fail, homeTimeZone, TOKEN_FILE, type Verbs } from "./context.ts";
|
|
12
|
+
|
|
13
|
+
/** The cached Enphase token, or undefined if `home enphase login` has not run. A corrupt file throws, naming it. */
|
|
14
|
+
export function enphaseToken(): { token: string; expiresAt: Date } | undefined {
|
|
15
|
+
let text: string;
|
|
16
|
+
try {
|
|
17
|
+
text = readFileSync(TOKEN_FILE, "utf8");
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const { token } = JSON.parse(text) as { token: string };
|
|
24
|
+
return { token, expiresAt: tokenExpiry(token) };
|
|
25
|
+
} catch (error) {
|
|
26
|
+
return fail(`${TOKEN_FILE}: ${(error as Error).message}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Enphase as an integration from IQ_GATEWAY_HOST and the token file, for `home status`. */
|
|
31
|
+
export function enphaseFromEnv() {
|
|
32
|
+
const t =
|
|
33
|
+
enphaseToken() ?? fail("no Enphase token", "auth", "home enphase login");
|
|
34
|
+
return enphase({
|
|
35
|
+
host: env.IQ_GATEWAY_HOST!,
|
|
36
|
+
token: t.token,
|
|
37
|
+
fetch: bunGatewayFetch(),
|
|
38
|
+
timeZone: homeTimeZone(),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const enphaseVerbs: Verbs = {
|
|
43
|
+
/** Mint the owner token and cache it. Prints the days left, nothing else. */
|
|
44
|
+
async login() {
|
|
45
|
+
const need = (n: string) => env[n] ?? fail(`set ${n}`);
|
|
46
|
+
const { token, expiresAt } = await mintToken({
|
|
47
|
+
username: need("ENPHASE_USERNAME"),
|
|
48
|
+
password: need("ENPHASE_PASSWORD"),
|
|
49
|
+
serial: need("IQ_GATEWAY_SERIAL_NUMBER"),
|
|
50
|
+
fetch,
|
|
51
|
+
});
|
|
52
|
+
writePrivate(
|
|
53
|
+
TOKEN_FILE,
|
|
54
|
+
JSON.stringify({
|
|
55
|
+
token,
|
|
56
|
+
expiresAt: expiresAt.toISOString(),
|
|
57
|
+
mintedAt: new Date().toISOString(),
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
console.log(`${daysLeft(expiresAt, new Date())} days left`);
|
|
61
|
+
},
|
|
62
|
+
};
|
package/src/cli/hue.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// `home lights off`, `home room`, `home scene`, `home pair` (also as `home hue <verb>`): one write to the bridge each.
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { hostname } from "node:os";
|
|
4
|
+
import {
|
|
5
|
+
bunPinnedFetch,
|
|
6
|
+
groups,
|
|
7
|
+
homeGroupedLightId,
|
|
8
|
+
hue,
|
|
9
|
+
hueClient,
|
|
10
|
+
pair,
|
|
11
|
+
scenes,
|
|
12
|
+
} from "../connectors/hue/index.ts";
|
|
13
|
+
import { snapshotFrom } from "../connectors/hue/state.ts";
|
|
14
|
+
import { FINDERS } from "../finders.ts";
|
|
15
|
+
import { env, fail, usage, type Verbs } from "./context.ts";
|
|
16
|
+
|
|
17
|
+
/** The bridge from HUE_BRIDGE_HOST and HUE_BRIDGE_ID, else found by mDNS, cached nowhere. */
|
|
18
|
+
export async function bridge(): Promise<{ host: string; id: string }> {
|
|
19
|
+
if (env.HUE_BRIDGE_HOST && env.HUE_BRIDGE_ID)
|
|
20
|
+
return { host: env.HUE_BRIDGE_HOST, id: env.HUE_BRIDGE_ID };
|
|
21
|
+
const b =
|
|
22
|
+
(await FINDERS.hue())[0] ??
|
|
23
|
+
fail("no Hue bridge found by mDNS; set HUE_BRIDGE_HOST and HUE_BRIDGE_ID");
|
|
24
|
+
return { host: env.HUE_BRIDGE_HOST ?? b.host, id: env.HUE_BRIDGE_ID ?? b.id };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
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
|
+
|
|
34
|
+
const key = () =>
|
|
35
|
+
env.HUE_APPLICATION_KEY ??
|
|
36
|
+
fail("set HUE_APPLICATION_KEY", "auth", "home pair");
|
|
37
|
+
|
|
38
|
+
/** Hue as an integration from the shell's env names, for `home status`. */
|
|
39
|
+
export async function hueFromEnv() {
|
|
40
|
+
const b = await bridge();
|
|
41
|
+
return hue({ host: b.host, bridgeId: b.id, key: key(), ca: ca() });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function client() {
|
|
45
|
+
const b = await bridge();
|
|
46
|
+
return hueClient({
|
|
47
|
+
host: b.host,
|
|
48
|
+
key: key(),
|
|
49
|
+
fetch: bunPinnedFetch(b.id, ca()),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
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(", ")}`)
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
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 } });
|
|
73
|
+
},
|
|
74
|
+
|
|
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",
|
|
82
|
+
);
|
|
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
|
+
},
|
|
93
|
+
|
|
94
|
+
async scene(words) {
|
|
95
|
+
const name = words.join(" ") || usage("usage: home scene <name>");
|
|
96
|
+
const c = await client();
|
|
97
|
+
await c.recallScene(
|
|
98
|
+
byName(scenes(snapshotFrom(await c.get(), "")), name, "scene").id,
|
|
99
|
+
);
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
async pair() {
|
|
103
|
+
const b = await bridge();
|
|
104
|
+
const { key } = await pair({
|
|
105
|
+
host: b.host,
|
|
106
|
+
fetch: bunPinnedFetch(b.id, ca()),
|
|
107
|
+
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
|
+
);
|
|
117
|
+
console.error(
|
|
118
|
+
"Paired. Put this in your shell environment (never in a repo):",
|
|
119
|
+
);
|
|
120
|
+
console.log(
|
|
121
|
+
`HUE_APPLICATION_KEY=${key}\nHUE_BRIDGE_HOST=${b.host}\nHUE_BRIDGE_ID=${b.id}`,
|
|
122
|
+
);
|
|
123
|
+
},
|
|
124
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// `home locate "<street address>" [--write [path]]`: the address's latitude and longitude from OpenStreetMap's Nominatim,
|
|
2
|
+
// printed; with --write, set in the config's `location` (and its `timezone`, when absent, to this machine's). The address
|
|
3
|
+
// itself goes only into the one query, never into the config or a file.
|
|
4
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { checkPlan, parseJsonc } from "../config.ts";
|
|
6
|
+
import { applyLocation, geocode } from "../locate.ts";
|
|
7
|
+
import {
|
|
8
|
+
configPathOf,
|
|
9
|
+
fail,
|
|
10
|
+
type Options,
|
|
11
|
+
printJson,
|
|
12
|
+
usage,
|
|
13
|
+
writeTo,
|
|
14
|
+
} from "./context.ts";
|
|
15
|
+
|
|
16
|
+
export async function locate(words: string[], o: Options): Promise<void> {
|
|
17
|
+
const address =
|
|
18
|
+
words.join(" ").trim() ||
|
|
19
|
+
usage(
|
|
20
|
+
'usage: home locate "<street address>" [--write [path]] (the address first: a word after --write is its path)',
|
|
21
|
+
);
|
|
22
|
+
const { version } = JSON.parse(
|
|
23
|
+
readFileSync(new URL("../../package.json", import.meta.url), "utf8"),
|
|
24
|
+
) as { version: string };
|
|
25
|
+
const at =
|
|
26
|
+
(await geocode(address, { fetch, userAgent: `khalido-home/${version}` })) ??
|
|
27
|
+
fail("Nominatim found no match; try the street, suburb and state");
|
|
28
|
+
const write = writeTo(o);
|
|
29
|
+
if (o.json && write === undefined) return printJson(at);
|
|
30
|
+
console.log(`${at.name}\n${at.latitude}, ${at.longitude}`);
|
|
31
|
+
if (write === undefined) return;
|
|
32
|
+
const path =
|
|
33
|
+
write ||
|
|
34
|
+
configPathOf(o) ||
|
|
35
|
+
fail("--write needs a path, --config or HOME_CONFIG");
|
|
36
|
+
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
37
|
+
const r = applyLocation(readFileSync(path, "utf8"), at, timeZone);
|
|
38
|
+
const problems = checkPlan(parseJsonc(r.text)).problems;
|
|
39
|
+
if (problems.length)
|
|
40
|
+
fail(
|
|
41
|
+
`${path} not written: the result would have problems: ${problems.join("; ")}`,
|
|
42
|
+
);
|
|
43
|
+
writeFileSync(path, r.text);
|
|
44
|
+
console.log(
|
|
45
|
+
`wrote ${path}: location ${at.latitude}, ${at.longitude}${r.timezoneSet ? `, timezone ${timeZone}` : ""}${r.fresh ? " (a new location object: the config had none, or only a commented-out one, left as it was)" : ""}`,
|
|
46
|
+
);
|
|
47
|
+
}
|
package/src/cli/main.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// `home`: the terminal layer. Commands come from cac; one `home <connector>` per manifest, its verbs from src/cli/<connector>.ts.
|
|
3
|
+
import { cac } from "cac";
|
|
4
|
+
import { ConnectorError, errorText } from "../connector.ts";
|
|
5
|
+
import { MANIFESTS } from "../manifests.ts";
|
|
6
|
+
import { cacheCommand } from "./cache.ts";
|
|
7
|
+
import { CAST_OPTIONS, CAST_USAGE, castVerbs } from "./cast.ts";
|
|
8
|
+
import { configCommand } from "./config.ts";
|
|
9
|
+
import {
|
|
10
|
+
addresses,
|
|
11
|
+
CliError,
|
|
12
|
+
type ErrorCode,
|
|
13
|
+
type Options,
|
|
14
|
+
usage,
|
|
15
|
+
type Verbs,
|
|
16
|
+
} from "./context.ts";
|
|
17
|
+
import { discover } from "./discover.ts";
|
|
18
|
+
import { enphaseVerbs } from "./enphase.ts";
|
|
19
|
+
import { hueVerbs } from "./hue.ts";
|
|
20
|
+
import { locate } from "./locate.ts";
|
|
21
|
+
import { probe } from "./probe.ts";
|
|
22
|
+
import { SPOTIFY_USAGE, spotifyVerbs } from "./spotify.ts";
|
|
23
|
+
import { status } from "./status.ts";
|
|
24
|
+
import { UP_OPTIONS, UP_USAGE, upVerbs } from "./up.ts";
|
|
25
|
+
|
|
26
|
+
const HUE_HELP: Record<string, string> = {
|
|
27
|
+
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",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Each connector's verbs beyond its status, the usage a wrong one prints, and any options of its own. */
|
|
34
|
+
const VERBS: Record<
|
|
35
|
+
string,
|
|
36
|
+
{
|
|
37
|
+
verbs: Verbs;
|
|
38
|
+
usage: string;
|
|
39
|
+
options?: [string, string, { type: unknown[] }?][];
|
|
40
|
+
}
|
|
41
|
+
> = {
|
|
42
|
+
hue: {
|
|
43
|
+
verbs: hueVerbs,
|
|
44
|
+
usage: `usage: home hue ${Object.values(HUE_HELP)
|
|
45
|
+
.map((h) => h.split(":")[0])
|
|
46
|
+
.join(" | ")}`,
|
|
47
|
+
},
|
|
48
|
+
enphase: { verbs: enphaseVerbs, usage: "usage: home enphase login" },
|
|
49
|
+
cast: { verbs: castVerbs, usage: CAST_USAGE, options: CAST_OPTIONS },
|
|
50
|
+
up: { verbs: upVerbs, usage: UP_USAGE, options: UP_OPTIONS },
|
|
51
|
+
spotify: { verbs: spotifyVerbs, usage: SPOTIFY_USAGE },
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const cli = cac("home");
|
|
55
|
+
cli.option("--json", "print JSON");
|
|
56
|
+
cli.option(
|
|
57
|
+
"--config <path>",
|
|
58
|
+
"a home config (JSONC); else HOME_CONFIG, else the env names in .env.example",
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const statusCommand = cli
|
|
62
|
+
.command(
|
|
63
|
+
"status",
|
|
64
|
+
"each integration: connected or not, which env names it still needs (the default)",
|
|
65
|
+
)
|
|
66
|
+
.alias("doctor")
|
|
67
|
+
.alias("!") // cac's marker for the default command: `home` alone runs status
|
|
68
|
+
.action((o: Options) => status(o));
|
|
69
|
+
cli
|
|
70
|
+
.command(
|
|
71
|
+
"discover",
|
|
72
|
+
"which smart-home devices answer here (mDNS + Daikin UDP); with a config, how they match it",
|
|
73
|
+
)
|
|
74
|
+
.option("--passes <n>", "discovery passes, 5 s apart (default 2)")
|
|
75
|
+
.option(
|
|
76
|
+
"--write [path]",
|
|
77
|
+
"match what answers by identity: add new devices, update moved hosts and renamed Cast names in the config",
|
|
78
|
+
)
|
|
79
|
+
.action((o: Options) => discover(o));
|
|
80
|
+
cli
|
|
81
|
+
.command(
|
|
82
|
+
"probe",
|
|
83
|
+
"read-only, no keys: Hue's public config, Daikin, the IQ Gateway's /info, each Cast device, each printer, each weather source",
|
|
84
|
+
)
|
|
85
|
+
.action((o: Options) => probe(o));
|
|
86
|
+
cli
|
|
87
|
+
.command(
|
|
88
|
+
"config [verb] [path]",
|
|
89
|
+
"config check [path]: what a home config would set up, secrets as set or unset, every problem",
|
|
90
|
+
)
|
|
91
|
+
.action(configCommand);
|
|
92
|
+
cli
|
|
93
|
+
.command(
|
|
94
|
+
"cache [verb]",
|
|
95
|
+
"the addresses kept between runs; `cache clear` deletes them",
|
|
96
|
+
)
|
|
97
|
+
.action(cacheCommand);
|
|
98
|
+
cli
|
|
99
|
+
.command(
|
|
100
|
+
"locate [...address]",
|
|
101
|
+
"latitude and longitude of a street address from OpenStreetMap's Nominatim",
|
|
102
|
+
)
|
|
103
|
+
.option(
|
|
104
|
+
"--write [path]",
|
|
105
|
+
"set them in the config's location (and timezone, when absent)",
|
|
106
|
+
)
|
|
107
|
+
.action(locate);
|
|
108
|
+
|
|
109
|
+
for (const m of MANIFESTS) {
|
|
110
|
+
const v = VERBS[m.name];
|
|
111
|
+
const c = cli.command(
|
|
112
|
+
`${m.name} [verb] [...args]`,
|
|
113
|
+
`${m.title}: its status${v ? `; ${Object.keys(v.verbs).join(", ")}` : ""}`,
|
|
114
|
+
);
|
|
115
|
+
for (const [raw, what, config] of v?.options ?? [])
|
|
116
|
+
c.option(raw, what, config);
|
|
117
|
+
c.action((verb: string | undefined, args: string[], o: Options) =>
|
|
118
|
+
verb === undefined
|
|
119
|
+
? status(o, m.name)
|
|
120
|
+
: (v && Object.hasOwn(v.verbs, verb)
|
|
121
|
+
? v.verbs[verb]!
|
|
122
|
+
: () => usage(v?.usage ?? `usage: home ${m.name}`))(args, o),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
// 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));
|
|
130
|
+
|
|
131
|
+
cli.help();
|
|
132
|
+
|
|
133
|
+
/** An error as `home` reports it: what went wrong, a coarse code, and the command that fixes it when there is one. */
|
|
134
|
+
function describe(e: unknown): {
|
|
135
|
+
error: string;
|
|
136
|
+
code: ErrorCode;
|
|
137
|
+
fix?: string;
|
|
138
|
+
} {
|
|
139
|
+
const error = errorText(e);
|
|
140
|
+
if (e instanceof CliError) return { error, code: e.code, fix: e.fix };
|
|
141
|
+
if (e instanceof Error && e.name === "CACError")
|
|
142
|
+
return { error, code: "usage" }; // cac: a missing argument or an unknown option
|
|
143
|
+
if (e instanceof ConnectorError && (e.status === 401 || e.status === 403))
|
|
144
|
+
return { error, code: "auth" };
|
|
145
|
+
// A timeout, or a socket error Bun and Node tag with a code (ECONNREFUSED, ENOTFOUND). A bare TypeError is more likely a bug.
|
|
146
|
+
const socket =
|
|
147
|
+
typeof (e as { code?: unknown })?.code === "string" &&
|
|
148
|
+
/^E[A-Z]+$/.test((e as { code: string }).code);
|
|
149
|
+
if (
|
|
150
|
+
e instanceof Error &&
|
|
151
|
+
(e.name === "TimeoutError" || e.name === "AbortError" || socket)
|
|
152
|
+
)
|
|
153
|
+
return { error, code: "network" };
|
|
154
|
+
return { error, code: "error" };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let failed: unknown;
|
|
158
|
+
try {
|
|
159
|
+
cli.parse(process.argv, { run: false });
|
|
160
|
+
if (cli.matchedCommand === statusCommand && cli.args.length)
|
|
161
|
+
usage(`unknown command "${cli.args[0]}"; \`home --help\` lists them`);
|
|
162
|
+
else await cli.runMatchedCommand();
|
|
163
|
+
} catch (e) {
|
|
164
|
+
failed = e ?? "failed";
|
|
165
|
+
} finally {
|
|
166
|
+
// Addresses discovery confirmed are kept whether the command succeeded or not; a cache that cannot be written is a warning, not a failure.
|
|
167
|
+
try {
|
|
168
|
+
addresses.flush();
|
|
169
|
+
} catch (e) {
|
|
170
|
+
console.error(`home: address cache not written: ${errorText(e)}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// stdout is data, stderr everything else: under --json an error is one JSON object on stderr. Exit 2 for usage, 1 for the rest.
|
|
174
|
+
if (failed !== undefined) {
|
|
175
|
+
const r = describe(failed);
|
|
176
|
+
console.error(
|
|
177
|
+
process.argv.includes("--json")
|
|
178
|
+
? JSON.stringify(r)
|
|
179
|
+
: `home: ${r.error}${r.fix ? ` (run \`${r.fix}\`)` : ""}`,
|
|
180
|
+
);
|
|
181
|
+
process.exit(r.code === "usage" ? 2 : 1);
|
|
182
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Printers without a config: what `home status`, `home printers` and `home probe` run. Cached addresses first, then
|
|
2
|
+
// discovery (AirPrint over mDNS) finds the rest. Read-only; the integration has no writes yet.
|
|
3
|
+
import { printers } from "../connectors/printers/index.ts";
|
|
4
|
+
import { addressBook } from "../discover.ts";
|
|
5
|
+
import { FINDERS } from "../finders.ts";
|
|
6
|
+
import { addresses } from "./context.ts";
|
|
7
|
+
|
|
8
|
+
/** Every printer the address cache knows plus whatever discovery finds, read once. */
|
|
9
|
+
export function printersNow() {
|
|
10
|
+
const book = addressBook(addresses.cache);
|
|
11
|
+
const known = Object.entries(book.entries()).flatMap(([key, c]) =>
|
|
12
|
+
key.startsWith("printers:")
|
|
13
|
+
? [{ uuid: key.slice(9), name: c.name, host: c.host, port: c.port }]
|
|
14
|
+
: [],
|
|
15
|
+
);
|
|
16
|
+
return printers({
|
|
17
|
+
printers: known,
|
|
18
|
+
find: FINDERS.printers,
|
|
19
|
+
seen: (uuid, a) => book.seen("printers", uuid, a),
|
|
20
|
+
});
|
|
21
|
+
}
|