Allow and migrate action key in service action (#21503)

This commit is contained in:
Bram Kragten 2024-07-31 14:36:14 +02:00 committed by GitHub
parent da2e530601
commit 78becb5440
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 163 additions and 95 deletions

View File

@ -77,7 +77,7 @@ export class HaServiceControl extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: {
service: string;
action: string;
target?: HassServiceTarget;
data?: Record<string, any>;
};
@ -112,23 +112,23 @@ export class HaServiceControl extends LitElement {
| undefined
| this["value"];
if (oldValue?.service !== this.value?.service) {
if (oldValue?.action !== this.value?.action) {
this._checkedKeys = new Set();
}
const serviceData = this._getServiceInfo(
this.value?.service,
this.value?.action,
this.hass.services
);
// Fetch the manifest if we have a service selected and the service domain changed.
// If no service is selected, clear the manifest.
if (this.value?.service) {
if (this.value?.action) {
if (
!oldValue?.service ||
computeDomain(this.value.service) !== computeDomain(oldValue.service)
!oldValue?.action ||
computeDomain(this.value.action) !== computeDomain(oldValue.action)
) {
this._fetchManifest(computeDomain(this.value?.service));
this._fetchManifest(computeDomain(this.value?.action));
}
} else {
this._manifest = undefined;
@ -168,7 +168,7 @@ export class HaServiceControl extends LitElement {
this._value = this.value;
}
if (oldValue?.service !== this.value?.service) {
if (oldValue?.action !== this.value?.action) {
let updatedDefaultValue = false;
if (this._value && serviceData) {
const loadDefaults = this.value && !("data" in this.value);
@ -367,7 +367,7 @@ export class HaServiceControl extends LitElement {
protected render() {
const serviceData = this._getServiceInfo(
this._value?.service,
this._value?.action,
this.hass.services
);
@ -392,11 +392,11 @@ export class HaServiceControl extends LitElement {
this._value
);
const domain = this._value?.service
? computeDomain(this._value.service)
const domain = this._value?.action
? computeDomain(this._value.action)
: undefined;
const serviceName = this._value?.service
? computeObjectId(this._value.service)
const serviceName = this._value?.action
? computeObjectId(this._value.action)
: undefined;
const description =
@ -410,7 +410,7 @@ export class HaServiceControl extends LitElement {
? nothing
: html`<ha-service-picker
.hass=${this.hass}
.value=${this._value?.service}
.value=${this._value?.action}
.disabled=${this.disabled}
@value-changed=${this._serviceChanged}
></ha-service-picker>`}
@ -596,11 +596,11 @@ export class HaServiceControl extends LitElement {
};
private _localizeValueCallback = (key: string) => {
if (!this._value?.service) {
if (!this._value?.action) {
return "";
}
return this.hass.localize(
`component.${computeDomain(this._value.service)}.selector.${key}`
`component.${computeDomain(this._value.action)}.selector.${key}`
);
};
@ -612,7 +612,7 @@ export class HaServiceControl extends LitElement {
if (checked) {
this._checkedKeys.add(key);
const field = this._getServiceInfo(
this._value?.service,
this._value?.action,
this.hass.services
)?.fields.find((_field) => _field.key === key);
@ -658,7 +658,7 @@ export class HaServiceControl extends LitElement {
private _serviceChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
if (ev.detail.value === this._value?.service) {
if (ev.detail.value === this._value?.action) {
return;
}

View File

@ -424,7 +424,7 @@ export class HatScriptGraph extends LitElement {
return html`
<hat-graph-node
.graphStart=${graphStart}
.iconPath=${node.service ? undefined : mdiRoomService}
.iconPath=${node.action ? undefined : mdiRoomService}
@focus=${this.selectNode(node, path)}
?track=${path in this.trace.trace}
?active=${this.selected === path}
@ -432,11 +432,11 @@ export class HatScriptGraph extends LitElement {
.error=${this.trace.trace[path]?.some((tr) => tr.error)}
tabindex=${this.trace && path in this.trace.trace ? "0" : "-1"}
>
${node.service
${node.action
? html`<ha-service-icon
slot="icon"
.hass=${this.hass}
.service=${node.service}
.service=${node.action}
></ha-service-icon>`
: nothing}
</hat-graph-node>

View File

@ -6,7 +6,7 @@ import { navigate } from "../common/navigate";
import { Context, HomeAssistant } from "../types";
import { BlueprintInput } from "./blueprint";
import { DeviceCondition, DeviceTrigger } from "./device_automation";
import { Action, MODES } from "./script";
import { Action, MODES, migrateAutomationAction } from "./script";
export const AUTOMATION_DEFAULT_MODE: (typeof MODES)[number] = "single";
export const AUTOMATION_DEFAULT_MAX = 10;
@ -28,7 +28,7 @@ export interface ManualAutomationConfig {
description?: string;
trigger: Trigger | Trigger[];
condition?: Condition | Condition[];
action: Action | Action[];
action?: Action | Action[];
mode?: (typeof MODES)[number];
max?: number;
max_exceeded?:
@ -357,7 +357,7 @@ export const normalizeAutomationConfig = <
>(
config: T
): T => {
// Normalize data: ensure trigger, action and condition are lists
// Normalize data: ensure triggers, actions and conditions are lists
// Happens when people copy paste their automations into the config
for (const key of ["trigger", "condition", "action"]) {
const value = config[key];
@ -365,6 +365,9 @@ export const normalizeAutomationConfig = <
config[key] = [value];
}
}
config.action = migrateAutomationAction(config.action || []);
return config;
};

View File

@ -49,7 +49,7 @@ const targetStruct = object({
export const serviceActionStruct: Describe<ServiceAction> = assign(
baseActionStruct,
object({
service: optional(string()),
action: optional(string()),
service_template: optional(string()),
entity_id: optional(string()),
target: optional(targetStruct),
@ -62,7 +62,7 @@ export const serviceActionStruct: Describe<ServiceAction> = assign(
const playMediaActionStruct: Describe<PlayMediaAction> = assign(
baseActionStruct,
object({
service: literal("media_player.play_media"),
action: literal("media_player.play_media"),
target: optional(object({ entity_id: optional(string()) })),
entity_id: optional(string()),
data: object({ media_content_id: string(), media_content_type: string() }),
@ -73,7 +73,7 @@ const playMediaActionStruct: Describe<PlayMediaAction> = assign(
const activateSceneActionStruct: Describe<ServiceSceneAction> = assign(
baseActionStruct,
object({
service: literal("scene.turn_on"),
action: literal("scene.turn_on"),
target: optional(object({ entity_id: optional(string()) })),
entity_id: optional(string()),
metadata: object(),
@ -132,7 +132,7 @@ export interface EventAction extends BaseAction {
}
export interface ServiceAction extends BaseAction {
service?: string;
action?: string;
service_template?: string;
entity_id?: string;
target?: HassServiceTarget;
@ -160,7 +160,7 @@ export interface DelayAction extends BaseAction {
}
export interface ServiceSceneAction extends BaseAction {
service: "scene.turn_on";
action: "scene.turn_on";
target?: { entity_id?: string };
entity_id?: string;
metadata: Record<string, unknown>;
@ -191,7 +191,7 @@ export interface WaitForTriggerAction extends BaseAction {
}
export interface PlayMediaAction extends BaseAction {
service: "media_player.play_media";
action: "media_player.play_media";
target?: { entity_id?: string };
entity_id?: string;
data: { media_content_id: string; media_content_type: string };
@ -404,7 +404,7 @@ export const getActionType = (action: Action): ActionType => {
if ("set_conversation_response" in action) {
return "set_conversation_response";
}
if ("service" in action) {
if ("action" in action) {
if ("metadata" in action) {
if (is(action, activateSceneActionStruct)) {
return "activate_scene";
@ -425,3 +425,60 @@ export const hasScriptFields = (
const fields = hass.services.script[computeObjectId(entityId)]?.fields;
return fields !== undefined && Object.keys(fields).length > 0;
};
export const migrateAutomationAction = (
action: Action | Action[]
): Action | Action[] => {
if (Array.isArray(action)) {
return action.map(migrateAutomationAction) as Action[];
}
if ("service" in action) {
if (!("action" in action)) {
action.action = action.service;
}
delete action.service;
}
if ("sequence" in action) {
for (const sequenceAction of (action as SequenceAction).sequence) {
migrateAutomationAction(sequenceAction);
}
}
const actionType = getActionType(action);
if (actionType === "parallel") {
const _action = action as ParallelAction;
migrateAutomationAction(_action.parallel);
}
if (actionType === "choose") {
const _action = action as ChooseAction;
if (Array.isArray(_action.choose)) {
for (const choice of _action.choose) {
migrateAutomationAction(choice.sequence);
}
} else if (_action.choose) {
migrateAutomationAction(_action.choose.sequence);
}
if (_action.default) {
migrateAutomationAction(_action.default);
}
}
if (actionType === "repeat") {
const _action = action as RepeatAction;
migrateAutomationAction(_action.repeat.sequence);
}
if (actionType === "if") {
const _action = action as IfAction;
migrateAutomationAction(_action.then);
if (_action.else) {
migrateAutomationAction(_action.else);
}
}
return action;
};

View File

@ -192,7 +192,7 @@ const tryDescribeAction = <T extends ActionType>(
if (
config.service_template ||
(config.service && isTemplate(config.service))
(config.action && isTemplate(config.action))
) {
return hass.localize(
targets.length
@ -204,8 +204,8 @@ const tryDescribeAction = <T extends ActionType>(
);
}
if (config.service) {
const [domain, serviceName] = config.service.split(".", 2);
if (config.action) {
const [domain, serviceName] = config.action.split(".", 2);
const service =
hass.localize(`component.${domain}.services.${serviceName}.name`) ||
hass.services[domain][serviceName]?.name;
@ -217,7 +217,7 @@ const tryDescribeAction = <T extends ActionType>(
: `${actionTranslationBaseKey}.service.description.service_name_no_targets`,
{
domain: domainToName(hass.localize, domain),
name: service || config.service,
name: service || config.action,
targets: formatListWithAnds(hass.locale, targets),
}
);
@ -230,7 +230,7 @@ const tryDescribeAction = <T extends ActionType>(
{
name: service
? `${domainToName(hass.localize, domain)}: ${service}`
: config.service,
: config.action,
targets: formatListWithAnds(hass.locale, targets),
}
);

View File

@ -148,7 +148,7 @@ class MoreInfoScript extends LitElement {
const newState = this.stateObj;
if (newState && (!oldState || oldState.entity_id !== newState.entity_id)) {
this._scriptData = { service: newState.entity_id, data: {} };
this._scriptData = { action: newState.entity_id, data: {} };
}
}

View File

@ -87,8 +87,8 @@ export const getType = (action: Action | undefined) => {
if (!action) {
return undefined;
}
if ("service" in action || "scene" in action) {
return getActionType(action) as "activate_scene" | "service" | "play_media";
if ("action" in action || "scene" in action) {
return getActionType(action) as "activate_scene" | "action" | "play_media";
}
if (["and", "or", "not"].some((key) => key in action)) {
return "condition" as const;
@ -214,12 +214,12 @@ export default class HaAutomationActionRow extends LitElement {
<ha-expansion-panel leftChevron>
<h3 slot="header">
${type === "service" &&
"service" in this.action &&
this.action.service
"action" in this.action &&
this.action.action
? html`<ha-service-icon
class="action-icon"
.hass=${this.hass}
.service=${this.action.service}
.service=${this.action.action}
></ha-service-icon>`
: html`<ha-svg-icon
class="action-icon"

View File

@ -19,7 +19,7 @@ import "../../../../components/ha-sortable";
import "../../../../components/ha-svg-icon";
import { getService, isService } from "../../../../data/action";
import type { AutomationClipboard } from "../../../../data/automation";
import { Action } from "../../../../data/script";
import { Action, migrateAutomationAction } from "../../../../data/script";
import { HomeAssistant, ItemPath } from "../../../../types";
import {
PASTE_VALUE,
@ -179,7 +179,7 @@ export default class HaAutomationAction extends LitElement {
actions = this.actions.concat(deepClone(this._clipboard!.action));
} else if (isService(action)) {
actions = this.actions.concat({
service: getService(action),
action: getService(action),
metadata: {},
});
} else {
@ -243,7 +243,10 @@ export default class HaAutomationAction extends LitElement {
private _actionChanged(ev: CustomEvent) {
ev.stopPropagation();
const actions = [...this.actions];
const newValue = ev.detail.value;
const newValue =
ev.detail.value === null
? ev.detail.value
: (migrateAutomationAction(ev.detail.value) as Action);
const index = (ev.target as any).index;
if (newValue === null) {

View File

@ -18,7 +18,7 @@ export class HaSceneAction extends LitElement implements ActionElement {
public static get defaultConfig(): SceneAction {
return {
service: "scene.turn_on",
action: "scene.turn_on",
target: {
entity_id: "",
},

View File

@ -20,7 +20,7 @@ export class HaPlayMediaAction extends LitElement implements ActionElement {
public static get defaultConfig(): PlayMediaAction {
return {
service: "media_player.play_media",
action: "media_player.play_media",
target: { entity_id: "" },
data: { media_content_id: "", media_content_type: "" },
metadata: {},

View File

@ -67,10 +67,7 @@ export class HaServiceAction extends LitElement implements ActionElement {
return;
}
const fields = this._fields(
this.hass.services,
this.action?.service
).fields;
const fields = this._fields(this.hass.services, this.action?.action).fields;
if (
this.action &&
(Object.entries(this.action).some(
@ -110,8 +107,8 @@ export class HaServiceAction extends LitElement implements ActionElement {
if (!this._action) {
return nothing;
}
const [domain, service] = this._action.service
? this._action.service.split(".", 2)
const [domain, service] = this._action.action
? this._action.action.split(".", 2)
: [undefined, undefined];
return html`
<ha-service-control
@ -168,8 +165,8 @@ export class HaServiceAction extends LitElement implements ActionElement {
}
const value = { ...this.action, ...ev.detail.value };
if ("response_variable" in this.action) {
const [domain, service] = this._action!.service
? this._action!.service.split(".", 2)
const [domain, service] = this._action!.action
? this._action!.action.split(".", 2)
: [undefined, undefined];
if (
domain &&
@ -181,6 +178,7 @@ export class HaServiceAction extends LitElement implements ActionElement {
this._responseChecked = false;
}
}
fireEvent(this, "value-changed", { value });
}

View File

@ -575,6 +575,7 @@ export class HaAutomationEditor extends KeyboardShortcutMixin(LitElement) {
private _valueChanged(ev: CustomEvent<{ value: AutomationConfig }>) {
ev.stopPropagation();
this._config = ev.detail.value;
if (this._readOnly) {
return;

View File

@ -25,7 +25,11 @@ import "../../../components/ha-service-picker";
import "../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../components/ha-yaml-editor";
import { forwardHaptic } from "../../../data/haptics";
import { Action, ServiceAction } from "../../../data/script";
import {
Action,
migrateAutomationAction,
ServiceAction,
} from "../../../data/script";
import {
callExecuteScript,
serviceCallWillDisconnect,
@ -49,14 +53,14 @@ class HaPanelDevAction extends LitElement {
private _yamlValid = true;
@storage({
key: "panel-dev-service-state-service-data",
key: "panel-dev-action-state-service-data",
state: true,
subscribe: false,
})
private _serviceData?: ServiceAction = { service: "", target: {}, data: {} };
private _serviceData?: ServiceAction = { action: "", target: {}, data: {} };
@storage({
key: "panel-dev-service-state-yaml-mode",
key: "panel-dev-action-state-yaml-mode",
state: true,
subscribe: false,
})
@ -72,7 +76,7 @@ class HaPanelDevAction extends LitElement {
const serviceParam = extractSearchParam("service");
if (serviceParam) {
this._serviceData = {
service: serviceParam,
action: serviceParam,
target: {},
data: {},
};
@ -81,11 +85,11 @@ class HaPanelDevAction extends LitElement {
this._yamlEditor?.setValue(this._serviceData)
);
}
} else if (!this._serviceData?.service) {
} else if (!this._serviceData?.action) {
const domain = Object.keys(this.hass.services).sort()[0];
const service = Object.keys(this.hass.services[domain]).sort()[0];
this._serviceData = {
service: `${domain}.${service}`,
action: `${domain}.${service}`,
target: {},
data: {},
};
@ -101,15 +105,15 @@ class HaPanelDevAction extends LitElement {
protected render() {
const { target, fields } = this._fields(
this.hass.services,
this._serviceData?.service
this._serviceData?.action
);
const domain = this._serviceData?.service
? computeDomain(this._serviceData?.service)
const domain = this._serviceData?.action
? computeDomain(this._serviceData?.action)
: undefined;
const serviceName = this._serviceData?.service
? computeObjectId(this._serviceData?.service)
const serviceName = this._serviceData?.action
? computeObjectId(this._serviceData?.action)
: undefined;
return html`
@ -124,7 +128,7 @@ class HaPanelDevAction extends LitElement {
? html`<div class="card-content">
<ha-service-picker
.hass=${this.hass}
.value=${this._serviceData?.service}
.value=${this._serviceData?.action}
@value-changed=${this._serviceChanged}
></ha-service-picker>
<ha-yaml-editor
@ -229,12 +233,12 @@ class HaPanelDevAction extends LitElement {
`
: ""}
</h3>
${this._serviceData?.service
${this._serviceData?.action
? html` <a
href=${documentationUrl(
this.hass,
"/integrations/" +
computeDomain(this._serviceData?.service)
computeDomain(this._serviceData?.action)
)}
title=${this.hass.localize(
"ui.components.service-control.integration_doc"
@ -316,23 +320,23 @@ class HaPanelDevAction extends LitElement {
);
private _validateServiceData = (
serviceData,
serviceData: ServiceAction | undefined,
fields,
target,
yamlMode: boolean,
localize: LocalizeFunc
): string | undefined => {
const errorCategory = yamlMode ? "yaml" : "ui";
if (!serviceData?.service) {
if (!serviceData?.action) {
return localize(
`ui.panel.developer-tools.tabs.actions.errors.${errorCategory}.no_service`
`ui.panel.developer-tools.tabs.actions.errors.${errorCategory}.no_action`
);
}
const domain = computeDomain(serviceData.service);
const service = computeObjectId(serviceData.service);
const domain = computeDomain(serviceData.action);
const service = computeObjectId(serviceData.action);
if (!domain || !service) {
return localize(
`ui.panel.developer-tools.tabs.actions.errors.${errorCategory}.invalid_service`
`ui.panel.developer-tools.tabs.actions.errors.${errorCategory}.invalid_action`
);
}
if (
@ -404,7 +408,7 @@ class HaPanelDevAction extends LitElement {
const { target, fields } = this._fields(
this.hass.services,
this._serviceData?.service
this._serviceData?.action
);
this._error = this._validateServiceData(
@ -420,7 +424,7 @@ class HaPanelDevAction extends LitElement {
button.actionError();
return;
}
const [domain, service] = this._serviceData!.service!.split(".", 2);
const [domain, service] = this._serviceData!.action!.split(".", 2);
const script: Action[] = [];
if (
this.hass.services?.[domain]?.[service] &&
@ -460,7 +464,7 @@ class HaPanelDevAction extends LitElement {
this._error =
localizedErrorMessage ||
this.hass.localize("ui.notification_toast.action_failed", {
service: this._serviceData!.service!,
service: this._serviceData!.action!,
}) + ` ${err.message}`;
return;
}
@ -485,7 +489,7 @@ class HaPanelDevAction extends LitElement {
private _checkUiSupported() {
const fields = this._fields(
this.hass.services,
this._serviceData?.service
this._serviceData?.action
).fields;
if (
this._serviceData &&
@ -512,16 +516,18 @@ class HaPanelDevAction extends LitElement {
}
private _serviceDataChanged(ev) {
if (this._serviceData?.service !== ev.detail.value.service) {
if (this._serviceData?.action !== ev.detail.value.action) {
this._error = undefined;
}
this._serviceData = ev.detail.value;
this._serviceData = migrateAutomationAction(
ev.detail.value
) as ServiceAction;
this._checkUiSupported();
}
private _serviceChanged(ev) {
ev.stopPropagation();
this._serviceData = { service: ev.detail.value || "", data: {} };
this._serviceData = { action: ev.detail.value || "", data: {} };
this._response = undefined;
this._error = undefined;
this._yamlEditor?.setValue(this._serviceData);
@ -531,14 +537,14 @@ class HaPanelDevAction extends LitElement {
private _fillExampleData() {
const { fields } = this._fields(
this.hass.services,
this._serviceData?.service
this._serviceData?.action
);
const domain = this._serviceData?.service
? computeDomain(this._serviceData?.service)
const domain = this._serviceData?.action
? computeDomain(this._serviceData?.action)
: undefined;
const serviceName = this._serviceData?.service
? computeObjectId(this._serviceData?.service)
const serviceName = this._serviceData?.action
? computeObjectId(this._serviceData?.action)
: undefined;
const example = {};

View File

@ -103,7 +103,7 @@ export class HuiActionEditor extends LitElement {
private _serviceAction = memoizeOne(
(config: CallServiceActionConfig): ServiceAction => ({
service: this._service,
action: this._service,
...(config.data || config.service_data
? { data: config.data ?? config.service_data }
: null),

View File

@ -6807,17 +6807,17 @@
"copy_clipboard_template": "Copy to clipboard (template)",
"errors": {
"ui": {
"no_service": "No action selected, please select an action",
"invalid_service": "Selected action is invalid, please select a valid action",
"no_action": "No action selected, please select an action",
"invalid_action": "Selected action is invalid, please select a valid action",
"no_target": "This action requires a target, please select a target from the picker",
"missing_required_field": "This action requires field {key}, please enter a valid value for {key}"
},
"yaml": {
"invalid_yaml": "Action YAML contains syntax errors, please fix the syntax",
"no_service": "No action defined, please define an action: key",
"invalid_service": "Defined action is invalid, please provide an action in the format domain.action",
"no_target": "This action requires a target, please define a target entity_id, device_id, or area_id under target: or data:",
"missing_required_field": "This action requires field {key}, which must be provided under data:"
"no_action": "No action defined, please define an 'action:' key",
"invalid_action": "Defined action is invalid, please provide an action in the format domain.action",
"no_target": "This action requires a target, please define a target 'entity_id', 'device_id', or 'area_id' under 'target:' or 'data:'",
"missing_required_field": "This action requires field {key}, which must be provided under 'data:'"
}
}
},