Add tag config panel (#6601)

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
This commit is contained in:
Paulus Schoutsen 2020-08-20 15:34:52 +02:00 committed by GitHub
parent a0b28e8ad1
commit d7e409b042
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 809 additions and 3 deletions

View File

@ -90,6 +90,12 @@ export interface ZoneTrigger {
event: "enter" | "leave";
}
export interface TagTrigger {
platform: "tag";
tag_id: string;
device_id?: string;
}
export interface TimeTrigger {
platform: "time";
at: string;
@ -116,6 +122,7 @@ export type Trigger =
| TimePatternTrigger
| WebhookTrigger
| ZoneTrigger
| TagTrigger
| TimeTrigger
| TemplateTrigger
| EventTrigger

57
src/data/tag.ts Normal file
View File

@ -0,0 +1,57 @@
import { HomeAssistant } from "../types";
import { HassEventBase } from "home-assistant-js-websocket";
export const EVENT_TAG_SCANNED = "tag_scanned";
export interface TagScannedEvent extends HassEventBase {
event_type: "tag_scanned";
data: {
tag_id: string;
device_id?: string;
};
}
export interface Tag {
id: string;
name?: string;
description?: string;
last_scanned?: string;
}
export interface UpdateTagParams {
name?: Tag["name"];
description?: Tag["description"];
}
export const fetchTags = async (hass: HomeAssistant) =>
hass.callWS<Tag[]>({
type: "tag/list",
});
export const createTag = async (
hass: HomeAssistant,
params: UpdateTagParams,
tagId?: string
) =>
hass.callWS<Tag>({
type: "tag/create",
tag_id: tagId,
...params,
});
export const updateTag = async (
hass: HomeAssistant,
tagId: string,
params: UpdateTagParams
) =>
hass.callWS<Tag>({
...params,
type: "tag/update",
tag_id: tagId,
});
export const deleteTag = async (hass: HomeAssistant, tagId: string) =>
hass.callWS<void>({
type: "tag/delete",
tag_id: tagId,
});

View File

@ -2,6 +2,7 @@ import { ExternalMessaging } from "./external_messaging";
export interface ExternalConfig {
hasSettingsScreen: boolean;
canWriteTag: boolean;
}
export const getExternalConfig = (

View File

@ -34,6 +34,7 @@ import "./types/ha-automation-trigger-time";
import "./types/ha-automation-trigger-time_pattern";
import "./types/ha-automation-trigger-webhook";
import "./types/ha-automation-trigger-zone";
import "./types/ha-automation-trigger-tag";
import { ActionDetail } from "@material/mwc-list/mwc-list-foundation";
import { haStyle } from "../../../../resources/styles";
@ -46,6 +47,7 @@ const OPTIONS = [
"mqtt",
"numeric_state",
"sun",
"tag",
"template",
"time",
"time_pattern",

View File

@ -0,0 +1,72 @@
import "@polymer/paper-input/paper-input";
import {
customElement,
html,
LitElement,
property,
internalProperty,
PropertyValues,
} from "lit-element";
import { TagTrigger } from "../../../../../data/automation";
import { HomeAssistant } from "../../../../../types";
import { TriggerElement } from "../ha-automation-trigger-row";
import { Tag, fetchTags } from "../../../../../data/tag";
import { fireEvent } from "../../../../../common/dom/fire_event";
@customElement("ha-automation-trigger-tag")
export class HaTagTrigger extends LitElement implements TriggerElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public trigger!: TagTrigger;
@internalProperty() private _tags: Tag[] = [];
public static get defaultConfig() {
return { tag_id: "" };
}
protected firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
this._fetchTags();
}
protected render() {
const { tag_id } = this.trigger;
return html`
<ha-paper-dropdown-menu
.label=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.type.tag.label"
)}
?disabled=${this._tags.length === 0}
>
<paper-listbox
slot="dropdown-content"
.selected=${tag_id}
attr-for-selected="tag_id"
@iron-select=${this._tagChanged}
>
${this._tags.map(
(tag) => html`
<paper-item tag_id=${tag.id} .tag=${tag}>
${tag.name || tag.id}
</paper-item>
`
)}
</paper-listbox>
</ha-paper-dropdown-menu>
`;
}
private async _fetchTags() {
this._tags = await fetchTags(this.hass);
}
private _tagChanged(ev) {
fireEvent(this, "value-changed", {
value: {
...this.trigger,
tag_id: ev.detail.item.tag.id,
},
});
}
}

View File

@ -32,6 +32,7 @@ import {
mdiInformation,
mdiMathLog,
mdiPencil,
mdiNfcVariant,
} from "@mdi/js";
declare global {
@ -99,6 +100,15 @@ export const configSections: { [name: string]: PageNavigation[] } = {
core: true,
},
],
experimental: [
{
component: "tags",
path: "/config/tags",
translationKey: "ui.panel.config.tags.caption",
iconPath: mdiNfcVariant,
core: true,
},
],
lovelace: [
{
component: "lovelace",
@ -195,6 +205,13 @@ class HaPanelConfig extends HassRouterPage {
/* webpackChunkName: "panel-config-automation" */ "./automation/ha-config-automation"
),
},
tags: {
tag: "ha-config-tags",
load: () =>
import(
/* webpackChunkName: "panel-config-tags" */ "./tags/ha-config-tags"
),
},
cloud: {
tag: "ha-config-cloud",
load: () =>

View File

@ -0,0 +1,209 @@
import "@material/mwc-button";
import "@polymer/paper-input/paper-input";
import {
css,
CSSResult,
html,
LitElement,
property,
internalProperty,
TemplateResult,
customElement,
} from "lit-element";
import { fireEvent } from "../../../common/dom/fire_event";
import { createCloseHeading } from "../../../components/ha-dialog";
import "../../../components/ha-switch";
import "../../../components/ha-formfield";
import "../../../components/map/ha-location-editor";
import { haStyleDialog } from "../../../resources/styles";
import { HomeAssistant } from "../../../types";
import { HassDialog } from "../../../dialogs/make-dialog-manager";
import { TagDetailDialogParams } from "./show-dialog-tag-detail";
import { UpdateTagParams, Tag } from "../../../data/tag";
@customElement("dialog-tag-detail")
class DialogTagDetail extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@internalProperty() private _id?: string;
@internalProperty() private _name!: string;
@internalProperty() private _error?: string;
@internalProperty() private _params?: TagDetailDialogParams;
@internalProperty() private _submitting = false;
public showDialog(params: TagDetailDialogParams): void {
this._params = params;
this._error = undefined;
if (this._params.entry) {
this._name = this._params.entry.name || "";
} else {
this._id = "";
this._name = "";
}
}
public closeDialog(): void {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render(): TemplateResult {
if (!this._params) {
return html``;
}
return html`
<ha-dialog
open
@closed=${this.closeDialog}
scrimClickAction
escapeKeyAction
.heading=${createCloseHeading(
this.hass,
this._params.entry
? this._params.entry.name || this._params.entry.id
: this.hass!.localize("ui.panel.config.tags.detail.new_tag")
)}
>
<div>
${this._error ? html` <div class="error">${this._error}</div> ` : ""}
<div class="form">
${this._params.entry
? html`${this.hass!.localize(
"ui.panel.config.tags.detail.tag_id"
)}:
${this._params.entry.id}`
: ""}
<paper-input
dialogInitialFocus
.value=${this._name}
.configValue=${"name"}
@value-changed=${this._valueChanged}
.label="${this.hass!.localize(
"ui.panel.config.tags.detail.name"
)}"
.errorMessage="${this.hass!.localize(
"ui.panel.config.tags.detail.required_error_msg"
)}"
required
auto-validate
></paper-input>
${!this._params.entry
? html` <paper-input
.value=${this._id}
.configValue=${"id"}
@value-changed=${this._valueChanged}
.label=${this.hass!.localize(
"ui.panel.config.tags.detail.tag_id"
)}
.placeholder=${this.hass!.localize(
"ui.panel.config.tags.detail.tag_id_placeholder"
)}
></paper-input>`
: ""}
</div>
</div>
${this._params.entry
? html`
<mwc-button
slot="secondaryAction"
class="warning"
@click="${this._deleteEntry}"
.disabled=${this._submitting}
>
${this.hass!.localize("ui.panel.config.tags.detail.delete")}
</mwc-button>
`
: html``}
<mwc-button
slot="primaryAction"
@click="${this._updateEntry}"
.disabled=${this._submitting}
>
${this._params.entry
? this.hass!.localize("ui.panel.config.tags.detail.update")
: this.hass!.localize("ui.panel.config.tags.detail.create")}
</mwc-button>
${this._params.openWrite && !this._params.entry
? html` <mwc-button
slot="primaryAction"
@click="${this._updateWriteEntry}"
.disabled=${this._submitting}
>
${this.hass!.localize(
"ui.panel.config.tags.detail.create_and_write"
)}
</mwc-button>`
: ""}
</ha-dialog>
`;
}
private _valueChanged(ev: CustomEvent) {
const configValue = (ev.target as any).configValue;
this._error = undefined;
this[`_${configValue}`] = ev.detail.value;
}
private async _updateEntry() {
this._submitting = true;
let newValue: Tag | undefined;
try {
const values: UpdateTagParams = {
name: this._name.trim(),
};
if (this._params!.entry) {
newValue = await this._params!.updateEntry!(values);
} else {
newValue = await this._params!.createEntry(values, this._id);
}
this._params = undefined;
} catch (err) {
this._error = err ? err.message : "Unknown error";
} finally {
this._submitting = false;
}
return newValue;
}
private async _updateWriteEntry() {
const tag = await this._updateEntry();
if (!tag) {
return;
}
this._params?.openWrite!(tag);
}
private async _deleteEntry() {
this._submitting = true;
try {
if (await this._params!.removeEntry!()) {
this._params = undefined;
}
} finally {
this._submitting = false;
}
}
static get styles(): CSSResult[] {
return [
haStyleDialog,
css`
a {
color: var(--primary-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-tag-detail": DialogTagDetail;
}
}

View File

@ -0,0 +1,293 @@
import "@material/mwc-fab";
import { mdiCog, mdiContentDuplicate, mdiPlus, mdiRobot } from "@mdi/js";
import {
customElement,
html,
internalProperty,
LitElement,
property,
PropertyValues,
} from "lit-element";
import memoizeOne from "memoize-one";
import { DataTableColumnContainer } from "../../../components/data-table/ha-data-table";
import "../../../components/ha-card";
import "../../../components/ha-relative-time";
import {
createTag,
deleteTag,
EVENT_TAG_SCANNED,
fetchTags,
Tag,
TagScannedEvent,
updateTag,
UpdateTagParams,
} from "../../../data/tag";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-tabs-subpage-data-table";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { HomeAssistant, Route } from "../../../types";
import { configSections } from "../ha-panel-config";
import { showTagDetailDialog } from "./show-dialog-tag-detail";
import "./tag-image";
import { getExternalConfig } from "../../../external_app/external_config";
import { showAutomationEditor, TagTrigger } from "../../../data/automation";
export interface TagRowData extends Tag {
last_scanned_datetime: Date | null;
}
@customElement("ha-config-tags")
export class HaConfigTags extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public isWide!: boolean;
@property() public narrow!: boolean;
@property() public route!: Route;
@internalProperty() private _tags: Tag[] = [];
@internalProperty() private _canWriteTags = false;
private _columns = memoizeOne(
(
narrow: boolean,
canWriteTags: boolean,
_language
): DataTableColumnContainer => {
const columns: DataTableColumnContainer = {
icon: {
title: "",
type: "icon",
template: (_icon, tag) => html`<tag-image .tag=${tag}></tag-image>`,
},
display_name: {
title: this.hass.localize("ui.panel.config.tags.headers.name"),
sortable: true,
filterable: true,
grows: true,
template: (name, tag: any) => html`${name}
${narrow
? html`<div class="secondary">
${tag.last_scanned
? html`<ha-relative-time
.hass=${this.hass}
.datetimeObj=${tag.last_scanned_datetime}
></ha-relative-time>`
: this.hass.localize("ui.components.relative_time.never")}
</div>`
: ""}`,
},
};
if (!narrow) {
columns.last_scanned_datetime = {
title: this.hass.localize(
"ui.panel.config.tags.headers.last_scanned"
),
sortable: true,
direction: "desc",
width: "20%",
template: (last_scanned_datetime) => html`
${last_scanned_datetime
? html`<ha-relative-time
.hass=${this.hass}
.datetimeObj=${last_scanned_datetime}
></ha-relative-time>`
: this.hass.localize("ui.components.relative_time.never")}
`,
};
}
if (canWriteTags) {
columns.write = {
title: "",
type: "icon-button",
template: (_write, tag: any) => html` <mwc-icon-button
.tag=${tag}
@click=${(ev: Event) =>
this._openWrite((ev.currentTarget as any).tag)}
title=${this.hass.localize("ui.panel.config.tags.write")}
>
<ha-svg-icon .path=${mdiContentDuplicate}></ha-svg-icon>
</mwc-icon-button>`,
};
}
columns.automation = {
title: "",
type: "icon-button",
template: (_automation, tag: any) => html` <mwc-icon-button
.tag=${tag}
@click=${(ev: Event) =>
this._createAutomation((ev.currentTarget as any).tag)}
title=${this.hass.localize("ui.panel.config.tags.create_automation")}
>
<ha-svg-icon .path=${mdiRobot}></ha-svg-icon>
</mwc-icon-button>`,
};
columns.edit = {
title: "",
type: "icon-button",
template: (_settings, tag: any) => html` <mwc-icon-button
.tag=${tag}
@click=${(ev: Event) =>
this._openDialog((ev.currentTarget as any).tag)}
title=${this.hass.localize("ui.panel.config.tags.edit")}
>
<ha-svg-icon .path=${mdiCog}></ha-svg-icon>
</mwc-icon-button>`,
};
return columns;
}
);
private _data = memoizeOne((tags: Tag[]): TagRowData[] => {
return tags.map((tag) => {
return {
...tag,
display_name: tag.name || tag.id,
last_scanned_datetime: tag.last_scanned
? new Date(tag.last_scanned)
: null,
};
});
});
protected firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
this._fetchTags();
if (this.hass && this.hass.auth.external) {
getExternalConfig(this.hass.auth.external).then((conf) => {
this._canWriteTags = conf.canWriteTag;
});
}
}
protected hassSubscribe() {
return [
this.hass.connection.subscribeEvents<TagScannedEvent>((ev) => {
const foundTag = this._tags.find((tag) => tag.id === ev.data.tag_id);
if (!foundTag) {
this._fetchTags();
return;
}
foundTag.last_scanned = ev.time_fired;
this._tags = [...this._tags];
}, EVENT_TAG_SCANNED),
];
}
protected render() {
return html`
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.route=${this.route}
.tabs=${configSections.experimental}
.columns=${this._columns(
this.narrow,
this._canWriteTags,
this.hass.language
)}
.data=${this._data(this._tags)}
.noDataText=${this.hass.localize("ui.panel.config.tags.no_tags")}
hasFab
>
<mwc-fab
slot="fab"
title=${this.hass.localize("ui.panel.config.tags.add_tag")}
@click=${this._addTag}
>
<ha-svg-icon slot="icon" path=${mdiPlus}></ha-svg-icon>
</mwc-fab>
</hass-tabs-subpage-data-table>
`;
}
private async _fetchTags() {
this._tags = await fetchTags(this.hass);
}
private _openWrite(tag: Tag) {
this.hass.auth.external!.fireMessage({
type: "tag/write",
payload: { name: tag.name || null, tag: tag.id },
});
}
private _createAutomation(tag: Tag) {
const data = {
alias: this.hass.localize(
"ui.panel.config.tags.automation_title",
"name",
tag.name || tag.id
),
trigger: [{ platform: "tag", tag_id: tag.id } as TagTrigger],
};
showAutomationEditor(this, data);
}
private _addTag() {
this._openDialog();
}
private _openDialog(entry?: Tag) {
showTagDetailDialog(this, {
entry,
openWrite: this._canWriteTags ? (tag) => this._openWrite(tag) : undefined,
createEntry: (values, tagId) => this._createTag(values, tagId),
updateEntry: entry
? (values) => this._updateTag(entry, values)
: undefined,
removeEntry: entry ? () => this._removeTag(entry) : undefined,
});
}
private async _createTag(
values: Partial<UpdateTagParams>,
tagId?: string
): Promise<Tag> {
const newTag = await createTag(this.hass, values, tagId);
this._tags = [...this._tags, newTag];
return newTag;
}
private async _updateTag(
selectedTag: Tag,
values: Partial<UpdateTagParams>
): Promise<Tag> {
const updated = await updateTag(this.hass, selectedTag.id, values);
this._tags = this._tags.map((tag) =>
tag.id === selectedTag.id ? updated : tag
);
return updated;
}
private async _removeTag(selectedTag: Tag) {
if (
!(await showConfirmationDialog(this, {
title: "Remove tag?",
text: `Are you sure you want to remove tag ${
selectedTag.name || selectedTag.id
}?`,
dismissText: this.hass!.localize("ui.common.no"),
confirmText: this.hass!.localize("ui.common.yes"),
}))
) {
return false;
}
try {
await deleteTag(this.hass, selectedTag.id);
this._tags = this._tags.filter((tag) => tag.id !== selectedTag.id);
return true;
} catch (err) {
return false;
}
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-tags": HaConfigTags;
}
}

View File

@ -0,0 +1,27 @@
import { fireEvent } from "../../../common/dom/fire_event";
import { Tag, UpdateTagParams } from "../../../data/tag";
export interface TagDetailDialogParams {
entry?: Tag;
openWrite?: (tag: Tag) => void;
createEntry: (
values: Partial<UpdateTagParams>,
tagId?: string
) => Promise<Tag>;
updateEntry?: (updates: Partial<UpdateTagParams>) => Promise<Tag>;
removeEntry?: () => Promise<boolean>;
}
export const loadTagDetailDialog = () =>
import(/* webpackChunkName: "dialog-tag-detail" */ "./dialog-tag-detail");
export const showTagDetailDialog = (
element: HTMLElement,
systemLogDetailParams: TagDetailDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-tag-detail",
dialogImport: loadTagDetailDialog,
dialogParams: systemLogDetailParams,
});
};

View File

@ -0,0 +1,93 @@
import {
property,
customElement,
LitElement,
html,
CSSResult,
css,
} from "lit-element";
import "../../../components/ha-svg-icon";
import { mdiNfcVariant } from "@mdi/js";
import { TagRowData } from "./ha-config-tags";
@customElement("tag-image")
export class HaTagImage extends LitElement {
@property() public tag?: TagRowData;
private _timeout?: number;
protected updated() {
const msSinceLastScaned = this.tag?.last_scanned_datetime
? new Date().getTime() - this.tag.last_scanned_datetime.getTime()
: undefined;
if (msSinceLastScaned && msSinceLastScaned < 1000) {
if (this._timeout) {
clearTimeout(this._timeout);
this._timeout = undefined;
this.classList.remove("just-scanned");
requestAnimationFrame(() => this.classList.add("just-scanned"));
} else {
this.classList.add("just-scanned");
}
this._timeout = window.setTimeout(() => {
this.classList.remove("just-scanned");
this._timeout = undefined;
}, 10000);
} else if (!msSinceLastScaned || msSinceLastScaned > 10000) {
clearTimeout(this._timeout);
this._timeout = undefined;
this.classList.remove("just-scanned");
}
}
protected render() {
if (!this.tag) {
return html``;
}
return html`<div class="container">
<div class="image">
<ha-svg-icon .path=${mdiNfcVariant}></ha-svg-icon>
</div>
</div>`;
}
static get styles(): CSSResult {
return css`
.image {
height: 100%;
width: 100%;
background-size: cover;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
}
.container {
height: 40px;
width: 40px;
border-radius: 50%;
}
:host(.just-scanned) .container {
animation: glow 10s;
}
@keyframes glow {
0% {
box-shadow: 0px 0px 24px 0px rgba(var(--rgb-primary-color), 0);
}
10% {
box-shadow: 0px 0px 24px 0px rgba(var(--rgb-primary-color), 1);
}
100% {
box-shadow: 0px 0px 24px 0px rgba(var(--rgb-primary-color), 0);
}
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"tag-image": HaTagImage;
}
}

View File

@ -601,6 +601,31 @@
"confirmation_text": "All devices in this area will become unassigned."
}
},
"tags": {
"caption": "Tags",
"description": "Manage tags",
"no_tags": "No tags",
"add_tag": "Add tag",
"write": "Write",
"edit": "Edit",
"create_automation": "Create automation with tag",
"automation_title": "Tag {name} is scanned",
"headers": {
"name": "Name",
"last_scanned": "Last scanned"
},
"detail": {
"new_tag": "New tag",
"name": "Name",
"description": "Description",
"tag_id": "Tag id",
"tag_id_placeholder": "Autogenerated when left empty",
"delete": "Delete",
"update": "Update",
"create": "Create",
"create_and_write": "Create and Write"
}
},
"helpers": {
"caption": "Helpers",
"description": "Manage elements that help build automations",
@ -878,7 +903,7 @@
"duplicate": "Duplicate",
"delete": "[%key:ui::panel::mailbox::delete_button%]",
"delete_confirm": "Are you sure you want to delete this?",
"unsupported_platform": "Unsupported platform: {platform}",
"unsupported_platform": "No UI support for platform: {platform}",
"type_select": "Trigger type",
"type": {
"device": {
@ -933,6 +958,9 @@
"sunset": "Sunset",
"offset": "Offset (optional)"
},
"tag": {
"label": "Tag"
},
"template": {
"label": "Template",
"value_template": "Value template"
@ -970,7 +998,7 @@
"duplicate": "[%key:ui::panel::config::automation::editor::triggers::duplicate%]",
"delete": "[%key:ui::panel::mailbox::delete_button%]",
"delete_confirm": "[%key:ui::panel::config::automation::editor::triggers::delete_confirm%]",
"unsupported_condition": "Unsupported condition: {condition}",
"unsupported_condition": "No UI support for condition: {condition}",
"type_select": "Condition type",
"type": {
"and": {
@ -1035,7 +1063,7 @@
"duplicate": "[%key:ui::panel::config::automation::editor::triggers::duplicate%]",
"delete": "[%key:ui::panel::mailbox::delete_button%]",
"delete_confirm": "[%key:ui::panel::config::automation::editor::triggers::delete_confirm%]",
"unsupported_action": "Unsupported action: {action}",
"unsupported_action": "No UI support for action: {action}",
"type_select": "Action type",
"type": {
"service": {