From d7e409b042d1823a303f6271e23c99155c87f55d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 20 Aug 2020 15:34:52 +0200 Subject: [PATCH] Add tag config panel (#6601) Co-authored-by: Bram Kragten --- src/data/automation.ts | 7 + src/data/tag.ts | 57 ++++ src/external_app/external_config.ts | 1 + .../trigger/ha-automation-trigger-row.ts | 2 + .../types/ha-automation-trigger-tag.ts | 72 +++++ src/panels/config/ha-panel-config.ts | 17 + src/panels/config/tags/dialog-tag-detail.ts | 209 +++++++++++++ src/panels/config/tags/ha-config-tags.ts | 293 ++++++++++++++++++ .../config/tags/show-dialog-tag-detail.ts | 27 ++ src/panels/config/tags/tag-image.ts | 93 ++++++ src/translations/en.json | 34 +- 11 files changed, 809 insertions(+), 3 deletions(-) create mode 100644 src/data/tag.ts create mode 100644 src/panels/config/automation/trigger/types/ha-automation-trigger-tag.ts create mode 100644 src/panels/config/tags/dialog-tag-detail.ts create mode 100644 src/panels/config/tags/ha-config-tags.ts create mode 100644 src/panels/config/tags/show-dialog-tag-detail.ts create mode 100644 src/panels/config/tags/tag-image.ts diff --git a/src/data/automation.ts b/src/data/automation.ts index b3e8d0b0f8..d4eaa843c7 100644 --- a/src/data/automation.ts +++ b/src/data/automation.ts @@ -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 diff --git a/src/data/tag.ts b/src/data/tag.ts new file mode 100644 index 0000000000..870777f7cb --- /dev/null +++ b/src/data/tag.ts @@ -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({ + type: "tag/list", + }); + +export const createTag = async ( + hass: HomeAssistant, + params: UpdateTagParams, + tagId?: string +) => + hass.callWS({ + type: "tag/create", + tag_id: tagId, + ...params, + }); + +export const updateTag = async ( + hass: HomeAssistant, + tagId: string, + params: UpdateTagParams +) => + hass.callWS({ + ...params, + type: "tag/update", + tag_id: tagId, + }); + +export const deleteTag = async (hass: HomeAssistant, tagId: string) => + hass.callWS({ + type: "tag/delete", + tag_id: tagId, + }); diff --git a/src/external_app/external_config.ts b/src/external_app/external_config.ts index 7651b1307b..d911ea2593 100644 --- a/src/external_app/external_config.ts +++ b/src/external_app/external_config.ts @@ -2,6 +2,7 @@ import { ExternalMessaging } from "./external_messaging"; export interface ExternalConfig { hasSettingsScreen: boolean; + canWriteTag: boolean; } export const getExternalConfig = ( diff --git a/src/panels/config/automation/trigger/ha-automation-trigger-row.ts b/src/panels/config/automation/trigger/ha-automation-trigger-row.ts index c88c644683..a50373bbc8 100644 --- a/src/panels/config/automation/trigger/ha-automation-trigger-row.ts +++ b/src/panels/config/automation/trigger/ha-automation-trigger-row.ts @@ -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", diff --git a/src/panels/config/automation/trigger/types/ha-automation-trigger-tag.ts b/src/panels/config/automation/trigger/types/ha-automation-trigger-tag.ts new file mode 100644 index 0000000000..954e1ef144 --- /dev/null +++ b/src/panels/config/automation/trigger/types/ha-automation-trigger-tag.ts @@ -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` + + + ${this._tags.map( + (tag) => html` + + ${tag.name || tag.id} + + ` + )} + + + `; + } + + 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, + }, + }); + } +} diff --git a/src/panels/config/ha-panel-config.ts b/src/panels/config/ha-panel-config.ts index 649e6168ae..dad0e22ee4 100644 --- a/src/panels/config/ha-panel-config.ts +++ b/src/panels/config/ha-panel-config.ts @@ -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: () => diff --git a/src/panels/config/tags/dialog-tag-detail.ts b/src/panels/config/tags/dialog-tag-detail.ts new file mode 100644 index 0000000000..f4edecf711 --- /dev/null +++ b/src/panels/config/tags/dialog-tag-detail.ts @@ -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` + +
+ ${this._error ? html`
${this._error}
` : ""} +
+ ${this._params.entry + ? html`${this.hass!.localize( + "ui.panel.config.tags.detail.tag_id" + )}: + ${this._params.entry.id}` + : ""} + + ${!this._params.entry + ? html` ` + : ""} +
+
+ ${this._params.entry + ? html` + + ${this.hass!.localize("ui.panel.config.tags.detail.delete")} + + ` + : html``} + + ${this._params.entry + ? this.hass!.localize("ui.panel.config.tags.detail.update") + : this.hass!.localize("ui.panel.config.tags.detail.create")} + + ${this._params.openWrite && !this._params.entry + ? html` + ${this.hass!.localize( + "ui.panel.config.tags.detail.create_and_write" + )} + ` + : ""} +
+ `; + } + + 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; + } +} diff --git a/src/panels/config/tags/ha-config-tags.ts b/src/panels/config/tags/ha-config-tags.ts new file mode 100644 index 0000000000..8abdc49d13 --- /dev/null +++ b/src/panels/config/tags/ha-config-tags.ts @@ -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``, + }, + 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`
+ ${tag.last_scanned + ? html`` + : this.hass.localize("ui.components.relative_time.never")} +
` + : ""}`, + }, + }; + 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`` + : this.hass.localize("ui.components.relative_time.never")} + `, + }; + } + if (canWriteTags) { + columns.write = { + title: "", + type: "icon-button", + template: (_write, tag: any) => html` + this._openWrite((ev.currentTarget as any).tag)} + title=${this.hass.localize("ui.panel.config.tags.write")} + > + + `, + }; + } + columns.automation = { + title: "", + type: "icon-button", + template: (_automation, tag: any) => html` + this._createAutomation((ev.currentTarget as any).tag)} + title=${this.hass.localize("ui.panel.config.tags.create_automation")} + > + + `, + }; + columns.edit = { + title: "", + type: "icon-button", + template: (_settings, tag: any) => html` + this._openDialog((ev.currentTarget as any).tag)} + title=${this.hass.localize("ui.panel.config.tags.edit")} + > + + `, + }; + 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((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` + + + + + + `; + } + + 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, + tagId?: string + ): Promise { + const newTag = await createTag(this.hass, values, tagId); + this._tags = [...this._tags, newTag]; + return newTag; + } + + private async _updateTag( + selectedTag: Tag, + values: Partial + ): Promise { + 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; + } +} diff --git a/src/panels/config/tags/show-dialog-tag-detail.ts b/src/panels/config/tags/show-dialog-tag-detail.ts new file mode 100644 index 0000000000..1088a0778c --- /dev/null +++ b/src/panels/config/tags/show-dialog-tag-detail.ts @@ -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, + tagId?: string + ) => Promise; + updateEntry?: (updates: Partial) => Promise; + removeEntry?: () => Promise; +} + +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, + }); +}; diff --git a/src/panels/config/tags/tag-image.ts b/src/panels/config/tags/tag-image.ts new file mode 100644 index 0000000000..c1b10ca86c --- /dev/null +++ b/src/panels/config/tags/tag-image.ts @@ -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`
+
+ +
+
`; + } + + 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; + } +} diff --git a/src/translations/en.json b/src/translations/en.json index cf43d60daa..6630adb0e6 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -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": {