Merge branch 'dev' into persistent_notification_trigger

This commit is contained in:
J. Nick Koston 2023-06-22 01:44:19 +02:00
commit ecba22d301
No known key found for this signature in database
54 changed files with 1578 additions and 463 deletions

View File

@ -0,0 +1,3 @@
---
title: Control Circular Slider
---

View File

@ -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`
<ha-card>
<div class="card-content">
<p class="title"><b>Config</b></p>
<div class="field">
<p>Current</p>
<ha-slider
min="10"
max="30"
.value=${this.current}
@change=${this._currentChanged}
pin
></ha-slider>
<p>${this.current} °C</p>
</div>
</div>
</ha-card>
<ha-card>
<div class="card-content">
<p class="title"><b>Single</b></p>
<ha-control-circular-slider
@value-changed=${this._valueChanged}
@value-changing=${this._valueChanging}
.value=${this.value}
.current=${this.current}
step="1"
min="10"
max="30"
></ha-control-circular-slider>
<div>
Value: ${this.value} °C
<br />
Changing:
${this.changingValue != null ? `${this.changingValue} °C` : "-"}
</div>
</div>
</ha-card>
<ha-card>
<div class="card-content">
<p class="title"><b>Dual</b></p>
<ha-control-circular-slider
dual
@low-changed=${this._valueChanged}
@low-changing=${this._valueChanging}
@high-changed=${this._highChanged}
@high-changing=${this._highChanging}
.low=${this.value}
.high=${this.high}
.current=${this.current}
step="1"
min="10"
max="30"
></ha-control-circular-slider>
<div>
Low value: ${this.value} °C
<br />
Low changing:
${this.changingValue != null ? `${this.changingValue} °C` : "-"}
<br />
High value: ${this.high} °C
<br />
High changing:
${this.changingHigh != null ? `${this.changingHigh} °C` : "-"}
</div>
</div>
</ha-card>
`;
}
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;
}
}

View File

