Add area config page (#5343)

* Add area config page

* Comments

* Update ha-config-area-page.ts

* Update ha-config-area-page.ts
This commit is contained in:
Bram Kragten 2020-03-30 14:21:36 +02:00 committed by GitHub
parent f6dac98abd
commit 5a2e08647f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 727 additions and 209 deletions

View File

@ -53,6 +53,9 @@ export const fallbackDeviceName = (
return undefined;
};
export const devicesInArea = (devices: DeviceRegistryEntry[], areaId: string) =>
devices.filter((device) => device.area_id === areaId);
export const updateDeviceRegistryEntry = (
hass: HomeAssistant,
deviceId: string,

View File

@ -1,8 +1,9 @@
import { fireEvent } from "../../common/dom/fire_event";
import { TemplateResult } from "lit-html";
interface BaseDialogParams {
confirmText?: string;
text?: string;
text?: string | TemplateResult;
title?: string;
}

View File

@ -88,6 +88,7 @@ export class HaTabsSubpageDataTable extends LitElement {
.route=${this.route}
.tabs=${this.tabs}
>
<div slot="toolbar-icon"><slot name="toolbar-icon"></slot></div>
${this.narrow
? html`
<div slot="header">

View File

@ -109,9 +109,9 @@ class DialogAreaDetail extends LitElement {
name: this._name.trim(),
};
if (this._params!.entry) {
await this._params!.updateEntry(values);
await this._params!.updateEntry!(values);
} else {
await this._params!.createEntry(values);
await this._params!.createEntry!(values);
}
this._params = undefined;
} catch (err) {
@ -124,7 +124,7 @@ class DialogAreaDetail extends LitElement {
private async _deleteEntry() {
this._submitting = true;
try {
if (await this._params!.removeEntry()) {
if (await this._params!.removeEntry!()) {
this._params = undefined;
}
} finally {

View File

@ -0,0 +1,397 @@
import "@material/mwc-button";
import "@polymer/paper-dialog-scrollable/paper-dialog-scrollable";
import "@polymer/paper-input/paper-input";
import {
css,
CSSResult,
customElement,
html,
LitElement,
property,
TemplateResult,
} from "lit-element";
import "../../../components/dialog/ha-paper-dialog";
import { haStyle } from "../../../resources/styles";
import { HomeAssistant, Route } from "../../../types";
import memoizeOne from "memoize-one";
import {
AreaRegistryEntry,
updateAreaRegistryEntry,
deleteAreaRegistryEntry,
} from "../../../data/area_registry";
import {
DeviceRegistryEntry,
devicesInArea,
computeDeviceName,
} from "../../../data/device_registry";
import { configSections } from "../ha-panel-config";
import {
showAreaRegistryDetailDialog,
loadAreaRegistryDetailDialog,
} from "./show-dialog-area-registry-detail";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import { RelatedResult, findRelated } from "../../../data/search";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { ifDefined } from "lit-html/directives/if-defined";
@customElement("ha-config-area-page")
class HaConfigAreaPage extends LitElement {
@property() public hass!: HomeAssistant;
@property() public areaId!: string;
@property() public areas!: AreaRegistryEntry[];
@property() public devices!: DeviceRegistryEntry[];
@property({ type: Boolean, reflect: true }) public narrow!: boolean;
@property() public isWide!: boolean;
@property() public showAdvanced!: boolean;
@property() public route!: Route;
@property() private _related?: RelatedResult;
private _area = memoizeOne((areaId: string, areas: AreaRegistryEntry[]):
| AreaRegistryEntry
| undefined => areas.find((area) => area.area_id === areaId));
private _devices = memoizeOne(
(areaId: string, devices: DeviceRegistryEntry[]): DeviceRegistryEntry[] =>
devicesInArea(devices, areaId)
);
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
loadAreaRegistryDetailDialog();
}
protected updated(changedProps) {
super.updated(changedProps);
if (changedProps.has("areaId")) {
this._findRelated();
}
}
protected render(): TemplateResult {
const area = this._area(this.areaId, this.areas);
if (!area) {
return html`
<hass-error-screen
error="${this.hass.localize("ui.panel.config.areas.area_not_found")}"
></hass-error-screen>
`;
}
const devices = this._devices(this.areaId, this.devices);
return html`
<hass-tabs-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.tabs=${configSections.integrations}
.route=${this.route}
>
${this.narrow
? html`
<span slot="header">
${area.name}
</span>
`
: ""}
<paper-icon-button
slot="toolbar-icon"
icon="hass:settings"
.entry=${area}
@click=${this._showSettings}
></paper-icon-button>
<div class="container">
${!this.narrow
? html`
<div class="fullwidth">
<h1>${area.name}</h1>
</div>
`
: ""}
<div class="column">
<ha-card
.header=${this.hass.localize("ui.panel.config.devices.caption")}
>${devices.length
? devices.map(
(device) =>
html`
<a href="/config/devices/device/${device.id}">
<paper-item>
<paper-item-body>
${computeDeviceName(device, this.hass)}
</paper-item-body>
<ha-icon-next></ha-icon-next>
</paper-item>
</a>
`
)
: html`
<paper-item class="no-link"
>${this.hass.localize(
"ui.panel.config.devices.no_devices"
)}</paper-item
>
`}
</ha-card>
</div>
<div class="column">
${isComponentLoaded(this.hass, "automation")
? html`
<ha-card
.header=${this.hass.localize(
"ui.panel.config.devices.automation.automations"
)}
>${this._related?.automation?.length
? this._related.automation.map((automation) => {
const state = this.hass.states[automation];
return state
? html`
<div>
<a
href=${ifDefined(
state.attributes.id
? `/config/automation/edit/${state.attributes.id}`
: undefined
)}
>
<paper-item
.disabled=${!state.attributes.id}
>
<paper-item-body>
${computeStateName(state)}
</paper-item-body>
<ha-icon-next></ha-icon-next>
</paper-item>
</a>
${!state.attributes.id
? html`
<paper-tooltip
>${this.hass.localize(
"ui.panel.config.devices.cant_edit"
)}
</paper-tooltip>
`
: ""}
</div>
`
: "";
})
: html`
<paper-item class="no-link"
>${this.hass.localize(
"ui.panel.config.devices.automation.no_automations"
)}</paper-item
>
`}
</ha-card>
`
: ""}
</div>
<div class="column">
${isComponentLoaded(this.hass, "scene")
? html`
<ha-card
.header=${this.hass.localize(
"ui.panel.config.devices.scene.scenes"
)}
>${this._related?.scene?.length
? this._related.scene.map((scene) => {
const state = this.hass.states[scene];
return state
? html`
<div>
<a
href=${ifDefined(
state.attributes.id
? `/config/scene/edit/${state.attributes.id}`
: undefined
)}
>
<paper-item
.disabled=${!state.attributes.id}
>
<paper-item-body>
${computeStateName(state)}
</paper-item-body>
<ha-icon-next></ha-icon-next>
</paper-item>
</a>
${!state.attributes.id
? html`
<paper-tooltip
>${this.hass.localize(
"ui.panel.config.devices.cant_edit"
)}
</paper-tooltip>
`
: ""}
</div>
`
: "";
})
: html`
<paper-item class="no-link"
>${this.hass.localize(
"ui.panel.config.devices.scene.no_scenes"
)}</paper-item
>
`}
</ha-card>
`
: ""}
${isComponentLoaded(this.hass, "script")
? html`
<ha-card
.header=${this.hass.localize(
"ui.panel.config.devices.script.scripts"
)}
>${this._related?.script?.length
? this._related.script.map((script) => {
const state = this.hass.states[script];
return state
? html`
<a
href=${ifDefined(
state.attributes.id
? `/config/script/edit/${state.attributes.id}`
: undefined
)}
>
<paper-item>
<paper-item-body>
${computeStateName(state)}
</paper-item-body>
<ha-icon-next></ha-icon-next>
</paper-item>
</a>
`
: "";
})
: html`
<paper-item class="no-link">
${this.hass.localize(
"ui.panel.config.devices.script.no_scripts"
)}</paper-item
>
`}
</ha-card>
`
: ""}
</div>
</div>
</hass-tabs-subpage>
`;
}
private async _findRelated() {
this._related = await findRelated(this.hass, "area", this.areaId);
}
private _showSettings(ev: MouseEvent) {
const entry: AreaRegistryEntry = (ev.currentTarget! as any).entry;
this._openDialog(entry);
}
private _openDialog(entry?: AreaRegistryEntry) {
showAreaRegistryDetailDialog(this, {
entry,
updateEntry: async (values) =>
updateAreaRegistryEntry(this.hass!, entry!.area_id, values),
removeEntry: async () => {
if (
!(await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.areas.delete.confirmation_title"
),
text: this.hass.localize(
"ui.panel.config.areas.delete.confirmation_text"
),
dismissText: this.hass.localize("ui.common.no"),
confirmText: this.hass.localize("ui.common.yes"),
}))
) {
return false;
}
try {
await deleteAreaRegistryEntry(this.hass!, entry!.area_id);
return true;
} catch (err) {
return false;
}
},
});
}
static get styles(): CSSResult[] {
return [
haStyle,
css`
h1 {
margin-top: 0;
font-family: var(--paper-font-headline_-_font-family);
-webkit-font-smoothing: var(
--paper-font-headline_-_-webkit-font-smoothing
);
font-size: var(--paper-font-headline_-_font-size);
font-weight: var(--paper-font-headline_-_font-weight);
letter-spacing: var(--paper-font-headline_-_letter-spacing);
line-height: var(--paper-font-headline_-_line-height);
opacity: var(--dark-primary-opacity);
}
.container {
display: flex;
flex-wrap: wrap;
margin: auto;
max-width: 1000px;
margin-top: 32px;
margin-bottom: 32px;
}
.column {
padding: 8px;
box-sizing: border-box;
width: 33%;
flex-grow: 1;
}
.fullwidth {
padding: 8px;
width: 100%;
}
.column > *:not(:first-child) {
margin-top: 16px;
}
:host([narrow]) .column {
width: 100%;
}
:host([narrow]) .container {
margin-top: 0;
}
paper-item {
cursor: pointer;
}
a {
text-decoration: none;
color: var(--primary-text-color);
}
paper-item.no-link {
cursor: default;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-area-page": HaConfigAreaPage;
}
}

View File

@ -0,0 +1,217 @@
import {
LitElement,
TemplateResult,
html,
css,
CSSResult,
property,
customElement,
} from "lit-element";
import "@polymer/paper-item/paper-item";
import "@polymer/paper-item/paper-item-body";
import { HomeAssistant, Route } from "../../../types";
import {
AreaRegistryEntry,
createAreaRegistryEntry,
} from "../../../data/area_registry";
import "../../../components/ha-fab";
import "../../../layouts/hass-loading-screen";
import "../../../layouts/hass-tabs-subpage-data-table";
import "../ha-config-section";
import {
showAreaRegistryDetailDialog,
loadAreaRegistryDetailDialog,
} from "./show-dialog-area-registry-detail";
import { configSections } from "../ha-panel-config";
import memoizeOne from "memoize-one";
import {
DataTableColumnContainer,
RowClickedEvent,
} from "../../../components/data-table/ha-data-table";
import {
devicesInArea,
DeviceRegistryEntry,
} from "../../../data/device_registry";
import { navigate } from "../../../common/navigate";
import { HASSDomEvent } from "../../../common/dom/fire_event";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
@customElement("ha-config-areas-dashboard")
export class HaConfigAreasDashboard extends LitElement {
@property() public hass!: HomeAssistant;
@property() public isWide?: boolean;
@property() public narrow!: boolean;
@property() public route!: Route;
@property() public areas!: AreaRegistryEntry[];
@property() public devices!: DeviceRegistryEntry[];
private _areas = memoizeOne(
(areas: AreaRegistryEntry[], devices: DeviceRegistryEntry[]) => {
return areas.map((area) => {
return {
...area,
devices: devicesInArea(devices, area.area_id).length,
};
});
}
);
private _columns = memoizeOne(
(narrow: boolean): DataTableColumnContainer =>
narrow
? {
name: {
title: this.hass.localize(
"ui.panel.config.areas.data_table.area"
),
sortable: true,
filterable: true,
grows: true,
direction: "asc",
},
}
: {
name: {
title: this.hass.localize(
"ui.panel.config.areas.data_table.area"
),
sortable: true,
filterable: true,
grows: true,
direction: "asc",
},
devices: {
title: this.hass.localize(
"ui.panel.config.areas.data_table.devices"
),
sortable: true,
type: "numeric",
width: "20%",
direction: "asc",
},
}
);
protected render(): TemplateResult {
return html`
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.tabs=${configSections.integrations}
.route=${this.route}
.columns=${this._columns(this.narrow)}
.data=${this._areas(this.areas, this.devices)}
@row-click=${this._handleRowClicked}
.noDataText=${this.hass.localize(
"ui.panel.config.areas.picker.no_areas"
)}
id="area_id"
>
<paper-icon-button
slot="toolbar-icon"
icon="hass:help-circle"
@click=${this._showHelp}
></paper-icon-button>
</hass-tabs-subpage-data-table>
<ha-fab
?is-wide=${this.isWide}
?narrow=${this.narrow}
icon="hass:plus"
title="${this.hass.localize(
"ui.panel.config.areas.picker.create_area"
)}"
@click=${this._createArea}
></ha-fab>
`;
}
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
loadAreaRegistryDetailDialog();
}
private _createArea() {
this._openDialog();
}
private _showHelp() {
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.areas.caption"),
text: html`
${this.hass.localize("ui.panel.config.areas.picker.introduction")}
<p>
${this.hass.localize("ui.panel.config.areas.picker.introduction2")}
</p>
<a href="/config/integrations/dashboard">
${this.hass.localize(
"ui.panel.config.areas.picker.integrations_page"
)}
</a>
`,
});
}
private _handleRowClicked(ev: HASSDomEvent<RowClickedEvent>) {
const areaId = ev.detail.id;
navigate(this, `/config/areas/area/${areaId}`);
}
private _openDialog(entry?: AreaRegistryEntry) {
showAreaRegistryDetailDialog(this, {
entry,
createEntry: async (values) =>
createAreaRegistryEntry(this.hass!, values),
});
}
static get styles(): CSSResult {
return css`
hass-loading-screen {
--app-header-background-color: var(--sidebar-background-color);
--app-header-text-color: var(--sidebar-text-color);
}
a {
color: var(--primary-color);
}
ha-card {
max-width: 600px;
margin: 16px auto;
overflow: hidden;
}
.empty {
text-align: center;
}
paper-item {
cursor: pointer;
padding-top: 4px;
padding-bottom: 4px;
}
ha-fab {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 1;
}
ha-fab[is-wide] {
bottom: 24px;
right: 24px;
}
ha-fab[narrow] {
bottom: 84px;
}
ha-fab.rtl {
right: auto;
left: 16px;
}
ha-fab[is-wide].rtl {
bottom: 24px;
right: auto;
left: 24px;
}
`;
}
}

