@khalido/home 0.1.2 → 0.1.3
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 +33 -2
- package/README.md +1 -1
- package/dist/clock.js +2 -0
- package/dist/connector.d.ts +9 -2
- package/dist/connectors/amber/index.js +1 -0
- package/dist/connectors/cast/index.d.ts +8 -6
- package/dist/connectors/cast/index.js +30 -4
- package/dist/connectors/cast/state.d.ts +52 -0
- package/dist/connectors/cast/state.js +122 -0
- package/dist/connectors/cast/writes.d.ts +0 -2
- package/dist/connectors/cast/writes.js +25 -7
- package/dist/connectors/daikin/index.d.ts +6 -2
- package/dist/connectors/daikin/index.js +21 -1
- package/dist/connectors/enphase/index.d.ts +5 -1
- package/dist/connectors/enphase/index.js +34 -2
- package/dist/connectors/hue/index.d.ts +6 -2
- package/dist/connectors/hue/index.js +39 -6
- package/dist/connectors/printers/index.d.ts +6 -2
- package/dist/connectors/printers/index.js +23 -0
- package/dist/connectors/spotify/index.d.ts +6 -1
- package/dist/connectors/spotify/index.js +26 -1
- package/dist/connectors/up/index.js +1 -0
- package/dist/connectors/weather/bom.js +1 -0
- package/dist/connectors/weather/google.js +52 -9
- package/dist/connectors/weather/index.d.ts +2 -1
- package/dist/connectors/weather/index.js +7 -0
- package/dist/connectors/weather/source.d.ts +2 -0
- package/dist/devices.d.ts +27 -0
- package/dist/devices.js +61 -0
- package/dist/health.d.ts +2 -1
- package/dist/health.js +2 -1
- package/dist/home.d.ts +41 -3
- package/dist/home.js +24 -7
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/docs/cli.md +1 -1
- package/docs/config.md +8 -3
- package/docs/decisions.md +4 -0
- package/docs/using.md +17 -0
- package/package.json +1 -1
- package/skills/use-home/SKILL.md +7 -7
- package/src/cli/enphase.ts +5 -1
- package/src/cli/status.ts +26 -3
- package/src/clock.ts +1 -0
- package/src/connector.ts +18 -2
- package/src/connectors/amber/index.ts +1 -0
- package/src/connectors/cast/README.md +7 -3
- package/src/connectors/cast/index.ts +59 -7
- package/src/connectors/cast/state.ts +177 -0
- package/src/connectors/cast/writes.ts +29 -8
- package/src/connectors/daikin/README.md +4 -0
- package/src/connectors/daikin/index.ts +41 -2
- package/src/connectors/enphase/index.ts +49 -2
- package/src/connectors/hue/index.ts +57 -8
- package/src/connectors/printers/README.md +4 -2
- package/src/connectors/printers/index.ts +43 -1
- package/src/connectors/spotify/README.md +14 -2
- package/src/connectors/spotify/index.ts +46 -2
- package/src/connectors/up/index.ts +1 -0
- package/src/connectors/weather/README.md +1 -1
- package/src/connectors/weather/bom.ts +1 -0
- package/src/connectors/weather/google.ts +66 -10
- package/src/connectors/weather/index.ts +16 -2
- package/src/connectors/weather/source.ts +2 -0
- package/src/devices.ts +102 -0
- package/src/health.ts +4 -2
- package/src/home.ts +75 -8
- package/src/index.ts +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,39 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
All notable changes to home.ts (`@khalido/home` on npm), in [Keep a Changelog](https://keepachangelog.com/) form. Versions follow [SemVer](https://semver.org) at 0.x: a minor release can break what a consumer calls, a patch can't.
|
|
3
|
+
All notable changes to home.ts (`@khalido/home` on npm), in [Keep a Changelog](https://keepachangelog.com/) form. Versions follow [SemVer](https://semver.org) at 0.x: a minor release can break what a consumer calls, a patch can't. A release that breaks something opens with a **Breaking** heading, so a consumer on `latest` sees it first.
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.1.3] - 2026-11-10
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`Part.reason`:** why a part is `unavailable`, for code to switch on: `auth` (a key or token was rejected or unusable, so a person must re-key), `config` (a setting or file is wrong) or `crashed` (its `run` threw). `health()` and `home --json` carry it. It can be absent. Hue, Enphase, Amber and Up set `auth` on a rejected key, and so does an Enphase token that does not parse. In `home status`, a connector that fails to build from the environment says `auth` or `config`.
|
|
12
|
+
- **`homeDevices(snapshot, config?)`:** every device in one list (Amber, Up and the weather read services and list nothing), each answering the same questions (`connector`, `id`, `name`, `kind`, `available`, `reason`, `asOf`, `state`, `text`, `room`, `members`), with the connector's own typed row in `detail` and `connector` narrowing its type. Each device connector also exports its own: `hueHomeDevices`, `daikinHomeDevices`, `enphaseHomeDevices`, `castHomeDevices`, `printersHomeDevices`, `spotifyHomeDevices`. `DeviceRecord` is the common shape and `deviceRecords(part, records)` what a connector returns them through: while its part isn't connected, none is `available`.
|
|
13
|
+
- **`ConnectorCode`:** the type of `ConnectorError.code`, every code the library sets, so a consumer's table of messages fails to compile when one is added.
|
|
14
|
+
- **cast:** `castStatus(snapshot)` says what each device and group is doing (`idle`, `restorable`, `busy` or `offline`, with the app, title and volume), and `castFree(snapshot, targets)` gives the targets an announcement would not disturb, with the ones it `skipped` and why. For announcing only where nothing is playing.
|
|
15
|
+
- **weather:** `reason` on an `unavailable` weather part (`auth` if a stopped source's key was rejected, else `config`), and `state.sources.<name>.reason` beside `stopped`, so code can see a dead Google key while another source keeps the part `connected`. A custom `WeatherSource` can return `stopReason` with `stop`.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- **`ConnectorError.code`** is typed `ConnectorCode`, no longer `string`. Reading it is unchanged. Only code that builds a `ConnectorError` with a code outside that set stops compiling; chota-bot and jaldi don't.
|
|
20
|
+
- **hue:** a room or zone the bridge gives no on or off for prints its name alone in `home hue`, no longer "off".
|
|
21
|
+
- **weather:** Google's key is sent in the `X-Goog-Api-Key` header, no longer in the URL.
|
|
22
|
+
- **weather:** Google's stop text says which it was: `google: the key was rejected (HTTP 400 API_KEY_INVALID)` or `google: the request was refused (HTTP 400); check the latitude and longitude`. Before, both read `google: key rejected or request refused (HTTP 400)`.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- **cast:** `announce` to a group with a `volume` left the group's own level at the announcement's, though every member's was put back. The group's is now restored too, before its members', so theirs land last.
|
|
27
|
+
- **spotify:** `interrupt` paused the music when the account was playing on another speaker (the box reports `playing` while not the active device). It now leaves it alone and returns `music: "untouched"`, as `duck` does.
|
|
28
|
+
- **spotify:** `maxVolume` gave up when a lowering was refused or unanswered after a second start signal (buffering, then playing) had opened a new window, leaving the volume above the cap. The new window now lowers again.
|
|
29
|
+
- **`createHome`:** an `onChange` that throws or rejects no longer marks the integration `unavailable`; the error goes to `console.error` and the integration keeps running.
|
|
30
|
+
- **`createHome`:** `refresh()` before `start()` resolves at once instead of never.
|
|
31
|
+
- **`createHome`:** an integration whose `run` fails after a good report keeps its last `state`.
|
|
32
|
+
- **`createHome`:** a `run` that throws synchronously marks that integration `unavailable`; the others still start.
|
|
33
|
+
- **`home <connector> --config`** needs only that connector's keys, and runs only that connector.
|
|
34
|
+
- **`realClock.sleep`** resolves at once on an already-aborted signal.
|
|
35
|
+
- **Docs:** the `readFile` in `docs/config.md` expands `~/`, which `home discover --write` puts in Enphase's `token`. The README says the package is ESM only.
|
|
36
|
+
|
|
7
37
|
## [0.1.2] - 2026-11-09
|
|
8
38
|
|
|
9
39
|
From chota-bot's live writes on a Raspberry Pi. `setVolume` on an inactive speaker now rejects, and `track` events can come later than before.
|
|
@@ -68,7 +98,8 @@ First release. Nine connectors: Hue, Daikin, Enphase, Google Cast, AirPrint prin
|
|
|
68
98
|
- **Agent skills** in `skills/`: `use-home` (reading safely, connecting, the rules for writes) and `draw-home` (an SVG plan of a home and its rooms). `llms.txt` points agents at the docs.
|
|
69
99
|
- **Node.** Hue and Enphase take the fetches in `@khalido/home/node`. The library runs on Bun 1.4+ and Node 24+; the `home` command needs Bun.
|
|
70
100
|
|
|
71
|
-
[Unreleased]: https://github.com/khalido/home.ts/compare/v0.1.
|
|
101
|
+
[Unreleased]: https://github.com/khalido/home.ts/compare/v0.1.3...HEAD
|
|
102
|
+
[0.1.3]: https://github.com/khalido/home.ts/compare/v0.1.2...v0.1.3
|
|
72
103
|
[0.1.2]: https://github.com/khalido/home.ts/compare/v0.1.1...v0.1.2
|
|
73
104
|
[0.1.1]: https://github.com/khalido/home.ts/compare/v0.1.0...v0.1.1
|
|
74
105
|
[0.1.0]: https://github.com/khalido/home.ts/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ Small, typed home automation for TypeScript, starting with the devices in my hou
|
|
|
13
13
|
bun add @khalido/home # or: npm install @khalido/home
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
The library runs on Bun 1.4+ and Node 24
|
|
16
|
+
The library runs on Bun 1.4+ and Node 24+, and is ESM only. The `home` command needs Bun.
|
|
17
17
|
|
|
18
18
|
## Try it
|
|
19
19
|
|
package/dist/clock.js
CHANGED
package/dist/connector.d.ts
CHANGED
|
@@ -6,13 +6,20 @@ export declare const TIMEOUT_MS = 10000;
|
|
|
6
6
|
* Node's fetch puts the reason there: "fetch failed: connect ECONNREFUSED 192.0.2.10:443".
|
|
7
7
|
*/
|
|
8
8
|
export declare function errorText(error: unknown): string;
|
|
9
|
+
/**
|
|
10
|
+
* Every `code` a `ConnectorError` from this library carries, so a consumer's table of messages fails to compile when one is added.
|
|
11
|
+
* The spotify connector's writes: `offline` (not connected), `waiting` (nobody signed in), `inactive` (not the active device), `refused`
|
|
12
|
+
* (the device said no), `timeout` (no answer), `invalid` (bad input, nothing sent). `link_button`: Hue wants its button
|
|
13
|
+
* pressed. `ipp`: a printer answered with an IPP error, its code in `status`.
|
|
14
|
+
*/
|
|
15
|
+
export type ConnectorCode = "offline" | "waiting" | "inactive" | "refused" | "timeout" | "invalid" | "link_button" | "ipp";
|
|
9
16
|
/** A failed device or service call. `status` is the HTTP status (or a device's own code); `code` a connector's own reason. */
|
|
10
17
|
export declare class ConnectorError extends Error {
|
|
11
18
|
readonly status?: number;
|
|
12
|
-
readonly code?:
|
|
19
|
+
readonly code?: ConnectorCode;
|
|
13
20
|
constructor(message: string, options?: {
|
|
14
21
|
status?: number;
|
|
15
|
-
code?:
|
|
22
|
+
code?: ConnectorCode;
|
|
16
23
|
cause?: unknown;
|
|
17
24
|
});
|
|
18
25
|
}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { type Clock } from "../../clock.ts";
|
|
3
3
|
import { type Found } from "../../discover.ts";
|
|
4
|
-
import type
|
|
4
|
+
import { type DeviceRecord, type Integration, type Part } from "../../home.ts";
|
|
5
5
|
import type { Manifest } from "../../manifest.ts";
|
|
6
6
|
import { type CastConnect } from "./codec.ts";
|
|
7
|
-
import { type CastDevice, type CastSnapshot, type Resolved } from "./state.ts";
|
|
7
|
+
import { type CastDevice, type CastSnapshot, type Resolved, type CastForm, type CastStatus } from "./state.ts";
|
|
8
8
|
import { type CastActions, type ServeClip } from "./writes.ts";
|
|
9
9
|
export type { CastConnect } from "./codec.ts";
|
|
10
|
-
export { castForm } from "./state.ts";
|
|
11
|
-
export type { CastDevice, CastForm, CastSnapshot, Resolved } from "./state.ts";
|
|
10
|
+
export { castDevices, castForm, castFree, castStatus } from "./state.ts";
|
|
11
|
+
export type { CastDevice, CastForm, CastSkipped, CastSnapshot, CastStatus, Resolved, } from "./state.ts";
|
|
12
12
|
export type { AnnounceResult, CastActions, CastClip, ServeClip, } from "./writes.ts";
|
|
13
13
|
/** A Cast device or speaker group in a home config. No host: a group's moves with its leader, so every address comes from discovery. */
|
|
14
14
|
export declare const configSchema: z.ZodObject<{
|
|
@@ -58,6 +58,8 @@ export type CastConfig = {
|
|
|
58
58
|
export declare function cast(config: CastConfig): Integration<CastSnapshot, "cast"> & CastActions;
|
|
59
59
|
/** One Cast device or group in a few words: "Nest Hub: idle · 47%", "Group: idle · 43% · 3 members: A, B, C, led by A". */
|
|
60
60
|
export declare function castText(d: CastDevice, s: CastSnapshot): string;
|
|
61
|
-
/**
|
|
62
|
-
export
|
|
61
|
+
/** A Cast device or group as a home device: `kind` is its form (`cast` for a model it doesn't know) or `group`; `detail` its `CastStatus`. */
|
|
62
|
+
export type CastHomeDevice = DeviceRecord<"cast", CastForm | "cast" | "group", CastStatus["state"], CastStatus>;
|
|
63
|
+
/** Every Cast device and group as a home device, devices first, each by name. */
|
|
64
|
+
export declare function castHomeDevices(part: Part<CastSnapshot> | undefined): CastHomeDevice[];
|
|
63
65
|
export declare const manifest: Manifest<CastSnapshot>;
|
|
@@ -3,11 +3,12 @@ import { asOf, realClock } from "../../clock.js";
|
|
|
3
3
|
import { errorText } from "../../connector.js";
|
|
4
4
|
import { HOUR, lostDetail, rescanDelay } from "../../discover.js";
|
|
5
5
|
import { poller } from "../../poll.js";
|
|
6
|
+
import { deviceRecords, } from "../../home.js";
|
|
6
7
|
import { NS } from "./codec.js";
|
|
7
8
|
import { CastPayload, KNOWN_TYPES } from "./schemas.js";
|
|
8
|
-
import { castKey, connection, found, mediaApp, reduce, } from "./state.js";
|
|
9
|
+
import { castKey, connection, found, mediaApp, reduce, castDevices, castStatus, } from "./state.js";
|
|
9
10
|
import { castActions, } from "./writes.js";
|
|
10
|
-
export { castForm } from "./state.js";
|
|
11
|
+
export { castDevices, castForm, castFree, castStatus } from "./state.js";
|
|
11
12
|
/** A Cast device or speaker group in a home config. No host: a group's moves with its leader, so every address comes from discovery. */
|
|
12
13
|
export const configSchema = z.looseObject({
|
|
13
14
|
connector: z.literal("cast"),
|
|
@@ -411,8 +412,33 @@ export function castText(d, s) {
|
|
|
411
412
|
];
|
|
412
413
|
return `${d.name}: ${parts.filter(Boolean).join(" · ")}`;
|
|
413
414
|
}
|
|
414
|
-
/**
|
|
415
|
-
export
|
|
415
|
+
/** Every Cast device and group as a home device, devices first, each by name. */
|
|
416
|
+
export function castHomeDevices(part) {
|
|
417
|
+
const s = part?.state;
|
|
418
|
+
if (!s)
|
|
419
|
+
return [];
|
|
420
|
+
return deviceRecords(part, castStatus(s).map((detail) => {
|
|
421
|
+
const d = s.devices[detail.id];
|
|
422
|
+
// Connected and heard from: available. Connecting, or connected with no status yet: not known.
|
|
423
|
+
const available = d.status === "disconnected"
|
|
424
|
+
? false
|
|
425
|
+
: d.status === "connected" && detail.state !== "offline"
|
|
426
|
+
? true
|
|
427
|
+
: undefined;
|
|
428
|
+
return {
|
|
429
|
+
connector: "cast",
|
|
430
|
+
id: d.id,
|
|
431
|
+
name: d.name,
|
|
432
|
+
kind: d.kind === "group" ? "group" : (d.form ?? "cast"),
|
|
433
|
+
...(available === undefined ? {} : { available }),
|
|
434
|
+
...(d.asOf ? { asOf: d.asOf } : {}), // never answered: no time to give
|
|
435
|
+
state: detail.state,
|
|
436
|
+
text: castText(d, s),
|
|
437
|
+
...(d.members ? { members: d.members } : {}),
|
|
438
|
+
detail,
|
|
439
|
+
};
|
|
440
|
+
}));
|
|
441
|
+
}
|
|
416
442
|
/** How many entries have an address, and one line per device and group. */
|
|
417
443
|
const castView = (s) => ({
|
|
418
444
|
found: Object.values(s.devices).filter((d) => d.host).length,
|
|
@@ -64,3 +64,55 @@ export declare const mediaApp: (app?: {
|
|
|
64
64
|
}[];
|
|
65
65
|
transportId?: string;
|
|
66
66
|
}) => boolean;
|
|
67
|
+
/** Google's own player for a URL of audio or video: what `announce` plays a clip with, and can put back. */
|
|
68
|
+
export declare const DEFAULT_MEDIA_RECEIVER = "CC1AD845";
|
|
69
|
+
/** Devices first, then groups, each by name. */
|
|
70
|
+
export declare const castDevices: (s: CastSnapshot) => CastDevice[];
|
|
71
|
+
/** A target is a castId, else a name in any case. `announce` and `castFree` find it the same way. */
|
|
72
|
+
export declare const findCast: (devices: Record<string, CastDevice>, target: string) => CastDevice | undefined;
|
|
73
|
+
/**
|
|
74
|
+
* What a device or group is doing, for a caller choosing where to play something. `state`:
|
|
75
|
+
* - `idle`: no app, or the idle screen.
|
|
76
|
+
* - `restorable`: the Default Media Receiver, whose clip `announce` puts back at its position.
|
|
77
|
+
* - `busy`: another app, playing or paused. An announce ends it and nothing here can resume it.
|
|
78
|
+
* - `offline`: not connected, or connected and not heard from yet.
|
|
79
|
+
* A member playing its group's media takes the group's state, with `via` naming the group and `app` the group's. A group
|
|
80
|
+
* is as free as its least free connected member. `standby` is a TV that is off or on another input: `idle` by its app,
|
|
81
|
+
* but a clip may wake it.
|
|
82
|
+
*/
|
|
83
|
+
export type CastStatus = {
|
|
84
|
+
id: string;
|
|
85
|
+
name: string;
|
|
86
|
+
kind: "device" | "group";
|
|
87
|
+
state: "idle" | "restorable" | "busy" | "offline";
|
|
88
|
+
app?: string;
|
|
89
|
+
playerState?: string;
|
|
90
|
+
title?: string;
|
|
91
|
+
artist?: string;
|
|
92
|
+
via?: string;
|
|
93
|
+
volume?: number;
|
|
94
|
+
muted?: boolean;
|
|
95
|
+
standby?: boolean;
|
|
96
|
+
members?: CastStatus[];
|
|
97
|
+
};
|
|
98
|
+
/** Every device and group in the snapshot as a `CastStatus`, devices first, each by name. */
|
|
99
|
+
export declare function castStatus(s: CastSnapshot): CastStatus[];
|
|
100
|
+
/** A target `castFree` left out: `standby` is a TV that is off, which a clip may wake. */
|
|
101
|
+
export type CastSkipped = {
|
|
102
|
+
name: string;
|
|
103
|
+
why: "busy" | "offline" | "standby" | "unknown";
|
|
104
|
+
app?: string;
|
|
105
|
+
};
|
|
106
|
+
/**
|
|
107
|
+
* Of these targets (names in any case, or ids), the ones an announcement would not disturb: `idle` or `restorable`, and
|
|
108
|
+
* not a TV in standby. `free` is names, for `announceAll`; `skipped` says which were left out and why.
|
|
109
|
+
* - A group stays a group while every connected member is free. An offline member doesn't split it: the group plays without it.
|
|
110
|
+
* - A group playing another app is skipped whole. Otherwise a group with a busy member, or one whose own socket is down,
|
|
111
|
+
* becomes its free members, named one by one (they then play out of step with each other).
|
|
112
|
+
* - A device is never named beside a free group that holds it, nor two groups that share a member: the second becomes its
|
|
113
|
+
* members not yet covered.
|
|
114
|
+
*/
|
|
115
|
+
export declare function castFree(s: CastSnapshot, targets: string[]): {
|
|
116
|
+
free: string[];
|
|
117
|
+
skipped: CastSkipped[];
|
|
118
|
+
};
|
|
@@ -143,3 +143,125 @@ function link(s) {
|
|
|
143
143
|
delete d.playingVia;
|
|
144
144
|
return { devices, asOf: s.asOf };
|
|
145
145
|
}
|
|
146
|
+
/** Google's own player for a URL of audio or video: what `announce` plays a clip with, and can put back. */
|
|
147
|
+
export const DEFAULT_MEDIA_RECEIVER = "CC1AD845";
|
|
148
|
+
/** Devices first, then groups, each by name. */
|
|
149
|
+
export const castDevices = (s) => Object.values(s.devices).sort((a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name));
|
|
150
|
+
/** A target is a castId, else a name in any case. `announce` and `castFree` find it the same way. */
|
|
151
|
+
export const findCast = (devices, target) => {
|
|
152
|
+
const all = Object.values(devices);
|
|
153
|
+
return (all.find((x) => x.id === castKey(target)) ??
|
|
154
|
+
all.find((x) => x.name.toLowerCase() === target.toLowerCase()));
|
|
155
|
+
};
|
|
156
|
+
const RANK = { idle: 0, restorable: 1, busy: 2, offline: 3 };
|
|
157
|
+
/** By its own app alone: not its group's media, not its members. */
|
|
158
|
+
const ownState = (d) => d.status !== "connected" || d.idle === undefined
|
|
159
|
+
? "offline"
|
|
160
|
+
: d.idle
|
|
161
|
+
? "idle"
|
|
162
|
+
: d.appId === DEFAULT_MEDIA_RECEIVER
|
|
163
|
+
? "restorable"
|
|
164
|
+
: "busy";
|
|
165
|
+
/** Every device and group in the snapshot as a `CastStatus`, devices first, each by name. */
|
|
166
|
+
export function castStatus(s) {
|
|
167
|
+
const one = (d) => {
|
|
168
|
+
const group = d.playingVia ? s.devices[d.playingVia] : undefined;
|
|
169
|
+
const own = ownState(d);
|
|
170
|
+
// Its group's media is playing on it: the group's app decides, and an unknown one counts as busy.
|
|
171
|
+
const self = own === "offline" || !d.playingVia
|
|
172
|
+
? own
|
|
173
|
+
: group && ownState(group) === "restorable"
|
|
174
|
+
? "restorable"
|
|
175
|
+
: "busy";
|
|
176
|
+
const members = d.kind === "group"
|
|
177
|
+
? (d.members ?? []).flatMap((id) => s.devices[id] ? [one(s.devices[id])] : [])
|
|
178
|
+
: undefined;
|
|
179
|
+
const state = self === "offline"
|
|
180
|
+
? self
|
|
181
|
+
: (members ?? [])
|
|
182
|
+
.map((m) => m.state)
|
|
183
|
+
.filter((m) => m !== "offline")
|
|
184
|
+
.reduce((a, b) => (RANK[b] > RANK[a] ? b : a), self);
|
|
185
|
+
const status = {
|
|
186
|
+
id: d.id,
|
|
187
|
+
name: d.name,
|
|
188
|
+
kind: d.kind,
|
|
189
|
+
state,
|
|
190
|
+
app: d.idle === false
|
|
191
|
+
? d.app
|
|
192
|
+
: group?.idle === false
|
|
193
|
+
? group.app
|
|
194
|
+
: undefined,
|
|
195
|
+
playerState: d.playerState,
|
|
196
|
+
title: d.title,
|
|
197
|
+
artist: d.artist,
|
|
198
|
+
via: d.playingVia && (group?.name ?? d.playingVia),
|
|
199
|
+
volume: d.volume,
|
|
200
|
+
muted: d.muted,
|
|
201
|
+
standby: d.standby,
|
|
202
|
+
members,
|
|
203
|
+
};
|
|
204
|
+
for (const k of Object.keys(status))
|
|
205
|
+
if (status[k] === undefined)
|
|
206
|
+
delete status[k];
|
|
207
|
+
return status;
|
|
208
|
+
};
|
|
209
|
+
return castDevices(s).map(one);
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Of these targets (names in any case, or ids), the ones an announcement would not disturb: `idle` or `restorable`, and
|
|
213
|
+
* not a TV in standby. `free` is names, for `announceAll`; `skipped` says which were left out and why.
|
|
214
|
+
* - A group stays a group while every connected member is free. An offline member doesn't split it: the group plays without it.
|
|
215
|
+
* - A group playing another app is skipped whole. Otherwise a group with a busy member, or one whose own socket is down,
|
|
216
|
+
* becomes its free members, named one by one (they then play out of step with each other).
|
|
217
|
+
* - A device is never named beside a free group that holds it, nor two groups that share a member: the second becomes its
|
|
218
|
+
* members not yet covered.
|
|
219
|
+
*/
|
|
220
|
+
export function castFree(s, targets) {
|
|
221
|
+
const status = new Map(castStatus(s).map((c) => [c.id, c]));
|
|
222
|
+
const free = new Map();
|
|
223
|
+
const covered = new Set(); // members of the groups in `free`
|
|
224
|
+
const skipped = new Map();
|
|
225
|
+
const ok = (c) => (c.state === "idle" || c.state === "restorable") && !c.standby;
|
|
226
|
+
const skip = (c) => skipped.set(c.id, {
|
|
227
|
+
name: c.name,
|
|
228
|
+
why: c.state === "offline"
|
|
229
|
+
? "offline"
|
|
230
|
+
: c.standby && c.state !== "busy"
|
|
231
|
+
? "standby"
|
|
232
|
+
: "busy",
|
|
233
|
+
...(c.app ? { app: c.app } : {}),
|
|
234
|
+
});
|
|
235
|
+
const device = (c) => (ok(c) ? free.set(c.id, c) : skip(c));
|
|
236
|
+
for (const target of targets) {
|
|
237
|
+
const d = findCast(s.devices, target);
|
|
238
|
+
const c = d && status.get(d.id);
|
|
239
|
+
if (!d || !c)
|
|
240
|
+
skipped.set(target, { name: target, why: "unknown" });
|
|
241
|
+
else if (c.kind === "device")
|
|
242
|
+
device(c);
|
|
243
|
+
else {
|
|
244
|
+
const members = c.members ?? [];
|
|
245
|
+
const own = ownState(d);
|
|
246
|
+
const up = members.filter((m) => m.state !== "offline");
|
|
247
|
+
if (own === "busy" || (own === "offline" && !members.length))
|
|
248
|
+
skip(c);
|
|
249
|
+
else if (own !== "offline" &&
|
|
250
|
+
up.every(ok) &&
|
|
251
|
+
!members.some((m) => covered.has(m.id))) {
|
|
252
|
+
free.set(c.id, c);
|
|
253
|
+
for (const m of members)
|
|
254
|
+
covered.add(m.id);
|
|
255
|
+
}
|
|
256
|
+
else
|
|
257
|
+
for (const m of members)
|
|
258
|
+
device(m);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
free: [...free.values()]
|
|
263
|
+
.filter((c) => c.kind === "group" || !covered.has(c.id))
|
|
264
|
+
.map((c) => c.name),
|
|
265
|
+
skipped: [...skipped.values()],
|
|
266
|
+
};
|
|
267
|
+
}
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import type { Clock } from "../../clock.ts";
|
|
2
2
|
import { type CastDevice } from "./state.ts";
|
|
3
|
-
/** Google's own player for a URL of audio or video. */
|
|
4
|
-
export declare const DEFAULT_MEDIA_RECEIVER = "CC1AD845";
|
|
5
3
|
/** Each request (a status, a volume, LAUNCH, LOAD, STOP) gets this long to answer. */
|
|
6
4
|
export declare const ANSWER_MS = 10000;
|
|
7
5
|
/** How long an announcement waits for its clip to finish, beyond the clip's own length when the device reports it. */
|
|
@@ -2,11 +2,9 @@ import { errorText } from "../../connector.js";
|
|
|
2
2
|
import { writeLane } from "../../lane.js";
|
|
3
3
|
import { NS } from "./codec.js";
|
|
4
4
|
import { MediaStatus, MultizoneStatus, ReceiverStatus, } from "./schemas.js";
|
|
5
|
-
import { castKey } from "./state.js";
|
|
5
|
+
import { castKey, DEFAULT_MEDIA_RECEIVER, findCast, } from "./state.js";
|
|
6
6
|
// Writes over the sockets `cast()` already holds: volume, mute, stop, and an announcement that plays a clip and puts
|
|
7
|
-
// the device back as it was. Never called by the integration itself;
|
|
8
|
-
/** Google's own player for a URL of audio or video. */
|
|
9
|
-
export const DEFAULT_MEDIA_RECEIVER = "CC1AD845";
|
|
7
|
+
// the device back as it was. Never called by the integration itself; announce, volume and mute have run on real devices (the README has the dates).
|
|
10
8
|
/** Each request (a status, a volume, LAUNCH, LOAD, STOP) gets this long to answer. */
|
|
11
9
|
export const ANSWER_MS = 10_000;
|
|
12
10
|
/** How long an announcement waits for its clip to finish, beyond the clip's own length when the device reports it. */
|
|
@@ -83,8 +81,7 @@ export function castActions(ctx) {
|
|
|
83
81
|
const { clock } = ctx;
|
|
84
82
|
const find = (target) => {
|
|
85
83
|
const all = Object.values(ctx.devices());
|
|
86
|
-
const d =
|
|
87
|
-
all.find((x) => x.name.toLowerCase() === target.toLowerCase());
|
|
84
|
+
const d = findCast(ctx.devices(), target);
|
|
88
85
|
if (!d)
|
|
89
86
|
throw new Error(`no Cast device or group "${target}"; have: ${all
|
|
90
87
|
.map((x) => x.name)
|
|
@@ -244,7 +241,7 @@ export function castActions(ctx) {
|
|
|
244
241
|
/**
|
|
245
242
|
* Read the target's state, set the volume if asked and unmute a muted target, launch the Default Media Receiver, play the clip to its end, then
|
|
246
243
|
* put back what it can, always: what the Default Media Receiver was playing is loaded again where it was, an idle
|
|
247
|
-
* device gets STOP, then the volume (a group's, member by member through each member's own socket). Another app
|
|
244
|
+
* device gets STOP, then the volume (a group's own first, then member by member through each member's own socket). Another app
|
|
248
245
|
* (Spotify, YouTube) is interrupted and cannot be resumed from here; the result says so. Throws only before anything changed.
|
|
249
246
|
*/
|
|
250
247
|
async announce(target, clip, options = {}) {
|
|
@@ -360,6 +357,27 @@ export function castActions(ctx) {
|
|
|
360
357
|
muted: before.volume?.muted,
|
|
361
358
|
},
|
|
362
359
|
];
|
|
360
|
+
// A group keeps a level of its own, which its members' levels don't bring back (live, 2026-11-10: it stayed at the
|
|
361
|
+
// announcement's 30 with every member restored). First, so whatever it does to the members, their own levels land last.
|
|
362
|
+
if (d.kind === "group" && options.volume !== undefined) {
|
|
363
|
+
const level = before.volume?.level;
|
|
364
|
+
if (level === undefined)
|
|
365
|
+
result.notRestored.push({
|
|
366
|
+
name: d.name,
|
|
367
|
+
why: "its volume before was not reported",
|
|
368
|
+
});
|
|
369
|
+
else
|
|
370
|
+
try {
|
|
371
|
+
await receiver(s, { type: "SET_VOLUME", volume: { level } });
|
|
372
|
+
result.restored.push(`${d.name}: volume`);
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
result.notRestored.push({
|
|
376
|
+
name: d.name,
|
|
377
|
+
why: `volume not restored: ${errorText(error)}`,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
363
381
|
for (const m of each) {
|
|
364
382
|
const ms = m.id === d.id ? s : ctx.session(m.id);
|
|
365
383
|
if (m.level === undefined)
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { type Clock } from "../../clock.ts";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import type
|
|
3
|
+
import { type DeviceRecord, type Integration, type Part } from "../../home.ts";
|
|
4
4
|
import { type Finder } from "../../discover.ts";
|
|
5
5
|
import type { Manifest } from "../../manifest.ts";
|
|
6
|
-
import { daikinClient, type DaikinState } from "./client.ts";
|
|
6
|
+
import { daikinClient, type DaikinState, type Mode } from "./client.ts";
|
|
7
7
|
export { DaikinError, type DaikinState, daikinClient, type Fan, type Mode, setFan, setMode, setPower, setTarget, type WriteRequest, } from "./client.ts";
|
|
8
8
|
/** Poll every 120 s, as Home Assistant's daikin integration does (its coordinator's update_interval). */
|
|
9
9
|
export declare const POLL_MS = 120000;
|
|
@@ -39,3 +39,7 @@ export declare function daikin(config: DaikinConfig): Integration<DaikinSnapshot
|
|
|
39
39
|
/** The AC in a few words: "off · cool 25 °C · room 24 °C · outdoor unit 19.5 °C". */
|
|
40
40
|
export declare function acText(s: DaikinState): string;
|
|
41
41
|
export declare const manifest: Manifest<DaikinSnapshot>;
|
|
42
|
+
/** The AC as a home device: `state` is `off`, its mode while on, or `on` when it hasn't said which; `detail` its snapshot. */
|
|
43
|
+
export type DaikinHomeDevice = DeviceRecord<"daikin", "ac", "off" | "on" | Mode, DaikinSnapshot>;
|
|
44
|
+
/** The one unit, once it has been read. It reports no name or id of its own: a home config's entry names and places it. */
|
|
45
|
+
export declare function daikinHomeDevices(part: Part<DaikinSnapshot> | undefined): DaikinHomeDevice[];
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { asOf, realClock } from "../../clock.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { deviceRecords, } from "../../home.js";
|
|
3
4
|
import { lostDetail, relocator } from "../../discover.js";
|
|
4
5
|
import { poller } from "../../poll.js";
|
|
5
|
-
import { daikinClient, DaikinError } from "./client.js";
|
|
6
|
+
import { daikinClient, DaikinError, } from "./client.js";
|
|
6
7
|
export { DaikinError, daikinClient, setFan, setMode, setPower, setTarget, } from "./client.js";
|
|
7
8
|
/** Poll every 120 s, as Home Assistant's daikin integration does (its coordinator's update_interval). */
|
|
8
9
|
export const POLL_MS = 120_000;
|
|
@@ -106,3 +107,22 @@ export const manifest = {
|
|
|
106
107
|
],
|
|
107
108
|
lines: ({ state }) => (state ? [acText(state)] : []),
|
|
108
109
|
};
|
|
110
|
+
/** The one unit, once it has been read. It reports no name or id of its own: a home config's entry names and places it. */
|
|
111
|
+
export function daikinHomeDevices(part) {
|
|
112
|
+
const s = part?.state;
|
|
113
|
+
if (!s)
|
|
114
|
+
return [];
|
|
115
|
+
return deviceRecords(part, [
|
|
116
|
+
{
|
|
117
|
+
connector: "daikin",
|
|
118
|
+
id: "daikin",
|
|
119
|
+
name: manifest.title,
|
|
120
|
+
kind: "ac",
|
|
121
|
+
available: true,
|
|
122
|
+
...(s.on === undefined ? {} : { state: s.on ? (s.mode ?? "on") : "off" }),
|
|
123
|
+
asOf: s.asOf,
|
|
124
|
+
text: `${manifest.title}: ${acText(s)}`,
|
|
125
|
+
detail: s,
|
|
126
|
+
},
|
|
127
|
+
]);
|
|
128
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type Clock } from "../../clock.ts";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { type Integration } from "../../home.ts";
|
|
3
|
+
import { type DeviceRecord, type Integration, type Part } from "../../home.ts";
|
|
4
4
|
import { type Finder } from "../../discover.ts";
|
|
5
5
|
import { type Manifest } from "../../manifest.ts";
|
|
6
6
|
import { type EnphaseState } from "./client.ts";
|
|
@@ -41,3 +41,7 @@ export declare function enphase(config: EnphaseConfig): Integration<EnphaseSnaps
|
|
|
41
41
|
/** "solar 2.1 kW, using 0.6 kW · token 300 days left". The login ask near the end is the integration's, in `detail`. */
|
|
42
42
|
export declare function enphaseText(s: EnphaseSnapshot): string;
|
|
43
43
|
export declare const manifest: Manifest<EnphaseSnapshot>;
|
|
44
|
+
/** The gateway as a home device: `state` is `producing` while the solar makes power, else `idle`; `detail` its snapshot. */
|
|
45
|
+
export type EnphaseHomeDevice = DeviceRecord<"enphase", "solar", "producing" | "idle", EnphaseSnapshot>;
|
|
46
|
+
/** The one gateway, once it has been read. */
|
|
47
|
+
export declare function enphaseHomeDevices(part: Part<EnphaseSnapshot> | undefined): EnphaseHomeDevice[];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { localDay, realClock } from "../../clock.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { filePath } from "../../home.js";
|
|
3
|
+
import { deviceRecords, filePath, } from "../../home.js";
|
|
4
4
|
import { lostDetail, relocator } from "../../discover.js";
|
|
5
5
|
import { kw } from "../../manifest.js";
|
|
6
6
|
import { poller } from "../../poll.js";
|
|
@@ -51,7 +51,17 @@ export function enphase(config) {
|
|
|
51
51
|
name: "enphase",
|
|
52
52
|
refresh: () => (where.reset(), poll.nudge()),
|
|
53
53
|
async run(signal, update) {
|
|
54
|
-
|
|
54
|
+
let expiresAt;
|
|
55
|
+
try {
|
|
56
|
+
expiresAt = tokenExpiry(config.token); // a bad token fails this integration only
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return update({
|
|
60
|
+
status: "unavailable",
|
|
61
|
+
reason: "auth",
|
|
62
|
+
detail: "the token is unreadable; run `home enphase login`",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
55
65
|
let last;
|
|
56
66
|
let day;
|
|
57
67
|
let moved; // kept in `detail` so a consumer can persist the new address
|
|
@@ -106,6 +116,7 @@ export function enphase(config) {
|
|
|
106
116
|
if (error instanceof EnphaseError && error.status === 401)
|
|
107
117
|
return update({
|
|
108
118
|
status: "unavailable",
|
|
119
|
+
reason: "auth",
|
|
109
120
|
detail: "the gateway rejected the token; run `home enphase login`",
|
|
110
121
|
state: last,
|
|
111
122
|
});
|
|
@@ -172,3 +183,24 @@ export const manifest = {
|
|
|
172
183
|
files: ["token"],
|
|
173
184
|
lines: ({ state }) => (state ? [enphaseText(state)] : []),
|
|
174
185
|
};
|
|
186
|
+
/** The one gateway, once it has been read. */
|
|
187
|
+
export function enphaseHomeDevices(part) {
|
|
188
|
+
const s = part?.state;
|
|
189
|
+
if (!s)
|
|
190
|
+
return [];
|
|
191
|
+
return deviceRecords(part, [
|
|
192
|
+
{
|
|
193
|
+
connector: "enphase",
|
|
194
|
+
id: "enphase",
|
|
195
|
+
name: manifest.title,
|
|
196
|
+
kind: "solar",
|
|
197
|
+
available: true,
|
|
198
|
+
...(s.productionW === undefined
|
|
199
|
+
? {}
|
|
200
|
+
: { state: s.productionW > 0 ? "producing" : "idle" }),
|
|
201
|
+
asOf: s.asOf,
|
|
202
|
+
text: `${manifest.title}: ${enphaseText(s)}`,
|
|
203
|
+
detail: s,
|
|
204
|
+
},
|
|
205
|
+
]);
|
|
206
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { type Clock } from "../../clock.ts";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { type Finder } from "../../discover.ts";
|
|
4
|
-
import { type Integration } from "../../home.ts";
|
|
4
|
+
import { type DeviceRecord, type Integration, type Part } from "../../home.ts";
|
|
5
5
|
import type { Manifest } from "../../manifest.ts";
|
|
6
6
|
import { type HueClient } from "./client.ts";
|
|
7
|
-
import { type HueSnapshot } from "./state.ts";
|
|
7
|
+
import { type GroupView, type HueSnapshot, type LightView } from "./state.ts";
|
|
8
8
|
export { bunPinnedFetch, type HueClient, HueError, type HueOptions, hueClient, pair, } from "./client.ts";
|
|
9
9
|
export type { HueEvent, Resource } from "./schemas.ts";
|
|
10
10
|
export { type GroupView, groups, type HueSnapshot, homeGroupedLightId, type LightView, lights, scenes, } from "./state.ts";
|
|
@@ -39,4 +39,8 @@ export type HueConfig = {
|
|
|
39
39
|
export declare function hue(config: HueConfig): Integration<HueSnapshot, "hue"> & {
|
|
40
40
|
readonly client: HueClient;
|
|
41
41
|
};
|
|
42
|
+
/** A Hue light, or a room or zone, as a home device: `detail` is its `LightView` or `GroupView`. */
|
|
43
|
+
export type HueHomeDevice = DeviceRecord<"hue", "light", "on" | "off", LightView> | DeviceRecord<"hue", "group", "on" | "off", GroupView>;
|
|
44
|
+
/** Every light, then every room and zone. A light's `available` is its Zigbee link; a group's `members` are its lights. */
|
|
45
|
+
export declare function hueHomeDevices(part: Part<HueSnapshot> | undefined): HueHomeDevice[];
|
|
42
46
|
export declare const manifest: Manifest<HueSnapshot>;
|