@ -9,7 +9,7 @@ const CONFIGS = [
heading: "markdown-it demo", heading: "markdown-it demo",
config: ` config: `
- type: markdown - type: markdown
content: >- content: |
# h1 Heading 8-) # h1 Heading 8-)
## h2 Heading ## h2 Heading
@ -65,6 +65,15 @@ const CONFIGS = [
>> ...by using additional greater-than signs right next to each other... >> ...by using additional greater-than signs right next to each other...
> > > ...or with spaces between arrows. > > > ...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 ## Lists

View File

@ -33,6 +33,7 @@ import {
mdiGoogleCirclesCommunities, mdiGoogleCirclesCommunities,
mdiHomeAssistant, mdiHomeAssistant,
mdiHomeAutomation, mdiHomeAutomation,
mdiImage,
mdiImageFilterFrames, mdiImageFilterFrames,
mdiLightbulb, mdiLightbulb,
mdiLightningBolt, mdiLightningBolt,
@ -90,6 +91,7 @@ export const FIXED_DOMAIN_ICONS = {
group: mdiGoogleCirclesCommunities, group: mdiGoogleCirclesCommunities,
homeassistant: mdiHomeAssistant, homeassistant: mdiHomeAssistant,
homekit: mdiHomeAutomation, homekit: mdiHomeAutomation,
image: mdiImage,
image_processing: mdiImageFilterFrames, image_processing: mdiImageFilterFrames,
input_button: mdiGestureTapButton, input_button: mdiGestureTapButton,
input_datetime: mdiCalendarClock, input_datetime: mdiCalendarClock,

View File

@ -1,13 +1,15 @@
import { UnsubscribeFunc } from "home-assistant-js-websocket"; 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"; import type { ClassElement } from "../../types";
type Callback = (oldValue: any, newValue: any) => void; type Callback = (oldValue: any, newValue: any) => void;
class Storage { class StorageClass {
constructor(subscribe = true, storage = window.localStorage) { constructor(storage = window.localStorage) {
this.storage = storage; this.storage = storage;
if (!subscribe) { if (storage !== window.localStorage) {
// storage events only work for localStorage
return; return;
} }
window.addEventListener("storage", (ev: StorageEvent) => { window.addEventListener("storage", (ev: StorageEvent) => {
@ -77,6 +79,7 @@ class Storage {
} }
public setValue(storageKey: string, value: any): any { public setValue(storageKey: string, value: any): any {
const oldValue = this._storage[storageKey];
this._storage[storageKey] = value; this._storage[storageKey] = value;
try { try {
if (value === undefined) { if (value === undefined) {
@ -86,49 +89,68 @@ class Storage {
} }
} catch (err: any) { } catch (err: any) {
// Safari in private mode doesn't allow localstorage // 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<string, StorageClass> = {};
export const LocalStorage = export const storage =
( (options: {
storageKey?: string, key?: string;
property?: boolean, storage?: "localStorage" | "sessionStorage";
subscribe = true, subscribe?: boolean;
storageType?: globalThis.Storage, state?: boolean;
propertyOptions?: PropertyDeclaration stateOptions?: InternalPropertyDeclaration;
): any => }): any =>
(clsElement: ClassElement) => { (clsElement: ClassElement) => {
const storage = const storageName = options.storage || "localStorage";
subscribe && !storageType
? subscribeStorage let storageInstance: StorageClass;
: new Storage(subscribe, storageType); if (storageName && storageName in storages) {
storageInstance = storages[storageName];
} else {
storageInstance = new StorageClass(window[storageName]);
storages[storageName] = storageInstance;
}
const key = String(clsElement.key); const key = String(clsElement.key);
storageKey = storageKey || String(clsElement.key); const storageKey = options.key || String(clsElement.key);
const initVal = clsElement.initializer const initVal = clsElement.initializer
? clsElement.initializer() ? clsElement.initializer()
: undefined; : undefined;
storage.addFromStorage(storageKey); storageInstance.addFromStorage(storageKey);
const subscribeChanges = (el: ReactiveElement): UnsubscribeFunc => const subscribeChanges =
storage.subscribeChanges(storageKey!, (oldValue) => { options.subscribe !== false
el.requestUpdate(clsElement.key, oldValue); ? (el: ReactiveElement): UnsubscribeFunc =>
}); storageInstance.subscribeChanges(
storageKey!,
(oldValue, _newValue) => {
el.requestUpdate(clsElement.key, oldValue);
}
)
: undefined;
const getValue = (): any => const getValue = (): any =>
storage.hasKey(storageKey!) ? storage.getValue(storageKey!) : initVal; storageInstance.hasKey(storageKey!)
? storageInstance.getValue(storageKey!)
: initVal;
const setValue = (el: ReactiveElement, value: any) => { const setValue = (el: ReactiveElement, value: any) => {
let oldValue: unknown | undefined; let oldValue: unknown | undefined;
if (property) { if (options.state) {
oldValue = getValue(); oldValue = getValue();
} }
storage.setValue(storageKey!, value); storageInstance.setValue(storageKey!, value);
if (property) { if (options.state) {
el.requestUpdate(clsElement.key, oldValue); el.requestUpdate(clsElement.key, oldValue);
} }
}; };
@ -148,22 +170,23 @@ export const LocalStorage =
configurable: true, configurable: true,
}, },
finisher(cls: typeof ReactiveElement) { finisher(cls: typeof ReactiveElement) {
if (property && subscribe) { if (options.state && options.subscribe) {
const connectedCallback = cls.prototype.connectedCallback; const connectedCallback = cls.prototype.connectedCallback;
const disconnectedCallback = cls.prototype.disconnectedCallback; const disconnectedCallback = cls.prototype.disconnectedCallback;
cls.prototype.connectedCallback = function () { cls.prototype.connectedCallback = function () {
connectedCallback.call(this); connectedCallback.call(this);
this[`__unbsubLocalStorage${key}`] = subscribeChanges(this); this[`__unbsubLocalStorage${key}`] = subscribeChanges?.(this);
}; };
cls.prototype.disconnectedCallback = function () { cls.prototype.disconnectedCallback = function () {
disconnectedCallback.call(this); disconnectedCallback.call(this);
this[`__unbsubLocalStorage${key}`](); this[`__unbsubLocalStorage${key}`]?.();
this[`__unbsubLocalStorage${key}`] = undefined;
}; };
} }
if (property) { if (options.state) {
cls.createProperty(clsElement.key, { cls.createProperty(clsElement.key, {
noAccessor: true, noAccessor: true,
...propertyOptions, ...options.stateOptions,
}); });
} }
}, },

View File

@ -191,7 +191,9 @@ export const computeStateDisplayFromEntityAttributes = (
// state is a timestamp // state is a timestamp
if ( if (
["button", "input_button", "scene", "stt", "tts"].includes(domain) || ["button", "image", "input_button", "scene", "stt", "tts"].includes(
domain
) ||
(domain === "sensor" && attributes.device_class === "timestamp") (domain === "sensor" && attributes.device_class === "timestamp")
) { ) {
try { try {

View File

@ -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`
<svg
id="slider"
viewBox="0 0 320 320"
overflow="visible"
class=${classMap({
pressed: Boolean(this._activeSlider),
})}
@keydown=${this._handleKeyDown}
tabindex=${this._lastSlider ? "0" : "-1"}
>
<g
id="container"
transform="translate(160 160) rotate(${ROTATE_ANGLE})"
>
<g id="interaction">
<path d=${trackPath} />
</g>
<g id="display">
<path class="background" d=${trackPath} />
<circle
.id=${this.dual ? "low" : "value"}
class="track"
cx="0"
cy="0"
r=${RADIUS}
stroke-dasharray=${lowStrokeDasharray}
stroke-dashoffset="0"
role="slider"
tabindex="0"
aria-valuemin=${this.min}
aria-valuemax=${this.max}
aria-valuenow=${lowValue != null
? this._steppedValue(lowValue)
: undefined}
aria-disabled=${this.disabled}
aria-label=${ifDefined(this.lowLabel ?? this.label)}
@keydown=${this._handleKeyDown}
@keyup=${this._handleKeyUp}
/>
${this.dual
? svg`
<circle
id="high"
class="track"
cx="0"
cy="0"
r=${RADIUS}
stroke-dasharray=${highStrokeDasharray}
stroke-dashoffset=${highStrokeDashOffset}
role="slider"
tabindex="0"
aria-valuemin=${this.min}
aria-valuemax=${this.max}
aria-valuenow=${
highValue != null
? this._steppedValue(highValue)
: undefined
}
aria-disabled=${this.disabled}
aria-label=${ifDefined(this.highLabel)}
@keydown=${this._handleKeyDown}
@keyup=${this._handleKeyUp}
/>
`
: nothing}
${this.current != null
? svg`
<g
style=${styleMap({ "--current-angle": `${currentAngle}deg` })}
class="current"
>
<line
x1=${RADIUS - 12}
y1="0"
x2=${RADIUS - 15}
y2="0"
stroke-width="4"
/>
<line
x1=${RADIUS - 15}
y1="0"
x2=${RADIUS - 20}
y2="0"
stroke-linecap="round"
stroke-width="4"
/>
</g>
`
: nothing}
</g>
</g>
</svg>
`;
}
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;
}
}

View File

@ -176,7 +176,7 @@ export class HaControlSlider extends LitElement {
this._mc = undefined; this._mc = undefined;
} }
this.removeEventListener("keydown", this._handleKeyDown); this.removeEventListener("keydown", this._handleKeyDown);
this.removeEventListener("keyup", this._handleKeyDown); this.removeEventListener("keyup", this._handleKeyUp);
} }
private get _tenPercentStep() { private get _tenPercentStep() {

View File

@ -3,6 +3,8 @@ import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { renderMarkdown } from "../resources/render-markdown"; import { renderMarkdown } from "../resources/render-markdown";
const _blockQuoteToAlert = { Note: "info", Warning: "warning" };
@customElement("ha-markdown-element") @customElement("ha-markdown-element")
class HaMarkdownElement extends ReactiveElement { class HaMarkdownElement extends ReactiveElement {
@property() public content?; @property() public content?;
@ -65,6 +67,34 @@ class HaMarkdownElement extends ReactiveElement {
node.loading = "lazy"; node.loading = "lazy";
} }
node.addEventListener("load", this._resize); 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);
}
} }
} }
} }

View File

@ -2,7 +2,7 @@ import { mdiImagePlus } from "@mdi/js";
import { html, LitElement, TemplateResult } from "lit"; import { html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { createImage, generateImageThumbnailUrl } from "../data/image"; import { createImage, generateImageThumbnailUrl } from "../data/image_upload";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box"; import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { import {
CropOptions, CropOptions,

View File

@ -23,19 +23,19 @@ import "@polymer/paper-item/paper-item";
import "@polymer/paper-listbox/paper-listbox"; import "@polymer/paper-listbox/paper-listbox";
import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { import {
css,
CSSResult, CSSResult,
CSSResultGroup, CSSResultGroup,
html,
LitElement, LitElement,
nothing,
PropertyValues, PropertyValues,
css,
html,
nothing,
} from "lit"; } from "lit";
import { customElement, eventOptions, property, state } from "lit/decorators"; import { customElement, eventOptions, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
import { guard } from "lit/directives/guard"; import { guard } from "lit/directives/guard";
import memoizeOne from "memoize-one"; 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 { fireEvent } from "../common/dom/fire_event";
import { toggleAttribute } from "../common/dom/toggle_attribute"; import { toggleAttribute } from "../common/dom/toggle_attribute";
import { stringCompare } from "../common/string/compare"; import { stringCompare } from "../common/string/compare";
@ -47,10 +47,10 @@ import {
subscribeNotifications, subscribeNotifications,
} from "../data/persistent_notification"; } from "../data/persistent_notification";
import { subscribeRepairsIssueRegistry } from "../data/repairs"; import { subscribeRepairsIssueRegistry } from "../data/repairs";
import { updateCanInstall, UpdateEntity } from "../data/update"; import { UpdateEntity, updateCanInstall } from "../data/update";
import { SubscribeMixin } from "../mixins/subscribe-mixin"; import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { actionHandler } from "../panels/lovelace/common/directives/action-handler-directive"; 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 { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant, PanelInfo, Route } from "../types"; import type { HomeAssistant, PanelInfo, Route } from "../types";
import "./ha-icon"; import "./ha-icon";
@ -214,15 +214,17 @@ class HaSidebar extends SubscribeMixin(LitElement) {
private sortableStyleLoaded = false; private sortableStyleLoaded = false;
// @ts-ignore @storage({
@LocalStorage("sidebarPanelOrder", true, { key: "sidebarPanelOrder",
attribute: false, state: true,
subscribe: true,
}) })
private _panelOrder: string[] = []; private _panelOrder: string[] = [];
// @ts-ignore @storage({
@LocalStorage("sidebarHiddenPanels", true, { key: "sidebarHiddenPanels",
attribute: false, state: true,
subscribe: true,
}) })
private _hiddenPanels: string[] = []; private _hiddenPanels: string[] = [];

View File

@ -1,7 +1,7 @@
import "@material/mwc-list/mwc-list-item"; import "@material/mwc-list/mwc-list-item";
import { css, html, LitElement, nothing, PropertyValues } from "lit"; import { css, html, LitElement, nothing, PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators"; 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 { fireEvent } from "../../common/dom/fire_event";
import { import {
MediaPlayerBrowseAction, MediaPlayerBrowseAction,
@ -43,7 +43,12 @@ class BrowseMediaTTS extends LitElement {
@state() private _provider?: TTSEngine; @state() private _provider?: TTSEngine;
@LocalStorage("TtsMessage", true, false) private _message!: string; @storage({
key: "TtsMessage",
state: true,
subscribe: false,
})
private _message!: string;
protected render() { protected render() {
return html`<ha-card> return html`<ha-card>

View File

@ -1,54 +1,15 @@
import { HomeAssistant } from "../types"; import {
HassEntityAttributeBase,
HassEntityBase,
} from "home-assistant-js-websocket";
interface Image { interface ImageEntityAttributes extends HassEntityAttributeBase {
filesize: number; access_token: string;
name: string;
uploaded_at: string; // isoformat date
content_type: string;
id: string;
} }
export interface ImageMutableParams { export interface ImageEntity extends HassEntityBase {
name: string; attributes: ImageEntityAttributes;
} }
export const generateImageThumbnailUrl = (mediaId: string, size: number) => export const computeImageUrl = (entity: ImageEntity): string =>
`/api/image/serve/${mediaId}/${size}x${size}`; `/api/image_proxy/${entity.entity_id}?token=${entity.attributes.access_token}&state=${entity.state}`;
export const fetchImages = (hass: HomeAssistant) =>
hass.callWS<Image[]>({ type: "image/list" });
export const createImage = async (
hass: HomeAssistant,
file: File
): Promise<Image> => {
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<ImageMutableParams>
) =>
hass.callWS<Image>({
type: "image/update",
media_id: id,
...updates,
});
export const deleteImage = (hass: HomeAssistant, id: string) =>
hass.callWS({
type: "image/delete",
media_id: id,
});

54
src/data/image_upload.ts Normal file
View File

@ -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<Image[]>({ type: "image/list" });
export const createImage = async (
hass: HomeAssistant,
file: File
): Promise<Image> => {
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<ImageMutableParams>
) =>
hass.callWS<Image>({
type: "image/update",
media_id: id,
...updates,
});
export const deleteImage = (hass: HomeAssistant, id: string) =>
hass.callWS({
type: "image/delete",
media_id: id,
});

View File

@ -260,10 +260,10 @@ export const calculateStatisticsSumGrowth = (
export const statisticsHaveType = ( export const statisticsHaveType = (
stats: StatisticValue[], stats: StatisticValue[],
type: StatisticType 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 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 = ( export const statisticsMetaHasType = (
metadata: StatisticsMetaData, metadata: StatisticsMetaData,

View File

@ -40,6 +40,7 @@ export const DOMAINS_WITH_MORE_INFO = [
"fan", "fan",
"group", "group",
"humidifier", "humidifier",
"image",
"input_boolean", "input_boolean",
"input_datetime", "input_datetime",
"light", "light",

View File

@ -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`<img
alt=${this.stateObj.attributes.friendly_name || this.stateObj.entity_id}
src=${this.hass.hassUrl(computeImageUrl(this.stateObj))}
/> `;
}
static get styles(): CSSResultGroup {
return css`
:host {
display: block;
text-align: center;
}
img {
max-width: 100%;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"more-info-image": MoreInfoImage;
}
}

View File

@ -18,6 +18,7 @@ const LAZY_LOADED_MORE_INFO_CONTROL = {
fan: () => import("./controls/more-info-fan"), fan: () => import("./controls/more-info-fan"),
group: () => import("./controls/more-info-group"), group: () => import("./controls/more-info-group"),
humidifier: () => import("./controls/more-info-humidifier"), humidifier: () => import("./controls/more-info-humidifier"),
image: () => import("./controls/more-info-image"),
input_boolean: () => import("./controls/more-info-input_boolean"), input_boolean: () => import("./controls/more-info-input_boolean"),
input_datetime: () => import("./controls/more-info-input_datetime"), input_datetime: () => import("./controls/more-info-input_datetime"),
light: () => import("./controls/more-info-light"), light: () => import("./controls/more-info-light"),

View File

@ -1,7 +1,7 @@
import { mdiPlayCircleOutline } from "@mdi/js"; import { mdiPlayCircleOutline } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators"; 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 { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-button"; import "../../components/ha-button";
import { createCloseHeading } from "../../components/ha-dialog"; import { createCloseHeading } from "../../components/ha-dialog";
@ -25,10 +25,12 @@ export class TTSTryDialog extends LitElement {
@query("#message") private _messageInput?: HaTextArea; @query("#message") private _messageInput?: HaTextArea;
@LocalStorage("ttsTryMessages", false, false) private _messages?: Record< @storage({
string, key: "ttsTryMessages",
string state: false,
>; subscribe: false,
})
private _messages?: Record<string, string>;
public showDialog(params: TTSTryDialogParams) { public showDialog(params: TTSTryDialogParams) {
this._params = params; this._params = params;

View File

@ -18,7 +18,7 @@ import {
TemplateResult, TemplateResult,
} from "lit"; } from "lit";
import { customElement, property, query, state } from "lit/decorators"; 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 { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation"; import { stopPropagation } from "../../common/dom/stop_propagation";
import "../../components/ha-button"; import "../../components/ha-button";
@ -57,7 +57,12 @@ export class HaVoiceCommandDialog extends LitElement {
@state() private _opened = false; @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; @state() private _pipeline?: AssistPipeline;

View File

@ -13,6 +13,7 @@ import { computeRTL } from "../common/util/compute_rtl";
import "../components/ha-icon-button-arrow-prev"; import "../components/ha-icon-button-arrow-prev";
import "../components/ha-menu-button"; import "../components/ha-menu-button";
import { HomeAssistant } from "../types"; import { HomeAssistant } from "../types";
import { haStyleScrollbar } from "../resources/styles";
@customElement("hass-subpage") @customElement("hass-subpage")
class HassSubpage extends LitElement { class HassSubpage extends LitElement {
@ -73,7 +74,9 @@ class HassSubpage extends LitElement {
<div class="main-title"><slot name="header">${this.header}</slot></div> <div class="main-title"><slot name="header">${this.header}</slot></div>
<slot name="toolbar-icon"></slot> <slot name="toolbar-icon"></slot>
</div> </div>
<div class="content" @scroll=${this._saveScrollPos}><slot></slot></div> <div class="content ha-scrollbar" @scroll=${this._saveScrollPos}>
<slot></slot>
</div>
<div id="fab"> <div id="fab">
<slot name="fab"></slot> <slot name="fab"></slot>
</div> </div>
@ -94,88 +97,91 @@ class HassSubpage extends LitElement {
} }
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return css` return [
:host { haStyleScrollbar,
display: block; css`
height: 100%; :host {
background-color: var(--primary-background-color); display: block;
overflow: hidden; height: 100%;
position: relative; 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;
} }
}
.toolbar a {
color: var(--sidebar-text-color);
text-decoration: none;
}
ha-menu-button, :host([narrow]) {
ha-icon-button-arrow-prev, width: 100%;
::slotted([slot="toolbar-icon"]) { position: fixed;
pointer-events: auto; }
color: var(--sidebar-icon-color);
}
.main-title { .toolbar {
margin: 0 0 0 24px; display: flex;
line-height: 20px; align-items: center;
flex-grow: 1; 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 { ha-menu-button,
position: relative; ha-icon-button-arrow-prev,
width: 100%; ::slotted([slot="toolbar-icon"]) {
height: calc(100% - 1px - var(--header-height)); pointer-events: auto;
overflow-y: auto; color: var(--sidebar-icon-color);
overflow: auto; }
-webkit-overflow-scrolling: touch;
}
#fab { .main-title {
position: absolute; margin: 0 0 0 24px;
right: calc(16px + env(safe-area-inset-right)); line-height: 20px;
bottom: calc(16px + env(safe-area-inset-bottom)); flex-grow: 1;
z-index: 1; }
}
:host([narrow]) #fab.tabs { .content {
bottom: calc(84px + env(safe-area-inset-bottom)); position: relative;
} width: 100%;
#fab[is-wide] { height: calc(100% - 1px - var(--header-height));
bottom: 24px; overflow-y: auto;
right: 24px; overflow: auto;
} -webkit-overflow-scrolling: touch;
:host([rtl]) #fab { }
right: auto;
left: calc(16px + env(safe-area-inset-left)); #fab {
} position: absolute;
:host([rtl][is-wide]) #fab { right: calc(16px + env(safe-area-inset-right));
bottom: 24px; bottom: calc(16px + env(safe-area-inset-bottom));
left: 24px; z-index: 1;
right: auto; }
} :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;
}
`,
];
} }
} }

View File

@ -11,7 +11,7 @@ import {
} from "lit"; } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map"; 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 { HASSDomEvent } from "../../common/dom/fire_event";
import { computeStateName } from "../../common/entity/compute_state_name"; import { computeStateName } from "../../common/entity/compute_state_name";
import "../../components/ha-card"; import "../../components/ha-card";
@ -41,7 +41,10 @@ class PanelCalendar extends LitElement {
@state() private _error?: string = undefined; @state() private _error?: string = undefined;
@LocalStorage("deSelectedCalendars", true) @storage({
key: "deSelectedCalendars",
state: true,
})
private _deSelectedCalendars: string[] = []; private _deSelectedCalendars: string[] = [];
private _start?: Date; private _start?: Date;

View File

@ -20,7 +20,7 @@ import { documentationUrl } from "../../../util/documentation-url";
import "./action/ha-automation-action"; import "./action/ha-automation-action";
import "./condition/ha-automation-condition"; import "./condition/ha-automation-condition";
import "./trigger/ha-automation-trigger"; import "./trigger/ha-automation-trigger";
import { LocalStorage } from "../../../common/decorators/local-storage"; import { storage } from "../../../common/decorators/storage";
@customElement("manual-automation-editor") @customElement("manual-automation-editor")
export class HaManualAutomationEditor extends LitElement { export class HaManualAutomationEditor extends LitElement {
@ -36,7 +36,12 @@ export class HaManualAutomationEditor extends LitElement {
@property({ attribute: false }) public stateObj?: HassEntity; @property({ attribute: false }) public stateObj?: HassEntity;
@LocalStorage("automationClipboard", true, false, window.sessionStorage) @storage({
key: "automationClipboard",
state: true,
subscribe: false,
storage: "sessionStorage",
})
private _clipboard: Clipboard = {}; private _clipboard: Clipboard = {};
protected render() { protected render() {

View File

@ -3,7 +3,7 @@ import "@material/mwc-list/mwc-list-item";
import { mdiPlayCircleOutline, mdiRobot } from "@mdi/js"; import { mdiPlayCircleOutline, mdiRobot } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators"; 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 { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation"; import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { computeStateDomain } from "../../../../common/entity/compute_state_domain"; import { computeStateDomain } from "../../../../common/entity/compute_state_domain";
@ -31,9 +31,19 @@ export class DialogTryTts extends LitElement {
@query("#message") private _messageInput?: HaTextArea; @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) { public showDialog(params: TryTtsDialogParams) {
this._params = params; this._params = params;

View File

@ -1,7 +1,7 @@
import "@material/mwc-button"; import "@material/mwc-button";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators"; 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-card";
import "../../../../../components/ha-code-editor"; import "../../../../../components/ha-code-editor";
import "../../../../../components/ha-formfield"; import "../../../../../components/ha-formfield";
@ -21,19 +21,39 @@ class HaPanelDevMqtt extends LitElement {
@property({ type: Boolean }) public narrow!: boolean; @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 = ""; private _topic = "";
@LocalStorage("panel-dev-mqtt-payload-ls", true, false) @storage({
key: "panel-dev-mqtt-payload-ls",
state: true,
subscribe: false,
})
private _payload = ""; private _payload = "";
@LocalStorage("panel-dev-mqtt-qos-ls", true, false) @storage({
key: "panel-dev-mqtt-qos-ls",
state: true,
subscribe: false,
})
private _qos = "0"; 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; 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; private _allowTemplate = false;
protected render(): TemplateResult { protected render(): TemplateResult {

View File

@ -8,7 +8,7 @@ import { formatTime } from "../../../../../common/datetime/format_time";
import { MQTTMessage, subscribeMQTTTopic } from "../../../../../data/mqtt"; import { MQTTMessage, subscribeMQTTTopic } from "../../../../../data/mqtt";
import { HomeAssistant } from "../../../../../types"; import { HomeAssistant } from "../../../../../types";
import "@material/mwc-list/mwc-list-item"; 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-formfield";
import "../../../../../components/ha-switch"; import "../../../../../components/ha-switch";
@ -18,13 +18,25 @@ const qosLevel = ["0", "1", "2"];
class MqttSubscribeCard extends LitElement { class MqttSubscribeCard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @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 = ""; private _topic = "";
@LocalStorage("panel-dev-mqtt-qos-subscribe", true, false) @storage({
key: "panel-dev-mqtt-qos-subscribe",
state: true,
subscribe: false,
})
private _qos = "0"; 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; private _json_format = false;
@state() private _subscribed?: () => void; @state() private _subscribed?: () => void;

View File

@ -3,7 +3,7 @@ import { mdiHelpCircle } from "@mdi/js";
import deepClone from "deep-clone-simple"; import deepClone from "deep-clone-simple";
import { css, CSSResultGroup, html, LitElement } from "lit"; import { css, CSSResultGroup, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators"; 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 { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-card"; import "../../../components/ha-card";
import "../../../components/ha-icon-button"; import "../../../components/ha-icon-button";
@ -26,7 +26,12 @@ export class HaManualScriptEditor extends LitElement {
@property({ attribute: false }) public config!: ScriptConfig; @property({ attribute: false }) public config!: ScriptConfig;
@LocalStorage("automationClipboard", true, false, window.sessionStorage) @storage({
key: "automationClipboard",
state: true,
subscribe: false,
storage: "sessionStorage",
})
private _clipboard: Clipboard = {}; private _clipboard: Clipboard = {};
protected render() { protected render() {

View File

@ -200,7 +200,7 @@ export class AssistPipelineDebug extends LitElement {
<ha-card> <ha-card>
<div class="card-content"> <div class="card-content">
<div class="row heading"> <div class="row heading">
<span>Speech-to-Text</span> <span>Speech-to-text</span>
${renderProgress(this.hass, this.pipelineRun, "stt")} ${renderProgress(this.hass, this.pipelineRun, "stt")}
</div> </div>
${this.pipelineRun.stt ${this.pipelineRun.stt
@ -274,7 +274,7 @@ export class AssistPipelineDebug extends LitElement {
<ha-card> <ha-card>
<div class="card-content"> <div class="card-content">
<div class="row heading"> <div class="row heading">
<span>Text-to-Speech</span> <span>Text-to-speech</span>
${renderProgress(this.hass, this.pipelineRun, "tts")} ${renderProgress(this.hass, this.pipelineRun, "tts")}
</div> </div>
${this.pipelineRun.tts ${this.pipelineRun.tts

View File

@ -4,7 +4,7 @@ import { load } from "js-yaml";
import { css, CSSResultGroup, html, LitElement } from "lit"; import { css, CSSResultGroup, html, LitElement } from "lit";
import { property, query, state } from "lit/decorators"; import { property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one"; 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 { computeDomain } from "../../../common/entity/compute_domain";
import { computeObjectId } from "../../../common/entity/compute_object_id"; import { computeObjectId } from "../../../common/entity/compute_object_id";
import { hasTemplate } from "../../../common/string/has-template"; import { hasTemplate } from "../../../common/string/has-template";
@ -38,10 +38,18 @@ class HaPanelDevService extends LitElement {
@state() private _uiAvailable = true; @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: {} }; 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; private _yamlMode = false;
@query("ha-yaml-editor") private _yamlEditor?: HaYamlEditor; @query("ha-yaml-editor") private _yamlEditor?: HaYamlEditor;

View File

@ -7,7 +7,7 @@ import {
import { css, html, LitElement, PropertyValues } from "lit"; import { css, html, LitElement, PropertyValues } from "lit";
import { property, query, state } from "lit/decorators"; import { property, query, state } from "lit/decorators";
import { ensureArray } from "../../common/array/ensure-array"; 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 { navigate } from "../../common/navigate";
import { constructUrlCurrentPath } from "../../common/url/construct-url"; import { constructUrlCurrentPath } from "../../common/url/construct-url";
import { import {
@ -58,7 +58,11 @@ class HaPanelHistory extends SubscribeMixin(LitElement) {
@state() private _endDate: Date; @state() private _endDate: Date;
@LocalStorage("historyPickedValue", true, false) @storage({
key: "historyPickedValue",
state: true,
subscribe: false,
})
private _targetPickerValue?: HassServiceTarget; private _targetPickerValue?: HassServiceTarget;
@state() private _isLoading = false; @state() private _isLoading = false;

View File

@ -4,14 +4,15 @@ import {
CSSResultGroup, CSSResultGroup,
html, html,
LitElement, LitElement,
PropertyValues,
nothing, nothing,
PropertyValues,
} from "lit"; } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined"; import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map"; import { styleMap } from "lit/directives/style-map";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
import { computeAttributeValueDisplay } from "../../../common/entity/compute_attribute_display";
import { computeStateDisplay } from "../../../common/entity/compute_state_display"; import { computeStateDisplay } from "../../../common/entity/compute_state_display";
import { computeStateDomain } from "../../../common/entity/compute_state_domain"; import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { computeStateName } from "../../../common/entity/compute_state_name"; import { computeStateName } from "../../../common/entity/compute_state_name";
@ -27,7 +28,6 @@ import "../../../components/ha-card";
import "../../../components/ha-icon"; import "../../../components/ha-icon";
import { HVAC_ACTION_TO_MODE } from "../../../data/climate"; import { HVAC_ACTION_TO_MODE } from "../../../data/climate";
import { isUnavailableState } from "../../../data/entity"; import { isUnavailableState } from "../../../data/entity";
import { computeAttributeValueDisplay } from "../../../common/entity/compute_attribute_display";
import { LightEntity } from "../../../data/light"; import { LightEntity } from "../../../data/light";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
import { computeCardSize } from "../common/compute-card-size"; import { computeCardSize } from "../common/compute-card-size";
@ -35,21 +35,12 @@ import { findEntities } from "../common/find-entities";
import { hasConfigOrEntityChanged } from "../common/has-changed"; import { hasConfigOrEntityChanged } from "../common/has-changed";
import { createEntityNotFoundWarning } from "../components/hui-warning"; import { createEntityNotFoundWarning } from "../components/hui-warning";
import { createHeaderFooterElement } from "../create-element/create-header-footer-element"; import { createHeaderFooterElement } from "../create-element/create-header-footer-element";
import { import { LovelaceCard, LovelaceHeaderFooter } from "../types";
LovelaceCard,
LovelaceCardEditor,
LovelaceHeaderFooter,
} from "../types";
import { HuiErrorCard } from "./hui-error-card"; import { HuiErrorCard } from "./hui-error-card";
import { EntityCardConfig } from "./types"; import { EntityCardConfig } from "./types";
@customElement("hui-entity-card") @customElement("hui-entity-card")
export class HuiEntityCard extends LitElement implements LovelaceCard { export class HuiEntityCard extends LitElement implements LovelaceCard {
public static async getConfigElement(): Promise<LovelaceCardEditor> {
await import("../editor/config-elements/hui-entity-card-editor");
return document.createElement("hui-entity-card-editor");
}
public static getStubConfig( public static getStubConfig(
hass: HomeAssistant, hass: HomeAssistant,
entities: string[], 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; @property({ attribute: false }) public hass?: HomeAssistant;
@state() private _config?: EntityCardConfig; @state() private _config?: EntityCardConfig;

View File

@ -3,19 +3,22 @@ import {
CSSResultGroup, CSSResultGroup,
html, html,
LitElement, LitElement,
PropertyValues,
nothing, nothing,
PropertyValues,
} from "lit"; } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined"; import { ifDefined } from "lit/directives/if-defined";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import "../../../components/ha-card"; import "../../../components/ha-card";
import { computeImageUrl, ImageEntity } from "../../../data/image";
import { ActionHandlerEvent } from "../../../data/lovelace"; import { ActionHandlerEvent } from "../../../data/lovelace";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
import { actionHandler } from "../common/directives/action-handler-directive"; import { actionHandler } from "../common/directives/action-handler-directive";
import { handleAction } from "../common/handle-action"; import { handleAction } from "../common/handle-action";
import { hasAction } from "../common/has-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 { LovelaceCard, LovelaceCardEditor } from "../types";
import { PictureCardConfig } from "./types"; import { PictureCardConfig } from "./types";
@ -30,8 +33,6 @@ export class HuiPictureCard extends LitElement implements LovelaceCard {
return { return {
type: "picture", type: "picture",
image: "https://demo.home-assistant.io/stub_config/t-shirt-promo.png", 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 { public setConfig(config: PictureCardConfig): void {
if (!config || !config.image) { if (!config || (!config.image && !config.image_entity)) {
throw new Error("Image required"); throw new Error("Image required");
} }
@ -52,10 +53,21 @@ export class HuiPictureCard extends LitElement implements LovelaceCard {
} }
protected shouldUpdate(changedProps: PropertyValues): boolean { protected shouldUpdate(changedProps: PropertyValues): boolean {
if (changedProps.size === 1 && changedProps.has("hass")) { if (!this._config || hasConfigChanged(this, changedProps)) {
return !changedProps.get("hass"); 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 { protected updated(changedProps: PropertyValues): void {
@ -83,6 +95,17 @@ export class HuiPictureCard extends LitElement implements LovelaceCard {
return nothing; 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`<hui-warning>
${createEntityNotFoundWarning(this.hass, this._config.image_entity)}
</hui-warning>`;
}
}
return html` return html`
<ha-card <ha-card
@action=${this._handleAction} @action=${this._handleAction}
@ -91,19 +114,29 @@ export class HuiPictureCard extends LitElement implements LovelaceCard {
hasDoubleClick: hasAction(this._config!.double_tap_action), hasDoubleClick: hasAction(this._config!.double_tap_action),
})} })}
tabindex=${ifDefined( tabindex=${ifDefined(
hasAction(this._config.tap_action) ? "0" : undefined hasAction(this._config.tap_action) || this._config.image_entity
? "0"
: undefined
)} )}
class=${classMap({ class=${classMap({
clickable: Boolean( clickable: Boolean(
this._config.tap_action || (this._config.image_entity && !this._config.tap_action) ||
this._config.hold_action || (this._config.tap_action &&
this._config.double_tap_action this._config.tap_action.action !== "none") ||
(this._config.hold_action &&
this._config.hold_action.action !== "none") ||
(this._config.double_tap_action &&
this._config.double_tap_action.action !== "none")
), ),
})} })}
> >
<img <img
alt=${this._config.alt_text} alt=${ifDefined(
src=${this.hass.hassUrl(this._config.image)} this._config.alt_text || stateObj?.attributes.friendly_name
)}
src=${this.hass.hassUrl(
stateObj ? computeImageUrl(stateObj) : this._config.image
)}
/> />
</ha-card> </ha-card>
`; `;

View File

@ -9,6 +9,7 @@ import {
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import "../../../components/ha-card"; import "../../../components/ha-card";
import { ImageEntity, computeImageUrl } from "../../../data/image";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
import { findEntities } from "../common/find-entities"; import { findEntities } from "../common/find-entities";
import { LovelaceElement, LovelaceElementConfig } from "../elements/types"; import { LovelaceElement, LovelaceElementConfig } from "../elements/types";
@ -62,7 +63,12 @@ class HuiPictureElementsCard extends LitElement implements LovelaceCard {
if (!config) { if (!config) {
throw new Error("Invalid configuration"); throw new Error("Invalid configuration");
} else if ( } 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) (config.state_image && !config.entity)
) { ) {
throw new Error("Image required"); throw new Error("Image required");
@ -115,12 +121,17 @@ class HuiPictureElementsCard extends LitElement implements LovelaceCard {
return nothing; return nothing;
} }
let stateObj: ImageEntity | undefined;
if (this._config.image_entity) {
stateObj = this.hass.states[this._config.image_entity] as ImageEntity;
}
return html` return html`
<ha-card .header=${this._config.title}> <ha-card .header=${this._config.title}>
<div id="root"> <div id="root">
<hui-image <hui-image
.hass=${this.hass} .hass=${this.hass}
.image=${this._config.image} .image=${stateObj ? computeImageUrl(stateObj) : this._config.image}
.stateImage=${this._config.state_image} .stateImage=${this._config.state_image}
.stateFilter=${this._config.state_filter} .stateFilter=${this._config.state_filter}
.cameraImage=${this._config.camera_image} .cameraImage=${this._config.camera_image}

View File

@ -14,6 +14,7 @@ import { computeDomain } from "../../../common/entity/compute_domain";
import { computeStateDisplay } from "../../../common/entity/compute_state_display"; import { computeStateDisplay } from "../../../common/entity/compute_state_display";
import { computeStateName } from "../../../common/entity/compute_state_name"; import { computeStateName } from "../../../common/entity/compute_state_name";
import "../../../components/ha-card"; import "../../../components/ha-card";
import { computeImageUrl, ImageEntity } from "../../../data/image";
import { ActionHandlerEvent } from "../../../data/lovelace"; import { ActionHandlerEvent } from "../../../data/lovelace";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
import { actionHandler } from "../common/directives/action-handler-directive"; import { actionHandler } from "../common/directives/action-handler-directive";
@ -68,7 +69,7 @@ class HuiPictureEntityCard extends LitElement implements LovelaceCard {
} }
if ( if (
computeDomain(config.entity) !== "camera" && !["camera", "image"].includes(computeDomain(config.entity)) &&
!config.image && !config.image &&
!config.state_image && !config.state_image &&
!config.camera_image !config.camera_image
@ -141,14 +142,18 @@ class HuiPictureEntityCard extends LitElement implements LovelaceCard {
footer = html`<div class="footer single">${entityState}</div>`; footer = html`<div class="footer single">${entityState}</div>`;
} }
const domain = computeDomain(this._config.entity);
return html` return html`
<ha-card> <ha-card>
<hui-image <hui-image
.hass=${this.hass} .hass=${this.hass}
.image=${this._config.image} .image=${domain === "image"
? computeImageUrl(stateObj as ImageEntity)
: this._config.image}
.stateImage=${this._config.state_image} .stateImage=${this._config.state_image}
.stateFilter=${this._config.state_filter} .stateFilter=${this._config.state_filter}
.cameraImage=${computeDomain(this._config.entity) === "camera" .cameraImage=${domain === "camera"
? this._config.entity ? this._config.entity
: this._config.camera_image} : this._config.camera_image}
.cameraView=${this._config.camera_view} .cameraView=${this._config.camera_view}

View File

@ -3,9 +3,9 @@ import {
CSSResultGroup, CSSResultGroup,
html, html,
LitElement, LitElement,
nothing,
PropertyValues, PropertyValues,
TemplateResult, TemplateResult,
nothing,
} from "lit"; } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
@ -18,6 +18,7 @@ import { computeStateName } from "../../../common/entity/compute_state_name";
import "../../../components/ha-card"; import "../../../components/ha-card";
import "../../../components/ha-icon-button"; import "../../../components/ha-icon-button";
import "../../../components/ha-state-icon"; import "../../../components/ha-state-icon";
import { computeImageUrl, ImageEntity } from "../../../data/image";
import { ActionHandlerEvent } from "../../../data/lovelace"; import { ActionHandlerEvent } from "../../../data/lovelace";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
import { actionHandler } from "../common/directives/action-handler-directive"; import { actionHandler } from "../common/directives/action-handler-directive";
@ -63,7 +64,7 @@ class HuiPictureGlanceCard extends LitElement implements LovelaceCard {
}; };
} }
@property({ attribute: false }) public hass?: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@state() private _config?: PictureGlanceCardConfig; @state() private _config?: PictureGlanceCardConfig;
@ -80,7 +81,12 @@ class HuiPictureGlanceCard extends LitElement implements LovelaceCard {
!config || !config ||
!config.entities || !config.entities ||
!Array.isArray(config.entities) || !Array.isArray(config.entities) ||
!(config.image || config.camera_image || config.state_image) || !(
config.image ||
config.image_entity ||
config.camera_image ||
config.state_image
) ||
(config.state_image && !config.entity) (config.state_image && !config.entity)
) { ) {
throw new Error("Invalid configuration"); throw new Error("Invalid configuration");
@ -108,25 +114,35 @@ class HuiPictureGlanceCard extends LitElement implements LovelaceCard {
} }
protected shouldUpdate(changedProps: PropertyValues): boolean { protected shouldUpdate(changedProps: PropertyValues): boolean {
if (hasConfigOrEntityChanged(this, changedProps)) { if (!this._config || hasConfigOrEntityChanged(this, changedProps)) {
return true; return true;
} }
if (!changedProps.has("hass")) {
return false;
}
const oldHass = changedProps.get("hass") as HomeAssistant | undefined; const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if ( if (
!oldHass || !oldHass ||
oldHass.themes !== this.hass!.themes || oldHass.themes !== this.hass.themes ||
oldHass.locale !== this.hass!.locale oldHass.locale !== this.hass.locale
) {
return true;
}
if (
this._config.image_entity &&
oldHass.states[this._config.image_entity] !==
this.hass.states[this._config.image_entity]
) { ) {
return true; return true;
} }
if (this._entitiesDialog) { if (this._entitiesDialog) {
for (const entity of this._entitiesDialog) { for (const entity of this._entitiesDialog) {
if ( if (oldHass.states[entity.entity] !== this.hass.states[entity.entity]) {
oldHass!.states[entity.entity] !== this.hass!.states[entity.entity]
) {
return true; return true;
} }
} }
@ -134,9 +150,7 @@ class HuiPictureGlanceCard extends LitElement implements LovelaceCard {
if (this._entitiesToggle) { if (this._entitiesToggle) {
for (const entity of this._entitiesToggle) { for (const entity of this._entitiesToggle) {
if ( if (oldHass.states[entity.entity] !== this.hass.states[entity.entity]) {
oldHass!.states[entity.entity] !== this.hass!.states[entity.entity]
) {
return true; return true;
} }
} }
@ -170,6 +184,11 @@ class HuiPictureGlanceCard extends LitElement implements LovelaceCard {
return nothing; return nothing;
} }
let stateObj: ImageEntity | undefined;
if (this._config.image_entity) {
stateObj = this.hass.states[this._config.image_entity] as ImageEntity;
}
return html` return html`
<ha-card> <ha-card>
<hui-image <hui-image
@ -177,7 +196,8 @@ class HuiPictureGlanceCard extends LitElement implements LovelaceCard {
clickable: Boolean( clickable: Boolean(
this._config.tap_action || this._config.tap_action ||
this._config.hold_action || this._config.hold_action ||
this._config.camera_image this._config.camera_image ||
this._config.image_entity
), ),
})} })}
@action=${this._handleAction} @action=${this._handleAction}
@ -190,7 +210,7 @@ class HuiPictureGlanceCard extends LitElement implements LovelaceCard {
)} )}
.config=${this._config} .config=${this._config}
.hass=${this.hass} .hass=${this.hass}
.image=${this._config.image} .image=${stateObj ? computeImageUrl(stateObj) : this._config.image}
.stateImage=${this._config.state_image} .stateImage=${this._config.state_image}
.stateFilter=${this._config.state_filter} .stateFilter=${this._config.state_filter}
.cameraImage=${this._config.camera_image} .cameraImage=${this._config.camera_image}
@ -200,7 +220,7 @@ class HuiPictureGlanceCard extends LitElement implements LovelaceCard {
></hui-image> ></hui-image>
<div class="box"> <div class="box">
${this._config.title ${this._config.title
? html` <div class="title">${this._config.title}</div> ` ? html`<div class="title">${this._config.title}</div>`
: ""} : ""}
<div class="row"> <div class="row">
${this._entitiesDialog!.map((entityConf) => ${this._entitiesDialog!.map((entityConf) =>

View File

@ -335,6 +335,7 @@ export interface StatisticCardConfig extends LovelaceCardConfig {
export interface PictureCardConfig extends LovelaceCardConfig { export interface PictureCardConfig extends LovelaceCardConfig {
image?: string; image?: string;
image_entity?: string;
tap_action?: ActionConfig; tap_action?: ActionConfig;
hold_action?: ActionConfig; hold_action?: ActionConfig;
double_tap_action?: ActionConfig; double_tap_action?: ActionConfig;
@ -345,6 +346,7 @@ export interface PictureCardConfig extends LovelaceCardConfig {
export interface PictureElementsCardConfig extends LovelaceCardConfig { export interface PictureElementsCardConfig extends LovelaceCardConfig {
title?: string; title?: string;
image?: string; image?: string;
image_entity?: string;
camera_image?: string; camera_image?: string;
camera_view?: HuiImage["cameraView"]; camera_view?: HuiImage["cameraView"];
state_image?: Record<string, unknown>; state_image?: Record<string, unknown>;

View File

@ -20,6 +20,7 @@ import {
AlarmPanelCardConfig, AlarmPanelCardConfig,
EntitiesCardConfig, EntitiesCardConfig,
HumidifierCardConfig, HumidifierCardConfig,
PictureCardConfig,
PictureEntityCardConfig, PictureEntityCardConfig,
ThermostatCardConfig, ThermostatCardConfig,
} from "../cards/types"; } from "../cards/types";
@ -125,6 +126,12 @@ export const computeCards = (
entity: entityId, entity: entityId,
}; };
cards.push(cardConfig); cards.push(cardConfig);
} else if (domain === "image") {
const cardConfig: PictureCardConfig = {
type: "picture",
image_entity: entityId,
};
cards.push(cardConfig);
} else if (domain === "climate") { } else if (domain === "climate") {
const cardConfig: ThermostatCardConfig = { const cardConfig: ThermostatCardConfig = {
type: "thermostat", type: "thermostat",

View File

@ -18,6 +18,7 @@ declare global {
export type ActionConfigParams = { export type ActionConfigParams = {
entity?: string; entity?: string;
camera_image?: string; camera_image?: string;
image_entity?: string;
hold_action?: ActionConfig; hold_action?: ActionConfig;
tap_action?: ActionConfig; tap_action?: ActionConfig;
double_tap_action?: ActionConfig; double_tap_action?: ActionConfig;
@ -87,9 +88,11 @@ export const handleAction = async (
switch (actionConfig.action) { switch (actionConfig.action) {
case "more-info": { case "more-info": {
if (config.entity || config.camera_image) { if (config.entity || config.camera_image || config.image_entity) {
fireEvent(node, "hass-more-info", { fireEvent(node, "hass-more-info", {
entityId: config.entity ? config.entity : config.camera_image!, entityId: (config.entity ||
config.camera_image ||
config.image_entity)!,
}); });
} else { } else {
showToast(node, { showToast(node, {

View File

@ -10,12 +10,14 @@ import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map"; import { styleMap } from "lit/directives/style-map";
import { STATES_OFF } from "../../../common/const"; import { STATES_OFF } from "../../../common/const";
import { computeDomain } from "../../../common/entity/compute_domain";
import parseAspectRatio from "../../../common/util/parse-aspect-ratio"; import parseAspectRatio from "../../../common/util/parse-aspect-ratio";
import "../../../components/ha-camera-stream"; import "../../../components/ha-camera-stream";
import type { HaCameraStream } from "../../../components/ha-camera-stream"; import type { HaCameraStream } from "../../../components/ha-camera-stream";
import "../../../components/ha-circular-progress"; import "../../../components/ha-circular-progress";
import { CameraEntity, fetchThumbnailUrlWithCache } from "../../../data/camera"; import { CameraEntity, fetchThumbnailUrlWithCache } from "../../../data/camera";
import { UNAVAILABLE } from "../../../data/entity"; import { UNAVAILABLE } from "../../../data/entity";
import { computeImageUrl, ImageEntity } from "../../../data/image";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
const UPDATE_INTERVAL = 10000; const UPDATE_INTERVAL = 10000;
@ -164,6 +166,8 @@ export class HuiImage extends LitElement {
} }
} else if (this.darkModeImage && this.hass.themes.darkMode) { } else if (this.darkModeImage && this.hass.themes.darkMode) {
imageSrc = this.darkModeImage; imageSrc = this.darkModeImage;
} else if (stateObj && computeDomain(stateObj.entity_id) === "image") {
imageSrc = computeImageUrl(stateObj as ImageEntity);
} else { } else {
imageSrc = this.image; imageSrc = this.image;
} }

View File

@ -1,7 +1,7 @@
import { customElement } from "lit/decorators"; import { customElement } from "lit/decorators";
import type { LovelaceCardConfig } from "../../../../data/lovelace"; import type { LovelaceCardConfig } from "../../../../data/lovelace";
import { getCardElementClass } from "../../create-element/create-card-element"; 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"; import { HuiElementEditor } from "../hui-element-editor";
@customElement("hui-card-element-editor") @customElement("hui-card-element-editor")
@ -16,6 +16,17 @@ export class HuiCardElementEditor extends HuiElementEditor<LovelaceCardConfig> {
return undefined; return undefined;
} }
protected async getConfigForm(): Promise<LovelaceConfigForm | undefined> {
const elClass = await getCardElementClass(this.configElementType!);
// Check if a schema exists
if (elClass && elClass.getConfigForm) {
return elClass.getConfigForm();
}
return undefined;
}
} }
declare global { declare global {

View File

@ -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 { assert, assign, boolean, object, optional, string } from "superstruct";
import { fireEvent } from "../../../../common/dom/fire_event"; import { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/ha-form/ha-form"; import { HaFormSchema } from "../../../../components/ha-form/types";
import type { SchemaUnion } from "../../../../components/ha-form/types"; import { EntityCardConfig } from "../../cards/types";
import type { HomeAssistant } from "../../../../types";
import type { EntityCardConfig } from "../../cards/types";
import { headerFooterConfigStructs } from "../../header-footer/structs"; import { headerFooterConfigStructs } from "../../header-footer/structs";
import type { LovelaceCardEditor } from "../../types"; import { LovelaceConfigForm } from "../../types";
import { baseLovelaceCardConfig } from "../structs/base-card-struct"; import { baseLovelaceCardConfig } from "../structs/base-card-struct";
const cardConfigStruct = assign( const struct = assign(
baseLovelaceCardConfig, baseLovelaceCardConfig,
object({ object({
entity: optional(string()), entity: optional(string()),
@ -54,67 +50,19 @@ const SCHEMA = [
{ name: "state_color", selector: { boolean: {} } }, { name: "state_color", selector: { boolean: {} } },
], ],
}, },
] as const; ] as HaFormSchema[];
@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`
<ha-form
.hass=${this.hass}
.data=${this._config}
.schema=${SCHEMA}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
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<typeof SCHEMA>) => {
if (schema.name === "entity") {
return this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.entity"
);
}
const entityCardConfigForm: LovelaceConfigForm = {
schema: SCHEMA,
assertConfig: (config: EntityCardConfig) => assert(config, struct),
computeLabel: (schema: HaFormSchema, localize: LocalizeFunc) => {
if (schema.name === "theme") { if (schema.name === "theme") {
return `${this.hass!.localize( return `${localize(
"ui.panel.lovelace.editor.card.generic.theme" "ui.panel.lovelace.editor.card.generic.theme"
)} (${this.hass!.localize( )} (${localize("ui.panel.lovelace.editor.card.config.optional")})`;
"ui.panel.lovelace.editor.card.config.optional"
)})`;
} }
return localize(`ui.panel.lovelace.editor.card.generic.${schema.name}`);
},
};
return this.hass!.localize( export default entityCardConfigForm;
`ui.panel.lovelace.editor.card.generic.${schema.name}`
);
};
}
declare global {
interface HTMLElementTagNameMap {
"hui-entity-card-editor": HuiEntityCardEditor;
}
}

View File

@ -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`
<ha-form
.hass=${this.hass}
.data=${this._config}
.schema=${this.schema}
.computeLabel=${this._computeLabelCallback}
.computeHelper=${this._computeHelperCallback}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
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;
}
}

View File

@ -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 { customElement, property, state } from "lit/decorators";
import { assert, assign, object, optional, string } from "superstruct"; import { assert, assign, object, optional, string } from "superstruct";
import { fireEvent } from "../../../../common/dom/fire_event"; import { fireEvent } from "../../../../common/dom/fire_event";
import { SchemaUnion } from "../../../../components/ha-form/types";
import "../../../../components/ha-theme-picker"; import "../../../../components/ha-theme-picker";
import { ActionConfig } from "../../../../data/lovelace";
import { HomeAssistant } from "../../../../types"; import { HomeAssistant } from "../../../../types";
import { PictureCardConfig } from "../../cards/types"; import { PictureCardConfig } from "../../cards/types";
import "../../components/hui-action-editor"; import "../../components/hui-action-editor";
import { LovelaceCardEditor } from "../../types"; import { LovelaceCardEditor } from "../../types";
import { actionConfigStruct } from "../structs/action-struct"; import { actionConfigStruct } from "../structs/action-struct";
import { baseLovelaceCardConfig } from "../structs/base-card-struct"; import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import { EditorTarget } from "../types";
import { configElementStyle } from "./config-elements-style";
const cardConfigStruct = assign( const cardConfigStruct = assign(
baseLovelaceCardConfig, baseLovelaceCardConfig,
object({ object({
image: optional(string()), image: optional(string()),
image_entity: optional(string()),
tap_action: optional(actionConfigStruct), tap_action: optional(actionConfigStruct),
hold_action: optional(actionConfigStruct), hold_action: optional(actionConfigStruct),
theme: optional(string()), 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") @customElement("hui-picture-card-editor")
export class HuiPictureCardEditor export class HuiPictureCardEditor
extends LitElement extends LitElement
@ -38,129 +52,45 @@ export class HuiPictureCardEditor
this._config = config; 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() { protected render() {
if (!this.hass || !this._config) { if (!this.hass || !this._config) {
return nothing; return nothing;
} }
const actions = ["navigate", "url", "call-service", "none"];
return html` return html`
<div class="card-config"> <ha-form
<ha-textfield .hass=${this.hass}
.label="${this.hass.localize( .data=${this._config}
"ui.panel.lovelace.editor.card.generic.image" .schema=${SCHEMA}
)} (${this.hass.localize( .computeLabel=${this._computeLabelCallback}
"ui.panel.lovelace.editor.card.config.required" @value-changed=${this._valueChanged}
)})" ></ha-form>
.value=${this._image}
.configValue=${"image"}
@input=${this._valueChanged}
></ha-textfield>
<ha-textfield
.label="${this.hass.localize(
"ui.panel.lovelace.editor.card.generic.alt_text"
)} (${this.hass.localize(
"ui.panel.lovelace.editor.card.config.optional"
)})"
.value=${this._alt_text}
.configValue=${"alt_text"}
@input=${this._valueChanged}
></ha-textfield>
<ha-theme-picker
.hass=${this.hass}
.value=${this._theme}
.label=${`${this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.theme"
)} (${this.hass!.localize(
"ui.panel.lovelace.editor.card.config.optional"
)})`}
.configValue=${"theme"}
@value-changed=${this._valueChanged}
></ha-theme-picker>
<hui-action-editor
.label="${this.hass.localize(
"ui.panel.lovelace.editor.card.generic.tap_action"
)} (${this.hass.localize(
"ui.panel.lovelace.editor.card.config.optional"
)})"
.hass=${this.hass}
.config=${this._tap_action}
.actions=${actions}
.configValue=${"tap_action"}
@value-changed=${this._valueChanged}
></hui-action-editor>
<hui-action-editor
.label="${this.hass.localize(
"ui.panel.lovelace.editor.card.generic.hold_action"
)} (${this.hass.localize(
"ui.panel.lovelace.editor.card.config.optional"
)})"
.hass=${this.hass}
.config=${this._hold_action}
.actions=${actions}
.configValue=${"hold_action"}
@value-changed=${this._valueChanged}
></hui-action-editor>
</div>
`; `;
} }
private _valueChanged(ev: CustomEvent): void { private _valueChanged(ev: CustomEvent): void {
if (!this._config || !this.hass) { fireEvent(this, "config-changed", { config: ev.detail.value });
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 });
} }
static get styles(): CSSResultGroup { private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
return [ switch (schema.name) {
configElementStyle, case "theme":
css` return `${this.hass!.localize(
ha-textfield { "ui.panel.lovelace.editor.card.generic.theme"
display: block; )} (${this.hass!.localize(
margin-bottom: 8px; "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 { declare global {

View File

@ -22,6 +22,7 @@ const cardConfigStruct = assign(
title: optional(string()), title: optional(string()),
entity: optional(string()), entity: optional(string()),
image: optional(string()), image: optional(string()),
image_entity: optional(string()),
camera_image: optional(string()), camera_image: optional(string()),
camera_view: optional(string()), camera_view: optional(string()),
aspect_ratio: optional(string()), aspect_ratio: optional(string()),
@ -35,6 +36,7 @@ const cardConfigStruct = assign(
const SCHEMA = [ const SCHEMA = [
{ name: "title", selector: { text: {} } }, { name: "title", selector: { text: {} } },
{ name: "image", selector: { text: {} } }, { name: "image", selector: { text: {} } },
{ name: "image_entity", selector: { entity: { domain: "image" } } },
{ name: "camera_image", selector: { entity: { domain: "camera" } } }, { name: "camera_image", selector: { entity: { domain: "camera" } } },
{ {
name: "", name: "",

View File

@ -187,7 +187,7 @@ export class HuiStatisticsGraphCardEditor
), ),
disabled: disabled:
!metaDatas || !metaDatas ||
!metaDatas.every((metaData) => !metaDatas.some((metaData) =>
statisticsMetaHasType( statisticsMetaHasType(
metaData, metaData,
supportedStatTypeMap[stat_type] supportedStatTypeMap[stat_type]
@ -246,12 +246,10 @@ export class HuiStatisticsGraphCardEditor
); );
const configured_stat_types = this._config!.stat_types const configured_stat_types = this._config!.stat_types
? ensureArray(this._config.stat_types) ? ensureArray(this._config.stat_types)
: stat_types.filter( : stat_types.filter((stat_type) =>
(stat_type) => this._metaDatas?.some((metaData) =>
stat_type !== "change" && statisticsMetaHasType(metaData, stat_type)
this._metaDatas?.every((metaData) => )
statisticsMetaHasType(metaData, stat_type)
)
); );
const data = { const data = {
chart_type: "line", chart_type: "line",
@ -320,9 +318,7 @@ export class HuiStatisticsGraphCardEditor
: undefined; : undefined;
if (config.stat_types && config.entities.length) { if (config.stat_types && config.entities.length) {
config.stat_types = ensureArray(config.stat_types).filter((stat_type) => config.stat_types = ensureArray(config.stat_types).filter((stat_type) =>
metadata!.every((metaData) => metadata!.some((metaData) => statisticsMetaHasType(metaData, stat_type))
statisticsMetaHasType(metaData, stat_type)
)
); );
if (!config.stat_types.length) { if (!config.stat_types.length) {
delete config.stat_types; delete config.stat_types;

View File

@ -8,13 +8,13 @@ import {
PropertyValues, PropertyValues,
TemplateResult, TemplateResult,
} from "lit"; } from "lit";
import { property, state, query } from "lit/decorators"; import { property, query, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
import { handleStructError } from "../../../common/structs/handle-errors"; import { handleStructError } from "../../../common/structs/handle-errors";
import { deepEqual } from "../../../common/util/deep-equal"; import { deepEqual } from "../../../common/util/deep-equal";
import "../../../components/ha-alert";
import "../../../components/ha-circular-progress"; import "../../../components/ha-circular-progress";
import "../../../components/ha-code-editor"; import "../../../components/ha-code-editor";
import "../../../components/ha-alert";
import type { HaCodeEditor } from "../../../components/ha-code-editor"; import type { HaCodeEditor } from "../../../components/ha-code-editor";
import type { import type {
LovelaceCardConfig, LovelaceCardConfig,
@ -23,11 +23,15 @@ import type {
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import type { LovelaceRowConfig } from "../entity-rows/types"; import type { LovelaceRowConfig } from "../entity-rows/types";
import { LovelaceHeaderFooterConfig } from "../header-footer/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 "./config-elements/hui-generic-entity-row-editor";
import { GUISupportError } from "./gui-support-error"; import { GUISupportError } from "./gui-support-error";
import { EditSubElementEvent, GUIModeChangedEvent } from "./types"; import { EditSubElementEvent, GUIModeChangedEvent } from "./types";
import { LovelaceTileFeatureConfig } from "../tile-features/types";
export interface ConfigChangedEvent { export interface ConfigChangedEvent {
config: config:
@ -182,6 +186,10 @@ export abstract class HuiElementEditor<T, C = any> extends LitElement {
return undefined; return undefined;
} }
protected async getConfigForm(): Promise<LovelaceConfigForm | undefined> {
return undefined;
}
protected get configElementType(): string | undefined { protected get configElementType(): string | undefined {
return this.value ? (this.value as any).type : undefined; return this.value ? (this.value as any).type : undefined;
} }
@ -328,6 +336,25 @@ export abstract class HuiElementEditor<T, C = any> extends LitElement {
this._loading = true; this._loading = true;
configElement = await this.getConfigElement(); 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) { if (configElement) {
configElement.hass = this.hass; configElement.hass = this.hass;
if ("lovelace" in configElement) { if ("lovelace" in configElement) {

View File

@ -4,7 +4,10 @@ import {
LovelaceTileFeatureConfig, LovelaceTileFeatureConfig,
LovelaceTileFeatureContext, LovelaceTileFeatureContext,
} from "../../tile-features/types"; } from "../../tile-features/types";
import type { LovelaceTileFeatureEditor } from "../../types"; import type {
LovelaceConfigForm,
LovelaceTileFeatureEditor,
} from "../../types";
import { HuiElementEditor } from "../hui-element-editor"; import { HuiElementEditor } from "../hui-element-editor";
@customElement("hui-tile-feature-element-editor") @customElement("hui-tile-feature-element-editor")
@ -24,6 +27,17 @@ export class HuiTileFeatureElementEditor extends HuiElementEditor<
return undefined; return undefined;
} }
protected async getConfigForm(): Promise<LovelaceConfigForm | undefined> {
const elClass = await getTileFeatureElementClass(this.configElementType!);
// Check if a schema exists
if (elClass && elClass.getConfigForm) {
return elClass.getConfigForm();
}
return undefined;
}
} }
declare global { declare global {

View File

@ -1,6 +1,7 @@
import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined"; import { ifDefined } from "lit/directives/if-defined";
import { ImageEntity, computeImageUrl } from "../../../data/image";
import { ActionHandlerEvent } from "../../../data/lovelace"; import { ActionHandlerEvent } from "../../../data/lovelace";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
import { computeTooltip } from "../common/compute-tooltip"; import { computeTooltip } from "../common/compute-tooltip";
@ -34,12 +35,16 @@ export class HuiImageElement extends LitElement implements LovelaceElement {
if (!this._config || !this.hass) { if (!this._config || !this.hass) {
return nothing; return nothing;
} }
let stateObj: ImageEntity | undefined;
if (this._config.image_entity) {
stateObj = this.hass.states[this._config.image_entity] as ImageEntity;
}
return html` return html`
<hui-image <hui-image
.hass=${this.hass} .hass=${this.hass}
.entity=${this._config.entity} .entity=${this._config.entity}
.image=${this._config.image} .image=${stateObj ? computeImageUrl(stateObj) : this._config.image}
.stateImage=${this._config.state_image} .stateImage=${this._config.state_image}
.cameraImage=${this._config.camera_image} .cameraImage=${this._config.camera_image}
.cameraView=${this._config.camera_view} .cameraView=${this._config.camera_view}

View File

@ -42,6 +42,7 @@ export interface ImageElementConfig extends LovelaceElementConfigBase {
hold_action?: ActionConfig; hold_action?: ActionConfig;
double_tap_action?: ActionConfig; double_tap_action?: ActionConfig;
image?: string; image?: string;
image_entity?: string;
state_image?: string; state_image?: string;
camera_image?: string; camera_image?: string;
camera_view?: HuiImage["cameraView"]; camera_view?: HuiImage["cameraView"];

View File

@ -1,4 +1,6 @@
import { HassEntity } from "home-assistant-js-websocket"; import { HassEntity } from "home-assistant-js-websocket";
import { LocalizeFunc } from "../../common/translations/localize";
import { HaFormSchema } from "../../components/ha-form/types";
import { import {
LovelaceBadgeConfig, LovelaceBadgeConfig,
LovelaceCardConfig, LovelaceCardConfig,
@ -45,6 +47,19 @@ export interface LovelaceCard extends HTMLElement {
setConfig(config: LovelaceCardConfig): void; setConfig(config: LovelaceCardConfig): void;
} }
export interface LovelaceConfigForm {
schema: HaFormSchema[];
assertConfig?: (config: LovelaceCardConfig) => void;
computeLabel?: (
schema: HaFormSchema,
localize: LocalizeFunc
) => string | undefined;
computeHelper?: (
schema: HaFormSchema,
localize: LocalizeFunc
) => string | undefined;
}
export interface LovelaceCardConstructor extends Constructor<LovelaceCard> { export interface LovelaceCardConstructor extends Constructor<LovelaceCard> {
getStubConfig?: ( getStubConfig?: (
hass: HomeAssistant, hass: HomeAssistant,
@ -52,6 +67,7 @@ export interface LovelaceCardConstructor extends Constructor<LovelaceCard> {
entitiesFallback: string[] entitiesFallback: string[]
) => LovelaceCardConfig; ) => LovelaceCardConfig;
getConfigElement?: () => LovelaceCardEditor; getConfigElement?: () => LovelaceCardEditor;
getConfigForm?: () => LovelaceConfigForm;
} }
export interface LovelaceHeaderFooterConstructor export interface LovelaceHeaderFooterConstructor
@ -104,11 +120,15 @@ export interface LovelaceTileFeature extends HTMLElement {
export interface LovelaceTileFeatureConstructor export interface LovelaceTileFeatureConstructor
extends Constructor<LovelaceTileFeature> { extends Constructor<LovelaceTileFeature> {
getConfigElement?: () => LovelaceTileFeatureEditor;
getStubConfig?: ( getStubConfig?: (
hass: HomeAssistant, hass: HomeAssistant,
stateObj?: HassEntity stateObj?: HassEntity
) => LovelaceTileFeatureConfig; ) => LovelaceTileFeatureConfig;
getConfigElement?: () => LovelaceTileFeatureEditor;
getConfigForm?: () => {
schema: HaFormSchema[];
assertConfig?: (config: LovelaceCardConfig) => void;
};
isSupported?: (stateObj?: HassEntity) => boolean; isSupported?: (stateObj?: HassEntity) => boolean;
} }

View File

@ -9,7 +9,7 @@ import {
TemplateResult, TemplateResult,
} from "lit"; } from "lit";
import { customElement, property, query, state } from "lit/decorators"; 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 { fireEvent, HASSDomEvent } from "../../common/dom/fire_event";
import { navigate } from "../../common/navigate"; import { navigate } from "../../common/navigate";
import "../../components/ha-menu-button"; 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; private _entityId = BROWSER_PLAYER;
@query("ha-media-player-browse") private _browser!: HaMediaPlayerBrowse; @query("ha-media-player-browse") private _browser!: HaMediaPlayerBrowse;

67
src/resources/svg-arc.ts Normal file
View File

@ -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(" ");
};

View File

@ -3,8 +3,10 @@ import "@polymer/iron-flex-layout/iron-flex-layout-classes";
import { html } from "@polymer/polymer/lib/utils/html-tag"; import { html } from "@polymer/polymer/lib/utils/html-tag";
/* eslint-plugin-disable lit */ /* eslint-plugin-disable lit */
import { PolymerElement } from "@polymer/polymer/polymer-element"; import { PolymerElement } from "@polymer/polymer/polymer-element";
import { supportsFeature } from "../common/entity/supports-feature";
import "../components/entity/state-info"; import "../components/entity/state-info";
import LocalizeMixin from "../mixins/localize-mixin"; import LocalizeMixin from "../mixins/localize-mixin";
import { LockEntityFeature } from "../data/lock";
/* /*
* @appliesMixin LocalizeMixin * @appliesMixin LocalizeMixin
@ -19,10 +21,19 @@ class StateCardLock extends LocalizeMixin(PolymerElement) {
height: 37px; height: 37px;
margin-right: -0.57em; margin-right: -0.57em;
} }
[hidden] {
display: none !important;
}
</style> </style>
<div class="horizontal justified layout"> <div class="horizontal justified layout">
${this.stateInfoTemplate} ${this.stateInfoTemplate}
<mwc-button
on-click="_callService"
data-service="open"
hidden$="[[!supportsOpen]]"
>[[localize('ui.card.lock.open')]]</mwc-button
>
<mwc-button <mwc-button
on-click="_callService" on-click="_callService"
data-service="unlock" data-service="unlock"
@ -61,12 +72,14 @@ class StateCardLock extends LocalizeMixin(PolymerElement) {
value: false, value: false,
}, },
isLocked: Boolean, isLocked: Boolean,
supportsOpen: Boolean,
}; };
} }
_stateObjChanged(newVal) { _stateObjChanged(newVal) {
if (newVal) { if (newVal) {
this.isLocked = newVal.state === "locked"; this.isLocked = newVal.state === "locked";
this.supportsOpen = supportsFeature(newVal, LockEntityFeature.OPEN);
} }
} }

View File

@ -147,7 +147,8 @@
"lock": { "lock": {
"code": "[%key:ui::card::alarm_control_panel::code%]", "code": "[%key:ui::card::alarm_control_panel::code%]",
"lock": "Lock", "lock": "Lock",
"unlock": "Unlock" "unlock": "Unlock",
"open": "Open"
}, },
"media_player": { "media_player": {
"source": "Source", "source": "Source",
@ -4666,6 +4667,7 @@
"aspect_ratio": "Aspect Ratio", "aspect_ratio": "Aspect Ratio",
"attribute": "Attribute", "attribute": "Attribute",
"camera_image": "Camera Entity", "camera_image": "Camera Entity",
"image_entity": "Image Entity",
"camera_view": "Camera View", "camera_view": "Camera View",
"double_tap_action": "Double Tap Action", "double_tap_action": "Double Tap Action",
"entities": "Entities", "entities": "Entities",