@khalido/home 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/.env.example +25 -0
  2. package/CHANGELOG.md +25 -0
  3. package/LICENSE +21 -0
  4. package/README.md +77 -2
  5. package/dist/clock.d.ts +17 -0
  6. package/dist/clock.js +43 -0
  7. package/dist/config.d.ts +273 -0
  8. package/dist/config.js +392 -0
  9. package/dist/connector.d.ts +34 -0
  10. package/dist/connector.js +47 -0
  11. package/dist/connectors/amber/client.d.ts +61 -0
  12. package/dist/connectors/amber/client.js +106 -0
  13. package/dist/connectors/amber/index.d.ts +30 -0
  14. package/dist/connectors/amber/index.js +94 -0
  15. package/dist/connectors/cast/codec.d.ts +38 -0
  16. package/dist/connectors/cast/codec.js +102 -0
  17. package/dist/connectors/cast/index.d.ts +60 -0
  18. package/dist/connectors/cast/index.js +426 -0
  19. package/dist/connectors/cast/schemas.d.ts +167 -0
  20. package/dist/connectors/cast/schemas.js +87 -0
  21. package/dist/connectors/cast/serve.d.ts +11 -0
  22. package/dist/connectors/cast/serve.js +57 -0
  23. package/dist/connectors/cast/state.d.ts +62 -0
  24. package/dist/connectors/cast/state.js +133 -0
  25. package/dist/connectors/cast/tls.d.ts +3 -0
  26. package/dist/connectors/cast/tls.js +67 -0
  27. package/dist/connectors/cast/writes.d.ts +80 -0
  28. package/dist/connectors/cast/writes.js +390 -0
  29. package/dist/connectors/daikin/client.d.ts +65 -0
  30. package/dist/connectors/daikin/client.js +221 -0
  31. package/dist/connectors/daikin/index.d.ts +41 -0
  32. package/dist/connectors/daikin/index.js +108 -0
  33. package/dist/connectors/enphase/client.d.ts +65 -0
  34. package/dist/connectors/enphase/client.js +173 -0
  35. package/dist/connectors/enphase/index.d.ts +43 -0
  36. package/dist/connectors/enphase/index.js +174 -0
  37. package/dist/connectors/enphase/schemas.d.ts +51 -0
  38. package/dist/connectors/enphase/schemas.js +34 -0
  39. package/dist/connectors/hue/client.d.ts +48 -0
  40. package/dist/connectors/hue/client.js +95 -0
  41. package/dist/connectors/hue/events.d.ts +41 -0
  42. package/dist/connectors/hue/events.js +156 -0
  43. package/dist/connectors/hue/index.d.ts +42 -0
  44. package/dist/connectors/hue/index.js +205 -0
  45. package/dist/connectors/hue/schemas.d.ts +65 -0
  46. package/dist/connectors/hue/schemas.js +48 -0
  47. package/dist/connectors/hue/state.d.ts +43 -0
  48. package/dist/connectors/hue/state.js +84 -0
  49. package/dist/connectors/printers/client.d.ts +28 -0
  50. package/dist/connectors/printers/client.js +74 -0
  51. package/dist/connectors/printers/codec.d.ts +76 -0
  52. package/dist/connectors/printers/codec.js +246 -0
  53. package/dist/connectors/printers/found.d.ts +15 -0
  54. package/dist/connectors/printers/found.js +35 -0
  55. package/dist/connectors/printers/index.d.ts +52 -0
  56. package/dist/connectors/printers/index.js +282 -0
  57. package/dist/connectors/printers/state.d.ts +60 -0
  58. package/dist/connectors/printers/state.js +99 -0
  59. package/dist/connectors/spotify/changes.d.ts +32 -0
  60. package/dist/connectors/spotify/changes.js +84 -0
  61. package/dist/connectors/spotify/client.d.ts +199 -0
  62. package/dist/connectors/spotify/client.js +270 -0
  63. package/dist/connectors/spotify/index.d.ts +92 -0
  64. package/dist/connectors/spotify/index.js +745 -0
  65. package/dist/connectors/up/client.d.ts +183 -0
  66. package/dist/connectors/up/client.js +308 -0
  67. package/dist/connectors/up/index.d.ts +91 -0
  68. package/dist/connectors/up/index.js +213 -0
  69. package/dist/connectors/up/spend.d.ts +78 -0
  70. package/dist/connectors/up/spend.js +213 -0
  71. package/dist/connectors/up/webhook.d.ts +13 -0
  72. package/dist/connectors/up/webhook.js +32 -0
  73. package/dist/connectors/weather/blocks.d.ts +51 -0
  74. package/dist/connectors/weather/blocks.js +106 -0
  75. package/dist/connectors/weather/bom.d.ts +37 -0
  76. package/dist/connectors/weather/bom.js +237 -0
  77. package/dist/connectors/weather/ftp.d.ts +5 -0
  78. package/dist/connectors/weather/ftp.js +46 -0
  79. package/dist/connectors/weather/google.d.ts +31 -0
  80. package/dist/connectors/weather/google.js +364 -0
  81. package/dist/connectors/weather/index.d.ts +53 -0
  82. package/dist/connectors/weather/index.js +267 -0
  83. package/dist/connectors/weather/open-meteo.d.ts +15 -0
  84. package/dist/connectors/weather/open-meteo.js +285 -0
  85. package/dist/connectors/weather/rain.d.ts +19 -0
  86. package/dist/connectors/weather/rain.js +51 -0
  87. package/dist/connectors/weather/source.d.ts +149 -0
  88. package/dist/connectors/weather/source.js +77 -0
  89. package/dist/connectors/weather/sun.d.ts +48 -0
  90. package/dist/connectors/weather/sun.js +65 -0
  91. package/dist/discover.d.ts +195 -0
  92. package/dist/discover.js +446 -0
  93. package/dist/energy.d.ts +61 -0
  94. package/dist/energy.js +162 -0
  95. package/dist/finders-entry.d.ts +1 -0
  96. package/dist/finders-entry.js +2 -0
  97. package/dist/finders.d.ts +45 -0
  98. package/dist/finders.js +173 -0
  99. package/dist/health.d.ts +28 -0
  100. package/dist/health.js +35 -0
  101. package/dist/home.d.ts +81 -0
  102. package/dist/home.js +114 -0
  103. package/dist/index.d.ts +11 -0
  104. package/dist/index.js +11 -0
  105. package/dist/lane.d.ts +17 -0
  106. package/dist/lane.js +74 -0
  107. package/dist/locate.d.ts +27 -0
  108. package/dist/locate.js +59 -0
  109. package/dist/manifest.d.ts +31 -0
  110. package/dist/manifest.js +4 -0
  111. package/dist/manifests.d.ts +5 -0
  112. package/dist/manifests.js +23 -0
  113. package/dist/node-fetch.d.ts +9 -0
  114. package/dist/node-fetch.js +25 -0
  115. package/dist/poll.d.ts +10 -0
  116. package/dist/poll.js +33 -0
  117. package/docs/cast.md +133 -0
  118. package/docs/cli.md +39 -0
  119. package/docs/config.md +36 -0
  120. package/docs/decisions.md +150 -0
  121. package/docs/devices.md +128 -0
  122. package/docs/enphase-notes.md +24 -0
  123. package/docs/google.md +92 -0
  124. package/docs/ideas.md +69 -0
  125. package/docs/matter.md +55 -0
  126. package/docs/performance.md +12 -0
  127. package/docs/philosophy.md +23 -0
  128. package/docs/up.md +84 -0
  129. package/docs/using.md +65 -0
  130. package/docs/weather-probe.md +63 -0
  131. package/examples/home.jsonc +74 -0
  132. package/examples/home.svg +76 -0
  133. package/home.schema.json +474 -0
  134. package/llms.txt +32 -0
  135. package/package.json +143 -4
  136. package/skills/draw-home/README.md +26 -0
  137. package/skills/draw-home/SKILL.md +82 -0
  138. package/skills/use-home/SKILL.md +86 -0
  139. package/src/cli/cache.ts +29 -0
  140. package/src/cli/cast.ts +172 -0
  141. package/src/cli/config.ts +52 -0
  142. package/src/cli/context.ts +118 -0
  143. package/src/cli/discover.ts +175 -0
  144. package/src/cli/enphase.ts +62 -0
  145. package/src/cli/hue.ts +124 -0
  146. package/src/cli/locate.ts +47 -0
  147. package/src/cli/main.ts +182 -0
  148. package/src/cli/printers.ts +21 -0
  149. package/src/cli/probe.ts +274 -0
  150. package/src/cli/spotify.ts +76 -0
  151. package/src/cli/status.ts +228 -0
  152. package/src/cli/up.ts +350 -0
  153. package/src/clock.ts +62 -0
  154. package/src/config.ts +578 -0
  155. package/src/connector.ts +77 -0
  156. package/src/connectors/amber/README.md +40 -0
  157. package/src/connectors/amber/client.ts +157 -0
  158. package/src/connectors/amber/index.ts +118 -0
  159. package/src/connectors/cast/README.md +43 -0
  160. package/src/connectors/cast/codec.ts +126 -0
  161. package/src/connectors/cast/index.ts +551 -0
  162. package/src/connectors/cast/schemas.ts +101 -0
  163. package/src/connectors/cast/serve.ts +72 -0
  164. package/src/connectors/cast/state.ts +203 -0
  165. package/src/connectors/cast/tls.ts +100 -0
  166. package/src/connectors/cast/writes.ts +572 -0
  167. package/src/connectors/daikin/README.md +78 -0
  168. package/src/connectors/daikin/client.ts +295 -0
  169. package/src/connectors/daikin/index.ts +145 -0
  170. package/src/connectors/enphase/README.md +64 -0
  171. package/src/connectors/enphase/client.ts +244 -0
  172. package/src/connectors/enphase/index.ts +241 -0
  173. package/src/connectors/enphase/schemas.ts +45 -0
  174. package/src/connectors/hue/README.md +50 -0
  175. package/src/connectors/hue/client.ts +157 -0
  176. package/src/connectors/hue/events.ts +182 -0
  177. package/src/connectors/hue/index.ts +261 -0
  178. package/src/connectors/hue/schemas.ts +60 -0
  179. package/src/connectors/hue/state.ts +124 -0
  180. package/src/connectors/printers/README.md +46 -0
  181. package/src/connectors/printers/client.ts +114 -0
  182. package/src/connectors/printers/codec.ts +292 -0
  183. package/src/connectors/printers/found.ts +46 -0
  184. package/src/connectors/printers/index.ts +385 -0
  185. package/src/connectors/printers/state.ts +182 -0
  186. package/src/connectors/spotify/README.md +80 -0
  187. package/src/connectors/spotify/changes.ts +127 -0
  188. package/src/connectors/spotify/client.ts +387 -0
  189. package/src/connectors/spotify/index.ts +929 -0
  190. package/src/connectors/up/README.md +35 -0
  191. package/src/connectors/up/client.ts +518 -0
  192. package/src/connectors/up/index.ts +370 -0
  193. package/src/connectors/up/spend.ts +280 -0
  194. package/src/connectors/up/webhook.ts +62 -0
  195. package/src/connectors/weather/README.md +84 -0
  196. package/src/connectors/weather/blocks.ts +173 -0
  197. package/src/connectors/weather/bom.ts +344 -0
  198. package/src/connectors/weather/ftp.ts +48 -0
  199. package/src/connectors/weather/google.ts +425 -0
  200. package/src/connectors/weather/index.ts +380 -0
  201. package/src/connectors/weather/open-meteo.ts +309 -0
  202. package/src/connectors/weather/rain.ts +73 -0
  203. package/src/connectors/weather/source.ts +236 -0
  204. package/src/connectors/weather/sun.ts +114 -0
  205. package/src/discover.ts +634 -0
  206. package/src/energy.ts +221 -0
  207. package/src/finders-entry.ts +2 -0
  208. package/src/finders.ts +247 -0
  209. package/src/health.ts +74 -0
  210. package/src/home.ts +205 -0
  211. package/src/index.ts +62 -0
  212. package/src/lane.ts +110 -0
  213. package/src/locate.ts +85 -0
  214. package/src/manifest.ts +43 -0
  215. package/src/manifests.ts +27 -0
  216. package/src/node-fetch.ts +41 -0
  217. package/src/poll.ts +33 -0