View File

@ -1,226 +1,120 @@
import "./ha-config-areas-dashboard";
import "./ha-config-area-page";
import { compare } from "../../../common/string/compare";
import {
LitElement,
TemplateResult,
html,
css,
CSSResult,
property,
customElement,
} from "lit-element";
import "@polymer/paper-item/paper-item";
import "@polymer/paper-item/paper-item-body";
import { HomeAssistant, Route } from "../../../types";
import {
AreaRegistryEntry,
updateAreaRegistryEntry,
deleteAreaRegistryEntry,
createAreaRegistryEntry,
subscribeAreaRegistry,
AreaRegistryEntry,
} from "../../../data/area_registry";
import "../../../components/ha-card";
import "../../../components/ha-fab";
import "../../../layouts/hass-tabs-subpage";
import "../../../layouts/hass-loading-screen";
import "../ha-config-section";
import {
showAreaRegistryDetailDialog,
loadAreaRegistryDetailDialog,
} from "./show-dialog-area-registry-detail";
import { classMap } from "lit-html/directives/class-map";
import { computeRTL } from "../../../common/util/compute_rtl";
HassRouterPage,
RouterOptions,
} from "../../../layouts/hass-router-page";
import { property, customElement, PropertyValues } from "lit-element";
import { HomeAssistant } from "../../../types";
import { ConfigEntry, getConfigEntries } from "../../../data/config_entries";
import {
DeviceRegistryEntry,
subscribeDeviceRegistry,
} from "../../../data/device_registry";
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { configSections } from "../ha-panel-config";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
@customElement("ha-config-areas")
export class HaConfigAreas extends LitElement {
class HaConfigAreas extends HassRouterPage {
@property() public hass!: HomeAssistant;
@property() public isWide?: boolean;
@property() public narrow!: boolean;
@property() public route!: Route;
@property() private _areas?: AreaRegistryEntry[];
private _unsubAreas?: UnsubscribeFunc;
@property() public isWide!: boolean;
@property() public showAdvanced!: boolean;
protected routerOptions: RouterOptions = {
defaultPage: "dashboard",
routes: {
dashboard: {
tag: "ha-config-areas-dashboard",
cache: true,
},
area: {
tag: "ha-config-area-page",
},
},
};
@property() private _configEntries: ConfigEntry[] = [];
@property() private _deviceRegistryEntries: DeviceRegistryEntry[] = [];
@property() private _areas: AreaRegistryEntry[] = [];
private _unsubs?: UnsubscribeFunc[];
public connectedCallback() {
super.connectedCallback();
if (!this.hass) {
return;
}
this._loadData();
}
public disconnectedCallback() {
super.disconnectedCallback();
if (this._unsubAreas) {
this._unsubAreas();
if (this._unsubs) {
while (this._unsubs.length) {
this._unsubs.pop()!();
}
this._unsubs = undefined;
}
}
protected render(): TemplateResult {
if (!this.hass || this._areas === undefined) {
return html`
<hass-loading-screen></hass-loading-screen>
`;
}
return html`
<hass-tabs-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.route=${this.route}
.tabs=${configSections.integrations}
>
<ha-config-section .isWide=${this.isWide}>
<span slot="header">
${this.hass.localize("ui.panel.config.areas.picker.header")}
</span>
<span slot="introduction">
${this.hass.localize("ui.panel.config.areas.picker.introduction")}
<p>
${this.hass.localize(
"ui.panel.config.areas.picker.introduction2"
)}
</p>
<a href="/config/integrations/dashboard">
${this.hass.localize(
"ui.panel.config.areas.picker.integrations_page"
)}
</a>
</span>
<ha-card>
${this._areas.map((entry) => {
return html`
<paper-item @click=${this._openEditEntry} .entry=${entry}>
<paper-item-body>
${entry.name}
</paper-item-body>
</paper-item>
`;
})}
${this._areas.length === 0
? html`
<div class="empty">
${this.hass.localize("ui.panel.config.areas.no_areas")}
<mwc-button @click=${this._createArea}>
${this.hass.localize("ui.panel.config.areas.create_area")}
</mwc-button>
</div>
`
: html``}
</ha-card>
</ha-config-section>
</hass-tabs-subpage>
<ha-fab
?is-wide=${this.isWide}
?narrow=${this.narrow}
icon="hass:plus"
title="${this.hass.localize("ui.panel.config.areas.create_area")}"
@click=${this._createArea}
class="${classMap({
rtl: computeRTL(this.hass),
})}"
></ha-fab>
`;
}
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
loadAreaRegistryDetailDialog();
}
protected updated(changedProps) {
super.updated(changedProps);
if (!this._unsubAreas) {
this._unsubAreas = subscribeAreaRegistry(
this.hass.connection,
(areas) => {
this._areas = areas;
}
);
}
}
private _createArea() {
this._openDialog();
}
private _openEditEntry(ev: MouseEvent) {
const entry: AreaRegistryEntry = (ev.currentTarget! as any).entry;
this._openDialog(entry);
}
private _openDialog(entry?: AreaRegistryEntry) {
showAreaRegistryDetailDialog(this, {
entry,
createEntry: async (values) =>
createAreaRegistryEntry(this.hass!, values),
updateEntry: async (values) =>
updateAreaRegistryEntry(this.hass!, entry!.area_id, values),
removeEntry: async () => {
if (
!(await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.areas.delete.confirmation_title"
),
text: this.hass.localize(
"ui.panel.config.areas.delete.confirmation_text"
),
dismissText: this.hass.localize("ui.common.no"),
confirmText: this.hass.localize("ui.common.yes"),
}))
) {
return false;
}
try {
await deleteAreaRegistryEntry(this.hass!, entry!.area_id);
return true;
} catch (err) {
return false;
}
},
this.addEventListener("hass-reload-entries", () => {
this._loadData();
});
}
static get styles(): CSSResult {
return css`
hass-loading-screen {
--app-header-background-color: var(--sidebar-background-color);
--app-header-text-color: var(--sidebar-text-color);
}
a {
color: var(--primary-color);
}
ha-card {
max-width: 600px;
margin: 16px auto;
overflow: hidden;
}
.empty {
text-align: center;
}
paper-item {
cursor: pointer;
padding-top: 4px;
padding-bottom: 4px;
}
ha-fab {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 1;
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (!this._unsubs && changedProps.has("hass")) {
this._loadData();
}
}
ha-fab[is-wide] {
bottom: 24px;
right: 24px;
}
ha-fab[narrow] {
bottom: 84px;
}
ha-fab.rtl {
right: auto;
left: 16px;
}
protected updatePageEl(pageEl) {
pageEl.hass = this.hass;
ha-fab[is-wide].rtl {
bottom: 24px;
right: auto;
left: 24px;
}
`;
if (this._currentPage === "area") {
pageEl.areaId = this.routeTail.path.substr(1);
}
pageEl.entries = this._configEntries;
pageEl.devices = this._deviceRegistryEntries;
pageEl.areas = this._areas;
pageEl.narrow = this.narrow;
pageEl.isWide = this.isWide;
pageEl.showAdvanced = this.showAdvanced;
pageEl.route = this.routeTail;
}
private _loadData() {
getConfigEntries(this.hass).then((configEntries) => {
this._configEntries = configEntries.sort((conf1, conf2) =>
compare(conf1.title, conf2.title)
);
});
if (this._unsubs) {
return;
}
this._unsubs = [
subscribeAreaRegistry(this.hass.connection, (areas) => {
this._areas = areas;
}),
subscribeDeviceRegistry(this.hass.connection, (entries) => {
this._deviceRegistryEntries = entries;
}),
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-areas": HaConfigAreas;
}
}

View File

@ -6,11 +6,11 @@ import {
export interface AreaRegistryDetailDialogParams {
entry?: AreaRegistryEntry;
createEntry: (values: AreaRegistryEntryMutableParams) => Promise<unknown>;
updateEntry: (
createEntry?: (values: AreaRegistryEntryMutableParams) => Promise<unknown>;
updateEntry?: (
updates: Partial<AreaRegistryEntryMutableParams>
) => Promise<unknown>;
removeEntry: () => Promise<boolean>;
removeEntry?: () => Promise<boolean>;
}
export const loadAreaRegistryDetailDialog = () =>

View File

@ -785,6 +785,10 @@
"areas": {
"caption": "Areas",
"description": "Overview of all areas in your home.",
"data_table": {
"area": "Area",
"devices": "Devices"
},
"picker": {
"header": "Areas",
"introduction": "Areas are used to organize where devices are. This information will be used throughout Home Assistant to help you in organizing your interface, permissions and integrations with other systems.",
@ -1442,6 +1446,7 @@
"unknown_error": "Unknown error",
"name": "Name",
"update": "Update",
"no_devices": "No devices",
"automation": {
"automations": "Automations",
"no_automations": "No automations",