@khalido/home 0.0.0-stage → 0.1.1
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 +47 -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 +428 -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 +18 -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 +754 -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 +280 -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 +197 -0
- package/dist/discover.js +452 -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 +48 -0
- package/dist/finders.js +176 -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 +41 -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 +25 -0
- package/skills/draw-home/SKILL.md +80 -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 +182 -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 +44 -0
- package/src/connectors/cast/codec.ts +126 -0
- package/src/connectors/cast/index.ts +552 -0
- package/src/connectors/cast/schemas.ts +101 -0
- package/src/connectors/cast/serve.ts +82 -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 +83 -0
- package/src/connectors/spotify/changes.ts +127 -0
- package/src/connectors/spotify/client.ts +387 -0
- package/src/connectors/spotify/index.ts +937 -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 +396 -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 +638 -0
- package/src/energy.ts +221 -0
- package/src/finders-entry.ts +2 -0
- package/src/finders.ts +250 -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
package/src/cli/probe.ts
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// `home probe`: read-only checks that need no key: Hue's public config, Daikin discovery and state, the IQ Gateway's
|
|
2
|
+
// /info, each Cast device's status, each printer's status, each weather source.
|
|
3
|
+
import { type CastSnapshot, castDevices } from "../connectors/cast/index.ts";
|
|
4
|
+
import {
|
|
5
|
+
type PrintersState,
|
|
6
|
+
printerList,
|
|
7
|
+
} from "../connectors/printers/index.ts";
|
|
8
|
+
import { gatewayHost } from "../connectors/enphase/client.ts";
|
|
9
|
+
import { bunPinnedFetch } from "../connectors/hue/index.ts";
|
|
10
|
+
import { type WeatherSource } from "../connectors/weather/index.ts";
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_POLL_MINUTES,
|
|
13
|
+
sourcePollMinutes,
|
|
14
|
+
} from "../connectors/weather/source.ts";
|
|
15
|
+
import { checkHome } from "../config.ts";
|
|
16
|
+
import { daikinFields, findDaikins } from "../finders.ts";
|
|
17
|
+
import { castNow } from "./cast.ts";
|
|
18
|
+
import { printersNow } from "./printers.ts";
|
|
19
|
+
import { settle } from "../home.ts";
|
|
20
|
+
import {
|
|
21
|
+
configPathOf,
|
|
22
|
+
env,
|
|
23
|
+
fail,
|
|
24
|
+
fromConfig,
|
|
25
|
+
type Options,
|
|
26
|
+
printJson,
|
|
27
|
+
readConfig,
|
|
28
|
+
} from "./context.ts";
|
|
29
|
+
import { bridge, ca } from "./hue.ts";
|
|
30
|
+
import { place, weatherSources } from "./status.ts";
|
|
31
|
+
|
|
32
|
+
/** The env names `probe` reads, filled from a config's addresses (secrets by the names it gives), so probe needs no second reading of it. */
|
|
33
|
+
function envFromConfig(path: string): Record<string, string> {
|
|
34
|
+
const { config, problems } = checkHome(readConfig(path), env);
|
|
35
|
+
if (!config) fail(`${path}: ${problems.join("; ")}`);
|
|
36
|
+
const out: Record<string, string | undefined> = {};
|
|
37
|
+
for (const d of config!.devices ?? []) {
|
|
38
|
+
if (d.connector === "hue")
|
|
39
|
+
Object.assign(out, {
|
|
40
|
+
HUE_BRIDGE_HOST: d.host,
|
|
41
|
+
HUE_BRIDGE_ID: d.bridgeId,
|
|
42
|
+
HUE_CA_FILE: fromConfig(path, d.ca),
|
|
43
|
+
HUE_APPLICATION_KEY: env[d.key],
|
|
44
|
+
});
|
|
45
|
+
if (d.connector === "daikin") out.DAIKIN_HOST = d.host;
|
|
46
|
+
if (d.connector === "enphase") out.IQ_GATEWAY_HOST = d.host;
|
|
47
|
+
if (d.connector === "amber")
|
|
48
|
+
Object.assign(out, { AMBER_KEY: env[d.key], AMBER_SITE_ID: d.siteId });
|
|
49
|
+
}
|
|
50
|
+
const at = config!.location;
|
|
51
|
+
if (at)
|
|
52
|
+
Object.assign(out, {
|
|
53
|
+
HOME_LATITUDE: String(at.latitude),
|
|
54
|
+
HOME_LONGITUDE: String(at.longitude),
|
|
55
|
+
HOME_BOM_FORECAST: at.bomForecast,
|
|
56
|
+
});
|
|
57
|
+
const g = config!.weather?.googleKey;
|
|
58
|
+
if (g) out.GOOGLE_WEATHER_KEY = env[g];
|
|
59
|
+
return Object.fromEntries(
|
|
60
|
+
Object.entries(out).filter(
|
|
61
|
+
(e): e is [string, string] => e[1] !== undefined,
|
|
62
|
+
),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** GET a URL and return its text, or the error, within 5 s. */
|
|
67
|
+
const tryGet = (url: string, init: RequestInit = {}) =>
|
|
68
|
+
fetch(url, { ...init, signal: AbortSignal.timeout(5000) }).then(
|
|
69
|
+
async (r) => ({ status: r.status, body: await r.text() }),
|
|
70
|
+
(e: Error) => ({ status: 0, body: e.message }),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const pick = (o: Record<string, unknown>, keys: string[]) =>
|
|
74
|
+
Object.fromEntries(keys.map((k) => [k, o[k]]));
|
|
75
|
+
|
|
76
|
+
/** What `basic_info` and the UDP answer may show: the adapter and firmware, never its name, MAC, Wi-Fi name or password. */
|
|
77
|
+
const DAIKIN_SHOWN = [
|
|
78
|
+
"ret",
|
|
79
|
+
"type",
|
|
80
|
+
"reg",
|
|
81
|
+
"ver",
|
|
82
|
+
"rev",
|
|
83
|
+
"adp_kind",
|
|
84
|
+
"pv",
|
|
85
|
+
"cpv",
|
|
86
|
+
"method",
|
|
87
|
+
"err",
|
|
88
|
+
"en_setzone",
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
/** The BRP084 reply's response codes only: its `adp_i` node holds the adapter's MAC (pydaikin reads it there). */
|
|
92
|
+
const answered = (reply: {
|
|
93
|
+
responses?: { fr?: string; rsc?: number }[];
|
|
94
|
+
notJson?: string;
|
|
95
|
+
}) =>
|
|
96
|
+
reply.notJson
|
|
97
|
+
? reply
|
|
98
|
+
: {
|
|
99
|
+
status: "answers",
|
|
100
|
+
responses: reply.responses?.map((r) => ({ from: r.fr, code: r.rsc })),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** JSON, or the start of the body as text when it is not. */
|
|
104
|
+
const jsonOr = (body: string) => {
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(body);
|
|
107
|
+
} catch {
|
|
108
|
+
return { notJson: body.slice(0, 80) };
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export async function probe(o: Options): Promise<void> {
|
|
113
|
+
const configPath = configPathOf(o);
|
|
114
|
+
if (configPath) Object.assign(env, envFromConfig(configPath)); // the config's addresses, for this one command
|
|
115
|
+
const out: Record<string, unknown> = {};
|
|
116
|
+
try {
|
|
117
|
+
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
|
+
};
|
|
133
|
+
} catch (error) {
|
|
134
|
+
out.hue = { status: "unavailable", detail: (error as Error).message };
|
|
135
|
+
}
|
|
136
|
+
const daikins = env.DAIKIN_HOST
|
|
137
|
+
? [{ ip: env.DAIKIN_HOST, fields: {} }]
|
|
138
|
+
: await findDaikins();
|
|
139
|
+
out.daikin = daikins.length
|
|
140
|
+
? await Promise.all(
|
|
141
|
+
daikins.map(async (d) => {
|
|
142
|
+
const info = await tryGet(`http://${d.ip}/common/basic_info`);
|
|
143
|
+
const control = await tryGet(
|
|
144
|
+
`http://${d.ip}/aircon/get_control_info`,
|
|
145
|
+
);
|
|
146
|
+
const sensor = await tryGet(`http://${d.ip}/aircon/get_sensor_info`);
|
|
147
|
+
const f = (r: { status: number; body: string }) =>
|
|
148
|
+
r.status === 200
|
|
149
|
+
? daikinFields(r.body)
|
|
150
|
+
: { status: r.status, error: r.body.slice(0, 80) };
|
|
151
|
+
const shown = (r: typeof info) =>
|
|
152
|
+
r.status === 200 ? pick(daikinFields(r.body), DAIKIN_SHOWN) : f(r);
|
|
153
|
+
const result: Record<string, unknown> = {
|
|
154
|
+
ip: d.ip,
|
|
155
|
+
udp: pick(d.fields, DAIKIN_SHOWN),
|
|
156
|
+
basic_info: shown(info),
|
|
157
|
+
control_info: f(control),
|
|
158
|
+
sensor_info: f(sensor),
|
|
159
|
+
};
|
|
160
|
+
if (info.status === 404) {
|
|
161
|
+
// Not the BRP069 API. Try the newer JSON one (BRP084): op 2 is a read of the adapter's info, as pydaikin does.
|
|
162
|
+
const body = JSON.stringify({
|
|
163
|
+
requests: [
|
|
164
|
+
{ op: 2, to: "/dsiot/edge.adp_i" },
|
|
165
|
+
{
|
|
166
|
+
op: 2,
|
|
167
|
+
to: "/dsiot/edge/adr_0100.dgc_status?filter=pv,pt,md",
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
});
|
|
171
|
+
const r = await tryGet(`http://${d.ip}/dsiot/multireq`, {
|
|
172
|
+
method: "POST",
|
|
173
|
+
headers: { "content-type": "application/json" },
|
|
174
|
+
body,
|
|
175
|
+
});
|
|
176
|
+
result.dsiot =
|
|
177
|
+
r.status === 200
|
|
178
|
+
? answered(jsonOr(r.body))
|
|
179
|
+
: { status: r.status, error: r.body.slice(0, 80) };
|
|
180
|
+
}
|
|
181
|
+
return result;
|
|
182
|
+
}),
|
|
183
|
+
)
|
|
184
|
+
: {
|
|
185
|
+
status:
|
|
186
|
+
"no answer to the UDP broadcast; set DAIKIN_HOST to try one address",
|
|
187
|
+
};
|
|
188
|
+
const gateway = env.IQ_GATEWAY_HOST && gatewayHost(env.IQ_GATEWAY_HOST);
|
|
189
|
+
if (gateway) {
|
|
190
|
+
// Bun cannot verify the gateway's chain (README); this unauthenticated read sends nothing secret.
|
|
191
|
+
const r = await tryGet(`https://${gateway}/info`, {
|
|
192
|
+
tls: { rejectUnauthorized: false },
|
|
193
|
+
} as RequestInit);
|
|
194
|
+
const tag = (t: string) =>
|
|
195
|
+
r.body.match(new RegExp(`<${t}>([^<]*)</${t}>`))?.[1];
|
|
196
|
+
const serial = tag("sn");
|
|
197
|
+
out.enphase =
|
|
198
|
+
r.status === 200
|
|
199
|
+
? {
|
|
200
|
+
status: "answers",
|
|
201
|
+
software: tag("software") ?? tag("version"),
|
|
202
|
+
part: tag("pn"),
|
|
203
|
+
serialMatchesEnv: env.IQ_GATEWAY_SERIAL_NUMBER
|
|
204
|
+
? serial === env.IQ_GATEWAY_SERIAL_NUMBER
|
|
205
|
+
: "IQ_GATEWAY_SERIAL_NUMBER unset",
|
|
206
|
+
}
|
|
207
|
+
: {
|
|
208
|
+
status: "unavailable",
|
|
209
|
+
detail: `${r.status} ${r.body.slice(0, 80)}`,
|
|
210
|
+
};
|
|
211
|
+
} else out.enphase = { status: "needs IQ_GATEWAY_HOST" };
|
|
212
|
+
// Cast: connect to every device and group, read receiver (and group) status, close. Nothing is launched or changed.
|
|
213
|
+
const c = (await settle([castNow()], { ms: 10_000 })).cast!;
|
|
214
|
+
const devices = (c.state as CastSnapshot | undefined)?.devices ?? {};
|
|
215
|
+
out.cast = {
|
|
216
|
+
status: c.status,
|
|
217
|
+
detail: c.detail,
|
|
218
|
+
anomaly: c.anomaly,
|
|
219
|
+
devices: castDevices({ devices, asOf: "" }).map(
|
|
220
|
+
({ host: _, asOf: __, ...d }) => d,
|
|
221
|
+
),
|
|
222
|
+
};
|
|
223
|
+
// Printers: one Get-Printer-Attributes to each AirPrint printer that answers mDNS (or the address cache). Nothing is sent but the read.
|
|
224
|
+
const pr = (await settle([printersNow()], { ms: 15_000 })).printers!;
|
|
225
|
+
out.printers = {
|
|
226
|
+
status: pr.status,
|
|
227
|
+
detail: pr.detail,
|
|
228
|
+
anomaly: pr.anomaly,
|
|
229
|
+
printers: printerList(
|
|
230
|
+
(pr.state as PrintersState | undefined) ?? { printers: {}, asOf: "" },
|
|
231
|
+
).map(({ host: _, asOf: __, ...p }) => p),
|
|
232
|
+
};
|
|
233
|
+
const needs = ["HOME_LATITUDE", "HOME_LONGITUDE"].filter((n) => !env[n]);
|
|
234
|
+
if (needs.length) out.weather = { status: `needs ${needs.join(", ")}` };
|
|
235
|
+
else {
|
|
236
|
+
const at = place();
|
|
237
|
+
const now = Date.now();
|
|
238
|
+
const probeOne = async (source: WeatherSource) => {
|
|
239
|
+
const t = performance.now();
|
|
240
|
+
const r = await source.read({
|
|
241
|
+
place: at,
|
|
242
|
+
at: now,
|
|
243
|
+
pollMinutes: sourcePollMinutes(source, DEFAULT_POLL_MINUTES),
|
|
244
|
+
});
|
|
245
|
+
const when = (s?: { at: string; modelled: boolean }) =>
|
|
246
|
+
s ? `${s.at}${s.modelled ? " (modelled)" : ""}` : undefined;
|
|
247
|
+
return {
|
|
248
|
+
status: r.stop
|
|
249
|
+
? "unavailable"
|
|
250
|
+
: r.now || r.forecast || r.sky || r.rain
|
|
251
|
+
? "answers"
|
|
252
|
+
: "no data",
|
|
253
|
+
ms: Math.round(performance.now() - t),
|
|
254
|
+
now: when(r.now),
|
|
255
|
+
station: r.now?.station,
|
|
256
|
+
forecastIssued: r.forecast?.issuedAt ?? when(r.forecast),
|
|
257
|
+
sky: when(r.sky),
|
|
258
|
+
rain: when(r.rain),
|
|
259
|
+
problem: r.stop ?? r.problem,
|
|
260
|
+
anomaly: r.anomaly,
|
|
261
|
+
};
|
|
262
|
+
};
|
|
263
|
+
const probed = await Promise.all(
|
|
264
|
+
weatherSources().map(async (s) => [s.name, await probeOne(s)] as const),
|
|
265
|
+
);
|
|
266
|
+
out.weather = {
|
|
267
|
+
...Object.fromEntries(probed),
|
|
268
|
+
...(env.GOOGLE_WEATHER_KEY
|
|
269
|
+
? {}
|
|
270
|
+
: { google: { status: "no key: set GOOGLE_WEATHER_KEY" } }),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
printJson(out);
|
|
274
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// `home spotify pause|play [link]|next|volume <0-100>`: one write to the Soloist speaker. Opens the socket, waits for
|
|
2
|
+
// Soloist to answer (at most 5 s), sends the command and closes.
|
|
3
|
+
import { spotify } from "../connectors/spotify/index.ts";
|
|
4
|
+
import type { Part } from "../home.ts";
|
|
5
|
+
import {
|
|
6
|
+
type Applied,
|
|
7
|
+
type SpotifyState,
|
|
8
|
+
spotifyText,
|
|
9
|
+
} from "../connectors/spotify/index.ts";
|
|
10
|
+
import { env, fail, usage, type Verbs } from "./context.ts";
|
|
11
|
+
|
|
12
|
+
/** SOLOIST_MAX_VOLUME as a number 0–100, or undefined when unset. */
|
|
13
|
+
function maxVolume(): number | undefined {
|
|
14
|
+
const raw = env.SOLOIST_MAX_VOLUME;
|
|
15
|
+
if (!raw) return undefined;
|
|
16
|
+
const n = Number(raw);
|
|
17
|
+
return Number.isInteger(n) && n >= 0 && n <= 100
|
|
18
|
+
? n
|
|
19
|
+
: fail(`SOLOIST_MAX_VOLUME: a whole number 0-100, not ${raw}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The speaker from SOLOIST_WS and SOLOIST_MAX_VOLUME, for `home status` and the verbs: both need SOLOIST_WS. */
|
|
23
|
+
export const spotifyFromEnv = () =>
|
|
24
|
+
spotify({
|
|
25
|
+
url:
|
|
26
|
+
env.SOLOIST_WS ||
|
|
27
|
+
fail(
|
|
28
|
+
"needs SOLOIST_WS, Soloist's --ws address as a URL (see .env.example)",
|
|
29
|
+
),
|
|
30
|
+
maxVolume: maxVolume(),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const SPOTIFY_USAGE =
|
|
34
|
+
"usage: home spotify pause | play [link] | next | volume <0-100>";
|
|
35
|
+
|
|
36
|
+
async function withSpotify<T>(
|
|
37
|
+
act: (s: ReturnType<typeof spotify>) => Promise<T>,
|
|
38
|
+
): Promise<T> {
|
|
39
|
+
const s = spotifyFromEnv();
|
|
40
|
+
const stop = new AbortController();
|
|
41
|
+
let last: Part<SpotifyState> | undefined;
|
|
42
|
+
const running = s.run(stop.signal, (p) => (last = p));
|
|
43
|
+
try {
|
|
44
|
+
for (
|
|
45
|
+
const until = Date.now() + 5000;
|
|
46
|
+
Date.now() < until && (last?.status ?? "connecting") === "connecting";
|
|
47
|
+
)
|
|
48
|
+
await Bun.sleep(50);
|
|
49
|
+
if (last?.status !== "connected")
|
|
50
|
+
fail(last?.detail ?? "Soloist did not answer in 5 s", "network");
|
|
51
|
+
return await act(s);
|
|
52
|
+
} finally {
|
|
53
|
+
stop.abort();
|
|
54
|
+
await running;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** What a write left, in a line: the speaker as Soloist then reported it, or that it has not reported the change yet. */
|
|
59
|
+
const said = ({ state, confirmed }: Applied) =>
|
|
60
|
+
console.log(
|
|
61
|
+
confirmed
|
|
62
|
+
? spotifyText(state)
|
|
63
|
+
: `sent; Soloist has not reported it yet (${spotifyText(state)})`,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
export const spotifyVerbs: Verbs = {
|
|
67
|
+
pause: () => withSpotify((s) => s.pause()).then(said),
|
|
68
|
+
next: () => withSpotify((s) => s.next()).then(said),
|
|
69
|
+
play: ([link]) => withSpotify((s) => s.play(link)).then(said),
|
|
70
|
+
async volume([value]) {
|
|
71
|
+
const n = Number(value);
|
|
72
|
+
if (value === undefined || value.trim() === "" || !(n >= 0 && n <= 100))
|
|
73
|
+
usage(SPOTIFY_USAGE);
|
|
74
|
+
said(await withSpotify((s) => s.setVolume(n)));
|
|
75
|
+
},
|
|
76
|
+
};
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// `home status` and `home <connector>`: build each integration (from a config, else from the shell's env names), run
|
|
2
|
+
// them until each has answered, and print each through its connector's manifest.
|
|
3
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
4
|
+
import { amber } from "../connectors/amber/index.ts";
|
|
5
|
+
import { tlsConnect } from "../connectors/cast/tls.ts";
|
|
6
|
+
import { daikin } from "../connectors/daikin/index.ts";
|
|
7
|
+
import {
|
|
8
|
+
bom,
|
|
9
|
+
google,
|
|
10
|
+
openMeteo,
|
|
11
|
+
weather,
|
|
12
|
+
type WeatherSource,
|
|
13
|
+
} from "../connectors/weather/index.ts";
|
|
14
|
+
import { ftpFetch } from "../connectors/weather/ftp.ts";
|
|
15
|
+
import { loadHome } from "../config.ts";
|
|
16
|
+
import { energyNow, gridText } from "../energy.ts";
|
|
17
|
+
import { FINDERS } from "../finders.ts";
|
|
18
|
+
import { type Integration, type Part, type Status, settle } from "../home.ts";
|
|
19
|
+
import { kw, type Manifest } from "../manifest.ts";
|
|
20
|
+
import { MANIFESTS, manifestOf } from "../manifests.ts";
|
|
21
|
+
import { castNow } from "./cast.ts";
|
|
22
|
+
import { printersNow } from "./printers.ts";
|
|
23
|
+
import { spotifyFromEnv } from "./spotify.ts";
|
|
24
|
+
import {
|
|
25
|
+
addresses,
|
|
26
|
+
CliError,
|
|
27
|
+
configPathOf,
|
|
28
|
+
env,
|
|
29
|
+
fail,
|
|
30
|
+
fromConfig,
|
|
31
|
+
homeTimeZone,
|
|
32
|
+
type Options,
|
|
33
|
+
printJson,
|
|
34
|
+
readConfig,
|
|
35
|
+
TOKEN_FILE,
|
|
36
|
+
} from "./context.ts";
|
|
37
|
+
import { enphaseFromEnv } from "./enphase.ts";
|
|
38
|
+
import { hueFromEnv } from "./hue.ts";
|
|
39
|
+
import { upFromEnv } from "./up.ts";
|
|
40
|
+
|
|
41
|
+
/** One connector's row: its part, or "not set up" with the env names (and steps) it still needs. */
|
|
42
|
+
type Row = Omit<Part, "status"> & {
|
|
43
|
+
status: Status | "not set up";
|
|
44
|
+
needs: string[];
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** HOME_LATITUDE and HOME_LONGITUDE as numbers; the manifest's `needs` has already checked they are set. */
|
|
48
|
+
export function place(): { latitude: number; longitude: number } {
|
|
49
|
+
const latitude = Number(env.HOME_LATITUDE);
|
|
50
|
+
const longitude = Number(env.HOME_LONGITUDE);
|
|
51
|
+
return Number.isFinite(latitude) && Number.isFinite(longitude)
|
|
52
|
+
? { latitude, longitude }
|
|
53
|
+
: fail("HOME_LATITUDE and HOME_LONGITUDE must be numbers");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The weather sources KO's env sets up, in preference order: BOM (opt-in, with the FTP transport), Open-Meteo, then Google if keyed, as the last resort. */
|
|
57
|
+
export const weatherSources = (): WeatherSource[] => [
|
|
58
|
+
bom({ fetch: ftpFetch(), forecastLocation: env.HOME_BOM_FORECAST }),
|
|
59
|
+
openMeteo(),
|
|
60
|
+
...(env.GOOGLE_WEATHER_KEY ? [google({ key: env.GOOGLE_WEATHER_KEY })] : []),
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Each connector from the shell's env names, once the manifest's non-optional names are set. `needs` adds a step that
|
|
65
|
+
* is not an env name (Enphase's token file). Addresses come from the env only; `home probe` and `home discover` search.
|
|
66
|
+
*/
|
|
67
|
+
const FROM_ENV: Record<
|
|
68
|
+
string,
|
|
69
|
+
{
|
|
70
|
+
needs?: () => string[];
|
|
71
|
+
build: () => Integration<any> | Promise<Integration<any>>;
|
|
72
|
+
}
|
|
73
|
+
> = {
|
|
74
|
+
hue: { build: hueFromEnv },
|
|
75
|
+
daikin: { build: () => daikin({ host: env.DAIKIN_HOST!, fetch }) },
|
|
76
|
+
enphase: {
|
|
77
|
+
needs: () => (existsSync(TOKEN_FILE) ? [] : ["`home enphase login`"]),
|
|
78
|
+
build: enphaseFromEnv,
|
|
79
|
+
},
|
|
80
|
+
amber: {
|
|
81
|
+
build: () =>
|
|
82
|
+
amber({ key: env.AMBER_KEY!, siteId: env.AMBER_SITE_ID, fetch }),
|
|
83
|
+
},
|
|
84
|
+
up: { build: upFromEnv },
|
|
85
|
+
weather: {
|
|
86
|
+
build: () =>
|
|
87
|
+
weather({
|
|
88
|
+
...place(),
|
|
89
|
+
sources: weatherSources(),
|
|
90
|
+
timeZone: homeTimeZone(),
|
|
91
|
+
}),
|
|
92
|
+
},
|
|
93
|
+
cast: { build: castNow },
|
|
94
|
+
printers: { build: printersNow },
|
|
95
|
+
spotify: { build: spotifyFromEnv },
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** The rows for these manifests from the shell: not set up, failed to build, or run until each has answered. */
|
|
99
|
+
async function statusFromEnv(
|
|
100
|
+
manifests: Manifest[],
|
|
101
|
+
): Promise<Record<string, Row>> {
|
|
102
|
+
const rows: Record<string, Row> = {};
|
|
103
|
+
const built = await Promise.all(
|
|
104
|
+
// in parallel: Hue's build can wait on mDNS
|
|
105
|
+
manifests.map(async (m): Promise<Integration<any>[]> => {
|
|
106
|
+
const from = FROM_ENV[m.name]!;
|
|
107
|
+
const needs = [
|
|
108
|
+
...m.env.filter((e) => !e.optional && !env[e.name]).map((e) => e.name),
|
|
109
|
+
...(from.needs?.() ?? []),
|
|
110
|
+
];
|
|
111
|
+
rows[m.name] = {
|
|
112
|
+
status: needs.length ? "not set up" : "connecting",
|
|
113
|
+
needs,
|
|
114
|
+
};
|
|
115
|
+
if (needs.length) return [];
|
|
116
|
+
try {
|
|
117
|
+
return [await from.build()];
|
|
118
|
+
} catch (error) {
|
|
119
|
+
const fix =
|
|
120
|
+
error instanceof CliError && error.fix
|
|
121
|
+
? ` (run \`${error.fix}\`)`
|
|
122
|
+
: "";
|
|
123
|
+
rows[m.name] = {
|
|
124
|
+
status: "unavailable",
|
|
125
|
+
needs,
|
|
126
|
+
detail: `${(error as Error).message}${fix}`,
|
|
127
|
+
};
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
for (const [name, part] of Object.entries(
|
|
133
|
+
(await settle(built.flat())) as Record<string, Part>,
|
|
134
|
+
))
|
|
135
|
+
rows[name] = { ...part, needs: [] };
|
|
136
|
+
return rows;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The rows from a config: its integrations (or the one named) run until each has answered (weather: every source), at most 15 s. */
|
|
140
|
+
async function statusFromConfig(
|
|
141
|
+
path: string,
|
|
142
|
+
only?: string,
|
|
143
|
+
): Promise<Record<string, Row>> {
|
|
144
|
+
const options = {
|
|
145
|
+
env,
|
|
146
|
+
readFile: (p: string) => readFileSync(fromConfig(path, p), "utf8"),
|
|
147
|
+
ftpFetch: ftpFetch(),
|
|
148
|
+
finders: FINDERS,
|
|
149
|
+
castConnect: tlsConnect,
|
|
150
|
+
cache: addresses.cache,
|
|
151
|
+
configAt: statSync(path).mtimeMs,
|
|
152
|
+
};
|
|
153
|
+
const all = loadHome(readConfig(path), options);
|
|
154
|
+
const integrations =
|
|
155
|
+
only === undefined ? all : all.filter((i) => i.name === only);
|
|
156
|
+
if (!integrations.length) fail(`no ${only} in ${path}`);
|
|
157
|
+
return Object.fromEntries(
|
|
158
|
+
Object.entries(await settle(integrations)).map(([name, p]) => [
|
|
159
|
+
name,
|
|
160
|
+
{ ...p, needs: [] },
|
|
161
|
+
]),
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** A row as `--json` prints it: status, needs and access, then the state as the connector's manifest shapes it. */
|
|
166
|
+
function rowRecord(name: string, r: Row): Record<string, unknown> {
|
|
167
|
+
const m = manifestOf(name);
|
|
168
|
+
return {
|
|
169
|
+
status: r.status,
|
|
170
|
+
needs: r.needs,
|
|
171
|
+
access: m?.access ?? "",
|
|
172
|
+
detail: r.detail,
|
|
173
|
+
anomaly: r.anomaly,
|
|
174
|
+
...(m?.record && r.state ? m.record(r as Part) : (r.state as object)),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A row's text: "name status · summary · needs … · detail · odd: … (access)", then its children indented. */
|
|
179
|
+
function rowLines(name: string, r: Row): string[] {
|
|
180
|
+
const m = manifestOf(name);
|
|
181
|
+
const [summary = "", ...children] =
|
|
182
|
+
r.state === undefined ? [] : (m?.lines(r as Part) ?? []);
|
|
183
|
+
const parts = [
|
|
184
|
+
r.status,
|
|
185
|
+
summary,
|
|
186
|
+
r.needs.length ? `needs ${r.needs.join(", ")}` : "",
|
|
187
|
+
r.detail ?? "",
|
|
188
|
+
r.anomaly ? `odd: ${r.anomaly}` : "",
|
|
189
|
+
];
|
|
190
|
+
return [
|
|
191
|
+
`${name.padEnd(9)}${parts.filter(Boolean).join(" · ")} (${m?.access ?? ""})`,
|
|
192
|
+
...children.map((line) => ` ${line}`),
|
|
193
|
+
];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** `home status`: every connector, with the energy now on top. With `only` (`home <connector>`), that connector alone. */
|
|
197
|
+
export async function status(o: Options, only?: string): Promise<void> {
|
|
198
|
+
const path = configPathOf(o);
|
|
199
|
+
const manifests = only === undefined ? MANIFESTS : [manifestOf(only)!];
|
|
200
|
+
const rows = path
|
|
201
|
+
? await statusFromConfig(path, only)
|
|
202
|
+
: await statusFromEnv(manifests);
|
|
203
|
+
if (only !== undefined) {
|
|
204
|
+
const row = rows[only]!;
|
|
205
|
+
if (row.status !== "connected") process.exitCode = 1; // `home <connector>` is a check a script can test; `home` alone lists every one and exits 0
|
|
206
|
+
return o.json
|
|
207
|
+
? printJson(rowRecord(only, row))
|
|
208
|
+
: console.log(rowLines(only, row).join("\n"));
|
|
209
|
+
}
|
|
210
|
+
const connected = (name: string) =>
|
|
211
|
+
rows[name]?.status === "connected"
|
|
212
|
+
? (rows[name]!.state as never)
|
|
213
|
+
: undefined;
|
|
214
|
+
const now = energyNow(connected("enphase"), connected("amber"));
|
|
215
|
+
if (o.json)
|
|
216
|
+
return printJson({
|
|
217
|
+
now,
|
|
218
|
+
...Object.fromEntries(
|
|
219
|
+
Object.entries(rows).map(([name, r]) => [name, rowRecord(name, r)]),
|
|
220
|
+
),
|
|
221
|
+
});
|
|
222
|
+
if (now.gridW !== undefined)
|
|
223
|
+
console.log(
|
|
224
|
+
`now solar ${kw(now.solarW)} · using ${kw(now.usingW)} · ${gridText(now)}`,
|
|
225
|
+
);
|
|
226
|
+
for (const [name, r] of Object.entries(rows))
|
|
227
|
+
console.log(rowLines(name, r).join("\n"));
|
|
228
|
+
}
|