diff --git a/gallery/src/pages/components/ha-control-circular-slider.markdown b/gallery/src/pages/components/ha-control-circular-slider.markdown new file mode 100644 index 0000000000..7a5a234cf2 --- /dev/null +++ b/gallery/src/pages/components/ha-control-circular-slider.markdown @@ -0,0 +1,3 @@ +--- +title: Control Circular Slider +--- diff --git a/gallery/src/pages/components/ha-control-circular-slider.ts b/gallery/src/pages/components/ha-control-circular-slider.ts new file mode 100644 index 0000000000..791fff7829 --- /dev/null +++ b/gallery/src/pages/components/ha-control-circular-slider.ts @@ -0,0 +1,153 @@ +import { css, html, LitElement, TemplateResult } from "lit"; +import { customElement, state } from "lit/decorators"; +import "../../../../src/components/ha-card"; +import "../../../../src/components/ha-control-circular-slider"; +import "../../../../src/components/ha-slider"; + +@customElement("demo-components-ha-control-circular-slider") +export class DemoHaCircularSlider extends LitElement { + @state() + private current = 22; + + @state() + private value = 19; + + @state() + private high = 25; + + @state() + private changingValue?: number; + + @state() + private changingHigh?: number; + + private _valueChanged(ev) { + this.value = ev.detail.value; + } + + private _valueChanging(ev) { + this.changingValue = ev.detail.value; + } + + private _highChanged(ev) { + this.high = ev.detail.value; + } + + private _highChanging(ev) { + this.changingHigh = ev.detail.value; + } + + private _currentChanged(ev) { + this.current = ev.currentTarget.value; + } + + protected render(): TemplateResult { + return html` + +
+

Config

+
+

Current

+ +

${this.current} °C

+
+
+
+ +
+

Single

+ +
+ Value: ${this.value} °C +
+ Changing: + ${this.changingValue != null ? `${this.changingValue} °C` : "-"} +
+
+
+ +
+

Dual

+ +
+ Low value: ${this.value} °C +
+ Low changing: + ${this.changingValue != null ? `${this.changingValue} °C` : "-"} +
+ High value: ${this.high} °C +
+ High changing: + ${this.changingHigh != null ? `${this.changingHigh} °C` : "-"} +
+
+
+ `; + } + + static get styles() { + return css` + ha-card { + max-width: 600px; + margin: 24px auto; + } + pre { + margin-top: 0; + margin-bottom: 8px; + } + p { + margin: 0; + } + p.title { + margin-bottom: 12px; + } + ha-control-circular-slider { + --control-circular-slider-color: #ff9800; + --control-circular-slider-background: #ff9800; + --control-circular-slider-background-opacity: 0.3; + } + ha-control-circular-slider[dual] { + --control-circular-slider-high-color: #2196f3; + --control-circular-slider-low-color: #ff9800; + --control-circular-slider-background: var(--disabled-color); + } + .field { + display: flex; + flex-direction: row; + align-items: center; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "demo-components-ha-control-circular-slider": DemoHaCircularSlider; + } +} diff --git a/gallery/src/pages/lovelace/markdown-card.ts b/gallery/src/pages/lovelace/markdown-card.ts index 1abca4bdcc..700283b6b8 100644 --- a/gallery/src/pages/lovelace/markdown-card.ts +++ b/gallery/src/pages/lovelace/markdown-card.ts @@ -9,7 +9,7 @@ const CONFIGS = [ heading: "markdown-it demo", config: ` - type: markdown - content: >- + content: | # h1 Heading 8-) ## h2 Heading @@ -65,6 +65,15 @@ const CONFIGS = [ >> ...by using additional greater-than signs right next to each other... > > > ...or with spaces between arrows. + > **Warning** Hey there + > This is a warning with a title + + > **Note** + > This is a note + + > **Note** + > This is a multiline note + > Lorem ipsum... ## Lists diff --git a/src/common/const.ts b/src/common/const.ts index 35bd81a3e2..76275f55a2 100644 --- a/src/common/const.ts +++ b/src/common/const.ts @@ -33,6 +33,7 @@ import { mdiGoogleCirclesCommunities, mdiHomeAssistant, mdiHomeAutomation, + mdiImage, mdiImageFilterFrames, mdiLightbulb, mdiLightningBolt, @@ -90,6 +91,7 @@ export const FIXED_DOMAIN_ICONS = { group: mdiGoogleCirclesCommunities, homeassistant: mdiHomeAssistant, homekit: mdiHomeAutomation, + image: mdiImage, image_processing: mdiImageFilterFrames, input_button: mdiGestureTapButton, input_datetime: mdiCalendarClock, diff --git a/src/common/decorators/local-storage.ts b/src/common/decorators/storage.ts similarity index 64% rename from src/common/decorators/local-storage.ts rename to src/common/decorators/storage.ts index 752b95933e..a35f0b414a 100644 --- a/src/common/decorators/local-storage.ts +++ b/src/common/decorators/storage.ts @@ -1,13 +1,15 @@ import { UnsubscribeFunc } from "home-assistant-js-websocket"; -import { PropertyDeclaration, ReactiveElement } from "lit"; +import { ReactiveElement } from "lit"; +import { InternalPropertyDeclaration } from "lit/decorators"; import type { ClassElement } from "../../types"; type Callback = (oldValue: any, newValue: any) => void; -class Storage { - constructor(subscribe = true, storage = window.localStorage) { +class StorageClass { + constructor(storage = window.localStorage) { this.storage = storage; - if (!subscribe) { + if (storage !== window.localStorage) { + // storage events only work for localStorage return; } window.addEventListener("storage", (ev: StorageEvent) => { @@ -77,6 +79,7 @@ class Storage { } public setValue(storageKey: string, value: any): any { + const oldValue = this._storage[storageKey]; this._storage[storageKey] = value; try { if (value === undefined) { @@ -86,49 +89,68 @@ class Storage { } } catch (err: any) { // Safari in private mode doesn't allow localstorage + } finally { + if (this._listeners[storageKey]) { + this._listeners[storageKey].forEach((listener) => + listener(oldValue, value) + ); + } } } } -const subscribeStorage = new Storage(); +const storages: Record = {}; -export const LocalStorage = - ( - storageKey?: string, - property?: boolean, - subscribe = true, - storageType?: globalThis.Storage, - propertyOptions?: PropertyDeclaration - ): any => +export const storage = + (options: { + key?: string; + storage?: "localStorage" | "sessionStorage"; + subscribe?: boolean; + state?: boolean; + stateOptions?: InternalPropertyDeclaration; + }): any => (clsElement: ClassElement) => { - const storage = - subscribe && !storageType - ? subscribeStorage - : new Storage(subscribe, storageType); + const storageName = options.storage || "localStorage"; + + let storageInstance: StorageClass; + if (storageName && storageName in storages) { + storageInstance = storages[storageName]; + } else { + storageInstance = new StorageClass(window[storageName]); + storages[storageName] = storageInstance; + } const key = String(clsElement.key); - storageKey = storageKey || String(clsElement.key); + const storageKey = options.key || String(clsElement.key); const initVal = clsElement.initializer ? clsElement.initializer() : undefined; - storage.addFromStorage(storageKey); + storageInstance.addFromStorage(storageKey); - const subscribeChanges = (el: ReactiveElement): UnsubscribeFunc => - storage.subscribeChanges(storageKey!, (oldValue) => { - el.requestUpdate(clsElement.key, oldValue); - }); + const subscribeChanges = + options.subscribe !== false + ? (el: ReactiveElement): UnsubscribeFunc => + storageInstance.subscribeChanges( + storageKey!, + (oldValue, _newValue) => { + el.requestUpdate(clsElement.key, oldValue); + } + ) + : undefined; const getValue = (): any => - storage.hasKey(storageKey!) ? storage.getValue(storageKey!) : initVal; + storageInstance.hasKey(storageKey!) + ? storageInstance.getValue(storageKey!) + : initVal; const setValue = (el: ReactiveElement, value: any) => { let oldValue: unknown | undefined; - if (property) { + if (options.state) { oldValue = getValue(); } - storage.setValue(storageKey!, value); - if (property) { + storageInstance.setValue(storageKey!, value); + if (options.state) { el.requestUpdate(clsElement.key, oldValue); } }; @@ -148,22 +170,23 @@ export const LocalStorage = configurable: true, }, finisher(cls: typeof ReactiveElement) { - if (property && subscribe) { + if (options.state && options.subscribe) { const connectedCallback = cls.prototype.connectedCallback; const disconnectedCallback = cls.prototype.disconnectedCallback; cls.prototype.connectedCallback = function () { connectedCallback.call(this); - this[`__unbsubLocalStorage${key}`] = subscribeChanges(this); + this[`__unbsubLocalStorage${key}`] = subscribeChanges?.(this); }; cls.prototype.disconnectedCallback = function () { disconnectedCallback.call(this); - this[`__unbsubLocalStorage${key}`](); + this[`__unbsubLocalStorage${key}`]?.(); + this[`__unbsubLocalStorage${key}`] = undefined; }; } - if (property) { + if (options.state) { cls.createProperty(clsElement.key, { noAccessor: true, - ...propertyOptions, + ...options.stateOptions, }); } }, diff --git a/src/common/entity/compute_state_display.ts b/src/common/entity/compute_state_display.ts index 34db8700d1..9c768bd55e 100644 --- a/src/common/entity/compute_state_display.ts +++ b/src/common/entity/compute_state_display.ts @@ -191,7 +191,9 @@ export const computeStateDisplayFromEntityAttributes = ( // state is a timestamp if ( - ["button", "input_button", "scene", "stt", "tts"].includes(domain) || + ["button", "image", "input_button", "scene", "stt", "tts"].includes( + domain + ) || (domain === "sensor" && attributes.device_class === "timestamp") ) { try { diff --git a/src/components/ha-control-circular-slider.ts b/src/components/ha-control-circular-slider.ts new file mode 100644 index 0000000000..7b7472ddbc --- /dev/null +++ b/src/components/ha-control-circular-slider.ts @@ -0,0 +1,546 @@ +import { + DIRECTION_ALL, + Manager, + Pan, + Tap, + TouchMouseInput, +} from "@egjs/hammerjs"; +import { + CSSResultGroup, + LitElement, + PropertyValues, + TemplateResult, + css, + html, + nothing, + svg, +} from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import { ifDefined } from "lit/directives/if-defined"; +import { styleMap } from "lit/directives/style-map"; +import { fireEvent } from "../common/dom/fire_event"; +import { clamp } from "../common/number/clamp"; +import { arc } from "../resources/svg-arc"; + +const MAX_ANGLE = 270; +const ROTATE_ANGLE = 360 - MAX_ANGLE / 2 - 90; +const RADIUS = 145; + +function xy2polar(x: number, y: number) { + const r = Math.sqrt(x * x + y * y); + const phi = Math.atan2(y, x); + return [r, phi]; +} + +function rad2deg(rad: number) { + return (rad / (2 * Math.PI)) * 360; +} + +type ActiveSlider = "low" | "high" | "value"; + +declare global { + interface HASSDomEvents { + "value-changing": { value: unknown }; + "low-changing": { value: unknown }; + "low-changed": { value: unknown }; + "high-changing": { value: unknown }; + "high-changed": { value: unknown }; + } +} + +const A11Y_KEY_CODES = new Set([ + "ArrowRight", + "ArrowUp", + "ArrowLeft", + "ArrowDown", + "PageUp", + "PageDown", + "Home", + "End", +]); + +@customElement("ha-control-circular-slider") +export class HaControlCircularSlider extends LitElement { + @property({ type: Boolean, reflect: true }) + public disabled = false; + + @property({ type: Boolean }) + public dual?: boolean; + + @property({ type: String }) + public label?: string; + + @property({ type: String, attribute: "low-label" }) + public lowLabel?: string; + + @property({ type: String, attribute: "high-label" }) + public highLabel?: string; + + @property({ type: Number }) + public value?: number; + + @property({ type: Number }) + public current?: number; + + @property({ type: Number }) + public low?: number; + + @property({ type: Number }) + public high?: number; + + @property({ type: Number }) + public step = 1; + + @property({ type: Number }) + public min = 0; + + @property({ type: Number }) + public max = 100; + + @state() + public _activeSlider?: ActiveSlider; + + @state() + public _lastSlider?: ActiveSlider; + + private _valueToPercentage(value: number) { + return ( + (clamp(value, this.min, this.max) - this.min) / (this.max - this.min) + ); + } + + private _percentageToValue(value: number) { + return (this.max - this.min) * value + this.min; + } + + private _steppedValue(value: number) { + return Math.round(value / this.step) * this.step; + } + + private _boundedValue(value: number) { + const min = + this._activeSlider === "high" ? Math.min(this.low ?? this.max) : this.min; + const max = + this._activeSlider === "low" ? Math.max(this.high ?? this.min) : this.max; + return Math.min(Math.max(value, min), max); + } + + protected firstUpdated(changedProperties: PropertyValues): void { + super.firstUpdated(changedProperties); + this._setupListeners(); + } + + connectedCallback(): void { + super.connectedCallback(); + this._setupListeners(); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + } + + private _mc?: HammerManager; + + private _getPercentageFromEvent = (e: HammerInput) => { + const bound = this._slider.getBoundingClientRect(); + const x = (2 * (e.center.x - bound.left - bound.width / 2)) / bound.width; + const y = (2 * (e.center.y - bound.top - bound.height / 2)) / bound.height; + + const [, phi] = xy2polar(x, y); + + const offset = (360 - MAX_ANGLE) / 2; + + const angle = ((rad2deg(phi) + offset - ROTATE_ANGLE + 360) % 360) - offset; + + return Math.max(Math.min(angle / MAX_ANGLE, 1), 0); + }; + + @query("#slider") + private _slider; + + @query("#interaction") + private _interaction; + + private _findActiveSlider(value: number): ActiveSlider { + if (!this.dual) return "value"; + const low = Math.max(this.low ?? this.min, this.min); + const high = Math.min(this.high ?? this.max, this.max); + if (low >= value) { + return "low"; + } + if (high <= value) { + return "high"; + } + const lowDistance = Math.abs(value - low); + const highDistance = Math.abs(value - high); + return lowDistance <= highDistance ? "low" : "high"; + } + + private _setActiveValue(value: number) { + if (!this._activeSlider) return; + this[this._activeSlider] = value; + } + + private _getActiveValue(): number | undefined { + if (!this._activeSlider) return undefined; + return this[this._activeSlider]; + } + + _setupListeners() { + if (this._interaction && !this._mc) { + this._mc = new Manager(this._interaction, { + inputClass: TouchMouseInput, + }); + this._mc.add( + new Pan({ + direction: DIRECTION_ALL, + enable: true, + threshold: 0, + }) + ); + + this._mc.add(new Tap({ event: "singletap" })); + + this._mc.on("pan", (e) => { + e.srcEvent.stopPropagation(); + e.srcEvent.preventDefault(); + }); + this._mc.on("panstart", (e) => { + if (this.disabled) return; + const percentage = this._getPercentageFromEvent(e); + const raw = this._percentageToValue(percentage); + this._activeSlider = this._findActiveSlider(raw); + this._lastSlider = this._activeSlider; + this.shadowRoot?.getElementById("#slider")?.focus(); + }); + this._mc.on("pancancel", () => { + if (this.disabled) return; + this._activeSlider = undefined; + }); + this._mc.on("panmove", (e) => { + if (this.disabled) return; + const percentage = this._getPercentageFromEvent(e); + const raw = this._percentageToValue(percentage); + const bounded = this._boundedValue(raw); + this._setActiveValue(bounded); + const stepped = this._steppedValue(bounded); + if (this._activeSlider) { + fireEvent(this, `${this._activeSlider}-changing`, { value: stepped }); + } + }); + this._mc.on("panend", (e) => { + if (this.disabled) return; + const percentage = this._getPercentageFromEvent(e); + const raw = this._percentageToValue(percentage); + const bounded = this._boundedValue(raw); + const stepped = this._steppedValue(bounded); + if (this._activeSlider) { + fireEvent(this, `${this._activeSlider}-changing`, { + value: undefined, + }); + fireEvent(this, `${this._activeSlider}-changed`, { value: stepped }); + } + this._activeSlider = undefined; + }); + this._mc.on("singletap", (e) => { + if (this.disabled) return; + const percentage = this._getPercentageFromEvent(e); + const raw = this._percentageToValue(percentage); + this._activeSlider = this._findActiveSlider(raw); + const bounded = this._boundedValue(raw); + const stepped = this._steppedValue(bounded); + this._setActiveValue(stepped); + if (this._activeSlider) { + fireEvent(this, `${this._activeSlider}-changing`, { + value: undefined, + }); + fireEvent(this, `${this._activeSlider}-changed`, { value: stepped }); + } + this._lastSlider = this._activeSlider; + this.shadowRoot?.getElementById("#slider")?.focus(); + this._activeSlider = undefined; + }); + } + } + + private get _tenPercentStep() { + return Math.max(this.step, (this.max - this.min) / 10); + } + + private _handleKeyDown(e: KeyboardEvent) { + if (!A11Y_KEY_CODES.has(e.code)) return; + e.preventDefault(); + if (this._lastSlider) { + this.shadowRoot?.getElementById(this._lastSlider)?.focus(); + } + this._activeSlider = + this._lastSlider ?? ((e.currentTarget as any).id as ActiveSlider); + this._lastSlider = undefined; + + const value = this._getActiveValue(); + + switch (e.code) { + case "ArrowRight": + case "ArrowUp": + this._setActiveValue( + this._boundedValue((value ?? this.min) + this.step) + ); + break; + case "ArrowLeft": + case "ArrowDown": + this._setActiveValue( + this._boundedValue((value ?? this.min) - this.step) + ); + break; + case "PageUp": + this._setActiveValue( + this._steppedValue( + this._boundedValue((value ?? this.min) + this._tenPercentStep) + ) + ); + break; + case "PageDown": + this._setActiveValue( + this._steppedValue( + this._boundedValue((value ?? this.min) - this._tenPercentStep) + ) + ); + break; + case "Home": + this._setActiveValue(this._boundedValue(this.min)); + break; + case "End": + this._setActiveValue(this._boundedValue(this.max)); + break; + } + fireEvent(this, `${this._activeSlider}-changing`, { + value: this._getActiveValue(), + }); + this._activeSlider = undefined; + } + + _handleKeyUp(e: KeyboardEvent) { + if (!A11Y_KEY_CODES.has(e.code)) return; + this._activeSlider = (e.currentTarget as any).id as ActiveSlider; + e.preventDefault(); + fireEvent(this, `${this._activeSlider}-changing`, { + value: undefined, + }); + fireEvent(this, `${this._activeSlider}-changed`, { + value: this._getActiveValue(), + }); + this._activeSlider = undefined; + } + + destroyListeners() { + if (this._mc) { + this._mc.destroy(); + this._mc = undefined; + } + } + + protected render(): TemplateResult { + const trackPath = arc({ x: 0, y: 0, start: 0, end: MAX_ANGLE, r: RADIUS }); + + const maxRatio = MAX_ANGLE / 360; + + const f = RADIUS * 2 * Math.PI; + const lowValue = this.dual ? this.low : this.value; + const highValue = this.high; + const lowPercentage = this._valueToPercentage(lowValue ?? this.min); + const highPercentage = this._valueToPercentage(highValue ?? this.max); + + const lowArcLength = lowPercentage * f * maxRatio; + const lowStrokeDasharray = `${lowArcLength} ${f - lowArcLength}`; + + const highArcLength = (1 - highPercentage) * f * maxRatio; + const highStrokeDasharray = `${highArcLength} ${f - highArcLength}`; + const highStrokeDashOffset = `${highArcLength + f * (1 - maxRatio)}`; + + const currentPercentage = this._valueToPercentage(this.current ?? 0); + const currentAngle = currentPercentage * MAX_ANGLE; + + return html` + + + + + + + + + ${this.dual + ? svg` + + ` + : nothing} + ${this.current != null + ? svg` + + + + + ` + : nothing} + + + + `; + } + + static get styles(): CSSResultGroup { + return css` + :host { + --control-circular-slider-color: var(--primary-color); + --control-circular-slider-background: #8b97a3; + --control-circular-slider-background-opacity: 0.3; + --control-circular-slider-low-color: var( + --control-circular-slider-color + ); + --control-circular-slider-high-color: var( + --control-circular-slider-color + ); + } + svg { + width: 320px; + display: block; + } + #slider { + outline: none; + } + #interaction { + display: flex; + fill: none; + stroke: transparent; + stroke-linecap: round; + stroke-width: 48px; + cursor: pointer; + } + #display { + pointer-events: none; + } + :host([disabled]) #interaction { + cursor: initial; + } + + .background { + fill: none; + stroke: var(--control-circular-slider-background); + opacity: var(--control-circular-slider-background-opacity); + stroke-linecap: round; + stroke-width: 24px; + } + + .track { + outline: none; + fill: none; + stroke-linecap: round; + stroke-width: 24px; + transition: stroke-width 300ms ease-in-out, + stroke-dasharray 300ms ease-in-out, + stroke-dashoffset 300ms ease-in-out; + } + + .track:focus-visible { + stroke-width: 28px; + } + + .pressed .track { + transition: stroke-width 300ms ease-in-out; + } + + .current { + stroke: var(--primary-text-color); + transform: rotate(var(--current-angle, 0)); + transition: transform 300ms ease-in-out; + } + + #value { + stroke: var(--control-circular-slider-color); + } + + #low { + stroke: var(--control-circular-slider-low-color); + } + + #high { + stroke: var(--control-circular-slider-high-color); + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-control-circular-slider": HaControlCircularSlider; + } +} diff --git a/src/components/ha-control-slider.ts b/src/components/ha-control-slider.ts index 2cc172969a..3f4abcf4e3 100644 --- a/src/components/ha-control-slider.ts +++ b/src/components/ha-control-slider.ts @@ -176,7 +176,7 @@ export class HaControlSlider extends LitElement { this._mc = undefined; } this.removeEventListener("keydown", this._handleKeyDown); - this.removeEventListener("keyup", this._handleKeyDown); + this.removeEventListener("keyup", this._handleKeyUp); } private get _tenPercentStep() { diff --git a/src/components/ha-markdown-element.ts b/src/components/ha-markdown-element.ts index 1a4691c917..7a15e35e0b 100644 --- a/src/components/ha-markdown-element.ts +++ b/src/components/ha-markdown-element.ts @@ -3,6 +3,8 @@ import { customElement, property } from "lit/decorators"; import { fireEvent } from "../common/dom/fire_event"; import { renderMarkdown } from "../resources/render-markdown"; +const _blockQuoteToAlert = { Note: "info", Warning: "warning" }; + @customElement("ha-markdown-element") class HaMarkdownElement extends ReactiveElement { @property() public content?; @@ -65,6 +67,34 @@ class HaMarkdownElement extends ReactiveElement { node.loading = "lazy"; } node.addEventListener("load", this._resize); + } else if (node instanceof HTMLQuoteElement) { + // Map GitHub blockquote elements to our ha-alert element + const firstElementChild = node.firstElementChild; + const quoteTitleElement = firstElementChild?.firstElementChild; + const quoteType = + quoteTitleElement?.textContent && + _blockQuoteToAlert[quoteTitleElement.textContent]; + + // GitHub is strict on how these are defined, we need to make sure we know what we have before starting to replace it + if (quoteTitleElement?.nodeName === "STRONG" && quoteType) { + const alertNote = document.createElement("ha-alert"); + alertNote.alertType = quoteType; + alertNote.title = + (firstElementChild!.childNodes[1].nodeName === "#text" && + firstElementChild!.childNodes[1].textContent?.trimStart()) || + ""; + + const childNodes = Array.from(firstElementChild!.childNodes); + for (const child of childNodes.slice( + childNodes.findIndex( + // There is always a line break between the title and the content, we want to skip that + (childNode) => childNode instanceof HTMLBRElement + ) + 1 + )) { + alertNote.appendChild(child); + } + node.firstElementChild!.replaceWith(alertNote); + } } } } diff --git a/src/components/ha-picture-upload.ts b/src/components/ha-picture-upload.ts index 15d2fef7ae..0a21f82ec7 100644 --- a/src/components/ha-picture-upload.ts +++ b/src/components/ha-picture-upload.ts @@ -2,7 +2,7 @@ import { mdiImagePlus } from "@mdi/js"; import { html, LitElement, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators"; import { fireEvent } from "../common/dom/fire_event"; -import { createImage, generateImageThumbnailUrl } from "../data/image"; +import { createImage, generateImageThumbnailUrl } from "../data/image_upload"; import { showAlertDialog } from "../dialogs/generic/show-dialog-box"; import { CropOptions, diff --git a/src/components/ha-sidebar.ts b/src/components/ha-sidebar.ts index f443ca9eee..0f95bbae23 100644 --- a/src/components/ha-sidebar.ts +++ b/src/components/ha-sidebar.ts @@ -23,19 +23,19 @@ import "@polymer/paper-item/paper-item"; import "@polymer/paper-listbox/paper-listbox"; import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { - css, CSSResult, CSSResultGroup, - html, LitElement, - nothing, PropertyValues, + css, + html, + nothing, } from "lit"; import { customElement, eventOptions, property, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import { guard } from "lit/directives/guard"; import memoizeOne from "memoize-one"; -import { LocalStorage } from "../common/decorators/local-storage"; +import { storage } from "../common/decorators/storage"; import { fireEvent } from "../common/dom/fire_event"; import { toggleAttribute } from "../common/dom/toggle_attribute"; import { stringCompare } from "../common/string/compare"; @@ -47,10 +47,10 @@ import { subscribeNotifications, } from "../data/persistent_notification"; import { subscribeRepairsIssueRegistry } from "../data/repairs"; -import { updateCanInstall, UpdateEntity } from "../data/update"; +import { UpdateEntity, updateCanInstall } from "../data/update"; import { SubscribeMixin } from "../mixins/subscribe-mixin"; import { actionHandler } from "../panels/lovelace/common/directives/action-handler-directive"; -import { loadSortable, SortableInstance } from "../resources/sortable.ondemand"; +import { SortableInstance, loadSortable } from "../resources/sortable.ondemand"; import { haStyleScrollbar } from "../resources/styles"; import type { HomeAssistant, PanelInfo, Route } from "../types"; import "./ha-icon"; @@ -214,15 +214,17 @@ class HaSidebar extends SubscribeMixin(LitElement) { private sortableStyleLoaded = false; - // @ts-ignore - @LocalStorage("sidebarPanelOrder", true, { - attribute: false, + @storage({ + key: "sidebarPanelOrder", + state: true, + subscribe: true, }) private _panelOrder: string[] = []; - // @ts-ignore - @LocalStorage("sidebarHiddenPanels", true, { - attribute: false, + @storage({ + key: "sidebarHiddenPanels", + state: true, + subscribe: true, }) private _hiddenPanels: string[] = []; diff --git a/src/components/media-player/ha-browse-media-tts.ts b/src/components/media-player/ha-browse-media-tts.ts index d16f2bbe15..39e36b60b7 100644 --- a/src/components/media-player/ha-browse-media-tts.ts +++ b/src/components/media-player/ha-browse-media-tts.ts @@ -1,7 +1,7 @@ import "@material/mwc-list/mwc-list-item"; import { css, html, LitElement, nothing, PropertyValues } from "lit"; import { customElement, property, state } from "lit/decorators"; -import { LocalStorage } from "../../common/decorators/local-storage"; +import { storage } from "../../common/decorators/storage"; import { fireEvent } from "../../common/dom/fire_event"; import { MediaPlayerBrowseAction, @@ -43,7 +43,12 @@ class BrowseMediaTTS extends LitElement { @state() private _provider?: TTSEngine; - @LocalStorage("TtsMessage", true, false) private _message!: string; + @storage({ + key: "TtsMessage", + state: true, + subscribe: false, + }) + private _message!: string; protected render() { return html` diff --git a/src/data/image.ts b/src/data/image.ts index 94198de6dc..f28ea74860 100644 --- a/src/data/image.ts +++ b/src/data/image.ts @@ -1,54 +1,15 @@ -import { HomeAssistant } from "../types"; +import { + HassEntityAttributeBase, + HassEntityBase, +} from "home-assistant-js-websocket"; -interface Image { - filesize: number; - name: string; - uploaded_at: string; // isoformat date - content_type: string; - id: string; +interface ImageEntityAttributes extends HassEntityAttributeBase { + access_token: string; } -export interface ImageMutableParams { - name: string; +export interface ImageEntity extends HassEntityBase { + attributes: ImageEntityAttributes; } -export const generateImageThumbnailUrl = (mediaId: string, size: number) => - `/api/image/serve/${mediaId}/${size}x${size}`; - -export const fetchImages = (hass: HomeAssistant) => - hass.callWS({ type: "image/list" }); - -export const createImage = async ( - hass: HomeAssistant, - file: File -): Promise => { - const fd = new FormData(); - fd.append("file", file); - const resp = await hass.fetchWithAuth("/api/image/upload", { - method: "POST", - body: fd, - }); - if (resp.status === 413) { - throw new Error(`Uploaded image is too large (${file.name})`); - } else if (resp.status !== 200) { - throw new Error("Unknown error"); - } - return resp.json(); -}; - -export const updateImage = ( - hass: HomeAssistant, - id: string, - updates: Partial -) => - hass.callWS({ - type: "image/update", - media_id: id, - ...updates, - }); - -export const deleteImage = (hass: HomeAssistant, id: string) => - hass.callWS({ - type: "image/delete", - media_id: id, - }); +export const computeImageUrl = (entity: ImageEntity): string => + `/api/image_proxy/${entity.entity_id}?token=${entity.attributes.access_token}&state=${entity.state}`; diff --git a/src/data/image_upload.ts b/src/data/image_upload.ts new file mode 100644 index 0000000000..94198de6dc --- /dev/null +++ b/src/data/image_upload.ts @@ -0,0 +1,54 @@ +import { HomeAssistant } from "../types"; + +interface Image { + filesize: number; + name: string; + uploaded_at: string; // isoformat date + content_type: string; + id: string; +} + +export interface ImageMutableParams { + name: string; +} + +export const generateImageThumbnailUrl = (mediaId: string, size: number) => + `/api/image/serve/${mediaId}/${size}x${size}`; + +export const fetchImages = (hass: HomeAssistant) => + hass.callWS({ type: "image/list" }); + +export const createImage = async ( + hass: HomeAssistant, + file: File +): Promise => { + const fd = new FormData(); + fd.append("file", file); + const resp = await hass.fetchWithAuth("/api/image/upload", { + method: "POST", + body: fd, + }); + if (resp.status === 413) { + throw new Error(`Uploaded image is too large (${file.name})`); + } else if (resp.status !== 200) { + throw new Error("Unknown error"); + } + return resp.json(); +}; + +export const updateImage = ( + hass: HomeAssistant, + id: string, + updates: Partial +) => + hass.callWS({ + type: "image/update", + media_id: id, + ...updates, + }); + +export const deleteImage = (hass: HomeAssistant, id: string) => + hass.callWS({ + type: "image/delete", + media_id: id, + }); diff --git a/src/data/recorder.ts b/src/data/recorder.ts index 23752d16f2..32b471f4a7 100644 --- a/src/data/recorder.ts +++ b/src/data/recorder.ts @@ -260,10 +260,10 @@ export const calculateStatisticsSumGrowth = ( export const statisticsHaveType = ( stats: StatisticValue[], type: StatisticType -) => stats.some((stat) => stat[type] !== null); +) => stats.some((stat) => stat[type] !== undefined && stat[type] !== null); const mean_stat_types: readonly StatisticType[] = ["mean", "min", "max"]; -const sum_stat_types: readonly StatisticType[] = ["sum"]; +const sum_stat_types: readonly StatisticType[] = ["sum", "state", "change"]; export const statisticsMetaHasType = ( metadata: StatisticsMetaData, diff --git a/src/dialogs/more-info/const.ts b/src/dialogs/more-info/const.ts index 79af1e13d3..d6d6d615aa 100644 --- a/src/dialogs/more-info/const.ts +++ b/src/dialogs/more-info/const.ts @@ -40,6 +40,7 @@ export const DOMAINS_WITH_MORE_INFO = [ "fan", "group", "humidifier", + "image", "input_boolean", "input_datetime", "light", diff --git a/src/dialogs/more-info/controls/more-info-image.ts b/src/dialogs/more-info/controls/more-info-image.ts new file mode 100644 index 0000000000..d54ed0cf43 --- /dev/null +++ b/src/dialogs/more-info/controls/more-info-image.ts @@ -0,0 +1,40 @@ +import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; +import { customElement, property } from "lit/decorators"; +import "../../../components/ha-camera-stream"; +import { computeImageUrl, ImageEntity } from "../../../data/image"; +import type { HomeAssistant } from "../../../types"; + +@customElement("more-info-image") +class MoreInfoImage extends LitElement { + @property({ attribute: false }) public hass?: HomeAssistant; + + @property({ attribute: false }) public stateObj?: ImageEntity; + + protected render() { + if (!this.hass || !this.stateObj) { + return nothing; + } + return html`${this.stateObj.attributes.friendly_name `; + } + + static get styles(): CSSResultGroup { + return css` + :host { + display: block; + text-align: center; + } + img { + max-width: 100%; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "more-info-image": MoreInfoImage; + } +} diff --git a/src/dialogs/more-info/state_more_info_control.ts b/src/dialogs/more-info/state_more_info_control.ts index 7c85f0db71..cc0d07eb67 100644 --- a/src/dialogs/more-info/state_more_info_control.ts +++ b/src/dialogs/more-info/state_more_info_control.ts @@ -18,6 +18,7 @@ const LAZY_LOADED_MORE_INFO_CONTROL = { fan: () => import("./controls/more-info-fan"), group: () => import("./controls/more-info-group"), humidifier: () => import("./controls/more-info-humidifier"), + image: () => import("./controls/more-info-image"), input_boolean: () => import("./controls/more-info-input_boolean"), input_datetime: () => import("./controls/more-info-input_datetime"), light: () => import("./controls/more-info-light"), diff --git a/src/dialogs/tts-try/dialog-tts-try.ts b/src/dialogs/tts-try/dialog-tts-try.ts index 113b5345ca..0854133bba 100644 --- a/src/dialogs/tts-try/dialog-tts-try.ts +++ b/src/dialogs/tts-try/dialog-tts-try.ts @@ -1,7 +1,7 @@ import { mdiPlayCircleOutline } from "@mdi/js"; import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; import { customElement, property, query, state } from "lit/decorators"; -import { LocalStorage } from "../../common/decorators/local-storage"; +import { storage } from "../../common/decorators/storage"; import { fireEvent } from "../../common/dom/fire_event"; import "../../components/ha-button"; import { createCloseHeading } from "../../components/ha-dialog"; @@ -25,10 +25,12 @@ export class TTSTryDialog extends LitElement { @query("#message") private _messageInput?: HaTextArea; - @LocalStorage("ttsTryMessages", false, false) private _messages?: Record< - string, - string - >; + @storage({ + key: "ttsTryMessages", + state: false, + subscribe: false, + }) + private _messages?: Record; public showDialog(params: TTSTryDialogParams) { this._params = params; diff --git a/src/dialogs/voice-command-dialog/ha-voice-command-dialog.ts b/src/dialogs/voice-command-dialog/ha-voice-command-dialog.ts index b3a83bfce5..1aa6695ba2 100644 --- a/src/dialogs/voice-command-dialog/ha-voice-command-dialog.ts +++ b/src/dialogs/voice-command-dialog/ha-voice-command-dialog.ts @@ -18,7 +18,7 @@ import { TemplateResult, } from "lit"; import { customElement, property, query, state } from "lit/decorators"; -import { LocalStorage } from "../../common/decorators/local-storage"; +import { storage } from "../../common/decorators/storage"; import { fireEvent } from "../../common/dom/fire_event"; import { stopPropagation } from "../../common/dom/stop_propagation"; import "../../components/ha-button"; @@ -57,7 +57,12 @@ export class HaVoiceCommandDialog extends LitElement { @state() private _opened = false; - @LocalStorage("AssistPipelineId", true, false) private _pipelineId?: string; + @storage({ + key: "AssistPipelineId", + state: true, + subscribe: false, + }) + private _pipelineId?: string; @state() private _pipeline?: AssistPipeline; diff --git a/src/layouts/hass-subpage.ts b/src/layouts/hass-subpage.ts index 5c2d73436e..75decf778a 100644 --- a/src/layouts/hass-subpage.ts +++ b/src/layouts/hass-subpage.ts @@ -13,6 +13,7 @@ import { computeRTL } from "../common/util/compute_rtl"; import "../components/ha-icon-button-arrow-prev"; import "../components/ha-menu-button"; import { HomeAssistant } from "../types"; +import { haStyleScrollbar } from "../resources/styles"; @customElement("hass-subpage") class HassSubpage extends LitElement { @@ -73,7 +74,9 @@ class HassSubpage extends LitElement {
${this.header}
-
+
+ +
@@ -94,88 +97,91 @@ class HassSubpage extends LitElement { } static get styles(): CSSResultGroup { - return css` - :host { - display: block; - height: 100%; - background-color: var(--primary-background-color); - overflow: hidden; - position: relative; - } - - :host([narrow]) { - width: 100%; - position: fixed; - } - - .toolbar { - display: flex; - align-items: center; - font-size: 20px; - height: var(--header-height); - padding: 8px 12px; - pointer-events: none; - background-color: var(--app-header-background-color); - font-weight: 400; - color: var(--app-header-text-color, white); - border-bottom: var(--app-header-border-bottom, none); - box-sizing: border-box; - } - @media (max-width: 599px) { - .toolbar { - padding: 4px; + return [ + haStyleScrollbar, + css` + :host { + display: block; + height: 100%; + background-color: var(--primary-background-color); + overflow: hidden; + position: relative; } - } - .toolbar a { - color: var(--sidebar-text-color); - text-decoration: none; - } - ha-menu-button, - ha-icon-button-arrow-prev, - ::slotted([slot="toolbar-icon"]) { - pointer-events: auto; - color: var(--sidebar-icon-color); - } + :host([narrow]) { + width: 100%; + position: fixed; + } - .main-title { - margin: 0 0 0 24px; - line-height: 20px; - flex-grow: 1; - } + .toolbar { + display: flex; + align-items: center; + font-size: 20px; + height: var(--header-height); + padding: 8px 12px; + pointer-events: none; + background-color: var(--app-header-background-color); + font-weight: 400; + color: var(--app-header-text-color, white); + border-bottom: var(--app-header-border-bottom, none); + box-sizing: border-box; + } + @media (max-width: 599px) { + .toolbar { + padding: 4px; + } + } + .toolbar a { + color: var(--sidebar-text-color); + text-decoration: none; + } - .content { - position: relative; - width: 100%; - height: calc(100% - 1px - var(--header-height)); - overflow-y: auto; - overflow: auto; - -webkit-overflow-scrolling: touch; - } + ha-menu-button, + ha-icon-button-arrow-prev, + ::slotted([slot="toolbar-icon"]) { + pointer-events: auto; + color: var(--sidebar-icon-color); + } - #fab { - position: absolute; - right: calc(16px + env(safe-area-inset-right)); - bottom: calc(16px + env(safe-area-inset-bottom)); - z-index: 1; - } - :host([narrow]) #fab.tabs { - bottom: calc(84px + env(safe-area-inset-bottom)); - } - #fab[is-wide] { - bottom: 24px; - right: 24px; - } - :host([rtl]) #fab { - right: auto; - left: calc(16px + env(safe-area-inset-left)); - } - :host([rtl][is-wide]) #fab { - bottom: 24px; - left: 24px; - right: auto; - } - `; + .main-title { + margin: 0 0 0 24px; + line-height: 20px; + flex-grow: 1; + } + + .content { + position: relative; + width: 100%; + height: calc(100% - 1px - var(--header-height)); + overflow-y: auto; + overflow: auto; + -webkit-overflow-scrolling: touch; + } + + #fab { + position: absolute; + right: calc(16px + env(safe-area-inset-right)); + bottom: calc(16px + env(safe-area-inset-bottom)); + z-index: 1; + } + :host([narrow]) #fab.tabs { + bottom: calc(84px + env(safe-area-inset-bottom)); + } + #fab[is-wide] { + bottom: 24px; + right: 24px; + } + :host([rtl]) #fab { + right: auto; + left: calc(16px + env(safe-area-inset-left)); + } + :host([rtl][is-wide]) #fab { + bottom: 24px; + left: 24px; + right: auto; + } + `, + ]; } } diff --git a/src/panels/calendar/ha-panel-calendar.ts b/src/panels/calendar/ha-panel-calendar.ts index 43c824eff9..878d6348fa 100644 --- a/src/panels/calendar/ha-panel-calendar.ts +++ b/src/panels/calendar/ha-panel-calendar.ts @@ -11,7 +11,7 @@ import { } from "lit"; import { customElement, property, state } from "lit/decorators"; import { styleMap } from "lit/directives/style-map"; -import { LocalStorage } from "../../common/decorators/local-storage"; +import { storage } from "../../common/decorators/storage"; import { HASSDomEvent } from "../../common/dom/fire_event"; import { computeStateName } from "../../common/entity/compute_state_name"; import "../../components/ha-card"; @@ -41,7 +41,10 @@ class PanelCalendar extends LitElement { @state() private _error?: string = undefined; - @LocalStorage("deSelectedCalendars", true) + @storage({ + key: "deSelectedCalendars", + state: true, + }) private _deSelectedCalendars: string[] = []; private _start?: Date; diff --git a/src/panels/config/automation/manual-automation-editor.ts b/src/panels/config/automation/manual-automation-editor.ts index 7324f8d5a4..c8cf4fa69b 100644 --- a/src/panels/config/automation/manual-automation-editor.ts +++ b/src/panels/config/automation/manual-automation-editor.ts @@ -20,7 +20,7 @@ import { documentationUrl } from "../../../util/documentation-url"; import "./action/ha-automation-action"; import "./condition/ha-automation-condition"; import "./trigger/ha-automation-trigger"; -import { LocalStorage } from "../../../common/decorators/local-storage"; +import { storage } from "../../../common/decorators/storage"; @customElement("manual-automation-editor") export class HaManualAutomationEditor extends LitElement { @@ -36,7 +36,12 @@ export class HaManualAutomationEditor extends LitElement { @property({ attribute: false }) public stateObj?: HassEntity; - @LocalStorage("automationClipboard", true, false, window.sessionStorage) + @storage({ + key: "automationClipboard", + state: true, + subscribe: false, + storage: "sessionStorage", + }) private _clipboard: Clipboard = {}; protected render() { diff --git a/src/panels/config/cloud/account/dialog-cloud-tts-try.ts b/src/panels/config/cloud/account/dialog-cloud-tts-try.ts index effed9dea4..bd079a5854 100644 --- a/src/panels/config/cloud/account/dialog-cloud-tts-try.ts +++ b/src/panels/config/cloud/account/dialog-cloud-tts-try.ts @@ -3,7 +3,7 @@ import "@material/mwc-list/mwc-list-item"; import { mdiPlayCircleOutline, mdiRobot } from "@mdi/js"; import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; import { customElement, property, query, state } from "lit/decorators"; -import { LocalStorage } from "../../../../common/decorators/local-storage"; +import { storage } from "../../../../common/decorators/storage"; import { fireEvent } from "../../../../common/dom/fire_event"; import { stopPropagation } from "../../../../common/dom/stop_propagation"; import { computeStateDomain } from "../../../../common/entity/compute_state_domain"; @@ -31,9 +31,19 @@ export class DialogTryTts extends LitElement { @query("#message") private _messageInput?: HaTextArea; - @LocalStorage("cloudTtsTryMessage", false, false) private _message!: string; + @storage({ + key: "cloudTtsTryMessage", + state: false, + subscribe: false, + }) + private _message!: string; - @LocalStorage("cloudTtsTryTarget", false, false) private _target!: string; + @storage({ + key: "cloudTtsTryTarget", + state: false, + subscribe: false, + }) + private _target!: string; public showDialog(params: TryTtsDialogParams) { this._params = params; diff --git a/src/panels/config/integrations/integration-panels/mqtt/mqtt-config-panel.ts b/src/panels/config/integrations/integration-panels/mqtt/mqtt-config-panel.ts index 297ad0c9d9..032ebf38ed 100644 --- a/src/panels/config/integrations/integration-panels/mqtt/mqtt-config-panel.ts +++ b/src/panels/config/integrations/integration-panels/mqtt/mqtt-config-panel.ts @@ -1,7 +1,7 @@ import "@material/mwc-button"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { customElement, property } from "lit/decorators"; -import { LocalStorage } from "../../../../../common/decorators/local-storage"; +import { storage } from "../../../../../common/decorators/storage"; import "../../../../../components/ha-card"; import "../../../../../components/ha-code-editor"; import "../../../../../components/ha-formfield"; @@ -21,19 +21,39 @@ class HaPanelDevMqtt extends LitElement { @property({ type: Boolean }) public narrow!: boolean; - @LocalStorage("panel-dev-mqtt-topic-ls", true, false) + @storage({ + key: "panel-dev-mqtt-topic-ls", + state: true, + subscribe: false, + }) private _topic = ""; - @LocalStorage("panel-dev-mqtt-payload-ls", true, false) + @storage({ + key: "panel-dev-mqtt-payload-ls", + state: true, + subscribe: false, + }) private _payload = ""; - @LocalStorage("panel-dev-mqtt-qos-ls", true, false) + @storage({ + key: "panel-dev-mqtt-qos-ls", + state: true, + subscribe: false, + }) private _qos = "0"; - @LocalStorage("panel-dev-mqtt-retain-ls", true, false) + @storage({ + key: "panel-dev-mqtt-retain-ls", + state: true, + subscribe: false, + }) private _retain = false; - @LocalStorage("panel-dev-mqtt-allow-template-ls", true, false) + @storage({ + key: "panel-dev-mqtt-allow-template-ls", + state: true, + subscribe: false, + }) private _allowTemplate = false; protected render(): TemplateResult { diff --git a/src/panels/config/integrations/integration-panels/mqtt/mqtt-subscribe-card.ts b/src/panels/config/integrations/integration-panels/mqtt/mqtt-subscribe-card.ts index bb04eb4f78..b1bf864377 100644 --- a/src/panels/config/integrations/integration-panels/mqtt/mqtt-subscribe-card.ts +++ b/src/panels/config/integrations/integration-panels/mqtt/mqtt-subscribe-card.ts @@ -8,7 +8,7 @@ import { formatTime } from "../../../../../common/datetime/format_time"; import { MQTTMessage, subscribeMQTTTopic } from "../../../../../data/mqtt"; import { HomeAssistant } from "../../../../../types"; import "@material/mwc-list/mwc-list-item"; -import { LocalStorage } from "../../../../../common/decorators/local-storage"; +import { storage } from "../../../../../common/decorators/storage"; import "../../../../../components/ha-formfield"; import "../../../../../components/ha-switch"; @@ -18,13 +18,25 @@ const qosLevel = ["0", "1", "2"]; class MqttSubscribeCard extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; - @LocalStorage("panel-dev-mqtt-topic-subscribe", true, false) + @storage({ + key: "panel-dev-mqtt-topic-subscribe", + state: true, + subscribe: false, + }) private _topic = ""; - @LocalStorage("panel-dev-mqtt-qos-subscribe", true, false) + @storage({ + key: "panel-dev-mqtt-qos-subscribe", + state: true, + subscribe: false, + }) private _qos = "0"; - @LocalStorage("panel-dev-mqtt-json-format", true, false) + @storage({ + key: "panel-dev-mqtt-json-format", + state: true, + subscribe: false, + }) private _json_format = false; @state() private _subscribed?: () => void; diff --git a/src/panels/config/script/manual-script-editor.ts b/src/panels/config/script/manual-script-editor.ts index 333c052500..585623dc3b 100644 --- a/src/panels/config/script/manual-script-editor.ts +++ b/src/panels/config/script/manual-script-editor.ts @@ -3,7 +3,7 @@ import { mdiHelpCircle } from "@mdi/js"; import deepClone from "deep-clone-simple"; import { css, CSSResultGroup, html, LitElement } from "lit"; import { customElement, property } from "lit/decorators"; -import { LocalStorage } from "../../../common/decorators/local-storage"; +import { storage } from "../../../common/decorators/storage"; import { fireEvent } from "../../../common/dom/fire_event"; import "../../../components/ha-card"; import "../../../components/ha-icon-button"; @@ -26,7 +26,12 @@ export class HaManualScriptEditor extends LitElement { @property({ attribute: false }) public config!: ScriptConfig; - @LocalStorage("automationClipboard", true, false, window.sessionStorage) + @storage({ + key: "automationClipboard", + state: true, + subscribe: false, + storage: "sessionStorage", + }) private _clipboard: Clipboard = {}; protected render() { diff --git a/src/panels/config/voice-assistants/debug/assist-render-pipeline-run.ts b/src/panels/config/voice-assistants/debug/assist-render-pipeline-run.ts index 739a829d75..f30d5cf5d7 100644 --- a/src/panels/config/voice-assistants/debug/assist-render-pipeline-run.ts +++ b/src/panels/config/voice-assistants/debug/assist-render-pipeline-run.ts @@ -200,7 +200,7 @@ export class AssistPipelineDebug extends LitElement {
- Speech-to-Text + Speech-to-text ${renderProgress(this.hass, this.pipelineRun, "stt")}
${this.pipelineRun.stt @@ -274,7 +274,7 @@ export class AssistPipelineDebug extends LitElement {
- Text-to-Speech + Text-to-speech ${renderProgress(this.hass, this.pipelineRun, "tts")}
${this.pipelineRun.tts diff --git a/src/panels/developer-tools/service/developer-tools-service.ts b/src/panels/developer-tools/service/developer-tools-service.ts index a169b0fb27..6096bd16b4 100644 --- a/src/panels/developer-tools/service/developer-tools-service.ts +++ b/src/panels/developer-tools/service/developer-tools-service.ts @@ -4,7 +4,7 @@ import { load } from "js-yaml"; import { css, CSSResultGroup, html, LitElement } from "lit"; import { property, query, state } from "lit/decorators"; import memoizeOne from "memoize-one"; -import { LocalStorage } from "../../../common/decorators/local-storage"; +import { storage } from "../../../common/decorators/storage"; import { computeDomain } from "../../../common/entity/compute_domain"; import { computeObjectId } from "../../../common/entity/compute_object_id"; import { hasTemplate } from "../../../common/string/has-template"; @@ -38,10 +38,18 @@ class HaPanelDevService extends LitElement { @state() private _uiAvailable = true; - @LocalStorage("panel-dev-service-state-service-data", true, false) + @storage({ + key: "panel-dev-service-state-service-data", + state: true, + subscribe: false, + }) private _serviceData?: ServiceAction = { service: "", target: {}, data: {} }; - @LocalStorage("panel-dev-service-state-yaml-mode", true, false) + @storage({ + key: "panel-dev-service-state-yaml-mode", + state: true, + subscribe: false, + }) private _yamlMode = false; @query("ha-yaml-editor") private _yamlEditor?: HaYamlEditor; diff --git a/src/panels/history/ha-panel-history.ts b/src/panels/history/ha-panel-history.ts index bb3c224750..94d5ab6c15 100644 --- a/src/panels/history/ha-panel-history.ts +++ b/src/panels/history/ha-panel-history.ts @@ -7,7 +7,7 @@ import { import { css, html, LitElement, PropertyValues } from "lit"; import { property, query, state } from "lit/decorators"; import { ensureArray } from "../../common/array/ensure-array"; -import { LocalStorage } from "../../common/decorators/local-storage"; +import { storage } from "../../common/decorators/storage"; import { navigate } from "../../common/navigate"; import { constructUrlCurrentPath } from "../../common/url/construct-url"; import { @@ -58,7 +58,11 @@ class HaPanelHistory extends SubscribeMixin(LitElement) { @state() private _endDate: Date; - @LocalStorage("historyPickedValue", true, false) + @storage({ + key: "historyPickedValue", + state: true, + subscribe: false, + }) private _targetPickerValue?: HassServiceTarget; @state() private _isLoading = false; diff --git a/src/panels/lovelace/cards/hui-entity-card.ts b/src/panels/lovelace/cards/hui-entity-card.ts index 548dbd80ec..7424877f52 100644 --- a/src/panels/lovelace/cards/hui-entity-card.ts +++ b/src/panels/lovelace/cards/hui-entity-card.ts @@ -4,14 +4,15 @@ import { CSSResultGroup, html, LitElement, - PropertyValues, nothing, + PropertyValues, } from "lit"; import { customElement, property, state } from "lit/decorators"; import { ifDefined } from "lit/directives/if-defined"; import { styleMap } from "lit/directives/style-map"; import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; import { fireEvent } from "../../../common/dom/fire_event"; +import { computeAttributeValueDisplay } from "../../../common/entity/compute_attribute_display"; import { computeStateDisplay } from "../../../common/entity/compute_state_display"; import { computeStateDomain } from "../../../common/entity/compute_state_domain"; import { computeStateName } from "../../../common/entity/compute_state_name"; @@ -27,7 +28,6 @@ import "../../../components/ha-card"; import "../../../components/ha-icon"; import { HVAC_ACTION_TO_MODE } from "../../../data/climate"; import { isUnavailableState } from "../../../data/entity"; -import { computeAttributeValueDisplay } from "../../../common/entity/compute_attribute_display"; import { LightEntity } from "../../../data/light"; import { HomeAssistant } from "../../../types"; import { computeCardSize } from "../common/compute-card-size"; @@ -35,21 +35,12 @@ import { findEntities } from "../common/find-entities"; import { hasConfigOrEntityChanged } from "../common/has-changed"; import { createEntityNotFoundWarning } from "../components/hui-warning"; import { createHeaderFooterElement } from "../create-element/create-header-footer-element"; -import { - LovelaceCard, - LovelaceCardEditor, - LovelaceHeaderFooter, -} from "../types"; +import { LovelaceCard, LovelaceHeaderFooter } from "../types"; import { HuiErrorCard } from "./hui-error-card"; import { EntityCardConfig } from "./types"; @customElement("hui-entity-card") export class HuiEntityCard extends LitElement implements LovelaceCard { - public static async getConfigElement(): Promise { - await import("../editor/config-elements/hui-entity-card-editor"); - return document.createElement("hui-entity-card-editor"); - } - public static getStubConfig( hass: HomeAssistant, entities: string[], @@ -70,6 +61,11 @@ export class HuiEntityCard extends LitElement implements LovelaceCard { }; } + public static async getConfigForm() { + return (await import("../editor/config-elements/hui-entity-card-editor")) + .default; + } + @property({ attribute: false }) public hass?: HomeAssistant; @state() private _config?: EntityCardConfig; diff --git a/src/panels/lovelace/cards/hui-picture-card.ts b/src/panels/lovelace/cards/hui-picture-card.ts index c72bf72bc9..843d447a67 100644 --- a/src/panels/lovelace/cards/hui-picture-card.ts +++ b/src/panels/lovelace/cards/hui-picture-card.ts @@ -3,19 +3,22 @@ import { CSSResultGroup, html, LitElement, - PropertyValues, nothing, + PropertyValues, } from "lit"; import { customElement, property } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import { ifDefined } from "lit/directives/if-defined"; import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; import "../../../components/ha-card"; +import { computeImageUrl, ImageEntity } from "../../../data/image"; import { ActionHandlerEvent } from "../../../data/lovelace"; import { HomeAssistant } from "../../../types"; import { actionHandler } from "../common/directives/action-handler-directive"; import { handleAction } from "../common/handle-action"; import { hasAction } from "../common/has-action"; +import { hasConfigChanged } from "../common/has-changed"; +import { createEntityNotFoundWarning } from "../components/hui-warning"; import { LovelaceCard, LovelaceCardEditor } from "../types"; import { PictureCardConfig } from "./types"; @@ -30,8 +33,6 @@ export class HuiPictureCard extends LitElement implements LovelaceCard { return { type: "picture", image: "https://demo.home-assistant.io/stub_config/t-shirt-promo.png", - tap_action: { action: "none" }, - hold_action: { action: "none" }, }; } @@ -44,7 +45,7 @@ export class HuiPictureCard extends LitElement implements LovelaceCard { } public setConfig(config: PictureCardConfig): void { - if (!config || !config.image) { + if (!config || (!config.image && !config.image_entity)) { throw new Error("Image required"); } @@ -52,10 +53,21 @@ export class HuiPictureCard extends LitElement implements LovelaceCard { } protected shouldUpdate(changedProps: PropertyValues): boolean { - if (changedProps.size === 1 && changedProps.has("hass")) { - return !changedProps.get("hass"); + if (!this._config || hasConfigChanged(this, changedProps)) { + return true; } - return true; + if (this._config.image_entity && changedProps.has("hass")) { + const oldHass = changedProps.get("hass") as HomeAssistant | undefined; + if ( + !oldHass || + oldHass.states[this._config.image_entity] !== + this.hass!.states[this._config.image_entity] + ) { + return true; + } + } + + return false; } protected updated(changedProps: PropertyValues): void { @@ -83,6 +95,17 @@ export class HuiPictureCard extends LitElement implements LovelaceCard { return nothing; } + let stateObj: ImageEntity | undefined; + + if (this._config.image_entity) { + stateObj = this.hass.states[this._config.image_entity] as ImageEntity; + if (!stateObj) { + return html` + ${createEntityNotFoundWarning(this.hass, this._config.image_entity)} + `; + } + } + return html` ${this._config.alt_text} `; diff --git a/src/panels/lovelace/cards/hui-picture-elements-card.ts b/src/panels/lovelace/cards/hui-picture-elements-card.ts index fad22f2c66..15c7f02e1f 100644 --- a/src/panels/lovelace/cards/hui-picture-elements-card.ts +++ b/src/panels/lovelace/cards/hui-picture-elements-card.ts @@ -9,6 +9,7 @@ import { import { customElement, property, state } from "lit/decorators"; import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; import "../../../components/ha-card"; +import { ImageEntity, computeImageUrl } from "../../../data/image"; import { HomeAssistant } from "../../../types"; import { findEntities } from "../common/find-entities"; import { LovelaceElement, LovelaceElementConfig } from "../elements/types"; @@ -62,7 +63,12 @@ class HuiPictureElementsCard extends LitElement implements LovelaceCard { if (!config) { throw new Error("Invalid configuration"); } else if ( - !(config.image || config.camera_image || config.state_image) || + !( + config.image || + config.image_entity || + config.camera_image || + config.state_image + ) || (config.state_image && !config.entity) ) { throw new Error("Image required"); @@ -115,12 +121,17 @@ class HuiPictureElementsCard extends LitElement implements LovelaceCard { return nothing; } + let stateObj: ImageEntity | undefined; + if (this._config.image_entity) { + stateObj = this.hass.states[this._config.image_entity] as ImageEntity; + } + return html`
${entityState}
`; } + const domain = computeDomain(this._config.entity); + return html`
${this._config.title - ? html`
${this._config.title}
` + ? html`
${this._config.title}
` : ""}
${this._entitiesDialog!.map((entityConf) => diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index fac076ad93..082b6c4b53 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -335,6 +335,7 @@ export interface StatisticCardConfig extends LovelaceCardConfig { export interface PictureCardConfig extends LovelaceCardConfig { image?: string; + image_entity?: string; tap_action?: ActionConfig; hold_action?: ActionConfig; double_tap_action?: ActionConfig; @@ -345,6 +346,7 @@ export interface PictureCardConfig extends LovelaceCardConfig { export interface PictureElementsCardConfig extends LovelaceCardConfig { title?: string; image?: string; + image_entity?: string; camera_image?: string; camera_view?: HuiImage["cameraView"]; state_image?: Record; diff --git a/src/panels/lovelace/common/generate-lovelace-config.ts b/src/panels/lovelace/common/generate-lovelace-config.ts index 99bfbdc634..65b66c3ce1 100644 --- a/src/panels/lovelace/common/generate-lovelace-config.ts +++ b/src/panels/lovelace/common/generate-lovelace-config.ts @@ -20,6 +20,7 @@ import { AlarmPanelCardConfig, EntitiesCardConfig, HumidifierCardConfig, + PictureCardConfig, PictureEntityCardConfig, ThermostatCardConfig, } from "../cards/types"; @@ -125,6 +126,12 @@ export const computeCards = ( entity: entityId, }; cards.push(cardConfig); + } else if (domain === "image") { + const cardConfig: PictureCardConfig = { + type: "picture", + image_entity: entityId, + }; + cards.push(cardConfig); } else if (domain === "climate") { const cardConfig: ThermostatCardConfig = { type: "thermostat", diff --git a/src/panels/lovelace/common/handle-action.ts b/src/panels/lovelace/common/handle-action.ts index f782d6ad53..83deadf5db 100644 --- a/src/panels/lovelace/common/handle-action.ts +++ b/src/panels/lovelace/common/handle-action.ts @@ -18,6 +18,7 @@ declare global { export type ActionConfigParams = { entity?: string; camera_image?: string; + image_entity?: string; hold_action?: ActionConfig; tap_action?: ActionConfig; double_tap_action?: ActionConfig; @@ -87,9 +88,11 @@ export const handleAction = async ( switch (actionConfig.action) { case "more-info": { - if (config.entity || config.camera_image) { + if (config.entity || config.camera_image || config.image_entity) { fireEvent(node, "hass-more-info", { - entityId: config.entity ? config.entity : config.camera_image!, + entityId: (config.entity || + config.camera_image || + config.image_entity)!, }); } else { showToast(node, { diff --git a/src/panels/lovelace/components/hui-image.ts b/src/panels/lovelace/components/hui-image.ts index 56c0873608..33201fd7af 100644 --- a/src/panels/lovelace/components/hui-image.ts +++ b/src/panels/lovelace/components/hui-image.ts @@ -10,12 +10,14 @@ import { customElement, property, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import { styleMap } from "lit/directives/style-map"; import { STATES_OFF } from "../../../common/const"; +import { computeDomain } from "../../../common/entity/compute_domain"; import parseAspectRatio from "../../../common/util/parse-aspect-ratio"; import "../../../components/ha-camera-stream"; import type { HaCameraStream } from "../../../components/ha-camera-stream"; import "../../../components/ha-circular-progress"; import { CameraEntity, fetchThumbnailUrlWithCache } from "../../../data/camera"; import { UNAVAILABLE } from "../../../data/entity"; +import { computeImageUrl, ImageEntity } from "../../../data/image"; import { HomeAssistant } from "../../../types"; const UPDATE_INTERVAL = 10000; @@ -164,6 +166,8 @@ export class HuiImage extends LitElement { } } else if (this.darkModeImage && this.hass.themes.darkMode) { imageSrc = this.darkModeImage; + } else if (stateObj && computeDomain(stateObj.entity_id) === "image") { + imageSrc = computeImageUrl(stateObj as ImageEntity); } else { imageSrc = this.image; } diff --git a/src/panels/lovelace/editor/card-editor/hui-card-element-editor.ts b/src/panels/lovelace/editor/card-editor/hui-card-element-editor.ts index 08a0b9bd42..be036fb404 100644 --- a/src/panels/lovelace/editor/card-editor/hui-card-element-editor.ts +++ b/src/panels/lovelace/editor/card-editor/hui-card-element-editor.ts @@ -1,7 +1,7 @@ import { customElement } from "lit/decorators"; import type { LovelaceCardConfig } from "../../../../data/lovelace"; import { getCardElementClass } from "../../create-element/create-card-element"; -import type { LovelaceCardEditor } from "../../types"; +import type { LovelaceCardEditor, LovelaceConfigForm } from "../../types"; import { HuiElementEditor } from "../hui-element-editor"; @customElement("hui-card-element-editor") @@ -16,6 +16,17 @@ export class HuiCardElementEditor extends HuiElementEditor { return undefined; } + + protected async getConfigForm(): Promise { + const elClass = await getCardElementClass(this.configElementType!); + + // Check if a schema exists + if (elClass && elClass.getConfigForm) { + return elClass.getConfigForm(); + } + + return undefined; + } } declare global { diff --git a/src/panels/lovelace/editor/config-elements/hui-entity-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-entity-card-editor.ts index 3379792b6f..4c93882620 100644 --- a/src/panels/lovelace/editor/config-elements/hui-entity-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-entity-card-editor.ts @@ -1,16 +1,12 @@ -import { html, LitElement, nothing } from "lit"; -import { customElement, property, state } from "lit/decorators"; import { assert, assign, boolean, object, optional, string } from "superstruct"; -import { fireEvent } from "../../../../common/dom/fire_event"; -import "../../../../components/ha-form/ha-form"; -import type { SchemaUnion } from "../../../../components/ha-form/types"; -import type { HomeAssistant } from "../../../../types"; -import type { EntityCardConfig } from "../../cards/types"; +import { LocalizeFunc } from "../../../../common/translations/localize"; +import { HaFormSchema } from "../../../../components/ha-form/types"; +import { EntityCardConfig } from "../../cards/types"; import { headerFooterConfigStructs } from "../../header-footer/structs"; -import type { LovelaceCardEditor } from "../../types"; +import { LovelaceConfigForm } from "../../types"; import { baseLovelaceCardConfig } from "../structs/base-card-struct"; -const cardConfigStruct = assign( +const struct = assign( baseLovelaceCardConfig, object({ entity: optional(string()), @@ -54,67 +50,19 @@ const SCHEMA = [ { name: "state_color", selector: { boolean: {} } }, ], }, -] as const; - -@customElement("hui-entity-card-editor") -export class HuiEntityCardEditor - extends LitElement - implements LovelaceCardEditor -{ - @property({ attribute: false }) public hass?: HomeAssistant; - - @state() private _config?: EntityCardConfig; - - public setConfig(config: EntityCardConfig): void { - assert(config, cardConfigStruct); - this._config = config; - } - - protected render() { - if (!this.hass || !this._config) { - return nothing; - } - - return html` - - `; - } - - private _valueChanged(ev: CustomEvent): void { - const config = ev.detail.value; - Object.keys(config).forEach((k) => config[k] === "" && delete config[k]); - fireEvent(this, "config-changed", { config }); - } - - private _computeLabelCallback = (schema: SchemaUnion) => { - if (schema.name === "entity") { - return this.hass!.localize( - "ui.panel.lovelace.editor.card.generic.entity" - ); - } +] as HaFormSchema[]; +const entityCardConfigForm: LovelaceConfigForm = { + schema: SCHEMA, + assertConfig: (config: EntityCardConfig) => assert(config, struct), + computeLabel: (schema: HaFormSchema, localize: LocalizeFunc) => { if (schema.name === "theme") { - return `${this.hass!.localize( + return `${localize( "ui.panel.lovelace.editor.card.generic.theme" - )} (${this.hass!.localize( - "ui.panel.lovelace.editor.card.config.optional" - )})`; + )} (${localize("ui.panel.lovelace.editor.card.config.optional")})`; } + return localize(`ui.panel.lovelace.editor.card.generic.${schema.name}`); + }, +}; - return this.hass!.localize( - `ui.panel.lovelace.editor.card.generic.${schema.name}` - ); - }; -} - -declare global { - interface HTMLElementTagNameMap { - "hui-entity-card-editor": HuiEntityCardEditor; - } -} +export default entityCardConfigForm; diff --git a/src/panels/lovelace/editor/config-elements/hui-form-editor.ts b/src/panels/lovelace/editor/config-elements/hui-form-editor.ts new file mode 100644 index 0000000000..9db046adfe --- /dev/null +++ b/src/panels/lovelace/editor/config-elements/hui-form-editor.ts @@ -0,0 +1,82 @@ +import { CSSResultGroup, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter"; +import { LocalizeFunc } from "../../../../common/translations/localize"; +import "../../../../components/ha-form/ha-form"; +import type { HaFormSchema } from "../../../../components/ha-form/types"; +import { LovelaceCardConfig } from "../../../../data/lovelace"; +import type { HomeAssistant } from "../../../../types"; +import type { LovelaceGenericElementEditor } from "../../types"; +import { configElementStyle } from "./config-elements-style"; + +@customElement("hui-form-editor") +export class HuiFormEditor + extends LitElement + implements LovelaceGenericElementEditor +{ + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public schema!: HaFormSchema[]; + + @state() private _config?: LovelaceCardConfig; + + public assertConfig(_config: LovelaceCardConfig): void { + return undefined; + } + + public setConfig(config: LovelaceCardConfig): void { + this.assertConfig(config); + this._config = config; + } + + protected render() { + if (!this._config) { + return nothing; + } + + return html` + + `; + } + + public computeLabel = ( + _schema: HaFormSchema, + _localize: LocalizeFunc + ): string | undefined => undefined; + + public computeHelper = ( + _schema: HaFormSchema, + _localize: LocalizeFunc + ): string | undefined => undefined; + + private _computeLabelCallback = (schema: HaFormSchema) => + this.computeLabel(schema, this.hass.localize) || + this.hass.localize( + `ui.panel.lovelace.editor.card.generic.${schema.name}` + ) || + capitalizeFirstLetter(schema.name.split("_").join(" ")); + + private _computeHelperCallback = (schema: HaFormSchema) => + this.computeHelper(schema, this.hass.localize); + + private _valueChanged(ev: CustomEvent): void { + const config = ev.detail.value; + fireEvent(this, "config-changed", { config }); + } + + static styles: CSSResultGroup = configElementStyle; +} + +declare global { + interface HTMLElementTagNameMap { + "hui-form-editor": HuiFormEditor; + } +} diff --git a/src/panels/lovelace/editor/config-elements/hui-picture-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-picture-card-editor.ts index 29e001688d..1ce62d6a78 100644 --- a/src/panels/lovelace/editor/config-elements/hui-picture-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-picture-card-editor.ts @@ -1,22 +1,21 @@ -import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; +import { html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { assert, assign, object, optional, string } from "superstruct"; import { fireEvent } from "../../../../common/dom/fire_event"; +import { SchemaUnion } from "../../../../components/ha-form/types"; import "../../../../components/ha-theme-picker"; -import { ActionConfig } from "../../../../data/lovelace"; import { HomeAssistant } from "../../../../types"; import { PictureCardConfig } from "../../cards/types"; import "../../components/hui-action-editor"; import { LovelaceCardEditor } from "../../types"; import { actionConfigStruct } from "../structs/action-struct"; import { baseLovelaceCardConfig } from "../structs/base-card-struct"; -import { EditorTarget } from "../types"; -import { configElementStyle } from "./config-elements-style"; const cardConfigStruct = assign( baseLovelaceCardConfig, object({ image: optional(string()), + image_entity: optional(string()), tap_action: optional(actionConfigStruct), hold_action: optional(actionConfigStruct), theme: optional(string()), @@ -24,6 +23,21 @@ const cardConfigStruct = assign( }) ); +const SCHEMA = [ + { name: "image", selector: { text: {} } }, + { name: "image_entity", selector: { entity: { domain: "image" } } }, + { name: "alt_text", selector: { text: {} } }, + { name: "theme", selector: { theme: {} } }, + { + name: "tap_action", + selector: { ui_action: {} }, + }, + { + name: "hold_action", + selector: { ui_action: {} }, + }, +] as const; + @customElement("hui-picture-card-editor") export class HuiPictureCardEditor extends LitElement @@ -38,129 +52,45 @@ export class HuiPictureCardEditor this._config = config; } - get _image(): string { - return this._config!.image || ""; - } - - get _tap_action(): ActionConfig { - return this._config!.tap_action || { action: "none" }; - } - - get _hold_action(): ActionConfig { - return this._config!.hold_action || { action: "none" }; - } - - get _theme(): string { - return this._config!.theme || ""; - } - - get _alt_text(): string { - return this._config!.alt_text || ""; - } - protected render() { if (!this.hass || !this._config) { return nothing; } - const actions = ["navigate", "url", "call-service", "none"]; - return html` -
- - - - - -
+ `; } private _valueChanged(ev: CustomEvent): void { - if (!this._config || !this.hass) { - return; - } - const target = ev.target! as EditorTarget; - const value = ev.detail?.value ?? target.value; - - if (this[`_${target.configValue}`] === value) { - return; - } - if (target.configValue) { - if (value !== false && !value) { - this._config = { ...this._config }; - delete this._config[target.configValue!]; - } else { - this._config = { - ...this._config, - [target.configValue!]: value, - }; - } - } - fireEvent(this, "config-changed", { config: this._config }); + fireEvent(this, "config-changed", { config: ev.detail.value }); } - static get styles(): CSSResultGroup { - return [ - configElementStyle, - css` - ha-textfield { - display: block; - margin-bottom: 8px; - } - `, - ]; - } + private _computeLabelCallback = (schema: SchemaUnion) => { + switch (schema.name) { + case "theme": + return `${this.hass!.localize( + "ui.panel.lovelace.editor.card.generic.theme" + )} (${this.hass!.localize( + "ui.panel.lovelace.editor.card.config.optional" + )})`; + default: + return ( + this.hass!.localize( + `ui.panel.lovelace.editor.card.picture-card.${schema.name}` + ) || + this.hass!.localize( + `ui.panel.lovelace.editor.card.generic.${schema.name}` + ) + ); + } + }; } declare global { diff --git a/src/panels/lovelace/editor/config-elements/hui-picture-glance-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-picture-glance-card-editor.ts index 76c0634698..c6c3d12146 100644 --- a/src/panels/lovelace/editor/config-elements/hui-picture-glance-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-picture-glance-card-editor.ts @@ -22,6 +22,7 @@ const cardConfigStruct = assign( title: optional(string()), entity: optional(string()), image: optional(string()), + image_entity: optional(string()), camera_image: optional(string()), camera_view: optional(string()), aspect_ratio: optional(string()), @@ -35,6 +36,7 @@ const cardConfigStruct = assign( const SCHEMA = [ { name: "title", selector: { text: {} } }, { name: "image", selector: { text: {} } }, + { name: "image_entity", selector: { entity: { domain: "image" } } }, { name: "camera_image", selector: { entity: { domain: "camera" } } }, { name: "", diff --git a/src/panels/lovelace/editor/config-elements/hui-statistics-graph-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-statistics-graph-card-editor.ts index e16a629b8f..8cb52183c5 100644 --- a/src/panels/lovelace/editor/config-elements/hui-statistics-graph-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-statistics-graph-card-editor.ts @@ -187,7 +187,7 @@ export class HuiStatisticsGraphCardEditor ), disabled: !metaDatas || - !metaDatas.every((metaData) => + !metaDatas.some((metaData) => statisticsMetaHasType( metaData, supportedStatTypeMap[stat_type] @@ -246,12 +246,10 @@ export class HuiStatisticsGraphCardEditor ); const configured_stat_types = this._config!.stat_types ? ensureArray(this._config.stat_types) - : stat_types.filter( - (stat_type) => - stat_type !== "change" && - this._metaDatas?.every((metaData) => - statisticsMetaHasType(metaData, stat_type) - ) + : stat_types.filter((stat_type) => + this._metaDatas?.some((metaData) => + statisticsMetaHasType(metaData, stat_type) + ) ); const data = { chart_type: "line", @@ -320,9 +318,7 @@ export class HuiStatisticsGraphCardEditor : undefined; if (config.stat_types && config.entities.length) { config.stat_types = ensureArray(config.stat_types).filter((stat_type) => - metadata!.every((metaData) => - statisticsMetaHasType(metaData, stat_type) - ) + metadata!.some((metaData) => statisticsMetaHasType(metaData, stat_type)) ); if (!config.stat_types.length) { delete config.stat_types; diff --git a/src/panels/lovelace/editor/hui-element-editor.ts b/src/panels/lovelace/editor/hui-element-editor.ts index b7b2dbf324..f711c63210 100644 --- a/src/panels/lovelace/editor/hui-element-editor.ts +++ b/src/panels/lovelace/editor/hui-element-editor.ts @@ -8,13 +8,13 @@ import { PropertyValues, TemplateResult, } from "lit"; -import { property, state, query } from "lit/decorators"; +import { property, query, state } from "lit/decorators"; import { fireEvent } from "../../../common/dom/fire_event"; import { handleStructError } from "../../../common/structs/handle-errors"; import { deepEqual } from "../../../common/util/deep-equal"; +import "../../../components/ha-alert"; import "../../../components/ha-circular-progress"; import "../../../components/ha-code-editor"; -import "../../../components/ha-alert"; import type { HaCodeEditor } from "../../../components/ha-code-editor"; import type { LovelaceCardConfig, @@ -23,11 +23,15 @@ import type { import type { HomeAssistant } from "../../../types"; import type { LovelaceRowConfig } from "../entity-rows/types"; import { LovelaceHeaderFooterConfig } from "../header-footer/types"; -import type { LovelaceGenericElementEditor } from "../types"; +import { LovelaceTileFeatureConfig } from "../tile-features/types"; +import type { + LovelaceConfigForm, + LovelaceGenericElementEditor, +} from "../types"; +import type { HuiFormEditor } from "./config-elements/hui-form-editor"; import "./config-elements/hui-generic-entity-row-editor"; import { GUISupportError } from "./gui-support-error"; import { EditSubElementEvent, GUIModeChangedEvent } from "./types"; -import { LovelaceTileFeatureConfig } from "../tile-features/types"; export interface ConfigChangedEvent { config: @@ -182,6 +186,10 @@ export abstract class HuiElementEditor extends LitElement { return undefined; } + protected async getConfigForm(): Promise { + return undefined; + } + protected get configElementType(): string | undefined { return this.value ? (this.value as any).type : undefined; } @@ -328,6 +336,25 @@ export abstract class HuiElementEditor extends LitElement { this._loading = true; configElement = await this.getConfigElement(); + if (!configElement) { + const form = await this.getConfigForm(); + if (form) { + await import("./config-elements/hui-form-editor"); + configElement = document.createElement("hui-form-editor"); + const { schema, assertConfig, computeLabel, computeHelper } = form; + (configElement as HuiFormEditor).schema = schema; + if (computeLabel) { + (configElement as HuiFormEditor).computeLabel = computeLabel; + } + if (computeHelper) { + (configElement as HuiFormEditor).computeHelper = computeHelper; + } + if (assertConfig) { + (configElement as HuiFormEditor).assertConfig = assertConfig; + } + } + } + if (configElement) { configElement.hass = this.hass; if ("lovelace" in configElement) { diff --git a/src/panels/lovelace/editor/tile-feature-editor/hui-tile-feature-element-editor.ts b/src/panels/lovelace/editor/tile-feature-editor/hui-tile-feature-element-editor.ts index 573d1e075c..58fb381399 100644 --- a/src/panels/lovelace/editor/tile-feature-editor/hui-tile-feature-element-editor.ts +++ b/src/panels/lovelace/editor/tile-feature-editor/hui-tile-feature-element-editor.ts @@ -4,7 +4,10 @@ import { LovelaceTileFeatureConfig, LovelaceTileFeatureContext, } from "../../tile-features/types"; -import type { LovelaceTileFeatureEditor } from "../../types"; +import type { + LovelaceConfigForm, + LovelaceTileFeatureEditor, +} from "../../types"; import { HuiElementEditor } from "../hui-element-editor"; @customElement("hui-tile-feature-element-editor") @@ -24,6 +27,17 @@ export class HuiTileFeatureElementEditor extends HuiElementEditor< return undefined; } + + protected async getConfigForm(): Promise { + const elClass = await getTileFeatureElementClass(this.configElementType!); + + // Check if a schema exists + if (elClass && elClass.getConfigForm) { + return elClass.getConfigForm(); + } + + return undefined; + } } declare global { diff --git a/src/panels/lovelace/elements/hui-image-element.ts b/src/panels/lovelace/elements/hui-image-element.ts index e54e23fb68..e86058a183 100644 --- a/src/panels/lovelace/elements/hui-image-element.ts +++ b/src/panels/lovelace/elements/hui-image-element.ts @@ -1,6 +1,7 @@ import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { ifDefined } from "lit/directives/if-defined"; +import { ImageEntity, computeImageUrl } from "../../../data/image"; import { ActionHandlerEvent } from "../../../data/lovelace"; import { HomeAssistant } from "../../../types"; import { computeTooltip } from "../common/compute-tooltip"; @@ -34,12 +35,16 @@ export class HuiImageElement extends LitElement implements LovelaceElement { if (!this._config || !this.hass) { return nothing; } + let stateObj: ImageEntity | undefined; + if (this._config.image_entity) { + stateObj = this.hass.states[this._config.image_entity] as ImageEntity; + } return html` void; + computeLabel?: ( + schema: HaFormSchema, + localize: LocalizeFunc + ) => string | undefined; + computeHelper?: ( + schema: HaFormSchema, + localize: LocalizeFunc + ) => string | undefined; +} + export interface LovelaceCardConstructor extends Constructor { getStubConfig?: ( hass: HomeAssistant, @@ -52,6 +67,7 @@ export interface LovelaceCardConstructor extends Constructor { entitiesFallback: string[] ) => LovelaceCardConfig; getConfigElement?: () => LovelaceCardEditor; + getConfigForm?: () => LovelaceConfigForm; } export interface LovelaceHeaderFooterConstructor @@ -104,11 +120,15 @@ export interface LovelaceTileFeature extends HTMLElement { export interface LovelaceTileFeatureConstructor extends Constructor { - getConfigElement?: () => LovelaceTileFeatureEditor; getStubConfig?: ( hass: HomeAssistant, stateObj?: HassEntity ) => LovelaceTileFeatureConfig; + getConfigElement?: () => LovelaceTileFeatureEditor; + getConfigForm?: () => { + schema: HaFormSchema[]; + assertConfig?: (config: LovelaceCardConfig) => void; + }; isSupported?: (stateObj?: HassEntity) => boolean; } diff --git a/src/panels/media-browser/ha-panel-media-browser.ts b/src/panels/media-browser/ha-panel-media-browser.ts index 3bd5da6fc9..e71291bec8 100644 --- a/src/panels/media-browser/ha-panel-media-browser.ts +++ b/src/panels/media-browser/ha-panel-media-browser.ts @@ -9,7 +9,7 @@ import { TemplateResult, } from "lit"; import { customElement, property, query, state } from "lit/decorators"; -import { LocalStorage } from "../../common/decorators/local-storage"; +import { storage } from "../../common/decorators/storage"; import { fireEvent, HASSDomEvent } from "../../common/dom/fire_event"; import { navigate } from "../../common/navigate"; import "../../components/ha-menu-button"; @@ -71,7 +71,11 @@ class PanelMediaBrowser extends LitElement { }, ]; - @LocalStorage("mediaBrowseEntityId", true, false) + @storage({ + key: "mediaBrowseEntityId", + state: true, + subscribe: false, + }) private _entityId = BROWSER_PLAYER; @query("ha-media-player-browse") private _browser!: HaMediaPlayerBrowse; diff --git a/src/resources/svg-arc.ts b/src/resources/svg-arc.ts new file mode 100644 index 0000000000..67e29b5157 --- /dev/null +++ b/src/resources/svg-arc.ts @@ -0,0 +1,67 @@ +type Vector = [number, number]; +type Matrix = [Vector, Vector]; + +const rotateVector = ([[a, b], [c, d]]: Matrix, [x, y]: Vector): Vector => [ + a * x + b * y, + c * x + d * y, +]; +const createRotateMatrix = (x: number): Matrix => [ + [Math.cos(x), -Math.sin(x)], + [Math.sin(x), Math.cos(x)], +]; +const addVector = ([a1, a2]: Vector, [b1, b2]: Vector): Vector => [ + a1 + b1, + a2 + b2, +]; + +export const toRadian = (angle: number) => (angle / 180) * Math.PI; + +type ArcOptions = { + x: number; + y: number; + r: number; + start: number; + end: number; + rotate?: number; +}; + +export const arc = (options: ArcOptions) => { + const { x, y, r, start, end, rotate = 0 } = options; + const cx = x; + const cy = y; + const rx = r; + const ry = r; + const t1 = toRadian(start); + const t2 = toRadian(end); + const delta = (t2 - t1) % (2 * Math.PI); + const phi = toRadian(rotate); + + const rotMatrix = createRotateMatrix(phi); + const [sX, sY] = addVector( + rotateVector(rotMatrix, [rx * Math.cos(t1), ry * Math.sin(t1)]), + [cx, cy] + ); + const [eX, eY] = addVector( + rotateVector(rotMatrix, [ + rx * Math.cos(t1 + delta), + ry * Math.sin(t1 + delta), + ]), + [cx, cy] + ); + const fA = delta > Math.PI ? 1 : 0; + const fS = delta > 0 ? 1 : 0; + + return [ + "M", + sX, + sY, + "A", + rx, + ry, + (phi / (2 * Math.PI)) * 360, + fA, + fS, + eX, + eY, + ].join(" "); +}; diff --git a/src/state-summary/state-card-lock.js b/src/state-summary/state-card-lock.js index d0280b7a1d..94ee6e27aa 100644 --- a/src/state-summary/state-card-lock.js +++ b/src/state-summary/state-card-lock.js @@ -3,8 +3,10 @@ import "@polymer/iron-flex-layout/iron-flex-layout-classes"; import { html } from "@polymer/polymer/lib/utils/html-tag"; /* eslint-plugin-disable lit */ import { PolymerElement } from "@polymer/polymer/polymer-element"; +import { supportsFeature } from "../common/entity/supports-feature"; import "../components/entity/state-info"; import LocalizeMixin from "../mixins/localize-mixin"; +import { LockEntityFeature } from "../data/lock"; /* * @appliesMixin LocalizeMixin @@ -19,10 +21,19 @@ class StateCardLock extends LocalizeMixin(PolymerElement) { height: 37px; margin-right: -0.57em; } + [hidden] { + display: none !important; + }
${this.stateInfoTemplate} + [[localize('ui.card.lock.open')]]