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
31 changed files with 1198 additions and 1071 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;
}
+10 -43
View File
@@ -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`
+24 -6
View File
@@ -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;
-8
View File
@@ -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 -48
View File
@@ -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"
+60 -17
View File
@@ -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;
};
+7 -17
View File
@@ -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;
+7 -5
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",
@@ -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
);
}
});
});
+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";