Compare commits

...
Author SHA1 Message Date
Maarten Lakerveld aa298cbf66 Share one log viewer between the live log dialog and the Z-Wave logs page
The Z-Wave logs page appended every streamed line to a textarea and never
trimmed it, so a tab left open at debug level grew without bound. The
live log dialog already capped and followed its lines, but that logic
lived in the dialog, so nothing stopped another view from forgetting it.

Move the line cap, the kept lines for download, follow-to-bottom, the
new logs indicator and search filtering into ha-log-viewer, and render
both views through it. The Z-Wave page gets the cap for free and the
dialog loses its private copy. The error log card keeps its own handling
for now because it also prepends older lines.
2026-10-19 10:00:30 +02:00
Lucas DeutschlandandCopilot Autofix powered by AI b9a04ed660 Fix map marker placement for zones with duplicate names (#54895)
* Fix map marker placement for zones with duplicate names

* Ensure entities are spread in ha-map-engine test

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

* Remove unused zone tracker state import

* Fix legacy map zone grouping and simplify tests

* Fix map zone selection when the active zone is hidden

* Update map markers when referenced zones change

* Align map coordinate fallback with Core zone selection

* Simplify map zone grouping and tests

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-10-19 09:51:35 +02:00
renovate[bot]andPetar Petrov 60108e1d74 Update dependency js-yaml to v5.5.0 (#55053)
Co-authored-by: Petar Petrov <[email protected]>
2026-10-19 08:45:52 +01:00
karwosts ca685a5c13 Add group role and aria labelling to thermostat card (#55045) 2026-10-19 08:43:23 +01:00
Petar Petrov f008af2751 Fix overlapping horizontal Sankey labels at narrow widths (#55021) 2026-10-19 08:34:09 +01:00
Daan Vervacke 47a0f8863d Fix inconsistent heading spacing on the integrations dashboard (#55037) 2026-10-19 09:32:14 +02:00
renovate[bot] f2bf89ad69 Update actions/setup-node action to v7.1.0 (#55052)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-19 07:29:57 +00:00
Aidan Timson c2955a8745 Add alert badge (#54881)
* Add alert badge

* Use shared alert domains in the alert badge

* Only alert while the alert badge entity is in an alert state

* Default the alert badge color to red and show its colors side by side

* Use consumeLocalize in the alert badge

* Rename the alert badge colors to alert color and clear color
2026-10-19 09:22:42 +02:00
24 changed files with 1019 additions and 283 deletions

No files matched your search

+1 -1
View File
@@ -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
View File
@@ -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",
+11 -6
View File
@@ -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:
+1 -1
View File
@@ -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;
}
+8 -6
View File
@@ -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,
};
+5
View File
@@ -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);
+254
View File
@@ -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;
}
}
+47 -14
View File
@@ -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;
+17 -213
View File
@@ -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;
}
@@ -0,0 +1,15 @@
import { ALERT_DOMAINS } from "../../../common/entity/alert_active";
import { computeDomain } from "../../../common/entity/compute_domain";
import type { AlertBadgeConfig } from "../badges/types";
import type { BadgeSuggestionProvider } from "./types";
export const alertBadgeSuggestions: BadgeSuggestionProvider<AlertBadgeConfig> =
{
getEntitySuggestion(_hass, entityId) {
if (!ALERT_DOMAINS.includes(computeDomain(entityId))) {
return [];
}
return [{ config: { type: "alert", entity: entityId } }];
},
};
@@ -1,3 +1,4 @@
import { alertBadgeSuggestions } from "./hui-alert-badge-suggestions";
import { entityBadgeSuggestions } from "./hui-entity-badge-suggestions";
import type { BadgeSuggestionProvider } from "./types";
@@ -6,4 +7,5 @@ export const BADGE_SUGGESTION_PROVIDERS: Record<
BadgeSuggestionProvider
> = {
entity: entityBadgeSuggestions,
alert: alertBadgeSuggestions,
};
@@ -0,0 +1,206 @@
import type { ContextType } from "@lit/context";
import { mdiAlertCircle } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { computeCssColor } from "../../../common/color/compute-color";
import { consume } from "../../../common/decorators/consume";
import {
consumeEntityState,
consumeLocalize,
} from "../../../common/decorators/consume-context-entry";
import { fireEvent } from "../../../common/dom/fire_event";
import {
ALERT_DOMAINS,
DEFAULT_ALERT_COLOR,
isAlertActive,
} from "../../../common/entity/alert_active";
import { computeDomain } from "../../../common/entity/compute_domain";
import { isValidEntityId } from "../../../common/entity/valid_entity_id";
import "../../../components/ha-badge";
import "../../../components/ha-state-icon";
import "../../../components/ha-svg-icon";
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 { actionHandler } from "../common/directives/action-handler-directive";
import { findEntities } from "../common/find-entities";
import type { LovelaceBadge, LovelaceBadgeEditor } from "../types";
import type { AlertBadgeConfig } from "./types";
@customElement("hui-alert-badge")
export class HuiAlertBadge extends LitElement implements LovelaceBadge {
public static async getConfigElement(): Promise<LovelaceBadgeEditor> {
await import("../editor/config-elements/hui-alert-badge-editor");
return document.createElement("hui-alert-badge-editor");
}
public static getStubConfig(
hass: HomeAssistant,
entities: string[],
entitiesFallback: string[]
): AlertBadgeConfig {
const [entity] = findEntities(
hass,
1,
entities,
entitiesFallback,
ALERT_DOMAINS
);
return { type: "alert", entity: entity || "" };
}
@state() private _config?: AlertBadgeConfig;
@state()
@consumeEntityState({ entityIdPath: ["_config", "entity"] })
private _stateObj?: HassEntity;
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters!: ContextType<typeof formattersContext>;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
public setConfig(config: AlertBadgeConfig): void {
if (
!isValidEntityId(config.entity) ||
(config.alert_color !== undefined &&
typeof config.alert_color !== "string") ||
(config.clear_color !== undefined &&
typeof config.clear_color !== "string") ||
(config.pulse !== undefined && typeof config.pulse !== "boolean")
) {
throw new Error("Invalid configuration");
}
if (!ALERT_DOMAINS.includes(computeDomain(config.entity))) {
throw new Error(
`Specify an entity from within the ${ALERT_DOMAINS.join(", ")} domains`
);
}
this._config = config;
}
protected render() {
if (!this._config || !this._formatters || !this._localize) {
return nothing;
}
const stateObj = this._stateObj;
if (!stateObj) {
return html`
<ha-badge .label=${this._config.entity} class="error">
<ha-svg-icon slot="icon" .path=${mdiAlertCircle}></ha-svg-icon>
${this._localize("ui.badge.entity.not_found")}
</ha-badge>
`;
}
const active = isAlertActive(stateObj);
const color = active
? (this._config.alert_color ?? DEFAULT_ALERT_COLOR)
: this._config.clear_color;
const pulse = active && this._config.pulse === true;
const hasColor = color !== undefined && color !== "none";
return html`
<ha-badge
type="button"
.label=${this._formatters.formatEntityName(stateObj)}
class=${classMap({ alert: true, pulse, "no-color": !hasColor })}
style=${styleMap({
"--ha-alert-color":
color && hasColor ? computeCssColor(color) : undefined,
})}
@action=${this._handleAction}
.actionHandler=${actionHandler({
hasHold: false,
hasDoubleClick: false,
})}
>
<ha-state-icon slot="icon" .stateObj=${stateObj}></ha-state-icon>
${this._formatters.formatEntityState(stateObj)}
</ha-badge>
`;
}
private _handleAction(ev: ActionHandlerEvent): void {
if (ev.detail.action === "tap" && this._config) {
fireEvent(this, "hass-more-info", { entityId: this._config.entity });
}
}
static styles = css`
@keyframes pulse-opacity {
from {
opacity: 0;
}
to {
opacity: var(--ha-alert-pulse-opacity);
}
}
:host {
--ha-alert-pulse-duration: 1s;
--ha-alert-pulse-opacity: 0.3;
--ha-alert-badge-background: var(
--ha-card-background,
var(--card-background-color, white)
);
}
ha-badge.error {
--badge-color: var(--red-color);
}
ha-badge.alert {
display: block;
position: relative;
isolation: isolate;
border-radius: var(
--ha-badge-border-radius,
calc(var(--ha-badge-size, 36px) / 2)
);
background: var(--ha-alert-badge-background);
--ha-card-background: transparent;
--badge-color: var(--ha-alert-color);
}
ha-badge.alert.no-color {
--badge-color: var(--secondary-text-color);
}
ha-badge.alert::before {
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
background-color: var(--ha-alert-color);
content: "";
opacity: var(--ha-alert-pulse-opacity);
pointer-events: none;
}
ha-badge.alert.pulse::before {
animation: pulse-opacity var(--ha-alert-pulse-duration) ease-in-out
infinite alternate;
}
@media (prefers-reduced-motion: reduce) {
ha-badge.alert.pulse::before {
animation: none;
}
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-alert-badge": HuiAlertBadge;
}
}
+8
View File
@@ -31,6 +31,14 @@ export interface StateLabelBadgeConfig extends LovelaceBadgeConfig {
double_tap_action?: ActionConfig;
}
export interface AlertBadgeConfig extends LovelaceBadgeConfig {
type: "alert";
entity: string;
alert_color?: string;
clear_color?: string;
pulse?: boolean;
}
export interface ShortcutBadgeConfig extends LovelaceBadgeConfig {
text?: string;
icon?: string;
@@ -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"
@@ -9,6 +9,7 @@ import {
const ALWAYS_LOADED_TYPES = new Set(["error", "entity"]);
const LAZY_LOAD_TYPES = {
alert: () => import("../badges/hui-alert-badge"),
"entity-filter": () => import("../badges/hui-entity-filter-badge"),
shortcut: () => import("../badges/hui-shortcut-badge"),
"state-label": () => import("../badges/hui-state-label-badge"),
@@ -0,0 +1,118 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { assert, assign, boolean, object, optional, string } from "superstruct";
import { fireEvent } from "../../../../common/dom/fire_event";
import {
ALERT_DOMAINS,
DEFAULT_ALERT_COLOR,
} from "../../../../common/entity/alert_active";
import "../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import type { AlertBadgeConfig } from "../../badges/types";
import type { LovelaceBadgeEditor } from "../../types";
import { baseLovelaceBadgeConfig } from "../structs/base-badge-struct";
const badgeConfigStruct = assign(
baseLovelaceBadgeConfig,
object({
entity: optional(string()),
alert_color: optional(string()),
clear_color: optional(string()),
pulse: optional(boolean()),
})
);
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
implements LovelaceBadgeEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _config?: AlertBadgeConfig;
public setConfig(config: AlertBadgeConfig): void {
assert(config, badgeConfigStruct);
this._config = config;
}
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
return html`
<ha-form
.hass=${this.hass}
.data=${this._config}
.schema=${SCHEMA}
.computeLabel=${this._computeLabelCallback}
.computeHelper=${this._computeHelperCallback}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _valueChanged(ev: ValueChangedEvent<AlertBadgeConfig>): void {
ev.stopPropagation();
fireEvent(this, "config-changed", { config: ev.detail.value });
}
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<typeof SCHEMA>) => {
if (
schema.name === "pulse" ||
schema.name === "alert_color" ||
schema.name === "clear_color"
) {
return this.hass?.localize(
`ui.panel.lovelace.editor.card.alert.${schema.name}_helper`
);
}
return undefined;
};
}
declare global {
interface HTMLElementTagNameMap {
"hui-alert-badge-editor": HuiAlertBadgeEditor;
}
}
@@ -9,4 +9,8 @@ export const coreBadges: Badge[] = [
type: "shortcut",
showElement: true,
},
{
type: "alert",
showElement: true,
},
];
+10 -3
View File
@@ -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",
@@ -11289,6 +11292,10 @@
"state_filter": "[%key:ui::panel::lovelace::editor::card::picture-elements::state_filter%]"
},
"badge": {
"alert": {
"name": "Alert",
"description": "A badge that highlights an entity that needs attention, with an optional color and pulsing background."
},
"entity": {
"name": "Entity",
"description": "The Entity badge gives you a quick overview of an entity.",
@@ -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
);
}
});
});
+126 -3
View File
@@ -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";