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",
config: `
- type: markdown
content: >-
content: |
# h1 Heading 8-)
## h2 Heading
@ -65,6 +65,15 @@ const CONFIGS = [
>> ...by using additional greater-than signs right next to each other...
> > > ...or with spaces between arrows.
> **Warning** Hey there
> This is a warning with a title
> **Note**
> This is a note
> **Note**
> This is a multiline note
> Lorem ipsum...
## Lists

View File

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

View File

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

View File

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

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.removeEventListener("keydown", this._handleKeyDown);
this.removeEventListener("keyup", this._handleKeyDown);
this.removeEventListener("keyup", this._handleKeyUp);
}
private get _tenPercentStep() {

View File

@ -3,6 +3,8 @@ import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { renderMarkdown } from "../resources/render-markdown";
const _blockQuoteToAlert = { Note: "info", Warning: "warning" };
@customElement("ha-markdown-element")
class HaMarkdownElement extends ReactiveElement {
@property() public content?;
@ -65,6 +67,34 @@ class HaMarkdownElement extends ReactiveElement {
node.loading = "lazy";
}
node.addEventListener("load", this._resize);
} else if (node instanceof HTMLQuoteElement) {
// Map GitHub blockquote elements to our ha-alert element
const firstElementChild = node.firstElementChild;
const quoteTitleElement = firstElementChild?.firstElementChild;
const quoteType =
quoteTitleElement?.textContent &&
_blockQuoteToAlert[quoteTitleElement.textContent];
// GitHub is strict on how these are defined, we need to make sure we know what we have before starting to replace it
if (quoteTitleElement?.nodeName === "STRONG" && quoteType) {
const alertNote = document.createElement("ha-alert");
alertNote.alertType = quoteType;
alertNote.title =
(firstElementChild!.childNodes[1].nodeName === "#text" &&
firstElementChild!.childNodes[1].textContent?.trimStart()) ||
"";
const childNodes = Array.from(firstElementChild!.childNodes);
for (const child of childNodes.slice(
childNodes.findIndex(
// There is always a line break between the title and the content, we want to skip that
(childNode) => childNode instanceof HTMLBRElement
) + 1
)) {
alertNote.appendChild(child);
}
node.firstElementChild!.replaceWith(alertNote);
}
}
}
}

View File

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

View File

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

View File

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

View File

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

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 = (
stats: StatisticValue[],
type: StatisticType
) => stats.some((stat) => stat[type] !== null);
) => stats.some((stat) => stat[type] !== undefined && stat[type] !== null);
const mean_stat_types: readonly StatisticType[] = ["mean", "min", "max"];
const sum_stat_types: readonly StatisticType[] = ["sum"];
const sum_stat_types: readonly StatisticType[] = ["sum", "state", "change"];
export const statisticsMetaHasType = (
metadata: StatisticsMetaData,

View File

@ -40,6 +40,7 @@ export const DOMAINS_WITH_MORE_INFO = [
"fan",
"group",
"humidifier",
"image",
"input_boolean",
"input_datetime",
"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"),
group: () => import("./controls/more-info-group"),
humidifier: () => import("./controls/more-info-humidifier"),
image: () => import("./controls/more-info-image"),
input_boolean: () => import("./controls/more-info-input_boolean"),
input_datetime: () => import("./controls/more-info-input_datetime"),
light: () => import("./controls/more-info-light"),

View File

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

View File

@ -18,7 +18,7 @@ import {
TemplateResult,
} from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { LocalStorage } from "../../common/decorators/local-storage";
import { storage } from "../../common/decorators/storage";
import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation";
import "../../components/ha-button";
@ -57,7 +57,12 @@ export class HaVoiceCommandDialog extends LitElement {
@state() private _opened = false;
@LocalStorage("AssistPipelineId", true, false) private _pipelineId?: string;
@storage({
key: "AssistPipelineId",
state: true,
subscribe: false,
})
private _pipelineId?: string;
@state() private _pipeline?: AssistPipeline;

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import "@material/mwc-list/mwc-list-item";
import { mdiPlayCircleOutline, mdiRobot } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { LocalStorage } from "../../../../common/decorators/local-storage";
import { storage } from "../../../../common/decorators/storage";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { computeStateDomain } from "../../../../common/entity/compute_state_domain";
@ -31,9 +31,19 @@ export class DialogTryTts extends LitElement {
@query("#message") private _messageInput?: HaTextArea;
@LocalStorage("cloudTtsTryMessage", false, false) private _message!: string;
@storage({
key: "cloudTtsTryMessage",
state: false,
subscribe: false,
})
private _message!: string;
@LocalStorage("cloudTtsTryTarget", false, false) private _target!: string;
@storage({
key: "cloudTtsTryTarget",
state: false,
subscribe: false,
})
private _target!: string;
public showDialog(params: TryTtsDialogParams) {
this._params = params;

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,7 @@ import { load } from "js-yaml";
import { css, CSSResultGroup, html, LitElement } from "lit";
import { property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { LocalStorage } from "../../../common/decorators/local-storage";
import { storage } from "../../../common/decorators/storage";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeObjectId } from "../../../common/entity/compute_object_id";
import { hasTemplate } from "../../../common/string/has-template";
@ -38,10 +38,18 @@ class HaPanelDevService extends LitElement {
@state() private _uiAvailable = true;
@LocalStorage("panel-dev-service-state-service-data", true, false)
@storage({
key: "panel-dev-service-state-service-data",
state: true,
subscribe: false,
})
private _serviceData?: ServiceAction = { service: "", target: {}, data: {} };
@LocalStorage("panel-dev-service-state-yaml-mode", true, false)
@storage({
key: "panel-dev-service-state-yaml-mode",
state: true,
subscribe: false,
})
private _yamlMode = false;
@query("ha-yaml-editor") private _yamlEditor?: HaYamlEditor;

View File

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

View File

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

View File

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

View File

@ -9,6 +9,7 @@ import {
import { customElement, property, state } from "lit/decorators";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import "../../../components/ha-card";
import { ImageEntity, computeImageUrl } from "../../../data/image";
import { HomeAssistant } from "../../../types";
import { findEntities } from "../common/find-entities";
import { LovelaceElement, LovelaceElementConfig } from "../elements/types";
@ -62,7 +63,12 @@ class HuiPictureElementsCard extends LitElement implements LovelaceCard {
if (!config) {
throw new Error("Invalid configuration");
} else if (
!(config.image || config.camera_image || config.state_image) ||
!(
config.image ||
config.image_entity ||
config.camera_image ||
config.state_image
) ||
(config.state_image && !config.entity)
) {
throw new Error("Image required");
@ -115,12 +121,17 @@ class HuiPictureElementsCard extends LitElement implements LovelaceCard {
return nothing;
}
let stateObj: ImageEntity | undefined;
if (this._config.image_entity) {
stateObj = this.hass.states[this._config.image_entity] as ImageEntity;
}
return html`
<ha-card .header=${this._config.title}>
<div id="root">
<hui-image
.hass=${this.hass}
.image=${this._config.image}
.image=${stateObj ? computeImageUrl(stateObj) : this._config.image}
.stateImage=${this._config.state_image}
.stateFilter=${this._config.state_filter}
.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 { computeStateName } from "../../../common/entity/compute_state_name";
import "../../../components/ha-card";
import { computeImageUrl, ImageEntity } from "../../../data/image";
import { ActionHandlerEvent } from "../../../data/lovelace";
import { HomeAssistant } from "../../../types";
import { actionHandler } from "../common/directives/action-handler-directive";
@ -68,7 +69,7 @@ class HuiPictureEntityCard extends LitElement implements LovelaceCard {
}
if (
computeDomain(config.entity) !== "camera" &&
!["camera", "image"].includes(computeDomain(config.entity)) &&
!config.image &&
!config.state_image &&
!config.camera_image
@ -141,14 +142,18 @@ class HuiPictureEntityCard extends LitElement implements LovelaceCard {
footer = html`<div class="footer single">${entityState}</div>`;
}
const domain = computeDomain(this._config.entity);
return html`
<ha-card>
<hui-image
.hass=${this.hass}
.image=${this._config.image}
.image=${domain === "image"
? computeImageUrl(stateObj as ImageEntity)
: this._config.image}
.stateImage=${this._config.state_image}
.stateFilter=${this._config.state_filter}
.cameraImage=${computeDomain(this._config.entity) === "camera"
.cameraImage=${domain === "camera"
? this._config.entity
: this._config.camera_image}
.cameraView=${this._config.camera_view}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import { customElement } from "lit/decorators";
import type { LovelaceCardConfig } from "../../../../data/lovelace";
import { getCardElementClass } from "../../create-element/create-card-element";
import type { LovelaceCardEditor } from "../../types";
import type { LovelaceCardEditor, LovelaceConfigForm } from "../../types";
import { HuiElementEditor } from "../hui-element-editor";
@customElement("hui-card-element-editor")
@ -16,6 +16,17 @@ export class HuiCardElementEditor extends HuiElementEditor<LovelaceCardConfig> {
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 {

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 { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types";
import type { EntityCardConfig } from "../../cards/types";
import { LocalizeFunc } from "../../../../common/translations/localize";
import { HaFormSchema } from "../../../../components/ha-form/types";
import { EntityCardConfig } from "../../cards/types";
import { headerFooterConfigStructs } from "../../header-footer/structs";
import type { LovelaceCardEditor } from "../../types";
import { LovelaceConfigForm } from "../../types";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
const cardConfigStruct = assign(
const struct = assign(
baseLovelaceCardConfig,
object({
entity: optional(string()),
@ -54,67 +50,19 @@ const SCHEMA = [
{ name: "state_color", selector: { boolean: {} } },
],
},
] as const;
@customElement("hui-entity-card-editor")
export class HuiEntityCardEditor
extends LitElement
implements LovelaceCardEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _config?: EntityCardConfig;
public setConfig(config: EntityCardConfig): void {
assert(config, cardConfigStruct);
this._config = config;
}
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
return html`
<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"
);
}
] as HaFormSchema[];
const entityCardConfigForm: LovelaceConfigForm = {
schema: SCHEMA,
assertConfig: (config: EntityCardConfig) => assert(config, struct),
computeLabel: (schema: HaFormSchema, localize: LocalizeFunc) => {
if (schema.name === "theme") {
return `${this.hass!.localize(
return `${localize(
"ui.panel.lovelace.editor.card.generic.theme"
)} (${this.hass!.localize(
"ui.panel.lovelace.editor.card.config.optional"
)})`;
)} (${localize("ui.panel.lovelace.editor.card.config.optional")})`;
}
return localize(`ui.panel.lovelace.editor.card.generic.${schema.name}`);
},
};
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
);
};
}
declare global {
interface HTMLElementTagNameMap {
"hui-entity-card-editor": HuiEntityCardEditor;
}
}
export default entityCardConfigForm;

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,7 +9,7 @@ import {
TemplateResult,
} from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { LocalStorage } from "../../common/decorators/local-storage";
import { storage } from "../../common/decorators/storage";
import { fireEvent, HASSDomEvent } from "../../common/dom/fire_event";
import { navigate } from "../../common/navigate";
import "../../components/ha-menu-button";
@ -71,7 +71,11 @@ class PanelMediaBrowser extends LitElement {
},
];
@LocalStorage("mediaBrowseEntityId", true, false)
@storage({
key: "mediaBrowseEntityId",
state: true,
subscribe: false,
})
private _entityId = BROWSER_PLAYER;
@query("ha-media-player-browse") private _browser!: HaMediaPlayerBrowse;

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

View File

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