Files
frontend/src/data/automation.ts
Wendelin 8a1a22d4bd New design for automation add trigger/condition/action dialog (#27529)
* WIP new add automation element

* WIP new add dialog

* revert merge

* Add tabs

* fix height

* Add max-height

* Add keybindings and blocks search separation

* Fix device translation

* fix translations, scroll issues, RTL

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-10-24 05:53:19 +00:00

644 lines
15 KiB
TypeScript

import type {
HassEntityAttributeBase,
HassEntityBase,
} from "home-assistant-js-websocket";
import { ensureArray } from "../common/array/ensure-array";
import { navigate } from "../common/navigate";
import type { LocalizeKeys } from "../common/translations/localize";
import { createSearchParam } from "../common/url/search-params";
import type { Context, HomeAssistant } from "../types";
import type { BlueprintInput } from "./blueprint";
import { CONDITION_BUILDING_BLOCKS } from "./condition";
import type { DeviceCondition, DeviceTrigger } from "./device_automation";
import type { Action, Field, MODES } from "./script";
import { migrateAutomationAction } from "./script";
export const AUTOMATION_DEFAULT_MODE: (typeof MODES)[number] = "single";
export const AUTOMATION_DEFAULT_MAX = 10;
export interface AutomationEntity extends HassEntityBase {
attributes: HassEntityAttributeBase & {
id?: string;
last_triggered: string;
};
}
export type AutomationConfig =
| ManualAutomationConfig
| BlueprintAutomationConfig;
export interface ManualAutomationConfig {
id?: string;
alias?: string;
description?: string;
triggers: Trigger | Trigger[];
/** @deprecated Use `triggers` instead */
trigger?: Trigger | Trigger[];
conditions?: Condition | Condition[];
/** @deprecated Use `conditions` instead */
condition?: Condition | Condition[];
actions: Action | Action[];
/** @deprecated Use `actions` instead */
action?: Action | Action[];
mode?: (typeof MODES)[number];
max?: number;
max_exceeded?:
| "silent"
| "critical"
| "fatal"
| "error"
| "warning"
| "warn"
| "info"
| "debug"
| "notset";
variables?: Record<string, unknown>;
}
export interface BlueprintAutomationConfig extends ManualAutomationConfig {
use_blueprint: { path: string; input?: BlueprintInput };
}
export interface ForDict {
days?: number;
hours?: number;
minutes?: number;
seconds?: number;
milliseconds?: number;
}
export interface ContextConstraint {
context_id?: string;
parent_id?: string;
user_id?: string | string[];
}
export interface TriggerList {
triggers: Trigger | Trigger[] | undefined;
}
export interface BaseTrigger {
alias?: string;
/** @deprecated Use `trigger` instead */
platform?: string;
trigger: string;
id?: string;
variables?: Record<string, unknown>;
enabled?: boolean;
}
export interface StateTrigger extends BaseTrigger {
trigger: "state";
entity_id: string | string[];
attribute?: string;
from?: string | string[];
to?: string | string[];
for?: string | number | ForDict;
}
export interface MqttTrigger extends BaseTrigger {
trigger: "mqtt";
topic: string;
payload?: string;
}
export interface GeoLocationTrigger extends BaseTrigger {
trigger: "geo_location";
source: string;
zone: string;
event: "enter" | "leave";
}
export interface HassTrigger extends BaseTrigger {
trigger: "homeassistant";
event: "start" | "shutdown";
}
export interface NumericStateTrigger extends BaseTrigger {
trigger: "numeric_state";
entity_id: string | string[];
attribute?: string;
above?: number;
below?: number;
value_template?: string;
for?: string | number | ForDict;
}
export interface ConversationTrigger extends BaseTrigger {
trigger: "conversation";
command: string | string[];
}
export interface SunTrigger extends BaseTrigger {
trigger: "sun";
offset: number;
event: "sunrise" | "sunset";
}
export interface TimePatternTrigger extends BaseTrigger {
trigger: "time_pattern";
hours?: number | string;
minutes?: number | string;
seconds?: number | string;
}
export interface WebhookTrigger extends BaseTrigger {
trigger: "webhook";
webhook_id: string;
allowed_methods?: string[];
local_only?: boolean;
}
export interface PersistentNotificationTrigger extends BaseTrigger {
trigger: "persistent_notification";
notification_id?: string;
update_type?: string[];
}
export interface ZoneTrigger extends BaseTrigger {
trigger: "zone";
entity_id: string;
zone: string;
event: "enter" | "leave";
}
export interface TagTrigger extends BaseTrigger {
trigger: "tag";
tag_id: string;
device_id?: string;
}
export interface TimeTrigger extends BaseTrigger {
trigger: "time";
at: string | { entity_id: string; offset?: string };
weekday?: string | string[];
}
export interface TemplateTrigger extends BaseTrigger {
trigger: "template";
value_template: string;
for?: string | number | ForDict;
}
export interface EventTrigger extends BaseTrigger {
trigger: "event";
event_type: string;
event_data?: any;
context?: ContextConstraint;
}
export interface CalendarTrigger extends BaseTrigger {
trigger: "calendar";
event: "start" | "end";
entity_id: string;
offset: string;
}
export type Trigger =
| StateTrigger
| MqttTrigger
| GeoLocationTrigger
| HassTrigger
| NumericStateTrigger
| SunTrigger
| ConversationTrigger
| TimePatternTrigger
| WebhookTrigger
| PersistentNotificationTrigger
| ZoneTrigger
| TagTrigger
| TimeTrigger
| TemplateTrigger
| EventTrigger
| DeviceTrigger
| CalendarTrigger
| TriggerList;
interface BaseCondition {
condition: string;
alias?: string;
enabled?: boolean;
}
export interface LogicalCondition extends BaseCondition {
condition: "and" | "not" | "or";
conditions: Condition | Condition[];
}
export interface StateCondition extends BaseCondition {
condition: "state";
entity_id: string;
attribute?: string;
state: string | number | string[];
for?: string | number | ForDict;
match?: "all" | "any";
}
export interface NumericStateCondition extends BaseCondition {
condition: "numeric_state";
entity_id: string;
attribute?: string;
above?: string | number;
below?: string | number;
value_template?: string;
}
export interface SunCondition extends BaseCondition {
condition: "sun";
after_offset?: number;
before_offset?: number;
after?: "sunrise" | "sunset";
before?: "sunrise" | "sunset";
}
export interface ZoneCondition extends BaseCondition {
condition: "zone";
entity_id: string;
zone: string;
}
type Weekday = "sun" | "mon" | "tue" | "wed" | "thu" | "fri" | "sat";
export interface TimeCondition extends BaseCondition {
condition: "time";
after?: string;
before?: string;
weekday?: Weekday | Weekday[];
}
export interface TemplateCondition extends BaseCondition {
condition: "template";
value_template: string;
}
export interface TriggerCondition extends BaseCondition {
condition: "trigger";
id: string;
}
type ShorthandBaseCondition = Omit<BaseCondition, "condition">;
export interface ShorthandAndConditionList extends ShorthandBaseCondition {
condition: Condition[];
}
export interface ShorthandAndCondition extends ShorthandBaseCondition {
and: Condition[];
}
export interface ShorthandOrCondition extends ShorthandBaseCondition {
or: Condition[];
}
export interface ShorthandNotCondition extends ShorthandBaseCondition {
not: Condition[];
}
export interface AutomationElementGroupCollection {
titleKey?: LocalizeKeys;
groups: AutomationElementGroup;
}
export type AutomationElementGroup = Record<
string,
{ icon?: string; members?: AutomationElementGroup }
>;
export type Condition =
| StateCondition
| NumericStateCondition
| SunCondition
| ZoneCondition
| TimeCondition
| TemplateCondition
| DeviceCondition
| LogicalCondition
| TriggerCondition;
export type ConditionWithShorthand =
| Condition
| ShorthandAndConditionList
| ShorthandAndCondition
| ShorthandOrCondition
| ShorthandNotCondition;
export const expandConditionWithShorthand = (
cond: ConditionWithShorthand
): Condition => {
if ("condition" in cond && Array.isArray(cond.condition)) {
return {
condition: "and",
conditions: cond.condition,
};
}
for (const condition of CONDITION_BUILDING_BLOCKS) {
if (condition in cond) {
return {
condition,
conditions: cond[condition],
} as Condition;
}
}
return cond as Condition;
};
export const triggerAutomationActions = (
hass: HomeAssistant,
entityId: string
) => {
hass.callService("automation", "trigger", {
entity_id: entityId,
skip_condition: true,
});
};
export const deleteAutomation = (hass: HomeAssistant, id: string) =>
hass.callApi("DELETE", `config/automation/config/${id}`);
let initialAutomationEditorData: Partial<AutomationConfig> | undefined;
export const fetchAutomationFileConfig = (hass: HomeAssistant, id: string) =>
hass.callApi<AutomationConfig>("GET", `config/automation/config/${id}`);
export const getAutomationStateConfig = (
hass: HomeAssistant,
entity_id: string
) =>
hass.callWS<{ config: AutomationConfig }>({
type: "automation/config",
entity_id,
});
export const saveAutomationConfig = (
hass: HomeAssistant,
id: string,
config: AutomationConfig
) => hass.callApi<undefined>("POST", `config/automation/config/${id}`, config);
export const normalizeAutomationConfig = <
T extends Partial<AutomationConfig> | AutomationConfig,
>(
config: T
): T => {
config = migrateAutomationConfig(config);
// Normalize data: ensure triggers, actions and conditions are lists
// Happens when people copy paste their automations into the config
for (const key of ["triggers", "conditions", "actions"]) {
const value = config[key];
if (value && !Array.isArray(value)) {
config[key] = [value];
}
}
return config;
};
export const migrateAutomationConfig = <
T extends Partial<AutomationConfig> | AutomationConfig,
>(
config: T
) => {
if ("trigger" in config) {
if (!("triggers" in config)) {
config.triggers = config.trigger;
}
delete config.trigger;
}
if ("condition" in config) {
if (!("conditions" in config)) {
config.conditions = config.condition;
}
delete config.condition;
}
if ("action" in config) {
if (!("actions" in config)) {
config.actions = config.action;
}
delete config.action;
}
if (config.triggers) {
config.triggers = migrateAutomationTrigger(config.triggers);
}
if (config.actions) {
config.actions = migrateAutomationAction(config.actions);
}
return config;
};
export const migrateAutomationTrigger = (
trigger: Trigger | Trigger[]
): Trigger | Trigger[] => {
if (!trigger) {
return trigger;
}
if (Array.isArray(trigger)) {
return trigger.map(migrateAutomationTrigger) as Trigger[];
}
if ("triggers" in trigger && trigger.triggers) {
trigger.triggers = migrateAutomationTrigger(trigger.triggers);
}
if ("platform" in trigger) {
if (!("trigger" in trigger)) {
// @ts-ignore
trigger.trigger = trigger.platform;
}
delete trigger.platform;
}
return trigger;
};
export const flattenTriggers = (
triggers: undefined | Trigger | Trigger[]
): Trigger[] => {
if (!triggers) {
return [];
}
const flatTriggers: Trigger[] = [];
ensureArray(triggers).forEach((t) => {
if ("triggers" in t) {
if (t.triggers) {
flatTriggers.push(...flattenTriggers(t.triggers));
}
} else {
flatTriggers.push(t);
}
});
return flatTriggers;
};
export const showAutomationEditor = (
data?: Partial<AutomationConfig>,
expanded?: boolean
) => {
initialAutomationEditorData = data;
const params = expanded ? `?${createSearchParam({ expanded: "1" })}` : "";
navigate(`/config/automation/edit/new${params}`);
};
export const duplicateAutomation = (config: AutomationConfig) => {
showAutomationEditor({
...config,
id: undefined,
alias: undefined,
});
};
export const getAutomationEditorInitData = () => {
const data = initialAutomationEditorData;
initialAutomationEditorData = undefined;
return data;
};
export const isTrigger = (config: unknown): boolean => {
if (!config || typeof config !== "object") {
return false;
}
const trigger = config as Record<string, unknown>;
return (
("trigger" in trigger && typeof trigger.trigger === "string") ||
("platform" in trigger && typeof trigger.platform === "string")
);
};
export const isCondition = (config: unknown): boolean => {
if (!config || typeof config !== "object") {
return false;
}
const condition = config as Record<string, unknown>;
return "condition" in condition && typeof condition.condition === "string";
};
export const isScriptField = (config: unknown): boolean => {
if (!config || typeof config !== "object") {
return false;
}
const field = config as Record<string, unknown>;
return "field" in field && typeof field.field === "object";
};
export const subscribeTrigger = (
hass: HomeAssistant,
onChange: (result: {
variables: {
trigger: Record<string, unknown>;
};
context: Context;
}) => void,
trigger: Trigger | Trigger[],
variables?: Record<string, unknown>
) =>
hass.connection.subscribeMessage(onChange, {
type: "subscribe_trigger",
trigger,
variables,
});
export const testCondition = (
hass: HomeAssistant,
condition: Condition | Condition[],
variables?: Record<string, unknown>
) =>
hass.callWS<{ result: boolean }>({
type: "test_condition",
condition,
variables,
});
export interface AutomationClipboard {
trigger?: Trigger;
condition?: Condition;
action?: Action;
}
export interface BaseSidebarConfig {
delete: () => void;
close: (focus?: boolean) => void;
}
export interface TriggerSidebarConfig extends BaseSidebarConfig {
save: (value: Trigger) => void;
rename: () => void;
disable: () => void;
duplicate: () => void;
cut: () => void;
copy: () => void;
insertAfter: (value: Trigger | Trigger[]) => boolean;
toggleYamlMode: () => void;
config: Trigger;
yamlMode: boolean;
uiSupported: boolean;
}
export interface ConditionSidebarConfig extends BaseSidebarConfig {
save: (value: Condition) => void;
rename: () => void;
disable: () => void;
test: () => void;
duplicate: () => void;
cut: () => void;
copy: () => void;
insertAfter: (value: Condition | Condition[]) => boolean;
toggleYamlMode: () => void;
config: Condition;
yamlMode: boolean;
uiSupported: boolean;
}
export interface ActionSidebarConfig extends BaseSidebarConfig {
save: (value: Action) => void;
rename: () => void;
disable: () => void;
duplicate: () => void;
cut: () => void;
copy: () => void;
insertAfter: (value: Action | Action[]) => boolean;
run: () => void;
toggleYamlMode: () => void;
config: {
action: Action;
};
yamlMode: boolean;
uiSupported: boolean;
}
export interface OptionSidebarConfig extends BaseSidebarConfig {
rename: () => void;
duplicate: () => void;
defaultOption?: boolean;
}
export interface ScriptFieldSidebarConfig extends BaseSidebarConfig {
save: (value: Field) => void;
config: {
field: Field;
selector: boolean;
key: string;
excludeKeys: string[];
};
toggleYamlMode: () => void;
yamlMode: boolean;
}
export type SidebarConfig =
| TriggerSidebarConfig
| ConditionSidebarConfig
| ActionSidebarConfig
| OptionSidebarConfig
| ScriptFieldSidebarConfig;
export interface ShowAutomationEditorParams {
data?: Partial<AutomationConfig>;
expanded?: boolean;
}