@khalido/home 0.2.0 → 0.2.2
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/CHANGELOG.md +24 -2
- package/dist/connector.d.ts +2 -0
- package/dist/connector.js +3 -0
- package/dist/connectors/amber/client.d.ts +1 -1
- package/dist/connectors/amber/client.js +1 -1
- package/dist/connectors/amber/index.js +2 -2
- package/dist/connectors/cast/index.d.ts +2 -1
- package/dist/connectors/cast/index.js +1 -0
- package/dist/connectors/cast/wav.d.ts +31 -0
- package/dist/connectors/cast/wav.js +149 -0
- package/dist/connectors/cast/writes.d.ts +25 -6
- package/dist/connectors/cast/writes.js +155 -62
- package/dist/connectors/enphase/client.d.ts +1 -1
- package/dist/connectors/enphase/client.js +1 -1
- package/dist/connectors/enphase/index.js +2 -1
- package/dist/connectors/hue/events.d.ts +0 -2
- package/dist/connectors/hue/events.js +1 -2
- package/dist/connectors/hue/index.js +2 -2
- package/dist/connectors/printers/client.d.ts +41 -6
- package/dist/connectors/printers/client.js +81 -8
- package/dist/connectors/printers/codec.d.ts +17 -3
- package/dist/connectors/printers/codec.js +32 -8
- package/dist/connectors/printers/index.d.ts +2 -0
- package/dist/connectors/printers/index.js +4 -0
- package/dist/connectors/printers/raster.d.ts +30 -0
- package/dist/connectors/printers/raster.js +175 -0
- package/dist/connectors/printers/state.d.ts +2 -0
- package/dist/connectors/spotify/changes.d.ts +1 -1
- package/dist/connectors/spotify/changes.js +1 -1
- package/dist/connectors/spotify/index.d.ts +28 -14
- package/dist/connectors/spotify/index.js +89 -48
- package/dist/connectors/up/client.d.ts +1 -1
- package/dist/connectors/up/client.js +1 -1
- package/dist/connectors/up/index.js +2 -2
- package/dist/connectors/weather/google.js +2 -2
- package/docs/cast.md +1 -1
- package/docs/cli.md +3 -1
- package/docs/decisions.md +7 -0
- package/docs/ideas.md +2 -0
- package/docs/using.md +1 -1
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/skills/draw-home/README.md +2 -0
- package/skills/use-home/SKILL.md +1 -1
- package/src/cli/cast.ts +41 -11
- package/src/cli/context.ts +1 -1
- package/src/cli/enphase.ts +31 -5
- package/src/cli/main.ts +14 -5
- package/src/cli/printers.ts +254 -3
- package/src/cli/spotify.ts +16 -12
- package/src/connector.ts +5 -0
- package/src/connectors/amber/README.md +1 -1
- package/src/connectors/amber/client.ts +1 -1
- package/src/connectors/amber/index.ts +2 -2
- package/src/connectors/cast/README.md +2 -0
- package/src/connectors/cast/index.ts +4 -0
- package/src/connectors/cast/wav.ts +181 -0
- package/src/connectors/cast/writes.ts +211 -71
- package/src/connectors/enphase/README.md +3 -1
- package/src/connectors/enphase/client.ts +1 -1
- package/src/connectors/enphase/index.ts +2 -1
- package/src/connectors/hue/events.ts +1 -4
- package/src/connectors/hue/index.ts +2 -2
- package/src/connectors/printers/README.md +9 -4
- package/src/connectors/printers/client.ts +186 -12
- package/src/connectors/printers/codec.ts +44 -10
- package/src/connectors/printers/index.ts +17 -0
- package/src/connectors/printers/raster.ts +215 -0
- package/src/connectors/printers/state.ts +2 -0
- package/src/connectors/spotify/README.md +5 -1
- package/src/connectors/spotify/changes.ts +1 -1
- package/src/connectors/spotify/index.ts +233 -106
- package/src/connectors/up/README.md +1 -1
- package/src/connectors/up/client.ts +1 -1
- package/src/connectors/up/index.ts +2 -2
- package/src/connectors/weather/google.ts +6 -2
package/src/connector.ts
CHANGED
|
@@ -51,6 +51,11 @@ export class ConnectorError extends Error {
|
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/** A 401 or 403 from a device or service: the key or token is wrong or revoked. Every connector reads it as `reason: "auth"`. */
|
|
55
|
+
export const isRejectedKey = (error: unknown) =>
|
|
56
|
+
error instanceof ConnectorError &&
|
|
57
|
+
(error.status === 401 || error.status === 403);
|
|
58
|
+
|
|
54
59
|
/** `fetch` that gives up after `timeoutMs`, or as soon as `signal` (a run's) aborts. Returns the response whatever its status. */
|
|
55
60
|
export function fetchTimeout(
|
|
56
61
|
fetcher: typeof fetch,
|
|
@@ -9,7 +9,7 @@ Amber Electric's live wholesale-linked prices, read through Amber's public API.
|
|
|
9
9
|
- `forecast`: the current interval, then each forecast one, both channels on one row (`PriceInterval`). `next=96` asks for more than Amber has, so the reply runs as far as Amber forecasts: on 2026-10-28 at 3 pm, 12 five-minute intervals then 14 half-hours, to 11 pm. Amber's `startTime` is a second past the interval's start, so `start` is taken to the minute.
|
|
10
10
|
- `estimate: true` means the current interval's price can still move; it is shown as "(estimate)", never as an anomaly.
|
|
11
11
|
- The Amber line in `home status` shows the descriptor, "(estimate)", a spike other than `none`, and a negative price. `energyNow` combines the prices with Enphase; see Energy below.
|
|
12
|
-
- A 401 marks Amber unavailable and stops polling.
|
|
12
|
+
- A 401 or 403 marks Amber unavailable and stops polling.
|
|
13
13
|
|
|
14
14
|
## Energy
|
|
15
15
|
|
|
@@ -4,7 +4,7 @@ import { ConnectorError, getJson } from "../../connector.ts";
|
|
|
4
4
|
// Amber Electric's public API v1 (https://app.amber.com.au/developers; the published spec is 1.0, there is no v2). Shapes checked against live replies,
|
|
5
5
|
// 2026-10-13; every field optional so a change degrades a reading rather than throwing.
|
|
6
6
|
|
|
7
|
-
/** A failed Amber call. `status` 401 means the key is wrong or revoked; 429 means the rate limit (50 per 5 min). */
|
|
7
|
+
/** A failed Amber call. `status` 401 or 403 (`isRejectedKey`) means the key is wrong or revoked; 429 means the rate limit (50 per 5 min). */
|
|
8
8
|
export class AmberError extends ConnectorError {}
|
|
9
9
|
|
|
10
10
|
/** One 5-minute price interval on one channel. Prices are c/kWh including GST, as a cost: negative feed-in means you are paid. */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { asOf, type Clock, realClock } from "../../clock.ts";
|
|
2
|
-
import { errorText } from "../../connector.ts";
|
|
2
|
+
import { errorText, isRejectedKey } from "../../connector.ts";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { envName, type Integration } from "../../home.ts";
|
|
5
5
|
import { energyNow } from "../../energy.ts";
|
|
@@ -62,7 +62,7 @@ export function amber(
|
|
|
62
62
|
update({ status: "connected", anomaly: last.anomaly, state: last });
|
|
63
63
|
} catch (error) {
|
|
64
64
|
if (signal.aborted) return; // stopped mid-read: not a failure
|
|
65
|
-
if (error
|
|
65
|
+
if (isRejectedKey(error))
|
|
66
66
|
return update({
|
|
67
67
|
status: "unavailable",
|
|
68
68
|
reason: "auth",
|
|
@@ -15,6 +15,8 @@ Nest speakers, Nest Hubs, Chromecasts, Google TV and speaker groups, read over C
|
|
|
15
15
|
- Writes, on the object `cast()` returns (`CastActions`), over the sockets it already holds, never a second connection. A target is a name (any case) or a castId; volume is 0 to 1, as the snapshot's. `setVolume`, `setMuted` (receiver `SET_VOLUME`; a group's goes to the group's socket; per device, levels and mutes each at most 30 a minute, the newest waiting one winning; one device's lane never waits on another's) and `stop` (receiver `STOP` of the running app; nothing when idle). Each request waits 10 s for its reply and a refusal (`LAUNCH_ERROR`, `LOAD_FAILED`, …) rejects, naming it.
|
|
16
16
|
- `castFreeSpeakers(state)`: the one call for "where can I announce now": `castFree` over every device and group, with `forms: ["speaker", "display"]`. Read live 2026-11-11 (Bun 1.4.4, reads only): 8 devices and groups gave one free group of four, with two TVs and a device whose model was not known skipped as `form`.
|
|
17
17
|
- `announce(target, clip, { volume?, timeoutMs? })`: `clip` is `{ url, type? }` or `{ bytes, type }`, bytes served through `serve` (`serveClip` from `@khalido/home/cast/serve`, passed to `cast()`). It reads the receiver status (and a Default Media Receiver's media status), for a group re-runs discovery first and reconnects if the leader moved, sets the volume if given and unmutes a muted target (as two requests: a `SET_VOLUME` takes a level or a mute, not both), launches the Default Media Receiver (`CC1AD845`), `LOAD`s the clip and waits for its media session to go `IDLE` with `FINISHED` (60 s plus the clip's length when the device reports it, or `timeoutMs`). Then, always: what the Default Media Receiver was playing is loaded again at its `currentTime`, an idle device gets `STOP`, then the volume and mute (a group's its own level first, then member by member, through each member's own socket, from the group's multizone status). Another app (Spotify, YouTube) is interrupted, as Google's own announcements do, and the result names it in `notRestored`. It returns `{ played, why?, restored, notRestored }` and throws only before anything changed (an unknown target, a socket down). A clip gets 15 s to reach `PLAYING`, then the name of its type goes in the failure, since a device that can't play it took 35-44 s to say so; a receiver error sooner carries its reason and code. `timeoutMs` replaces both waits. `announceAll(targets, clip, options)` does several at once, one result per device, an unreachable target its own `played: false`; a group and one of its members would clash, untried.
|
|
18
|
+
- **WAV.** A clip of WAV bytes (`audio/wav` and its other names) is rewritten as 48 kHz, 16-bit, stereo PCM before it is served (`castWav`, in `wav.ts`, plain TypeScript, linear interpolation): a 24 kHz mono WAV failed on audio-only speakers (below) and Google lists WAV for them without rates. Integer PCM of 8 to 32 bits and 32-bit float are read; anything else throws before anything is sent. A clip already in that form is sent unchanged. `castWav(bytes)` is exported for a clip served by URL. Live 2026-11-12 (Bun 1.4.4, from the Mac, KO's OK): a 24 kHz mono 16-bit WAV from macOS `say`, converted, played to the end on a Nest Audio at volume 30 in 3.9 s, idle and volume restored. Then at volume 50, one at a time, KO home: a Nest Mini (the oldest here), a Google Home speaker, a Nest Hub and a Nest Hub Max all played it, 3.7 to 7.5 s each, each put back to idle and its volume (the Hub Max, muted, unmuted for the clip and muted again). KO heard each, and the Nest Audio again at volume 100 from the next room. So every Cast model here plays the converted WAV, and none needs MP3. After it, `home cast volume <name> 50` printed the speaker's line once it reported the level (`…: idle · 50%`), the first live run of that line.
|
|
19
|
+
- **Dry run.** Each write takes `{ dryRun: true }` last: the target is found and checked as for a real write, the reads it needs run (an announce reads the receiver status, a Default Media Receiver's media and a group's members, and re-runs discovery for a group), and no write is sent and nothing served. `setVolume`, `setMuted` and `stop` resolve `{ wouldSend }`; `announce` resolves `played: false`, `why: "dry run"`, `wouldSend` in order (volume, LAUNCH, LOAD, then the restores), and in `notRestored` what the real one could not put back. Its `STOP` has no `sessionId`: that is the launched app's, which only the real run knows. Live 2026-11-12 (Bun 1.4.4) on a Nest Hub: `home cast volume … --dry-run` and `home cast announce … --dry-run`, nothing played.
|
|
18
20
|
- `serveClip(bytes, type, { host?, signal? })` (`node:http`, Node-only like `tls.ts`): an address of this machine on the target's network (else the first non-internal IPv4), a random port and an unguessable path, `Content-Type` and `Content-Length`; the whole body to every GET and HEAD (a `Range` probe gets a full 200, which HTTP allows), so a player that probes before it fetches still gets the clip. It closes on `close()`, which `announce` calls once the clip has played, when `signal` aborts, or after 60 s. Not yet seen live: how the Default Media Receiver fetches, and the macOS local-network prompt the first serve may raise.
|
|
19
21
|
- CLI: `home cast announce --to <name> [--to <name>…] (--say "<text>" | --file <path>) [--volume 0-100]`, `home cast volume <name> <0-100>`, `home cast mute <name> on|off`, `home cast stop <name>`. `--say` runs `say -o <tmp>.m4a --file-format=m4af --data-format=aac` (without the data format, m4af holds uncompressed PCM), serves it as `audio/mp4` and deletes the file. The library never runs `say`.
|
|
20
22
|
- Config: one entry per device or group, `{ "connector": "cast", "name": "…", "castId": "…" }`, all folded into one `cast` integration; `home discover --write` fills them and refreshes a renamed entry's `name` (the same `castId` with a new `fn`), printed as `renamed`. No host: discovery gives every address.
|
|
@@ -52,10 +52,14 @@ export type {
|
|
|
52
52
|
CastStatus,
|
|
53
53
|
Resolved,
|
|
54
54
|
} from "./state.ts";
|
|
55
|
+
export { CAST_WAV, castWav, type WavFormat } from "./wav.ts";
|
|
55
56
|
export type {
|
|
56
57
|
AnnounceResult,
|
|
57
58
|
CastActions,
|
|
58
59
|
CastClip,
|
|
60
|
+
CastDryRun,
|
|
61
|
+
CastRequest,
|
|
62
|
+
CastWriteOptions,
|
|
59
63
|
ServeClip,
|
|
60
64
|
} from "./writes.ts";
|
|
61
65
|
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// WAV for Cast speakers. Google lists WAV (LPCM) for Google Home speakers without saying which rates
|
|
2
|
+
// (developers.google.com/cast/docs/media), and a 24 kHz mono WAV failed on two audio-only Nest speakers while the displays
|
|
3
|
+
// played it (live, 2026-11-09). So a WAV clip is rewritten as 48 kHz, 16-bit, stereo, the most common LPCM there is, in
|
|
4
|
+
// plain TypeScript: no encoder, nothing to install. Linear interpolation with no low-pass: enough for speech (TTS is 16 to
|
|
5
|
+
// 24 kHz); a source above 48 kHz would alias.
|
|
6
|
+
|
|
7
|
+
/** What a WAV file says about its audio. */
|
|
8
|
+
export type WavFormat = {
|
|
9
|
+
sampleRate: number;
|
|
10
|
+
channels: number;
|
|
11
|
+
bitsPerSample: number;
|
|
12
|
+
/** 1 integer PCM, 3 IEEE float; WAVE_FORMAT_EXTENSIBLE is read as its sub-format. */
|
|
13
|
+
encoding: "pcm" | "float";
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** The rate, depth and channels every WAV clip is rewritten to. */
|
|
17
|
+
export const CAST_WAV: Omit<WavFormat, "encoding"> = {
|
|
18
|
+
sampleRate: 48_000,
|
|
19
|
+
channels: 2,
|
|
20
|
+
bitsPerSample: 16,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const WAV_TYPES = new Set([
|
|
24
|
+
"audio/wav",
|
|
25
|
+
"audio/wave",
|
|
26
|
+
"audio/x-wav",
|
|
27
|
+
"audio/vnd.wave",
|
|
28
|
+
]);
|
|
29
|
+
/** Whether a clip's MIME type is WAV. */
|
|
30
|
+
export const isWav = (type: string | undefined) =>
|
|
31
|
+
!!type && WAV_TYPES.has(type.split(";")[0]!.trim().toLowerCase());
|
|
32
|
+
|
|
33
|
+
const ascii = (b: Uint8Array, at: number) =>
|
|
34
|
+
String.fromCharCode(b[at]!, b[at + 1]!, b[at + 2]!, b[at + 3]!);
|
|
35
|
+
|
|
36
|
+
/** A WAV file's format and its samples, each channel as floats −1 to 1. Throws on anything but integer or float PCM. */
|
|
37
|
+
export function readWav(bytes: Uint8Array): {
|
|
38
|
+
format: WavFormat;
|
|
39
|
+
channels: Float32Array[];
|
|
40
|
+
} {
|
|
41
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.length);
|
|
42
|
+
if (
|
|
43
|
+
bytes.length < 12 ||
|
|
44
|
+
ascii(bytes, 0) !== "RIFF" ||
|
|
45
|
+
ascii(bytes, 8) !== "WAVE"
|
|
46
|
+
)
|
|
47
|
+
throw new Error("wav: not a RIFF WAVE file");
|
|
48
|
+
let format: WavFormat | undefined;
|
|
49
|
+
let blockAlign = 0;
|
|
50
|
+
let data: Uint8Array | undefined;
|
|
51
|
+
for (let at = 12; at + 8 <= bytes.length;) {
|
|
52
|
+
const id = ascii(bytes, at);
|
|
53
|
+
const size = view.getUint32(at + 4, true);
|
|
54
|
+
// 0xFFFFFFFF is a streaming writer's "unknown length" (ffmpeg on a pipe): to the end. Otherwise a chunk cut short is
|
|
55
|
+
// a file cut short, and a cut-off announcement must not play as if it were whole.
|
|
56
|
+
const end = size === 0xffffffff ? bytes.length : at + 8 + size;
|
|
57
|
+
if (end > bytes.length && (id === "fmt " || id === "data"))
|
|
58
|
+
throw new Error(
|
|
59
|
+
`wav: the ${id.trim()} chunk says ${size} bytes, the file has ${bytes.length - at - 8}`,
|
|
60
|
+
);
|
|
61
|
+
const body = bytes.subarray(at + 8, Math.min(end, bytes.length));
|
|
62
|
+
if (id === "fmt ") {
|
|
63
|
+
if (body.length < 16) throw new Error("wav: a fmt chunk under 16 bytes");
|
|
64
|
+
const f = new DataView(body.buffer, body.byteOffset, body.length);
|
|
65
|
+
let tag = f.getUint16(0, true);
|
|
66
|
+
if (tag === 0xfffe && body.length >= 26) tag = f.getUint16(24, true); // extensible: the sub-format GUID's first two bytes
|
|
67
|
+
if (tag !== 1 && tag !== 3)
|
|
68
|
+
throw new Error(`wav: format ${tag} is not PCM or float`);
|
|
69
|
+
format = {
|
|
70
|
+
channels: f.getUint16(2, true),
|
|
71
|
+
sampleRate: f.getUint32(4, true),
|
|
72
|
+
bitsPerSample: f.getUint16(14, true),
|
|
73
|
+
encoding: tag === 3 ? "float" : "pcm",
|
|
74
|
+
};
|
|
75
|
+
blockAlign = f.getUint16(12, true);
|
|
76
|
+
} else if (id === "data") data = body;
|
|
77
|
+
if (size === 0xffffffff) break;
|
|
78
|
+
at += 8 + size + (size % 2); // chunks are padded to an even length
|
|
79
|
+
}
|
|
80
|
+
if (!format) throw new Error("wav: no fmt chunk");
|
|
81
|
+
if (!data) throw new Error("wav: no data chunk");
|
|
82
|
+
const { channels: n, bitsPerSample: bits, encoding } = format;
|
|
83
|
+
const width = bits / 8;
|
|
84
|
+
if (
|
|
85
|
+
!n ||
|
|
86
|
+
!format.sampleRate ||
|
|
87
|
+
!(encoding === "float" ? bits === 32 : [8, 16, 24, 32].includes(bits))
|
|
88
|
+
)
|
|
89
|
+
throw new Error(
|
|
90
|
+
`wav: ${bits}-bit ${encoding}, ${n} channels at ${format.sampleRate} Hz is not read`,
|
|
91
|
+
);
|
|
92
|
+
if (blockAlign !== width * n)
|
|
93
|
+
throw new Error(
|
|
94
|
+
`wav: ${blockAlign} bytes a frame, not ${width * n} for ${n} × ${bits}-bit`,
|
|
95
|
+
);
|
|
96
|
+
const frames = Math.floor(data.length / (width * n));
|
|
97
|
+
if (!frames) throw new Error("wav: no samples");
|
|
98
|
+
const d = new DataView(data.buffer, data.byteOffset, data.length);
|
|
99
|
+
const out = Array.from({ length: n }, () => new Float32Array(frames));
|
|
100
|
+
for (let i = 0; i < frames; i++)
|
|
101
|
+
for (let c = 0; c < n; c++) {
|
|
102
|
+
const at = (i * n + c) * width;
|
|
103
|
+
out[c]![i] =
|
|
104
|
+
encoding === "float"
|
|
105
|
+
? d.getFloat32(at, true)
|
|
106
|
+
: bits === 8
|
|
107
|
+
? (d.getUint8(at) - 128) / 128
|
|
108
|
+
: bits === 16
|
|
109
|
+
? d.getInt16(at, true) / 32768
|
|
110
|
+
: bits === 24
|
|
111
|
+
? ((d.getUint8(at + 2) << 24) |
|
|
112
|
+
(d.getUint8(at + 1) << 16) |
|
|
113
|
+
(d.getUint8(at) << 8)) /
|
|
114
|
+
2147483648
|
|
115
|
+
: d.getInt32(at, true) / 2147483648;
|
|
116
|
+
}
|
|
117
|
+
return { format, channels: out };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A WAV clip as one a Cast speaker plays: 48 kHz, 16-bit, stereo (`CAST_WAV`). A clip already in that form comes back as
|
|
122
|
+
* it is. Mono is copied to both channels; more than two channels keep the first two. A float sample past ±1 is limited to
|
|
123
|
+
* full scale, as any 16-bit output must.
|
|
124
|
+
*/
|
|
125
|
+
export function castWav(bytes: Uint8Array): {
|
|
126
|
+
bytes: Uint8Array;
|
|
127
|
+
from: WavFormat;
|
|
128
|
+
converted: boolean;
|
|
129
|
+
} {
|
|
130
|
+
const { format, channels } = readWav(bytes);
|
|
131
|
+
const { sampleRate: rate, channels: n, bitsPerSample } = CAST_WAV;
|
|
132
|
+
if (
|
|
133
|
+
format.encoding === "pcm" &&
|
|
134
|
+
format.sampleRate === rate &&
|
|
135
|
+
format.channels === n &&
|
|
136
|
+
format.bitsPerSample === bitsPerSample
|
|
137
|
+
)
|
|
138
|
+
return { bytes, from: format, converted: false };
|
|
139
|
+
const src = [channels[0]!, channels[1] ?? channels[0]!];
|
|
140
|
+
const frames = Math.round((src[0]!.length * rate) / format.sampleRate);
|
|
141
|
+
const step = format.sampleRate / rate;
|
|
142
|
+
const out = new Uint8Array(44 + frames * n * 2);
|
|
143
|
+
const v = new DataView(out.buffer);
|
|
144
|
+
const text = (at: number, s: string) =>
|
|
145
|
+
[...s].forEach((ch, i) => v.setUint8(at + i, ch.charCodeAt(0)));
|
|
146
|
+
text(0, "RIFF");
|
|
147
|
+
v.setUint32(4, out.length - 8, true);
|
|
148
|
+
text(8, "WAVE");
|
|
149
|
+
text(12, "fmt ");
|
|
150
|
+
v.setUint32(16, 16, true);
|
|
151
|
+
v.setUint16(20, 1, true); // PCM
|
|
152
|
+
v.setUint16(22, n, true);
|
|
153
|
+
v.setUint32(24, rate, true);
|
|
154
|
+
v.setUint32(28, rate * n * 2, true); // bytes per second
|
|
155
|
+
v.setUint16(32, n * 2, true); // bytes per frame
|
|
156
|
+
v.setUint16(34, 16, true);
|
|
157
|
+
text(36, "data");
|
|
158
|
+
v.setUint32(40, frames * n * 2, true);
|
|
159
|
+
for (let i = 0; i < frames; i++) {
|
|
160
|
+
const x = i * step;
|
|
161
|
+
const j = Math.floor(x);
|
|
162
|
+
const t = x - j;
|
|
163
|
+
for (let c = 0; c < n; c++) {
|
|
164
|
+
const s = src[c]!;
|
|
165
|
+
const a = s[j] ?? 0;
|
|
166
|
+
const b = s[j + 1] ?? a;
|
|
167
|
+
const raw = a + (b - a) * t;
|
|
168
|
+
if (Number.isNaN(raw))
|
|
169
|
+
throw new Error(`wav: sample ${j} is not a number`);
|
|
170
|
+
const y = Math.max(-1, Math.min(1, raw));
|
|
171
|
+
v.setInt16(44 + (i * n + c) * 2, Math.round(y * 32767), true);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return { bytes: out, from: format, converted: true };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** A format in a few words: `24000 Hz mono 16-bit`. */
|
|
178
|
+
export const wavText = (
|
|
179
|
+
f: Omit<WavFormat, "encoding"> & { encoding?: string },
|
|
180
|
+
) =>
|
|
181
|
+
`${f.sampleRate} Hz ${f.channels === 1 ? "mono" : f.channels === 2 ? "stereo" : `${f.channels}-channel`} ${f.bitsPerSample}-bit${f.encoding === "float" ? " float" : ""}`;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Clock } from "../../clock.ts";
|
|
2
2
|
import { errorText } from "../../connector.ts";
|
|
3
3
|
import { type WriteLane, writeLane } from "../../lane.ts";
|
|
4
|
+
import { CAST_WAV, castWav, isWav, type WavFormat, wavText } from "./wav.ts";
|
|
4
5
|
import { NS } from "./codec.ts";
|
|
5
6
|
import {
|
|
6
7
|
type Application,
|
|
@@ -43,12 +44,27 @@ const REFUSALS = new Set([
|
|
|
43
44
|
export type CastClip =
|
|
44
45
|
{ url: string; type?: string } | { bytes: Uint8Array; type: string };
|
|
45
46
|
|
|
46
|
-
/**
|
|
47
|
+
/** One Cast message, as a dry run reports it: the device it goes to, the channel (`receiver` or the app's `media`) and the payload. */
|
|
48
|
+
export type CastRequest = {
|
|
49
|
+
device: string;
|
|
50
|
+
channel: "receiver" | "media";
|
|
51
|
+
payload: Record<string, unknown>;
|
|
52
|
+
};
|
|
53
|
+
/** `dryRun`: run every check a real write runs (the target, its connection, the input) and the reads it makes first, send no write, and say what would go. */
|
|
54
|
+
export type CastWriteOptions = { dryRun?: boolean };
|
|
55
|
+
/** A dry run's answer: the messages in order; empty when there is nothing to send. */
|
|
56
|
+
export type CastDryRun = { wouldSend: CastRequest[] };
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* What `announce` did: whether the clip played to its end, why not, and what it put back or could not. A dry run has
|
|
60
|
+
* `played: false`, `why: "dry run"`, `wouldSend`, and in `notRestored` what the real one could not put back.
|
|
61
|
+
*/
|
|
47
62
|
export type AnnounceResult = {
|
|
48
63
|
played: boolean;
|
|
49
64
|
why?: string;
|
|
50
65
|
restored: string[];
|
|
51
66
|
notRestored: { name: string; why: string }[];
|
|
67
|
+
wouldSend?: CastRequest[];
|
|
52
68
|
};
|
|
53
69
|
|
|
54
70
|
/** Serves bytes once over HTTP on the LAN: `serveClip` from `@khalido/home/cast/serve`. `host` is the device that will fetch it. */
|
|
@@ -60,20 +76,32 @@ export type ServeClip = (
|
|
|
60
76
|
|
|
61
77
|
/** The writes on `cast()`'s object. A target is a device's or group's name (any case) or its castId; volume is 0 to 1, as the snapshot's. */
|
|
62
78
|
export type CastActions = {
|
|
63
|
-
|
|
64
|
-
|
|
79
|
+
/** Resolves `undefined` once sent, or a `CastDryRun` under `dryRun`. */
|
|
80
|
+
setVolume(
|
|
81
|
+
target: string,
|
|
82
|
+
level: number,
|
|
83
|
+
options?: CastWriteOptions,
|
|
84
|
+
): Promise<CastDryRun | undefined>;
|
|
85
|
+
setMuted(
|
|
86
|
+
target: string,
|
|
87
|
+
muted: boolean,
|
|
88
|
+
options?: CastWriteOptions,
|
|
89
|
+
): Promise<CastDryRun | undefined>;
|
|
65
90
|
/** Ends the running app (receiver STOP), which returns the device to its idle screen. Nothing running: nothing sent. */
|
|
66
|
-
stop(
|
|
91
|
+
stop(
|
|
92
|
+
target: string,
|
|
93
|
+
options?: CastWriteOptions,
|
|
94
|
+
): Promise<CastDryRun | undefined>;
|
|
67
95
|
announce(
|
|
68
96
|
target: string,
|
|
69
97
|
clip: CastClip,
|
|
70
|
-
options?: { volume?: number; timeoutMs?: number },
|
|
98
|
+
options?: { volume?: number; timeoutMs?: number } & CastWriteOptions,
|
|
71
99
|
): Promise<AnnounceResult>;
|
|
72
100
|
/** `announce` to several targets at once, one result per device, in the order given. A target that can't be reached is its own `played: false`. A group and one of its own members are not told apart. */
|
|
73
101
|
announceAll(
|
|
74
102
|
targets: string[],
|
|
75
103
|
clip: CastClip,
|
|
76
|
-
options?: { volume?: number; timeoutMs?: number },
|
|
104
|
+
options?: { volume?: number; timeoutMs?: number } & CastWriteOptions,
|
|
77
105
|
): Promise<(AnnounceResult & { target: string })[]>;
|
|
78
106
|
};
|
|
79
107
|
|
|
@@ -407,22 +435,55 @@ export function castActions(ctx: WriteContext): CastActions {
|
|
|
407
435
|
);
|
|
408
436
|
};
|
|
409
437
|
|
|
438
|
+
const toReceiver = (
|
|
439
|
+
d: CastDevice,
|
|
440
|
+
payload: Record<string, unknown>,
|
|
441
|
+
): CastRequest => ({ device: d.name, channel: "receiver", payload });
|
|
442
|
+
/** A dry SET_VOLUME: the target found and connected, nothing sent. */
|
|
443
|
+
const dryVolume = (
|
|
444
|
+
target: string,
|
|
445
|
+
volume: { level: number } | { muted: boolean },
|
|
446
|
+
): CastDryRun => {
|
|
447
|
+
const d = find(target);
|
|
448
|
+
live(d);
|
|
449
|
+
return {
|
|
450
|
+
wouldSend: [toReceiver(d, { type: "SET_VOLUME", volume })],
|
|
451
|
+
};
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
/** Each WAV clip made here, and the format it came from, so a dry run can say what was converted. */
|
|
455
|
+
const fromWav = new WeakMap<Uint8Array, WavFormat>();
|
|
456
|
+
/** WAV bytes as 48 kHz 16-bit stereo, which audio-only speakers play (`wav.ts`); any other clip as it is. */
|
|
457
|
+
const playable = (clip: CastClip): CastClip => {
|
|
458
|
+
if ("url" in clip || !isWav(clip.type) || fromWav.has(clip.bytes))
|
|
459
|
+
return clip;
|
|
460
|
+
const r = castWav(clip.bytes);
|
|
461
|
+
if (r.converted) fromWav.set(r.bytes, r.from);
|
|
462
|
+
return { bytes: r.bytes, type: "audio/wav" };
|
|
463
|
+
};
|
|
464
|
+
|
|
410
465
|
const actions: CastActions = {
|
|
411
|
-
async setVolume(target, level) {
|
|
466
|
+
async setVolume(target, level, options) {
|
|
412
467
|
checkLevel(level);
|
|
468
|
+
if (options?.dryRun) return dryVolume(target, { level });
|
|
413
469
|
await setVolume(target, { level });
|
|
414
470
|
},
|
|
415
|
-
async setMuted(target, muted) {
|
|
471
|
+
async setMuted(target, muted, options) {
|
|
472
|
+
if (options?.dryRun) return dryVolume(target, { muted });
|
|
416
473
|
await setVolume(target, { muted });
|
|
417
474
|
},
|
|
418
|
-
async stop(target) {
|
|
419
|
-
const
|
|
475
|
+
async stop(target, options) {
|
|
476
|
+
const d = find(target);
|
|
477
|
+
const s = live(d);
|
|
420
478
|
const app = (await receiver(s)).applications?.[0];
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
479
|
+
const stop = {
|
|
480
|
+
type: "STOP",
|
|
481
|
+
...(app?.sessionId ? { sessionId: app.sessionId } : {}),
|
|
482
|
+
};
|
|
483
|
+
const running = app && !app.isIdleScreen;
|
|
484
|
+
if (options?.dryRun)
|
|
485
|
+
return { wouldSend: running ? [toReceiver(d, stop)] : [] };
|
|
486
|
+
if (running) await receiver(s, stop);
|
|
426
487
|
},
|
|
427
488
|
|
|
428
489
|
/**
|
|
@@ -431,12 +492,14 @@ export function castActions(ctx: WriteContext): CastActions {
|
|
|
431
492
|
* device gets STOP, then the volume (a group's own first, then member by member through each member's own socket). Another app
|
|
432
493
|
* (Spotify, YouTube) is interrupted and cannot be resumed from here; the result says so. Throws only before anything changed.
|
|
433
494
|
*/
|
|
434
|
-
async announce(target,
|
|
495
|
+
async announce(target, given, options = {}) {
|
|
435
496
|
if (options.volume !== undefined) checkLevel(options.volume);
|
|
436
|
-
if (!("url" in
|
|
497
|
+
if (!("url" in given) && !ctx.serve)
|
|
437
498
|
throw new Error(
|
|
438
499
|
"a clip of bytes needs serve: serveClip from @khalido/home/cast/serve",
|
|
439
500
|
);
|
|
501
|
+
const clip = playable(given); // a bad WAV throws here, before anything changed
|
|
502
|
+
const wavFrom = "url" in clip ? undefined : fromWav.get(clip.bytes);
|
|
440
503
|
let d = find(target);
|
|
441
504
|
const s = d.kind === "group" ? await regroup(d) : live(d);
|
|
442
505
|
d = ctx.devices()[d.id] ?? d; // a group's host as discovery gives it now, for serving the clip on its network
|
|
@@ -456,6 +519,7 @@ export function castActions(ctx: WriteContext): CastActions {
|
|
|
456
519
|
restored: [],
|
|
457
520
|
notRestored: [],
|
|
458
521
|
};
|
|
522
|
+
if (options.dryRun) return dryAnnounce();
|
|
459
523
|
const serving = new AbortController();
|
|
460
524
|
let served: { close(): void } | undefined;
|
|
461
525
|
let volumeSet = false;
|
|
@@ -501,6 +565,59 @@ export function castActions(ctx: WriteContext): CastActions {
|
|
|
501
565
|
}
|
|
502
566
|
return result;
|
|
503
567
|
|
|
568
|
+
/** What the announce would send, from the reads above; what it could not put back goes in `notRestored`. */
|
|
569
|
+
function dryAnnounce(): AnnounceResult {
|
|
570
|
+
const to = (
|
|
571
|
+
device: { name: string },
|
|
572
|
+
payload: Record<string, unknown>,
|
|
573
|
+
): CastRequest => ({
|
|
574
|
+
device: device.name,
|
|
575
|
+
channel: "receiver",
|
|
576
|
+
payload,
|
|
577
|
+
});
|
|
578
|
+
const media = (payload: Record<string, unknown>): CastRequest => ({
|
|
579
|
+
device: d.name,
|
|
580
|
+
channel: "media",
|
|
581
|
+
payload,
|
|
582
|
+
});
|
|
583
|
+
const sends: CastRequest[] = [];
|
|
584
|
+
if (options.volume !== undefined)
|
|
585
|
+
sends.push(
|
|
586
|
+
to(d, { type: "SET_VOLUME", volume: { level: options.volume } }),
|
|
587
|
+
);
|
|
588
|
+
if (unmute)
|
|
589
|
+
sends.push(to(d, { type: "SET_VOLUME", volume: { muted: false } }));
|
|
590
|
+
sends.push(
|
|
591
|
+
to(d, { type: "LAUNCH", appId: DEFAULT_MEDIA_RECEIVER }),
|
|
592
|
+
media({
|
|
593
|
+
type: "LOAD",
|
|
594
|
+
media: {
|
|
595
|
+
contentId:
|
|
596
|
+
"url" in clip
|
|
597
|
+
? clip.url
|
|
598
|
+
: `(${clip.bytes.length} bytes of ${clip.type}${wavFrom ? `, converted from ${wavText(wavFrom)} to ${wavText(CAST_WAV)}` : ""}, served on the LAN)`,
|
|
599
|
+
contentType: clip.type ?? "audio/mpeg",
|
|
600
|
+
streamType: "BUFFERED",
|
|
601
|
+
},
|
|
602
|
+
autoplay: true,
|
|
603
|
+
}),
|
|
604
|
+
);
|
|
605
|
+
if (priorMedia) sends.push(media({ type: "LOAD", ...priorMedia }));
|
|
606
|
+
else {
|
|
607
|
+
sends.push(to(d, { type: "STOP" }));
|
|
608
|
+
if (!idleApp(prior) && prior?.appId !== DEFAULT_MEDIA_RECEIVER)
|
|
609
|
+
result.notRestored.push({
|
|
610
|
+
name: d.name,
|
|
611
|
+
why: `${prior?.displayName ?? prior?.appId ?? "an app"} would be interrupted and cannot be resumed from here`,
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
for (const step of volumeSteps())
|
|
615
|
+
if ("gap" in step)
|
|
616
|
+
result.notRestored.push({ name: step.name, why: step.gap });
|
|
617
|
+
else for (const p of step.payloads) sends.push(to(step, p));
|
|
618
|
+
return { ...result, why: "dry run", wouldSend: sends };
|
|
619
|
+
}
|
|
620
|
+
|
|
504
621
|
async function restoreApp() {
|
|
505
622
|
try {
|
|
506
623
|
// A LAUNCH that timed out may still have started: look before deciding nothing of ours runs.
|
|
@@ -538,81 +655,104 @@ export function castActions(ctx: WriteContext): CastActions {
|
|
|
538
655
|
}
|
|
539
656
|
}
|
|
540
657
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
658
|
+
/**
|
|
659
|
+
* The volume put back after the clip, as steps: a group's own level first (it keeps one of its own, which its members'
|
|
660
|
+
* levels don't bring back: live, 2026-11-10, it stayed at the announcement's 30 with every member restored), so
|
|
661
|
+
* whatever it does to the members, their own levels land last; then each member's, or the target's, through its own
|
|
662
|
+
* socket. A step it can't take is a gap with the reason. `restoreVolume` takes them; a dry run reports them.
|
|
663
|
+
*/
|
|
664
|
+
function volumeSteps(): (
|
|
665
|
+
| {
|
|
666
|
+
name: string;
|
|
667
|
+
socket: CastSession;
|
|
668
|
+
payloads: Record<string, unknown>[];
|
|
669
|
+
label: string;
|
|
670
|
+
}
|
|
671
|
+
| { name: string; gap: string }
|
|
672
|
+
)[] {
|
|
673
|
+
const steps: ReturnType<typeof volumeSteps> = [];
|
|
555
674
|
if (d.kind === "group" && options.volume !== undefined) {
|
|
556
675
|
const level = before.volume?.level;
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
name: d.name,
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
result.notRestored.push({
|
|
568
|
-
name: d.name,
|
|
569
|
-
why: `volume not restored: ${errorText(error)}`,
|
|
570
|
-
});
|
|
571
|
-
}
|
|
676
|
+
steps.push(
|
|
677
|
+
level === undefined
|
|
678
|
+
? { name: d.name, gap: "its volume before was not reported" }
|
|
679
|
+
: {
|
|
680
|
+
name: d.name,
|
|
681
|
+
socket: s,
|
|
682
|
+
payloads: [{ type: "SET_VOLUME", volume: { level } }],
|
|
683
|
+
label: `${d.name}: volume`,
|
|
684
|
+
},
|
|
685
|
+
);
|
|
572
686
|
}
|
|
687
|
+
const each =
|
|
688
|
+
d.kind === "group"
|
|
689
|
+
? members
|
|
690
|
+
: options.volume !== undefined || unmute
|
|
691
|
+
? [
|
|
692
|
+
{
|
|
693
|
+
id: d.id,
|
|
694
|
+
name: d.name,
|
|
695
|
+
level: before.volume?.level,
|
|
696
|
+
muted: before.volume?.muted,
|
|
697
|
+
},
|
|
698
|
+
]
|
|
699
|
+
: [];
|
|
573
700
|
for (const m of each) {
|
|
574
701
|
const ms = m.id === d.id ? s : ctx.session(m.id);
|
|
575
702
|
if (m.level === undefined)
|
|
576
|
-
|
|
703
|
+
steps.push({
|
|
577
704
|
name: m.name,
|
|
578
|
-
|
|
705
|
+
gap: "its volume before was not reported",
|
|
579
706
|
});
|
|
580
707
|
else if (!ms)
|
|
581
|
-
|
|
708
|
+
steps.push({
|
|
582
709
|
name: m.name,
|
|
583
|
-
|
|
710
|
+
gap: "not connected, so its volume stays as the announcement set it",
|
|
584
711
|
});
|
|
585
712
|
else
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
}
|
|
713
|
+
steps.push({
|
|
714
|
+
name: m.name,
|
|
715
|
+
socket: ms,
|
|
716
|
+
payloads: [
|
|
717
|
+
{ type: "SET_VOLUME", volume: { level: m.level } },
|
|
718
|
+
...(m.muted
|
|
719
|
+
? [{ type: "SET_VOLUME", volume: { muted: true } }]
|
|
720
|
+
: []),
|
|
721
|
+
],
|
|
722
|
+
label: `${m.name}: volume${m.muted ? ", muted" : ""}`,
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
return steps;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
async function restoreVolume() {
|
|
729
|
+
for (const step of volumeSteps()) {
|
|
730
|
+
if ("gap" in step) {
|
|
731
|
+
result.notRestored.push({ name: step.name, why: step.gap });
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
try {
|
|
735
|
+
for (const p of step.payloads)
|
|
736
|
+
await receiver(step.socket, p as { type: string });
|
|
737
|
+
result.restored.push(step.label);
|
|
738
|
+
} catch (error) {
|
|
739
|
+
result.notRestored.push({
|
|
740
|
+
name: step.name,
|
|
741
|
+
why: `volume not restored: ${errorText(error)}`,
|
|
742
|
+
});
|
|
743
|
+
}
|
|
605
744
|
}
|
|
606
745
|
}
|
|
607
746
|
},
|
|
608
747
|
|
|
609
|
-
async announceAll(targets,
|
|
748
|
+
async announceAll(targets, given, options = {}) {
|
|
610
749
|
// A caller's mistake is one error, not a failure per speaker.
|
|
611
750
|
if (options.volume !== undefined) checkLevel(options.volume);
|
|
612
|
-
if (!("url" in
|
|
751
|
+
if (!("url" in given) && !ctx.serve)
|
|
613
752
|
throw new Error(
|
|
614
753
|
"a clip of bytes needs serve: serveClip from @khalido/home/cast/serve",
|
|
615
754
|
);
|
|
755
|
+
const clip = playable(given); // converted once, for every target
|
|
616
756
|
const id = (t: string) => {
|
|
617
757
|
try {
|
|
618
758
|
return find(t).id;
|
|
@@ -10,9 +10,11 @@ Reads an IQ Gateway (Envoy) on the LAN: production, consumption and grid flow. `
|
|
|
10
10
|
- `host` may be pasted with a scheme or a trailing slash (`https://…/`); `gatewayHost` reduces it to the bare host for requests, discovery and the address cache.
|
|
11
11
|
- In the home config the device may carry `marker` (the plan's marker where the array is drawn, on the roof outside any room) and `capacityW` (the array's rated watts from the installer's paperwork, never guessed from the gateway). Neither reaches the gateway; they are for drawing.
|
|
12
12
|
- A meter counts only when `/ivp/meters` says `enabled` **and** its reading has `activeCount > 0`; otherwise production comes from the inverters and Wh today is left out, since pyenphase found the inverter daily total unreliable.
|
|
13
|
-
- The token's days left ride in the snapshot; under 30 the status says to run `home enphase login`. A 401 marks Enphase unavailable and stops polling.
|
|
13
|
+
- The token's days left ride in the snapshot; under 30 the status says to run `home enphase login`. A 401 or 403 marks Enphase unavailable and stops polling.
|
|
14
14
|
- `home enphase login` mints the token (below) and writes `~/.config/home/enphase-token.json` (0600, directory 0700), printing only the days left. `home status` shows solar now, consumption and the token's days left.
|
|
15
15
|
|
|
16
|
+
**Live, 2026-11-12 (Bun 1.4.4).** From late October `home enphase` said ECONNREFUSED. The gateway had not changed: DHCP had given it a new address, mDNS and the address cache had the new one, and an `IQ_GATEWAY_HOST` in the shell still named the old one, which the CLI trusted without a fallback. The CLI now takes the serial from the address cache and runs discovery after one failed read, so the status line reads and says "moved from a to b". The owner token was fine throughout (338 days left).
|
|
17
|
+
|
|
16
18
|
**Live, KO's gateway, 2026-10-13 (D8.3.5528, owner token).** `/info` answers without a token; everything else answers 401 without one. With the token, one GET each:
|
|
17
19
|
|
|
18
20
|
- `/ivp/meters`: `production` and `net-consumption` **enabled**, `meteringStatus normal`; `storage`, `backfeed`, `load`, `evse`, `pv3p` disabled. So both CTs are fitted.
|