Compare commits

...
4 changed files with 127 additions and 4 deletions
+19 -1
View File
@@ -1,4 +1,6 @@
import type { Context, HomeAssistant } from "../types";
import { ensureArray } from "../common/array/ensure-array";
import { isValidEntityId } from "../common/entity/valid_entity_id";
import type { Context, HomeAssistant, ServiceCallRequest } from "../types";
import type { Action } from "./script";
export const callExecuteScript = (
@@ -22,3 +24,19 @@ export const serviceCallWillDisconnect = (
"update.home_assistant_core_update",
"update.home_assistant_operating_system_update",
].includes(serviceData?.entity_id));
// Core merges the target into the service data, so a target entity_id
// replaces the legacy service data one rather than adding to it. Its schema
// also accepts comma separated ids and lowercases them.
export const getServiceCallEntityIds = (
serviceData?: ServiceCallRequest["serviceData"],
target?: ServiceCallRequest["target"]
): string[] => [
...new Set(
(ensureArray(target?.entity_id ?? serviceData?.entity_id) ?? [])
.filter((id): id is string => typeof id === "string")
.flatMap((id) => id.split(","))
.map((id) => id.trim().toLowerCase())
.filter(isValidEntityId)
),
];
+10
View File
@@ -184,6 +184,15 @@ interface EMOutgoingMessageAddEntityTo extends EMMessage {
};
}
interface EMOutgoingMessageEntityControlled extends EMMessage {
type: "entity/controlled";
payload: {
entity_ids: string[];
domain: string;
service: string;
};
}
interface EMOutgoingMessageMoreInfoOpened extends EMMessage {
type: "more_info/opened";
payload: {
@@ -239,6 +248,7 @@ type EMOutgoingMessageWithoutAnswer =
| EMOutgoingMessageImprovScan
| EMOutgoingMessageImprovConfigureDevice
| EMOutgoingMessageAddEntityTo
| EMOutgoingMessageEntityControlled
| EMOutgoingMessageFocusElement
| EMOutgoingMessageReloadAndClearCache
| EMOutgoingMessageAssistSettings;
+48 -3
View File
@@ -18,7 +18,10 @@ import {
subscribeFrontendUserData,
} from "../data/frontend";
import { forwardHaptic } from "../data/haptics";
import { serviceCallWillDisconnect } from "../data/service";
import {
getServiceCallEntityIds,
serviceCallWillDisconnect,
} from "../data/service";
import {
DateFormat,
FirstWeekday,
@@ -32,7 +35,12 @@ import { preserveUnchangedRecord } from "../common/util/preserve-unchanged-recor
import { subscribeFloorRegistry } from "../data/ws-floor_registry";
import { subscribePanels } from "../data/ws-panels";
import { translationMetadata } from "../resources/translations-metadata";
import type { Constructor, HomeAssistant, ServiceCallResponse } from "../types";
import type {
Constructor,
HomeAssistant,
ServiceCallRequest,
ServiceCallResponse,
} from "../types";
import {
addBrandsAuth,
clearBrandsTokenRefresh,
@@ -114,7 +122,7 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
);
}
try {
return (await callService(
const response = (await callService(
conn,
domain,
service,
@@ -122,11 +130,24 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
target,
returnResponse
)) as ServiceCallResponse;
this._reportEntityControlToExternalApp(
domain,
service,
serviceData,
target
);
return response;
} catch (err: any) {
if (
err.error?.code === ERR_CONNECTION_LOST &&
serviceCallWillDisconnect(domain, service, serviceData)
) {
this._reportEntityControlToExternalApp(
domain,
service,
serviceData,
target
);
return { context: { id: "" } };
}
if (this.hass?.debugConnection) {
@@ -405,4 +426,28 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
this._updateHass({});
}
}
private _reportEntityControlToExternalApp(
domain: string,
service: string,
serviceData?: ServiceCallRequest["serviceData"],
target?: ServiceCallRequest["target"]
) {
const external = this.hass?.auth.external;
if (!external) {
return;
}
const entityIds = getServiceCallEntityIds(serviceData, target);
if (!entityIds.length) {
return;
}
try {
external.fireMessage({
type: "entity/controlled",
payload: { entity_ids: entityIds, domain, service },
});
} catch (_err) {
// Reporting is best effort and must not fail the service call.
}
}
};
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { getServiceCallEntityIds } from "../../src/data/service";
describe("getServiceCallEntityIds", () => {
it("returns an empty list when no entities are targeted", () => {
expect(getServiceCallEntityIds()).toEqual([]);
expect(getServiceCallEntityIds({ brightness: 50 }, {})).toEqual([]);
expect(getServiceCallEntityIds({}, { area_id: "kitchen" })).toEqual([]);
});
it("reads a single entity from target or service data", () => {
expect(getServiceCallEntityIds({}, { entity_id: "light.a" })).toEqual([
"light.a",
]);
expect(getServiceCallEntityIds({ entity_id: "light.a" })).toEqual([
"light.a",
]);
});
it("prefers the target over the legacy service data entity ids", () => {
expect(
getServiceCallEntityIds(
{ entity_id: ["light.a", "light.b"] },
{ entity_id: ["light.b", "light.c"] }
)
).toEqual(["light.b", "light.c"]);
});
it("deduplicates entity ids", () => {
expect(
getServiceCallEntityIds({}, { entity_id: ["light.a", "light.a"] })
).toEqual(["light.a"]);
});
it("splits comma separated ids and lowercases them like Core does", () => {
expect(
getServiceCallEntityIds({}, { entity_id: "Light.A, light.b ,light.a" })
).toEqual(["light.a", "light.b"]);
});
it("ignores wildcard, malformed, and non-string entity ids", () => {
expect(getServiceCallEntityIds({ entity_id: "all" })).toEqual([]);
expect(
getServiceCallEntityIds(
{},
{ entity_id: ["all", "none", "", "light", 5] as unknown as string[] }
)
).toEqual([]);
});
});