@@ -0,0 +1,40 @@
1
+ # Amber
2
+
3
+ Amber Electric's live wholesale-linked prices, read through Amber's public API. Cloud, not local: one HTTPS call with a key. `iot_class`: **cloud_polling**, every 60 s.
4
+
5
+ ## Built
6
+
7
+ - `amber({ key, fetch })` finds the account's active site once, then polls `GET /v1/sites/{id}/prices/current?next=96` every 60 s (Home Assistant's amberelectric integration polls every minute too). One call took about 270 ms with `next=1` (measured, Bun 1.4.2); `next=96` returns about 52 intervals instead of 3.
8
+ - `toPrices` keeps the **CurrentInterval** of each channel: `importCents` (general), `exportCents` (feed-in, sign flipped so it reads as what you are paid), `priceDescriptor` (the general channel's descriptor) and `exportDescriptor`, `spike` (Amber's own `spikeStatus`: `none`, `potential`, `spike`; the library sets no threshold), `renewables`, `estimate`, and the next interval's import forecast.
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
+ - `estimate: true` means the current interval's price can still move; it is shown as "(estimate)", never as an anomaly.
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.
13
+
14
+ ## Energy
15
+
16
+ `energyNow(enphase, amber)` in `src/energy.ts` is the one place the two combine, so the CLI, jaldi and the bot show the same numbers.
17
+
18
+ - **Now**: solar, use, grid flow, and what the grid flow costs or earns per hour (`centsPerHour`), plus Amber's descriptor, spike and estimate.
19
+ - **Negative prices**: `negativePrice` is "paid to use" when the import price is below zero, else "exporting at a loss" when the feed-in price is. Both can hold in one interval; the import side wins.
20
+ - **Surplus**: `surplusW` is the export as a positive number (`-gridW` while exporting, else 0; undefined with no grid reading), and `surplusCentsPerHour` what it earns at the export price, negative when exporting costs you, never clamped.
21
+ - **Best window**: `bestWindow(forecast, hours, { by, from })` is the cheapest run of `hours` by import price, or the greenest by renewables, averaged by time over unbroken intervals; undefined when the forecast is shorter or broken. An average some interval lacks is left out (a greenest window can have no `importCents`). A forecast row whose times do not parse is left out and counted in `anomaly`. It ranks by `perKwh`, not Amber's `advancedPrice.predicted` (14.70c against 15.56c on the first forecast interval of the fixture). On the 2026-10-28 forecast (`test/fixtures/amber/forecast.json`, read at 3 pm Sydney) the cheapest 2 hours were 3:40–5:40 pm at 13.7c. Solar is not in it: Enphase has no forecast.
22
+ - **Renewables above 100%**: the same forecast has 101.17 and 102.6. Passed through as Amber sends them.
23
+ - **Flow**: `"importing"`, `"exporting"` or `"balanced"`. Balanced is a display rule: within `BALANCED_W` (20 W) of zero the word and any animation say balanced, so a meter wobbling around zero does not flip; `gridW` and `surplusW` stay as read.
24
+ - **Words and drawing**: `gridText(now)` gives the phrase every view shares ("exporting 1.2 kW, earning 42 c/h"), which `home status` prints in its `now` line. `solarLevel(now, capacityW)` scales solar for a drawing against the array's rated size or, without it, today's peak, and says which.
25
+
26
+ ## What the API said (KO's account, 2026-10-13)
27
+
28
+ - `GET /v1/sites`: one active site, its network named, `intervalLength: 5`, channels `general` and `feedIn`.
29
+ - `GET …/prices/current?next=1&previous=1`: for each channel an `ActualInterval`, a `CurrentInterval` (with `estimate`) and a `ForecastInterval` (with `advancedPrice`). Fields: `perKwh` and `spotPerKwh` (c/kWh incl. GST), `renewables` (%), `descriptor`, `spikeStatus`, `startTime`, `endTime`, `nemTime`, `tariffInformation`.
30
+ - **Rate limit** from the response headers: `ratelimit-policy: 50;w=300`, so 50 calls per 5 minutes. A 60 s poll uses 5.
31
+ - **Feed-in sign**: the feed-in `perKwh` was −35.3 with spot at +33.1 and descriptor `high`, so a negative feed-in price means you are paid. Amber's spec does not say this; it is inferred from the live reply. A positive feed-in price would mean exporting costs you.
32
+ - Prices move in 5-minute intervals and the current one is revised until `estimate` is false, so polling faster than a minute adds nothing; slower than 5 minutes misses intervals.
33
+
34
+ ## Sources
35
+
36
+ | Source | Licence | Used for |
37
+ | ------------------------------------------------------------------------------------------------------------------------ | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
38
+ | [Amber developer page](https://app.amber.com.au/developers) | Amber's terms | the key and the API |
39
+ | [amberelectric/public-api](https://github.com/amberelectric/public-api) (`swagger.json`) | MIT per GitHub | endpoints, field enums (`descriptor`: extremelyLow, veryLow, low, neutral, high, spike, and the retired negative; `spikeStatus`: none, potential, spike); the spec is version 1.0 at `/v1`, there is no v2 |
40
+ | [Home Assistant `amberelectric`](https://github.com/home-assistant/core/tree/dev/homeassistant/components/amberelectric) | Apache-2.0 | 1-minute poll, current plus forecast intervals |
@@ -0,0 +1,157 @@
1
+ import { z } from "zod";
2
+ import { ConnectorError, getJson } from "../../connector.ts";
3
+
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
+ // 2026-10-13; every field optional so a change degrades a reading rather than throwing.
6
+
7
+ /** A failed Amber call. `status` 401 means the key is wrong or revoked; 429 means the rate limit (50 per 5 min). */
8
+ export class AmberError extends ConnectorError {}
9
+
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. */
11
+ export const Interval = z.looseObject({
12
+ type: z.string(), // "ActualInterval" | "CurrentInterval" | "ForecastInterval"
13
+ channelType: z.string(), // "general" (import) | "feedIn" (export) | "controlledLoad"
14
+ perKwh: z.number(),
15
+ spotPerKwh: z.number().optional(),
16
+ renewables: z.number().optional(), // percent of the grid
17
+ descriptor: z.string().optional(), // "extremelyLow" … "spike"
18
+ spikeStatus: z.string().optional(), // "none" | "potential" | "spike"
19
+ startTime: z.string().optional(),
20
+ endTime: z.string().optional(),
21
+ estimate: z.boolean().optional(), // CurrentInterval: true until the interval's price is final
22
+ duration: z.number().optional(), // minutes: 5 for the current hour, 30 further out
23
+ });
24
+ export type Interval = z.infer<typeof Interval>;
25
+
26
+ const Site = z.looseObject({
27
+ id: z.string(),
28
+ status: z.string().optional(),
29
+ intervalLength: z.number().optional(),
30
+ });
31
+
32
+ /** Prices now, as a home view wants them: what a kWh costs to import and earns to export. */
33
+ export type AmberState = {
34
+ importCents?: number; // c/kWh you pay now
35
+ exportCents?: number; // c/kWh you are paid now (Amber's negative feed-in price, sign flipped)
36
+ priceDescriptor?: string; // the general (import) channel's descriptor: "extremelyLow" | "veryLow" | "low" | "neutral" | "high" | "spike" ("negative" is legacy)
37
+ exportDescriptor?: string;
38
+ spike?: string; // Amber's own spikeStatus on the general channel: "none" | "potential" | "spike"; no threshold of ours
39
+ renewables?: number; // percent
40
+ estimate?: boolean; // true while the current interval's price can still move; information, not an anomaly
41
+ nextImportCents?: number; // the next interval's forecast
42
+ intervalEnds?: string;
43
+ anomaly?: string; // forecast intervals left out because their times could not be read
44
+ forecast?: PriceInterval[]; // the current interval, then Amber's forecast, as far ahead as Amber has one (about 8 hours, 2026-10-28)
45
+ };
46
+
47
+ /**
48
+ * One interval of the forecast, both channels. `start` is the interval's own start: Amber's `startTime` is a second past
49
+ * it (07:15:01 for 07:15–07:20), so it is taken to the minute. `exportCents` is what you are paid, as in `AmberState`.
50
+ */
51
+ export type PriceInterval = {
52
+ start: string;
53
+ end: string;
54
+ importCents?: number;
55
+ exportCents?: number;
56
+ renewables?: number;
57
+ descriptor?: string;
58
+ };
59
+
60
+ /** How many intervals to ask for: more than Amber forecasts (48 half-hours), so the reply is as far as Amber has one. */
61
+ export const NEXT = 96;
62
+
63
+ /** Map `prices/current?next=…` to the state: the CurrentInterval of each channel, the next import forecast, and the forecast. */
64
+ export function toPrices(intervals: unknown): AmberState {
65
+ const list = z.array(Interval).parse(intervals);
66
+ const pick = (type: string, channel: string) =>
67
+ list.find((i) => i.type === type && i.channelType === channel);
68
+ const buy = pick("CurrentInterval", "general");
69
+ const sell = pick("CurrentInterval", "feedIn");
70
+ return {
71
+ importCents: buy?.perKwh,
72
+ exportCents: sell === undefined ? undefined : -sell.perKwh,
73
+ priceDescriptor: buy?.descriptor,
74
+ exportDescriptor: sell?.descriptor,
75
+ spike: buy?.spikeStatus,
76
+ renewables: buy?.renewables,
77
+ estimate: buy?.estimate,
78
+ nextImportCents: pick("ForecastInterval", "general")?.perKwh,
79
+ intervalEnds: buy?.endTime,
80
+ ...forecastOf(list),
81
+ };
82
+ }
83
+
84
+ const MINUTE = 60_000;
85
+
86
+ /**
87
+ * The current and forecast intervals, in time order, each channel's price on one row; none when Amber sent none. An interval
88
+ * whose times do not parse is left out and counted in `anomaly`, so one odd row never fails the prices.
89
+ */
90
+ function forecastOf(
91
+ list: Interval[],
92
+ ): Pick<AmberState, "forecast" | "anomaly"> {
93
+ const timed = list.filter(
94
+ (i) => i.type === "CurrentInterval" || i.type === "ForecastInterval",
95
+ );
96
+ const ahead = timed.filter(
97
+ (i) =>
98
+ Number.isFinite(Date.parse(i.startTime ?? "")) &&
99
+ Number.isFinite(Date.parse(i.endTime ?? "")),
100
+ );
101
+ const odd = timed.length - ahead.length;
102
+ const feedIn = new Map(
103
+ ahead.filter((i) => i.channelType === "feedIn").map((i) => [i.endTime, i]),
104
+ );
105
+ const rows = ahead
106
+ .filter((i) => i.channelType === "general")
107
+ .map((i) => {
108
+ const sell = feedIn.get(i.endTime);
109
+ return {
110
+ start: new Date(
111
+ Math.floor(Date.parse(i.startTime!) / MINUTE) * MINUTE,
112
+ ).toISOString(),
113
+ end: new Date(Date.parse(i.endTime!)).toISOString(),
114
+ importCents: i.perKwh,
115
+ exportCents: sell === undefined ? undefined : -sell.perKwh,
116
+ renewables: i.renewables,
117
+ descriptor: i.descriptor,
118
+ };
119
+ })
120
+ .sort((a, b) => a.start.localeCompare(b.start));
121
+ return {
122
+ forecast: rows.length ? rows : undefined,
123
+ anomaly: odd
124
+ ? `${odd} price row(s) left out: their start or end time could not be read`
125
+ : undefined,
126
+ };
127
+ }
128
+
129
+ /** A read-only client for Amber's API. The key comes in; nothing is stored. */
130
+ export function amberClient(options: { key: string; fetch: typeof fetch }) {
131
+ const headers = {
132
+ authorization: `Bearer ${options.key}`,
133
+ accept: "application/json",
134
+ };
135
+ const get = (path: string, signal?: AbortSignal) =>
136
+ getJson(options.fetch, `https://api.amber.com.au/v1${path}`, {
137
+ init: { headers },
138
+ signal,
139
+ error: AmberError,
140
+ });
141
+ return {
142
+ get,
143
+ /** The account's active site id (the first active one). */
144
+ async siteId(signal?: AbortSignal): Promise<string> {
145
+ const sites = z.array(Site).parse(await get("/sites", signal));
146
+ const site = sites.find((s) => s.status === "active") ?? sites[0];
147
+ if (!site) throw new AmberError("no Amber site on this account");
148
+ return site.id;
149
+ },
150
+ /** Prices now and the forecast for a site: one call, ~270 ms (measured with `next=1`, Bun 1.4.2). */
151
+ async prices(siteId: string, signal?: AbortSignal): Promise<AmberState> {
152
+ return toPrices(
153
+ await get(`/sites/${siteId}/prices/current?next=${NEXT}`, signal),
154
+ );
155
+ },
156
+ };
157
+ }
@@ -0,0 +1,118 @@
1
+ import { asOf, type Clock, realClock } from "../../clock.ts";
2
+ import { errorText } from "../../connector.ts";
3
+ import { z } from "zod";
4
+ import { envName, type Integration } from "../../home.ts";
5
+ import { energyNow } from "../../energy.ts";
6
+ import { field, type Manifest } from "../../manifest.ts";
7
+ import { poller } from "../../poll.ts";
8
+ import { amberClient, AmberError, type AmberState } from "./client.ts";
9
+
10
+ export {
11
+ AmberError,
12
+ type AmberState,
13
+ amberClient,
14
+ type Interval,
15
+ type PriceInterval,
16
+ } from "./client.ts";
17
+
18
+ /** Poll every 60 s: prices move in 5-minute intervals and the current one is revised inside it; 5 calls in 5 min, limit 50. */
19
+ export const POLL_MS = 60_000;
20
+
21
+ /** Amber in a home config: the key's env name, and the site when the account has more than one. */
22
+ export const configSchema = z.looseObject({
23
+ connector: z.literal("amber"),
24
+ key: envName.describe("env name holding the Amber API key"),
25
+ siteId: z
26
+ .string()
27
+ .optional()
28
+ .describe("the site; the first active one when left out"),
29
+ });
30
+
31
+ /** Amber settings for a home. `siteId` is looked up once when left out. */
32
+ export type AmberConfig = {
33
+ key: string;
34
+ siteId?: string;
35
+ fetch: typeof fetch;
36
+ clock?: Clock;
37
+ };
38
+
39
+ /** What the snapshot holds: prices now and when they were read. */
40
+ export type AmberSnapshot = AmberState & { asOf: string };
41
+
42
+ /** Amber prices as a home integration: one read now, then every 60 s. */
43
+ export function amber(
44
+ config: AmberConfig,
45
+ ): Integration<AmberSnapshot, "amber"> {
46
+ const client = amberClient(config);
47
+ const clock = config.clock ?? realClock;
48
+ const poll = poller(clock);
49
+ return {
50
+ name: "amber",
51
+ refresh: poll.nudge,
52
+ async run(signal, update) {
53
+ let siteId = config.siteId;
54
+ let last: AmberSnapshot | undefined;
55
+ while (!signal.aborted) {
56
+ try {
57
+ siteId ??= await client.siteId(signal);
58
+ last = {
59
+ ...(await client.prices(siteId, signal)),
60
+ asOf: asOf(clock),
61
+ };
62
+ update({ status: "connected", anomaly: last.anomaly, state: last });
63
+ } catch (error) {
64
+ if (signal.aborted) return; // stopped mid-read: not a failure
65
+ if (error instanceof AmberError && error.status === 401)
66
+ return update({
67
+ status: "unavailable",
68
+ detail: "Amber rejected the key",
69
+ state: last,
70
+ });
71
+ update({
72
+ status: "disconnected",
73
+ detail: errorText(error),
74
+ state: last,
75
+ });
76
+ }
77
+ await poll.wait(POLL_MS, signal);
78
+ }
79
+ },
80
+ };
81
+ }
82
+
83
+ /** Amber's prices in a few words: "buy 40.9c low (estimate) · sell 35.3c · 7% renewables", with a spike and a negative price when there is one. */
84
+ export function amberText(a: AmberState): string {
85
+ const { negativePrice } = energyNow(undefined, a);
86
+ return [
87
+ `buy ${a.importCents?.toFixed(1) ?? "?"}c${a.priceDescriptor ? ` ${a.priceDescriptor}` : ""}${a.estimate ? " (estimate)" : ""}`,
88
+ a.spike && a.spike !== "none" ? `spike ${a.spike}` : "",
89
+ `sell ${a.exportCents?.toFixed(1) ?? "?"}c`,
90
+ negativePrice ?? "",
91
+ field(a.renewables, (v) => `${Math.round(v)}% renewables`),
92
+ ]
93
+ .filter(Boolean)
94
+ .join(" · ");
95
+ }
96
+
97
+ export const manifest: Manifest<AmberSnapshot> = {
98
+ name: "amber",
99
+ title: "Amber Electric",
100
+ iotClass: "cloud_polling",
101
+ pollSeconds: POLL_MS / 1000,
102
+ access: "cloud HTTPS, AMBER_KEY; 50 calls per 5 min",
103
+ env: [
104
+ {
105
+ name: "AMBER_KEY",
106
+ example: "psk_xxxxxxxx",
107
+ what: "Amber Electric API key (app.amber.com.au/developers)",
108
+ },
109
+ {
110
+ name: "AMBER_SITE_ID",
111
+ example: "",
112
+ optional: true,
113
+ what: "the first active site is used when unset",
114
+ },
115
+ ],
116
+ lines: ({ state }) =>
117
+ state?.importCents === undefined ? [] : [amberText(state)],
118
+ };
@@ -0,0 +1,43 @@
1
+ # Google Cast
2
+
3
+ Nest speakers, Nest Hubs, Chromecasts, Google TV and speaker groups, read over CastV2 on the local network. `iot_class`: **local_push** (one socket per device or group, status pushed, a PING every 5 s). The integration only reads; the writes (volume, mute, stop, announcements) are calls on the same object, never made by the integration itself, and so far tried only on a scripted fake socket. The design and what comes next (casting a page) are in [docs/cast.md](../../../docs/cast.md).
4
+
5
+ ## Built
6
+
7
+ - `cast({ resolve, connect })` keeps every Cast device and group current as one integration. `resolve()` returns what mDNS `_googlecast._tcp` found (`id`, `name` from TXT `fn`, `model` from `md`, host, port, `group`); the CLI runs `dns-sd`. `connect` is `tlsConnect` from `@khalido/home/cast/tls`. `expect` lists the config's entries, so one discovery misses shows as disconnected rather than vanishing. From a config, `resolve` is the shared two-pass discovery, done after the first pass once every entry has answered. An entry with a cached address (the address cache, `loadHome`'s `cache`) connects at once with a 1 s timeout; discovery runs only when an entry has none or a cached address fails, so a stale one still reads as `connecting` until discovery has had its say. A cached port other than 8009 marks a group, since devices always listen on 8009.
8
+ - Per socket: `CONNECT` to `receiver-0`, receiver `GET_STATUS`, multizone `GET_STATUS` (a group's members; on a device's own port, the device alone, by its current name), then whatever the device pushes. A running app with the media namespace gets a `CONNECT` and media `GET_STATUS` on its `transportId`, as Home Assistant does. A device's `PING` is answered with `PONG`; a socket silent for three heartbeats (15 s), or without a receiver status 10 s after connecting, is closed and the detail says which.
9
+ - A dropped socket reconnects with backoff (1 s doubling to 60 s) at whatever address discovery last gave. The drop starts one shared `resolve()` at once (a group's address changes with its leader); while anything stays down or missing, that one discovery repeats at 1, 5 and 15 minutes, then hourly, as the relocator does, never once per socket. `refresh()` re-asks every open socket and nudges the discovery, which also finds devices when nothing was found at start.
10
+ - The part is `connecting` until every device has answered or failed, then `connected` while any is up; `detail` names the ones down. A payload of a known type the schema rejects is an `anomaly`, never thrown.
11
+ - State (`CastSnapshot`, by mDNS id): app, `idle` (no app, or Backdrop's `isIdleScreen`), `standby`, volume (unrounded), muted, `volumeControl`, player state, title, artist; groups carry `members` and `leader`; devices carry `groups`, and `playingVia` when they show a playing group's media because their own is empty (Home Assistant's model, not yet seen live).
12
+ - Names: a device's `name` in the snapshot is its own, from discovery's `fn` or its own multizone status (the same text on all six live devices, 2026-10-13), so a rename in the Google Home app shows once the device answers, even when every address came from the cache and discovery never ran; the config's name is only the placeholder until then. A group's multizone status lists its members, not itself, so a group's new name shows at the next discovery.
13
+ - 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.
14
+ - `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 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).
15
+ - `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.
16
+ - CLI: `home cast announce --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`.
17
+ - 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.
18
+
19
+ ## Protocol
20
+
21
+ CastV2 is TLS to port 8009 (a group: its own port), carrying length-prefixed protobuf `CastMessage`s (`cast_channel.proto`: version, source, destination, namespace, payload type, payload). Every namespace here sends JSON in `payload_utf8`, so `codec.ts` encodes and decodes that one message by hand (about 100 lines with framing and the channel type) rather than taking `protobufjs`. Namespaces: `tp.connection` (`CONNECT`, `CLOSE`), `tp.heartbeat` (`PING`, `PONG`), `receiver` (`GET_STATUS` → `RECEIVER_STATUS`), `media` (on an app's transport), `multizone` (`GET_STATUS` → `MULTIZONE_STATUS`, then `DEVICE_ADDED`, `DEVICE_UPDATED`, `DEVICE_REMOVED`).
22
+
23
+ **Live, KO's seven entries, 2026-10-13 (Bun 1.4.2, read-only).** Every one answered TLS plus both `GET_STATUS`es in 33–111 ms; TLS 1.2 on the Nest Mini and Home Speaker, 1.3 elsewhere. The recorded payloads, ids and names replaced, are in `test/fixtures/cast/`.
24
+
25
+ - **Certificates** are self-signed, valid 48 hours, so they rotate. The name (CN) is the device's id as a dashed UUID: the mDNS `id` with dashes.
26
+ - **Groups.** A group is its own mDNS entry, at its leader's address on a high port, and presents **its leader's certificate**. Multizone status on the group's port lists the members (by `deviceId`, the same dashed id) with their volumes; on a device's own port it lists only that device, so membership is read from group sockets only. TXT `rm` does not mark membership. `GET_CASTING_GROUPS` is refused.
27
+ - **Receiver status**: hubs and the Chromecast Ultra run Backdrop (`E8C28D3C`) with `isIdleScreen: true`; the speakers report no application; the TV Streamer reports `isStandBy: true`, `isActiveInput: false` and a `fixed` volume; the Ultra's volume is `attenuation`.
28
+ - No device sent a `PING` in 12 s of listening, so our 5 s ping is what keeps the socket's silence check honest.
29
+ - Not seen yet: media status (nothing was playing), a group playing, a leader change, member changes, and Node's `tls` (no Node caller yet).
30
+
31
+ ## TLS: verification off, the name checked
32
+
33
+ As with [Enphase](../enphase/README.md#tls-why-the-bun-path-skips-verification), verification is off, here because there is nothing to verify against: each device signs its own certificate and rotates it every two days, and Google's device-auth challenge would need its Cast root. Instead `cast()` checks the certificate's name against discovery: a device's must be its own id, and a group's must be a known device's id, which is then its `leader`. A mismatch (an address now held by another device) closes the socket and discovers again. What this protects against: devices that have shuffled addresses, so one is never read as another. What it does not: a host on the LAN can mint a certificate with any name it likes, so it is not authentication. Nothing secret is sent on these sockets. A write goes to whichever device answers at an address whose certificate names the target, with the same caveat.
34
+
35
+ `tls.ts` (`node:tls`) and `serve.ts` (`node:http`) are the connector's Node APIs, so they sit outside the runtime-neutral build like `weather/ftp.ts` and are passed in (`connect`, `serve`).
36
+
37
+ ## Sources
38
+
39
+ - [Google Cast documentation](https://developers.google.com/cast/docs/overview); the receiver and media message shapes are in the [Web Receiver](https://developers.google.com/cast/docs/web_receiver) and [media messages](https://developers.google.com/cast/docs/media/messages) references.
40
+ - [`cast_channel.proto`](https://chromium.googlesource.com/chromium/src/+/main/components/media_router/common/providers/cast/channel/cast_channel.proto) (Chromium, BSD-3-Clause): the `CastMessage` fields.
41
+ - [pychromecast](https://github.com/home-assistant-libs/pychromecast) (MIT): the namespaces, heartbeat, media and multizone controllers. Read for facts, nothing copied.
42
+ - [Home Assistant `cast`](https://github.com/home-assistant/core/tree/dev/homeassistant/components/cast) (Apache-2.0): groups and showing a group's media on its members.
43
+ - [`castv2`](https://github.com/thibauts/node-castv2) (MIT) and [`@foxxmd/chromecast-client`](https://github.com/FoxxMD/chromecast-client) (MIT): looked at and not used; docs/cast.md says why.
@@ -0,0 +1,126 @@
1
+ // CastV2's one protobuf message, CastMessage (cast_channel.proto), by hand: six fields, two wire types. Each message on
2
+ // the socket is a 4-byte big-endian length, then the encoded message. Only string payloads (JSON) are used.
3
+
4
+ /** One message on a Cast socket. `payload` is the JSON text; a binary payload (unused by any namespace here) comes as undefined. */
5
+ export type CastMessage = {
6
+ source: string;
7
+ destination: string;
8
+ namespace: string;
9
+ payload?: string;
10
+ };
11
+
12
+ /** The namespaces a state reader uses. */
13
+ export const NS = {
14
+ connection: "urn:x-cast:com.google.cast.tp.connection",
15
+ heartbeat: "urn:x-cast:com.google.cast.tp.heartbeat",
16
+ receiver: "urn:x-cast:com.google.cast.receiver",
17
+ media: "urn:x-cast:com.google.cast.media",
18
+ multizone: "urn:x-cast:com.google.cast.multizone",
19
+ } as const;
20
+
21
+ /** The largest message the protocol allows (64 KiB); a longer length prefix means the stream is out of step. */
22
+ export const MAX_MESSAGE = 65_536;
23
+
24
+ const text = new TextEncoder();
25
+ const varint = (n: number): number[] => {
26
+ const out: number[] = [];
27
+ for (; n > 127; n = Math.floor(n / 128)) out.push((n % 128) | 128);
28
+ return [...out, n];
29
+ };
30
+ const field = (number: number, value: string): number[] => {
31
+ const bytes = text.encode(value);
32
+ return [(number << 3) | 2, ...varint(bytes.length), ...bytes];
33
+ };
34
+
35
+ /** A message as framed on the socket: version 0, string payload. */
36
+ export function encode(m: CastMessage): Uint8Array {
37
+ const body = [
38
+ 0x08,
39
+ 0,
40
+ ...field(2, m.source),
41
+ ...field(3, m.destination),
42
+ ...field(4, m.namespace),
43
+ 0x28,
44
+ 0,
45
+ ...field(6, m.payload ?? ""),
46
+ ];
47
+ const out = new Uint8Array(4 + body.length);
48
+ new DataView(out.buffer).setUint32(0, body.length);
49
+ out.set(body, 4);
50
+ return out;
51
+ }
52
+
53
+ /** One message's bytes (without the length prefix). Unknown fields are skipped. */
54
+ export function decode(bytes: Uint8Array): CastMessage {
55
+ const strings: Record<number, string> = {};
56
+ let i = 0;
57
+ const read = () => {
58
+ let n = 0;
59
+ for (let scale = 1; ; scale *= 128) {
60
+ if (i >= bytes.length) throw new Error("cast: truncated varint");
61
+ const b = bytes[i++]!;
62
+ n += (b & 127) * scale;
63
+ if (b < 128) return n;
64
+ }
65
+ };
66
+ const decoder = new TextDecoder();
67
+ while (i < bytes.length) {
68
+ const key = read();
69
+ const wire = key & 7;
70
+ if (wire === 0) read();
71
+ else if (wire === 2) {
72
+ const length = read();
73
+ if (i + length > bytes.length) throw new Error("cast: truncated field");
74
+ strings[key >> 3] = decoder.decode(bytes.subarray(i, i + length));
75
+ i += length;
76
+ } else if (wire === 1 || wire === 5) i += wire === 1 ? 8 : 4;
77
+ else throw new Error(`cast: unknown wire type ${wire}`);
78
+ }
79
+ return {
80
+ source: strings[2] ?? "",
81
+ destination: strings[3] ?? "",
82
+ namespace: strings[4] ?? "",
83
+ payload: strings[6],
84
+ };
85
+ }
86
+
87
+ /** Splits a byte stream into messages: `push` each chunk as it arrives, get back every message it completed. */
88
+ export function framer() {
89
+ let buffer = new Uint8Array(0);
90
+ return (chunk: Uint8Array): CastMessage[] => {
91
+ const joined = new Uint8Array(buffer.length + chunk.length);
92
+ joined.set(buffer);
93
+ joined.set(chunk, buffer.length);
94
+ const out: CastMessage[] = [];
95
+ let at = 0;
96
+ while (joined.length - at >= 4) {
97
+ const length = new DataView(joined.buffer, at).getUint32(0);
98
+ if (length > MAX_MESSAGE)
99
+ throw new Error(
100
+ `cast: a ${length}-byte message, over the protocol's ${MAX_MESSAGE}`,
101
+ );
102
+ if (joined.length - at < 4 + length) break;
103
+ out.push(decode(joined.subarray(at + 4, at + 4 + length)));
104
+ at += 4 + length;
105
+ }
106
+ buffer = joined.slice(at);
107
+ return out;
108
+ };
109
+ }
110
+
111
+ /** An open Cast socket. `peer` is its certificate's name (a device's id), `protocol` its TLS version. Tests pass a fake. */
112
+ export type CastChannel = {
113
+ peer?: string;
114
+ protocol?: string;
115
+ send(message: CastMessage): void;
116
+ messages: AsyncIterable<CastMessage>;
117
+ close(): void;
118
+ };
119
+
120
+ /** How to open one: `tlsConnect` from `@khalido/home/cast/tls` in Bun and Node. */
121
+ export type CastConnect = (to: {
122
+ host: string;
123
+ port: number;
124
+ signal?: AbortSignal;
125
+ timeoutMs?: number;
126
+ }) => Promise<CastChannel>;