mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-19 12:19:32 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa298cbf66 | ||
|
|
b9a04ed660 | ||
|
|
60108e1d74 | ||
|
|
ca685a5c13 | ||
|
|
f008af2751 | ||
|
|
47a0f8863d | ||
|
|
f2bf89ad69 |
No files matched your search
@@ -34,7 +34,7 @@ runs:
|
||||
hashFiles('.nvmrc', 'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'patches/**') }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@
|
||||
"home-assistant-js-websocket": "9.7.1",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "12.1.3",
|
||||
"js-yaml": "5.4.3",
|
||||
"js-yaml": "5.5.0",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
"lit": "3.3.3",
|
||||
|
||||
Generated
+11
-6
@@ -386,8 +386,8 @@ importers:
|
||||
specifier: 12.1.3
|
||||
version: 12.1.3
|
||||
js-yaml:
|
||||
specifier: 5.4.3
|
||||
version: 5.4.3
|
||||
specifier: 5.5.0
|
||||
version: 5.5.0
|
||||
leaflet:
|
||||
specifier: 1.9.4
|
||||
version: 1.9.4
|
||||
@@ -3916,6 +3916,9 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Sn98ftV3KfsO8xfLPHG6oT8y+b0yvbrLgZjgPDXsharNY15tTAjqIsIju4S32nl6/iEUNNE4v+yL2Zhm8ooP3w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-dgojXfc4SiqmNwe38PnbT3zJasrz7g62dLAPD+VFT5RJb8W7LGRqw2IFd2ES+plnhsp4HYNJmFqMU1tCThdCww==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
@@ -5629,8 +5632,8 @@ packages:
|
||||
resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==}
|
||||
hasBin: true
|
||||
|
||||
js-yaml@5.4.3:
|
||||
resolution: {integrity: sha512-yzzNtgczwgoVHNigCyKTXcQJU4Y8t/ikU4UxXW0BvrLfHTGWZhsNcOT2s/a7vn81/4KqVnNmHjaUEPlvWnNwVw==}
|
||||
js-yaml@5.5.0:
|
||||
resolution: {integrity: sha512-paNr4LFIFPjeeSmwNYdrb8FafLLG/Cr2Af3GjbsHJ5CxIjH4r0c2emlmRdAtpPAieP1gRVzk6buBbVP5gBMGAg==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
@@ -11518,6 +11521,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -13365,9 +13370,9 @@ snapshots:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
js-yaml@5.4.3:
|
||||
js-yaml@5.5.0:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
argparse: 3.0.3
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
|
||||
@@ -29,7 +29,7 @@ const ALLOWED_LICENSES = new Set([
|
||||
"(MIT OR CC0-1.0)",
|
||||
"(MIT OR Apache-2.0)",
|
||||
"(MIT AND Zlib)",
|
||||
"Python-2.0", // argparse - Python Software Foundation License (permissive)
|
||||
"PSF-2.0", // argparse - Python Software Foundation License (permissive)
|
||||
"Public Domain",
|
||||
"W3C-20150513", // wicg-inert - W3C Software and Document License (permissive)
|
||||
"Unlicense",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { parsePlainText } from "zrender/lib/graphic/helper/parseText.js";
|
||||
|
||||
const FONT_FAMILY = "Roboto, Noto, sans-serif";
|
||||
// zrender's overflow "break" only wraps at these characters, see isWordBreakChar
|
||||
const WORD_BREAK =
|
||||
/[ ,&?/;\]]|[^\u0020-\u024F\u0370-\u10FF\u1200-\u13FF\u1E00-\u206F]/;
|
||||
|
||||
interface FitLabelOptions {
|
||||
/** Font size the label is measured at, and the largest size returned */
|
||||
fontSize: number;
|
||||
width: number;
|
||||
height: number;
|
||||
/** Height of the label at `fontSize` before ECharts wraps it */
|
||||
unwrappedHeight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Largest font size at which an ECharts label with `overflow: "break"`,
|
||||
* wrapped at `width`, fits in `height` without breaking a word.
|
||||
*/
|
||||
export function fitLabelFontSize(
|
||||
text: string,
|
||||
{ fontSize: baseFontSize, width, height, unwrappedHeight }: FitLabelOptions
|
||||
) {
|
||||
const font = `${baseFontSize}px ${FONT_FAMILY}`;
|
||||
const measure = (label: string, wrapWidth?: number) =>
|
||||
parsePlainText(
|
||||
label,
|
||||
{ font, width: wrapWidth, overflow: "break" },
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
// ECharts measures at the final font size, leave room for rounding
|
||||
const fitWidth = Math.max(width - 1, 1);
|
||||
const unwrappedContentHeight = measure(text).contentHeight;
|
||||
const words = text.split(WORD_BREAK).filter(Boolean);
|
||||
// a label at a smaller font wraps like one at baseFontSize in a wider box
|
||||
const wrapWidthAt = (fontSize: number) =>
|
||||
(fitWidth * baseFontSize) / fontSize;
|
||||
const maxFontSize = (height / unwrappedHeight) * baseFontSize;
|
||||
const fits = (fontSize: number) => {
|
||||
const wrapWidth = wrapWidthAt(fontSize);
|
||||
if (words.some((word) => measure(word, wrapWidth).lines.length > 1)) {
|
||||
return false;
|
||||
}
|
||||
const wrappedHeight =
|
||||
unwrappedHeight +
|
||||
(measure(text, wrapWidth).contentHeight - unwrappedContentHeight);
|
||||
// exact at maxFontSize when the label doesn't wrap
|
||||
return fontSize <= maxFontSize * (unwrappedHeight / wrappedHeight);
|
||||
};
|
||||
let low = 0;
|
||||
let high = Math.min(baseFontSize, maxFontSize);
|
||||
if (fits(high)) {
|
||||
low = high;
|
||||
} else {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const mid = (low + high) / 2;
|
||||
if (fits(mid)) {
|
||||
low = mid;
|
||||
} else {
|
||||
high = mid;
|
||||
}
|
||||
}
|
||||
}
|
||||
// zrender moves a space that doesn't fit to the next line, indenting it.
|
||||
// Shrinking further only widens the wrap, so the label still fits.
|
||||
const spaceWidth = measure(" ").contentWidth;
|
||||
const indents = (fontSize: number) =>
|
||||
text
|
||||
.split("\n")
|
||||
.some((paragraph) =>
|
||||
measure(paragraph, wrapWidthAt(fontSize)).lines.some(
|
||||
(line, i) => i > 0 && line.startsWith(" ")
|
||||
)
|
||||
);
|
||||
let fontSize = low;
|
||||
for (let i = 0; i < 3 && indents(fontSize); i++) {
|
||||
fontSize = (fitWidth * baseFontSize) / (wrapWidthAt(fontSize) + spaceWidth);
|
||||
}
|
||||
return fontSize;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import SankeyChart from "../../resources/echarts/components/sankey/install";
|
||||
import type { HaECOption } from "../../resources/echarts/echarts";
|
||||
import { measureTextWidth } from "../../util/text";
|
||||
import { fitLabelFontSize } from "./fit-label-font-size";
|
||||
import "./ha-chart-base";
|
||||
import "./ha-chart-tooltip-marker";
|
||||
import "../ha-alert";
|
||||
@@ -279,13 +280,14 @@ export class HaSankeyChart extends LitElement {
|
||||
}
|
||||
|
||||
const availableHeight = params.rect.height + 8 * this._currentZoom; // account for the margin
|
||||
const fontSize = Math.min(
|
||||
(availableHeight / params.labelRect.height) * FONT_SIZE,
|
||||
FONT_SIZE
|
||||
);
|
||||
return {
|
||||
fontSize,
|
||||
lineHeight: fontSize,
|
||||
// labelRect is measured before the width is applied, so unwrapped
|
||||
fontSize: fitLabelFontSize(params.text, {
|
||||
fontSize: FONT_SIZE,
|
||||
width: labelSpace,
|
||||
height: availableHeight,
|
||||
unwrappedHeight: params.labelRect.height,
|
||||
}),
|
||||
width: labelSpace,
|
||||
height: params.rect.height,
|
||||
};
|
||||
|
||||
@@ -40,6 +40,11 @@ export class HaAnsiToHtml extends LitElement {
|
||||
return html`<pre class=${classMap({ wrap: !this.wrapDisabled })}></pre>`;
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
CSS.highlights?.delete("search-results");
|
||||
}
|
||||
|
||||
protected firstUpdated(_changedProperties: PropertyValues<this>): void {
|
||||
super.firstUpdated(_changedProperties);
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { mdiArrowCollapseDown } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { consume } from "../common/decorators/consume";
|
||||
import { debounce } from "../common/util/debounce";
|
||||
import { internationalizationContext } from "../data/context";
|
||||
import "./ha-ansi-to-html";
|
||||
import type { HaAnsiToHtml } from "./ha-ansi-to-html";
|
||||
import "./ha-button";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
const SCROLL_FOLLOW_MARGIN = 40;
|
||||
|
||||
@customElement("ha-log-viewer")
|
||||
export class HaLogViewer extends LitElement {
|
||||
@property({ type: Number, attribute: "max-lines" }) public maxLines = 5000;
|
||||
|
||||
@property() public filter = "";
|
||||
|
||||
@property({ type: Boolean, attribute: "wrap-disabled" }) public wrapDisabled =
|
||||
false;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@state() private _newLogsIndicator = false;
|
||||
|
||||
@state() private _noMatches = false;
|
||||
|
||||
@query(".log") private _logElement!: HTMLElement;
|
||||
|
||||
@query("ha-ansi-to-html") private _ansiToHtml!: HaAnsiToHtml;
|
||||
|
||||
private _lines: string[] = [];
|
||||
|
||||
private _followFrame?: number;
|
||||
|
||||
private _ready = false;
|
||||
|
||||
public get lines(): string[] {
|
||||
return this._lines;
|
||||
}
|
||||
|
||||
public addLines(lines: string[]) {
|
||||
const newLines = lines
|
||||
.flatMap((line) => line.split("\n"))
|
||||
.slice(-this.maxLines);
|
||||
if (!newLines.length) {
|
||||
return;
|
||||
}
|
||||
const overflow = this._lines.length + newLines.length > this.maxLines;
|
||||
this._lines = this._lines.concat(newLines).slice(-this.maxLines);
|
||||
if (!this._ready) {
|
||||
return;
|
||||
}
|
||||
const follow = this._isScrolledToBottom();
|
||||
this._renderLines(newLines);
|
||||
if (overflow) {
|
||||
this._trimLines();
|
||||
}
|
||||
if (follow) {
|
||||
this._followBottom();
|
||||
} else {
|
||||
this._newLogsIndicator = true;
|
||||
}
|
||||
}
|
||||
|
||||
public clear() {
|
||||
this._cancelFollowBottom();
|
||||
this._lines = [];
|
||||
this._newLogsIndicator = false;
|
||||
this._noMatches = false;
|
||||
if (this._ready) {
|
||||
this._ansiToHtml.clear();
|
||||
}
|
||||
}
|
||||
|
||||
public scrollToBottom() {
|
||||
this._newLogsIndicator = false;
|
||||
if (this._ready) {
|
||||
this._logElement.scrollTo(0, this._logElement.scrollHeight);
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._cancelFollowBottom();
|
||||
this._debouncedFilter.cancel();
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<div class="log" @scroll=${this._handleScroll}>
|
||||
<slot></slot>
|
||||
${
|
||||
this._noMatches
|
||||
? html`<div class="no-matches">
|
||||
${this._i18n.localize("ui.components.log_viewer.no_matches")}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
<ha-ansi-to-html ?wrap-disabled=${this.wrapDisabled}></ha-ansi-to-html>
|
||||
</div>
|
||||
<ha-button
|
||||
class="new-logs-indicator ${classMap({
|
||||
visible: this._newLogsIndicator,
|
||||
})}"
|
||||
size="s"
|
||||
appearance="filled"
|
||||
.disabled=${!this._newLogsIndicator}
|
||||
@click=${this.scrollToBottom}
|
||||
>
|
||||
<ha-svg-icon .path=${mdiArrowCollapseDown} slot="start"></ha-svg-icon>
|
||||
${this._i18n.localize("ui.components.log_viewer.scroll_down_button")}
|
||||
<ha-svg-icon .path=${mdiArrowCollapseDown} slot="end"></ha-svg-icon>
|
||||
</ha-button>
|
||||
`;
|
||||
}
|
||||
|
||||
protected async firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
await this._ansiToHtml.updateComplete;
|
||||
this._renderLines(this._lines);
|
||||
this._ready = true;
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
super.updated(changedProps);
|
||||
if (
|
||||
changedProps.has("filter") &&
|
||||
changedProps.get("filter") !== undefined
|
||||
) {
|
||||
this._debouncedFilter();
|
||||
}
|
||||
}
|
||||
|
||||
private _renderLines(lines: string[]) {
|
||||
if (!this.filter) {
|
||||
this._ansiToHtml.parseLinesToColoredPre(lines);
|
||||
return;
|
||||
}
|
||||
this._ansiToHtml.filterLines("");
|
||||
this._ansiToHtml.parseLinesToColoredPre(lines);
|
||||
this._applyFilter();
|
||||
}
|
||||
|
||||
private _debouncedFilter = debounce(() => {
|
||||
this._applyFilter();
|
||||
if (!this.filter) {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
}, 150);
|
||||
|
||||
private _applyFilter() {
|
||||
this._noMatches =
|
||||
this._lines.length > 0 && !this._ansiToHtml.filterLines(this.filter);
|
||||
}
|
||||
|
||||
private _trimLines() {
|
||||
const logElement = this._logElement;
|
||||
const scrollTopBeforeTrim = logElement.scrollTop;
|
||||
const scrollHeightBeforeTrim = logElement.scrollHeight;
|
||||
this._ansiToHtml.trimLines(this.maxLines);
|
||||
const removedHeight = scrollHeightBeforeTrim - logElement.scrollHeight;
|
||||
logElement.scrollTop = Math.max(0, scrollTopBeforeTrim - removedHeight);
|
||||
}
|
||||
|
||||
private _isScrolledToBottom(): boolean {
|
||||
const log = this._logElement;
|
||||
return (
|
||||
log.scrollHeight - log.scrollTop - log.clientHeight < SCROLL_FOLLOW_MARGIN
|
||||
);
|
||||
}
|
||||
|
||||
private _handleScroll() {
|
||||
if (this._newLogsIndicator && this._isScrolledToBottom()) {
|
||||
this._newLogsIndicator = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _followBottom() {
|
||||
this._cancelFollowBottom();
|
||||
const logElement = this._logElement;
|
||||
const scrollTopBeforeFrame = logElement.scrollTop;
|
||||
this._followFrame = requestAnimationFrame(() => {
|
||||
this._followFrame = undefined;
|
||||
if (logElement.scrollTop === scrollTopBeforeFrame) {
|
||||
logElement.scrollTop = logElement.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _cancelFollowBottom() {
|
||||
if (this._followFrame !== undefined) {
|
||||
cancelAnimationFrame(this._followFrame);
|
||||
this._followFrame = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.log {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: var(--ha-space-2) var(--ha-space-2) var(--ha-space-8);
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
overflow-anchor: none;
|
||||
direction: ltr;
|
||||
font-family: var(--ha-font-family-code);
|
||||
font-size: var(--ha-font-size-s);
|
||||
}
|
||||
.no-matches {
|
||||
color: var(--secondary-text-color);
|
||||
padding: var(--ha-space-2);
|
||||
}
|
||||
.new-logs-indicator {
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
inset-inline-start: 4px;
|
||||
height: 0;
|
||||
transition: height 0.4s ease-out;
|
||||
}
|
||||
.new-logs-indicator.visible {
|
||||
height: 32px;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.new-logs-indicator {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-log-viewer": HaLogViewer;
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,10 @@ import { UNIT_KM } from "../../common/const";
|
||||
import { transform } from "../../common/decorators/transform";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeStateDomain } from "../../common/entity/compute_state_domain";
|
||||
import { getEntityLocation } from "../../common/entity/get_entity_location";
|
||||
import {
|
||||
findFirstActiveZone,
|
||||
getEntityLocation,
|
||||
} from "../../common/entity/get_entity_location";
|
||||
import { supportsVectorMaps } from "../../common/map/base-layer";
|
||||
import type {
|
||||
MapCircleHandle,
|
||||
@@ -66,6 +69,7 @@ import {
|
||||
statesContext,
|
||||
uiContext,
|
||||
} from "../../data/context";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
|
||||
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
import { ensureMapTilesToken } from "../../data/map_tiles";
|
||||
import "../ha-tooltip";
|
||||
@@ -78,7 +82,6 @@ import type {
|
||||
ThemeMode,
|
||||
} from "../../types";
|
||||
import { FLOATING_LIFT, floatingMarkerFootprint } from "./ha-entity-marker";
|
||||
import { computeZoneTrackerState } from "../../data/zone";
|
||||
|
||||
declare global {
|
||||
// for fire event
|
||||
@@ -528,8 +531,19 @@ export class HaMap extends ReactiveElement {
|
||||
} else if (this._loaded && oldStates && this.entities) {
|
||||
// Check if any state has changed
|
||||
for (const entity of this.entities) {
|
||||
const entityId = getEntityId(entity);
|
||||
const stateObj = this._states[entityId];
|
||||
const inZones: string[] | undefined = stateObj?.attributes.in_zones;
|
||||
if (
|
||||
oldStates[getEntityId(entity)] !== this._states[getEntityId(entity)]
|
||||
oldStates[entityId] !== stateObj ||
|
||||
(stateObj &&
|
||||
["person", "device_tracker"].includes(
|
||||
computeStateDomain(stateObj)
|
||||
) &&
|
||||
Array.isArray(inZones) &&
|
||||
inZones.some(
|
||||
(zoneId) => oldStates[zoneId] !== this._states[zoneId]
|
||||
))
|
||||
) {
|
||||
this._drawEntities();
|
||||
autoFitRequired = !this._pauseAutoFit;
|
||||
@@ -1347,8 +1361,6 @@ export class HaMap extends ReactiveElement {
|
||||
this._watchRegistry();
|
||||
|
||||
const computedStyles = getComputedStyle(this);
|
||||
// A person's state is "home" for the home zone, the zone name otherwise
|
||||
const zoneByState: Record<string, string> = {};
|
||||
this._zonePositions = {};
|
||||
this._zoneRadii = {};
|
||||
for (const entity of this.entities) {
|
||||
@@ -1359,7 +1371,6 @@ export class HaMap extends ReactiveElement {
|
||||
computeStateDomain(stateObj) === "zone" &&
|
||||
(this.renderPassive || !stateObj.attributes.passive)
|
||||
) {
|
||||
zoneByState[computeZoneTrackerState(stateObj)] = stateObj.entity_id;
|
||||
if (
|
||||
typeof stateObj.attributes.latitude === "number" &&
|
||||
typeof stateObj.attributes.longitude === "number"
|
||||
@@ -1518,6 +1529,22 @@ export class HaMap extends ReactiveElement {
|
||||
typeof entity !== "string" && (entity.selected ?? false);
|
||||
entityMarker.floating = true;
|
||||
|
||||
let zoneId: string | undefined;
|
||||
if (["person", "device_tracker"].includes(computeStateDomain(stateObj))) {
|
||||
const inZones: string[] | undefined = stateObj.attributes.in_zones;
|
||||
if (Array.isArray(inZones) && inZones.length > 0) {
|
||||
const activeZone = findFirstActiveZone(inZones, states);
|
||||
if (activeZone && this._zonePositions[activeZone.entity_id]) {
|
||||
zoneId = activeZone.entity_id;
|
||||
}
|
||||
} else if (
|
||||
!Array.isArray(inZones) ||
|
||||
!["not_home", UNKNOWN, UNAVAILABLE].includes(stateObj.state)
|
||||
) {
|
||||
zoneId = this._zoneContaining(position, gpsAccuracy);
|
||||
}
|
||||
}
|
||||
|
||||
const clusterData: ClusterData = {
|
||||
entityId,
|
||||
title,
|
||||
@@ -1527,11 +1554,7 @@ export class HaMap extends ReactiveElement {
|
||||
unit: entityMarker.entityUnit ?? "",
|
||||
color: entityColor,
|
||||
selected: entityMarker.selected,
|
||||
zoneId: ["person", "device_tracker"].includes(
|
||||
computeStateDomain(stateObj)
|
||||
)
|
||||
? (zoneByState[stateObj.state] ?? this._zoneContaining(position))
|
||||
: undefined,
|
||||
zoneId,
|
||||
};
|
||||
|
||||
const accuracy =
|
||||
@@ -1591,16 +1614,26 @@ export class HaMap extends ReactiveElement {
|
||||
);
|
||||
}
|
||||
|
||||
private _zoneContaining(position: MapLatLng): string | undefined {
|
||||
private _zoneContaining(
|
||||
position: MapLatLng,
|
||||
gpsAccuracy = 0
|
||||
): string | undefined {
|
||||
let found: string | undefined;
|
||||
let foundRadius = Infinity;
|
||||
let foundDistance = Infinity;
|
||||
for (const [zoneId, radius] of Object.entries(this._zoneRadii)) {
|
||||
if (this._states[zoneId]?.attributes.passive) {
|
||||
continue;
|
||||
}
|
||||
const distance = distanceMeters(position, this._zonePositions[zoneId]);
|
||||
if (
|
||||
radius < foundRadius &&
|
||||
distanceMeters(position, this._zonePositions[zoneId]) <= radius
|
||||
distance - gpsAccuracy < radius &&
|
||||
(radius < foundRadius ||
|
||||
(radius === foundRadius && distance < foundDistance))
|
||||
) {
|
||||
found = zoneId;
|
||||
foundRadius = radius;
|
||||
foundDistance = distance;
|
||||
}
|
||||
}
|
||||
return found;
|
||||
|
||||
@@ -4,25 +4,18 @@ import {
|
||||
type UnsubscribeFunc,
|
||||
} from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import {
|
||||
mdiArrowCollapseDown,
|
||||
mdiCircle,
|
||||
mdiWrap,
|
||||
mdiWrapDisabled,
|
||||
} from "@mdi/js";
|
||||
import { mdiCircle, mdiWrap, mdiWrapDisabled } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
|
||||
import { consume } from "../../../common/decorators/consume";
|
||||
import { debounce } from "../../../common/util/debounce";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-ansi-to-html";
|
||||
import type { HaAnsiToHtml } from "../../../components/ha-ansi-to-html";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-dialog";
|
||||
import "../../../components/ha-dialog-footer";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-log-viewer";
|
||||
import type { HaLogViewer } from "../../../components/ha-log-viewer";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import "../../../components/input/ha-input-search";
|
||||
import type { HaInputSearch } from "../../../components/input/ha-input-search";
|
||||
@@ -40,12 +33,6 @@ import type { LiveLogDialogParams } from "./show-dialog-live-log";
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const ANSI_ESCAPE = /\x1b\[[0-9;]*m/g;
|
||||
|
||||
// Stay close to the bottom to keep following new lines
|
||||
const SCROLL_FOLLOW_MARGIN = 40;
|
||||
|
||||
// Keep only the newest lines so memory stays bounded
|
||||
const DEFAULT_LOG_LINE_LIMIT = 5000;
|
||||
|
||||
@customElement("dialog-live-log")
|
||||
class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
|
||||
@state()
|
||||
@@ -64,29 +51,16 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
|
||||
|
||||
@state() private _live = false;
|
||||
|
||||
@state() private _newLogsIndicator = false;
|
||||
|
||||
@state() private _search = "";
|
||||
|
||||
@state() private _wrapLines = true;
|
||||
|
||||
@state() private _noMatches = false;
|
||||
|
||||
@query(".log") private _logElement?: HTMLElement;
|
||||
|
||||
@query("ha-ansi-to-html") private _ansiToHtml?: HaAnsiToHtml;
|
||||
|
||||
private _lines: string[] = [];
|
||||
|
||||
// Number of stored lines already rendered in the view
|
||||
private _shownCount = 0;
|
||||
@query("ha-log-viewer") private _logViewer?: HaLogViewer;
|
||||
|
||||
private _unsub?: Promise<UnsubscribeFunc>;
|
||||
|
||||
private _resubscribeOnReady = false;
|
||||
|
||||
private _followFrame?: number;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
const connection = this._connection.connection;
|
||||
@@ -147,153 +121,39 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
|
||||
}
|
||||
this._unsub = undefined;
|
||||
this._resubscribeOnReady = false;
|
||||
this._cancelFollowBottom();
|
||||
this._lines = [];
|
||||
this._shownCount = 0;
|
||||
this._lineCount = 0;
|
||||
this._newLogsIndicator = false;
|
||||
this._noMatches = false;
|
||||
this._ansiToHtml?.clear();
|
||||
this._logViewer?.clear();
|
||||
this._subscribe();
|
||||
};
|
||||
|
||||
private _unsubscribe() {
|
||||
this._cancelFollowBottom();
|
||||
this._debounceSearch.cancel();
|
||||
// _subscribe reports subscription errors, so only ignore them here
|
||||
this._unsub?.then((unsub) => unsub()).catch(() => undefined);
|
||||
this._unsub = undefined;
|
||||
this._live = false;
|
||||
}
|
||||
|
||||
private _isScrolledToBottom(): boolean {
|
||||
const log = this._logElement;
|
||||
return (
|
||||
!log ||
|
||||
log.scrollHeight - log.scrollTop - log.clientHeight < SCROLL_FOLLOW_MARGIN
|
||||
);
|
||||
}
|
||||
|
||||
private _handleScroll() {
|
||||
if (this._newLogsIndicator && this._isScrolledToBottom()) {
|
||||
this._newLogsIndicator = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _scrollToBottom() {
|
||||
this._newLogsIndicator = false;
|
||||
this._logElement?.scrollTo(0, this._logElement.scrollHeight);
|
||||
}
|
||||
|
||||
private _followBottom() {
|
||||
this._cancelFollowBottom();
|
||||
const logElement = this._logElement;
|
||||
const scrollTopBeforeFrame = logElement ? logElement.scrollTop : 0;
|
||||
this._followFrame = requestAnimationFrame(() => {
|
||||
this._followFrame = undefined;
|
||||
if (logElement && logElement.scrollTop === scrollTopBeforeFrame) {
|
||||
logElement.scrollTop = logElement.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _cancelFollowBottom() {
|
||||
if (this._followFrame !== undefined) {
|
||||
cancelAnimationFrame(this._followFrame);
|
||||
this._followFrame = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _addLines(lines: string[]) {
|
||||
const newLines = lines
|
||||
.flatMap((line) => line.split("\n"))
|
||||
.slice(-DEFAULT_LOG_LINE_LIMIT);
|
||||
if (!newLines.length) {
|
||||
const logViewer = this._logViewer;
|
||||
if (!logViewer) {
|
||||
return;
|
||||
}
|
||||
const follow = this._isScrolledToBottom();
|
||||
this._lines = this._lines.concat(newLines);
|
||||
this._trimLines();
|
||||
this._lineCount = this._lines.length;
|
||||
this._showNewLines(follow);
|
||||
}
|
||||
|
||||
private _trimLines() {
|
||||
const excessLineCount = this._lines.length - DEFAULT_LOG_LINE_LIMIT;
|
||||
if (excessLineCount <= 0) {
|
||||
return;
|
||||
}
|
||||
const logElement = this._logElement;
|
||||
const scrollTopBeforeTrimming = logElement ? logElement.scrollTop : 0;
|
||||
const scrollHeightBeforeTrimming = logElement ? logElement.scrollHeight : 0;
|
||||
|
||||
this._lines.splice(0, excessLineCount);
|
||||
this._shownCount = Math.max(0, this._shownCount - excessLineCount);
|
||||
if (this._ansiToHtml) {
|
||||
this._ansiToHtml.trimLines(this._shownCount);
|
||||
}
|
||||
if (logElement) {
|
||||
const removedHeight =
|
||||
scrollHeightBeforeTrimming - logElement.scrollHeight;
|
||||
logElement.scrollTop = Math.max(
|
||||
0,
|
||||
scrollTopBeforeTrimming - removedHeight
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private _showNewLines(follow: boolean) {
|
||||
const newLines = this._lines.slice(this._shownCount);
|
||||
if (!newLines.length) {
|
||||
return;
|
||||
}
|
||||
this._shownCount = this._lines.length;
|
||||
this._renderLines(newLines);
|
||||
if (follow) {
|
||||
this._followBottom();
|
||||
} else {
|
||||
this._newLogsIndicator = true;
|
||||
}
|
||||
}
|
||||
|
||||
private _renderLines(lines: string[]) {
|
||||
const ansiToHtml = this._ansiToHtml;
|
||||
if (!ansiToHtml) {
|
||||
return;
|
||||
}
|
||||
if (!this._search) {
|
||||
ansiToHtml.parseLinesToColoredPre(lines);
|
||||
return;
|
||||
}
|
||||
// ha-ansi-to-html re-filters all lines after every added line while a
|
||||
// search is set, so search once after adding them
|
||||
ansiToHtml.filterLines("");
|
||||
ansiToHtml.parseLinesToColoredPre(lines);
|
||||
this._noMatches =
|
||||
this._shownCount > 0 && !ansiToHtml.filterLines(this._search);
|
||||
logViewer.addLines(lines);
|
||||
this._lineCount = logViewer.lines.length;
|
||||
}
|
||||
|
||||
private _searchChanged(ev: HASSDomTargetEvent<HaInputSearch>) {
|
||||
this._search = ev.target.value ?? "";
|
||||
this._debounceSearch();
|
||||
}
|
||||
|
||||
private _debounceSearch = debounce(() => {
|
||||
if (this._ansiToHtml) {
|
||||
this._noMatches =
|
||||
this._shownCount > 0 && !this._ansiToHtml.filterLines(this._search);
|
||||
}
|
||||
if (!this._search) {
|
||||
this._scrollToBottom();
|
||||
}
|
||||
}, 150);
|
||||
|
||||
private _toggleLineWrap() {
|
||||
this._wrapLines = !this._wrapLines;
|
||||
}
|
||||
|
||||
private _download() {
|
||||
const text = this._lines.join("\n").replace(ANSI_ESCAPE, "");
|
||||
const text = (this._logViewer?.lines ?? [])
|
||||
.join("\n")
|
||||
.replace(ANSI_ESCAPE, "");
|
||||
const timeString = new Date().toISOString().replace(/:/g, "-");
|
||||
const url = URL.createObjectURL(new Blob([text], { type: "text/plain" }));
|
||||
fileDownload(
|
||||
@@ -342,7 +202,10 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
<div class="log-container">
|
||||
<div class="log" @scroll=${this._handleScroll}>
|
||||
<ha-log-viewer
|
||||
.filter=${this._search}
|
||||
?wrap-disabled=${!this._wrapLines}
|
||||
>
|
||||
${
|
||||
this._lineCount
|
||||
? nothing
|
||||
@@ -353,38 +216,7 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
|
||||
)}
|
||||
</div>`
|
||||
}
|
||||
${
|
||||
this._noMatches
|
||||
? html`<div class="waiting">
|
||||
${this._i18n.localize(
|
||||
"ui.panel.config.integrations.config_entry.live_log.no_matches"
|
||||
)}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
<ha-ansi-to-html
|
||||
?wrap-disabled=${!this._wrapLines}
|
||||
></ha-ansi-to-html>
|
||||
</div>
|
||||
<ha-button
|
||||
class="new-logs-indicator ${classMap({
|
||||
visible: this._newLogsIndicator,
|
||||
})}"
|
||||
size="s"
|
||||
appearance="filled"
|
||||
.disabled=${!this._newLogsIndicator}
|
||||
@click=${this._scrollToBottom}
|
||||
>
|
||||
<ha-svg-icon
|
||||
.path=${mdiArrowCollapseDown}
|
||||
slot="start"
|
||||
></ha-svg-icon>
|
||||
${this._i18n.localize("ui.panel.config.logs.scroll_down_button")}
|
||||
<ha-svg-icon
|
||||
.path=${mdiArrowCollapseDown}
|
||||
slot="end"
|
||||
></ha-svg-icon>
|
||||
</ha-button>
|
||||
</ha-log-viewer>
|
||||
${
|
||||
this._live
|
||||
? html`<div class="live-indicator">
|
||||
@@ -431,34 +263,9 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.log {
|
||||
/* Fills the dialog when it is full screen on narrow screens */
|
||||
flex: 1 1 60vh;
|
||||
min-height: 0;
|
||||
padding: var(--ha-space-2) var(--ha-space-2) var(--ha-space-8);
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
overflow-anchor: none;
|
||||
direction: ltr;
|
||||
font-family: var(--ha-font-family-code);
|
||||
font-size: var(--ha-font-size-s);
|
||||
}
|
||||
.new-logs-indicator {
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
inset-inline-start: 4px;
|
||||
height: 0;
|
||||
transition: height 0.4s ease-out;
|
||||
}
|
||||
.new-logs-indicator.visible {
|
||||
height: 32px;
|
||||
}
|
||||
@keyframes breathe {
|
||||
from {
|
||||
@@ -485,9 +292,6 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
|
||||
width: 14px;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.new-logs-indicator {
|
||||
transition: none;
|
||||
}
|
||||
.live-indicator ha-svg-icon {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@@ -1080,8 +1080,8 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
||||
minmax(min(300px, 100%), 1fr)
|
||||
);
|
||||
gap: var(--ha-space-4);
|
||||
padding: var(--ha-space-4);
|
||||
margin: var(--ha-space-2);
|
||||
padding: var(--ha-space-6) var(--ha-space-4) 0;
|
||||
margin: 0;
|
||||
}
|
||||
.empty-message {
|
||||
margin: auto;
|
||||
@@ -1166,10 +1166,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
||||
position: relative;
|
||||
}
|
||||
h1 {
|
||||
margin-top: 8px;
|
||||
margin-left: 16px;
|
||||
margin-inline-start: 16px;
|
||||
margin-inline-end: initial;
|
||||
margin: var(--ha-space-6) var(--ha-space-4) 0;
|
||||
}
|
||||
ha-dropdown ha-icon-button {
|
||||
color: var(--primary-text-color);
|
||||
|
||||
@@ -6,6 +6,8 @@ import { customElement, property, query, state } from "lit/decorators";
|
||||
import { capitalizeFirstLetter } from "../../../../../common/string/capitalize-first-letter";
|
||||
import "../../../../../components/ha-card";
|
||||
import "../../../../../components/ha-icon-button";
|
||||
import "../../../../../components/ha-log-viewer";
|
||||
import type { HaLogViewer } from "../../../../../components/ha-log-viewer";
|
||||
import "../../../../../components/ha-select";
|
||||
import type { HaSelectSelectEvent } from "../../../../../components/ha-select";
|
||||
import type { ZWaveJSLogConfig } from "../../../../../data/zwave_js";
|
||||
@@ -32,29 +34,26 @@ class ZWaveJSLogs extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _logConfig?: ZWaveJSLogConfig;
|
||||
|
||||
@query("textarea", true) private _textarea?: HTMLTextAreaElement;
|
||||
@query("ha-log-viewer") private _logViewer?: HaLogViewer;
|
||||
|
||||
public hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
|
||||
return [
|
||||
subscribeZWaveJSLogs(this.hass, this.configEntryId, (update) => {
|
||||
if (!this.hasUpdated) {
|
||||
return;
|
||||
}
|
||||
if (update.type === "log_message") {
|
||||
if (Array.isArray(update.log_message.message)) {
|
||||
for (const line of update.log_message.message) {
|
||||
this._textarea!.value += `${line}\n`;
|
||||
}
|
||||
} else {
|
||||
this._textarea!.value += `${update.log_message.message}\n`;
|
||||
}
|
||||
this._logViewer?.addLines(
|
||||
Array.isArray(update.log_message.message)
|
||||
? update.log_message.message
|
||||
: [update.log_message.message]
|
||||
);
|
||||
} else {
|
||||
this._logConfig = update.log_config;
|
||||
}
|
||||
}).then((unsub) => {
|
||||
this._textarea!.value += `${this.hass.localize(
|
||||
"ui.panel.config.zwave_js.logs.subscribed_to_logs"
|
||||
)}\n`;
|
||||
this._logViewer?.addLines([
|
||||
this.hass.localize(
|
||||
"ui.panel.config.zwave_js.logs.subscribed_to_logs"
|
||||
),
|
||||
]);
|
||||
return unsub;
|
||||
}),
|
||||
];
|
||||
@@ -112,7 +111,7 @@ class ZWaveJSLogs extends SubscribeMixin(LitElement) {
|
||||
.path=${mdiDownload}
|
||||
></ha-icon-button>
|
||||
</ha-card>
|
||||
<textarea readonly></textarea>
|
||||
<ha-log-viewer></ha-log-viewer>
|
||||
</div>
|
||||
</hass-subpage>
|
||||
`;
|
||||
@@ -136,7 +135,7 @@ class ZWaveJSLogs extends SubscribeMixin(LitElement) {
|
||||
private _downloadLogs() {
|
||||
fileDownload(
|
||||
`data:text/plain;charset=utf-8,${encodeURIComponent(
|
||||
this._textarea!.value
|
||||
this._logViewer!.lines.join("\n")
|
||||
)}`,
|
||||
`zwave_js.log`
|
||||
);
|
||||
@@ -151,10 +150,11 @@ class ZWaveJSLogs extends SubscribeMixin(LitElement) {
|
||||
return;
|
||||
}
|
||||
setZWaveJSLogLevel(this.hass!, this.configEntryId, selected);
|
||||
this._textarea!.value += `${this.hass.localize(
|
||||
"ui.panel.config.zwave_js.logs.log_level_changed",
|
||||
{ level: capitalizeFirstLetter(selected) }
|
||||
)}\n`;
|
||||
this._logViewer?.addLines([
|
||||
this.hass.localize("ui.panel.config.zwave_js.logs.log_level_changed", {
|
||||
level: capitalizeFirstLetter(selected),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
static get styles(): CSSResultArray {
|
||||
@@ -168,11 +168,6 @@ class ZWaveJSLogs extends SubscribeMixin(LitElement) {
|
||||
box-sizing: border-box;
|
||||
padding: 16px;
|
||||
}
|
||||
textarea {
|
||||
flex-grow: 1;
|
||||
padding: 16px;
|
||||
font-family: var(--ha-font-family-code);
|
||||
}
|
||||
ha-card {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
@@ -26,20 +26,11 @@ import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import { formattersContext } from "../../../data/context";
|
||||
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { hasTileCardAction } from "../cards/tile/tile-card-helpers";
|
||||
import { actionHandler } from "../common/directives/action-handler-directive";
|
||||
import { findEntities } from "../common/find-entities";
|
||||
import { hasAction } from "../common/has-action";
|
||||
import type { LovelaceBadge, LovelaceBadgeEditor } from "../types";
|
||||
import type { AlertBadgeConfig } from "./types";
|
||||
|
||||
export const ALERT_BADGE_DEFAULT_CONFIG: Partial<AlertBadgeConfig> = {
|
||||
type: "alert",
|
||||
show_name: true,
|
||||
show_state: true,
|
||||
show_icon: true,
|
||||
};
|
||||
|
||||
@customElement("hui-alert-badge")
|
||||
export class HuiAlertBadge extends LitElement implements LovelaceBadge {
|
||||
public static async getConfigElement(): Promise<LovelaceBadgeEditor> {
|
||||
@@ -64,10 +55,6 @@ export class HuiAlertBadge extends LitElement implements LovelaceBadge {
|
||||
return { type: "alert", entity: entity || "" };
|
||||
}
|
||||
|
||||
public static getDefaultConfig(): Partial<AlertBadgeConfig> {
|
||||
return ALERT_BADGE_DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
@state() private _config?: AlertBadgeConfig;
|
||||
|
||||
@state()
|
||||
@@ -100,7 +87,7 @@ export class HuiAlertBadge extends LitElement implements LovelaceBadge {
|
||||
);
|
||||
}
|
||||
|
||||
this._config = { ...ALERT_BADGE_DEFAULT_CONFIG, ...config };
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -128,21 +115,10 @@ export class HuiAlertBadge extends LitElement implements LovelaceBadge {
|
||||
const pulse = active && this._config.pulse === true;
|
||||
const hasColor = color !== undefined && color !== "none";
|
||||
|
||||
const name = this._formatters.formatEntityName(stateObj, this._config.name);
|
||||
|
||||
const { show_name: showName, show_state: showState } = this._config;
|
||||
|
||||
const content = showState
|
||||
? this._formatters.formatEntityState(stateObj)
|
||||
: showName
|
||||
? name
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
<ha-badge
|
||||
.type=${hasTileCardAction(this._config) ? "button" : "badge"}
|
||||
.label=${showState && showName ? name : undefined}
|
||||
.iconOnly=${!content}
|
||||
type="button"
|
||||
.label=${this._formatters.formatEntityName(stateObj)}
|
||||
class=${classMap({ alert: true, pulse, "no-color": !hasColor })}
|
||||
style=${styleMap({
|
||||
"--ha-alert-color":
|
||||
@@ -150,29 +126,20 @@ export class HuiAlertBadge extends LitElement implements LovelaceBadge {
|
||||
})}
|
||||
@action=${this._handleAction}
|
||||
.actionHandler=${actionHandler({
|
||||
hasHold: hasAction(this._config.hold_action),
|
||||
hasDoubleClick: hasAction(this._config.double_tap_action),
|
||||
hasHold: false,
|
||||
hasDoubleClick: false,
|
||||
})}
|
||||
>
|
||||
${
|
||||
this._config.show_icon
|
||||
? html`<ha-state-icon
|
||||
slot="icon"
|
||||
.stateObj=${stateObj}
|
||||
.icon=${this._config.icon}
|
||||
></ha-state-icon>`
|
||||
: nothing
|
||||
}
|
||||
${content}
|
||||
<ha-state-icon slot="icon" .stateObj=${stateObj}></ha-state-icon>
|
||||
${this._formatters.formatEntityState(stateObj)}
|
||||
</ha-badge>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleAction(ev: ActionHandlerEvent): void {
|
||||
fireEvent(this, "hass-action", {
|
||||
config: this._config!,
|
||||
action: ev.detail.action!,
|
||||
});
|
||||
if (ev.detail.action === "tap" && this._config) {
|
||||
fireEvent(this, "hass-more-info", { entityId: this._config.entity });
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
|
||||
@@ -8,17 +8,15 @@ import memoizeOne from "memoize-one";
|
||||
import { computeCssColor } from "../../../common/color/compute-color";
|
||||
import { hsv2rgb, rgb2hex, rgb2hsv } from "../../../common/color/convert-color";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
|
||||
import { stateActive } from "../../../common/entity/state_active";
|
||||
import { stateColorCss } from "../../../common/entity/state_color";
|
||||
import "../../../components/ha-badge";
|
||||
import "../../../components/ha-state-icon";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import { cameraUrlWithWidthHeight } from "../../../data/camera";
|
||||
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import {
|
||||
getTileImageUrl,
|
||||
hasTileCardAction,
|
||||
} from "../cards/tile/tile-card-helpers";
|
||||
import { actionHandler } from "../common/directives/action-handler-directive";
|
||||
import { findEntities } from "../common/find-entities";
|
||||
import { handleAction } from "../common/handle-action";
|
||||
@@ -101,7 +99,12 @@ export class HuiEntityBadge extends LitElement implements LovelaceBadge {
|
||||
}
|
||||
|
||||
get hasAction() {
|
||||
return hasTileCardAction(this._config);
|
||||
return (
|
||||
!this._config?.tap_action ||
|
||||
hasAction(this._config?.tap_action) ||
|
||||
hasAction(this._config?.hold_action) ||
|
||||
hasAction(this._config?.double_tap_action)
|
||||
);
|
||||
}
|
||||
|
||||
private _computeStateColor = memoizeOne(
|
||||
@@ -135,6 +138,21 @@ export class HuiEntityBadge extends LitElement implements LovelaceBadge {
|
||||
}
|
||||
);
|
||||
|
||||
private _getImageUrl(stateObj: HassEntity): string | undefined {
|
||||
const entityPicture =
|
||||
stateObj.attributes.entity_picture_local ||
|
||||
stateObj.attributes.entity_picture;
|
||||
|
||||
if (!entityPicture) return undefined;
|
||||
|
||||
let imageUrl = this.hass!.hassUrl(entityPicture);
|
||||
if (computeStateDomain(stateObj) === "camera") {
|
||||
imageUrl = cameraUrlWithWidthHeight(imageUrl, 32, 32);
|
||||
}
|
||||
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._config || !this.hass) {
|
||||
return nothing;
|
||||
@@ -177,7 +195,7 @@ export class HuiEntityBadge extends LitElement implements LovelaceBadge {
|
||||
const showEntityPicture = this._config.show_entity_picture;
|
||||
|
||||
const imageUrl = showEntityPicture
|
||||
? getTileImageUrl(this.hass, stateObj, 32)
|
||||
? this._getImageUrl(stateObj)
|
||||
: undefined;
|
||||
|
||||
const label = showState && showName ? name : undefined;
|
||||
|
||||
@@ -34,17 +34,9 @@ export interface StateLabelBadgeConfig extends LovelaceBadgeConfig {
|
||||
export interface AlertBadgeConfig extends LovelaceBadgeConfig {
|
||||
type: "alert";
|
||||
entity: string;
|
||||
name?: string | EntityNameItem | EntityNameItem[];
|
||||
icon?: string;
|
||||
alert_color?: string;
|
||||
clear_color?: string;
|
||||
pulse?: boolean;
|
||||
show_name?: boolean;
|
||||
show_state?: boolean;
|
||||
show_icon?: boolean;
|
||||
tap_action?: ActionConfig;
|
||||
hold_action?: ActionConfig;
|
||||
double_tap_action?: ActionConfig;
|
||||
}
|
||||
|
||||
export interface ShortcutBadgeConfig extends LovelaceBadgeConfig {
|
||||
|
||||
@@ -25,13 +25,11 @@ import { formattersContext } from "../../../data/context";
|
||||
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { findEntities } from "../common/find-entities";
|
||||
import { hasAction } from "../common/has-action";
|
||||
import type {
|
||||
LovelaceCard,
|
||||
LovelaceCardEditor,
|
||||
LovelaceGridOptions,
|
||||
} from "../types";
|
||||
import { hasTileCardAction } from "./tile/tile-card-helpers";
|
||||
import { tileCardStyle } from "./tile/tile-card-style";
|
||||
import type { AlertCardConfig } from "./types";
|
||||
|
||||
@@ -86,25 +84,19 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
|
||||
`Specify an entity from within the ${ALERT_DOMAINS.join(", ")} domains`
|
||||
);
|
||||
}
|
||||
|
||||
this._config = {
|
||||
tap_action: { action: "more-info" },
|
||||
...config,
|
||||
};
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
public getCardSize(): number {
|
||||
return this._config?.vertical ? 2 : 1;
|
||||
return 1;
|
||||
}
|
||||
|
||||
public getGridOptions(): LovelaceGridOptions {
|
||||
const vertical = Boolean(this._config?.vertical);
|
||||
|
||||
return {
|
||||
columns: 6,
|
||||
rows: vertical ? 2 : 1,
|
||||
min_columns: vertical ? 3 : 6,
|
||||
min_rows: vertical ? 2 : 1,
|
||||
rows: 1,
|
||||
min_columns: 6,
|
||||
min_rows: 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -118,6 +110,11 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const stateDisplay = this._formatters.formatEntityState(stateObj);
|
||||
const areaName = this._formatters.formatEntityName(stateObj, {
|
||||
type: "area",
|
||||
});
|
||||
|
||||
const active = isAlertActive(stateObj);
|
||||
|
||||
const color = active
|
||||
@@ -127,10 +124,6 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
|
||||
const pulse = active && this._config.pulse === true;
|
||||
const hasColor = color !== undefined && color !== "none";
|
||||
|
||||
const areaName = this._formatters.formatEntityName(stateObj, {
|
||||
type: "area",
|
||||
});
|
||||
|
||||
return html`
|
||||
<ha-card
|
||||
class=${classMap({ pulse, "no-color": !hasColor })}
|
||||
@@ -143,40 +136,25 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
|
||||
})}
|
||||
>
|
||||
<ha-tile-container
|
||||
.vertical=${Boolean(this._config.vertical)}
|
||||
.interactive=${hasTileCardAction(this._config)}
|
||||
.actionHandlerOptions=${{
|
||||
hasHold: hasAction(this._config.hold_action),
|
||||
hasDoubleClick: hasAction(this._config.double_tap_action),
|
||||
}}
|
||||
.interactive=${true}
|
||||
.actionHandlerOptions=${{ hasHold: false, hasDoubleClick: false }}
|
||||
@action=${this._handleAction}
|
||||
>
|
||||
<ha-tile-icon slot="icon">
|
||||
<ha-state-icon
|
||||
slot="icon"
|
||||
.icon=${this._config.icon}
|
||||
.stateObj=${stateObj}
|
||||
></ha-state-icon>
|
||||
<ha-state-icon slot="icon" .stateObj=${stateObj}></ha-state-icon>
|
||||
</ha-tile-icon>
|
||||
<ha-tile-info slot="info">
|
||||
<span slot="primary"
|
||||
>${this._formatters.formatEntityName(
|
||||
stateObj,
|
||||
this._config.name
|
||||
)}</span
|
||||
>${this._formatters.formatEntityName(stateObj)}</span
|
||||
>
|
||||
${
|
||||
this._config.hide_state
|
||||
? nothing
|
||||
: html`<span slot="secondary">
|
||||
${this._formatters.formatEntityState(stateObj)} ·
|
||||
<ha-relative-time
|
||||
.datetime=${stateObj.last_changed}
|
||||
.format=${"short"}
|
||||
></ha-relative-time>
|
||||
${areaName ? html` · ${areaName}` : nothing}
|
||||
</span>`
|
||||
}
|
||||
<span slot="secondary">
|
||||
${stateDisplay} ·
|
||||
<ha-relative-time
|
||||
.datetime=${stateObj.last_changed}
|
||||
.format=${"short"}
|
||||
></ha-relative-time>
|
||||
${areaName ? html` · ${areaName}` : nothing}
|
||||
</span>
|
||||
</ha-tile-info>
|
||||
</ha-tile-container>
|
||||
</ha-card>
|
||||
@@ -184,10 +162,9 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
|
||||
}
|
||||
|
||||
private _handleAction(ev: ActionHandlerEvent): void {
|
||||
fireEvent(this, "hass-action", {
|
||||
config: this._config!,
|
||||
action: ev.detail.action!,
|
||||
});
|
||||
if (ev.detail.action === "tap" && this._config) {
|
||||
fireEvent(this, "hass-more-info", { entityId: this._config.entity });
|
||||
}
|
||||
}
|
||||
|
||||
static styles = [
|
||||
|
||||
@@ -140,8 +140,8 @@ export class HuiHumidifierCard extends LitElement implements LovelaceCard {
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
<ha-card>
|
||||
<p class="title">${name}</p>
|
||||
<ha-card role="group" aria-labelledby="title">
|
||||
<p class="title" id="title">${name}</p>
|
||||
<div class="container">
|
||||
<ha-state-control-humidifier-humidity
|
||||
style=${styleMap({
|
||||
|
||||
@@ -139,8 +139,8 @@ export class HuiThermostatCard extends LitElement implements LovelaceCard {
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
<ha-card>
|
||||
<p class="title">${name}</p>
|
||||
<ha-card role="group" aria-labelledby="title">
|
||||
<p class="title" id="title">${name}</p>
|
||||
<div class="container">
|
||||
${
|
||||
domain === "water_heater"
|
||||
|
||||
@@ -8,6 +8,7 @@ import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { computeCssColor } from "../../../common/color/compute-color";
|
||||
import { hsv2rgb, rgb2hex, rgb2hsv } from "../../../common/color/convert-color";
|
||||
import { DOMAINS_TOGGLE } from "../../../common/const";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { stateActive } from "../../../common/entity/state_active";
|
||||
import { stateColorCss } from "../../../common/entity/state_color";
|
||||
@@ -17,6 +18,7 @@ import "../../../components/tile/ha-tile-badge";
|
||||
import "../../../components/tile/ha-tile-container";
|
||||
import "../../../components/tile/ha-tile-icon";
|
||||
import "../../../components/tile/ha-tile-info";
|
||||
import { cameraUrlWithWidthHeight } from "../../../data/camera";
|
||||
import { timerJustFinished } from "../../../data/timer";
|
||||
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
|
||||
import "../../../state-display/state-display";
|
||||
@@ -37,16 +39,18 @@ import type {
|
||||
LovelaceGridOptions,
|
||||
} from "../types";
|
||||
import { renderTileBadge } from "./tile/badges/tile-badge";
|
||||
import {
|
||||
getTileActionDefaults,
|
||||
getTileIconActionConfig,
|
||||
getTileImageUrl,
|
||||
hasTileCardAction,
|
||||
hasTileIconAction,
|
||||
} from "./tile/tile-card-helpers";
|
||||
import { tileCardStyle } from "./tile/tile-card-style";
|
||||
import type { TileCardConfig } from "./types";
|
||||
|
||||
export const getEntityDefaultTileIconAction = (entityId: string) => {
|
||||
const domain = computeDomain(entityId);
|
||||
const supportsIconAction =
|
||||
DOMAINS_TOGGLE.has(domain) ||
|
||||
["button", "input_button", "scene"].includes(domain);
|
||||
|
||||
return supportsIconAction ? "toggle" : "none";
|
||||
};
|
||||
|
||||
@customElement("hui-tile-card")
|
||||
export class HuiTileCard extends LitElement implements LovelaceCard {
|
||||
public static async getConfigElement(): Promise<LovelaceCardEditor> {
|
||||
@@ -120,7 +124,12 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
|
||||
}
|
||||
|
||||
this._config = {
|
||||
...getTileActionDefaults(config.entity),
|
||||
tap_action: {
|
||||
action: "more-info",
|
||||
},
|
||||
icon_tap_action: {
|
||||
action: getEntityDefaultTileIconAction(config.entity),
|
||||
},
|
||||
...config,
|
||||
};
|
||||
this._featureContext = {
|
||||
@@ -175,12 +184,28 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
|
||||
return;
|
||||
}
|
||||
|
||||
handleAction(
|
||||
this,
|
||||
this.hass!,
|
||||
getTileIconActionConfig(this._config!.entity, this._config!),
|
||||
ev.detail.action!
|
||||
);
|
||||
const config = {
|
||||
entity: this._config!.entity,
|
||||
tap_action: this._config!.icon_tap_action,
|
||||
hold_action: this._config!.icon_hold_action,
|
||||
double_tap_action: this._config!.icon_double_tap_action,
|
||||
};
|
||||
handleAction(this, this.hass!, config, ev.detail.action!);
|
||||
}
|
||||
|
||||
private _getImageUrl(entity: HassEntity): string | undefined {
|
||||
const entityPicture =
|
||||
entity.attributes.entity_picture_local ||
|
||||
entity.attributes.entity_picture;
|
||||
|
||||
if (!entityPicture) return undefined;
|
||||
|
||||
let imageUrl = this.hass!.hassUrl(entityPicture);
|
||||
if (computeDomain(entity.entity_id) === "camera") {
|
||||
imageUrl = cameraUrlWithWidthHeight(imageUrl, 80, 80);
|
||||
}
|
||||
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
private _computeStateColor = memoizeOne(
|
||||
@@ -222,6 +247,24 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
|
||||
}
|
||||
);
|
||||
|
||||
private get _hasCardAction() {
|
||||
return (
|
||||
!this._config?.tap_action ||
|
||||
hasAction(this._config?.tap_action) ||
|
||||
hasAction(this._config?.hold_action) ||
|
||||
hasAction(this._config?.double_tap_action)
|
||||
);
|
||||
}
|
||||
|
||||
private get _hasIconAction() {
|
||||
return (
|
||||
!this._config?.icon_tap_action ||
|
||||
hasAction(this._config?.icon_tap_action) ||
|
||||
hasAction(this._config?.icon_hold_action) ||
|
||||
hasAction(this._config?.icon_double_tap_action)
|
||||
);
|
||||
}
|
||||
|
||||
private _featurePosition = memoizeOne((config: TileCardConfig) => {
|
||||
if (config.vertical) {
|
||||
return "bottom";
|
||||
@@ -274,7 +317,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
|
||||
};
|
||||
|
||||
const imageUrl = this._config.show_entity_picture
|
||||
? getTileImageUrl(this.hass, stateObj)
|
||||
? this._getImageUrl(stateObj)
|
||||
: undefined;
|
||||
|
||||
const featurePosition = this._featurePosition(this._config);
|
||||
@@ -291,7 +334,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
|
||||
.featurePosition=${featurePosition}
|
||||
.vertical=${Boolean(this._config.vertical)}
|
||||
.fixedInfoHeight=${fixedInfoHeight}
|
||||
.interactive=${hasTileCardAction(this._config)}
|
||||
.interactive=${this._hasCardAction}
|
||||
.actionHandlerOptions=${{
|
||||
hasHold: hasAction(this._config!.hold_action),
|
||||
hasDoubleClick: hasAction(this._config!.double_tap_action),
|
||||
@@ -305,7 +348,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
|
||||
hasHold: hasAction(this._config!.icon_hold_action),
|
||||
hasDoubleClick: hasAction(this._config!.icon_double_tap_action),
|
||||
}}
|
||||
.interactive=${hasTileIconAction(this._config)}
|
||||
.interactive=${this._hasIconAction}
|
||||
.accessibleLabel=${name}
|
||||
.imageUrl=${imageUrl}
|
||||
data-domain=${ifDefined(domain)}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { DOMAINS_TOGGLE } from "../../../../common/const";
|
||||
import { computeDomain } from "../../../../common/entity/compute_domain";
|
||||
import { cameraUrlWithWidthHeight } from "../../../../data/camera";
|
||||
import type { HomeAssistantConnection } from "../../../../types";
|
||||
import type { ActionConfigParams } from "../../common/handle-action";
|
||||
import { hasAction } from "../../common/has-action";
|
||||
import type { TileActionsConfig } from "../types";
|
||||
|
||||
export const getEntityDefaultTileIconAction = (entityId: string) => {
|
||||
const domain = computeDomain(entityId);
|
||||
|
||||
const supportsIconAction =
|
||||
DOMAINS_TOGGLE.has(domain) ||
|
||||
["button", "input_button", "scene"].includes(domain);
|
||||
|
||||
return supportsIconAction ? "toggle" : "none";
|
||||
};
|
||||
|
||||
export const getTileActionDefaults = (
|
||||
entityId: string
|
||||
): Pick<TileActionsConfig, "tap_action" | "icon_tap_action"> => ({
|
||||
tap_action: { action: "more-info" },
|
||||
icon_tap_action: { action: getEntityDefaultTileIconAction(entityId) },
|
||||
});
|
||||
|
||||
export const hasTileCardAction = (config?: TileActionsConfig) =>
|
||||
!config?.tap_action ||
|
||||
hasAction(config.tap_action) ||
|
||||
hasAction(config.hold_action) ||
|
||||
hasAction(config.double_tap_action);
|
||||
|
||||
export const hasTileIconAction = (config?: TileActionsConfig) =>
|
||||
!config?.icon_tap_action ||
|
||||
hasAction(config.icon_tap_action) ||
|
||||
hasAction(config.icon_hold_action) ||
|
||||
hasAction(config.icon_double_tap_action);
|
||||
|
||||
export const getTileIconActionConfig = (
|
||||
entity: string,
|
||||
config: TileActionsConfig
|
||||
): ActionConfigParams => ({
|
||||
entity,
|
||||
tap_action: config.icon_tap_action,
|
||||
hold_action: config.icon_hold_action,
|
||||
double_tap_action: config.icon_double_tap_action,
|
||||
});
|
||||
|
||||
export const getTileImageUrl = (
|
||||
connection: Pick<HomeAssistantConnection, "hassUrl">,
|
||||
stateObj: HassEntity,
|
||||
size = 80
|
||||
): string | undefined => {
|
||||
const entityPicture =
|
||||
stateObj.attributes.entity_picture_local ||
|
||||
stateObj.attributes.entity_picture;
|
||||
|
||||
if (!entityPicture) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const imageUrl = connection.hassUrl(entityPicture);
|
||||
|
||||
return computeDomain(stateObj.entity_id) === "camera"
|
||||
? cameraUrlWithWidthHeight(imageUrl, size, size)
|
||||
: imageUrl;
|
||||
};
|
||||
@@ -686,16 +686,7 @@ export interface WeatherForecastCardConfig extends LovelaceCardConfig {
|
||||
double_tap_action?: ActionConfig;
|
||||
}
|
||||
|
||||
export interface TileActionsConfig {
|
||||
tap_action?: ActionConfig;
|
||||
hold_action?: ActionConfig;
|
||||
double_tap_action?: ActionConfig;
|
||||
icon_tap_action?: ActionConfig;
|
||||
icon_hold_action?: ActionConfig;
|
||||
icon_double_tap_action?: ActionConfig;
|
||||
}
|
||||
|
||||
export interface TileCardConfig extends LovelaceCardConfig, TileActionsConfig {
|
||||
export interface TileCardConfig extends LovelaceCardConfig {
|
||||
entity: string;
|
||||
name?: string | EntityNameItem | EntityNameItem[];
|
||||
hide_state?: boolean;
|
||||
@@ -704,6 +695,12 @@ export interface TileCardConfig extends LovelaceCardConfig, TileActionsConfig {
|
||||
color?: string;
|
||||
show_entity_picture?: boolean;
|
||||
vertical?: boolean;
|
||||
tap_action?: ActionConfig;
|
||||
hold_action?: ActionConfig;
|
||||
double_tap_action?: ActionConfig;
|
||||
icon_tap_action?: ActionConfig;
|
||||
icon_hold_action?: ActionConfig;
|
||||
icon_double_tap_action?: ActionConfig;
|
||||
features?: LovelaceCardFeatureConfig[];
|
||||
features_position?: LovelaceCardFeaturePosition;
|
||||
time_format?: TimestampRenderingFormat;
|
||||
@@ -742,16 +739,9 @@ export interface ShortcutCardConfig extends LovelaceCardConfig {
|
||||
export interface AlertCardConfig extends LovelaceCardConfig {
|
||||
type: "alert";
|
||||
entity: string;
|
||||
name?: string | EntityNameItem | EntityNameItem[];
|
||||
icon?: string;
|
||||
alert_color?: string;
|
||||
clear_color?: string;
|
||||
pulse?: boolean;
|
||||
hide_state?: boolean;
|
||||
vertical?: boolean;
|
||||
tap_action?: ActionConfig;
|
||||
hold_action?: ActionConfig;
|
||||
double_tap_action?: ActionConfig;
|
||||
}
|
||||
|
||||
export interface ToggleGroupCardConfig extends LovelaceCardConfig {
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { mdiAlertOutline, mdiTextShort } from "@mdi/js";
|
||||
import { DEFAULT_ALERT_COLOR } from "../../../../common/entity/alert_active";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import type { HaFormSchema } from "../../../../components/ha-form/types";
|
||||
import { contentLayoutSchema, displayedElementsSchema } from "./content-schema";
|
||||
|
||||
export const alertSchema = () =>
|
||||
({
|
||||
name: "alert",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
iconPath: mdiAlertOutline,
|
||||
schema: [
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
{
|
||||
name: "clear_color",
|
||||
selector: {
|
||||
ui_color: { include_none: true, default_color: "none" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "alert_color",
|
||||
selector: {
|
||||
ui_color: {
|
||||
include_none: true,
|
||||
default_color: DEFAULT_ALERT_COLOR,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ name: "pulse", selector: { boolean: {} } },
|
||||
],
|
||||
}) as const satisfies HaFormSchema;
|
||||
|
||||
const nameSchema = {
|
||||
name: "name",
|
||||
selector: { entity_name: {} },
|
||||
context: { entity: "entity" },
|
||||
} as const satisfies HaFormSchema;
|
||||
|
||||
const iconSchema = {
|
||||
name: "icon",
|
||||
selector: { icon: {} },
|
||||
context: { icon_entity: "entity" },
|
||||
} as const satisfies HaFormSchema;
|
||||
|
||||
export const alertCardContentSchema = (localize: LocalizeFunc) =>
|
||||
({
|
||||
name: "content",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
iconPath: mdiTextShort,
|
||||
schema: [
|
||||
nameSchema,
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [iconSchema, { name: "hide_state", selector: { boolean: {} } }],
|
||||
},
|
||||
contentLayoutSchema(localize),
|
||||
],
|
||||
}) as const satisfies HaFormSchema;
|
||||
|
||||
export const alertBadgeContentSchema = (localize: LocalizeFunc) =>
|
||||
({
|
||||
name: "content",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
iconPath: mdiTextShort,
|
||||
schema: [nameSchema, iconSchema, displayedElementsSchema(localize)],
|
||||
}) as const satisfies HaFormSchema;
|
||||
@@ -1,246 +0,0 @@
|
||||
import { mdiTextShort } from "@mdi/js";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import type { HaFormSchema } from "../../../../components/ha-form/types";
|
||||
|
||||
export interface TileContentFormConfig {
|
||||
hide_state?: boolean;
|
||||
state_content?: string | string[];
|
||||
vertical?: boolean;
|
||||
content_layout?: string;
|
||||
}
|
||||
|
||||
export interface BadgeContentFormConfig {
|
||||
show_name?: boolean;
|
||||
show_state?: boolean;
|
||||
show_icon?: boolean;
|
||||
state_content?: string | string[];
|
||||
displayed_elements?: string[];
|
||||
}
|
||||
|
||||
const colorSchema = {
|
||||
name: "color",
|
||||
selector: {
|
||||
ui_color: {
|
||||
default_color: "state",
|
||||
include_state: true,
|
||||
},
|
||||
},
|
||||
} as const satisfies HaFormSchema;
|
||||
|
||||
export const contentLayoutSchema = (localize: LocalizeFunc) =>
|
||||
({
|
||||
name: "content_layout",
|
||||
required: true,
|
||||
selector: {
|
||||
select: {
|
||||
mode: "box",
|
||||
options: ["horizontal", "vertical"].map((value) => ({
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.card.tile.content_layout_options.${value}`
|
||||
),
|
||||
value,
|
||||
image: {
|
||||
src: `/static/images/form/tile_content_layout_${value}.svg`,
|
||||
src_dark: `/static/images/form/tile_content_layout_${value}_dark.svg`,
|
||||
flip_rtl: true,
|
||||
},
|
||||
})),
|
||||
},
|
||||
},
|
||||
}) as const satisfies HaFormSchema;
|
||||
|
||||
export const displayedElementsSchema = (localize: LocalizeFunc) =>
|
||||
({
|
||||
name: "displayed_elements",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "list",
|
||||
multiple: true,
|
||||
options: (["name", "state", "icon"] as const).map((value) => ({
|
||||
value,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.badge.entity.displayed_elements_options.${value}`
|
||||
),
|
||||
})),
|
||||
},
|
||||
},
|
||||
}) as const satisfies HaFormSchema;
|
||||
|
||||
export const tileContentSchema = (
|
||||
localize: LocalizeFunc,
|
||||
showTimeFormat: boolean
|
||||
) =>
|
||||
({
|
||||
name: "content",
|
||||
flatten: true,
|
||||
type: "expandable",
|
||||
iconPath: mdiTextShort,
|
||||
schema: [
|
||||
{
|
||||
name: "name",
|
||||
selector: {
|
||||
entity_name: {},
|
||||
},
|
||||
context: { entity: "entity" },
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
{
|
||||
name: "icon",
|
||||
selector: {
|
||||
icon: {},
|
||||
},
|
||||
context: { icon_entity: "entity" },
|
||||
},
|
||||
colorSchema,
|
||||
{
|
||||
name: "show_entity_picture",
|
||||
selector: {
|
||||
boolean: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "hide_state",
|
||||
selector: {
|
||||
boolean: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "state_content",
|
||||
visible: { field: "hide_state", operator: "not_eq", value: true },
|
||||
selector: {
|
||||
ui_state_content: {
|
||||
allow_context: true,
|
||||
},
|
||||
},
|
||||
context: {
|
||||
filter_entity: "entity",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "time_format",
|
||||
visible: showTimeFormat,
|
||||
selector: {
|
||||
ui_time_format: {},
|
||||
},
|
||||
},
|
||||
contentLayoutSchema(localize),
|
||||
],
|
||||
}) as const satisfies HaFormSchema;
|
||||
|
||||
export const badgeContentSchema = (
|
||||
localize: LocalizeFunc,
|
||||
showTimeFormat: boolean
|
||||
) =>
|
||||
({
|
||||
name: "content",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
iconPath: mdiTextShort,
|
||||
schema: [
|
||||
{
|
||||
name: "name",
|
||||
selector: {
|
||||
entity_name: {},
|
||||
},
|
||||
context: { entity: "entity" },
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
colorSchema,
|
||||
{
|
||||
name: "icon",
|
||||
selector: {
|
||||
icon: {},
|
||||
},
|
||||
context: { icon_entity: "entity" },
|
||||
},
|
||||
{
|
||||
name: "show_entity_picture",
|
||||
selector: {
|
||||
boolean: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
displayedElementsSchema(localize),
|
||||
{
|
||||
name: "state_content",
|
||||
selector: {
|
||||
ui_state_content: {
|
||||
allow_name: true,
|
||||
},
|
||||
},
|
||||
context: {
|
||||
filter_entity: "entity",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "time_format",
|
||||
visible: showTimeFormat,
|
||||
selector: {
|
||||
ui_time_format: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
}) as const satisfies HaFormSchema;
|
||||
|
||||
export const tileContentFormData = <T extends TileContentFormConfig>(
|
||||
config: T
|
||||
): T => ({
|
||||
...config,
|
||||
content_layout: config.vertical ? "vertical" : "horizontal",
|
||||
});
|
||||
|
||||
export const tileContentConfigFromForm = <T extends TileContentFormConfig>(
|
||||
value: T
|
||||
): T => {
|
||||
const config = { ...value };
|
||||
|
||||
if (config.hide_state || !config.state_content) {
|
||||
delete config.state_content;
|
||||
}
|
||||
|
||||
if (config.content_layout) {
|
||||
config.vertical = config.content_layout === "vertical";
|
||||
delete config.content_layout;
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
export const badgeContentFormData = <T extends BadgeContentFormConfig>(
|
||||
config: T
|
||||
): T => ({
|
||||
...config,
|
||||
displayed_elements: [
|
||||
...(config.show_name ? ["name"] : []),
|
||||
...(config.show_state ? ["state"] : []),
|
||||
...(config.show_icon ? ["icon"] : []),
|
||||
],
|
||||
});
|
||||
|
||||
export const badgeContentConfigFromForm = <T extends BadgeContentFormConfig>(
|
||||
value: T
|
||||
): T => {
|
||||
const config = { ...value };
|
||||
|
||||
if (!config.state_content) {
|
||||
delete config.state_content;
|
||||
}
|
||||
|
||||
if (config.displayed_elements) {
|
||||
config.show_name = config.displayed_elements.includes("name");
|
||||
config.show_state = config.displayed_elements.includes("state");
|
||||
config.show_icon = config.displayed_elements.includes("icon");
|
||||
delete config.displayed_elements;
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
@@ -1,47 +1,53 @@
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { assert, assign, boolean, object, optional, string } from "superstruct";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { ALERT_DOMAINS } from "../../../../common/entity/alert_active";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import {
|
||||
ALERT_DOMAINS,
|
||||
DEFAULT_ALERT_COLOR,
|
||||
} from "../../../../common/entity/alert_active";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
import type {
|
||||
HaFormSchema,
|
||||
SchemaUnion,
|
||||
} from "../../../../components/ha-form/types";
|
||||
import type { SchemaUnion } from "../../../../components/ha-form/types";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
|
||||
import type { AlertBadgeConfig } from "../../badges/types";
|
||||
import { ALERT_BADGE_DEFAULT_CONFIG } from "../../badges/hui-alert-badge";
|
||||
import type { LovelaceBadgeEditor } from "../../types";
|
||||
import { actionConfigStruct } from "../structs/action-struct";
|
||||
import { baseLovelaceBadgeConfig } from "../structs/base-badge-struct";
|
||||
import { entityNameStruct } from "../structs/entity-name-struct";
|
||||
import { alertBadgeContentSchema, alertSchema } from "./alert-schema";
|
||||
import {
|
||||
badgeContentConfigFromForm,
|
||||
badgeContentFormData,
|
||||
} from "./content-schema";
|
||||
import { entityInteractionsSchema } from "./interactions-schema";
|
||||
|
||||
const badgeConfigStruct = assign(
|
||||
baseLovelaceBadgeConfig,
|
||||
object({
|
||||
entity: optional(string()),
|
||||
name: optional(entityNameStruct),
|
||||
icon: optional(string()),
|
||||
alert_color: optional(string()),
|
||||
clear_color: optional(string()),
|
||||
pulse: optional(boolean()),
|
||||
show_name: optional(boolean()),
|
||||
show_state: optional(boolean()),
|
||||
show_icon: optional(boolean()),
|
||||
tap_action: optional(actionConfigStruct),
|
||||
hold_action: optional(actionConfigStruct),
|
||||
double_tap_action: optional(actionConfigStruct),
|
||||
})
|
||||
);
|
||||
|
||||
const SCHEMA = [
|
||||
{
|
||||
name: "entity",
|
||||
required: true,
|
||||
selector: { entity: { filter: { domain: ALERT_DOMAINS } } },
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
{
|
||||
name: "clear_color",
|
||||
selector: { ui_color: { include_none: true, default_color: "none" } },
|
||||
},
|
||||
{
|
||||
name: "alert_color",
|
||||
selector: {
|
||||
ui_color: { include_none: true, default_color: DEFAULT_ALERT_COLOR },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ name: "pulse", selector: { boolean: {} } },
|
||||
] as const;
|
||||
|
||||
@customElement("hui-alert-badge-editor")
|
||||
export class HuiAlertBadgeEditor
|
||||
extends LitElement
|
||||
@@ -53,25 +59,9 @@ export class HuiAlertBadgeEditor
|
||||
|
||||
public setConfig(config: AlertBadgeConfig): void {
|
||||
assert(config, badgeConfigStruct);
|
||||
this._config = { ...ALERT_BADGE_DEFAULT_CONFIG, ...config };
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(localize: LocalizeFunc) =>
|
||||
[
|
||||
{
|
||||
name: "entity",
|
||||
required: true,
|
||||
selector: { entity: { filter: { domain: ALERT_DOMAINS } } },
|
||||
},
|
||||
alertSchema(),
|
||||
alertBadgeContentSchema(localize),
|
||||
entityInteractionsSchema(),
|
||||
] as const satisfies readonly HaFormSchema[]
|
||||
);
|
||||
|
||||
private _formData = memoizeOne(badgeContentFormData<AlertBadgeConfig>);
|
||||
|
||||
protected render() {
|
||||
if (!this.hass || !this._config) {
|
||||
return nothing;
|
||||
@@ -80,8 +70,8 @@ export class HuiAlertBadgeEditor
|
||||
return html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData(this._config)}
|
||||
.schema=${this._schema(this.hass.localize)}
|
||||
.data=${this._config}
|
||||
.schema=${SCHEMA}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
.computeHelper=${this._computeHelperCallback}
|
||||
@value-changed=${this._valueChanged}
|
||||
@@ -91,40 +81,22 @@ export class HuiAlertBadgeEditor
|
||||
|
||||
private _valueChanged(ev: ValueChangedEvent<AlertBadgeConfig>): void {
|
||||
ev.stopPropagation();
|
||||
|
||||
const config = badgeContentConfigFromForm(ev.detail.value);
|
||||
|
||||
fireEvent(this, "config-changed", { config });
|
||||
fireEvent(this, "config-changed", { config: ev.detail.value });
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
) => {
|
||||
switch (schema.name) {
|
||||
case "pulse":
|
||||
case "alert_color":
|
||||
case "clear_color":
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.alert.${schema.name}`
|
||||
);
|
||||
case "alert":
|
||||
return this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.card.alert.alert_section"
|
||||
);
|
||||
case "displayed_elements":
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.badge.entity.${schema.name}`
|
||||
);
|
||||
default:
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.generic.${schema.name}`
|
||||
);
|
||||
private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
|
||||
if (schema.name === "entity") {
|
||||
return this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.card.generic.entity"
|
||||
);
|
||||
}
|
||||
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.alert.${schema.name}`
|
||||
);
|
||||
};
|
||||
|
||||
private _computeHelperCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
) => {
|
||||
private _computeHelperCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
|
||||
if (
|
||||
schema.name === "pulse" ||
|
||||
schema.name === "alert_color" ||
|
||||
|
||||
@@ -1,45 +1,53 @@
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { assert, assign, boolean, object, optional, string } from "superstruct";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { ALERT_DOMAINS } from "../../../../common/entity/alert_active";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import {
|
||||
ALERT_DOMAINS,
|
||||
DEFAULT_ALERT_COLOR,
|
||||
} from "../../../../common/entity/alert_active";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
import type {
|
||||
HaFormSchema,
|
||||
SchemaUnion,
|
||||
} from "../../../../components/ha-form/types";
|
||||
import type { SchemaUnion } from "../../../../components/ha-form/types";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
|
||||
import type { AlertCardConfig } from "../../cards/types";
|
||||
import type { LovelaceCardEditor } from "../../types";
|
||||
import { actionConfigStruct } from "../structs/action-struct";
|
||||
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
|
||||
import { entityNameStruct } from "../structs/entity-name-struct";
|
||||
import { alertCardContentSchema, alertSchema } from "./alert-schema";
|
||||
import {
|
||||
tileContentConfigFromForm,
|
||||
tileContentFormData,
|
||||
} from "./content-schema";
|
||||
import { entityInteractionsSchema } from "./interactions-schema";
|
||||
|
||||
const cardConfigStruct = assign(
|
||||
baseLovelaceCardConfig,
|
||||
object({
|
||||
entity: optional(string()),
|
||||
name: optional(entityNameStruct),
|
||||
icon: optional(string()),
|
||||
alert_color: optional(string()),
|
||||
clear_color: optional(string()),
|
||||
pulse: optional(boolean()),
|
||||
hide_state: optional(boolean()),
|
||||
vertical: optional(boolean()),
|
||||
tap_action: optional(actionConfigStruct),
|
||||
hold_action: optional(actionConfigStruct),
|
||||
double_tap_action: optional(actionConfigStruct),
|
||||
})
|
||||
);
|
||||
|
||||
const SCHEMA = [
|
||||
{
|
||||
name: "entity",
|
||||
required: true,
|
||||
selector: { entity: { filter: { domain: ALERT_DOMAINS } } },
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
{
|
||||
name: "clear_color",
|
||||
selector: { ui_color: { include_none: true, default_color: "none" } },
|
||||
},
|
||||
{
|
||||
name: "alert_color",
|
||||
selector: {
|
||||
ui_color: { include_none: true, default_color: DEFAULT_ALERT_COLOR },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ name: "pulse", selector: { boolean: {} } },
|
||||
] as const;
|
||||
|
||||
@customElement("hui-alert-card-editor")
|
||||
export class HuiAlertCardEditor
|
||||
extends LitElement
|
||||
@@ -54,20 +62,6 @@ export class HuiAlertCardEditor
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(localize: LocalizeFunc) =>
|
||||
[
|
||||
{
|
||||
name: "entity",
|
||||
required: true,
|
||||
selector: { entity: { filter: { domain: ALERT_DOMAINS } } },
|
||||
},
|
||||
alertSchema(),
|
||||
alertCardContentSchema(localize),
|
||||
entityInteractionsSchema(),
|
||||
] as const satisfies readonly HaFormSchema[]
|
||||
);
|
||||
|
||||
protected render() {
|
||||
if (!this.hass || !this._config) {
|
||||
return nothing;
|
||||
@@ -76,8 +70,8 @@ export class HuiAlertCardEditor
|
||||
return html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${tileContentFormData(this._config)}
|
||||
.schema=${this._schema(this.hass.localize)}
|
||||
.data=${this._config}
|
||||
.schema=${SCHEMA}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
.computeHelper=${this._computeHelperCallback}
|
||||
@value-changed=${this._valueChanged}
|
||||
@@ -87,41 +81,22 @@ export class HuiAlertCardEditor
|
||||
|
||||
private _valueChanged(ev: ValueChangedEvent<AlertCardConfig>): void {
|
||||
ev.stopPropagation();
|
||||
|
||||
const config = tileContentConfigFromForm(ev.detail.value);
|
||||
|
||||
fireEvent(this, "config-changed", { config });
|
||||
fireEvent(this, "config-changed", { config: ev.detail.value });
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
) => {
|
||||
switch (schema.name) {
|
||||
case "pulse":
|
||||
case "alert_color":
|
||||
case "clear_color":
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.alert.${schema.name}`
|
||||
);
|
||||
case "alert":
|
||||
return this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.card.alert.alert_section"
|
||||
);
|
||||
case "hide_state":
|
||||
case "content_layout":
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.tile.${schema.name}`
|
||||
);
|
||||
default:
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.generic.${schema.name}`
|
||||
);
|
||||
private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
|
||||
if (schema.name === "entity") {
|
||||
return this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.card.generic.entity"
|
||||
);
|
||||
}
|
||||
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.alert.${schema.name}`
|
||||
);
|
||||
};
|
||||
|
||||
private _computeHelperCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
) => {
|
||||
private _computeHelperCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
|
||||
if (
|
||||
schema.name === "pulse" ||
|
||||
schema.name === "alert_color" ||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { mdiGestureTap, mdiTextShort } from "@mdi/js";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
@@ -19,7 +20,7 @@ import type {
|
||||
HaFormSchema,
|
||||
SchemaUnion,
|
||||
} from "../../../../components/ha-form/types";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import {
|
||||
DEFAULT_CONFIG,
|
||||
DISPLAY_TYPES,
|
||||
@@ -27,16 +28,11 @@ import {
|
||||
} from "../../badges/hui-entity-badge";
|
||||
import type { EntityBadgeConfig } from "../../badges/types";
|
||||
import type { LovelaceBadgeEditor } from "../../types";
|
||||
import { ACTION_RELATED_CONTEXT } from "../../components/hui-action-editor";
|
||||
import { actionConfigStruct } from "../structs/action-struct";
|
||||
import { baseLovelaceBadgeConfig } from "../structs/base-badge-struct";
|
||||
import { entityNameStruct } from "../structs/entity-name-struct";
|
||||
import { configElementStyle } from "./config-elements-style";
|
||||
import {
|
||||
badgeContentConfigFromForm,
|
||||
badgeContentFormData,
|
||||
badgeContentSchema,
|
||||
} from "./content-schema";
|
||||
import { entityInteractionsSchema } from "./interactions-schema";
|
||||
import { stateContentHasTimestamp } from "../../../../state-display/state-display";
|
||||
import { timeFormatConfigStruct } from "../../components/types";
|
||||
|
||||
@@ -82,12 +78,148 @@ export class HuiEntityBadgeEditor
|
||||
(localize: LocalizeFunc, showTimeFormat: boolean) =>
|
||||
[
|
||||
{ name: "entity", selector: { entity: {} } },
|
||||
badgeContentSchema(localize, showTimeFormat),
|
||||
entityInteractionsSchema(),
|
||||
{
|
||||
name: "content",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
iconPath: mdiTextShort,
|
||||
schema: [
|
||||
{
|
||||
name: "name",
|
||||
selector: {
|
||||
entity_name: {},
|
||||
},
|
||||
context: { entity: "entity" },
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
{
|
||||
name: "color",
|
||||
selector: {
|
||||
ui_color: {
|
||||
default_color: "state",
|
||||
include_state: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "icon",
|
||||
selector: {
|
||||
icon: {},
|
||||
},
|
||||
context: { icon_entity: "entity" },
|
||||
},
|
||||
{
|
||||
name: "show_entity_picture",
|
||||
selector: {
|
||||
boolean: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "displayed_elements",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "list",
|
||||
multiple: true,
|
||||
options: [
|
||||
{
|
||||
value: "name",
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.badge.entity.displayed_elements_options.name`
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "state",
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.badge.entity.displayed_elements_options.state`
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "icon",
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.badge.entity.displayed_elements_options.icon`
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "state_content",
|
||||
selector: {
|
||||
ui_state_content: {
|
||||
allow_name: true,
|
||||
},
|
||||
},
|
||||
context: {
|
||||
filter_entity: "entity",
|
||||
},
|
||||
},
|
||||
...(showTimeFormat
|
||||
? ([
|
||||
{
|
||||
name: "time_format",
|
||||
selector: {
|
||||
ui_time_format: {},
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[])
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "interactions",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
iconPath: mdiGestureTap,
|
||||
schema: [
|
||||
{
|
||||
name: "tap_action",
|
||||
selector: {
|
||||
ui_action: {
|
||||
default_action: "more-info",
|
||||
},
|
||||
},
|
||||
context: ACTION_RELATED_CONTEXT,
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "optional_actions",
|
||||
flatten: true,
|
||||
schema: (["hold_action", "double_tap_action"] as const).map(
|
||||
(action) => ({
|
||||
name: action,
|
||||
selector: {
|
||||
ui_action: {
|
||||
default_action: "none" as const,
|
||||
},
|
||||
},
|
||||
context: ACTION_RELATED_CONTEXT,
|
||||
})
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[]
|
||||
);
|
||||
|
||||
private _formData = memoizeOne(badgeContentFormData<EntityBadgeConfig>);
|
||||
_displayedElements = memoizeOne((config: EntityBadgeConfig) => {
|
||||
const elements: string[] = [];
|
||||
if (config.show_name) {
|
||||
elements.push("name");
|
||||
}
|
||||
if (config.show_state) {
|
||||
elements.push("state");
|
||||
}
|
||||
if (config.show_icon) {
|
||||
elements.push("icon");
|
||||
}
|
||||
return elements;
|
||||
});
|
||||
|
||||
protected render() {
|
||||
if (!this.hass || !this._config) {
|
||||
@@ -106,7 +238,10 @@ export class HuiEntityBadgeEditor
|
||||
|
||||
const schema = this._schema(this.hass!.localize, showTimeFormat);
|
||||
|
||||
const data = this._formData(this._config);
|
||||
const data = {
|
||||
...this._config,
|
||||
displayed_elements: this._displayedElements(this._config),
|
||||
};
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
@@ -120,13 +255,24 @@ export class HuiEntityBadgeEditor
|
||||
`;
|
||||
}
|
||||
|
||||
private _valueChanged(ev: ValueChangedEvent<EntityBadgeConfig>): void {
|
||||
private _valueChanged(ev: CustomEvent): void {
|
||||
ev.stopPropagation();
|
||||
if (!this._config || !this.hass) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = badgeContentConfigFromForm(ev.detail.value);
|
||||
const config = { ...ev.detail.value } as EntityBadgeConfig;
|
||||
|
||||
if (!config.state_content) {
|
||||
delete config.state_content;
|
||||
}
|
||||
|
||||
if (config.displayed_elements) {
|
||||
config.show_name = config.displayed_elements.includes("name");
|
||||
config.show_state = config.displayed_elements.includes("state");
|
||||
config.show_icon = config.displayed_elements.includes("icon");
|
||||
delete config.displayed_elements;
|
||||
}
|
||||
|
||||
fireEvent(this, "config-changed", { config });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mdiListBox } from "@mdi/js";
|
||||
import { mdiGestureTap, mdiListBox, mdiTextShort } from "@mdi/js";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
@@ -30,6 +30,8 @@ import type {
|
||||
LovelaceCardFeatureConfig,
|
||||
LovelaceCardFeatureContext,
|
||||
} from "../../card-features/types";
|
||||
import { ACTION_RELATED_CONTEXT } from "../../components/hui-action-editor";
|
||||
import { getEntityDefaultTileIconAction } from "../../cards/hui-tile-card";
|
||||
import type { TileCardConfig } from "../../cards/types";
|
||||
import type { LovelaceCardEditor } from "../../types";
|
||||
import { actionConfigStruct } from "../structs/action-struct";
|
||||
@@ -37,13 +39,7 @@ import { baseLovelaceCardConfig } from "../structs/base-card-struct";
|
||||
import { entityNameStruct } from "../structs/entity-name-struct";
|
||||
import type { EditDetailElementEvent, EditSubElementEvent } from "../types";
|
||||
import { configElementStyle } from "./config-elements-style";
|
||||
import {
|
||||
tileContentConfigFromForm,
|
||||
tileContentFormData,
|
||||
tileContentSchema,
|
||||
} from "./content-schema";
|
||||
import { getSupportedFeaturesType } from "./hui-card-features-editor";
|
||||
import { tileInteractionsSchema } from "./interactions-schema";
|
||||
import { stateContentHasTimestamp } from "../../../../state-display/state-display";
|
||||
import { timeFormatConfigStruct } from "../../components/types";
|
||||
|
||||
@@ -100,8 +96,147 @@ export class HuiTileCardEditor
|
||||
) =>
|
||||
[
|
||||
{ name: "entity", selector: { entity: {} } },
|
||||
tileContentSchema(localize, showTimeFormat),
|
||||
tileInteractionsSchema(entityId),
|
||||
{
|
||||
name: "content",
|
||||
flatten: true,
|
||||
type: "expandable",
|
||||
iconPath: mdiTextShort,
|
||||
schema: [
|
||||
{
|
||||
name: "name",
|
||||
selector: {
|
||||
entity_name: {},
|
||||
},
|
||||
context: { entity: "entity" },
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
{
|
||||
name: "icon",
|
||||
selector: {
|
||||
icon: {},
|
||||
},
|
||||
context: { icon_entity: "entity" },
|
||||
},
|
||||
{
|
||||
name: "color",
|
||||
selector: {
|
||||
ui_color: {
|
||||
default_color: "state",
|
||||
include_state: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "show_entity_picture",
|
||||
selector: {
|
||||
boolean: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "hide_state",
|
||||
selector: {
|
||||
boolean: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "state_content",
|
||||
visible: { field: "hide_state", operator: "not_eq", value: true },
|
||||
selector: {
|
||||
ui_state_content: {
|
||||
allow_context: true,
|
||||
},
|
||||
},
|
||||
context: {
|
||||
filter_entity: "entity",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "time_format",
|
||||
visible: showTimeFormat,
|
||||
selector: {
|
||||
ui_time_format: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "content_layout",
|
||||
required: true,
|
||||
selector: {
|
||||
select: {
|
||||
mode: "box",
|
||||
options: ["horizontal", "vertical"].map((value) => ({
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.card.tile.content_layout_options.${value}`
|
||||
),
|
||||
value,
|
||||
image: {
|
||||
src: `/static/images/form/tile_content_layout_${value}.svg`,
|
||||
src_dark: `/static/images/form/tile_content_layout_${value}_dark.svg`,
|
||||
flip_rtl: true,
|
||||
},
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "interactions",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
iconPath: mdiGestureTap,
|
||||
schema: [
|
||||
{
|
||||
name: "tap_action",
|
||||
selector: {
|
||||
ui_action: {
|
||||
default_action: "more-info",
|
||||
},
|
||||
},
|
||||
context: ACTION_RELATED_CONTEXT,
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "divider",
|
||||
},
|
||||
{
|
||||
name: "icon_tap_action",
|
||||
selector: {
|
||||
ui_action: {
|
||||
default_action: entityId
|
||||
? getEntityDefaultTileIconAction(entityId)
|
||||
: "more-info",
|
||||
},
|
||||
},
|
||||
context: ACTION_RELATED_CONTEXT,
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "optional_actions",
|
||||
flatten: true,
|
||||
schema: (
|
||||
[
|
||||
"hold_action",
|
||||
"icon_hold_action",
|
||||
"double_tap_action",
|
||||
"icon_double_tap_action",
|
||||
] as const
|
||||
).map((action) => ({
|
||||
name: action,
|
||||
selector: {
|
||||
ui_action: {
|
||||
default_action: "none" as const,
|
||||
},
|
||||
},
|
||||
context: ACTION_RELATED_CONTEXT,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[]
|
||||
);
|
||||
|
||||
@@ -161,7 +296,10 @@ export class HuiTileCardEditor
|
||||
|
||||
const featuresSchema = this._featuresSchema(this.hass.localize, vertical);
|
||||
|
||||
const data = tileContentFormData(this._config);
|
||||
const data = {
|
||||
...this._config,
|
||||
content_layout: vertical ? "vertical" : "horizontal",
|
||||
};
|
||||
|
||||
// Default features position to bottom and force it to bottom in vertical mode
|
||||
if (!data.features_position || vertical) {
|
||||
@@ -224,10 +362,24 @@ export class HuiTileCardEditor
|
||||
|
||||
const newConfig = ev.detail.value as TileCardConfig;
|
||||
|
||||
let config: TileCardConfig = tileContentConfigFromForm({
|
||||
let config: TileCardConfig = {
|
||||
features: this._config.features,
|
||||
...newConfig,
|
||||
});
|
||||
};
|
||||
|
||||
if (config.hide_state) {
|
||||
delete config.state_content;
|
||||
}
|
||||
|
||||
if (!config.state_content) {
|
||||
delete config.state_content;
|
||||
}
|
||||
|
||||
// Convert content_layout to vertical
|
||||
if (config.content_layout) {
|
||||
config.vertical = config.content_layout === "vertical";
|
||||
delete config.content_layout;
|
||||
}
|
||||
|
||||
config = orderProperties(config, fieldOrder);
|
||||
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import { mdiGestureTap } from "@mdi/js";
|
||||
import type { HaFormSchema } from "../../../../components/ha-form/types";
|
||||
import { getEntityDefaultTileIconAction } from "../../cards/tile/tile-card-helpers";
|
||||
import { ACTION_RELATED_CONTEXT } from "../../components/hui-action-editor";
|
||||
|
||||
export const tileInteractionsSchema = (entityId: string | undefined) =>
|
||||
({
|
||||
name: "interactions",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
iconPath: mdiGestureTap,
|
||||
schema: [
|
||||
{
|
||||
name: "tap_action",
|
||||
selector: {
|
||||
ui_action: {
|
||||
default_action: "more-info",
|
||||
},
|
||||
},
|
||||
context: ACTION_RELATED_CONTEXT,
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "divider",
|
||||
},
|
||||
{
|
||||
name: "icon_tap_action",
|
||||
selector: {
|
||||
ui_action: {
|
||||
default_action: entityId
|
||||
? getEntityDefaultTileIconAction(entityId)
|
||||
: "more-info",
|
||||
},
|
||||
},
|
||||
context: ACTION_RELATED_CONTEXT,
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "optional_actions",
|
||||
flatten: true,
|
||||
schema: (
|
||||
[
|
||||
"hold_action",
|
||||
"icon_hold_action",
|
||||
"double_tap_action",
|
||||
"icon_double_tap_action",
|
||||
] as const
|
||||
).map((action) => ({
|
||||
name: action,
|
||||
selector: {
|
||||
ui_action: {
|
||||
default_action: "none" as const,
|
||||
},
|
||||
},
|
||||
context: ACTION_RELATED_CONTEXT,
|
||||
})),
|
||||
},
|
||||
],
|
||||
}) as const satisfies HaFormSchema;
|
||||
|
||||
export const entityInteractionsSchema = () =>
|
||||
({
|
||||
name: "interactions",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
iconPath: mdiGestureTap,
|
||||
schema: [
|
||||
{
|
||||
name: "tap_action",
|
||||
selector: {
|
||||
ui_action: {
|
||||
default_action: "more-info",
|
||||
},
|
||||
},
|
||||
context: ACTION_RELATED_CONTEXT,
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "optional_actions",
|
||||
flatten: true,
|
||||
schema: (["hold_action", "double_tap_action"] as const).map(
|
||||
(action) => ({
|
||||
name: action,
|
||||
selector: {
|
||||
ui_action: {
|
||||
default_action: "none" as const,
|
||||
},
|
||||
},
|
||||
context: ACTION_RELATED_CONTEXT,
|
||||
})
|
||||
),
|
||||
},
|
||||
],
|
||||
}) as const satisfies HaFormSchema;
|
||||
@@ -747,6 +747,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"log_viewer": {
|
||||
"scroll_down_button": "New logs - Click to scroll",
|
||||
"no_matches": "No log lines match the search"
|
||||
},
|
||||
"logbook": {
|
||||
"entries_not_found": "No activity found.",
|
||||
"automation_triggered": "Triggered",
|
||||
@@ -4917,7 +4921,7 @@
|
||||
"log_file_disabled_debug_download": {
|
||||
"environment": "Debug logging has been disabled, but no log file was downloaded because HA_DISABLE_LOG_FILE is set and Home Assistant is not writing a log file."
|
||||
},
|
||||
"scroll_down_button": "New logs - Click to scroll",
|
||||
"scroll_down_button": "[%key:ui::components::log_viewer::scroll_down_button%]",
|
||||
"live": "Live",
|
||||
"provider_not_found": "Log provider not found",
|
||||
"provider_not_available": "Logs for ''{provider}'' are not available on your system.",
|
||||
@@ -7676,8 +7680,7 @@
|
||||
"live_log": {
|
||||
"title": "Live log",
|
||||
"waiting": "Waiting for log messages from {integration}…",
|
||||
"unsupported": "This version of Home Assistant can't stream the log.",
|
||||
"no_matches": "No log lines match the search"
|
||||
"unsupported": "This version of Home Assistant can't stream the log."
|
||||
},
|
||||
"state": {
|
||||
"loaded": "Loaded",
|
||||
@@ -10493,8 +10496,7 @@
|
||||
"clear_color": "Clear color",
|
||||
"clear_color_helper": "Used when the entity isn't in an alert state. The background doesn't pulse.",
|
||||
"pulse": "Pulse",
|
||||
"pulse_helper": "Animates the colored background while the entity is in an alert state. When reduced motion is on, the background stays tinted instead.",
|
||||
"alert_section": "Alert"
|
||||
"pulse_helper": "Animates the colored background while the entity is in an alert state. When reduced motion is on, the background stays tinted instead."
|
||||
},
|
||||
"area": {
|
||||
"name": "Area",
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { setPlatformAPI } from "zrender/lib/core/platform.js";
|
||||
import { parsePlainText } from "zrender/lib/graphic/helper/parseText.js";
|
||||
import { fitLabelFontSize } from "../../../src/components/chart/fit-label-font-size";
|
||||
|
||||
const FONT_SIZE = 12;
|
||||
// jsdom has no canvas, so measure every character as 0.6em wide
|
||||
const EM = 0.6;
|
||||
const LINE_HEIGHT = EM * FONT_SIZE;
|
||||
|
||||
const fit = (text: string, width: number, height: number) =>
|
||||
fitLabelFontSize(text, {
|
||||
fontSize: FONT_SIZE,
|
||||
width,
|
||||
height,
|
||||
unwrappedHeight: text.split("\n").length * LINE_HEIGHT,
|
||||
});
|
||||
|
||||
// wrap like ECharts does at the returned font size
|
||||
const wrap = (text: string, fontSize: number, width: number) =>
|
||||
text.split("\n").map(
|
||||
(paragraph) =>
|
||||
parsePlainText(
|
||||
paragraph,
|
||||
{
|
||||
font: `${fontSize}px Roboto, Noto, sans-serif`,
|
||||
width,
|
||||
overflow: "break",
|
||||
},
|
||||
undefined,
|
||||
undefined
|
||||
).lines
|
||||
);
|
||||
|
||||
beforeAll(() => {
|
||||
setPlatformAPI({
|
||||
measureText: (text, font) => ({
|
||||
width: text.length * EM * parseFloat(/([\d.]+)px/.exec(font!)![1]),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
describe("fitLabelFontSize", () => {
|
||||
it("keeps the font size when the label fits", () => {
|
||||
expect(fit("Kitchen", 100, 20)).toBe(FONT_SIZE);
|
||||
});
|
||||
|
||||
it("ignores indentation after an explicit newline", () => {
|
||||
expect(fit("Kitchen\n light", 100, 30)).toBe(FONT_SIZE);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["narrow wrapped label", "Untracked consumption", 70, 22],
|
||||
["long word", "Geschirrspülmaschine", 70, 40],
|
||||
["word that fills the width", "Electric vehicle charger", 40, 20],
|
||||
["label and value", "Electric vehicle charger\n140 kWh", 70, 20],
|
||||
["small height", "Coffee machine", 70, 9],
|
||||
])("fits a %s without breaking words", (_name, text, width, height) => {
|
||||
const fontSize = fit(text, width, height);
|
||||
expect(fontSize).toBeGreaterThan(0);
|
||||
expect(fontSize).toBeLessThanOrEqual(FONT_SIZE);
|
||||
|
||||
const paragraphs = wrap(text, fontSize, width);
|
||||
const lines = paragraphs.flat();
|
||||
expect(lines.length * EM * fontSize).toBeLessThanOrEqual(height);
|
||||
for (const word of text.split(/\s+/)) {
|
||||
expect(lines.some((line) => line.includes(word))).toBe(true);
|
||||
}
|
||||
for (const paragraph of paragraphs) {
|
||||
expect(paragraph.slice(1).some((line) => line.startsWith(" "))).toBe(
|
||||
false
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
HaMap,
|
||||
HaMapEditableLocation,
|
||||
} from "../../../src/components/map/ha-map";
|
||||
import { createMockEntityState } from "../../fixtures/hass";
|
||||
|
||||
const FORMATTERS = {
|
||||
formatEntityName: computeEntityNameDisplayWithoutContext,
|
||||
@@ -187,6 +188,21 @@ const STATES = {
|
||||
},
|
||||
} as unknown as HassEntities;
|
||||
|
||||
const ZONES = {
|
||||
"zone.paulus": createMockEntityState("zone.paulus", "0", {
|
||||
friendly_name: "Work",
|
||||
latitude: 52.372,
|
||||
longitude: 4.89,
|
||||
radius: 100,
|
||||
}),
|
||||
"zone.anne_therese": createMockEntityState("zone.anne_therese", "0", {
|
||||
friendly_name: "Work",
|
||||
latitude: 52.377,
|
||||
longitude: 4.895,
|
||||
radius: 100,
|
||||
}),
|
||||
};
|
||||
|
||||
const leafletMap = (el: HaMap) => (el as any)._engine?.leafletMap;
|
||||
const container = (el: HaMap) => el.shadowRoot!.getElementById("map")!;
|
||||
const isRevealed = (el: HaMap) => container(el).classList.contains("drawn");
|
||||
@@ -194,11 +210,14 @@ const isLoaded = (el: HaMap) => (el as any)._loaded as boolean;
|
||||
const entityHandles = (el: HaMap) =>
|
||||
(el as any)._entityHandles as MapMarkerHandle[];
|
||||
|
||||
const createMap = async (): Promise<HaMap> => {
|
||||
const createMap = async (
|
||||
states: HassEntities = STATES,
|
||||
entities: string[] = Object.keys(states)
|
||||
): Promise<HaMap> => {
|
||||
const el = document.createElement("ha-map");
|
||||
el.entities = ["device_tracker.paulus", "device_tracker.anne_therese"];
|
||||
el.entities = entities;
|
||||
el.clusterMarkers = false;
|
||||
(el as any)._states = STATES;
|
||||
(el as any)._states = states;
|
||||
(el as any)._formatters = FORMATTERS;
|
||||
(el as any)._config = {
|
||||
config: { latitude: 52.3731339, longitude: 4.8903147 },
|
||||
@@ -236,6 +255,110 @@ describe("ha-map engine selection", () => {
|
||||
expect(entityHandles(el)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("groups same-named zones by reported IDs", async () => {
|
||||
const states: HassEntities = {
|
||||
...ZONES,
|
||||
"person.paulus": createMockEntityState("person.paulus", "Work", {
|
||||
in_zones: ["zone.paulus"],
|
||||
}),
|
||||
"device_tracker.anne_therese": createMockEntityState(
|
||||
"device_tracker.anne_therese",
|
||||
"Work",
|
||||
{
|
||||
latitude: ZONES["zone.paulus"].attributes.latitude,
|
||||
longitude: ZONES["zone.paulus"].attributes.longitude,
|
||||
in_zones: ["zone.anne_therese"],
|
||||
}
|
||||
),
|
||||
};
|
||||
const el = await createMap(states);
|
||||
|
||||
expect(entityHandles(el).map((marker) => marker.clusterData)).toMatchObject(
|
||||
[
|
||||
{ entityId: "person.paulus", zoneId: "zone.paulus" },
|
||||
{
|
||||
entityId: "device_tracker.anne_therese",
|
||||
zoneId: "zone.anne_therese",
|
||||
},
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
entityId: "device_tracker.paulus",
|
||||
state: "Work",
|
||||
inZones: undefined,
|
||||
expectedZoneId: "zone.paulus",
|
||||
},
|
||||
{
|
||||
entityId: "person.paulus",
|
||||
state: "Work",
|
||||
inZones: [],
|
||||
expectedZoneId: "zone.paulus",
|
||||
},
|
||||
{
|
||||
entityId: "person.paulus",
|
||||
state: "not_home",
|
||||
inZones: [],
|
||||
expectedZoneId: undefined,
|
||||
},
|
||||
])(
|
||||
"handles zone membership for $entityId ($state, in_zones: $inZones)",
|
||||
async ({ entityId, state, inZones, expectedZoneId }) => {
|
||||
const el = await createMap({
|
||||
...ZONES,
|
||||
[entityId]: createMockEntityState(entityId, state, {
|
||||
latitude: ZONES["zone.paulus"].attributes.latitude,
|
||||
longitude: ZONES["zone.paulus"].attributes.longitude,
|
||||
in_zones: inZones,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(entityHandles(el)).toMatchObject([
|
||||
{ clusterData: { entityId, zoneId: expectedZoneId } },
|
||||
]);
|
||||
}
|
||||
);
|
||||
|
||||
it("updates grouping when a hidden active zone becomes passive and is restored", async () => {
|
||||
const hiddenZone = ZONES["zone.paulus"];
|
||||
const states: HassEntities = {
|
||||
...ZONES,
|
||||
"zone.enclosing": createMockEntityState("zone.enclosing", "0", {
|
||||
...hiddenZone.attributes,
|
||||
friendly_name: "City",
|
||||
radius: 1000,
|
||||
}),
|
||||
"person.paulus": createMockEntityState("person.paulus", "Work", {
|
||||
in_zones: [hiddenZone.entity_id, "zone.enclosing"],
|
||||
}),
|
||||
};
|
||||
const el = await createMap(states, ["zone.enclosing", "person.paulus"]);
|
||||
expect(entityHandles(el)[0].clusterData).toMatchObject({
|
||||
zoneId: undefined,
|
||||
});
|
||||
|
||||
(el as any)._states = {
|
||||
...states,
|
||||
[hiddenZone.entity_id]: createMockEntityState(
|
||||
hiddenZone.entity_id,
|
||||
hiddenZone.state,
|
||||
{ ...hiddenZone.attributes, passive: true }
|
||||
),
|
||||
};
|
||||
await el.updateComplete;
|
||||
expect(entityHandles(el)[0].clusterData).toMatchObject({
|
||||
zoneId: "zone.enclosing",
|
||||
});
|
||||
|
||||
(el as any)._states = states;
|
||||
await el.updateComplete;
|
||||
expect(entityHandles(el)[0].clusterData).toMatchObject({
|
||||
zoneId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("sets up while themes are not loaded yet, as during onboarding", async () => {
|
||||
const el = document.createElement("ha-map");
|
||||
el.themeMode = "auto";
|
||||
|
||||
Reference in new issue
Block a user