Skip to content

Commit dcb3ffc

Browse files
committed
fix(home-assistant): describe compacted history rows and guard the entity filter
The get_history output schema reused the /api/states shape, which requires entity_id and last_updated. Home Assistant does not return those for the rows between the first and last of a period once minimal_response is set, so the declared contract rejected real responses. A live 7846-point response validates against the corrected schema and fails against the previous one. History rows now use their own schema where only state and last_changed are required, and /api/states keeps the strict shape. Also normalize the entity filter before sending it. Home Assistant reads a missing filter_entity_id as every entity and queryParams drops empty strings, so a list that normalizes to nothing would silently widen a scoped query into a whole-instance dump. The schema's minItems already rejects an empty array, but the request should not depend on that alone, and a whitespace-only id passes the schema while still being unusable.
1 parent 15b3fa6 commit dcb3ffc

2 files changed

Lines changed: 44 additions & 13 deletions

File tree

src/providers/home_assistant/actions.ts

Lines changed: 22 additions & 12 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.");
@@ -238,7 +248,7 @@ export const homeAssistantActions: ActionDefinition[] = [
238248
{
239249
history: s.array(
240250
"One list of state objects per requested entity, in the order Home Assistant returns them.",
241-
s.array("The recorded states for one entity.", stateSchema),
251+
s.array("The recorded states for one entity.", historyStateSchema),
242252
),
243253
},
244254
"The recorded Home Assistant state history.",

src/providers/home_assistant/runtime.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ export const homeAssistantActionHandlers: Record<HomeAssistantRestActionName, Ho
128128
path: startTime ? `/api/history/period/${encodeURIComponent(startTime)}` : "/api/history/period",
129129
method: "GET",
130130
query: queryParams({
131-
filter_entity_id: requiredStringArray(input.entityIds, "entityIds", badHomeAssistantRequest).join(","),
131+
filter_entity_id: readHistoryEntityIds(input.entityIds),
132132
end_time: optionalString(input.endTime),
133133
minimal_response: presenceFlag(input.minimalResponse),
134134
no_attributes: presenceFlag(input.noAttributes),
@@ -210,6 +210,27 @@ function presenceFlag(value: unknown): string | undefined {
210210
return optionalBoolean(value) === true ? "1" : undefined;
211211
}
212212

213+
/**
214+
* Build the `filter_entity_id` value, rejecting an entity list that carries no
215+
* usable id.
216+
*
217+
* Home Assistant treats a missing `filter_entity_id` as "every entity", and
218+
* `queryParams` drops empty strings, so a list that normalizes to nothing would
219+
* turn a scoped query into a whole-instance history dump. The action schema
220+
* already requires at least one non-empty id, but the request must not depend on
221+
* that alone, and a whitespace-only id passes the schema while still being
222+
* unusable.
223+
*/
224+
function readHistoryEntityIds(value: unknown): string {
225+
const entityIds = requiredStringArray(value, "entityIds", badHomeAssistantRequest)
226+
.map((entityId) => entityId.trim())
227+
.filter((entityId) => entityId.length > 0);
228+
if (entityIds.length === 0) {
229+
throw badHomeAssistantRequest("entityIds must contain at least one entity id");
230+
}
231+
return entityIds.join(",");
232+
}
233+
213234
export function validateHomeAssistantCredential(input: { values: Record<string, string> }): {
214235
profile: { accountId: string; displayName: string };
215236
grantedScopes: string[];

0 commit comments

Comments
 (0)