Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
270 changes: 259 additions & 11 deletions src/providers/home_assistant/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,27 @@ const contextSchema = s.looseObject("The Home Assistant context attached to a st
user_id: s.nullableString("The optional Home Assistant user identifier."),
});

const stateSchema = s.looseRequiredObject(
"One Home Assistant entity state object.",
{
entity_id: s.string("The Home Assistant entity identifier."),
state: s.string("The current state value."),
attributes: s.looseObject("The integration-specific attributes for the entity state."),
last_changed: s.string("The timestamp when the state last changed."),
last_updated: s.string("The timestamp when the state object was last updated."),
context: contextSchema,
},
{ optional: ["attributes", "context"] },
const stateProperties = {
entity_id: s.string("The Home Assistant entity identifier."),
state: s.string("The current state value."),
attributes: s.looseObject("The integration-specific attributes for the entity state."),
last_changed: s.string("The timestamp when the state last changed."),
last_updated: s.string("The timestamp when the state object was last updated."),
context: contextSchema,
};

const stateSchema = s.looseRequiredObject("One Home Assistant entity state object.", stateProperties, {
optional: ["attributes", "context"],
});

// History rows are not full state objects. With minimal_response, Home Assistant
// returns only state and last_changed for the entries between the first and last
// of the period, dropping entity_id, attributes, and last_updated; no_attributes
// drops attributes on every row. Only state and last_changed are always present.
const historyStateSchema = s.looseRequiredObject(
"One recorded Home Assistant state. Fields other than state and last_changed are omitted for compacted rows.",
stateProperties,
{ optional: ["entity_id", "attributes", "last_updated", "context"] },
);

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

// The editable config store reached through /api/config/<component>/config/<key>.
// Every domain there takes an admin token and only covers entries Home Assistant
// itself manages (automations.yaml, scripts.yaml, scenes.yaml); entries defined
// in other YAML files are not visible to it.
const configAdminNote =
"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.";

const configWriteResultSchema = s.actionOutput(
{ result: s.string("The Home Assistant result status, normally ok.") },
"The Home Assistant config write result.",
);

function configKeyInput(field: string, description: string): JsonSchema {
return s.actionInput({ [field]: s.nonEmptyString(description) }, [field], "Input parameters for one config entry.");
}

function configSaveInput(field: string, description: string, configDescription: string): JsonSchema {
return s.actionInput(
{
[field]: s.nonEmptyString(description),
config: s.looseObject(configDescription),
},
[field, "config"],
"Input parameters for saving one config entry.",
);
}

function configReadOutput(description: string): JsonSchema {
return s.actionOutput({ config: s.looseObject(description) }, "The stored Home Assistant configuration entry.");
}

export const homeAssistantActions: ActionDefinition[] = [
defineProviderAction(service, {
name: "get_config",
Expand Down Expand Up @@ -177,6 +218,125 @@ export const homeAssistantActions: ActionDefinition[] = [
"The rendered Home Assistant template response.",
),
}),
defineProviderAction(service, {
name: "get_history",
description:
"Fetch recorded state history for one or more Home Assistant entities over a time period, for answering questions about how a value changed.",
followUpActions: ["home_assistant.get_logbook"],
inputSchema: s.actionInput(
{
entityIds: s.array(
"The entity ids to fetch history for. Home Assistant requires at least one.",
s.nonEmptyString("One Home Assistant entity identifier."),
{ minItems: 1 },
),
startTime: s.dateTime("The start of the period. Defaults to one day before now when omitted."),
endTime: s.dateTime("The end of the period. Defaults to one day after the start time."),
minimalResponse: s.boolean(
"Return only state changes without full attribute payloads, which greatly reduces response size.",
),
noAttributes: s.boolean("Omit entity attributes from the response."),
skipInitialState: s.boolean("Omit the state that was already active at the start of the period."),
significantChangesOnly: s.boolean(
"Return only significant state changes. Home Assistant defaults this to true.",
),
},
["entityIds"],
"Input parameters for one Home Assistant history query.",
),
outputSchema: s.actionOutput(
{
history: s.array(
"One list of state objects per requested entity, in the order Home Assistant returns them.",
s.array("The recorded states for one entity.", historyStateSchema),
),
},
"The recorded Home Assistant state history.",
),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}),
defineProviderAction(service, {
name: "get_logbook",
description:
"Fetch the Home Assistant logbook: the human-readable timeline of what happened and what triggered it, for diagnosing why something changed.",
inputSchema: s.actionInput(
{
startTime: s.dateTime("The start of the period. Defaults to one day before now when omitted."),
endTime: s.dateTime("The end of the period."),
entityIds: s.array(
"Optional entity ids to restrict the logbook to.",
s.nonEmptyString("One Home Assistant entity identifier."),
),
period: s.positiveInteger("The number of days to cover, used when no end time is given."),
contextId: s.nonEmptyString(
"Optional Home Assistant context id, to list only the entries produced by one action.",
),
},
[],
"Input parameters for one Home Assistant logbook query.",
),
outputSchema: s.actionOutput(
{
entries: s.array("The logbook entries.", s.looseObject("One Home Assistant logbook entry.")),
},
"The Home Assistant logbook entries for the requested period.",
),
}),
defineProviderAction(service, {
name: "list_calendars",
description: "List the calendar entities exposed by Home Assistant.",
followUpActions: ["home_assistant.list_calendar_events"],
inputSchema: emptyInputSchema,
outputSchema: s.actionOutput(
{
calendars: s.array(
"The Home Assistant calendar entities.",
s.looseRequiredObject(
"One Home Assistant calendar entity.",
{
entity_id: s.string("The calendar entity identifier."),
name: s.string("The calendar display name."),
},
{ optional: ["name"] },
),
),
},
"The Home Assistant calendar entities.",
),
}),
defineProviderAction(service, {
name: "list_calendar_events",
description: "List the events on one Home Assistant calendar between a start and end time.",
inputSchema: s.actionInput(
{
entityId: s.nonEmptyString("The calendar entity identifier, for example calendar.personal."),
start: s.dateTime("The inclusive start of the window."),
end: s.dateTime("The exclusive end of the window, which must be after the start."),
},
["entityId", "start", "end"],
"Input parameters for one Home Assistant calendar event query.",
),
outputSchema: s.actionOutput(
{
events: s.array(
"The calendar events in the requested window.",
s.looseObject(
"One Home Assistant calendar event. Start and end are objects holding either dateTime or date.",
),
),
},
"The Home Assistant calendar events.",
),
}),
defineProviderAction(service, {
name: "get_error_log",
description:
"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.",
inputSchema: emptyInputSchema,
outputSchema: s.actionOutput(
{ log: s.string("The plain-text Home Assistant error log.") },
"The Home Assistant error log.",
),
}),
defineProviderAction(service, {
name: "get_registries",
description:
Expand Down Expand Up @@ -229,6 +389,7 @@ export const homeAssistantActions: ActionDefinition[] = [
name: "list_device_automations",
description:
"List the triggers, conditions, and actions one Home Assistant device supports, for building automations against that device.",
followUpActions: ["home_assistant.validate_config"],
inputSchema: s.actionInput(
{
deviceId: s.nonEmptyString("The Home Assistant device registry identifier."),
Expand Down Expand Up @@ -272,6 +433,7 @@ export const homeAssistantActions: ActionDefinition[] = [
name: "validate_config",
description:
"Validate Home Assistant trigger, condition, and action configurations before storing them in an automation.",
followUpActions: ["home_assistant.save_automation_config", "home_assistant.save_script_config"],
inputSchema: s.actionInput(
{
triggers: s.array("The trigger configurations to validate.", s.looseObject("One Home Assistant trigger.")),
Expand All @@ -293,4 +455,90 @@ export const homeAssistantActions: ActionDefinition[] = [
"The Home Assistant configuration validation result.",
),
}),
defineProviderAction(service, {
name: "get_automation_config",
description: `Fetch the stored configuration for one Home Assistant automation. ${configAdminNote}`,
followUpActions: ["home_assistant.save_automation_config"],
inputSchema: configKeyInput("automationId", "The automation id, which is the id field inside the automation."),
outputSchema: configReadOutput("The stored automation configuration."),
}),
defineProviderAction(service, {
name: "save_automation_config",
description: `Create or replace one Home Assistant automation. Posting to an unused id creates the automation. ${configAdminNote}`,
followUpActions: ["home_assistant.get_automation_config", "home_assistant.get_logbook"],
inputSchema: configSaveInput(
"automationId",
"The automation id to create or replace.",
"The automation configuration, with the same keys as an automations.yaml entry such as alias, triggers, conditions, actions, and mode.",
),
outputSchema: configWriteResultSchema,
}),
defineProviderAction(service, {
name: "delete_automation_config",
description: `Delete one Home Assistant automation. ${configAdminNote}`,
inputSchema: configKeyInput("automationId", "The automation id to delete."),
outputSchema: configWriteResultSchema,
}),
defineProviderAction(service, {
name: "get_script_config",
description: `Fetch the stored configuration for one Home Assistant script. ${configAdminNote}`,
followUpActions: ["home_assistant.save_script_config"],
inputSchema: configKeyInput("scriptKey", "The script key, the slug after script. in the entity id."),
outputSchema: configReadOutput("The stored script configuration."),
}),
defineProviderAction(service, {
name: "save_script_config",
description: `Create or replace one Home Assistant script. Posting to an unused key creates the script. ${configAdminNote}`,
followUpActions: ["home_assistant.get_script_config"],
inputSchema: configSaveInput(
"scriptKey",
"The script key to create or replace, which must be a slug of lowercase letters, digits, and underscores.",
"The script configuration, with the same keys as a scripts.yaml entry such as alias, sequence, and mode.",
),
outputSchema: configWriteResultSchema,
}),
defineProviderAction(service, {
name: "delete_script_config",
description: `Delete one Home Assistant script. ${configAdminNote}`,
inputSchema: configKeyInput("scriptKey", "The script key to delete."),
outputSchema: configWriteResultSchema,
}),
defineProviderAction(service, {
name: "get_scene_config",
description: `Fetch the stored configuration for one Home Assistant scene. ${configAdminNote}`,
followUpActions: ["home_assistant.save_scene_config"],
inputSchema: configKeyInput("sceneId", "The scene id, which is the id field inside the scene."),
outputSchema: configReadOutput("The stored scene configuration."),
}),
defineProviderAction(service, {
name: "save_scene_config",
description: `Create or replace one Home Assistant scene. Posting to an unused id creates the scene. ${configAdminNote}`,
followUpActions: ["home_assistant.get_scene_config"],
inputSchema: configSaveInput(
"sceneId",
"The scene id to create or replace.",
"The scene configuration, with the same keys as a scenes.yaml entry such as name and entities.",
),
outputSchema: configWriteResultSchema,
}),
defineProviderAction(service, {
name: "delete_scene_config",
description: `Delete one Home Assistant scene. ${configAdminNote}`,
inputSchema: configKeyInput("sceneId", "The scene id to delete."),
outputSchema: configWriteResultSchema,
}),
defineProviderAction(service, {
name: "check_config",
description:
"Ask Home Assistant to validate its own configuration files and report errors and warnings. Requires an admin access token.",
inputSchema: emptyInputSchema,
outputSchema: s.actionOutput(
{
result: s.string("Either valid or invalid."),
errors: s.nullableString("The configuration errors, or null when there are none."),
warnings: s.nullableString("The configuration warnings, or null when there are none."),
},
"The Home Assistant configuration check result.",
),
}),
];
7 changes: 6 additions & 1 deletion src/providers/home_assistant/executors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { HomeAssistantActionContext } from "./runtime.ts";

import { isPrivateNetworkAccessAllowed } from "../../core/request.ts";
import { defineProviderExecutors, requireApiKeyCredential } from "../provider-runtime.ts";
import { homeAssistantConfigActionHandlers } from "./runtime-config.ts";
import { homeAssistantWebSocketActionHandlers } from "./runtime-ws.ts";
import {
homeAssistantActionHandlers,
Expand All @@ -14,7 +15,11 @@ const service = "home_assistant";

export const executors: ProviderExecutors = defineProviderExecutors<HomeAssistantActionContext>({
service,
handlers: { ...homeAssistantActionHandlers, ...homeAssistantWebSocketActionHandlers },
handlers: {
...homeAssistantActionHandlers,
...homeAssistantConfigActionHandlers,
...homeAssistantWebSocketActionHandlers,
},
allowPrivateNetwork: isPrivateNetworkAccessAllowed,
async createContext(context: ExecutionContext, fetcher: typeof fetch): Promise<HomeAssistantActionContext> {
const credential = await requireApiKeyCredential(context, service);
Expand Down
Loading
Loading