Skip to content

Commit cebbe00

Browse files
authored
feat(home-assistant): add REST coverage for config CRUD, history, and calendars (#239)
## Summary - add automation, script, and scene config CRUD, reached over REST at `/api/config/<component>/config/<key>` - add history, logbook, calendar, and error log actions from Home Assistant's published REST API - fix `buildHomeAssistantUrl` dropping the instance base path Fifteen actions in total, all REST. No WebSocket code is touched. ## Problem Two separate gaps, both in the REST surface. **The editable config store was missing entirely.** Home Assistant serves automation, script, and scene configuration over REST, but at `/api/config/<component>/config/<key>` rather than under the endpoints listed in the published REST API reference. The provider could therefore run an automation but never write one. That endpoint family is absent from the reference page, which is the most likely reason it was never covered — the eight actions the provider started with map exactly onto that page and nothing outside it. **There was no time dimension.** `/api/states` answers "what is the temperature now"; nothing answered "how did it get there" or "what tripped that switch overnight". `history`, `logbook`, `calendars`, and `error_log` are all on the published reference page and were simply never implemented. ## Config CRUD `automation`, `script`, and `scene` differ only in three respects: the path segment, the input field carrying the key, and whether Home Assistant validates that key as a slug (`cv.slug` for script, `cv.string` for automation and scene). One descriptor captures those differences and parameterises shared read/write/delete handlers, rather than nine near-identical copies. The key travels in the path and Home Assistant injects it into the stored entry itself, so the request body is the bare config object. `POST` is create-or-update: writing to an unused key creates the entry, which is how the UI creates automations. Both endpoints require an admin token, and they only see entries Home Assistant manages itself — an automation defined in a separate YAML file or a package is not visible and returns not found. The action descriptions say so, because a non-admin token fails at the provider rather than at input validation. This pairs with `validate_config` from #227 to close the loop an agent needs for automation work: discover what a device can do, validate a candidate config, write it, then read the logbook to confirm it fired. `followUpActions` wires that sequence into the catalog so it is discoverable rather than something the caller has to know. ## History and diagnostics `get_history` exposes `minimalResponse` and `noAttributes` because a full-attribute history across several entities is large enough to matter for an agent's context budget. Against a real instance, one entity over 24 hours returned 7219 points; the compact form drops each row from the full state object to `{"state", "last_changed"}`. Home Assistant reads `minimal_response`, `no_attributes`, and `skip_initial_state` by presence rather than by value, so `false` omits the parameter instead of sending `0`. `significant_changes_only` is read by value and defaults to true, so it is encoded normally. `get_error_log` needs one caveat: Home Assistant registers that view only when the instance runs with file logging, so it can report not found on an otherwise healthy instance. Rather than surface a bare "Not Found", the handler maps that case to a message explaining why, and the action description repeats it. ## Base path fix `normalizeBaseUrl` accepts an instance URL with a path, so a Home Assistant behind a reverse proxy at `/ha` is a supported configuration. `buildHomeAssistantUrl` assigned the endpoint path over `url.pathname`, which dropped that prefix and sent every request to `/api/...` at the origin instead of `/ha/api/...`. It now appends to the base path. The WebSocket URL builder added in #227 already did this, so the two transports agree. This is a separate commit; it predates both PRs, and the fifteen new paths here would have multiplied it. ## Actions | Action | Endpoint | | --- | --- | | `get_automation_config` / `save_automation_config` / `delete_automation_config` | `GET`/`POST`/`DELETE /api/config/automation/config/{id}` | | `get_script_config` / `save_script_config` / `delete_script_config` | `GET`/`POST`/`DELETE /api/config/script/config/{key}` | | `get_scene_config` / `save_scene_config` / `delete_scene_config` | `GET`/`POST`/`DELETE /api/config/scene/config/{id}` | | `check_config` | `POST /api/config/core/check_config` | | `get_history` | `GET /api/history/period/{timestamp}` | | `get_logbook` | `GET /api/logbook/{timestamp}` | | `list_calendars` | `GET /api/calendars` | | `list_calendar_events` | `GET /api/calendars/{entity_id}` | | `get_error_log` | `GET /api/error_log` | Reference: https://developers.home-assistant.io/docs/api/rest ## Verification - `npm run fix-check`, `npm test` (584 passing) - read paths against a real instance: `check_config` returned valid; `get_history` returned 7219 points for one entity over 24 hours with the compact form visibly applied; `get_logbook` returned 4417 entries; `list_calendars` returned an empty list on an instance with no calendar integration; `get_error_log` returned the explanatory 404 described above - write paths against the same instance, creating and then removing an `oc_smoke_test` entry in each domain: create returned `ok`, the read-back showed the key injected into the stored entry, delete returned `ok`, and each subsequent read reported not found. A non-slug script key was rejected at the provider before any request. A following scan of 3830 entities found nothing left behind.
1 parent af9090f commit cebbe00

4 files changed

Lines changed: 542 additions & 30 deletions

File tree

src/providers/home_assistant/actions.ts

Lines changed: 259 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,27 @@ const contextSchema = s.looseObject("The Home Assistant context attached to a st
1111
user_id: s.nullableString("The optional Home Assistant user identifier."),
1212
});
1313

