20241002.2 (#22197)

This commit is contained in:
Bram Kragten 2024-10-02 16:43:14 +02:00 committed by GitHub
commit 693dbfd050
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 559 additions and 371 deletions

View File

@ -111,6 +111,16 @@ export const demoEntitiesSections: DemoConfig["entities"] = (localize) =>
friendly_name: "Living room Temperature", friendly_name: "Living room Temperature",
}, },
}, },
"sensor.living_room_humidity": {
entity_id: "sensor.living_room_humidity",
state: "57",
attributes: {
state_class: "measurement",
unit_of_measurement: "%",
device_class: "humidity",
friendly_name: "Living room Humidity",
},
},
"sensor.outdoor_temperature": { "sensor.outdoor_temperature": {
entity_id: "sensor.outdoor_temperature", entity_id: "sensor.outdoor_temperature",
state: "10.5", state: "10.5",
@ -189,6 +199,14 @@ export const demoEntitiesSections: DemoConfig["entities"] = (localize) =>
supported_features: 32, supported_features: 32,
}, },
}, },
"binary_sensor.kitchen_motion": {
entity_id: "light.kitchen_motion",
state: "on",
attributes: {
device_class: "motion",
friendly_name: "Kitchen motion",
},
},
"light.worktop_spotlights": { "light.worktop_spotlights": {
entity_id: "light.worktop_spotlights", entity_id: "light.worktop_spotlights",
state: "off", state: "off",
@ -423,6 +441,14 @@ export const demoEntitiesSections: DemoConfig["entities"] = (localize) =>
supported_features: 64063, supported_features: 64063,
}, },
}, },
"switch.in_meeting": {
entity_id: "switch.in_meeting",
state: "on",
attributes: {
icon: "mdi:laptop-account",
friendly_name: "In a meeting",
},
},
"sensor.standing_desk_height": { "sensor.standing_desk_height": {
entity_id: "sensor.standing_desk_height", entity_id: "sensor.standing_desk_height",
state: "72", state: "72",

View File

@ -30,12 +30,36 @@ export const demoLovelaceSections: DemoConfig["lovelace"] = (localize) => ({
? [] ? []
: [ : [
{ {
title: `${localize("ui.panel.page-demo.config.sections.titles.welcome")} 👋`, cards: [
cards: [{ type: "custom:ha-demo-card" }], {
type: "heading",
heading: `${localize("ui.panel.page-demo.config.sections.titles.welcome")} 👋`,
},
{ type: "custom:ha-demo-card" },
],
}, },
]), ]),
{ {
cards: [ cards: [
{
type: "heading",
heading: localize(
"ui.panel.page-demo.config.sections.titles.living_room"
),
icon: "mdi:sofa",
badges: [
{
type: "entity",
entity: "sensor.living_room_temperature",
color: "red",
},
{
type: "entity",
entity: "sensor.living_room_humidity",
color: "indigo",
},
],
},
{ {
type: "tile", type: "tile",
entity: "light.floor_lamp", entity: "light.floor_lamp",
@ -54,13 +78,6 @@ export const demoLovelaceSections: DemoConfig["lovelace"] = (localize) => ({
type: "tile", type: "tile",
entity: "light.bar_lamp", entity: "light.bar_lamp",
}, },
{
graph: "line",
type: "sensor",
entity: "sensor.living_room_temperature",
detail: 1,
name: "Temperature",
},
{ {
type: "tile", type: "tile",
entity: "cover.living_room_garden_shutter", entity: "cover.living_room_garden_shutter",
@ -71,11 +88,25 @@ export const demoLovelaceSections: DemoConfig["lovelace"] = (localize) => ({
entity: "media_player.living_room_nest_mini", entity: "media_player.living_room_nest_mini",
}, },
], ],
title: `🛋️ ${localize("ui.panel.page-demo.config.sections.titles.living_room")} `,
}, },
{ {
type: "grid", type: "grid",
cards: [ cards: [
{
type: "heading",
heading: localize(
"ui.panel.page-demo.config.sections.titles.kitchen"
),
icon: "mdi:fridge",
badges: [
{
type: "entity",
entity: "binary_sensor.kitchen_motion",
show_state: false,
color: "blue",
},
],
},
{ {
type: "tile", type: "tile",
entity: "cover.kitchen_shutter", entity: "cover.kitchen_shutter",
@ -106,11 +137,17 @@ export const demoLovelaceSections: DemoConfig["lovelace"] = (localize) => ({
entity: "media_player.kitchen_nest_audio", entity: "media_player.kitchen_nest_audio",
}, },
], ],
title: `👩‍🍳 ${localize("ui.panel.page-demo.config.sections.titles.kitchen")}`,
}, },
{ {
type: "grid", type: "grid",
cards: [ cards: [
{
type: "heading",
heading: localize(
"ui.panel.page-demo.config.sections.titles.energy"
),
icon: "mdi:transmission-tower",
},
{ {
type: "tile", type: "tile",
entity: "binary_sensor.tesla_wall_connector_vehicle_connected", entity: "binary_sensor.tesla_wall_connector_vehicle_connected",
@ -148,11 +185,17 @@ export const demoLovelaceSections: DemoConfig["lovelace"] = (localize) => ({
color: "dark-grey", color: "dark-grey",
}, },
], ],
title: `⚡️ ${localize("ui.panel.page-demo.config.sections.titles.energy")}`,
}, },
{ {
type: "grid", type: "grid",
cards: [ cards: [
{
type: "heading",
heading: localize(
"ui.panel.page-demo.config.sections.titles.climate"
),
icon: "mdi:thermometer",
},
{ {
type: "tile", type: "tile",
entity: "sun.sun", entity: "sun.sun",
@ -185,16 +228,38 @@ export const demoLovelaceSections: DemoConfig["lovelace"] = (localize) => ({
state_content: ["preset_mode", "current_temperature"], state_content: ["preset_mode", "current_temperature"],
}, },
], ],
title: `🌤️ ${localize("ui.panel.page-demo.config.sections.titles.climate")}`,
}, },
{ {
type: "grid", type: "grid",
cards: [ cards: [
{
type: "heading",
heading: localize(
"ui.panel.page-demo.config.sections.titles.study"
),
icon: "mdi:desk-lamp",
badges: [
{
type: "entity",
entity: "switch.in_meeting",
state: "on",
state_content: "name",
visibility: [
{
condition: "state",
state: "on",
entity: "switch.in_meeting",
},
],
},
],
},
{ {
type: "tile", type: "tile",
entity: "cover.study_shutter", entity: "cover.study_shutter",
name: "Shutter", name: "Shutter",
}, },
{ {
type: "tile", type: "tile",
entity: "light.study_spotlights", entity: "light.study_spotlights",
@ -211,12 +276,23 @@ export const demoLovelaceSections: DemoConfig["lovelace"] = (localize) => ({
color: "brown", color: "brown",
icon: "mdi:desk", icon: "mdi:desk",
}, },
{
type: "tile",
entity: "switch.in_meeting",
name: "Meeting mode",
},
], ],
title: `🧑‍💻 ${localize("ui.panel.page-demo.config.sections.titles.study")}`,
}, },
{ {
type: "grid", type: "grid",
cards: [ cards: [
{
type: "heading",
heading: localize(
"ui.panel.page-demo.config.sections.titles.outdoor"
),
icon: "mdi:tree",
},
{ {
type: "tile", type: "tile",
entity: "light.outdoor_light", entity: "light.outdoor_light",
@ -246,11 +322,17 @@ export const demoLovelaceSections: DemoConfig["lovelace"] = (localize) => ({
name: "Illuminance", name: "Illuminance",
}, },
], ],
title: `🌳 ${localize("ui.panel.page-demo.config.sections.titles.outdoor")}`,
}, },
{ {
type: "grid", type: "grid",
cards: [ cards: [
{
type: "heading",
heading: localize(
"ui.panel.page-demo.config.sections.titles.updates"
),
icon: "mdi:update",
},
{ {
type: "tile", type: "tile",
entity: "automation.home_assistant_auto_update", entity: "automation.home_assistant_auto_update",
@ -276,7 +358,6 @@ export const demoLovelaceSections: DemoConfig["lovelace"] = (localize) => ({
icon: "mdi:home-assistant", icon: "mdi:home-assistant",
}, },
], ],
title: `🎉 ${localize("ui.panel.page-demo.config.sections.titles.updates")}`,
}, },
], ],
}, },

View File

@ -13,10 +13,11 @@
<% for (const entry of es5EntryJS) { %> <% for (const entry of es5EntryJS) { %>
loadES5("<%= entry %>"); loadES5("<%= entry %>");
<% } %> <% } %>
}
} else { } else {
<% for (const entry of es5EntryJS) { %> <% for (const entry of es5EntryJS) { %>
loadES5("<%= entry %>"); loadES5("<%= entry %>");
<% } %> <% } %>
} }
}
})(); })();

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "home-assistant-frontend" name = "home-assistant-frontend"
version = "20241002.1" version = "20241002.2"
license = {text = "Apache-2.0"} license = {text = "Apache-2.0"}
description = "The Home Assistant frontend" description = "The Home Assistant frontend"
readme = "README.md" readme = "README.md"

View File

@ -8,6 +8,7 @@ import { Context, HomeAssistant } from "../types";
import { BlueprintInput } from "./blueprint"; import { BlueprintInput } from "./blueprint";
import { DeviceCondition, DeviceTrigger } from "./device_automation"; import { DeviceCondition, DeviceTrigger } from "./device_automation";
import { Action, MODES, migrateAutomationAction } from "./script"; import { Action, MODES, migrateAutomationAction } from "./script";
import { createSearchParam } from "../common/url/search-params";
export const AUTOMATION_DEFAULT_MODE: (typeof MODES)[number] = "single"; export const AUTOMATION_DEFAULT_MODE: (typeof MODES)[number] = "single";
export const AUTOMATION_DEFAULT_MAX = 10; export const AUTOMATION_DEFAULT_MAX = 10;
@ -462,9 +463,13 @@ export const flattenTriggers = (
return flatTriggers; return flatTriggers;
}; };
export const showAutomationEditor = (data?: Partial<AutomationConfig>) => { export const showAutomationEditor = (
data?: Partial<AutomationConfig>,
expanded?: boolean
) => {
initialAutomationEditorData = data; initialAutomationEditorData = data;
navigate("/config/automation/edit/new"); const params = expanded ? `?${createSearchParam({ expanded: "1" })}` : "";
navigate(`/config/automation/edit/new${params}`);
}; };
export const duplicateAutomation = (config: AutomationConfig) => { export const duplicateAutomation = (config: AutomationConfig) => {

View File

@ -28,6 +28,7 @@ import {
} from "./automation"; } from "./automation";
import { BlueprintInput } from "./blueprint"; import { BlueprintInput } from "./blueprint";
import { computeObjectId } from "../common/entity/compute_object_id"; import { computeObjectId } from "../common/entity/compute_object_id";
import { createSearchParam } from "../common/url/search-params";
export const MODES = ["single", "restart", "queued", "parallel"] as const; export const MODES = ["single", "restart", "queued", "parallel"] as const;
export const MODES_MAX = ["queued", "parallel"] as const; export const MODES_MAX = ["queued", "parallel"] as const;
@ -347,9 +348,13 @@ export const getScriptStateConfig = (hass: HomeAssistant, entity_id: string) =>
entity_id, entity_id,
}); });
export const showScriptEditor = (data?: Partial<ScriptConfig>) => { export const showScriptEditor = (
data?: Partial<ScriptConfig>,
expanded?: boolean
) => {
inititialScriptEditorData = data; inititialScriptEditorData = data;
navigate("/config/script/edit/new"); const params = expanded ? `?${createSearchParam({ expanded: "1" })}` : "";
navigate(`/config/script/edit/new${params}`);
}; };
export const getScriptEditorInitData = () => { export const getScriptEditorInitData = () => {

View File

@ -18,7 +18,7 @@ export interface ThreadDataSet {
channel: number | null; channel: number | null;
created: string; created: string;
dataset_id: string; dataset_id: string;
extended_pan_id: string | null; extended_pan_id: string;
network_name: string; network_name: string;
pan_id: string | null; pan_id: string | null;
preferred_border_agent_id: string | null; preferred_border_agent_id: string | null;

View File

@ -18,6 +18,7 @@ import {
updateReleaseNotes, updateReleaseNotes,
} from "../../../data/update"; } from "../../../data/update";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import { showAlertDialog } from "../../generic/show-dialog-box";
@customElement("more-info-update") @customElement("more-info-update")
class MoreInfoUpdate extends LitElement { class MoreInfoUpdate extends LitElement {
@ -127,29 +128,27 @@ class MoreInfoUpdate extends LitElement {
</ha-formfield> ` </ha-formfield> `
: ""} : ""}
<div class="actions"> <div class="actions">
${this.stateObj.attributes.auto_update ${this.stateObj.state === BINARY_STATE_OFF &&
? "" this.stateObj.attributes.skipped_version
: this.stateObj.state === BINARY_STATE_OFF && ? html`
this.stateObj.attributes.skipped_version <mwc-button @click=${this._handleClearSkipped}>
? html` ${this.hass.localize(
<mwc-button @click=${this._handleClearSkipped}> "ui.dialogs.more_info_control.update.clear_skipped"
${this.hass.localize( )}
"ui.dialogs.more_info_control.update.clear_skipped" </mwc-button>
)} `
</mwc-button> : html`
` <mwc-button
: html` @click=${this._handleSkip}
<mwc-button .disabled=${skippedVersion ||
@click=${this._handleSkip} this.stateObj.state === BINARY_STATE_OFF ||
.disabled=${skippedVersion || updateIsInstalling(this.stateObj)}
this.stateObj.state === BINARY_STATE_OFF || >
updateIsInstalling(this.stateObj)} ${this.hass.localize(
> "ui.dialogs.more_info_control.update.skip"
${this.hass.localize( )}
"ui.dialogs.more_info_control.update.skip" </mwc-button>
)} `}
</mwc-button>
`}
${supportsFeature(this.stateObj, UpdateEntityFeature.INSTALL) ${supportsFeature(this.stateObj, UpdateEntityFeature.INSTALL)
? html` ? html`
<mwc-button <mwc-button
@ -211,6 +210,17 @@ class MoreInfoUpdate extends LitElement {
} }
private _handleSkip(): void { private _handleSkip(): void {
if (this.stateObj!.attributes.auto_update) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.dialogs.more_info_control.update.auto_update_enabled_title"
),
text: this.hass.localize(
"ui.dialogs.more_info_control.update.auto_update_enabled_text"
),
});
return;
}
this.hass.callService("update", "skip", { this.hass.callService("update", "skip", {
entity_id: this.stateObj!.entity_id, entity_id: this.stateObj!.entity_id,
}); });

View File

@ -2,7 +2,7 @@ import { css, html, LitElement, nothing, PropertyValues } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-circular-progress"; import "../../components/ha-circular-progress";
import { OFF, ON, UNAVAILABLE } from "../../data/entity"; import { OFF, ON, UNAVAILABLE, UNKNOWN } from "../../data/entity";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
import { AssistantSetupStyles } from "./styles"; import { AssistantSetupStyles } from "./styles";
@ -32,10 +32,11 @@ export class HaVoiceAssistantSetupStepUpdate extends LitElement {
if ( if (
(oldState?.state === UNAVAILABLE && (oldState?.state === UNAVAILABLE &&
newState?.state !== UNAVAILABLE) || newState?.state !== UNAVAILABLE) ||
(oldState?.state === OFF && newState?.state === ON) (oldState?.state !== ON && newState?.state === ON)
) { ) {
// Device is rebooted, let's move on // Device is rebooted, let's move on
this._tryUpdate(false); this._tryUpdate(false);
return;
} }
} }
} }
@ -58,7 +59,7 @@ export class HaVoiceAssistantSetupStepUpdate extends LitElement {
return html`<div class="content"> return html`<div class="content">
<img src="/static/icons/casita/loading.png" /> <img src="/static/icons/casita/loading.png" />
<h1> <h1>
${stateObj.state === OFF ${stateObj.state === OFF || stateObj.state === UNKNOWN
? "Checking for updates" ? "Checking for updates"
: "Updating your voice assistant"} : "Updating your voice assistant"}
</h1> </h1>
@ -88,10 +89,7 @@ export class HaVoiceAssistantSetupStepUpdate extends LitElement {
return; return;
} }
const updateEntity = this.hass.states[this.updateEntityId]; const updateEntity = this.hass.states[this.updateEntityId];
if ( if (updateEntity && this.hass.states[updateEntity.entity_id].state === ON) {
updateEntity &&
this.hass.states[updateEntity.entity_id].state === "on"
) {
this._updated = true; this._updated = true;
await this.hass.callService( await this.hass.callService(
"update", "update",

View File

@ -141,9 +141,10 @@ interface EMOutgoingMessageImprovScan extends EMMessage {
interface EMOutgoingMessageThreadStoreInPlatformKeychain extends EMMessage { interface EMOutgoingMessageThreadStoreInPlatformKeychain extends EMMessage {
type: "thread/store_in_platform_keychain"; type: "thread/store_in_platform_keychain";
payload: { payload: {
mac_extended_address: string; mac_extended_address: string | null;
border_agent_id: string; border_agent_id: string | null;
active_operational_dataset: string; active_operational_dataset: string;
extended_pan_id: string;
}; };
} }

View File

@ -156,6 +156,15 @@ export default class HaAutomationAction extends LitElement {
} }
} }
public expandAll() {
const rows = this.shadowRoot!.querySelectorAll<HaAutomationActionRow>(
"ha-automation-action-row"
)!;
rows.forEach((row) => {
row.expand();
});
}
private _addActionDialog() { private _addActionDialog() {
showAddAutomationElementDialog(this, { showAddAutomationElementDialog(this, {
type: "action", type: "action",

View File

@ -106,6 +106,15 @@ export default class HaAutomationCondition extends LitElement {
} }
} }
public expandAll() {
const rows = this.shadowRoot!.querySelectorAll<HaAutomationConditionRow>(
"ha-automation-condition-row"
)!;
rows.forEach((row) => {
row.expand();
});
}
private get nested() { private get nested() {
return this.path !== undefined; return this.path !== undefined;
} }

View File

@ -1,7 +1,14 @@
import "@material/mwc-button/mwc-button"; import "@material/mwc-button/mwc-button";
import { mdiHelpCircle } from "@mdi/js"; import { mdiHelpCircle } from "@mdi/js";
import { HassEntity } from "home-assistant-js-websocket"; import { HassEntity } from "home-assistant-js-websocket";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; import {
css,
CSSResultGroup,
html,
LitElement,
nothing,
PropertyValues,
} from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import { ensureArray } from "../../../common/array/ensure-array"; import { ensureArray } from "../../../common/array/ensure-array";
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
@ -21,6 +28,14 @@ import { documentationUrl } from "../../../util/documentation-url";
import "./action/ha-automation-action"; import "./action/ha-automation-action";
import "./condition/ha-automation-condition"; import "./condition/ha-automation-condition";
import "./trigger/ha-automation-trigger"; import "./trigger/ha-automation-trigger";
import type HaAutomationTrigger from "./trigger/ha-automation-trigger";
import type HaAutomationAction from "./action/ha-automation-action";
import type HaAutomationCondition from "./condition/ha-automation-condition";
import {
extractSearchParam,
removeSearchParam,
} from "../../../common/url/search-params";
import { constructUrlCurrentPath } from "../../../common/url/construct-url";
@customElement("manual-automation-editor") @customElement("manual-automation-editor")
export class HaManualAutomationEditor extends LitElement { export class HaManualAutomationEditor extends LitElement {
@ -36,6 +51,31 @@ export class HaManualAutomationEditor extends LitElement {
@property({ attribute: false }) public stateObj?: HassEntity; @property({ attribute: false }) public stateObj?: HassEntity;
protected firstUpdated(changedProps: PropertyValues): void {
super.firstUpdated(changedProps);
const expanded = extractSearchParam("expanded");
if (expanded === "1") {
this._clearParam("expanded");
const items = this.shadowRoot!.querySelectorAll<
HaAutomationTrigger | HaAutomationCondition | HaAutomationAction
>("ha-automation-trigger, ha-automation-condition, ha-automation-action");
items.forEach((el) => {
el.updateComplete.then(() => {
el.expandAll();
});
});
}
}
private _clearParam(param: string) {
window.history.replaceState(
null,
"",
constructUrlCurrentPath(removeSearchParam(param))
);
}
protected render() { protected render() {
return html` return html`
${this.stateObj?.state === "off" ${this.stateObj?.state === "off"

View File

@ -179,6 +179,15 @@ export default class HaAutomationTrigger extends LitElement {
} }
} }
public expandAll() {
const rows = this.shadowRoot!.querySelectorAll<HaAutomationTriggerRow>(
"ha-automation-trigger-row"
)!;
rows.forEach((row) => {
row.expand();
});
}
private _getKey(action: Trigger) { private _getKey(action: Trigger) {
if (!this._triggerKeys.has(action)) { if (!this._triggerKeys.has(action)) {
this._triggerKeys.set(action, Math.random().toString()); this._triggerKeys.set(action, Math.random().toString());

View File

@ -1,23 +0,0 @@
import { customElement } from "lit/decorators";
import {
DeviceAction,
localizeDeviceAutomationAction,
} from "../../../../data/device_automation";
import { HaDeviceAutomationCard } from "./ha-device-automation-card";
@customElement("ha-device-actions-card")
export class HaDeviceActionsCard extends HaDeviceAutomationCard<DeviceAction> {
readonly type = "action";
readonly headerKey = "ui.panel.config.devices.automation.actions.caption";
constructor() {
super(localizeDeviceAutomationAction);
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-device-actions-card": HaDeviceActionsCard;
}
}

View File

@ -1,142 +0,0 @@
import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/chips/ha-assist-chip";
import "../../../../components/chips/ha-chip-set";
import { showAutomationEditor } from "../../../../data/automation";
import {
DeviceAction,
DeviceAutomation,
} from "../../../../data/device_automation";
import { EntityRegistryEntry } from "../../../../data/entity_registry";
import { showScriptEditor } from "../../../../data/script";
import { buttonLinkStyle } from "../../../../resources/styles";
import { HomeAssistant } from "../../../../types";
declare global {
interface HASSDomEvents {
"entry-selected": undefined;
}
}
export abstract class HaDeviceAutomationCard<
T extends DeviceAutomation,
> extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public deviceId?: string;
@property({ type: Boolean }) public script = false;
@property({ attribute: false }) public automations: T[] = [];
@property({ attribute: false }) entityReg?: EntityRegistryEntry[];
@state() public _showSecondary = false;
abstract headerKey: Parameters<typeof this.hass.localize>[0];
abstract type: "action" | "condition" | "trigger";
private _localizeDeviceAutomation: (
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
automation: T
) => string;
constructor(
localizeDeviceAutomation: HaDeviceAutomationCard<T>["_localizeDeviceAutomation"]
) {
super();
this._localizeDeviceAutomation = localizeDeviceAutomation;
}
protected shouldUpdate(changedProps): boolean {
if (changedProps.has("deviceId") || changedProps.has("automations")) {
return true;
}
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || oldHass.language !== this.hass.language) {
return true;
}
return false;
}
protected render() {
if (this.automations.length === 0 || !this.entityReg) {
return nothing;
}
const automations = this._showSecondary
? this.automations
: this.automations.filter(
(automation) => automation.metadata?.secondary === false
);
return html`
<h3>${this.hass.localize(this.headerKey)}</h3>
<div class="content">
<ha-chip-set>
${automations.map(
(automation, idx) => html`
<ha-assist-chip
filled
.index=${idx}
@click=${this._handleAutomationClicked}
class=${automation.metadata?.secondary ? "secondary" : ""}
.label=${this._localizeDeviceAutomation(
this.hass,
this.entityReg!,
automation
)}
>
</ha-assist-chip>
`
)}
</ha-chip-set>
${!this._showSecondary && automations.length < this.automations.length
? html`<button class="link" @click=${this._toggleSecondary}>
Show ${this.automations.length - automations.length} more...
</button>`
: ""}
</div>
`;
}
private _toggleSecondary() {
this._showSecondary = !this._showSecondary;
}
private _handleAutomationClicked(ev: CustomEvent) {
const automation = { ...this.automations[(ev.currentTarget as any).index] };
if (!automation) {
return;
}
delete automation.metadata;
if (this.script) {
showScriptEditor({ sequence: [automation as DeviceAction] });
fireEvent(this, "entry-selected");
return;
}
const data = {};
data[this.type] = [automation];
showAutomationEditor(data);
fireEvent(this, "entry-selected");
}
static styles = [
buttonLinkStyle,
css`
h3 {
color: var(--primary-text-color);
}
.secondary {
--ha-assist-chip-filled-container-color: rgba(
var(--rgb-primary-text-color),
0.07
);
}
button.link {
color: var(--primary-color);
}
`,
];
}

View File

@ -1,8 +1,18 @@
import "@material/mwc-button/mwc-button"; import {
import { CSSResultGroup, html, LitElement, nothing } from "lit"; mdiAbTesting,
mdiGestureTap,
mdiPencilOutline,
mdiRoomService,
} from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event"; import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-dialog"; import { shouldHandleRequestSelectedEvent } from "../../../../common/mwc/handle-request-selected-event";
import { createCloseHeading } from "../../../../components/ha-dialog";
import {
AutomationConfig,
showAutomationEditor,
} from "../../../../data/automation";
import { import {
DeviceAction, DeviceAction,
DeviceCondition, DeviceCondition,
@ -12,11 +22,9 @@ import {
fetchDeviceTriggers, fetchDeviceTriggers,
sortDeviceAutomations, sortDeviceAutomations,
} from "../../../../data/device_automation"; } from "../../../../data/device_automation";
import { haStyleDialog } from "../../../../resources/styles"; import { ScriptConfig, showScriptEditor } from "../../../../data/script";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import { HomeAssistant } from "../../../../types"; import { HomeAssistant } from "../../../../types";
import "./ha-device-actions-card";
import "./ha-device-conditions-card";
import "./ha-device-triggers-card";
import { DeviceAutomationDialogParams } from "./show-dialog-device-automation"; import { DeviceAutomationDialogParams } from "./show-dialog-device-automation";
@customElement("dialog-device-automation") @customElement("dialog-device-automation")
@ -77,75 +85,184 @@ export class DialogDeviceAutomation extends LitElement {
}); });
} }
private _handleRowClick = (ev) => {
if (!shouldHandleRequestSelectedEvent(ev) || !this._params) {
return;
}
const type = (ev.currentTarget as any).type;
const isScript = this._params.script;
this.closeDialog();
if (isScript) {
const newScript = {} as ScriptConfig;
if (type === "action") {
newScript.sequence = [this._actions[0]];
}
showScriptEditor(newScript, true);
} else {
const newAutomation = {} as AutomationConfig;
if (type === "trigger") {
newAutomation.triggers = [this._triggers[0]];
}
if (type === "condition") {
newAutomation.conditions = [this._conditions[0]];
}
if (type === "action") {
newAutomation.actions = [this._actions[0]];
}
showAutomationEditor(newAutomation, true);
}
};
protected render() { protected render() {
if (!this._params) { if (!this._params) {
return nothing; return nothing;
} }
const mode = this._params.script ? "script" : "automation";
const title = this.hass.localize(`ui.panel.config.devices.${mode}.create`, {
type: this.hass.localize(
`ui.panel.config.devices.type.${
this._params.device.entry_type || "device"
}`
),
});
return html` return html`
<ha-dialog <ha-dialog
open open
hideActions
@closed=${this.closeDialog} @closed=${this.closeDialog}
.heading=${this.hass.localize( .heading=${createCloseHeading(this.hass, title)}
`ui.panel.config.devices.${
this._params.script ? "script" : "automation"
}.create`,
{
type: this.hass.localize(
`ui.panel.config.devices.type.${
this._params.device.entry_type || "device"
}`
),
}
)}
> >
<div @entry-selected=${this.closeDialog}> <mwc-list
innerRole="listbox"
itemRoles="option"
innerAriaLabel="Create new automation"
rootTabbable
dialogInitialFocus
>
${this._triggers.length
? html`
<ha-list-item
hasmeta
twoline
graphic="icon"
.type=${"trigger"}
@request-selected=${this._handleRowClick}
>
<ha-svg-icon
slot="graphic"
.path=${mdiGestureTap}
></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.devices.automation.triggers.title`
)}
<span slot="secondary">
${this.hass.localize(
`ui.panel.config.devices.automation.triggers.description`
)}
</span>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
`
: nothing}
${this._conditions.length
? html`
<ha-list-item
hasmeta
twoline
graphic="icon"
.type=${"condition"}
@request-selected=${this._handleRowClick}
>
<ha-svg-icon
slot="graphic"
.path=${mdiAbTesting}
></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.devices.automation.conditions.title`
)}
<span slot="secondary">
${this.hass.localize(
`ui.panel.config.devices.automation.conditions.description`
)}
</span>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
`
: nothing}
${this._actions.length
? html`
<ha-list-item
hasmeta
twoline
graphic="icon"
.type=${"action"}
@request-selected=${this._handleRowClick}
>
<ha-svg-icon
slot="graphic"
.path=${mdiRoomService}
></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.devices.${mode}.actions.title`
)}
<span slot="secondary">
${this.hass.localize(
`ui.panel.config.devices.${mode}.actions.description`
)}
</span>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
`
: nothing}
${this._triggers.length || ${this._triggers.length ||
this._conditions.length || this._conditions.length ||
this._actions.length this._actions.length
? html` ? html`<li divider role="separator"></li>`
${this._triggers.length : nothing}
? html` <ha-list-item
<ha-device-triggers-card hasmeta
.hass=${this.hass} twoline
.automations=${this._triggers} graphic="icon"
.entityReg=${this._params.entityReg} @request-selected=${this._handleRowClick}
></ha-device-triggers-card> >
` <ha-svg-icon slot="graphic" .path=${mdiPencilOutline}></ha-svg-icon>
: ""} ${this.hass.localize(`ui.panel.config.devices.${mode}.new.title`)}
${this._conditions.length <span slot="secondary">
? html` ${this.hass.localize(
<ha-device-conditions-card `ui.panel.config.devices.${mode}.new.description`
.hass=${this.hass}
.automations=${this._conditions}
.entityReg=${this._params.entityReg}
></ha-device-conditions-card>
`
: ""}
${this._actions.length
? html`
<ha-device-actions-card
.hass=${this.hass}
.automations=${this._actions}
.script=${this._params.script}
.entityReg=${this._params.entityReg}
></ha-device-actions-card>
`
: ""}
`
: this.hass.localize(
"ui.panel.config.devices.automation.no_device_automations"
)} )}
</div> </span>
<mwc-button slot="primaryAction" @click=${this.closeDialog}> <ha-icon-next slot="meta"></ha-icon-next>
${this.hass.localize("ui.common.close")} </ha-list-item>
</mwc-button> </mwc-list>
</ha-dialog> </ha-dialog>
`; `;
} }
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return haStyleDialog; return [
haStyle,
haStyleDialog,
css`
ha-dialog {
--dialog-content-padding: 0;
--mdc-dialog-max-height: 60vh;
}
@media all and (min-width: 550px) {
ha-dialog {
--mdc-dialog-min-width: 500px;
}
}
ha-icon-next {
width: 24px;
}
`,
];
} }
} }

View File

@ -1,23 +0,0 @@
import { customElement } from "lit/decorators";
import {
DeviceCondition,
localizeDeviceAutomationCondition,
} from "../../../../data/device_automation";
import { HaDeviceAutomationCard } from "./ha-device-automation-card";
@customElement("ha-device-conditions-card")
export class HaDeviceConditionsCard extends HaDeviceAutomationCard<DeviceCondition> {
readonly type = "condition";
readonly headerKey = "ui.panel.config.devices.automation.conditions.caption";
constructor() {
super(localizeDeviceAutomationCondition);
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-device-conditions-card": HaDeviceConditionsCard;
}
}

View File

@ -1,23 +0,0 @@
import { customElement } from "lit/decorators";
import {
DeviceTrigger,
localizeDeviceAutomationTrigger,
} from "../../../../data/device_automation";
import { HaDeviceAutomationCard } from "./ha-device-automation-card";
@customElement("ha-device-triggers-card")
export class HaDeviceTriggersCard extends HaDeviceAutomationCard<DeviceTrigger> {
readonly type = "trigger";
readonly headerKey = "ui.panel.config.devices.automation.triggers.caption";
constructor() {
super(localizeDeviceAutomationTrigger);
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-device-triggers-card": HaDeviceTriggersCard;
}
}

View File

@ -37,6 +37,7 @@ import {
ThreadDataSet, ThreadDataSet,
ThreadRouter, ThreadRouter,
addThreadDataSet, addThreadDataSet,
getThreadDataSetTLV,
listThreadDataSets, listThreadDataSets,
removeThreadDataSet, removeThreadDataSet,
setPreferredBorderAgent, setPreferredBorderAgent,
@ -168,8 +169,7 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) {
(otbr) => otbr.extended_pan_id === network.dataset!.extended_pan_id (otbr) => otbr.extended_pan_id === network.dataset!.extended_pan_id
)); ));
const canImportKeychain = const canImportKeychain =
this.hass.auth.external?.config.canTransferThreadCredentialsToKeychain && this.hass.auth.external?.config.canTransferThreadCredentialsToKeychain;
otbrForNetwork;
return html`<ha-card> return html`<ha-card>
<div class="card-header"> <div class="card-header">
@ -208,8 +208,12 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) {
${network.routers.map((router) => { ${network.routers.map((router) => {
const otbr = const otbr =
this._otbrInfo && this._otbrInfo[router.extended_address]; this._otbrInfo && this._otbrInfo[router.extended_address];
const showOverflow = const showDefaultRouter = !!network.dataset;
("dataset" in network && router.border_agent_id) || otbr; const isDefaultRouter =
showDefaultRouter &&
router.extended_address ===
network.dataset!.preferred_extended_address;
const showOverflow = showDefaultRouter || otbr;
return html`<ha-list-item return html`<ha-list-item
class="router" class="router"
twoline twoline
@ -235,9 +239,7 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) {
""} ""}
<span slot="secondary">${router.server}</span> <span slot="secondary">${router.server}</span>
${showOverflow ${showOverflow
? html`${network.dataset && ? html`${isDefaultRouter
router.extended_address ===
network.dataset.preferred_extended_address
? html`<ha-svg-icon ? html`<ha-svg-icon
.path=${mdiCellphoneKey} .path=${mdiCellphoneKey}
.title=${this.hass.localize( .title=${this.hass.localize(
@ -259,13 +261,9 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) {
.path=${mdiDotsVertical} .path=${mdiDotsVertical}
slot="trigger" slot="trigger"
></ha-icon-button> ></ha-icon-button>
${network.dataset && router.border_agent_id ${showDefaultRouter
? html`<ha-list-item ? html`<ha-list-item .disabled=${isDefaultRouter}>
.disabled=${router.border_agent_id === ${isDefaultRouter
network.dataset.preferred_border_agent_id}
>
${router.border_agent_id ===
network.dataset.preferred_border_agent_id
? this.hass.localize( ? this.hass.localize(
"ui.panel.config.thread.default_router" "ui.panel.config.thread.default_router"
) )
@ -321,9 +319,13 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) {
> >
</div>` </div>`
: ""} : ""}
${canImportKeychain ${canImportKeychain &&
network.dataset?.preferred &&
network.routers?.length
? html`<div class="card-actions"> ? html`<div class="card-actions">
<mwc-button .otbr=${otbrForNetwork} @click=${this._sendCredentials} <mwc-button
.networkDataset=${network.dataset}
@click=${this._sendCredentials}
>Send credentials to phone</mwc-button >Send credentials to phone</mwc-button
> >
</div>` </div>`
@ -331,17 +333,30 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) {
</ha-card>`; </ha-card>`;
} }
private _sendCredentials(ev) { private async _sendCredentials(ev) {
const otbr = (ev.currentTarget as any).otbr as OTBRInfo; const dataset = (ev.currentTarget as any).networkDataset as ThreadDataSet;
if (!otbr) { if (!dataset) {
return;
}
if (
!dataset.preferred_extended_address &&
!dataset.preferred_border_agent_id
) {
showAlertDialog(this, {
title: "Error",
text: this.hass.localize("ui.panel.config.thread.no_preferred_router"),
});
return; return;
} }
this.hass.auth.external!.fireMessage({ this.hass.auth.external!.fireMessage({
type: "thread/store_in_platform_keychain", type: "thread/store_in_platform_keychain",
payload: { payload: {
mac_extended_address: otbr.extended_address, mac_extended_address: dataset.preferred_extended_address,
border_agent_id: otbr.border_agent_id, border_agent_id: dataset.preferred_border_agent_id,
active_operational_dataset: otbr.active_dataset_tlvs, active_operational_dataset: (
await getThreadDataSetTLV(this.hass, dataset.dataset_id)
).tlv,
extended_pan_id: dataset.extended_pan_id,
}, },
}); });
} }
@ -467,10 +482,7 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) {
const network = (ev.currentTarget as any).network as ThreadNetwork; const network = (ev.currentTarget as any).network as ThreadNetwork;
const router = (ev.currentTarget as any).router as ThreadRouter; const router = (ev.currentTarget as any).router as ThreadRouter;
const otbr = (ev.currentTarget as any).otbr as OTBRInfo; const otbr = (ev.currentTarget as any).otbr as OTBRInfo;
const index = const index = Number(ev.detail.index);
network.dataset && router.border_agent_id
? Number(ev.detail.index)
: Number(ev.detail.index) + 1;
switch (index) { switch (index) {
case 0: case 0:
this._setPreferredBorderAgent(network.dataset!, router); this._setPreferredBorderAgent(network.dataset!, router);

View File

@ -1,8 +1,20 @@
import "@material/mwc-button/mwc-button"; import "@material/mwc-button/mwc-button";
import { mdiHelpCircle } from "@mdi/js"; import { mdiHelpCircle } from "@mdi/js";
import { CSSResultGroup, LitElement, css, html, nothing } from "lit"; import {
CSSResultGroup,
LitElement,
PropertyValues,
css,
html,
nothing,
} from "lit";
import { customElement, property, query } from "lit/decorators"; import { customElement, property, query } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
import { constructUrlCurrentPath } from "../../../common/url/construct-url";
import {
extractSearchParam,
removeSearchParam,
} from "../../../common/url/search-params";
import { nestedArrayMove } from "../../../common/util/array-move"; import { nestedArrayMove } from "../../../common/util/array-move";
import "../../../components/ha-card"; import "../../../components/ha-card";
import "../../../components/ha-icon-button"; import "../../../components/ha-icon-button";
@ -12,6 +24,7 @@ import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url"; import { documentationUrl } from "../../../util/documentation-url";
import "../automation/action/ha-automation-action"; import "../automation/action/ha-automation-action";
import type HaAutomationAction from "../automation/action/ha-automation-action";
import "./ha-script-fields"; import "./ha-script-fields";
import type HaScriptFields from "./ha-script-fields"; import type HaScriptFields from "./ha-script-fields";
@ -58,6 +71,31 @@ export class HaManualScriptEditor extends LitElement {
} }
} }
protected firstUpdated(changedProps: PropertyValues): void {
super.firstUpdated(changedProps);
const expanded = extractSearchParam("expanded");
if (expanded === "1") {
this._clearParam("expanded");
const items = this.shadowRoot!.querySelectorAll<HaAutomationAction>(
"ha-automation-action"
);
items.forEach((el) => {
el.updateComplete.then(() => {
el.expandAll();
});
});
}
}
private _clearParam(param: string) {
window.history.replaceState(
null,
"",
constructUrlCurrentPath(removeSearchParam(param))
);
}
protected render() { protected render() {
return html` return html`
${this.config.description ${this.config.description

View File

@ -7,6 +7,7 @@ import "../../../components/ha-card";
import "../../../components/ha-textfield"; import "../../../components/ha-textfield";
import "../../../components/ha-yaml-editor"; import "../../../components/ha-yaml-editor";
import "../../../components/ha-button"; import "../../../components/ha-button";
import "../../../components/ha-alert";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
@customElement("event-subscribe-card") @customElement("event-subscribe-card")
@ -22,6 +23,8 @@ class EventSubscribeCard extends LitElement {
event: HassEvent; event: HassEvent;
}> = []; }> = [];
@state() private _error?: string;
private _eventCount = 0; private _eventCount = 0;
public disconnectedCallback() { public disconnectedCallback() {
@ -52,6 +55,9 @@ class EventSubscribeCard extends LitElement {
.value=${this._eventType} .value=${this._eventType}
@input=${this._valueChanged} @input=${this._valueChanged}
></ha-textfield> ></ha-textfield>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}
</div> </div>
<div class="card-actions"> <div class="card-actions">
<ha-button <ha-button
@ -110,33 +116,43 @@ class EventSubscribeCard extends LitElement {
private _valueChanged(ev): void { private _valueChanged(ev): void {
this._eventType = ev.target.value; this._eventType = ev.target.value;
this._error = undefined;
} }
private async _startOrStopListening(): Promise<void> { private async _startOrStopListening(): Promise<void> {
if (this._subscribed) { if (this._subscribed) {
this._subscribed(); this._subscribed();
this._subscribed = undefined; this._subscribed = undefined;
this._error = undefined;
} else { } else {
this._subscribed = await this.hass!.connection.subscribeEvents<HassEvent>( try {
(event) => { this._subscribed =
const tail = await this.hass!.connection.subscribeEvents<HassEvent>((event) => {
this._events.length > 30 ? this._events.slice(0, 29) : this._events; const tail =
this._events = [ this._events.length > 30
{ ? this._events.slice(0, 29)
event, : this._events;
id: this._eventCount++, this._events = [
}, {
...tail, event,
]; id: this._eventCount++,
}, },
this._eventType ...tail,
); ];
}, this._eventType);
} catch (error: any) {
this._error = this.hass!.localize(
"ui.panel.developer-tools.tabs.events.subscribe_failed",
{ error: error.message || "Unknown error" }
);
}
} }
} }
private _clearEvents(): void { private _clearEvents(): void {
this._events = []; this._events = [];
this._eventCount = 0; this._eventCount = 0;
this._error = undefined;
} }
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
@ -145,6 +161,9 @@ class EventSubscribeCard extends LitElement {
display: block; display: block;
margin-bottom: 16px; margin-bottom: 16px;
} }
.error-message {
margin-top: 8px;
}
.event { .event {
border-top: 1px solid var(--divider-color); border-top: 1px solid var(--divider-color);
padding-top: 8px; padding-top: 8px;

View File

@ -1191,7 +1191,9 @@
"skip": "Skip", "skip": "Skip",
"clear_skipped": "Clear skipped", "clear_skipped": "Clear skipped",
"install": "Install", "install": "Install",
"create_backup": "Create backup before updating" "create_backup": "Create backup before updating",
"auto_update_enabled_title": "Can not skip version",
"auto_update_enabled_text": "Automatic updates for this item have been enabled; skipping it is, therefore, unavailable. You can either install this update now or wait for Home Assistant to do it automatically."
}, },
"updater": { "updater": {
"title": "Update instructions" "title": "Update instructions"
@ -4039,18 +4041,25 @@
"unknown_automation": "Unknown automation", "unknown_automation": "Unknown automation",
"create": "Create automation with {type}", "create": "Create automation with {type}",
"create_disable": "Can't create automation with disabled {type}", "create_disable": "Can't create automation with disabled {type}",
"new": {
"title": "Create new automation",
"description": "Start with an empty automation from scratch"
},
"triggers": { "triggers": {
"caption": "Do something when…", "title": "Use device as trigger",
"description": "When something happens to the device",
"no_triggers": "No triggers", "no_triggers": "No triggers",
"unknown_trigger": "Unknown trigger" "unknown_trigger": "Unknown trigger"
}, },
"conditions": { "conditions": {
"caption": "Only do something if…", "title": "Use device as condition",
"description": "Only if a condition is met for the device",
"no_conditions": "No conditions", "no_conditions": "No conditions",
"unknown_condition": "Unknown condition" "unknown_condition": "Unknown condition"
}, },
"actions": { "actions": {
"caption": "When something is triggered…", "title": "Use device as action",
"description": "Do something on the device",
"no_actions": "No actions", "no_actions": "No actions",
"unknown_action": "Unknown action" "unknown_action": "Unknown action"
}, },
@ -4061,7 +4070,15 @@
"scripts": "scripts", "scripts": "scripts",
"no_scripts": "No scripts", "no_scripts": "No scripts",
"create": "Create script with {type}", "create": "Create script with {type}",
"create_disable": "Can't create script with disabled {type}" "create_disable": "Can't create script with disabled {type}",
"new": {
"title": "Create new script",
"description": "Start with an empty script from scratch"
},
"actions": {
"title": "Use device as action",
"description": "Do something on this device."
}
}, },
"scene": { "scene": {
"scenes_heading": "Scenes", "scenes_heading": "Scenes",
@ -4590,6 +4607,7 @@
"confirm_delete_dataset": "Delete {name} dataset?", "confirm_delete_dataset": "Delete {name} dataset?",
"confirm_delete_dataset_text": "This network will be removed from Home Assistant.", "confirm_delete_dataset_text": "This network will be removed from Home Assistant.",
"no_border_routers": "No border routers found", "no_border_routers": "No border routers found",
"no_preferred_router": "No preferred border router defined",
"border_routers": "{count} border {count, plural,\n one {router}\n other {routers}\n}", "border_routers": "{count} border {count, plural,\n one {router}\n other {routers}\n}",
"managed_by_home_assistant": "Managed by Home Assistant", "managed_by_home_assistant": "Managed by Home Assistant",
"operational_dataset": "Operational dataset", "operational_dataset": "Operational dataset",
@ -6881,7 +6899,8 @@
"stop_listening": "Stop listening", "stop_listening": "Stop listening",
"clear_events": "Clear events", "clear_events": "Clear events",
"alert_event_type": "Event type is a mandatory field", "alert_event_type": "Event type is a mandatory field",
"notification_event_fired": "Event {type} successfully fired!" "notification_event_fired": "Event {type} successfully fired!",
"subscribe_failed": "Failed to subscribe to event: {error}"
}, },
"actions": { "actions": {
"title": "Actions", "title": "Actions",