14-
const stateSchema = s.looseRequiredObject(
15-
"One Home Assistant entity state object.",
16-
{
17-
entity_id: s.string("The Home Assistant entity identifier."),
18-
state: s.string("The current state value."),
19-
attributes: s.looseObject("The integration-specific attributes for the entity state."),
20-
last_changed: s.string("The timestamp when the state last changed."),
21-
last_updated: s.string("The timestamp when the state object was last updated."),
22-
context: contextSchema,
23-
},
24-
{ optional: ["attributes", "context"] },
14+
const stateProperties = {
15+
entity_id: s.string("The Home Assistant entity identifier."),
16+
state: s.string("The current state value."),
17+
attributes: s.looseObject("The integration-specific attributes for the entity state."),
18+
last_changed: s.string("The timestamp when the state last changed."),
19+
last_updated: s.string("The timestamp when the state object was last updated."),
20+
context: contextSchema,
21+
};
22+
23+
const stateSchema = s.looseRequiredObject("One Home Assistant entity state object.", stateProperties, {
24+
optional: ["attributes", "context"],
25+
});
26+
27+
// History rows are not full state objects. With minimal_response, Home Assistant
28+
// returns only state and last_changed for the entries between the first and last
29+
// of the period, dropping entity_id, attributes, and last_updated; no_attributes
30+
// drops attributes on every row. Only state and last_changed are always present.
31+
const historyStateSchema = s.looseRequiredObject(
32+
"One recorded Home Assistant state. Fields other than state and last_changed are omitted for compacted rows.",
33+
stateProperties,
34+
{ optional: ["entity_id", "attributes", "last_updated", "context"] },
2535
);
2636

2737
const emptyInputSchema = s.actionInput({}, [], "No input is required for this action.");
@@ -69,6 +79,37 @@ function registryListSchema(description: string): JsonSchema {
6979
return s.nullable(s.array(description, s.looseObject("One Home Assistant registry entry.")));
7080
}
7181

82+
// The editable config store reached through /api/config/<component>/config/<key>.
83+
// Every domain there takes an admin token and only covers entries Home Assistant
84+
// itself manages (automations.yaml, scripts.yaml, scenes.yaml); entries defined
85+
// in other YAML files are not visible to it.
86+
const configAdminNote =
87+
"Requires an admin access token, and only covers entries stored in the Home Assistant UI-editable config; entries defined in other YAML files return not found.";
88+
89+
const configWriteResultSchema = s.actionOutput(
90+
{ result: s.string("The Home Assistant result status, normally ok.") },
91+
"The Home Assistant config write result.",
92+
);
93+
94+
function configKeyInput(field: string, description: string): JsonSchema {
95+
return s.actionInput({ [field]: s.nonEmptyString(description) }, [field], "Input parameters for one config entry.");
96+
}
97+
98+
function configSaveInput(field: string, description: string, configDescription: string): JsonSchema {
99+
return s.actionInput(
100+
{
101+
[field]: s.nonEmptyString(description),
102+
config: s.looseObject(configDescription),
103+
},
104+
[field, "config"],
105+
"Input parameters for saving one config entry.",
106+
);
107+
}
108+
109+
function configReadOutput(description: string): JsonSchema {
110+
return s.actionOutput({ config: s.looseObject(description) }, "The stored Home Assistant configuration entry.");
111+
}
112+
72113
export const homeAssistantActions: ActionDefinition[] = [
73114
defineProviderAction(service, {
74115
name: "get_config",
@@ -177,6 +218,125 @@ export const homeAssistantActions: ActionDefinition[] = [
177218
"The rendered Home Assistant template response.",
178219
),
179220
}),
221+
defineProviderAction(service, {
222+
name: "get_history",
223+
description:
224+
"Fetch recorded state history for one or more Home Assistant entities over a time period, for answering questions about how a value changed.",
225+
followUpActions: ["home_assistant.get_logbook"],
226+
inputSchema: s.actionInput(
227+
{
228+
entityIds: s.array(
229+
"The entity ids to fetch history for. Home Assistant requires at least one.",
230+
s.nonEmptyString("One Home Assistant entity identifier."),
231+
{ minItems: 1 },
232+
),
233+
startTime: s.dateTime("The start of the period. Defaults to one day before now when omitted."),
234+
endTime: s.dateTime("The end of the period. Defaults to one day after the start time."),
235+
minimalResponse: s.boolean(
236+
"Return only state changes without full attribute payloads, which greatly reduces response size.",
237+
),
238+
noAttributes: s.boolean("Omit entity attributes from the response."),
239+
skipInitialState: s.boolean("Omit the state that was already active at the start of the period."),
240+
significantChangesOnly: s.boolean(
241+
"Return only significant state changes. Home Assistant defaults this to true.",
242+
),
243+
},
244+
["entityIds"],
245+
"Input parameters for one Home Assistant history query.",
246+
),
247+
outputSchema: s.actionOutput(
248+
{
249+
history: s.array(
250+
"One list of state objects per requested entity, in the order Home Assistant returns them.",
251+
s.array("The recorded states for one entity.", historyStateSchema),
252+
),
253+
},
254+
"The recorded Home Assistant state history.",
255+
),
256+
}),
257+
defineProviderAction(service, {
258+
name: "get_logbook",
259+
description:
260+
"Fetch the Home Assistant logbook: the human-readable timeline of what happened and what triggered it, for diagnosing why something changed.",
261+
inputSchema: s.actionInput(
262+
{
263+
startTime: s.dateTime("The start of the period. Defaults to one day before now when omitted."),
264+
endTime: s.dateTime("The end of the period."),
265+
entityIds: s.array(
266+
"Optional entity ids to restrict the logbook to.",
267+
s.nonEmptyString("One Home Assistant entity identifier."),
268+
),
269+
period: s.positiveInteger("The number of days to cover, used when no end time is given."),
270+
contextId: s.nonEmptyString(
271+
"Optional Home Assistant context id, to list only the entries produced by one action.",
272+
),
273+
},
274+
[],
275+
"Input parameters for one Home Assistant logbook query.",
276+
),
277+
outputSchema: s.actionOutput(
278+
{
279+
entries: s.array("The logbook entries.", s.looseObject("One Home Assistant logbook entry.")),
280+
},
281+
"The Home Assistant logbook entries for the requested period.",
282+
),
283+
}),
284+
defineProviderAction(service, {
285+
name: "list_calendars",
286+
description: "List the calendar entities exposed by Home Assistant.",
287+
followUpActions: ["home_assistant.list_calendar_events"],
288+
inputSchema: emptyInputSchema,
289+
outputSchema: s.actionOutput(
290+
{
291+
calendars: s.array(
292+
"The Home Assistant calendar entities.",
293+
s.looseRequiredObject(
294+
"One Home Assistant calendar entity.",
295+
{
296+
entity_id: s.string("The calendar entity identifier."),
297+
name: s.string("The calendar display name."),
298+
},
299+
{ optional: ["name"] },
300+
),
301+
),
302+
},
303+
"The Home Assistant calendar entities.",
304+
),
305+
}),
306+
defineProviderAction(service, {
307+
name: "list_calendar_events",
308+
description: "List the events on one Home Assistant calendar between a start and end time.",
309+
inputSchema: s.actionInput(
310+
{
311+
entityId: s.nonEmptyString("The calendar entity identifier, for example calendar.personal."),
312+
start: s.dateTime("The inclusive start of the window."),
313+
end: s.dateTime("The exclusive end of the window, which must be after the start."),
314+
},
315+
["entityId", "start", "end"],
316+
"Input parameters for one Home Assistant calendar event query.",
317+
),
318+
outputSchema: s.actionOutput(
319+
{
320+
events: s.array(
321+
"The calendar events in the requested window.",
322+
s.looseObject(
323+
"One Home Assistant calendar event. Start and end are objects holding either dateTime or date.",
324+
),
325+
),
326+
},
327+
"The Home Assistant calendar events.",
328+
),
329+
}),
330+
defineProviderAction(service, {
331+
name: "get_error_log",
332+
description:
333+
"Fetch the Home Assistant error log for the current session as plain text. Home Assistant serves this only when the instance runs with file logging enabled, so it can report not found on an otherwise healthy instance.",
334+
inputSchema: emptyInputSchema,
335+
outputSchema: s.actionOutput(
336+
{ log: s.string("The plain-text Home Assistant error log.") },
337+
"The Home Assistant error log.",
338+
),
339+
}),
180340
defineProviderAction(service, {
181341
name: "get_registries",
182342
description:
@@ -229,6 +389,7 @@ export const homeAssistantActions: ActionDefinition[] = [
229389
name: "list_device_automations",
230390
description:
231391
"List the triggers, conditions, and actions one Home Assistant device supports, for building automations against that device.",
392+
followUpActions: ["home_assistant.validate_config"],
232393
inputSchema: s.actionInput(
233394
{
234395
deviceId: s.nonEmptyString("The Home Assistant device registry identifier."),
@@ -272,6 +433,7 @@ export const homeAssistantActions: ActionDefinition[] = [
272433
name: "validate_config",
273434
description:
274435
"Validate Home Assistant trigger, condition, and action configurations before storing them in an automation.",
436+
followUpActions: ["home_assistant.save_automation_config", "home_assistant.save_script_config"],
275437
inputSchema: s.actionInput(
276438
{
277439
triggers: s.array("The trigger configurations to validate.", s.looseObject("One Home Assistant trigger.")),
@@ -293,4 +455,90 @@ export const homeAssistantActions: ActionDefinition[] = [
293455
"The Home Assistant configuration validation result.",
294456
),
295457
}),
458+
defineProviderAction(service, {
459+
name: "get_automation_config",
460+
description: `Fetch the stored configuration for one Home Assistant automation. ${configAdminNote}`,
461+
followUpActions: ["home_assistant.save_automation_config"],
462+
inputSchema: configKeyInput("automationId", "The automation id, which is the id field inside the automation."),
463+
outputSchema: configReadOutput("The stored automation configuration."),
464+
}),
465+
defineProviderAction(service, {
466+
name: "save_automation_config",
467+
description: `Create or replace one Home Assistant automation. Posting to an unused id creates the automation. ${configAdminNote}`,
468+
followUpActions: ["home_assistant.get_automation_config", "home_assistant.get_logbook"],
469+
inputSchema: configSaveInput(
470+
"automationId",
471+
"The automation id to create or replace.",
472+
"The automation configuration, with the same keys as an automations.yaml entry such as alias, triggers, conditions, actions, and mode.",
473+
),
474+
outputSchema: configWriteResultSchema,
475+
}),
476+
defineProviderAction(service, {
477+
name: "delete_automation_config",
478+
description: `Delete one Home Assistant automation. ${configAdminNote}`,
479+
inputSchema: configKeyInput("automationId", "The automation id to delete."),
480+
outputSchema: configWriteResultSchema,
481+
}),
482+
defineProviderAction(service, {
483+
name: "get_script_config",
484+
description: `Fetch the stored configuration for one Home Assistant script. ${configAdminNote}`,
485+
followUpActions: ["home_assistant.save_script_config"],
486+
inputSchema: configKeyInput("scriptKey", "The script key, the slug after script. in the entity id."),
487+
outputSchema: configReadOutput("The stored script configuration."),
488+
}),
489+
defineProviderAction(service, {
490+
name: "save_script_config",
491+
description: `Create or replace one Home Assistant script. Posting to an unused key creates the script. ${configAdminNote}`,
492+
followUpActions: ["home_assistant.get_script_config"],
493+
inputSchema: configSaveInput(
494+
"scriptKey",
495+
"The script key to create or replace, which must be a slug of lowercase letters, digits, and underscores.",
496+
"The script configuration, with the same keys as a scripts.yaml entry such as alias, sequence, and mode.",
497+
),
498+
outputSchema: configWriteResultSchema,
499+
}),
500+
defineProviderAction(service, {
501+
name: "delete_script_config",
502+
description: `Delete one Home Assistant script. ${configAdminNote}`,
503+
inputSchema: configKeyInput("scriptKey", "The script key to delete."),
504+
outputSchema: configWriteResultSchema,
505+
}),
506+
defineProviderAction(service, {
507+
name: "get_scene_config",
508+
description: `Fetch the stored configuration for one Home Assistant scene. ${configAdminNote}`,
509+
followUpActions: ["home_assistant.save_scene_config"],
510+
inputSchema: configKeyInput("sceneId", "The scene id, which is the id field inside the scene."),
511+
outputSchema: configReadOutput("The stored scene configuration."),
512+
}),
513+
defineProviderAction(service, {
514+
name: "save_scene_config",
515+
description: `Create or replace one Home Assistant scene. Posting to an unused id creates the scene. ${configAdminNote}`,
516+
followUpActions: ["home_assistant.get_scene_config"],
517+
inputSchema: configSaveInput(
518+
"sceneId",
519+
"The scene id to create or replace.",
520+
"The scene configuration, with the same keys as a scenes.yaml entry such as name and entities.",
521+
),
522+
outputSchema: configWriteResultSchema,
523+
}),
524+
defineProviderAction(service, {
525+
name: "delete_scene_config",
526+
description: `Delete one Home Assistant scene. ${configAdminNote}`,
527+
inputSchema: configKeyInput("sceneId", "The scene id to delete."),
528+
outputSchema: configWriteResultSchema,
529+
}),
530+
defineProviderAction(service, {
531+
name: "check_config",
532+
description:
533+
"Ask Home Assistant to validate its own configuration files and report errors and warnings. Requires an admin access token.",
534+
inputSchema: emptyInputSchema,
535+
outputSchema: s.actionOutput(
536+
{
537+
result: s.string("Either valid or invalid."),
538+
errors: s.nullableString("The configuration errors, or null when there are none."),
539+
warnings: s.nullableString("The configuration warnings, or null when there are none."),
540+
},
541+
"The Home Assistant configuration check result.",
542+
),
543+
}),
296544
];

src/providers/home_assistant/executors.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { HomeAssistantActionContext } from "./runtime.ts";
33

44
import { isPrivateNetworkAccessAllowed } from "../../core/request.ts";
55
import { defineProviderExecutors, requireApiKeyCredential } from "../provider-runtime.ts";
6+
import { homeAssistantConfigActionHandlers } from "./runtime-config.ts";
67
import { homeAssistantWebSocketActionHandlers } from "./runtime-ws.ts";
78
import {
89
homeAssistantActionHandlers,
@@ -14,7 +15,11 @@ const service = "home_assistant";
1415

1516
export const executors: ProviderExecutors = defineProviderExecutors<HomeAssistantActionContext>({
1617
service,
17-
handlers: { ...homeAssistantActionHandlers, ...homeAssistantWebSocketActionHandlers },
18+
handlers: {
19+
...homeAssistantActionHandlers,
20+
...homeAssistantConfigActionHandlers,
21+
...homeAssistantWebSocketActionHandlers,
22+
},
1823
allowPrivateNetwork: isPrivateNetworkAccessAllowed,
1924
async createContext(context: ExecutionContext, fetcher: typeof fetch): Promise<HomeAssistantActionContext> {
2025
const credential = await requireApiKeyCredential(context, service);

0 commit comments

Comments
 (0)