Compare commits

..
13 Commits
Author SHA1 Message Date
Bram Kragten 8f73718a35 Bumped version to 20260826.1 2026-08-27 17:07:22 +02:00
Paul BotteinandBram Kragten 4bf2acdb43 Show serial port info in a bottom sheet on mobile (#53839) 2026-08-27 17:06:48 +02:00
Shay RedmondandBram Kragten f622e5eeb9 Small copy improvements for Cloud feature list (#53833) 2026-08-27 17:06:47 +02:00
Paul BotteinandBram Kragten 595068597f Keep the row config when changing a device automation type (#53832) 2026-08-27 17:06:46 +02:00
Aidan TimsonandBram Kragten c708f35ab6 Fix MQTT subscribe controls layout (#53831) 2026-08-27 17:06:45 +02:00
Aidan TimsonandBram Kragten 205874c800 Show app icons for serial port consumers (#53827) 2026-08-27 17:06:44 +02:00
Petar Petrov 47ddcbe2e5 Fix editor state leaking when switching automations, scripts, or scenes (#53826)
* Fix editor state leaking when switching automations, scripts, or scenes

Recreate the page when the item id in the route changes so YAML mode,
undo history, and scene live state cannot carry over to the next item.

* Declare itemId on editor routes instead of guessing from the path

Path-shape matching remounted nested config routers, Lovelace, and
developer tools. Recreate only where edit/show routes opt in.

* Do not restore scene live states when the editor is torn down

A remounted editor starts in review; disconnect only needs to drop
the subscription, not undo device changes from live mode.

* Use the normal create path when remounting itemId pages

Clear the current page instead of calling _createPanel directly so
load and the loading screen still run. Drop the redundant cache delete.

* Fix dialog-form tests leaking focus restore after jsdom teardown

Nested submit/cancel schedules a nextRender callback that ran after
Vitest tore down HTMLElement, failing CI with an unhandled rejection.
2026-08-27 17:06:43 +02:00
Petar PetrovandBram Kragten c43b8bfbe0 Do not show empty Grid legend item on energy usage graph (#53822)
Do not add empty combined Grid series on energy usage graph
2026-08-27 17:06:41 +02:00
Bram KragtenandClaude Opus 5 e349b00b15 Replace CARTO raster tiles with OpenStreetMap vector tiles (#53816)
Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-27 17:06:40 +02:00
Paul BotteinandBram Kragten 0bb8bdc98a Clarify the replaced device state in pickers and target rows (#53807) 2026-08-27 17:06:39 +02:00
Petar PetrovandBram Kragten bbc5385843 Fix more-info dialog rewriting the URL after navigation (#53806) 2026-08-27 17:06:37 +02:00
Paul BotteinandBram Kragten 43dc5651d3 Keep the automation config when replacing a device in device automations (#53805) 2026-08-27 17:06:36 +02:00
Aidan TimsonandBram Kragten 18dcf7abc0 Refine entity details context section (#53797) 2026-08-27 17:06:35 +02:00
93 changed files with 1212 additions and 4344 deletions
-4
View File
@@ -108,10 +108,6 @@ async function copyMapPanel(staticDir) {
// Style, glyphs and sprites for the vector base map
await ensureMapAssets();
fs.copySync(mapAssetsDir, staticPath("map/"));
copyFileDir(
npmPath("@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js"),
staticPath("map/")
);
}
function copyZXingWasm(staticDir) {
+1 -2
View File
@@ -13,7 +13,6 @@ import fs from "fs-extra";
import gulp from "gulp";
import { extract } from "tar";
import paths from "../paths.cjs";
import { addLatinLabels } from "./map-labels.js";
// The tile URL is deliberately never named here: the OSMF asks consumers to
// resolve it through the TileJSON so they can move the tiles without every
@@ -196,7 +195,7 @@ const buildMapAssets = async () => {
["light", colorful],
["dark", eclipse],
].map(([name, builder]) => {
const style = addLatinLabels(useTileJson(name, builder(styleOptions)));
const style = useTileJson(name, builder(styleOptions));
assertBoldStaysOnRefs(name, style);
return writeFile(
path.join(outputDir, `${name}.json`),
-42
View File
@@ -1,42 +0,0 @@
// Adds the English name to labels whose local name is not in Latin script.
// Shortbread tiles carry `name`, `name_en` and `name_de` only.
const NAME = ["get", "name"];
const NAME_EN = ["get", "name_en"];
// Strings compare by code point: anything from Basic Latin up to Latin
// Extended-B, digits and punctuation included.
const IS_LATIN = ["<", NAME, "ɐ"];
const ENGLISH_SCALE = 0.8;
// Streets are line-placed and cannot break lines.
const withEnglish = (placement) =>
placement === "line"
? ["concat", NAME, " (", NAME_EN, ")"]
: ["format", NAME, {}, "\n", {}, NAME_EN, { "font-scale": ENGLISH_SCALE }];
const isNameLabel = (layer) =>
JSON.stringify(layer.layout?.["text-field"]) === JSON.stringify(NAME);
export const addLatinLabels = (style) => ({
...style,
layers: style.layers.map((layer) =>
isNameLabel(layer)
? {
...layer,
layout: {
...layer.layout,
"text-field": [
"case",
IS_LATIN,
NAME,
["!", ["has", "name_en"]],
NAME,
withEnglish(layer.layout["symbol-placement"]),
],
},
}
: layer
),
});
+7 -8
View File
@@ -75,7 +75,6 @@
"@lit/context": "1.1.6",
"@lit/reactive-element": "2.1.2",
"@lit/task": "1.0.3",
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
"@maplibre/maplibre-gl-leaflet": "0.1.4",
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
@@ -110,7 +109,7 @@
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.14",
"js-yaml": "5.4.0",
"js-yaml": "5.3.0",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
@@ -118,7 +117,7 @@
"lit-html": "3.3.3",
"luxon": "3.7.2",
"maplibre-gl": "5.24.0",
"marked": "18.0.11",
"marked": "18.0.10",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -150,7 +149,7 @@
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@html-eslint/eslint-plugin": "0.65.0",
"@lokalise/node-api": "16.4.0",
"@lokalise/node-api": "16.3.0",
"@octokit/auth-oauth-device": "8.0.4",
"@octokit/plugin-retry": "8.1.1",
"@octokit/rest": "22.0.1",
@@ -180,7 +179,7 @@
"browserslist": "4.28.8",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.9.1",
"eslint": "10.9.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
@@ -190,7 +189,7 @@
"eslint-plugin-wc": "3.1.0",
"fancy-log": "2.0.0",
"fs-extra": "11.4.0",
"generate-license-file": "4.2.3",
"generate-license-file": "4.2.1",
"glob": "13.0.6",
"globals": "17.11.0",
"gulp": "5.0.1",
@@ -207,7 +206,7 @@
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
"map-stream": "0.0.7",
"minify-literals": "2.2.0",
"minify-literals": "2.1.0",
"pinst": "3.0.0",
"prettier": "3.9.6",
"rspack-manifest-plugin": "5.2.2",
@@ -217,7 +216,7 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.68.0",
"typescript-eslint": "8.67.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.11",
"webpack-stats-plugin": "1.1.3",
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260826.0"
version = "20260826.1"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+3 -24
View File
@@ -1,6 +1,6 @@
import type { maplibreGL } from "@maplibre/maplibre-gl-leaflet";
import type { Map as LeafletMap } from "leaflet";
import type { setRTLTextPlugin, StyleSpecification } from "maplibre-gl";
import type { StyleSpecification } from "maplibre-gl";
import type { LeafletModuleType } from "../dom/setup-leaflet-map";
// Shortbread vector tiles from the OpenStreetMap Foundation. Only their tile
@@ -13,10 +13,6 @@ const VECTOR_STYLES = {
dark: "/static/map/dark.json",
} as const;
// Without it Arabic and Hebrew labels render reversed. Loaded by MapLibre's
// worker, hence a URL rather than an import.
const RTL_TEXT_PLUGIN_URL = "/static/map/mapbox-gl-rtl-text.js";
// Fallback for browsers without WebGL2, which MapLibre needs even for raster,
// so it stays a Leaflet tile layer. Still CARTO, and temporarily so: OSM's
// raster blocks a browser that sends no Referer, and the only referrer a browser
@@ -75,20 +71,6 @@ const loadStyle = async (url: string): Promise<StyleSpecification> => {
return style;
};
// Global to MapLibre, and it throws when set twice.
let rtlTextPluginRequested = false;
const ensureRTLTextPlugin = (setPlugin: typeof setRTLTextPlugin) => {
if (rtlTextPluginRequested) {
return;
}
rtlTextPluginRequested = true;
setPlugin(new URL(RTL_TEXT_PLUGIN_URL, location.href).href, true).catch(
() => {
// RTL labels stay reversed; everything else still renders.
}
);
};
const createVectorLayer = async (
createLayer: typeof maplibreGL,
leaflet: LeafletModuleType,
@@ -229,11 +211,8 @@ export const createBaseLayer = async (
if (supportsWebGL2()) {
let vectorLayer: MapBaseLayer | undefined;
try {
const [{ maplibreGL: createLayer }, maplibre] = await Promise.all([
import("@maplibre/maplibre-gl-leaflet"),
import("maplibre-gl"),
]);
ensureRTLTextPlugin(maplibre.setRTLTextPlugin);
const { maplibreGL: createLayer } =
await import("@maplibre/maplibre-gl-leaflet");
vectorLayer = await createVectorLayer(
createLayer,
leaflet,
+2 -85
View File
@@ -80,67 +80,6 @@ const ensureDialogsClosed = async (timestamp: number): Promise<boolean> => {
return ensureDialogsClosed(timestamp);
};
/**
* Lets a page with unsaved changes (e.g. the automation editor) veto
* navigation. `isDirty` is read live at navigation time; `prompt` resolves
* true when navigation may proceed.
*/
export interface UnsavedChangesGuard {
isDirty(): boolean;
prompt(): Promise<boolean>;
}
const unsavedChangesGuards = new Set<UnsavedChangesGuard>();
export const registerUnsavedChangesGuard = (guard: UnsavedChangesGuard) => {
unsavedChangesGuards.add(guard);
};
export const unregisterUnsavedChangesGuard = (guard: UnsavedChangesGuard) => {
unsavedChangesGuards.delete(guard);
};
let pendingUnsavedPrompt: Promise<boolean> | undefined;
/**
* Counts navigations that changed the history entry, so a navigation held up
* by an unsaved-changes prompt can tell whether a newer one has moved the app
* on in the meantime.
*/
let committedNavigations = 0;
/**
* Asks each dirty guard whether navigation may proceed. Returns true when
* nothing is dirty or every prompt was confirmed. Concurrent navigations
* share one pending prompt instead of stacking dialogs; the dirty check runs
* before joining it, so a navigation triggered from inside a prompt (e.g. by
* its save action) cannot deadlock on its own promise.
*/
const ensureUnsavedChangesConfirmed = (): Promise<boolean> => {
const dirtyGuards = [...unsavedChangesGuards].filter((guard) =>
guard.isDirty()
);
if (!dirtyGuards.length) {
return Promise.resolve(true);
}
if (!pendingUnsavedPrompt) {
pendingUnsavedPrompt = (async () => {
try {
for (const guard of dirtyGuards) {
// eslint-disable-next-line no-await-in-loop
if (!(await guard.prompt())) {
return false;
}
}
return true;
} finally {
pendingUnsavedPrompt = undefined;
}
})();
}
return pendingUnsavedPrompt;
};
const buildHistoryState = (
data: Record<string, unknown> | undefined,
from?: string
@@ -152,7 +91,7 @@ const buildHistoryState = (
return { ...state, from };
};
const performNavigation = async (path: string, options?: NavigateOptions) => {
export const navigate = async (path: string, options?: NavigateOptions) => {
const canProceed = await ensureDialogsClosed(Date.now());
if (!canProceed) {
return false;
@@ -188,28 +127,9 @@ const performNavigation = async (path: string, options?: NavigateOptions) => {
fireEvent(mainWindow, "location-changed", {
replace,
});
committedNavigations += 1;
return true;
};
export const navigate = async (path: string, options?: NavigateOptions) => {
// Only guard actual departures: navigating to the current path keeps the
// page, and any unsaved state on it, mounted.
if (path !== currentPath()) {
const navigationsBeforePrompt = committedNavigations;
if (!(await ensureUnsavedChangesConfirmed())) {
return false;
}
if (committedNavigations !== navigationsBeforePrompt) {
// Another navigation landed while the prompt was waiting for an answer,
// so this destination is stale. Dropping it keeps a late answer from
// pulling the user back off the page they are on now.
return false;
}
}
return performNavigation(path, options);
};
/**
* Whether the previous history entry is a page this app navigated away from.
* `history.length` cannot answer this: a login redirect goes through
@@ -222,9 +142,6 @@ export const canGoBack = (): boolean =>
/**
* Navigate back to the page we came from, falling back to a path when the
* previous entry is not ours (deep link, login redirect, fresh tab).
* Deliberately not guarded against unsaved changes: pages with such a guard
* confirm in their own back handlers, and delete flows leave through here
* after the edited item is already gone.
*/
export const goBack = async (fallbackPath?: string): Promise<void> => {
const canProceed = await ensureDialogsClosed(Date.now());
@@ -239,5 +156,5 @@ export const goBack = async (fallbackPath?: string): Promise<void> => {
return;
}
await performNavigation(fallbackPath || "/", { replace: true });
await navigate(fallbackPath || "/", { replace: true });
};
@@ -1,37 +0,0 @@
import deepFreeze from "deep-freeze";
const inFlightRequests = new WeakMap<object, Map<string, Promise<unknown>>>();
export const shareInFlightRequest = <T>(
owner: object,
key: string,
fetcher: () => Promise<T>
): Promise<T> => {
let requests = inFlightRequests.get(owner);
if (!requests) {
requests = new Map();
inFlightRequests.set(owner, requests);
}
const ownerRequests = requests;
const existing = ownerRequests.get(key);
if (existing) {
return existing as Promise<T>;
}
const request = fetcher()
.then((result) => deepFreeze(result) as T)
.finally(() => {
if (ownerRequests.get(key) !== request) {
return;
}
ownerRequests.delete(key);
if (ownerRequests.size === 0) {
inFlightRequests.delete(owner);
}
});
ownerRequests.set(key, request);
return request;
};
+14 -43
View File
@@ -3,27 +3,6 @@ import type { TooltipPositionCallback } from "echarts/types/dist/shared";
export const TOOLTIP_GAP_PX = 12;
export const TOOLTIP_TOP_OFFSET_PX = 10;
const offsetFromCursor = (
cursorX: number,
dom: unknown,
viewW: number,
tipW: number
) => {
const rtl =
dom instanceof HTMLElement && getComputedStyle(dom).direction === "rtl";
const rightOfCursor = cursorX + TOOLTIP_GAP_PX;
const leftOfCursor = cursorX - TOOLTIP_GAP_PX - tipW;
let x = rtl ? leftOfCursor : rightOfCursor;
const overflowsRight = x + tipW > viewW;
const overflowsLeft = x < 0;
if (overflowsRight || overflowsLeft) {
x = rtl ? rightOfCursor : leftOfCursor;
}
return Math.max(0, Math.min(x, viewW - tipW));
};
/**
* Pins the tooltip near the top of the chart and offsets it horizontally
* from the cursor so it never covers the data point being inspected.
@@ -41,29 +20,21 @@ export const sideTooltipPosition: TooltipPositionCallback = (
const [viewW, viewH] = size.viewSize;
const [tipW, tipH] = size.contentSize;
const x = offsetFromCursor(cursorX, dom, viewW, tipW);
const rtl =
dom instanceof HTMLElement && getComputedStyle(dom).direction === "rtl";
const rightOfCursor = cursorX + TOOLTIP_GAP_PX;
const leftOfCursor = cursorX - TOOLTIP_GAP_PX - tipW;
let x = rtl ? leftOfCursor : rightOfCursor;
const overflowsRight = x + tipW > viewW;
const overflowsLeft = x < 0;
if (overflowsRight || overflowsLeft) {
x = rtl ? rightOfCursor : leftOfCursor;
}
x = Math.max(0, Math.min(x, viewW - tipW));
const y = Math.max(0, Math.min(TOOLTIP_TOP_OFFSET_PX, viewH - tipH));
return [x, y];
};
/**
* Offsets the tooltip horizontally from the cursor and keeps it level with it.
* For item-trigger tooltips where the cursor's row is what the tooltip shows.
*/
export const itemTooltipPosition: TooltipPositionCallback = (
point,
_params,
dom,
_rect,
size
) => {
const [cursorX, cursorY] = point;
const [viewW, viewH] = size.viewSize;
const [tipW, tipH] = size.contentSize;
const x = offsetFromCursor(cursorX, dom, viewW, tipW);
const y = Math.max(0, Math.min(cursorY - tipH / 2, viewH - tipH));
return [x, y];
};
@@ -328,7 +328,6 @@ export class StateHistoryChartLine extends LitElement {
...createYAxisPrecisionBounds({
min: this._clampYAxis(minYAxis),
max: this._clampYAxis(maxYAxis),
unit: this.unit,
onFractionDigits: (digits) => {
if (digits !== this._yAxisFractionDigits) {
this._yAxisFractionDigits = digits;
@@ -1,3 +1,4 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -12,7 +13,7 @@ import { computeRTL } from "../../common/util/compute_rtl";
import type { TimelineEntity } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { itemTooltipPosition } from "./chart-tooltip-position";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { computeTimelineColor } from "./timeline-color";
import type { HaECOption, HaECSeries } from "../../resources/echarts/echarts";
@@ -23,6 +24,7 @@ import { measureTextWidth } from "../../util/text";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
const ROW_HEIGHT = 30;
const ROW_HEIGHT_INSIDE_LABELS = 64;
const GRID_BOTTOM = 30;
@customElement("state-history-chart-timeline")
@@ -41,6 +43,10 @@ export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
/** Draw each row's name above its bar instead of in a label column. */
@property({ attribute: "inside-labels", type: Boolean })
public insideLabels = false;
@property({ attribute: "click-for-more-info", type: Boolean })
public clickForMoreInfo = true;
@@ -63,6 +69,13 @@ export class StateHistoryChartTimeline extends LitElement {
@state() private _yWidth = 0;
private _width = 0;
private _resize = new ResizeController(this, {
skipInitial: true,
callback: (entries) => entries[0]?.contentRect.width,
});
private _chartTime: Date = new Date();
protected render() {
@@ -70,7 +83,7 @@ export class StateHistoryChartTimeline extends LitElement {
<ha-chart-base
.hass=${this.hass}
.options=${this._chartOptions}
.height=${`${this.data.length * ROW_HEIGHT + GRID_BOTTOM}px`}
.height=${`${this.data.length * (this.insideLabels ? ROW_HEIGHT_INSIDE_LABELS : ROW_HEIGHT) + GRID_BOTTOM}px`}
.data=${this._chartData as HaECSeries}
small-controls
@chart-click=${this._handleChartClick}
@@ -180,13 +193,19 @@ export class StateHistoryChartTimeline extends LitElement {
this._generateData();
}
const width = this.insideLabels ? Math.round(this._resize.value ?? 0) : 0;
const widthChanged = width !== this._width;
this._width = width;
if (
!this.hasUpdated ||
changedProps.has("startTime") ||
changedProps.has("endTime") ||
changedProps.has("showNames") ||
changedProps.has("insideLabels") ||
changedProps.has("paddingYAxis") ||
changedProps.has("_yWidth")
changedProps.has("_yWidth") ||
widthChanged
) {
this._createOptions();
}
@@ -196,14 +215,22 @@ export class StateHistoryChartTimeline extends LitElement {
const narrow = this.narrow;
const showNames = this.chunked || this.showNames;
const maxInternalLabelWidth = narrow ? 105 : 185;
const labelWidth = showNames
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const insideLabels = this.insideLabels;
const labelWidth =
showNames && !insideLabels
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const labelMargin = 5;
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
// Keeps the plot aligned with the line charts sharing the y-axis padding.
const plotPadding = insideLabels ? this.paddingYAxis : labelWidth;
// A zero width hides the labels instead of truncating them.
const insideLabelWidth = this._width
? Math.max(0, this._width - plotPadding - labelMargin)
: undefined;
this._chartOptions = {
xAxis: {
type: "time",
@@ -227,41 +254,56 @@ export class StateHistoryChartTimeline extends LitElement {
axisLine: {
show: false,
},
axisLabel: {
show: showNames,
width: labelWidth,
overflow: "truncate",
margin: labelMargin,
formatter: (id: string) => {
const label = this._chartData.find((d) => d.id === id)
?.name as string;
const width = label
? Math.min(
measureTextWidth(label, 12) + labelMargin,
maxInternalLabelWidth
)
: 0;
if (width > this._yWidth) {
this._yWidth = width;
fireEvent(this, "y-width-changed", {
value: this._yWidth,
chartIndex: this.chartIndex,
});
axisLabel: insideLabels
? {
show: showNames,
inside: true,
margin: 0,
padding: [0, rtl ? 2 : 0, 14, rtl ? 0 : 2],
align: rtl ? "right" : "left",
verticalAlign: "bottom",
width: insideLabelWidth,
overflow: "truncate",
formatter: (id: string) =>
(this._chartData.find((d) => d.id === id)?.name as string) ??
"",
hideOverlap: true,
}
return label;
},
hideOverlap: true,
},
: {
show: showNames,
width: labelWidth,
overflow: "truncate",
margin: labelMargin,
formatter: (id: string) => {
const label = this._chartData.find((d) => d.id === id)
?.name as string;
const width = label
? Math.min(
measureTextWidth(label, 12) + labelMargin,
maxInternalLabelWidth
)
: 0;
if (width > this._yWidth) {
this._yWidth = width;
fireEvent(this, "y-width-changed", {
value: this._yWidth,
chartIndex: this.chartIndex,
});
}
return label;
},
hideOverlap: true,
},
},
grid: {
top: 10,
top: insideLabels ? 20 : 10,
bottom: GRID_BOTTOM,
left: rtl ? 1 : labelWidth,
right: rtl ? labelWidth : 1,
left: rtl ? 1 : plotPadding,
right: rtl ? plotPadding : 1,
},
tooltip: {
renderMode: "html",
position: itemTooltipPosition,
position: sideTooltipPosition,
confine: true,
formatter: this._renderTooltip,
},
@@ -401,6 +443,10 @@ export class StateHistoryChartTimeline extends LitElement {
}
static styles = css`
:host {
display: block;
}
ha-chart-base {
--chart-max-height: none;
}
@@ -79,6 +79,10 @@ export class StateHistoryCharts extends LitElement {
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
/** Draw timeline row names above their bar instead of in a label column. */
@property({ attribute: "inside-labels", type: Boolean, reflect: true })
public insideLabels = false;
@property({ attribute: "click-for-more-info", type: Boolean })
public clickForMoreInfo = true;
@@ -227,6 +231,7 @@ export class StateHistoryCharts extends LitElement {
.startTime=${this._computedStartTime}
.endTime=${this._computedEndTime}
.showNames=${this.showNames}
.insideLabels=${this.insideLabels}
.names=${this.names}
.narrow=${this.narrow}
.chunked=${this.virtualize}
@@ -424,6 +429,12 @@ export class StateHistoryCharts extends LitElement {
padding-top: 8px;
}
/* Names inside the plot sit close to the chart above them, so the groups
need more room between them to stay apart. */
:host([inside-labels]) .entry-container.timeline:not(:first-child) {
margin-top: var(--ha-space-8);
}
.entry-container:hover {
z-index: 1;
}
-1
View File
@@ -446,7 +446,6 @@ export class StatisticsChart extends LitElement {
...createYAxisPrecisionBounds({
min: this._clampYAxis(minYAxis),
max: this._clampYAxis(maxYAxis),
unit: this.unit,
// Bar charts stay anchored at 0, so precision must reflect the
// 0-based range that is actually rendered.
includeZero: !yAxisScale,
+26 -121
View File
@@ -1,32 +1,13 @@
import { intervalScaleEnsureValidExtent } from "echarts/lib/scale/helper";
import { getPrecision, nice, round } from "echarts/lib/util/number";
// A range smaller than this fraction of the axis magnitude is floating-point
// noise (e.g. from summed statistics), not real precision.
const NEGLIGIBLE_RANGE_RATIO = 1e-10;
// Intervals the axis aims for. Passed to ECharts rather than assumed, so the
// precision derived here cannot drift from the ticks it renders.
const SPLIT_NUMBER = 5;
// How thin a gap between the data and the plot edge counts as no gap at all,
// as a fraction of the data span. ECharts floors the axis minimum and ceils the
// maximum to a tick multiple, which usually leaves headroom, but quantized
// states often land exactly on a tick and get none — collapsing area-filled
// series, which are drawn from their value down to the axis minimum. Widening
// the extent by this much before that rounding bumps any axis with less
// headroom out to a full tick, and leaves the rest where they are.
const GAP_FRACTION_OF_SPAN = 0.02;
// A percentage has a real ceiling the way zero is a real floor, so the gap must
// not push the axis past it. Not every `%` sensor is bounded — power factor is
// signed and can read over 100 — so this only applies while the data stays under.
const PERCENT_MAX = 100;
// Derive the number of decimal digits to use for Y-axis labels from the
// observed data range, by asking ECharts for the same tick interval it will
// render. This matches the precision it actually draws, so labels are neither
// truncated to identical values nor padded with extra zeros.
// observed data range. We mirror how ECharts sizes its ticks: it splits the
// range into ~5 intervals (its default `splitNumber`) and rounds that raw
// interval to a "nice" 1/2/3/5×10ⁿ value, then reports the decimals that nice
// interval needs. This matches the precision ECharts actually renders, so
// labels are neither truncated to identical values nor padded with extra zeros.
export function computeYAxisFractionDigits(
min: number,
max: number,
@@ -41,7 +22,13 @@ export function computeYAxisFractionDigits(
// with a tail of zeros (e.g. "0.20000000000000"), so treat it as flat.
const magnitude = Math.max(Math.abs(lo), Math.abs(hi));
if (range <= magnitude * NEGLIGIBLE_RANGE_RATIO) return 1;
return getPrecision(nice(range / SPLIT_NUMBER, true));
const rawInterval = range / 5;
const exponent = Math.floor(Math.log10(rawInterval));
const mantissa = rawInterval / 10 ** exponent; // in [1, 10)
// Rounding the mantissa to a nice value only ever carries to the next power
// of ten (mantissa ≥ 7 → 10), which needs one fewer decimal.
const niceExponent = mantissa >= 7 ? exponent + 1 : exponent;
return Math.max(0, -niceExponent);
}
interface YAxisExtentValues {
@@ -57,116 +44,34 @@ const resolveYAxisBound = (
values: YAxisExtentValues
): number | undefined => (typeof bound === "function" ? bound(values) : bound);
// A constant series has no span for the gap to be a fraction of, so ECharts
// falls back to `Math.abs(min)` when sizing it. That makes the extent unequal,
// which in turn stops `intervalScaleEnsureValidExtent` from applying the ±|v|/2
// expansion a flat series relies on for its window. Run that expansion here and
// return fixed bounds, which also suppresses the gap.
const flatSeriesExtent = (value: number, fixed: [boolean, boolean]) => {
const [lo, hi] = intervalScaleEnsureValidExtent([value, value], fixed);
const interval = nice((hi - lo) / SPLIT_NUMBER, true);
const precision = getPrecision(interval);
return {
min: round(Math.floor(lo / interval) * interval, precision),
max: round(Math.ceil(hi / interval) * interval, precision),
};
};
// Build the `yAxis` options that keep tick-label precision and the plot-edge gap
// in agreement with the extent ECharts renders. It re-invokes the `min`/`max`
// callbacks with the extent of the visible (zoom-filtered) data on every
// dataZoom, and always before the label formatter runs, so the fraction digits
// recomputed here track the zoomed range. A callback returns `undefined`
// wherever auto-scaling should stand, and a number only where the axis has to be
// pinned: an explicit bound, the zero anchor, or a constant series.
// Wrap the Y-axis `min`/`max` options in callbacks so the tick-label precision
// tracks the currently visible axis extent. ECharts re-invokes these callbacks
// with the extent of the visible (zoom-filtered) data on every dataZoom, and
// always before the label formatter runs, so recomputing the fraction digits
// here keeps zoomed-in labels distinct. The callbacks return the original
// bounds unchanged, so auto-scaling still applies when a bound is not set.
export function createYAxisPrecisionBounds(options: {
min?: YAxisBound;
max?: YAxisBound;
// Set for bar axes anchored at 0, so precision reflects the 0-based range.
// Such an axis also gets no gap: pushing it below zero would defeat the zero
// anchoring and leave the bars floating above the axis.
includeZero?: boolean;
// Used to recognise a bounded quantity, so the gap cannot widen the axis past
// a limit the data itself never crosses.
unit?: string;
onFractionDigits: (digits: number) => void;
}): {
min: (values: YAxisExtentValues) => number | undefined;
max: (values: YAxisExtentValues) => number | undefined;
boundaryGap: [number, number];
splitNumber: number;
} {
const { min, max, includeZero, unit, onFractionDigits } = options;
const naturalMax = unit === "%" ? PERCENT_MAX : undefined;
const resolveBounds = (values: YAxisExtentValues) => {
const resolvedMin = resolveYAxisBound(min, values);
const resolvedMax = resolveYAxisBound(max, values);
if (
includeZero ||
!Number.isFinite(values.min) ||
!Number.isFinite(values.max)
) {
return { min: resolvedMin, max: resolvedMax, gap: 0 };
}
if (values.min === values.max) {
const flat = flatSeriesExtent(values.min, [
resolvedMin !== undefined,
resolvedMax !== undefined,
]);
// The expansion is a fraction of the magnitude, so a constant series near
// the ceiling would otherwise overshoot it too.
const flatMax =
naturalMax !== undefined && values.max <= naturalMax
? Math.min(flat.max, naturalMax)
: flat.max;
return {
min: resolvedMin ?? flat.min,
max: resolvedMax ?? flatMax,
gap: 0,
};
}
const gap = (values.max - values.min) * GAP_FRACTION_OF_SPAN;
// Never let the gap carry a series past a boundary it does not itself cross.
const floor = values.min >= 0 ? 0 : undefined;
const ceiling =
naturalMax !== undefined && values.max <= naturalMax
? naturalMax
: values.max <= 0
? 0
: undefined;
return {
min:
resolvedMin ??
(floor !== undefined && values.min - floor < gap ? floor : undefined),
max:
resolvedMax ??
(ceiling !== undefined && ceiling - values.max < gap
? ceiling
: undefined),
gap,
};
};
const { min, max, includeZero, onFractionDigits } = options;
return {
// Always emit the key. `setOption` merges the Y axis rather than replacing
// it, so a conditionally spread gap would survive a chart switching to a
// zero-anchored type and leave its bars floating.
boundaryGap: includeZero
? [0, 0]
: [GAP_FRACTION_OF_SPAN, GAP_FRACTION_OF_SPAN],
splitNumber: SPLIT_NUMBER,
min: (values) => {
const bounds = resolveBounds(values);
const resolvedMin = resolveYAxisBound(min, values);
const resolvedMax = resolveYAxisBound(max, values);
const extentMin = resolvedMin ?? values.min;
const extentMax = resolvedMax ?? values.max;
onFractionDigits(
computeYAxisFractionDigits(
bounds.min ?? values.min - bounds.gap,
bounds.max ?? values.max + bounds.gap,
includeZero
)
computeYAxisFractionDigits(extentMin, extentMax, includeZero)
);
return bounds.min;
return resolvedMin;
},
max: (values) => resolveBounds(values).max,
max: (values) => resolveYAxisBound(max, values),
};
}
+1 -1
View File
@@ -16,10 +16,10 @@ import { computeStateName } from "../../common/entity/compute_state_name";
import { computeRTL } from "../../common/util/compute_rtl";
import { domainToName } from "../../data/integration";
import {
getStatisticIds,
getStatisticLabel,
type StatisticsMetaData,
} from "../../data/recorder";
import { getStatisticIds } from "../../data/recorder_statistic_ids";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
import "../ha-combo-box-item";
+1 -1
View File
@@ -90,7 +90,7 @@ class HaAlert extends LitElement {
static styles = css`
.issue-type {
position: relative;
padding: var(--ha-alert-padding, 8px);
padding: 8px;
display: flex;
}
.icon {
-4
View File
@@ -95,10 +95,6 @@ export class HaDrawer extends LitElement {
}
private _handleAfterHide(ev: Event) {
// Ignore wa-after-hide from nested Web Awesome components (e.g. tooltips)
if (ev.target !== ev.currentTarget) {
return;
}
ev.stopPropagation();
this.open = false;
fireEvent(this, "hass-drawer-closed");
@@ -1,13 +1,6 @@
import {
getSelectorInitialValue,
getSelectorInitialValueOrUndefined,
} from "./get-selector-initial-value";
import type { Selector } from "../../data/selector";
import type { HaFormData, HaFormSchema } from "./types";
interface ComputeInitialHaFormDataOptions {
skipUnsupportedSelectors?: boolean;
}
const setDefaultValue = (
field: HaFormSchema,
value: HaFormData | undefined
@@ -25,8 +18,7 @@ const setDefaultValue = (
};
export const computeInitialHaFormData = (
schema: HaFormSchema[] | readonly HaFormSchema[],
options?: ComputeInitialHaFormDataOptions
schema: HaFormSchema[] | readonly HaFormSchema[]
): Record<string, any> => {
const data = {};
schema.forEach((field) => {
@@ -41,7 +33,7 @@ export const computeInitialHaFormData = (
} else if ("default" in field) {
data[field.name] = setDefaultValue(field, field.default);
} else if (field.type === "expandable") {
const expandableData = computeInitialHaFormData(field.schema, options);
const expandableData = computeInitialHaFormData(field.schema);
if (field.required || Object.keys(expandableData).length) {
// Only add expandable data if it's required or any of its children have initial values.
data[field.name] = expandableData;
@@ -70,11 +62,104 @@ export const computeInitialHaFormData = (
seconds: 0,
};
} else if ("selector" in field) {
const initialValue = options?.skipUnsupportedSelectors
? getSelectorInitialValueOrUndefined(field.selector)
: getSelectorInitialValue(field.selector);
if (initialValue !== undefined) {
data[field.name] = initialValue;
const selector: Selector = field.selector;
if ("device" in selector) {
data[field.name] = selector.device?.multiple ? [] : "";
} else if ("entity" in selector) {
data[field.name] = selector.entity?.multiple ? [] : "";
} else if ("area" in selector) {
data[field.name] = selector.area?.multiple ? [] : "";
} else if ("label" in selector) {
data[field.name] = selector.label?.multiple ? [] : "";
} else if ("boolean" in selector) {
data[field.name] = false;
} else if (
"addon" in selector ||
"attribute" in selector ||
"file" in selector ||
"icon" in selector ||
"serial_port" in selector ||
"template" in selector ||
"text" in selector ||
"theme" in selector ||
"object" in selector
) {
data[field.name] = "";
} else if ("number" in selector) {
data[field.name] = selector.number?.min ?? 0;
} else if ("select" in selector) {
if (selector.select?.options.length) {
const firstOption = selector.select.options[0];
const val =
typeof firstOption === "string" ? firstOption : firstOption.value;
data[field.name] = selector.select.multiple ? [val] : val;
}
} else if ("country" in selector) {
if (selector.country?.countries?.length) {
data[field.name] = selector.country.countries[0];
}
} else if ("language" in selector) {
if (selector.language?.languages?.length) {
data[field.name] = selector.language.languages[0];
}
} else if ("duration" in selector) {
data[field.name] = {
hours: 0,
minutes: 0,
seconds: 0,
};
} else if ("time" in selector) {
data[field.name] = "00:00:00";
} else if ("date" in selector || "datetime" in selector) {
const now = new Date().toISOString().slice(0, 10);
data[field.name] = `${now}T00:00:00`;
} else if ("color_rgb" in selector) {
data[field.name] = [0, 0, 0];
} else if ("color_temp" in selector) {
data[field.name] = selector.color_temp?.min_mireds ?? 153;
} else if (
"action" in selector ||
"trigger" in selector ||
"condition" in selector
) {
data[field.name] = [];
} else if ("media" in selector || "target" in selector) {
data[field.name] = {};
} else if ("state" in selector) {
data[field.name] = selector.state?.multiple ? [] : "";
} else if ("choose" in selector) {
const firstChoice = Object.keys(selector.choose.choices)[0];
if (!firstChoice) {
data[field.name] = {};
} else {
data[field.name] = {
active_choice: firstChoice,
[firstChoice]: computeInitialHaFormData([
{
name: firstChoice,
selector: selector.choose.choices[firstChoice].selector,
},
])[firstChoice],
};
}
} else if ("numeric_threshold" in selector) {
const mode = selector.numeric_threshold?.mode ?? "crossed";
const type = mode === "changed" ? "any" : "above";
data[field.name] =
type === "any"
? { type }
: {
type,
value: {
number: selector.numeric_threshold?.number?.min ?? 0,
active_choice: "number",
},
};
} else {
throw new Error(
`Selector ${Object.keys(selector)[0]} not supported in initial form data`
);
}
}
});
@@ -1,89 +1,25 @@
import { DEFAULT_MIN_KELVIN } from "../../common/color/convert-light-color";
import type {
Selector,
SelectorForType,
SelectorType,
} from "../../data/selector";
type SelectorFallbackValues = {
[T in SelectorType]: ((selector: SelectorForType<T>) => unknown) | undefined;
};
const SELECTOR_FALLBACK_VALUES = {
action: undefined,
addon: undefined,
automation_behavior: undefined,
app: undefined,
area: undefined,
areas_display: undefined,
attribute: undefined,
assist_pipeline: undefined,
boolean: () => false,
choose: undefined,
color_rgb: undefined,
condition: undefined,
config_entry: undefined,
conversation_agent: undefined,
constant: (selector) => selector.constant?.value,
country: undefined,
date: undefined,
datetime: undefined,
device: undefined,
device_class: undefined,
duration: undefined,
entity: undefined,
entity_name: undefined,
statistic: undefined,
file: undefined,
floor: undefined,
label: undefined,
language: undefined,
navigation: undefined,
number: (selector) => selector.number?.min ?? 0,
numeric_threshold: undefined,
object: undefined,
period: undefined,
qr_code: undefined,
select: undefined,
selector: undefined,
serial_port: undefined,
state: undefined,
backup_location: undefined,
stt: undefined,
target: undefined,
template: undefined,
text: undefined,
time: undefined,
icon: undefined,
media: undefined,
theme: undefined,
timezone: undefined,
button_toggle: undefined,
trigger: undefined,
tts: undefined,
tts_voice: undefined,
location: undefined,
color_temp: (selector) => {
if (selector.color_temp?.unit === "kelvin") {
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
}
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
},
ui_action: undefined,
ui_clock_date_format: undefined,
ui_color: undefined,
ui_state_content: undefined,
ui_time_format: undefined,
} satisfies SelectorFallbackValues;
import type { Selector } from "../../data/selector";
/**
* Value a selector already displays when no field value is set.
* Used when enabling an optional service/trigger/condition field.
*/
export const getSelectorFallbackValue = (selector: Selector): unknown => {
const type = Object.keys(selector)[0] as SelectorType;
const fallbackValue = SELECTOR_FALLBACK_VALUES[type];
return fallbackValue?.(selector as never);
if ("constant" in selector) {
return selector.constant?.value;
}
if ("boolean" in selector) {
return false;
}
if ("number" in selector) {
return selector.number?.min ?? 0;
}
if ("color_temp" in selector) {
if (selector.color_temp?.unit === "kelvin") {
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
}
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
}
return undefined;
};
@@ -1,133 +0,0 @@
import type {
Selector,
SelectorForType,
SelectorType,
} from "../../data/selector";
type SelectorInitialValues = {
[T in SelectorType]: ((selector: SelectorForType<T>) => unknown) | undefined;
};
const SELECTOR_INITIAL_VALUES = {
action: () => [],
addon: () => "",
automation_behavior: undefined,
app: undefined,
area: (selector) => (selector.area?.multiple ? [] : ""),
areas_display: undefined,
attribute: () => "",
assist_pipeline: undefined,
boolean: () => false,
choose: (selector) => {
const firstChoice = Object.keys(selector.choose.choices)[0];
if (!firstChoice) {
return {};
}
const childValue = getSelectorInitialValueOrUndefined(
selector.choose.choices[firstChoice].selector
);
return childValue === undefined
? { active_choice: firstChoice }
: { active_choice: firstChoice, [firstChoice]: childValue };
},
color_rgb: () => [0, 0, 0],
condition: () => [],
config_entry: undefined,
conversation_agent: undefined,
constant: (selector) => selector.constant?.value,
country: (selector) => selector.country?.countries?.[0],
date: () => `${new Date().toISOString().slice(0, 10)}T00:00:00`,
datetime: () => `${new Date().toISOString().slice(0, 10)}T00:00:00`,
device: (selector) => (selector.device?.multiple ? [] : ""),
device_class: (selector) =>
selector.device_class?.multiple ? [] : undefined,
duration: () => ({
hours: 0,
minutes: 0,
seconds: 0,
}),
entity: (selector) => (selector.entity?.multiple ? [] : ""),
entity_name: undefined,
statistic: undefined,
file: () => "",
floor: undefined,
label: (selector) => (selector.label?.multiple ? [] : ""),
language: (selector) => selector.language?.languages?.[0],
navigation: undefined,
number: (selector) => selector.number?.min ?? 0,
numeric_threshold: (selector) => {
const mode = selector.numeric_threshold?.mode ?? "crossed";
const type = mode === "changed" ? "any" : "above";
return type === "any"
? { type }
: {
type,
value: {
number: selector.numeric_threshold?.number?.min ?? 0,
active_choice: "number",
},
};
},
object: (selector) => (selector.object?.multiple ? [] : ""),
period: undefined,
qr_code: undefined,
select: (selector) => {
const select = selector.select;
if (!select?.options.length) {
return undefined;
}
const firstOption = select.options[0];
const value =
typeof firstOption === "string" ? firstOption : firstOption.value;
return select.multiple ? [value] : value;
},
selector: undefined,
serial_port: () => "",
state: (selector) => (selector.state?.multiple ? [] : ""),
backup_location: undefined,
stt: undefined,
target: () => ({}),
template: () => "",
text: (selector) => (selector.text?.multiple ? [] : ""),
time: () => "00:00:00",
icon: () => "",
media: () => ({}),
theme: () => "",
timezone: undefined,
button_toggle: undefined,
trigger: () => [],
tts: undefined,
tts_voice: undefined,
location: undefined,
color_temp: (selector) => selector.color_temp?.min_mireds ?? 153,
ui_action: undefined,
ui_clock_date_format: undefined,
ui_color: undefined,
ui_state_content: undefined,
ui_time_format: undefined,
} satisfies SelectorInitialValues;
export const getSelectorInitialValueOrUndefined = (
selector: Selector
): unknown => {
const type = Object.keys(selector)[0] as SelectorType;
return SELECTOR_INITIAL_VALUES[type]?.(selector as never);
};
export const getSelectorInitialValue = (selector: Selector): unknown => {
const type = Object.keys(selector)[0] as SelectorType;
const initialValue = SELECTOR_INITIAL_VALUES[type];
if (!initialValue) {
throw new Error(`Selector ${type} not supported in initial form data`);
}
return initialValue(selector as never);
};
@@ -67,16 +67,15 @@ export class HaNumberSelector extends LitElement {
}
}
// On iOS/iPadOS the numeric and decimal on-screen keypads have no minus key.
// Leaving inputmode unset on a number input gives the "Numbers and
// Punctuation" keyboard there, which does include a minus. Other platforms
// include a minus on their number keypads, so restrict this workaround to
// Safari/WebKit and only when the selector allows negatives: either an
// explicit negative min, or no min at all (e.g. the numeric threshold
// selector used by the power triggers).
const useSafariNegativeKeyboard =
// On iOS/iPadOS the numeric and decimal on-screen keypads have no minus key,
// so negatives can only be typed with the full "text" keyboard. Other
// platforms include a minus on their number keypads, so restrict this
// workaround to Safari/WebKit and only when the selector allows negatives
// (e.g. numeric_state triggers/conditions).
const useTextInputMode =
isSafari &&
(this.selector.number?.min === undefined || this.selector.number.min < 0);
this.selector.number?.min !== undefined &&
this.selector.number.min < 0;
const translationKey = this.selector.number?.translation_key;
let unit = this.selector.number?.unit_of_measurement;
@@ -113,8 +112,8 @@ export class HaNumberSelector extends LitElement {
}
<ha-input
.inputmode=${
useSafariNegativeKeyboard
? undefined
useTextInputMode
? "text"
: this.selector.number?.step === "any" ||
(this.selector.number?.step ?? 1) % 1 !== 0
? "decimal"
@@ -13,7 +13,6 @@ import type { ObjectSelector } from "../../data/selector";
import { formatSelectorValue } from "../../data/selector/format_selector_value";
import { showFormDialog } from "../../dialogs/form/show-form-dialog";
import type { HomeAssistant } from "../../types";
import { computeInitialHaFormData } from "../ha-form/compute-initial-ha-form-data";
import type { HaFormSchema } from "../ha-form/types";
import "../ha-input-helper-text";
import "../ha-md-list";
@@ -238,14 +237,10 @@ export class HaObjectSelector extends LitElement {
private async _addItem(ev) {
ev.stopPropagation();
const schema = this._schema(this.selector);
const newItem = await showFormDialog(this, {
title: this.hass.localize("ui.common.add"),
schema,
data: computeInitialHaFormData(schema, {
skipUnsupportedSelectors: true,
}),
schema: this._schema(this.selector),
data: {},
computeLabel: this._computeLabel,
computeHelper: this._computeHelper,
submitText: this.hass.localize("ui.common.add"),
@@ -298,7 +293,7 @@ export class HaObjectSelector extends LitElement {
const index = ev.currentTarget.index;
if (!this.selector.object!.multiple) {
fireEvent(this, "value-changed", { value: "" });
fireEvent(this, "value-changed", { value: undefined });
return;
}
+2 -2
View File
@@ -3,7 +3,7 @@ import { html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
import type { Selector, SelectorType } from "../../data/selector";
import type { Selector } from "../../data/selector";
import {
handleLegacyDeviceSelector,
handleLegacyEntitySelector,
@@ -70,7 +70,7 @@ const LOAD_ELEMENTS = {
ui_color: () => import("./ha-selector-ui-color"),
ui_state_content: () => import("./ha-selector-ui-state-content"),
ui_time_format: () => import("./ha-selector-ui-time-format"),
} satisfies Record<SelectorType, () => Promise<unknown>>;
};
const LEGACY_UI_SELECTORS = new Set(["ui-action", "ui-color"]);
+107 -135
View File
@@ -9,8 +9,7 @@ import {
} from "../resources/theme/color/color.globals";
import type { HomeAssistant, ThemeSettings, ValueChangedEvent } from "../types";
import "./ha-button";
import "./item/ha-list-item-base";
import "./list/ha-list-base";
import "./ha-settings-row";
import "./ha-theme-picker";
import "./input/ha-input";
import "./radio/ha-radio-group";
@@ -26,7 +25,6 @@ export interface ThemeSettingsLabels {
autoMode?: string;
lightMode?: string;
darkMode?: string;
colors?: string;
primaryColor?: string;
accentColor?: string;
reset?: string;
@@ -44,7 +42,7 @@ export class HaThemeSettings extends LitElement {
@property() public heading?: string;
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: "include-default", type: Boolean })
public includeDefault = false;
@@ -64,121 +62,98 @@ export class HaThemeSettings extends LitElement {
? this.hass.themes.default_dark_theme || this.hass.themes.default_theme
: this.hass.themes.default_theme;
const showDarkMode =
curTheme === HOME_ASSISTANT_THEME ||
(curThemeIsUseDefault &&
this.hass.themes.default_dark_theme &&
this.hass.themes.default_theme) ||
this._supportsModeSelection(curTheme);
return html`
<ha-list-base>
<ha-settings-row .narrow=${this.narrow} ?empty=${!this.showThemePicker}>
${
this.heading
? html`<span slot="heading">${this.heading}</span>`
: nothing
}
${
this.description
? html`<span slot="description">${this.description}</span>`
: nothing
}
${
this.showThemePicker
? html`
<ha-list-item-base>
${
this.heading
? html`<span slot="headline">${this.heading}</span>`
: nothing
}
${
this.description
? html`<span slot="supporting-text"
>${this.description}</span
>`
: nothing
}
<ha-theme-picker
slot="end"
.label=${this.labels?.theme}
.noThemeLabel=${this.labels?.noTheme}
.value=${themeSettings?.theme || undefined}
.disabled=${this.themePickerDisabled}
?include-default=${this.includeDefault}
@value-changed=${this._handleThemeSelection}
></ha-theme-picker>
</ha-list-item-base>
<ha-theme-picker
.label=${this.labels?.theme}
.noThemeLabel=${this.labels?.noTheme}
.value=${themeSettings?.theme || undefined}
.disabled=${this.themePickerDisabled}
?include-default=${this.includeDefault}
@value-changed=${this._handleThemeSelection}
></ha-theme-picker>
`
: nothing
}
${
showDarkMode
? html`
<ha-list-item-base>
<span slot="headline"
>${this.labels?.mode ?? "Theme mode"}</span
>
<ha-radio-group
slot="end"
@change=${this._handleDarkMode}
name="dark_mode"
.value=${
themeSettings?.dark === undefined
? "auto"
: themeSettings.dark
? "dark"
: "light"
}
orientation="horizontal"
>
<ha-radio-option value="auto">
${this.labels?.autoMode ?? "Auto"}
</ha-radio-option>
<ha-radio-option value="light">
${this.labels?.lightMode ?? "Light"}
</ha-radio-option>
<ha-radio-option value="dark">
${this.labels?.darkMode ?? "Dark"}
</ha-radio-option>
</ha-radio-group>
</ha-list-item-base>
`
: nothing
}
${
curTheme === HOME_ASSISTANT_THEME
? html`
<ha-list-item-base>
<span slot="headline"
>${this.labels?.colors ?? "Custom colors"}</span
>
<div slot="end" class="color-pickers">
<ha-input
.value=${themeSettings?.primaryColor || DefaultPrimaryColor}
type="color"
.label=${this.labels?.primaryColor ?? "Primary color"}
.name=${"primaryColor"}
@change=${this._handleColorChange}
></ha-input>
<ha-input
.value=${themeSettings?.accentColor || DefaultAccentColor}
type="color"
.label=${this.labels?.accentColor ?? "Accent color"}
.name=${"accentColor"}
@change=${this._handleColorChange}
></ha-input>
</div>
</ha-list-item-base>
${
themeSettings?.primaryColor || themeSettings?.accentColor
? html`
<div class="reset-row">
<ha-button
appearance="plain"
size="s"
@click=${this._resetColors}
>
${this.labels?.reset ?? "Reset"}
</ha-button>
</div>
`
: nothing
</ha-settings-row>
${
curTheme === HOME_ASSISTANT_THEME ||
(curThemeIsUseDefault &&
this.hass.themes.default_dark_theme &&
this.hass.themes.default_theme) ||
this._supportsModeSelection(curTheme)
? html`<div class="inputs">
<ha-radio-group
@change=${this._handleDarkMode}
name="dark_mode"
.ariaLabel=${this.labels?.mode ?? "Theme mode"}
.value=${
themeSettings?.dark === undefined
? "auto"
: themeSettings.dark
? "dark"
: "light"
}
`
: nothing
}
</ha-list-base>
orientation="horizontal"
>
<ha-radio-option value="auto">
${this.labels?.autoMode ?? "Auto"}
</ha-radio-option>
<ha-radio-option value="light">
${this.labels?.lightMode ?? "Light"}
</ha-radio-option>
<ha-radio-option value="dark">
${this.labels?.darkMode ?? "Dark"}
</ha-radio-option>
</ha-radio-group>
${
curTheme === HOME_ASSISTANT_THEME
? html`<div class="color-pickers">
<ha-input
.value=${themeSettings?.primaryColor || DefaultPrimaryColor}
type="color"
.label=${this.labels?.primaryColor ?? "Primary color"}
.name=${"primaryColor"}
@change=${this._handleColorChange}
></ha-input>
<ha-input
.value=${themeSettings?.accentColor || DefaultAccentColor}
type="color"
.label=${this.labels?.accentColor ?? "Accent color"}
.name=${"accentColor"}
@change=${this._handleColorChange}
></ha-input>
${
themeSettings?.primaryColor ||
themeSettings?.accentColor
? html` <ha-button
appearance="plain"
size="s"
@click=${this._resetColors}
>
${this.labels?.reset ?? "Reset"}
</ha-button>`
: nothing
}
</div>`
: nothing
}
</div>`
: nothing
}
`;
}
@@ -256,35 +231,32 @@ export class HaThemeSettings extends LitElement {
a {
color: var(--primary-color);
}
ha-list-base {
--ha-row-item-padding-block: var(--ha-space-2);
.inputs {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
margin: 0 var(--ha-space-3);
}
ha-theme-picker {
min-width: 150px;
ha-radio-group {
display: flex;
justify-content: center;
margin-inline-end: var(--ha-space-3);
}
.color-pickers {
display: flex;
gap: var(--ha-space-2);
}
.color-pickers ha-input {
min-width: 150px;
flex: 1;
}
:host([narrow]) ha-list-item-base::part(base) {
flex-direction: column;
align-items: flex-start;
}
:host([narrow]) ha-list-item-base::part(end) {
width: 100%;
}
:host([narrow]) .color-pickers {
width: 100%;
}
.reset-row {
display: flex;
justify-content: flex-end;
padding-inline-end: var(--ha-space-4);
padding-bottom: var(--ha-space-4);
align-items: center;
flex-grow: 1;
}
ha-input {
min-width: 75px;
flex-grow: 1;
margin: 0 var(--ha-space-1);
}
ha-theme-picker {
display: block;
width: 100%;
}
`;
}
@@ -564,73 +564,75 @@ export class HaTargetPickerItemRow extends LitElement {
this.primaryEntitiesOnly
);
let referencedAreas = entries.referenced_areas;
const hiddenAreaIds: string[] = [];
if (this.type === "floor" || this.type === "label") {
referencedAreas = referencedAreas.filter((area_id) => {
const area = this.hass.areas[area_id];
// Absent from the registry is not a filter decision: drop the id
// without marking it hidden, so entities targeted through their
// own area or label are not dropped along with it.
if (!area) {
entries.referenced_areas = entries.referenced_areas.filter(
(area_id) => {
const area = this.hass.areas[area_id];
// Absent from the registry is not a filter decision: drop the id
// without marking it hidden, so entities targeted through their
// own area or label are not dropped along with it.
if (!area) {
return false;
}
if (
(this.type === "floor" || area.labels.includes(this.itemId)) &&
areaMeetsFilter(
area,
this.hass.devices,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter,
!this.primaryEntitiesOnly
)
) {
return true;
}
hiddenAreaIds.push(area_id);
return false;
}
if (
(this.type === "floor" || area.labels.includes(this.itemId)) &&
areaMeetsFilter(
area,
this.hass.devices,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter,
!this.primaryEntitiesOnly
)
) {
return true;
}
hiddenAreaIds.push(area_id);
return false;
});
);
}
let referencedDevices = entries.referenced_devices;
const hiddenDeviceIds: string[] = [];
if (
this.type === "floor" ||
this.type === "area" ||
this.type === "label"
) {
referencedDevices = referencedDevices.filter((device_id) => {
const device = this.hass.devices[device_id];
if (!device) {
entries.referenced_devices = entries.referenced_devices.filter(
(device_id) => {
const device = this.hass.devices[device_id];
if (!device) {
return false;
}
if (
!hiddenAreaIds.includes(device.area_id || "") &&
deviceMeetsFilter(
device,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter,
!this.primaryEntitiesOnly
)
) {
return true;
}
hiddenDeviceIds.push(device_id);
return false;
}
if (
!hiddenAreaIds.includes(device.area_id || "") &&
deviceMeetsFilter(
device,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter,
!this.primaryEntitiesOnly
)
) {
return true;
}
hiddenDeviceIds.push(device_id);
return false;
});
);
}
const referencedEntities = entries.referenced_entities.filter(
entries.referenced_entities = entries.referenced_entities.filter(
(entity_id) => {
const entity = this.hass.entities[entity_id];
// Core can reference entities that are absent from the display
@@ -645,9 +647,9 @@ export class HaTargetPickerItemRow extends LitElement {
(this.type === "area" && entity.area_id === this.itemId) ||
(this.type === "floor" &&
entity.area_id &&
referencedAreas.includes(entity.area_id)) ||
entries.referenced_areas.includes(entity.area_id)) ||
(this.type === "label" && entity.labels.includes(this.itemId)) ||
referencedDevices.includes(entity.device_id || "")
entries.referenced_devices.includes(entity.device_id || "")
) {
return entityRegMeetsFilter(
entity,
@@ -662,12 +664,7 @@ export class HaTargetPickerItemRow extends LitElement {
}
);
this._entries = {
...entries,
referenced_areas: referencedAreas,
referenced_devices: referencedDevices,
referenced_entities: referencedEntities,
};
this._entries = entries;
} catch (e) {
// eslint-disable-next-line no-console
console.error("Failed to extract target", e);
+33 -50
View File
@@ -1553,8 +1553,7 @@ export const formatConsumptionShort = (
hass: HomeAssistant,
consumption: number | null,
unit: string,
targetUnit?: string,
displayPrecision?: number
targetUnit?: string
): string => {
const units = ["Wh", "kWh", "MWh", "GWh", "TWh"];
let pickedUnit = unit;
@@ -1584,19 +1583,10 @@ export const formatConsumptionShort = (
pickedUnit = units[unitIndex];
}
return (
formatNumber(
val,
hass.locale,
displayPrecision !== undefined && pickedUnit === unit
? {
minimumFractionDigits: displayPrecision,
maximumFractionDigits: displayPrecision,
}
: {
maximumFractionDigits:
Math.abs(val) < 10 ? 2 : Math.abs(val) < 100 ? 1 : 0,
}
) +
formatNumber(val, hass.locale, {
maximumFractionDigits:
Math.abs(val) < 10 ? 2 : Math.abs(val) < 100 ? 1 : 0,
}) +
" " +
pickedUnit
);
@@ -1919,13 +1909,20 @@ export const downloadEnergyData = (
const device_consumption_water = energyData.prefs.device_consumption_water;
const stats = energyData.state.stats;
interface CsvRow {
id: string;
type: string;
unit: string;
data: StatisticValue[];
}
const rows: CsvRow[] = [];
const timeSet = new Set<number>();
Object.values(stats).forEach((stat) => {
stat.forEach((datapoint) => {
timeSet.add(datapoint.start);
});
});
const times = Array.from(timeSet).sort();
const headers =
"entity_id,type,unit," +
times.map((t) => new Date(t).toISOString()).join(",") +
"\n";
const csv: string[] = [];
csv[0] = headers;
const processCsvRow = function (
id: string,
@@ -1933,7 +1930,20 @@ export const downloadEnergyData = (
unit: string,
data: StatisticValue[]
) {
rows.push({ id, type, unit, data });
let n = 0;
const row: string[] = [];
row.push(id);
row.push(type);
row.push(unit.normalize("NFKD"));
times.forEach((t) => {
if (n < data.length && data[n].start === t) {
row.push((data[n].change ?? "").toString());
n++;
} else {
row.push("");
}
});
csv.push(row.join(",") + "\n");
};
const processStat = function (stat: string, type: string, unit: string) {
@@ -2180,33 +2190,6 @@ export const downloadEnergyData = (
);
}
const timeSet = new Set<number>();
rows.forEach((row) => {
row.data.forEach((datapoint) => {
timeSet.add(datapoint.start);
});
});
const times = Array.from(timeSet).sort();
const csv: string[] = [
"entity_id,type,unit," +
times.map((t) => new Date(t).toISOString()).join(",") +
"\n",
];
rows.forEach(({ id, type, unit, data }) => {
let n = 0;
const row: string[] = [id, type, unit.normalize("NFKD")];
times.forEach((t) => {
if (n < data.length && data[n].start === t) {
row.push((data[n].change ?? "").toString());
n++;
} else {
row.push("");
}
});
csv.push(row.join(",") + "\n");
});
const blob = new Blob(csv, {
type: "text/csv",
});
+3
View File
@@ -50,6 +50,9 @@ export const DOMAIN_ATTRIBUTES_UNITS = {
azimuth: "°",
elevation: "°",
},
vacuum: {
battery_level: "%",
},
valve: {
current_position: "%",
},
+4 -2
View File
@@ -40,7 +40,8 @@ export const searchPlaces = (
limit ? `&limit=${limit}` : ""
}${addressdetails ? "&addressdetails=1" : ""}&accept-language=${
hass.locale.language
}&email=abuse@home-assistant.io`
}&email=abuse@home-assistant.io`,
{ headers: { "User-Agent": `HomeAssistant/${hass.config.version}` } }
).then((res) => {
if (res.ok) {
return res.json();
@@ -58,7 +59,8 @@ export const reverseGeocode = (
location[1]
}&accept-language=${hass.locale.language}&zoom=${
zoom ?? 18
}&format=jsonv2&email=abuse@home-assistant.io`
}&format=jsonv2&email=abuse@home-assistant.io`,
{ headers: { "User-Agent": `HomeAssistant/${hass.config.version}` } }
).then((res) => {
if (res.ok) {
return res.json();
+9
View File
@@ -160,6 +160,15 @@ export const getRecorderEntityOptions = (
entity_id,
});
export const getStatisticIds = (
hass: Pick<HomeAssistant, "callWS">,
statistic_type?: "mean" | "sum"
) =>
hass.callWS<StatisticsMetaData[]>({
type: "recorder/list_statistic_ids",
statistic_type,
});
export const getStatisticMetadata = (
hass: HomeAssistant,
statistic_ids?: string[]
-21
View File
@@ -1,21 +0,0 @@
import { shareInFlightRequest } from "../common/util/share-in-flight-request";
import type { HomeAssistant } from "../types";
import type { StatisticsMetaData } from "./recorder";
type StatisticIdsType = "mean" | "sum";
type StatisticIdsApi = Pick<HomeAssistant, "callWS">;
export const getStatisticIds = (
hass: StatisticIdsApi,
statistic_type?: StatisticIdsType
) =>
shareInFlightRequest(
hass.callWS,
`recorder/list_statistic_ids:${statistic_type ?? "all"}`,
() =>
hass.callWS<StatisticsMetaData[]>({
type: "recorder/list_statistic_ids",
statistic_type,
})
);
-14
View File
@@ -88,20 +88,6 @@ export type Selector =
| UiTimeFormatSelector
| BackupLocationSelector;
type KeysOfUnion<T> = T extends T ? keyof T : never;
export type SelectorType = KeysOfUnion<Selector>;
type UnionMemberWithKey<U, K extends PropertyKey> = U extends unknown
? K extends keyof U
? U
: never
: never;
export type SelectorForType<T extends SelectorType> = UnionMemberWithKey<
Selector,
T
>;
export interface ActionSelector {
action: {
optionsInSidebar?: boolean;
+4 -10
View File
@@ -14,7 +14,6 @@ import {
import type { HaEntityPickerEntityFilterFunc } from "./entity/entity";
import type { EntityComboBoxItem } from "./entity/entity_picker";
import type { EntityRegistryDisplayEntry } from "./entity/entity_registry";
import { shareInFlightRequest } from "../common/util/share-in-flight-request";
export const TARGET_SEPARATOR = "________";
@@ -55,18 +54,13 @@ export const extractFromTarget = async (
target: HassServiceTarget,
expandGroup = false,
primaryEntitiesOnly = true
) => {
const request = {
type: "extract_from_target" as const,
) =>
callWS<ExtractFromTargetResult>({
type: "extract_from_target",
target,
expand_group: expandGroup,
primary_entities_only: primaryEntitiesOnly,
};
return shareInFlightRequest(callWS, JSON.stringify(request), () =>
callWS<ExtractFromTargetResult>(request)
);
};
});
export const getTargetEntityCount = (target?: HassServiceTarget): number => {
const tempTarget = {
-10
View File
@@ -526,16 +526,6 @@ export const fetchZwaveNetworkStatus = (
});
};
/** Node IDs of the nodes each node can reach directly, keyed by node ID. */
export const fetchZwaveNetworkNeighbors = (
hass: HomeAssistant,
entry_id: string
): Promise<Record<number, number[]>> =>
hass.callWS({
type: "zwave_js/network_neighbors",
entry_id,
});
export const fetchZwaveDataCollectionStatus = (
hass: HomeAssistant,
entry_id: string
@@ -116,7 +116,10 @@ export class HaMoreInfoViewVacuumSegmentMapping extends LitElement {
></ha-vacuum-segment-area-mapper>
<div class="footer">
<ha-button @click=${this._save} .disabled=${this._submitting}>
<ha-button
@click=${this._save}
.disabled=${!this._dirtyState?.isDirty || this._submitting}
>
${this.hass.localize("ui.common.save")}
</ha-button>
</div>
+1 -1
View File
@@ -235,7 +235,7 @@ class DialogShortcuts extends DialogMixin(LitElement) {
<ha-alert slot="footer">
${this._i18n.localize("ui.dialogs.shortcuts.enable_shortcuts_hint", {
user_profile: html`<a href="/profile/browser#shortcuts"
user_profile: html`<a href="/profile/general#shortcuts"
>${this._i18n.localize(
"ui.dialogs.shortcuts.enable_shortcuts_hint_user_profile"
)}</a
+22 -15
View File
@@ -1,9 +1,5 @@
import type { LitElement, PropertyValues } from "lit";
import type { UnsavedChangesGuard } from "../common/navigate";
import {
registerUnsavedChangesGuard,
unregisterUnsavedChangesGuard,
} from "../common/navigate";
import { isNavigationClick } from "../common/dom/is-navigation-click";
import type { Constructor } from "../types";
export const PreventUnsavedMixin = <T extends Constructor<LitElement>>(
@@ -13,34 +9,45 @@ export const PreventUnsavedMixin = <T extends Constructor<LitElement>>(
/** Provided by `DirtyStateProviderMixin`. */
declare isDirtyState: boolean;
private _handleUnload = (e: BeforeUnloadEvent) => e.preventDefault();
private _handleClick = async (e: MouseEvent) => {
// get the right target, otherwise the composedPath would return <home-assistant> in the new event
const target = e.composedPath()[0];
if (!isNavigationClick(e)) {
return;
}
private _unsavedChangesGuard: UnsavedChangesGuard = {
isDirty: () => this.isDirtyState,
prompt: () => this.promptDiscardChanges(),
const result = await this.promptDiscardChanges();
if (result) {
this._removeListeners();
if (target) {
const newEvent = new MouseEvent(e.type, e);
target.dispatchEvent(newEvent);
}
}
};
public connectedCallback(): void {
super.connectedCallback();
private _handleUnload = (e: BeforeUnloadEvent) => e.preventDefault();
registerUnsavedChangesGuard(this._unsavedChangesGuard);
private _removeListeners() {
window.removeEventListener("click", this._handleClick, true);
window.removeEventListener("beforeunload", this._handleUnload);
}
protected willUpdate(changedProperties: PropertyValues<this>): void {
super.willUpdate(changedProperties);
if (this.isDirtyState && this.isConnected) {
window.addEventListener("click", this._handleClick, true);
window.addEventListener("beforeunload", this._handleUnload);
} else {
window.removeEventListener("beforeunload", this._handleUnload);
this._removeListeners();
}
}
public disconnectedCallback(): void {
super.disconnectedCallback();
unregisterUnsavedChangesGuard(this._unsavedChangesGuard);
window.removeEventListener("beforeunload", this._handleUnload);
this._removeListeners();
}
protected async promptDiscardChanges(): Promise<boolean> {
+16 -81
View File
@@ -16,7 +16,6 @@ import {
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoize from "memoize-one";
import { firstWeekdayIndex } from "../../common/datetime/first_weekday";
import { resolveTimeZone } from "../../common/datetime/resolve-time-zone";
@@ -78,10 +77,6 @@ export class HAFullCalendar extends LitElement {
@property({ attribute: "add-fab", type: Boolean }) public addFab = false;
@property({ attribute: "add-fab-size" }) public addFabSize = "large";
@property({ attribute: "add-fab-style" }) public addFabStyle = "on_top";
@property({ attribute: false }) public events: CalendarEvent[] = [];
@property({ attribute: false }) public calendars: CalendarData[] = [];
@@ -177,34 +172,13 @@ export class HAFullCalendar extends LitElement {
</ha-icon-button-next>
</div>
<h1>${this.calendar.view.title}</h1>
<div>
<ha-button-toggle-group
.buttons=${viewToggleButtons}
.active=${this._activeView}
size="s"
no-wrap
@value-changed=${this._handleView}
></ha-button-toggle-group>
${
this.addFab &&
this._hasMutableCalendars &&
this.addFabStyle === "header"
? html`<ha-button
size="s"
class="fab-header"
aria-label=${this.hass.localize(
"ui.components.calendar.event.add"
)}
@click=${this._createEvent}
>
<ha-svg-icon
slot=""
.path=${mdiPlus}
></ha-svg-icon>
</ha-button>`
: nothing
}
</div>
<ha-button-toggle-group
.buttons=${viewToggleButtons}
.active=${this._activeView}
size="s"
no-wrap
@value-changed=${this._handleView}
></ha-button-toggle-group>
`
: html`
<div class="controls">
@@ -234,34 +208,13 @@ export class HAFullCalendar extends LitElement {
"ui.components.calendar.today"
)}</ha-button
>
<div>
<ha-button-toggle-group
.buttons=${viewToggleButtons}
.active=${this._activeView}
size="s"
no-wrap
@value-changed=${this._handleView}
></ha-button-toggle-group>
${
this.addFab &&
this._hasMutableCalendars &&
this.addFabStyle === "header"
? html`<ha-button
size="s"
class="fab-header"
aria-label=${this.hass.localize(
"ui.components.calendar.event.add"
)}
@click=${this._createEvent}
>
<ha-svg-icon
slot=""
.path=${mdiPlus}
></ha-svg-icon>
</ha-button>`
: nothing
}
</div>
<ha-button-toggle-group
.buttons=${viewToggleButtons}
.active=${this._activeView}
size="s"
no-wrap
@value-changed=${this._handleView}
></ha-button-toggle-group>
</div>
`
}
@@ -272,15 +225,8 @@ export class HAFullCalendar extends LitElement {
<div id="calendar"></div>
${
this.addFab &&
this._hasMutableCalendars &&
this.addFabStyle !== "header"
? html`<ha-button
size=${this.addFabSize.charAt(0)}
class=${classMap({ below: this.addFabStyle === "below" })}
slot="fab"
@click=${this._createEvent}
>
this.addFab && this._hasMutableCalendars
? html`<ha-button size="l" slot="fab" @click=${this._createEvent}>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.components.calendar.event.add")}
</ha-button>`
@@ -628,17 +574,6 @@ export class HAFullCalendar extends LitElement {
--ha-button-box-shadow: var(--ha-box-shadow-l);
}
ha-button.below[slot="fab"] {
position: relative;
margin-inline-start: auto;
padding-top: var(--ha-space-2);
left: 0;
right: 0;
bottom: 0;
top: 0;
--ha-button-box-shadow: none;
}
#calendar {
flex-grow: 1;
background-color: var(
@@ -1,5 +1,4 @@
import "@home-assistant/webawesome/dist/components/tree-item/tree-item";
import type WaTreeItem from "@home-assistant/webawesome/dist/components/tree-item/tree-item";
import "@home-assistant/webawesome/dist/components/tree/tree";
import type { WaSelectionChangeEvent } from "@home-assistant/webawesome/dist/events/selection-change";
import { consume, type ContextType } from "@lit/context";
@@ -361,7 +360,6 @@ export default class HaAutomationAddFromTarget extends LitElement {
? html`<ha-list-base>${floorAreas}</ha-list-base>`
: html`<wa-tree
@wa-selection-change=${this._handleSelectionChange}
@dblclick=${this._handleDoubleClick}
>${floorAreas}</wa-tree
>`
}`
@@ -1446,26 +1444,6 @@ export default class HaAutomationAddFromTarget extends LitElement {
this._toggleItem(targetId, false);
}
private _handleDoubleClick(ev: MouseEvent) {
// the expand button and non-selectable items already toggle on single click
if (
ev
.composedPath()
.some((el) => (el as HTMLElement).classList?.contains("expand-button"))
) {
return;
}
const item = (ev.target as HTMLElement).closest<
WaTreeItem & { target: string }
>("wa-tree-item");
if (!item || item.isLeaf || item.preventSelection) {
return;
}
// avoid leaving the label text selected by the double click
window.getSelection()?.removeAllRanges();
this._toggleItem(item.target, !item.expanded);
}
private async _loadConfigEntries() {
const configEntries = await getConfigEntries(this.hass);
this._configEntryLookup = Object.fromEntries(
@@ -896,15 +896,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
return;
}
this.yamlErrors = undefined;
resolve(true);
},
onClose: () => resolve(false),
onDiscard: () => {
this.yamlErrors = undefined;
this._markDirtyStateClean();
resolve(true);
},
onDiscard: () => resolve(true),
entityRegistryUpdate: this.entityRegistryUpdate,
entityRegistryEntry: this.registryEntry,
title: this.hass.localize(
@@ -57,7 +57,7 @@ export class CloudAccountOverview extends LitElement {
private _renderTopCard(): TemplateResult {
return html`
<ha-card outlined class="summary-card">
<ha-card outlined>
<div class="card-content">
<div
class="thank-you-header"
@@ -121,8 +121,8 @@ export class CloudAccountOverview extends LitElement {
<p class="muted">
${this.hass.localize("ui.panel.config.cloud.account.funding_note")}
</p>
${this._renderSubscriptionState()}
</div>
${this._renderSubscriptionState()}
</ha-card>
`;
}
@@ -132,7 +132,6 @@ export class CloudAccountOverview extends LitElement {
case "trial":
return html`
<ha-alert
class="subscription-alert"
alert-type="warning"
.title=${this.hass.localize(
"ui.panel.config.cloud.account.overview.trial_title"
@@ -157,7 +156,6 @@ export class CloudAccountOverview extends LitElement {
case "canceled":
return html`
<ha-alert
class="subscription-alert"
alert-type="warning"
.title=${this.hass.localize(
"ui.panel.config.cloud.account.overview.canceled_title"
@@ -183,7 +181,6 @@ export class CloudAccountOverview extends LitElement {
case "expired":
return html`
<ha-alert
class="subscription-alert"
alert-type="error"
.title=${this.hass.localize(
"ui.panel.config.cloud.account.overview.expired_title"
@@ -639,15 +636,11 @@ export class CloudAccountOverview extends LitElement {
width: auto;
color: var(--primary-text-color);
}
/* Prevent the embedded .subscription-alert ha-alert from bleeding outside the card */
.summary-card {
overflow: hidden;
}
.subscription-alert {
ha-alert {
display: block;
--ha-alert-padding: var(--ha-space-3);
margin-top: var(--ha-space-3);
}
.subscription-alert ha-button[slot="action"] {
ha-alert ha-button[slot="action"] {
width: max-content;
white-space: nowrap;
}
@@ -1,12 +1,12 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "./ha-icon-next";
import "./ha-svg-icon";
import "./item/ha-list-item-button";
import "./list/ha-list-nav";
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
import type { HomeAssistant } from "../types";
import "../../../components/ha-icon-next";
import "../../../components/ha-svg-icon";
import "../../../components/item/ha-list-item-button";
import "../../../components/list/ha-list-nav";
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
import type { HomeAssistant } from "../../../types";
@customElement("ha-config-navigation-list")
class HaConfigNavigationList extends LitElement {
@@ -28,7 +28,7 @@ import { showRestartDialog } from "../../../dialogs/restart/show-dialog-restart"
import "../../../layouts/hass-subpage";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import "../../../components/ha-config-navigation-list";
import "../components/ha-config-navigation-list";
import "../ha-config-section";
import { configSections } from "../config-sections";
@@ -14,7 +14,7 @@ import {
type RegisterChildPanelReady,
} from "../../../layouts/panel-ready";
import type { HomeAssistant } from "../../../types";
import "../../../components/ha-config-navigation-list";
import "../components/ha-config-navigation-list";
@customElement("ha-config-navigation")
class HaConfigNavigation extends LitElement {
@@ -201,7 +201,7 @@ export class DialogVacuumSegmentMapping
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${this._submitting}
.disabled=${this._submitting || !this.isDirtyState}
>
${this.hass.localize("ui.common.save")}
</ha-button>
@@ -12,15 +12,6 @@ function getLQIWidth(lqi: number): number {
return lqi > 200 ? 3 : lqi > 100 ? 2 : 1;
}
const RELATIONSHIP_PRIORITY: Record<string, number> = {
Parent: 0,
Sibling: 1,
NoneOfTheAbove: 2,
Child: 3,
PreviousChild: 5,
};
const UNKNOWN_RELATIONSHIP_PRIORITY = 4;
export function createZHANetworkChartData(
devices: ZHADevice[],
hass: HomeAssistant,
@@ -168,78 +159,38 @@ export function createZHANetworkChartData(
existingLinks.push(link);
}
});
}
});
// For every device whose routing table was empty (so it got no link
// above), independently compute its preferred fallback neighbor before
// creating any of these links. Doing this device-by-device while
// creating links (as before) meant whichever device the backend happens
// to list first "claims" the connection, and a sibling router listed
// later never gets to contribute its own — often better — choice,
// splitting the graph into islands depending on backend device order.
const fallbackTargets = new Map<string, { ieee: string; lqi: string }>();
devices.forEach((device) => {
const hasLink = links.some(
(link) => link.source === device.ieee || link.target === device.ieee
);
if (hasLink) {
return;
}
const neighbors: { ieee: string; lqi: string; relationship?: string }[] =
device.neighbors ?? [];
if (neighbors.length === 0) {
// If there are no neighbors, look for links from other devices
devices.forEach((d) => {
if (d.neighbors && d.neighbors.length > 0) {
const neighbor = d.neighbors.find((n) => n.ieee === device.ieee);
if (neighbor) {
neighbors.push({ ieee: d.ieee, lqi: neighbor.lqi });
} else if (existingLinks.length === 0) {
// If there are no links, create a link to the closest neighbor
const neighbors: { ieee: string; lqi: string }[] = device.neighbors ?? [];
if (neighbors.length === 0) {
// If there are no neighbors, look for links from other devices
devices.forEach((d) => {
if (d.neighbors && d.neighbors.length > 0) {
const neighbor = d.neighbors.find((n) => n.ieee === device.ieee);
if (neighbor) {
neighbors.push({ ieee: d.ieee, lqi: neighbor.lqi });
}
}
}
});
});
}
const closestNeighbor = neighbors.sort(
(a, b) => parseInt(b.lqi) - parseInt(a.lqi)
)[0];
if (closestNeighbor) {
links.push({
source: device.ieee,
target: closestNeighbor.ieee,
value: parseInt(closestNeighbor.lqi),
symbolSize: 5,
lineStyle: {
width: 1,
color: style.getPropertyValue("--dark-primary-color"),
type: "dotted",
},
ignoreForceLayout: true,
});
}
}
// Prefer a neighbor the device's own Zigbee stack reports as its
// parent: picking by raw LQI alone regularly favors a nearby child end
// device (e.g. a plug sitting right next to its router) over the
// router's actual uplink, which severs it from the rest of the mesh in
// the visualization even though the real network is connected.
const closestNeighbor = neighbors.sort((a, b) => {
const aPriority =
RELATIONSHIP_PRIORITY[a.relationship ?? ""] ??
UNKNOWN_RELATIONSHIP_PRIORITY;
const bPriority =
RELATIONSHIP_PRIORITY[b.relationship ?? ""] ??
UNKNOWN_RELATIONSHIP_PRIORITY;
const priorityDiff = aPriority - bPriority;
return priorityDiff !== 0
? priorityDiff
: parseInt(b.lqi) - parseInt(a.lqi);
})[0];
if (closestNeighbor) {
fallbackTargets.set(device.ieee, closestNeighbor);
}
});
const addedFallbackPairs = new Set<string>();
fallbackTargets.forEach((target, ieee) => {
const pairKey = [ieee, target.ieee].sort().join("|");
if (addedFallbackPairs.has(pairKey)) {
return;
}
addedFallbackPairs.add(pairKey);
links.push({
source: ieee,
target: target.ieee,
value: parseInt(target.lqi),
symbolSize: 5,
lineStyle: {
width: 1,
color: style.getPropertyValue("--dark-primary-color"),
type: "dotted",
},
ignoreForceLayout: true,
});
});
// Now set ignoreForceLayout to false for the best connection of each device
@@ -1,4 +1,3 @@
import { mdiSpiderWeb } from "@mdi/js";
import type {
CallbackDataParams,
TopLevelFormatterParams,
@@ -17,7 +16,6 @@ import type {
NetworkLink,
NetworkNode,
} from "../../../../../components/chart/ha-network-graph";
import "../../../../../components/ha-icon-button";
import "../../../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../../../components/input/ha-input-search";
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
@@ -27,7 +25,6 @@ import type {
ZWaveJSNodeStatus,
} from "../../../../../data/zwave_js";
import {
fetchZwaveNetworkNeighbors,
fetchZwaveNetworkStatus,
getNodeIdFromDevice,
NodeStatus,
@@ -36,7 +33,6 @@ import {
import "../../../../../layouts/hass-subpage";
import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin";
import type { HomeAssistant, Route } from "../../../../../types";
import { showToast } from "../../../../../util/toast";
@customElement("zwave_js-network-visualization")
export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
@@ -59,19 +55,11 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
@state() private _devices: Record<string, DeviceRegistryEntry> = {};
@state() private _neighbors?: Record<number, number[]>;
@state() private _showNeighbors = false;
@state() private _searchFilter = "";
// Route statistics reference repeaters by device registry ID
private _nodeIdsByDeviceId: Record<string, number> = {};
private _neighborLinks = new Set<string>();
private _loadingNeighbors = false;
public hassSubscribe() {
const subscriptions: Promise<UnsubscribeFunc>[] = [];
const devices: Record<number, DeviceRegistryEntry> = {};
@@ -101,32 +89,6 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
return subscriptions;
}
private async _toggleNeighbors() {
this._showNeighbors = !this._showNeighbors;
if (!this._showNeighbors || this._neighbors || this._loadingNeighbors) {
return;
}
// fetched on demand: reading neighbors turns the radio off briefly
this._loadingNeighbors = true;
try {
this._neighbors = await fetchZwaveNetworkNeighbors(
this.hass,
this.configEntryId
);
} catch (err: unknown) {
this._showNeighbors = false;
showToast(this, {
message:
(err as { message?: string }).message ??
this.hass.localize(
"ui.panel.config.zwave_js.visualization.neighbors_error"
),
});
} finally {
this._loadingNeighbors = false;
}
}
public connectedCallback() {
super.connectedCallback();
this._fetchNetworkStatus();
@@ -154,23 +116,13 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
.searchFilter=${this._searchFilter}
.data=${this._getNetworkData(
this._nodeStatuses,
this._nodeStatistics,
this._showNeighbors ? this._neighbors : undefined
this._nodeStatistics
)}
.searchableAttributes=${this._getSearchableAttributes}
.tooltipFormatter=${this._tooltipFormatter}
@chart-click=${this._handleChartClick}
>
${!this.narrow ? this._renderInputSearch("search") : nothing}
<ha-icon-button
slot="button"
class=${this._showNeighbors ? "active" : "inactive"}
.path=${mdiSpiderWeb}
.label=${this.hass.localize(
"ui.panel.config.zwave_js.visualization.toggle_neighbors"
)}
@click=${this._toggleNeighbors}
></ha-icon-button>
</ha-network-graph>
</hass-subpage>
`;
@@ -232,13 +184,6 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
sourceDevice?.name_by_user ?? sourceDevice?.name ?? source;
const targetName =
targetDevice?.name_by_user ?? targetDevice?.name ?? target;
if (this._neighborLinks.has(`${source}>${target}`)) {
return html`${sourceName}${targetName}<br /><b
>${this.hass.localize(
"ui.panel.config.zwave_js.visualization.neighbor"
)}</b
>`;
}
// links point away from the controller, so the route belongs to the target
const stats =
this._nodeStatistics[target] ?? this._nodeStatistics[source];
@@ -318,8 +263,7 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
private _getNetworkData = memoizeOne(
(
nodeStatuses: Record<number, ZWaveJSNodeStatus>,
nodeStatistics: Record<number, ZWaveJSNodeStatisticsUpdatedMessage>,
neighbors: Record<number, number[]> | undefined
nodeStatistics: Record<number, ZWaveJSNodeStatisticsUpdatedMessage>
): NetworkData => {
const style = getComputedStyle(this);
const nodes: NetworkNode[] = [];
@@ -476,49 +420,7 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
});
});
// Neighbors are the nodes a node can reach directly. They are symmetric
// and carry no signal information, so they fill in the mesh underneath
// the measured routes without overriding them.
const neighborLinks: NetworkLink[] = [];
const neighborKeys = new Set<string>();
Object.entries(neighbors ?? {}).forEach(([nodeId, neighborIds]) => {
neighborIds.forEach((neighborId) => {
const target = String(neighborId);
if (!nodeStatuses[neighborId] || target === nodeId) {
return;
}
const [a, b] = [nodeId, target].sort();
const key = `${a}>${b}`;
if (
neighborKeys.has(key) ||
links.some(
(link) =>
(link.source === nodeId && link.target === target) ||
(link.source === target && link.target === nodeId)
)
) {
return;
}
neighborKeys.add(key);
neighborLinks.push({
source: a,
target: b,
// equal values in both directions render the link without an arrow
value: 1,
reverseValue: 1,
lineStyle: {
width: 1,
color: style.getPropertyValue("--disabled-color"),
type: "dashed",
},
// neighbors are plentiful, let the routes shape the layout
ignoreForceLayout: true,
});
});
});
this._neighborLinks = neighborKeys;
return { nodes, links: [...neighborLinks, ...links], categories };
return { nodes, links, categories };
}
);
@@ -558,17 +460,6 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
ha-input-search {
flex: 1;
}
/* ha-chart-base can't style re-slotted buttons, so mirror its look */
ha-icon-button[slot="button"] {
background: var(--card-background-color);
border-radius: var(--ha-border-radius-sm);
--ha-icon-button-size: 32px;
color: var(--primary-color);
border: 1px solid var(--divider-color);
}
ha-icon-button[slot="button"].inactive {
color: var(--state-inactive-color);
}
`,
];
}
+13 -17
View File
@@ -1126,24 +1126,20 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
}
private async _confirmUnsavedChanged(): Promise<boolean> {
if (!this.isDirtyState) {
return true;
if (this.isDirtyState) {
return showConfirmationDialog(this, {
title: this.hass!.localize(
"ui.panel.config.scene.editor.unsaved_confirm_title"
),
text: this.hass!.localize(
"ui.panel.config.scene.editor.unsaved_confirm_text"
),
confirmText: this.hass!.localize("ui.common.leave"),
dismissText: this.hass!.localize("ui.common.stay"),
destructive: true,
});
}
const confirmed = await showConfirmationDialog(this, {
title: this.hass!.localize(
"ui.panel.config.scene.editor.unsaved_confirm_title"
),
text: this.hass!.localize(
"ui.panel.config.scene.editor.unsaved_confirm_text"
),
confirmText: this.hass!.localize("ui.common.leave"),
dismissText: this.hass!.localize("ui.common.stay"),
destructive: true,
});
if (confirmed) {
this._markDirtyStateClean();
}
return confirmed;
return true;
}
private async _duplicate() {
+1 -6
View File
@@ -804,15 +804,10 @@ export class HaScriptEditor extends SubscribeMixin(
return;
}
this.yamlErrors = undefined;
resolve(true);
},
onClose: () => resolve(false),
onDiscard: () => {
this.yamlErrors = undefined;
this._markDirtyStateClean();
resolve(true);
},
onDiscard: () => resolve(true),
entityRegistryUpdate: this.entityRegistryUpdate,
entityRegistryEntry: this.registryEntry,
title: this.hass.localize(
@@ -52,11 +52,11 @@ import type {
} from "../../../../data/recorder";
import {
clearStatistics,
getStatisticIds,
StatisticMeanType,
updateStatisticsIssues,
validateStatistics,
} from "../../../../data/recorder";
import { getStatisticIds } from "../../../../data/recorder_statistic_ids";
import {
apiContext,
internationalizationContext,
@@ -395,11 +395,9 @@ export class VoiceAssistantsExpose extends LitElement {
aliases: entry?.aliases || [],
};
}
if (result[entityId]) {
result[entityId].assistants_sortable_key = getAssistantsSortableKey(
result[entityId].assistants
);
}
result[entityId].assistants_sortable_key = getAssistantsSortableKey(
result[entityId].assistants
);
});
}
return Object.values(result);
+7 -1
View File
@@ -266,6 +266,7 @@ class HaPanelHistory extends LitElement {
.startTime=${this._startDate}
.endTime=${this._endDate}
.narrow=${this.narrow}
inside-labels
sync-charts
>
</state-history-charts>
@@ -809,7 +810,12 @@ class HaPanelHistory extends LitElement {
flex: 1;
min-width: 0;
overflow: hidden auto;
padding: 16px;
padding: 16px 8px;
}
/* Line the charts up with the toolbar when there are no axis labels. */
:host([narrow]) .results {
padding-inline: 16px;
}
.progress-wrapper {
@@ -188,7 +188,7 @@ class HuiHistoryChartCardFeature
}
if (this._coordinates && !this._coordinates.length) {
return html`
<div class="container no-history-found">
<div class="container">
<div class="info">
${this.hass!.localize(
"ui.components.history_charts.no_history_found"
@@ -280,14 +280,6 @@ class HuiHistoryChartCardFeature
pointer-events: none !important;
}
.no-history-found {
height: 100%;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
hui-graph-base {
width: 100%;
--accent-color: var(--feature-color);
@@ -217,34 +217,22 @@ export class HuiClockCardDigital extends LitElement {
"hour minute second"
"hour minute am-pm";
font-size: var(
--ha-clock-card-digital-font-size-small,
var(--ha-font-size-2xl)
);
font-size: 1.5rem;
font-weight: var(--ha-font-weight-medium);
line-height: 0.8;
direction: ltr;
}
.time-title + .time-parts {
font-size: var(
--ha-clock-card-digital-font-size-small,
var(--ha-font-size-2xl)
);
font-size: 1.5rem;
}
.time-parts.size-medium {
font-size: var(
--ha-clock-card-digital-font-size-medium,
calc(48px * var(--ha-font-size-scale))
);
font-size: 3rem;
}
.time-parts.size-large {
font-size: var(
--ha-clock-card-digital-font-size-large,
calc(64px * var(--ha-font-size-scale))
);
font-size: 4rem;
}
.time-parts.size-medium .time-part.second,
@@ -112,11 +112,7 @@ class HuiEnergyDistrubutionCard
) {
return true;
}
const oldHass = changedProps.get("hass");
if (!oldHass) {
return true;
}
const oldStates = oldHass.states;
const oldStates = changedProps.get("hass").states;
if (
this._data?.co2SignalEntity &&
this.hass.states[this._data.co2SignalEntity] !==
@@ -124,20 +120,6 @@ class HuiEnergyDistrubutionCard
) {
return true;
}
if (
this._data &&
energySourcesByType(this._data.prefs).gas?.some((source) => {
const statId = source.stat_energy_from;
return (
this.hass.entities[statId]?.display_precision !==
oldHass.entities[statId]?.display_precision ||
this.hass.states[statId]?.attributes.unit_of_measurement !==
oldHass.states[statId]?.attributes.unit_of_measurement
);
})
) {
return true;
}
if (this._data && periodIncludesNow(this._data)) {
const batteries = energySourcesByType(this._data.prefs).battery;
if (
@@ -180,21 +162,6 @@ class HuiEnergyDistrubutionCard
const hasBattery = types.battery !== undefined;
const hasGas = types.gas !== undefined;
const hasWater = types.water !== undefined;
const gasUnit = this._data.gasUnit;
const gasDisplayPrecisions = types.gas
?.filter(
(source) =>
this.hass.states[source.stat_energy_from]?.attributes
.unit_of_measurement === gasUnit
)
.map(
(source) =>
this.hass.entities[source.stat_energy_from]?.display_precision
)
.filter((precision): precision is number => precision !== undefined);
const gasDisplayPrecision = gasDisplayPrecisions?.length
? Math.max(...gasDisplayPrecisions)
: undefined;
const hasReturnToGrid =
types.grid?.some((source) => source.stat_energy_to) ?? false;
@@ -470,9 +437,7 @@ class HuiEnergyDistrubutionCard
${formatConsumptionShort(
this.hass,
gasUsage,
this._data.gasUnit,
undefined,
gasDisplayPrecision
this._data.gasUnit
)}
</div>
<svg width="80" height="30">
@@ -11,7 +11,6 @@ import "../../../../components/chart/ha-chart-base";
import "../../../../components/ha-card";
import type { EnergyData } from "../../../../data/energy";
import {
energySourcesByType,
getEnergyDataCollection,
validateEnergyCollectionKey,
} from "../../../../data/energy";
@@ -67,8 +66,6 @@ export class HuiEnergyGasGraphCard
@state() private _total?: number;
private _energyData?: EnergyData;
protected hassSubscribeRequiredHostProps = ["_config"];
public hassSubscribe(): UnsubscribeFunc[] {
@@ -91,68 +88,11 @@ export class HuiEnergyGasGraphCard
}
protected shouldUpdate(changedProps: PropertyValues<this>): boolean {
if (
return (
hasConfigChanged(this, changedProps) ||
changedProps.size > 1 ||
!changedProps.has("hass")
) {
return true;
}
const oldHass = changedProps.get("hass");
if (!oldHass) {
return true;
}
if (
this._energyData &&
energySourcesByType(this._energyData.prefs).gas?.some((source) => {
const statId = source.stat_energy_from;
return (
this.hass.entities[statId]?.display_precision !==
oldHass.entities[statId]?.display_precision ||
this.hass.states[statId]?.attributes.unit_of_measurement !==
oldHass.states[statId]?.attributes.unit_of_measurement
);
})
) {
return true;
}
return false;
}
private get _displayPrecision(): number | undefined {
if (!this._energyData) {
return undefined;
}
const gasDisplayPrecisions = energySourcesByType(this._energyData.prefs)
.gas?.filter(
(source) =>
this.hass.states[source.stat_energy_from]?.attributes
.unit_of_measurement === this._unit
)
.map(
(source) =>
this.hass.entities[source.stat_energy_from]?.display_precision
)
.filter((precision): precision is number => precision !== undefined);
return gasDisplayPrecisions?.length
? Math.max(...gasDisplayPrecisions)
: undefined;
}
private get _gasFormatOptions(): Intl.NumberFormatOptions | undefined {
const displayPrecision = this._displayPrecision;
return displayPrecision !== undefined
? {
minimumFractionDigits: displayPrecision,
maximumFractionDigits: displayPrecision,
}
: undefined;
);
}
protected render() {
@@ -171,11 +111,7 @@ export class HuiEnergyGasGraphCard
? html`<hui-energy-graph-chip
.tooltip=${this._formatTotal(this._total)}
>
${formatNumber(
this._total,
this.hass.locale,
this._gasFormatOptions
)}
${formatNumber(this._total, this.hass.locale)}
${this._unit}
</hui-energy-graph-chip>`
: nothing
@@ -199,7 +135,7 @@ export class HuiEnergyGasGraphCard
this._unit,
this._compareStart,
this._compareEnd,
this._displayPrecision ?? this._yAxisFractionDigits
this._yAxisFractionDigits
)}
chart-type="bar"
></ha-chart-base>
@@ -226,10 +162,7 @@ export class HuiEnergyGasGraphCard
private _formatTotal = (total: number) =>
this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_gas_graph.total_consumed",
{
num: formatNumber(total, this.hass.locale, this._gasFormatOptions),
unit: this._unit,
}
{ num: formatNumber(total, this.hass.locale), unit: this._unit }
);
private _createOptions = memoizeOne(
@@ -258,8 +191,6 @@ export class HuiEnergyGasGraphCard
);
private async _getStatistics(energyData: EnergyData): Promise<void> {
this._energyData = energyData;
const result = generateEnergyGasGraphData({
hass: this.hass,
energyData,
@@ -87,35 +87,11 @@ export class HuiEnergySourcesTableCard
}
protected shouldUpdate(changedProps: PropertyValues<this>): boolean {
if (
return (
hasConfigChanged(this, changedProps) ||
changedProps.size > 1 ||
!changedProps.has("hass")
) {
return true;
}
const oldHass = changedProps.get("hass");
if (!oldHass) {
return true;
}
if (
this._data &&
energySourcesByType(this._data.prefs).gas?.some((source) => {
const statId = source.stat_energy_from;
return (
this.hass.entities[statId]?.display_precision !==
oldHass.entities[statId]?.display_precision ||
this.hass.states[statId]?.attributes.unit_of_measurement !==
oldHass.states[statId]?.attributes.unit_of_measurement
);
})
) {
return true;
}
return false;
);
}
protected _renderRow(
@@ -132,20 +108,6 @@ export class HuiEnergySourcesTableCard
compare: boolean,
name?: string
) {
const displayPrecision =
type === "gas" &&
this.hass.states[statId]?.attributes.unit_of_measurement === energyUnit
? this.hass.entities[statId]?.display_precision
: undefined;
const formatOptions =
displayPrecision !== undefined
? {
minimumFractionDigits: displayPrecision,
maximumFractionDigits: displayPrecision,
}
: undefined;
return html`<tr
class="mdc-data-table__row ${classMap({
clickable: !isExternalStatistic(statId),
@@ -189,8 +151,7 @@ export class HuiEnergySourcesTableCard
${
compare
? html`<td class="mdc-data-table__cell mdc-data-table__cell--numeric">
${formatNumber(compareEnergy, this.hass.locale, formatOptions)}
${energyUnit}
${formatNumber(compareEnergy, this.hass.locale)} ${energyUnit}
</td>
${
showCosts
@@ -211,7 +172,7 @@ export class HuiEnergySourcesTableCard
: ""
}
<td class="mdc-data-table__cell mdc-data-table__cell--numeric">
${formatNumber(energy, this.hass.locale, formatOptions)} ${energyUnit}
${formatNumber(energy, this.hass.locale)} ${energyUnit}
</td>
${
showCosts
@@ -242,8 +203,7 @@ export class HuiEnergySourcesTableCard
showCosts: boolean,
compare: boolean,
bulletColor?: { border: string; background: string },
isFinalTotal?: boolean,
formatOptions?: Intl.NumberFormatOptions
isFinalTotal?: boolean
) {
return html` <tr
class="mdc-data-table__row ${bulletColor && !isFinalTotal ? "" : "total"}"
@@ -268,11 +228,7 @@ export class HuiEnergySourcesTableCard
${
compareEnergy === null
? ""
: `${formatNumber(
compareEnergy,
this.hass.locale,
formatOptions
)} ${energyUnit}`
: `${formatNumber(compareEnergy, this.hass.locale)} ${energyUnit}`
}
</td>
${
@@ -297,11 +253,7 @@ export class HuiEnergySourcesTableCard
${
energy === null
? ""
: `${formatNumber(
energy,
this.hass.locale,
formatOptions
)} ${energyUnit}`
: `${formatNumber(energy, this.hass.locale)} ${energyUnit}`
}
</td>
${
@@ -406,30 +358,6 @@ export class HuiEnergySourcesTableCard
water: this._data.waterUnit,
};
const gasDisplayPrecisions = types.gas
?.filter(
(source) =>
this.hass.states[source.stat_energy_from]?.attributes
.unit_of_measurement === units.gas
)
.map(
(source) =>
this.hass.entities[source.stat_energy_from]?.display_precision
)
.filter((precision): precision is number => precision !== undefined);
const gasDisplayPrecision = gasDisplayPrecisions?.length
? Math.max(...gasDisplayPrecisions)
: undefined;
const gasFormatOptions =
gasDisplayPrecision !== undefined
? {
minimumFractionDigits: gasDisplayPrecision,
maximumFractionDigits: gasDisplayPrecision,
}
: undefined;
const compare = this._data.statsCompare !== undefined;
const _extractStatData = (
@@ -553,9 +481,7 @@ export class HuiEnergySourcesTableCard
0
),
}
: undefined,
false,
type === "gas" ? gasFormatOptions : undefined
: undefined
)
: ""
}`;
+13 -12
View File
@@ -222,9 +222,6 @@ export class HuiCalendarCard
"has-title": !!this._config.title,
loading: loading,
})}
?add-fab=${this._config.show_add_event}
add-fab-style=${this._config.show_add_event ? (this._config.add_event_style ?? "below") : nothing}
add-fab-size=${this._config.show_add_event && this._config.add_event_style !== "header" ? (this._config.add_event_size ?? "small") : nothing}
.narrow=${this._narrow}
.events=${this._events}
.calendars=${this._calendars}
@@ -373,7 +370,11 @@ export class HuiCalendarCard
}
private _measureCard() {
this._narrow = this.offsetWidth < 870;
const card = this.shadowRoot!.querySelector("ha-card");
if (!card) {
return;
}
this._narrow = card.offsetWidth < 870;
}
private async _attachObserver(): Promise<void> {
@@ -382,7 +383,12 @@ export class HuiCalendarCard
debounce(() => this._measureCard(), 250, false)
);
}
this._resizeObserver.observe(this);
const card = this.shadowRoot!.querySelector("ha-card");
// If we show an error or warning there is no ha-card
if (!card) {
return;
}
this._resizeObserver.observe(card);
}
static styles = css`
@@ -408,9 +414,9 @@ export class HuiCalendarCard
ha-full-calendar {
--calendar-height: 400px;
display: flex;
display: block;
width: 100%;
height: 100%;
height: var(--calendar-height);
min-height: var(--calendar-height);
}
@@ -425,14 +431,9 @@ export class HuiCalendarCard
ha-full-calendar.is-grid.has-title,
ha-full-calendar.is-panel.has-title {
--header-height: calc(
var(--ha-card-header-font-size, var(--ha-font-size-2xl)) *
var(--ha-line-height-condensed) + 16px
);
--calendar-height: calc(
100% - var(--ha-card-header-font-size, var(--ha-font-size-2xl)) - 22px
);
height: calc(100% - var(--header-height));
}
.loading {
-3
View File
@@ -52,9 +52,6 @@ export interface CalendarCardConfig extends LovelaceCardConfig {
initial_view?: FullCalendarView;
title?: string;
theme?: string;
show_add_event?: boolean;
add_event_style?: "below" | "on_top" | "header";
add_event_size?: "small" | "medium" | "large";
}
export interface ConditionalCardConfig extends LovelaceCardConfig {
@@ -10,16 +10,12 @@ import {
optional,
string,
union,
literal,
} from "superstruct";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/entity/ha-entities-picker";
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 } from "../../../../types";
import type { CalendarCardConfig } from "../../cards/types";
import type { LovelaceCardEditor } from "../../types";
@@ -31,13 +27,6 @@ const cardConfigStruct = assign(
title: optional(union([string(), boolean()])),
initial_view: optional(string()),
theme: optional(string()),
show_add_event: optional(boolean()),
add_event_style: optional(
union([literal("below"), literal("on_top"), literal("header")])
),
add_event_size: optional(
union([literal("small"), literal("medium"), literal("large")])
),
entities: array(string()),
})
);
@@ -83,83 +72,7 @@ export class HuiCalendarCardEditor
],
},
{ name: "theme", required: false, selector: { theme: {} } },
{ name: "show_add_event", required: false, selector: { boolean: {} } },
{
name: "",
type: "grid",
schema: [
{
name: "add_event_style",
default: "below",
visible: { field: "show_add_event", operator: "eq", value: true },
required: false,
selector: {
select: {
options: [
{
value: "below",
label: localize(
"ui.panel.lovelace.editor.card.calendar.add_event.style.below"
),
},
{
value: "on_top",
label: localize(
"ui.panel.lovelace.editor.card.calendar.add_event.style.on_top"
),
},
{
value: "header",
label: localize(
"ui.panel.lovelace.editor.card.calendar.add_event.style.header"
),
},
],
mode: "dropdown",
},
},
},
{
name: "add_event_size",
default: "small",
visible: [
{ field: "show_add_event", operator: "eq", value: true },
{
field: "add_event_style",
operator: "not_eq",
value: "header",
},
],
required: false,
selector: {
select: {
options: [
{
value: "small",
label: localize(
"ui.panel.lovelace.editor.card.calendar.add_event.size.s"
),
},
{
value: "medium",
label: localize(
"ui.panel.lovelace.editor.card.calendar.add_event.size.m"
),
},
{
value: "large",
label: localize(
"ui.panel.lovelace.editor.card.calendar.add_event.size.l"
),
},
],
mode: "dropdown",
},
},
},
],
},
] as const satisfies readonly HaFormSchema[]
] as const
);
protected render() {
@@ -165,7 +165,7 @@ export class HuiViewBackgroundEditor extends LitElement {
}
private _currentBackgroundImage(): string | undefined {
const background = this._backgroundData(this.config);
const background = this._backgroundData(this._config);
return typeof background.image === "object"
? background.image.media_content_id
: background.image;
@@ -1,32 +0,0 @@
import { computeDomain } from "../../../../common/entity/compute_domain";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type {
PictureEntityCardConfig,
TileCardConfig,
} from "../../cards/types";
export const computeFavoriteCardConfig = (
entityId: string
): LovelaceCardConfig => {
// A camera tile would only show a thumbnail, so give the picture the room.
if (computeDomain(entityId) === "camera") {
return {
type: "picture-entity",
entity: entityId,
show_name: false,
show_state: false,
grid_options: {
columns: 6,
rows: 2,
},
} satisfies PictureEntityCardConfig;
}
return {
type: "tile",
entity: entityId,
// Favorites come from all over the home, so name the area.
state_content: ["state", "area_name"],
show_entity_picture: true,
} satisfies TileCardConfig;
};
@@ -34,7 +34,6 @@ import type {
TileCardConfig,
UpdatesCardConfig,
} from "../../cards/types";
import { computeFavoriteCardConfig } from "../helpers/favorite-cards";
import {
LARGE_SCREEN_CONDITION,
SMALL_SCREEN_CONDITION,
@@ -272,7 +271,15 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
column_span: maxColumns,
cards: [
favoritesHeadingCard,
...favoriteEntities.map(computeFavoriteCardConfig),
...favoriteEntities.map(
(entityId) =>
({
type: "tile",
entity: entityId,
state_content: ["state", "area_name"],
show_entity_picture: true,
}) satisfies TileCardConfig
),
],
};
}
@@ -4,9 +4,8 @@ import { isComponentLoaded } from "../../../../common/config/is_component_loaded
import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
import { getCommonControlsUsagePrediction } from "../../../../data/usage_prediction";
import type { HomeAssistant } from "../../../../types";
import type { HeadingCardConfig } from "../../cards/types";
import type { HeadingCardConfig, TileCardConfig } from "../../cards/types";
import type { Condition } from "../../common/validate-condition";
import { computeFavoriteCardConfig } from "../helpers/favorite-cards";
import type { LovelaceStrategyDependency } from "../types";
const DEFAULT_LIMIT = 8;
@@ -26,6 +25,13 @@ export interface CommonControlsSectionStrategyConfig {
title_visibilty?: Condition[];
}
const toTileCard = (entity: string): TileCardConfig => ({
type: "tile",
entity,
state_content: ["state", "area_name"],
show_entity_picture: true,
});
@customElement("common-controls-section-strategy")
export class CommonControlsSectionStrategy extends ReactiveElement {
static registryDependencies: readonly LovelaceStrategyDependency[] = [];
@@ -57,9 +63,7 @@ export class CommonControlsSectionStrategy extends ReactiveElement {
// Pinned entities already fill the section, skip the prediction call.
if (includedEntities.length >= limit) {
section.cards!.push(
...includedEntities.slice(0, limit).map(computeFavoriteCardConfig)
);
section.cards!.push(...includedEntities.slice(0, limit).map(toTileCard));
return section;
}
@@ -106,7 +110,7 @@ export class CommonControlsSectionStrategy extends ReactiveElement {
return section;
}
section.cards!.push(...entities.map(computeFavoriteCardConfig));
section.cards!.push(...entities.map(toTileCard));
return section;
}
}
@@ -15,8 +15,10 @@ import type { HomeAssistant } from "../../../types";
import type { TileCardConfig } from "../../lovelace/cards/types";
import { BINARY_STATE_ON } from "../../../common/const";
import { computeDomain } from "../../../common/entity/compute_domain";
import { getDeviceEntityDisplayLookup } from "../../../data/device/device_registry";
import { findBatteryChargingEntity } from "../../../data/entity/entity_registry";
import {
type EntityRegistryDisplayEntry,
findBatteryChargingEntity,
} from "../../../data/entity/entity_registry";
export interface MaintenanceViewStrategyConfig {
type: "maintenance";
@@ -35,42 +37,44 @@ export const maintenanceEntityFilters: EntityFilter[] = [
const LOW_BATTERY_THRESHOLD = 20;
const _deviceEntityLookup = memoizeOne((entities: HomeAssistant["entities"]) =>
getDeviceEntityDisplayLookup(Object.values(entities))
const _deviceEntities = memoizeOne(
(
deviceId: string,
entities: HomeAssistant["entities"]
): EntityRegistryDisplayEntry[] => {
const entries = Object.values(entities);
return entries.filter((entity) => entity.device_id === deviceId);
}
);
export const filterLowBatteryEntities = (
hass: HomeAssistant,
entityIds: string[]
): string[] => {
return entityIds.filter((entityId) => {
): string[] =>
entityIds.filter((entityId) => {
const state = hass.states[entityId]?.state ?? "";
if (computeDomain(entityId) === "binary_sensor") {
return state === BINARY_STATE_ON;
}
const stateValue = parseFloat(state);
if (isNaN(stateValue) || stateValue > LOW_BATTERY_THRESHOLD) {
return false;
}
const deviceId = hass.entities[entityId]?.device_id;
if (!deviceId) {
return true;
}
const entities = deviceId ? _deviceEntities(deviceId, hass.entities) : [];
const batteryChargingEntity = findBatteryChargingEntity(
hass.states,
_deviceEntityLookup(hass.entities)[deviceId] ?? []
entities
);
const batteryCharging = batteryChargingEntity
? hass.states[batteryChargingEntity.entity_id]
? hass.states[batteryChargingEntity?.entity_id]
: undefined;
return batteryCharging?.state !== "on";
if (batteryCharging && batteryCharging.state === "on") {
return false;
}
const stateValue = parseFloat(state);
return !isNaN(stateValue) && stateValue <= LOW_BATTERY_THRESHOLD;
});
};
export const filterUnavailableBatteryEntities = (
hass: HomeAssistant,
-9
View File
@@ -322,15 +322,6 @@ export const getMyRedirects = (): Redirects => ({
profile_security: {
redirect: "/profile/security",
},
profile_preferences: {
redirect: "/profile/preferences",
},
profile_localization: {
redirect: "/profile/localization",
},
profile_browser: {
redirect: "/profile/browser",
},
profile: {
redirect: "/profile",
},
+1 -1
View File
@@ -77,7 +77,7 @@ class HaPanelNotFound extends LitElement {
),
tap_action: {
action: "navigate",
navigation_path: "/profile/dashboard",
navigation_path: "/profile/general",
},
},
],
+21 -17
View File
@@ -1,34 +1,38 @@
import { customElement, property } from "lit/decorators";
import { mdiAccount, mdiLock } from "@mdi/js";
import type { PropertyValues } from "lit";
import type { RouterOptions } from "../../layouts/hass-router-page";
import { HassRouterPage } from "../../layouts/hass-router-page";
import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../types";
export const profileSections: PageNavigation[] = [
{
path: "/profile/general",
translationKey: "ui.panel.profile.tabs.general",
iconPath: mdiAccount,
},
{
path: "/profile/security",
translationKey: "ui.panel.profile.tabs.security",
iconPath: mdiLock,
},
];
@customElement("ha-panel-profile")
class HaPanelProfile extends HassRouterPage {
class HaPanelProfile extends SubscribeMixin(HassRouterPage) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
protected routerOptions: RouterOptions = {
defaultPage: "dashboard",
defaultPage: "general",
routes: {
dashboard: {
tag: "ha-profile-dashboard",
load: () => import("./ha-profile-dashboard"),
},
preferences: {
tag: "ha-profile-section-preferences",
load: () => import("./ha-profile-section-preferences"),
},
localization: {
tag: "ha-profile-section-localization",
load: () => import("./ha-profile-section-localization"),
},
browser: {
tag: "ha-profile-section-browser",
load: () => import("./ha-profile-section-browser"),
general: {
tag: "ha-profile-section-general",
load: () => import("./ha-profile-section-general"),
},
security: {
tag: "ha-profile-section-security",
-1
View File
@@ -81,7 +81,6 @@ export class HaPickThemeRow extends SubscribeMixin(LitElement) {
darkMode: this.hass.localize(
"ui.panel.profile.themes.dark_mode.dark"
),
colors: this.hass.localize("ui.panel.profile.themes.colors"),
primaryColor: this.hass.localize(
"ui.panel.profile.themes.primary_color"
),
-220
View File
@@ -1,220 +0,0 @@
import {
mdiAccount,
mdiCellphone,
mdiEarth,
mdiLock,
mdiMonitor,
mdiTune,
} from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-button";
import "../../components/ha-card";
import "../../components/ha-config-navigation-list";
import "../../components/ha-svg-icon";
import "./ha-pick-theme-row";
import { isExternal } from "../../data/external";
import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box";
import "../../layouts/hass-subpage";
import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant, Route } from "../../types";
@customElement("ha-profile-dashboard")
class HaProfileDashboard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public route!: Route;
protected render(): TemplateResult {
const pages: PageNavigation[] = [
{
path: "/profile/preferences",
name: this.hass.localize("ui.panel.profile.user_preferences_header"),
description: this.hass.localize(
"ui.panel.profile.user_preferences_description"
),
iconPath: mdiTune,
},
{
path: "/profile/localization",
name: this.hass.localize("ui.panel.profile.localization_header"),
description: this.hass.localize(
"ui.panel.profile.localization_description"
),
iconPath: mdiEarth,
},
{
path: "/profile/browser",
name: this.hass.localize(
isExternal
? "ui.panel.profile.mobile_app_settings"
: "ui.panel.profile.browser_settings"
),
description: this.hass.localize("ui.panel.profile.browser_description"),
iconPath: isExternal ? mdiCellphone : mdiMonitor,
},
{
path: "/profile/security",
name: this.hass.localize("ui.panel.profile.tabs.security"),
description: this.hass.localize(
"ui.panel.profile.security_description"
),
iconPath: mdiLock,
},
];
return html`
<hass-subpage
main-page
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("panel.profile")}
>
<div class="container">
<ha-card>
<div class="card-content">
<div class="heading">
<div class="icon">
<ha-svg-icon .path=${mdiAccount}></ha-svg-icon>
</div>
<div class="details">
${this.hass.user!.name}
${
this.hass.user!.is_owner
? html`<br /><small
>${this.hass.localize(
"ui.panel.profile.is_owner"
)}</small
>`
: ""
}
</div>
<ha-button
variant="danger"
appearance="plain"
@click=${this._handleLogOut}
>
${this.hass.localize("ui.panel.profile.logout")}
</ha-button>
</div>
</div>
</ha-card>
<ha-card
.header=${this.hass.localize("ui.panel.profile.themes.header")}
>
<ha-pick-theme-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-theme-row>
</ha-card>
<ha-card outlined>
<ha-config-navigation-list
.hass=${this.hass}
.pages=${pages}
has-secondary
.label=${this.hass.localize("panel.profile")}
></ha-config-navigation-list>
</ha-card>
</div>
</hass-subpage>
`;
}
private _handleLogOut() {
showConfirmationDialog(this, {
title: this.hass.localize("ui.panel.profile.logout_title"),
text: this.hass.localize("ui.panel.profile.logout_text"),
confirmText: this.hass.localize("ui.panel.profile.logout"),
confirm: () => fireEvent(this, "hass-logout"),
destructive: true,
});
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
}
.container {
padding: var(--ha-space-2) var(--ha-space-4)
calc(var(--ha-space-4) + var(--safe-area-inset-bottom));
}
ha-card {
margin: 0 auto var(--ha-space-4);
max-width: 600px;
}
ha-card[outlined] {
overflow: hidden;
}
.heading {
display: flex;
align-items: center;
column-gap: var(--ha-space-4);
}
.icon {
position: relative;
border-radius: var(--ha-border-radius-2xl);
width: var(--ha-space-10);
height: var(--ha-space-10);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
}
.icon::before {
display: block;
content: "";
position: absolute;
inset: 0;
background-color: var(--primary-color);
opacity: 0.2;
}
.icon ha-svg-icon {
color: var(--primary-color);
width: var(--ha-space-6);
height: var(--ha-space-6);
}
.details {
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed);
color: var(--primary-text-color);
}
.details small {
font-size: var(--ha-font-size-m);
color: var(--secondary-text-color);
}
ha-button {
margin-inline-start: auto;
flex-shrink: 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-profile-dashboard": HaProfileDashboard;
}
}
@@ -1,130 +0,0 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import "../../components/ha-card";
import "../../components/ha-md-list";
import { isExternal } from "../../data/external";
import "../../layouts/hass-subpage";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant, Route } from "../../types";
import { isMobileClient } from "../../util/is_mobile";
import "./ha-enable-shortcuts-row";
import "./ha-force-narrow-row";
import "./ha-push-notifications-row";
import "./ha-set-suspend-row";
import "./ha-set-vibrate-row";
@customElement("ha-profile-section-browser")
class HaProfileSectionBrowser extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public route!: Route;
protected render(): TemplateResult {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/profile"
.header=${this.hass.localize(
isExternal
? "ui.panel.profile.mobile_app_settings"
: "ui.panel.profile.browser_settings"
)}
>
<div class="container">
<ha-card
.header=${this.hass.localize(
isExternal
? "ui.panel.profile.mobile_app_settings"
: "ui.panel.profile.browser_settings"
)}
>
<div class="card-content">
${this.hass.localize("ui.panel.profile.client_settings_detail")}
</div>
<ha-md-list>
${
this.hass.dockedSidebar !== "auto" || !this.narrow
? html`
<ha-force-narrow-row
.hass=${this.hass}
></ha-force-narrow-row>
`
: ""
}
${
"vibrate" in navigator
? html`
<ha-set-vibrate-row
.hass=${this.hass}
></ha-set-vibrate-row>
`
: ""
}
${
!isExternal &&
isComponentLoaded(this.hass.config, "html5.notify")
? html`
<ha-push-notifications-row
.hass=${this.hass}
></ha-push-notifications-row>
`
: ""
}
<ha-set-suspend-row .hass=${this.hass}></ha-set-suspend-row>
${
!isMobileClient
? html`
<ha-enable-shortcuts-row
id="shortcuts"
.hass=${this.hass}
></ha-enable-shortcuts-row>
`
: ""
}
</ha-md-list>
</ha-card>
</div>
</hass-subpage>
`;
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
}
.container {
padding: var(--ha-space-2) var(--ha-space-4)
calc(var(--ha-space-4) + var(--safe-area-inset-bottom));
}
ha-card {
margin: 0 auto var(--ha-space-4);
max-width: 600px;
}
ha-md-list {
background: none;
padding-top: 0;
padding-bottom: 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-profile-section-browser": HaProfileSectionBrowser;
}
}
@@ -0,0 +1,305 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { fireEvent } from "../../common/dom/fire_event";
import { replaceCurrentUrl } from "../../common/navigate";
import { nextRender } from "../../common/util/render-status";
import "../../components/ha-button";
import "../../components/ha-card";
import "../../components/item/ha-row-item";
import { isExternal } from "../../data/external";
import type { CoreFrontendUserData } from "../../data/frontend";
import { subscribeFrontendUserData } from "../../data/frontend";
import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box";
import { showEditSidebarDialog } from "../../dialogs/sidebar/show-dialog-edit-sidebar";
import "../../layouts/hass-tabs-subpage";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant, Route } from "../../types";
import { isMobileClient } from "../../util/is_mobile";
import "./ha-enable-shortcuts-row";
import "./ha-entity-id-picker-row";
import "./ha-force-narrow-row";
import { profileSections } from "./ha-panel-profile";
import "./ha-pick-dashboard-row";
import "./ha-pick-date-format-row";
import "./ha-pick-first-weekday-row";
import "./ha-pick-language-row";
import "./ha-pick-number-format-row";
import "./ha-pick-theme-row";
import "./ha-pick-time-format-row";
import "./ha-pick-time-zone-row";
import "./ha-push-notifications-row";
import "./ha-set-suspend-row";
import "./ha-set-vibrate-row";
@customElement("ha-profile-section-general")
class HaProfileSectionGeneral extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@state() private _coreUserData?: CoreFrontendUserData | null;
@property({ attribute: false }) public route!: Route;
private _unsubCoreData?: Promise<UnsubscribeFunc>;
private _getCoreData() {
this._unsubCoreData = subscribeFrontendUserData(
this.hass.connection,
"core",
({ value }) => {
this._coreUserData = value;
}
);
}
public connectedCallback() {
super.connectedCallback();
if (this.hass) {
this._getCoreData();
}
this._scrollToHash();
}
public firstUpdated() {
if (!this._unsubCoreData) {
this._getCoreData();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
if (this._unsubCoreData) {
this._unsubCoreData.then((unsub) => unsub());
this._unsubCoreData = undefined;
}
}
private async _scrollToHash() {
await nextRender();
const hash = window.location.hash.substring(1);
if (hash) {
const element = this.shadowRoot!.getElementById(hash);
element?.scrollIntoView();
this._clearHash();
}
}
private _clearHash() {
replaceCurrentUrl(window.location.pathname);
}
protected render(): TemplateResult {
return html`
<hass-tabs-subpage
main-page
.hass=${this.hass}
.tabs=${profileSections}
.route=${this.route}
>
<div slot="title">${this.hass.localize("panel.profile")}</div>
<div class="content">
<ha-card .header=${this.hass.user!.name}>
<div class="card-content">
${this.hass.localize("ui.panel.profile.current_user", {
fullName: this.hass.user!.name,
})}
${
this.hass.user!.is_owner
? this.hass.localize("ui.panel.profile.is_owner")
: ""
}
</div>
<div class="card-actions">
<ha-button
variant="danger"
appearance="plain"
@click=${this._handleLogOut}
>
${this.hass.localize("ui.panel.profile.logout")}
</ha-button>
</div>
</ha-card>
<ha-card
.header=${this.hass.localize(
"ui.panel.profile.user_preferences_header"
)}
>
<div class="card-content">
${this.hass.localize("ui.panel.profile.user_preferences_detail")}
</div>
<ha-pick-language-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-language-row>
<ha-pick-theme-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-theme-row>
<ha-pick-dashboard-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-dashboard-row>
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.profile.customize_sidebar.header"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.profile.customize_sidebar.description"
)}</span
>
<ha-button
slot="end"
appearance="plain"
size="s"
@click=${this._customizeSidebar}
>
${this.hass.localize(
"ui.panel.profile.customize_sidebar.button"
)}
</ha-button>
</ha-row-item>
${
this.hass.user!.is_admin
? html`
<ha-entity-id-picker-row
.hass=${this.hass}
.coreUserData=${this._coreUserData}
></ha-entity-id-picker-row>
`
: nothing
}
</ha-card>
<ha-card
.header=${this.hass.localize(
"ui.panel.profile.localization_header"
)}
>
<div class="card-content">
${this.hass.localize("ui.panel.profile.localization_detail")}
</div>
<ha-pick-time-zone-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-time-zone-row>
<ha-pick-number-format-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-number-format-row>
<ha-pick-time-format-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-time-format-row>
<ha-pick-date-format-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-date-format-row>
<ha-pick-first-weekday-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-first-weekday-row>
</ha-card>
<ha-card
.header=${this.hass.localize(
isExternal
? "ui.panel.profile.mobile_app_settings"
: "ui.panel.profile.browser_settings"
)}
>
<div class="card-content">
${this.hass.localize("ui.panel.profile.client_settings_detail")}
</div>
${
this.hass.dockedSidebar !== "auto" || !this.narrow
? html`
<ha-force-narrow-row
.hass=${this.hass}
></ha-force-narrow-row>
`
: nothing
}
${
"vibrate" in navigator
? html`
<ha-set-vibrate-row .hass=${this.hass}></ha-set-vibrate-row>
`
: nothing
}
${
!isExternal && isComponentLoaded(this.hass.config, "html5.notify")
? html`
<ha-push-notifications-row
.hass=${this.hass}
></ha-push-notifications-row>
`
: nothing
}
<ha-set-suspend-row .hass=${this.hass}></ha-set-suspend-row>
${
!isMobileClient
? html`
<ha-enable-shortcuts-row
id="shortcuts"
.hass=${this.hass}
></ha-enable-shortcuts-row>
`
: nothing
}
</ha-card>
</div>
</hass-tabs-subpage>
`;
}
private _customizeSidebar() {
showEditSidebarDialog(this);
}
private _handleLogOut() {
showConfirmationDialog(this, {
title: this.hass.localize("ui.panel.profile.logout_title"),
text: this.hass.localize("ui.panel.profile.logout_text"),
confirmText: this.hass.localize("ui.panel.profile.logout"),
confirm: () => fireEvent(this, "hass-logout"),
destructive: true,
});
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
}
.content {
display: block;
max-width: 600px;
margin: 0 auto;
padding-bottom: var(--safe-area-inset-bottom);
}
.content > * {
display: block;
margin: 24px 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-profile-section-general": HaProfileSectionGeneral;
}
}
@@ -1,98 +0,0 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../components/ha-card";
import "../../layouts/hass-subpage";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant, Route } from "../../types";
import "./ha-pick-date-format-row";
import "./ha-pick-first-weekday-row";
import "./ha-pick-language-row";
import "./ha-pick-number-format-row";
import "./ha-pick-time-format-row";
import "./ha-pick-time-zone-row";
@customElement("ha-profile-section-localization")
class HaProfileSectionLocalization extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public route!: Route;
protected render(): TemplateResult {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/profile"
.header=${this.hass.localize("ui.panel.profile.localization_header")}
>
<div class="container">
<ha-card
.header=${this.hass.localize(
"ui.panel.profile.localization_header"
)}
>
<div class="card-content">
${this.hass.localize("ui.panel.profile.localization_detail")}
</div>
<ha-pick-language-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-language-row>
<ha-pick-time-zone-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-time-zone-row>
<ha-pick-number-format-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-number-format-row>
<ha-pick-time-format-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-time-format-row>
<ha-pick-date-format-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-date-format-row>
<ha-pick-first-weekday-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-first-weekday-row>
</ha-card>
</div>
</hass-subpage>
`;
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
}
.container {
padding: var(--ha-space-2) var(--ha-space-4)
calc(var(--ha-space-4) + var(--safe-area-inset-bottom));
}
ha-card {
margin: 0 auto var(--ha-space-4);
max-width: 600px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-profile-section-localization": HaProfileSectionLocalization;
}
}
@@ -1,162 +0,0 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../components/ha-button";
import "../../components/ha-card";
import "../../components/ha-md-list";
import "../../components/ha-md-list-item";
import type { CoreFrontendUserData } from "../../data/frontend";
import { subscribeFrontendUserData } from "../../data/frontend";
import { showEditSidebarDialog } from "../../dialogs/sidebar/show-dialog-edit-sidebar";
import "../../layouts/hass-subpage";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant, Route } from "../../types";
import "./ha-entity-id-picker-row";
import "./ha-pick-dashboard-row";
@customElement("ha-profile-section-preferences")
class HaProfileSectionPreferences extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public route!: Route;
@state() private _coreUserData?: CoreFrontendUserData | null;
private _unsubCoreData?: Promise<UnsubscribeFunc>;
private _getCoreData() {
this._unsubCoreData = subscribeFrontendUserData(
this.hass.connection,
"core",
({ value }) => {
this._coreUserData = value;
}
);
}
public connectedCallback() {
super.connectedCallback();
if (this.hass) {
this._getCoreData();
}
}
public firstUpdated() {
if (!this._unsubCoreData) {
this._getCoreData();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
if (this._unsubCoreData) {
this._unsubCoreData.then((unsub) => unsub());
this._unsubCoreData = undefined;
}
}
protected render(): TemplateResult {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/profile"
.header=${this.hass.localize(
"ui.panel.profile.user_preferences_header"
)}
>
<div class="container">
<ha-card
.header=${this.hass.localize(
"ui.panel.profile.user_preferences_header"
)}
>
<div class="card-content">
${this.hass.localize("ui.panel.profile.user_preferences_detail")}
</div>
<ha-pick-dashboard-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-dashboard-row>
<ha-md-list>
<ha-md-list-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.profile.customize_sidebar.header"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.profile.customize_sidebar.description"
)}</span
>
<ha-button
slot="end"
appearance="plain"
size="s"
@click=${this._customizeSidebar}
>
${this.hass.localize(
"ui.panel.profile.customize_sidebar.button"
)}
</ha-button>
</ha-md-list-item>
${
this.hass.user!.is_admin
? html`
<ha-entity-id-picker-row
.hass=${this.hass}
.coreUserData=${this._coreUserData}
></ha-entity-id-picker-row>
`
: ""
}
</ha-md-list>
</ha-card>
</div>
</hass-subpage>
`;
}
private _customizeSidebar() {
showEditSidebarDialog(this);
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
}
.container {
padding: var(--ha-space-2) var(--ha-space-4)
calc(var(--ha-space-4) + var(--safe-area-inset-bottom));
}
ha-card {
margin: 0 auto var(--ha-space-4);
max-width: 600px;
}
ha-md-list {
background: none;
padding-top: 0;
padding-bottom: 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-profile-section-preferences": HaProfileSectionPreferences;
}
}
@@ -1,7 +1,8 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../layouts/hass-subpage";
import "../../layouts/hass-tabs-subpage";
import { profileSections } from "./ha-panel-profile";
import type { RefreshToken } from "../../data/refresh_token";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant, Route } from "../../types";
@@ -31,12 +32,14 @@ class HaProfileSectionSecurity extends LitElement {
protected render(): TemplateResult {
return html`
<hass-subpage
<hass-tabs-subpage
main-page
.hass=${this.hass}
back-path="/profile"
.header=${this.hass.localize("ui.panel.profile.tabs.security")}
.tabs=${profileSections}
.route=${this.route}
>
<div class="container">
<div slot="title">${this.hass.localize("panel.profile")}</div>
<div class="content">
${
this.hass.user!.credentials.some(
(cred) => cred.auth_provider_type === "homeassistant"
@@ -67,7 +70,7 @@ class HaProfileSectionSecurity extends LitElement {
@hass-refresh-tokens=${this._refreshRefreshTokens}
></ha-long-lived-access-tokens-card>
</div>
</hass-subpage>
</hass-tabs-subpage>
`;
}
@@ -90,15 +93,21 @@ class HaProfileSectionSecurity extends LitElement {
-moz-user-select: initial;
}
.container {
padding: var(--ha-space-2) var(--ha-space-4)
calc(var(--ha-space-4) + var(--safe-area-inset-bottom));
.content {
display: block;
max-width: 600px;
margin: 0 auto;
padding-bottom: var(--safe-area-inset-bottom);
}
.container > * {
.content > * {
display: block;
margin: 0 auto var(--ha-space-4);
max-width: 600px;
margin: 24px 0;
}
.promo-advanced {
text-align: center;
color: var(--secondary-text-color);
}
`,
];
@@ -18,9 +18,11 @@ import type {
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { SecurityAlertEntityConfig } from "../../../data/frontend";
import type { HomeAssistant } from "../../../types";
import type { LogbookCardConfig } from "../../lovelace/cards/types";
import type {
LogbookCardConfig,
TileCardConfig,
} from "../../lovelace/cards/types";
import { computeAreaTileCardConfig } from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
import { computeFavoriteCardConfig } from "../../lovelace/strategies/helpers/favorite-cards";
import { computeSecurityAlertCardConfig } from "./security-alerts";
export interface SecurityViewStrategyConfig {
@@ -184,7 +186,15 @@ export class SecurityViewStrategy extends ReactiveElement {
),
heading_style: "title",
},
...favoriteEntities.map(computeFavoriteCardConfig),
...favoriteEntities.map(
(entityId) =>
({
type: "tile",
entity: entityId,
state_content: ["state", "area_name"],
show_entity_picture: true,
}) satisfies TileCardConfig
),
],
});
}
+7 -28
View File
@@ -8403,10 +8403,7 @@
"status": "Status",
"version": "Version",
"data_rate": "Data rate",
"area": "Area",
"neighbor": "In range of each other",
"toggle_neighbors": "Toggle neighbor connections. Loading neighbor data temporarily turns off the Z-Wave adapter.",
"neighbors_error": "Failed to load neighbor connections"
"area": "Area"
},
"node_status": {
"0": "Unknown",
@@ -9924,21 +9921,6 @@
"dayGridWeek": "Week",
"dayGridDay": "Day",
"listWeek": "List (7 days)"
},
"show_add_event": "Show add event button",
"add_event_style": "Add event button style",
"add_event_size": "Add event button size",
"add_event": {
"style": {
"header": "Header",
"below": "Below",
"on_top": "On top"
},
"size": {
"s": "Small",
"m": "Medium",
"l": "Large"
}
}
},
"conditional": {
@@ -11195,19 +11177,17 @@
},
"profile": {
"tabs": {
"general": "General",
"security": "Security"
},
"current_user": "You are currently logged in as {fullName}.",
"is_owner": "You are an owner.",
"user_preferences_header": "Appearance",
"user_preferences_description": "Default dashboard and sidebar",
"user_preferences_header": "User preferences",
"user_preferences_detail": "The following settings are tied to your account and will persist across all sessions and devices.",
"localization_header": "Localization",
"localization_description": "Language, time zone, date, time, and number formats",
"localization_detail": "These settings are tied to your account and control how dates, times, and numbers are displayed.",
"mobile_app_settings": "This mobile app",
"browser_settings": "This browser",
"browser_description": "Notifications, display, and shortcuts",
"security_description": "Password, multi-factor authentication, and access tokens",
"mobile_app_settings": "Mobile app settings",
"browser_settings": "Browser settings",
"client_settings_detail": "The following settings are local to this client only, and may reset to defaults on logout or when local data is cleared.",
"logout": "Log out",
"logout_title": "Log out?",
@@ -11322,7 +11302,6 @@
"migrate_success": "Theme settings migrated.",
"migrate_failed": "Failed to migrate theme settings.",
"theme_mode": "Theme mode",
"colors": "Custom colors",
"dark_mode": {
"auto": "Auto",
"light": "Light",
@@ -11330,7 +11309,7 @@
},
"primary_color": "Primary color",
"accent_color": "Accent color",
"reset": "Reset colors",
"reset": "Reset",
"use_default": "Use default theme"
},
"dashboard": {
-8
View File
@@ -13,11 +13,3 @@ declare module "echarts/lib/util/states" {
declare module "echarts/lib/chart/sankey/SankeyView" {
export { default } from "echarts/types/src/chart/sankey/SankeyView";
}
declare module "echarts/lib/util/number" {
export * from "echarts/types/src/util/number";
}
declare module "echarts/lib/scale/helper" {
export * from "echarts/types/src/scale/helper";
}
-106
View File
@@ -1,106 +0,0 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from "vitest";
import { addLatinLabels } from "../../build-scripts/gulp/map-labels.js";
// The rewrite keys on the exact `text-field` @versatiles/style emits; a bump
// that changed it would silently ship local-only names again.
const layer = (id, layout) => ({ id, type: "symbol", layout });
const STYLE = {
layers: [
layer("label-place-city", { "text-field": ["get", "name"] }),
layer("label-street-primary", {
"symbol-placement": "line",
"text-field": ["get", "name"],
}),
layer("label-motorway-shield", { "text-field": "{ref}" }),
{ id: "water", type: "fill" },
],
};
// Just enough of the expression language for the expressions built here.
const evaluate = (expression, properties) => {
if (!Array.isArray(expression)) {
return expression;
}
const [op, ...args] = expression;
switch (op) {
case "get":
return properties[args[0]];
case "has":
return args[0] in properties;
case "!":
return !evaluate(args[0], properties);
case "<":
return evaluate(args[0], properties) < evaluate(args[1], properties);
case "concat":
return args.map((arg) => evaluate(arg, properties)).join("");
case "format":
return args
.filter((_, i) => i % 2 === 0)
.map((arg) => evaluate(arg, properties))
.join("");
case "case":
for (let i = 0; i < args.length - 1; i += 2) {
if (evaluate(args[i], properties)) {
return evaluate(args[i + 1], properties);
}
}
return evaluate(args[args.length - 1], properties);
default:
throw new Error(`Unexpected operator ${op}`);
}
};
const textField = (style, id) =>
style.layers.find((l) => l.id === id).layout["text-field"];
describe("addLatinLabels", () => {
const style = addLatinLabels(STYLE);
const city = textField(style, "label-place-city");
const street = textField(style, "label-street-primary");
it("leaves Latin names alone", () => {
expect(evaluate(city, { name: "Köln", name_en: "Cologne" })).toBe("Köln");
expect(evaluate(city, { name: "1er arrondissement" })).toBe(
"1er arrondissement"
);
});
it("adds the English name under a non-Latin one", () => {
expect(evaluate(city, { name: "Москва", name_en: "Moscow" })).toBe(
"Москва\nMoscow"
);
expect(evaluate(city, { name: "بيروت", name_en: "Beirut" })).toBe(
"بيروت\nBeirut"
);
expect(evaluate(city, { name: "東京", name_en: "Tokyo" })).toBe(
"東京\nTokyo"
);
});
it("sets the English line smaller", () => {
expect(city.at(-1)).toEqual(
expect.arrayContaining([["get", "name_en"], { "font-scale": 0.8 }])
);
});
it("falls back to the local name without an English one", () => {
expect(evaluate(city, { name: "בני ברק" })).toBe("בני ברק");
});
it("keeps street labels on one line", () => {
expect(
evaluate(street, { name: "شارع الحمرا", name_en: "Hamra Street" })
).toBe("شارع الحمرا (Hamra Street)");
});
it("does not touch other layers", () => {
expect(textField(style, "label-motorway-shield")).toBe("{ref}");
expect(style.layers[3]).toEqual(STYLE.layers[3]);
});
});
-16
View File
@@ -18,10 +18,6 @@ const maplibreGL = vi.hoisted(() => vi.fn(() => maplibreLayer));
vi.mock("@maplibre/maplibre-gl-leaflet", () => ({ maplibreGL }));
const setRTLTextPlugin = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("maplibre-gl", () => ({ setRTLTextPlugin }));
const STYLE = {
version: 8,
sources: {},
@@ -113,18 +109,6 @@ describe("createBaseLayer", () => {
browser.retina = false;
});
it("registers the RTL text plugin once, lazily, from our own host", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false);
await createBaseLayer(leaflet, map, false);
expect(setRTLTextPlugin).toHaveBeenCalledOnce();
expect(setRTLTextPlugin).toHaveBeenCalledWith(
`${location.origin}/static/map/mapbox-gl-rtl-text.js`,
true
);
});
it("uses vector tiles when WebGL2 is available", async () => {
const createBaseLayer = await setWebGL2(true);
+3 -151
View File
@@ -1,16 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type {
NavigateOptions,
UnsavedChangesGuard,
} from "../../src/common/navigate";
import {
canGoBack,
goBack,
navigate,
registerUnsavedChangesGuard,
unregisterUnsavedChangesGuard,
} from "../../src/common/navigate";
import type { NavigateOptions } from "../../src/common/navigate";
import { canGoBack, goBack, navigate } from "../../src/common/navigate";
// navigate() closes open dialogs before touching history.
vi.mock("../../src/dialogs/make-dialog-manager", () => ({
@@ -63,145 +54,6 @@ describe("navigate", () => {
});
});
describe("unsaved changes guard", () => {
const registeredGuards: UnsavedChangesGuard[] = [];
// Registering through this keeps afterEach able to clean up the module-level
// registry, which outlives the test that filled it.
const trackGuard = <T extends UnsavedChangesGuard>(guard: T): T => {
registerUnsavedChangesGuard(guard);
registeredGuards.push(guard);
return guard;
};
const registerGuard = (isDirty: boolean, promptResult = true) =>
trackGuard({
isDirty: vi.fn(() => isDirty),
prompt: vi.fn(async () => promptResult),
});
// A guard whose prompt stays open until the test answers it.
const trackDeferredGuard = (isDirty: () => boolean) => {
let answer!: (value: boolean) => void;
const guard = trackGuard({
isDirty,
prompt: vi.fn(
() =>
new Promise<boolean>((resolve) => {
answer = resolve;
})
),
});
return { guard, answerPrompt: (value: boolean) => answer(value) };
};
beforeEach(() => {
setEntry("/config");
});
afterEach(() => {
registeredGuards.splice(0).forEach(unregisterUnsavedChangesGuard);
});
it("navigates without prompting when no guard is dirty", async () => {
const guard = registerGuard(false);
expect(await navigate("/config/areas")).toBe(true);
expect(window.location.pathname).toEqual("/config/areas");
expect(guard.prompt).not.toHaveBeenCalled();
});
it("prompts a dirty guard and navigates when confirmed", async () => {
const guard = registerGuard(true, true);
expect(await navigate("/config/areas")).toBe(true);
expect(guard.prompt).toHaveBeenCalledOnce();
expect(window.location.pathname).toEqual("/config/areas");
});
it("leaves history untouched when the prompt is declined", async () => {
const guard = registerGuard(true, false);
const listener = vi.fn();
window.addEventListener("location-changed", listener);
expect(await navigate("/config/areas")).toBe(false);
window.removeEventListener("location-changed", listener);
expect(guard.prompt).toHaveBeenCalledOnce();
expect(window.location.pathname).toEqual("/config");
expect(listener).not.toHaveBeenCalled();
});
it("does not prompt when navigating to the current path", async () => {
const guard = registerGuard(true, false);
expect(await navigate("/config")).toBe(true);
expect(guard.prompt).not.toHaveBeenCalled();
});
it("skips a pending prompt once no guard is dirty anymore", async () => {
let dirty = true;
const { guard, answerPrompt } = trackDeferredGuard(() => dirty);
const first = navigate("/config/areas");
dirty = false;
expect(await navigate("/config/devices/dashboard")).toBe(true);
expect(window.location.pathname).toEqual("/config/devices/dashboard");
expect(guard.prompt).toHaveBeenCalledOnce();
answerPrompt(true);
await first;
});
it("drops a navigation superseded while its prompt was open", async () => {
let dirty = true;
const { answerPrompt } = trackDeferredGuard(() => dirty);
const superseded = navigate("/config/areas");
dirty = false;
await navigate("/config/devices/dashboard");
answerPrompt(true);
expect(await superseded).toBe(false);
expect(window.location.pathname).toEqual("/config/devices/dashboard");
});
it("shares one pending prompt between concurrent navigations", async () => {
const { guard, answerPrompt } = trackDeferredGuard(() => true);
const first = navigate("/config/areas");
const second = navigate("/config/devices/dashboard");
answerPrompt(true);
await Promise.all([first, second]);
expect(guard.prompt).toHaveBeenCalledOnce();
});
it("stops prompting once the guard is unregistered", async () => {
const guard = registerGuard(true, false);
unregisterUnsavedChangesGuard(guard);
expect(await navigate("/config/areas")).toBe(true);
expect(guard.prompt).not.toHaveBeenCalled();
});
it("does not prompt for goBack's fallback navigation", async () => {
const guard = registerGuard(true, false);
await goBack("/config/cloud/account");
expect(window.location.pathname).toEqual("/config/cloud/account");
expect(guard.prompt).not.toHaveBeenCalled();
});
});
describe("goBack", () => {
beforeEach(() => {
setEntry("/config/cloud/remote");
@@ -1,106 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { shareInFlightRequest } from "../../../src/common/util/share-in-flight-request";
const deferred = <T>() => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
describe("shareInFlightRequest", () => {
it("shares an in-flight request for the same owner and key", async () => {
const owner = {};
const request = deferred<number>();
const fetcher = vi.fn(() => request.promise);
const first = shareInFlightRequest(owner, "resource:a", fetcher);
const second = shareInFlightRequest(owner, "resource:a", fetcher);
expect(first).toBe(second);
expect(fetcher).toHaveBeenCalledTimes(1);
request.resolve(42);
await expect(first).resolves.toBe(42);
await expect(second).resolves.toBe(42);
});
it("does not share requests with different keys", async () => {
const owner = {};
const fetcherA = vi.fn(async () => "a");
const fetcherB = vi.fn(async () => "b");
await Promise.all([
shareInFlightRequest(owner, "resource:a", fetcherA),
shareInFlightRequest(owner, "resource:b", fetcherB),
]);
expect(fetcherA).toHaveBeenCalledTimes(1);
expect(fetcherB).toHaveBeenCalledTimes(1);
});
it("does not share requests between different owners", async () => {
const firstOwner = {};
const secondOwner = {};
const firstFetcher = vi.fn(async () => "first");
const secondFetcher = vi.fn(async () => "second");
await Promise.all([
shareInFlightRequest(firstOwner, "resource:a", firstFetcher),
shareInFlightRequest(secondOwner, "resource:a", secondFetcher),
]);
expect(firstFetcher).toHaveBeenCalledTimes(1);
expect(secondFetcher).toHaveBeenCalledTimes(1);
});
it("forgets a request after it resolves", async () => {
const owner = {};
const fetcher = vi.fn(async () => 42);
await shareInFlightRequest(owner, "resource:a", fetcher);
await shareInFlightRequest(owner, "resource:a", fetcher);
expect(fetcher).toHaveBeenCalledTimes(2);
});
it("forgets a request after it rejects", async () => {
const owner = {};
const fetcher = vi
.fn<() => Promise<number>>()
.mockRejectedValueOnce(new Error("failed"))
.mockResolvedValueOnce(42);
await expect(
shareInFlightRequest(owner, "resource:a", fetcher)
).rejects.toThrow("failed");
await expect(
shareInFlightRequest(owner, "resource:a", fetcher)
).resolves.toBe(42);
expect(fetcher).toHaveBeenCalledTimes(2);
});
it("deep-freezes resolved results", async () => {
const owner = {};
const result = { items: ["a"] };
const fetcher = vi.fn(async () => result);
const shared = await shareInFlightRequest(owner, "resource:a", fetcher);
expect(shared).toBe(result);
expect(Object.isFrozen(shared)).toBe(true);
expect(Object.isFrozen((shared as typeof result).items)).toBe(true);
expect(() => {
(shared as typeof result).items.push("b");
}).toThrow();
});
});
@@ -1,103 +0,0 @@
import { describe, expect, it } from "vitest";
import { SVGRenderer } from "echarts/renderers";
import echarts from "../../../src/resources/echarts/echarts";
import { createYAxisPrecisionBounds } from "../../../src/components/chart/y-axis-fraction-digits";
// jsdom has no canvas, and zrender reaches for one to measure label text.
HTMLCanvasElement.prototype.getContext = (() => ({
measureText: () => ({ width: 10 }),
})) as any;
// The app registers the canvas renderer; jsdom needs the SVG one.
echarts.use([SVGRenderer]);
// The gap is opened by ECharts' own tick rounding, not by our helper, so these
// assert the extent ECharts actually renders rather than what we hand it.
const renderExtent = (
data: number[],
yAxis: Record<string, unknown>,
nextYAxis?: Record<string, unknown>
): [number, number] => {
const chart = echarts.init(null, null, {
ssr: true,
renderer: "svg",
width: 400,
height: 300,
});
const option = (axis: Record<string, unknown>) => ({
xAxis: { type: "category", data: data.map((_, index) => String(index)) },
yAxis: { type: "value", ...axis },
series: [{ type: "line", data }],
});
chart.setOption(option(yAxis));
if (nextYAxis) {
chart.setOption(option(nextYAxis), { replaceMerge: ["series"] });
}
// getModel() is internal, but it is the only way to read the rendered extent.
const extent = (chart as any)
.getModel()
.getComponent("yAxis")
.axis.scale.getExtent();
chart.dispose();
return extent;
};
const withGap = (includeZero = false, unit?: string) => ({
scale: !includeZero,
...createYAxisPrecisionBounds({
includeZero,
unit,
onFractionDigits: () => undefined,
}),
});
describe("Y-axis tick nudge", () => {
it("opens a gap under data that lands exactly on a tick", () => {
// The reported bug: a thermostat's states are quantized, so the minimum is
// an exact tick and the HVAC action band has nothing to fill.
expect(renderExtent([18, 21.2, 19], { scale: true })).toEqual([18, 21.5]);
expect(renderExtent([18, 21.2, 19], withGap())).toEqual([17.5, 21.5]);
});
it("leaves an axis with real headroom untouched", () => {
// Its minimum sits well clear of the tick below it, so the rounding it
// already gets is enough and the widened extent floors to the same place.
const data = [18.3, 21.2, 19.5];
expect(renderExtent(data, withGap())).toEqual(
renderExtent(data, { scale: true })
);
});
it("keeps a non-negative series anchored at zero", () => {
expect(renderExtent([0, 3500, 1200], withGap())[0]).toBe(0);
// Without the clamp the nudged minimum floors a whole interval below zero.
expect(
renderExtent([0, 3500, 1200], {
scale: true,
boundaryGap: [0.02, 0.02],
})[0]
).toBe(-1000);
});
it("keeps the window ECharts gives a constant series", () => {
expect(renderExtent([21, 21, 21], withGap())).toEqual(
renderExtent([21, 21, 21], { scale: true })
);
// A series flat at zero has no magnitude to expand by.
expect(renderExtent([0, 0, 0], withGap())).toEqual(
renderExtent([0, 0, 0], { scale: true })
);
});
it("does not round a percentage axis past 100", () => {
const battery = [20, 100, 60, 20, 80];
expect(renderExtent(battery, withGap())).toEqual([0, 120]);
expect(renderExtent(battery, withGap(false, "%"))).toEqual([0, 100]);
});
it("re-anchors at zero when a chart switches to a zero-anchored type", () => {
// setOption merges the Y axis, so a gap that is only emitted conditionally
// survives the switch and leaves the bars floating.
expect(renderExtent([20, 500, 300], withGap(), withGap(true))[0]).toBe(0);
});
});
@@ -72,25 +72,13 @@ describe("computeYAxisFractionDigits", () => {
});
describe("createYAxisPrecisionBounds", () => {
const makeBounds = (
options: Omit<
Parameters<typeof createYAxisPrecisionBounds>[0],
"onFractionDigits"
> = {}
) => {
const onFractionDigits = vi.fn();
return {
...createYAxisPrecisionBounds({ ...options, onFractionDigits }),
onFractionDigits,
};
};
it("computes digits from the visible extent when no bounds are set", () => {
const { min, max, onFractionDigits } = makeBounds();
const onFractionDigits = vi.fn();
const { min, max } = createYAxisPrecisionBounds({ onFractionDigits });
// Zoomed-out extent -> coarse precision, callbacks leave scaling to ECharts
expect(min({ min: 10, max: 100 })).toBeUndefined();
expect(max({ min: 10, max: 100 })).toBeUndefined();
expect(min({ min: 0, max: 100 })).toBeUndefined();
expect(max({ min: 0, max: 100 })).toBeUndefined();
expect(onFractionDigits).toHaveBeenLastCalledWith(0);
// Zoomed-in narrow extent -> more decimals so ticks stay distinct
@@ -99,7 +87,12 @@ describe("createYAxisPrecisionBounds", () => {
});
it("computes digits from numeric bounds and returns them unchanged", () => {
const { min, max, onFractionDigits } = makeBounds({ min: 1.85, max: 2 });
const onFractionDigits = vi.fn();
const { min, max } = createYAxisPrecisionBounds({
min: 1.85,
max: 2,
onFractionDigits,
});
// Fixed bounds pin the range, so the visible extent is ignored
expect(min({ min: 1.9, max: 1.95 })).toBe(1.85);
@@ -108,9 +101,11 @@ describe("createYAxisPrecisionBounds", () => {
});
it("resolves function bounds and passes their result through", () => {
const { min, max, onFractionDigits } = makeBounds({
const onFractionDigits = vi.fn();
const { min, max } = createYAxisPrecisionBounds({
min: ({ min: dataMin }) => dataMin - 1,
max: ({ max: dataMax }) => dataMax + 1,
onFractionDigits,
});
expect(min({ min: 10, max: 11 })).toBe(9);
@@ -120,7 +115,11 @@ describe("createYAxisPrecisionBounds", () => {
});
it("unions the extent with zero for anchored axes", () => {
const { min, onFractionDigits } = makeBounds({ includeZero: true });
const onFractionDigits = vi.fn();
const { min } = createYAxisPrecisionBounds({
includeZero: true,
onFractionDigits,
});
// Data sits at 20..25, but a bar axis renders from 0 -> coarse precision
min({ min: 20, max: 25 });
@@ -132,102 +131,10 @@ describe("createYAxisPrecisionBounds", () => {
});
it("does not over-pad when the visible extent collapses to noise", () => {
const { min, onFractionDigits } = makeBounds();
const onFractionDigits = vi.fn();
const { min } = createYAxisPrecisionBounds({ onFractionDigits });
min({ min: 0.3, max: 0.3 + 1e-15 });
expect(onFractionDigits).toHaveBeenLastCalledWith(1);
});
it("widens the extent so ECharts always rounds out a gap", () => {
const { min, max, boundaryGap, splitNumber } = makeBounds();
// The gap is applied by ECharts, so the bounds stay auto-scaled.
expect(boundaryGap).toEqual([0.02, 0.02]);
// Pinned rather than assumed, since the precision here is derived from it.
expect(splitNumber).toBe(5);
expect(min({ min: 18, max: 21.2 })).toBeUndefined();
expect(max({ min: 18, max: 21.2 })).toBeUndefined();
});
it("anchors at zero instead of widening a non-negative series below it", () => {
const { min, max } = makeBounds();
// Without this, ECharts floors the widened -0.0035 to a full interval below
// zero, inventing a negative region on a power chart.
expect(min({ min: 0, max: 3500 })).toBe(0);
expect(max({ min: 0, max: 3500 })).toBeUndefined();
// A minimum clear of the gap is left to ECharts.
expect(min({ min: 100, max: 3500 })).toBeUndefined();
});
it("anchors an all-negative series at zero from above", () => {
const { min, max } = makeBounds();
expect(max({ min: -3500, max: 0 })).toBe(0);
expect(min({ min: -3500, max: 0 })).toBeUndefined();
// Mixed-sign data is widened at both ends.
expect(min({ min: -20, max: 20 })).toBeUndefined();
expect(max({ min: -20, max: 20 })).toBeUndefined();
});
it("keeps the expanded window ECharts gives a constant series", () => {
const { min, max, onFractionDigits } = makeBounds();
// ECharts only expands a flat extent while its bounds are equal, so the gap
// would otherwise collapse this to 20.99998..21.00002 at five decimals.
expect(min({ min: 21, max: 21 })).toBe(10);
expect(max({ min: 21, max: 21 })).toBe(35);
expect(onFractionDigits).toHaveBeenLastCalledWith(0);
// A series flat at zero has no magnitude to expand by.
expect(min({ min: 0, max: 0 })).toBe(0);
expect(max({ min: 0, max: 0 })).toBe(1);
});
it("stops a percentage axis at 100 the way it stops at zero", () => {
const { max } = makeBounds({ unit: "%" });
// A battery that reaches full would otherwise round out to an axis
// labelled up to 120%.
expect(max({ min: 20, max: 100 })).toBe(100);
expect(max({ min: 20, max: 99 })).toBe(100);
// Well clear of the ceiling, so it still gets its gap.
expect(max({ min: 40, max: 60 })).toBeUndefined();
// Not every % sensor is bounded: power factor is signed and can read over
// 100, and a series already past the ceiling must be left alone.
expect(max({ min: -80, max: 140 })).toBeUndefined();
});
it("holds a constant percentage under the ceiling too", () => {
const { max } = makeBounds({ unit: "%" });
// A device left on the charger reads a flat 100%, and the expansion a flat
// series gets is a fraction of its magnitude, so it overshoots on its own.
expect(max({ min: 100, max: 100 })).toBe(100);
expect(max({ min: 80, max: 80 })).toBe(100);
// Far enough below that the expansion never reaches the ceiling.
expect(max({ min: 54, max: 54 })).toBe(90);
});
it("only treats a percentage as bounded", () => {
// The same extent without the unit is widened as usual.
expect(makeBounds().max({ min: 20, max: 100 })).toBeUndefined();
expect(
makeBounds({ unit: "W" }).max({ min: 20, max: 100 })
).toBeUndefined();
});
it("never widens a zero-anchored axis", () => {
const { min, max, boundaryGap } = makeBounds({ includeZero: true });
// Widening below zero would defeat ECharts' anchoring and float the bars.
expect(boundaryGap).toEqual([0, 0]);
expect(min({ min: 0, max: 3500 })).toBeUndefined();
expect(max({ min: 20, max: 20 })).toBeUndefined();
});
});
@@ -1,256 +0,0 @@
import { describe, expect, it } from "vitest";
import { computeInitialHaFormData } from "../../../src/components/ha-form/compute-initial-ha-form-data";
import type { Selector } from "../../../src/data/selector";
import type { HaFormSchema } from "../../../src/components/ha-form/types";
const requiredField = (selector: Selector): HaFormSchema => ({
name: "value",
required: true,
selector,
});
const optionalField = (selector: Selector): HaFormSchema => ({
name: "value",
required: false,
selector,
});
describe("computeInitialHaFormData", () => {
it("initializes required text selector with an empty string", () => {
expect(computeInitialHaFormData([requiredField({ text: {} })])).toEqual({
value: "",
});
});
it("initializes required multiple text selector with an empty array", () => {
expect(
computeInitialHaFormData([
requiredField({
text: {
multiple: true,
},
}),
])
).toEqual({
value: [],
});
});
it("initializes required object selector with an empty string", () => {
expect(computeInitialHaFormData([requiredField({ object: {} })])).toEqual({
value: "",
});
});
it("initializes required multiple object selector with an empty array", () => {
expect(
computeInitialHaFormData([
requiredField({
object: {
multiple: true,
},
}),
])
).toEqual({
value: [],
});
});
it("leaves a required single device class selector unset", () => {
expect(
computeInitialHaFormData([
requiredField({
device_class: {
domain: "sensor",
},
}),
])
).toEqual({});
});
it("initializes a required multiple device class selector with an empty array", () => {
expect(
computeInitialHaFormData([
requiredField({
device_class: {
domain: "sensor",
multiple: true,
},
}),
])
).toEqual({
value: [],
});
});
it("does not initialize optional text selectors", () => {
expect(
computeInitialHaFormData([
optionalField({ text: {} }),
{
...optionalField({
text: {
multiple: true,
},
}),
name: "multiple",
},
])
).toEqual({});
});
it("does not initialize optional object selectors", () => {
expect(
computeInitialHaFormData([
optionalField({ object: {} }),
{
...optionalField({
object: {
multiple: true,
},
}),
name: "multiple",
},
])
).toEqual({});
});
it("initializes a required constant selector without losing falsy values", () => {
expect(
computeInitialHaFormData([
requiredField({
constant: {
value: false,
},
}),
])
).toEqual({
value: false,
});
expect(
computeInitialHaFormData([
requiredField({
constant: {
value: 0,
},
}),
])
).toEqual({
value: 0,
});
});
it("initializes a required choose selector from a constant first choice", () => {
const schema = [
{
name: "match",
required: true,
selector: {
choose: {
choices: {
Disabled: {
selector: {
constant: {
value: "",
},
},
},
Enabled: {
selector: {
number: {},
},
},
},
},
},
},
] as const;
expect(computeInitialHaFormData(schema)).toEqual({
match: {
active_choice: "Disabled",
Disabled: "",
},
});
});
it("initializes a required choose selector from its child selector", () => {
const schema = [
{
name: "mode",
required: true,
selector: {
choose: {
choices: {
First: {
selector: {
text: {
multiple: true,
},
},
},
Second: {
selector: {
text: {},
},
},
},
},
},
},
] as const;
expect(computeInitialHaFormData(schema)).toEqual({
mode: {
active_choice: "First",
First: [],
},
});
});
it("throws for an unsupported required selector", () => {
expect(() =>
computeInitialHaFormData([
requiredField({
ui_action: {
default_action: "none",
},
}),
])
).toThrow("Selector ui_action not supported in initial form data");
});
it("omits a first choose child without an initial value", () => {
const schema = [
{
name: "mode",
required: true,
selector: {
choose: {
choices: {
First: {
selector: {
ui_action: {
default_action: "none",
},
},
},
Second: {
selector: {
text: {},
},
},
},
},
},
},
] as const;
expect(computeInitialHaFormData(schema)).toStrictEqual({
mode: {
active_choice: "First",
},
});
});
});
@@ -1,11 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ObjectSelector } from "../../../src/data/selector";
import type { HomeAssistant } from "../../../src/types";
import type { HaObjectSelector } from "../../../src/components/ha-selector/ha-selector-object";
import type {
FormDialogData,
FormDialogParams,
} from "../../../src/dialogs/form/show-form-dialog";
import type { FormDialogParams } from "../../../src/dialogs/form/show-form-dialog";
import "../../../src/components/ha-selector/ha-selector-object";
vi.mock("../../../src/components/ha-input-helper-text", () => {
@@ -55,23 +51,12 @@ const selectorConfig = {
const getInternals = (selector: HaObjectSelector) =>
selector as unknown as Record<string, unknown>;
interface ItemActionEvent {
stopPropagation: () => void;
currentTarget: {
item?: FormDialogData;
index?: number;
};
}
const mountSelector = async (
value: FormDialogData | FormDialogData[] | "",
config: ObjectSelector = selectorConfig
) => {
const mountSelector = async (value: Record<string, string>[]) => {
const selector = document.createElement(
"ha-selector-object"
) as HaObjectSelector;
selector.hass = hass;
selector.selector = config;
selector.selector = selectorConfig;
selector.value = value;
document.body.append(selector);
await selector.updateComplete;
@@ -81,8 +66,8 @@ const mountSelector = async (
const resolveFormDialog = async (
selector: HaObjectSelector,
action: "_addItem" | "_editItem",
result: FormDialogData | null,
item?: FormDialogData,
result: Record<string, string> | null,
item?: Record<string, string>,
index?: number
) => {
let params: FormDialogParams | undefined;
@@ -102,14 +87,15 @@ const resolveFormDialog = async (
);
});
const event: ItemActionEvent = {
const event = {
stopPropagation: vi.fn(),
currentTarget: { item, index },
};
const operation = (
getInternals(selector)[action] as (event: ItemActionEvent) => Promise<void>
getInternals(selector)[action] as (ev: typeof event) => Promise<void>
)(event);
await Promise.all([dialogShown, operation]);
await dialogShown;
await operation;
return params!;
};
@@ -119,105 +105,6 @@ afterEach(() => {
});
describe("ha-selector-object form dialog flow", () => {
it("initializes Add dialog data from the object field schema", async () => {
const selector = await mountSelector([], {
object: {
multiple: true,
fields: {
name: {
required: true,
selector: { text: {} },
},
states: {
required: true,
selector: {
text: {
multiple: true,
},
},
},
},
},
});
const params = await resolveFormDialog(selector, "_addItem", {
name: "A",
states: ["on"],
});
expect(params.data).toEqual({
name: "",
states: [],
});
});
it("leaves unsupported required fields unset in Add dialog data", async () => {
const selector = await mountSelector([], {
object: {
multiple: true,
fields: {
name: {
required: true,
selector: { text: {} },
},
tap_action: {
required: true,
selector: {
ui_action: {
default_action: "none",
},
},
},
},
},
});
const params = await resolveFormDialog(selector, "_addItem", {
name: "A",
tap_action: {
action: "none",
},
});
expect(params.data).toEqual({
name: "",
});
});
it("restores the initialized empty value after an add-delete round trip", async () => {
const selector = await mountSelector("", {
object: {
fields: {
name: {
selector: { text: {} },
},
},
},
});
const valueChanged = vi.fn();
selector.addEventListener("value-changed", valueChanged);
await resolveFormDialog(selector, "_addItem", { name: "A" });
expect(valueChanged.mock.calls[0][0].detail.value).toEqual({ name: "A" });
selector.value = valueChanged.mock.calls[0][0].detail.value;
await selector.updateComplete;
const event: ItemActionEvent = {
stopPropagation: vi.fn(),
currentTarget: {
index: 0,
},
};
(getInternals(selector)._deleteItem as (event: ItemActionEvent) => void)(
event
);
expect(valueChanged.mock.calls[1][0].detail.value).toBe("");
});
it("appends an item through the real Add flow", async () => {
const first = { name: "A" };
const selector = await mountSelector([first]);
@@ -173,31 +173,4 @@ describe("ha-target-picker-item-row target extraction", () => {
expect(entries!.referenced_devices).toEqual(["dev_1"]);
expect(entries!.referenced_entities).toEqual(["light.on_dev1"]);
});
it("does not mutate the extracted target result", async () => {
const result = extractResult({
referenced_areas: ["area_missing"],
referenced_devices: ["dev_missing"],
referenced_entities: ["light.missing"],
});
const entries = await extractedBy(
result,
{
areas: {},
devices: {},
entities: {},
},
{ type: "floor", itemId: "floor_1" }
);
expect(entries).toBeDefined();
expect(entries).not.toBe(result);
expect(entries!.referenced_areas).toEqual([]);
expect(entries!.referenced_devices).toEqual([]);
expect(entries!.referenced_entities).toEqual([]);
expect(result.referenced_areas).toEqual(["area_missing"]);
expect(result.referenced_devices).toEqual(["dev_missing"]);
expect(result.referenced_entities).toEqual(["light.missing"]);
});
});
-136
View File
@@ -1,136 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { HomeAssistant } from "../../src/types";
import { getStatisticIds } from "../../src/data/recorder_statistic_ids";
import type { StatisticsMetaData } from "../../src/data/recorder";
const createHass = (callWS = vi.fn()) =>
({
callWS,
}) as unknown as Pick<HomeAssistant, "callWS">;
const deferred = <T>() => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
describe("getStatisticIds", () => {
it("shares concurrent requests for the same statistic type", async () => {
const request = deferred<StatisticsMetaData[]>();
const callWS = vi.fn().mockReturnValue(request.promise);
const hass = createHass(callWS);
const first = getStatisticIds(hass);
const second = getStatisticIds(hass);
expect(callWS).toHaveBeenCalledTimes(1);
expect(second).toBe(first);
request.resolve([]);
await Promise.all([first, second]);
});
it("works with callers that only expose callWS", async () => {
const request = deferred<StatisticsMetaData[]>();
const callWS = vi.fn().mockReturnValue(request.promise);
const api = { callWS } as Pick<HomeAssistant, "callWS">;
const first = getStatisticIds(api);
const second = getStatisticIds(api);
expect(callWS).toHaveBeenCalledTimes(1);
expect(second).toBe(first);
request.resolve([]);
await Promise.all([first, second]);
});
it("shares requests across callers with the same callWS", async () => {
const request = deferred<StatisticsMetaData[]>();
const callWS = vi.fn().mockReturnValue(request.promise);
const firstHass = createHass(callWS);
const secondHass = createHass(callWS);
const first = getStatisticIds(firstHass);
const second = getStatisticIds(secondHass);
expect(callWS).toHaveBeenCalledTimes(1);
expect(second).toBe(first);
request.resolve([]);
await Promise.all([first, second]);
});
it("does not share requests for different statistic types", async () => {
const callWS = vi.fn().mockResolvedValue([]);
const hass = createHass(callWS);
await Promise.all([
getStatisticIds(hass),
getStatisticIds(hass, "mean"),
getStatisticIds(hass, "sum"),
]);
expect(callWS).toHaveBeenCalledTimes(3);
expect(callWS).toHaveBeenNthCalledWith(1, {
type: "recorder/list_statistic_ids",
statistic_type: undefined,
});
expect(callWS).toHaveBeenNthCalledWith(2, {
type: "recorder/list_statistic_ids",
statistic_type: "mean",
});
expect(callWS).toHaveBeenNthCalledWith(3, {
type: "recorder/list_statistic_ids",
statistic_type: "sum",
});
});
it("does not share requests across different callWS owners", async () => {
const firstRequest = deferred<StatisticsMetaData[]>();
const secondRequest = deferred<StatisticsMetaData[]>();
const firstCallWS = vi.fn().mockReturnValue(firstRequest.promise);
const secondCallWS = vi.fn().mockReturnValue(secondRequest.promise);
const first = getStatisticIds(createHass(firstCallWS));
const second = getStatisticIds(createHass(secondCallWS));
expect(firstCallWS).toHaveBeenCalledTimes(1);
expect(secondCallWS).toHaveBeenCalledTimes(1);
expect(second).not.toBe(first);
firstRequest.resolve([]);
secondRequest.resolve([]);
await Promise.all([first, second]);
});
it("fetches again after the previous request settles", async () => {
const callWS = vi.fn().mockResolvedValue([]);
const hass = createHass(callWS);
await getStatisticIds(hass, "sum");
await getStatisticIds(hass, "sum");
expect(callWS).toHaveBeenCalledTimes(2);
});
it("retries after a failed request", async () => {
const callWS = vi
.fn()
.mockRejectedValueOnce(new Error("failed"))
.mockResolvedValueOnce([]);
const hass = createHass(callWS);
await expect(getStatisticIds(hass)).rejects.toThrow("failed");
await getStatisticIds(hass);
expect(callWS).toHaveBeenCalledTimes(2);
});
});
-80
View File
@@ -1,80 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { extractFromTarget } from "../../src/data/target";
type CallWS = Parameters<typeof extractFromTarget>[0];
const callWS = () => vi.fn().mockResolvedValue({}) as unknown as CallWS;
describe("extractFromTarget in-flight sharing", () => {
it("shares identical concurrent requests", async () => {
const ws = callWS();
const target = { area_id: ["bathroom"] };
await Promise.all([
extractFromTarget(ws, target),
extractFromTarget(ws, target),
]);
expect(ws).toHaveBeenCalledTimes(1);
});
it("does not share different targets", async () => {
const ws = callWS();
await Promise.all([
extractFromTarget(ws, { area_id: ["bathroom"] }),
extractFromTarget(ws, { area_id: ["kitchen"] }),
]);
expect(ws).toHaveBeenCalledTimes(2);
});
it("does not share different expandGroup values", async () => {
const ws = callWS();
const target = { area_id: ["bathroom"] };
await Promise.all([
extractFromTarget(ws, target, false),
extractFromTarget(ws, target, true),
]);
expect(ws).toHaveBeenCalledTimes(2);
});
it("does not share different primaryEntitiesOnly values", async () => {
const ws = callWS();
const target = { area_id: ["bathroom"] };
await Promise.all([
extractFromTarget(ws, target, false, true),
extractFromTarget(ws, target, false, false),
]);
expect(ws).toHaveBeenCalledTimes(2);
});
it("does not share between different callWS owners", async () => {
const first = callWS();
const second = callWS();
const target = { area_id: ["bathroom"] };
await Promise.all([
extractFromTarget(first, target),
extractFromTarget(second, target),
]);
expect(first).toHaveBeenCalledTimes(1);
expect(second).toHaveBeenCalledTimes(1);
});
it("fetches again after the request settles", async () => {
const ws = callWS();
const target = { area_id: ["bathroom"] };
await extractFromTarget(ws, target);
await extractFromTarget(ws, target);
expect(ws).toHaveBeenCalledTimes(2);
});
});
+2 -2
View File
@@ -101,7 +101,7 @@ test.describe("Home Assistant Demo", () => {
);
}, DEMO_THEME_STORAGE_KEY);
await loadDemo(page, "/#/profile/dashboard");
await loadDemo(page, "/#/profile/general");
const themeRow = page.locator("ha-pick-theme-row");
await expect(themeRow).toBeVisible({ timeout: PANEL_TIMEOUT });
@@ -121,7 +121,7 @@ test.describe("Home Assistant Demo", () => {
await expectStoredDemoTheme(page, { theme: "default", dark: true });
await expectDemoDarkMode(page, true);
await loadDemo(page, "/#/profile/dashboard");
await loadDemo(page, "/#/profile/general");
await expect(themeRow).toBeVisible({ timeout: PANEL_TIMEOUT });
await expect(
themeRow.locator('ha-radio-option[value="dark"]')
-143
View File
@@ -1,143 +0,0 @@
import { LitElement } from "lit";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { navigate } from "../../src/common/navigate";
import { DirtyStateProviderMixin } from "../../src/mixins/dirty-state-provider-mixin";
import { PreventUnsavedMixin } from "../../src/mixins/prevent-unsaved-mixin";
// navigate() closes open dialogs before touching history.
vi.mock("../../src/dialogs/make-dialog-manager", () => ({
closeAllDialogs: vi.fn(async () => true),
}));
interface TestState {
name: string;
}
// Mirrors the editors: DirtyStateProviderMixin wraps PreventUnsavedMixin.
class TestPreventUnsaved extends DirtyStateProviderMixin<TestState>()(
PreventUnsavedMixin(LitElement)
) {
public promptResponse = true;
public promptCalls = 0;
public initialize(value: TestState) {
this._initDirtyTracking({ type: "shallow" }, value);
}
public setValue(value: TestState) {
this._updateDirtyState(value);
}
public markClean() {
this._markDirtyStateClean();
}
protected async promptDiscardChanges(): Promise<boolean> {
this.promptCalls++;
return this.promptResponse;
}
}
customElements.define("test-prevent-unsaved", TestPreventUnsaved);
declare global {
interface HTMLElementTagNameMap {
"test-prevent-unsaved": TestPreventUnsaved;
}
}
const setEntry = (path: string) => {
window.history.replaceState(null, "", path);
};
const mountClean = async () => {
const element = document.createElement("test-prevent-unsaved");
document.body.append(element);
element.initialize({ name: "Kitchen" });
await element.updateComplete;
return element;
};
const mountDirty = async () => {
const element = await mountClean();
element.setValue({ name: "Bedroom" });
await element.updateComplete;
return element;
};
describe("PreventUnsavedMixin", () => {
beforeEach(() => {
setEntry("/config/automation/edit/1234");
});
afterEach(() => {
document.querySelectorAll("test-prevent-unsaved").forEach((element) => {
element.remove();
});
});
it("blocks navigation while dirty when the prompt is declined", async () => {
const element = await mountDirty();
element.promptResponse = false;
expect(await navigate("/config/scene/dashboard")).toBe(false);
expect(element.promptCalls).toBe(1);
expect(window.location.pathname).toEqual("/config/automation/edit/1234");
});
it("navigates while dirty when the prompt is confirmed", async () => {
const element = await mountDirty();
expect(await navigate("/config/scene/dashboard")).toBe(true);
expect(element.promptCalls).toBe(1);
expect(window.location.pathname).toEqual("/config/scene/dashboard");
});
it("does not prompt while clean", async () => {
const element = await mountClean();
expect(await navigate("/config/scene/dashboard")).toBe(true);
expect(element.promptCalls).toBe(0);
expect(window.location.pathname).toEqual("/config/scene/dashboard");
});
it("does not prompt after changes are marked clean, before the next render", async () => {
const element = await mountDirty();
element.markClean();
expect(await navigate("/config/scene/dashboard")).toBe(true);
expect(element.promptCalls).toBe(0);
expect(window.location.pathname).toEqual("/config/scene/dashboard");
});
it("stops guarding after being disconnected", async () => {
const element = await mountDirty();
element.promptResponse = false;
element.remove();
expect(await navigate("/config/scene/dashboard")).toBe(true);
expect(element.promptCalls).toBe(0);
expect(window.location.pathname).toEqual("/config/scene/dashboard");
});
it("arms beforeunload only while dirty", async () => {
const element = await mountDirty();
let event = new Event("beforeunload", { cancelable: true });
window.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
element.setValue({ name: "Kitchen" });
await element.updateComplete;
event = new Event("beforeunload", { cancelable: true });
window.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
});
});
@@ -1,295 +0,0 @@
import { describe, expect, it } from "vitest";
import { createZHANetworkChartData } from "../../../../../../src/panels/config/integrations/integration-panels/zha/zha-network-data";
import type { ZHADevice } from "../../../../../../src/data/zha";
import type { HomeAssistant } from "../../../../../../src/types";
const hass = {
devices: {},
areas: {},
localize: () => "",
} as unknown as HomeAssistant;
const device = (partial: Partial<ZHADevice>): ZHADevice =>
({
available: true,
name: partial.ieee!,
lqi: 0,
rssi: "",
last_seen: "",
manufacturer: "",
model: "",
quirk_applied: false,
quirk_class: "",
entities: [],
manufacturer_code: 0,
device_reg_id: partial.ieee!,
active_coordinator: false,
signature: {},
routes: [],
neighbors: [],
...partial,
}) as ZHADevice;
describe("createZHANetworkChartData", () => {
it("links a router to its upstream router instead of a nearby child device when routing tables are empty", () => {
// Regression test: many Zigbee radios (e.g. TI CC2652 via zigpy-znp)
// never populate Mgmt_Rtg routes, so the chart has to fall back to
// picking a device's strongest RF neighbor. A router's own child end
// device (e.g. a plug sitting right next to it) commonly reports a
// stronger LQI than the router's real uplink, which must not be
// allowed to hide the backbone connection.
const coordinator = device({
ieee: "coordinator",
device_type: "Coordinator",
nwk: 0,
});
const upstreamRouter = device({
ieee: "upstream-router",
device_type: "Router",
nwk: 1,
neighbors: [
{
ieee: "coordinator",
nwk: "0x0000",
lqi: "200",
depth: "0",
relationship: "Parent",
},
],
});
const downstreamRouter = device({
ieee: "downstream-router",
device_type: "Router",
nwk: 2,
neighbors: [
// Real backbone link: weaker signal, correctly flagged as a sibling router
{
ieee: "upstream-router",
nwk: "0x0001",
lqi: "80",
depth: "1",
relationship: "Sibling",
},
// Nearby child end device with a much stronger signal than the real uplink
{
ieee: "child-end-device",
nwk: "0x0003",
lqi: "250",
depth: "2",
relationship: "Child",
},
],
});
const childEndDevice = device({
ieee: "child-end-device",
device_type: "EndDevice",
nwk: 3,
neighbors: [
{
ieee: "downstream-router",
nwk: "0x0002",
lqi: "250",
depth: "2",
relationship: "Parent",
},
],
});
const { links } = createZHANetworkChartData(
[coordinator, upstreamRouter, downstreamRouter, childEndDevice],
hass,
document.createElement("div")
);
const adjacency = new Map<string, string[]>();
for (const link of links) {
adjacency.set(link.source, [
...(adjacency.get(link.source) ?? []),
link.target,
]);
adjacency.set(link.target, [
...(adjacency.get(link.target) ?? []),
link.source,
]);
}
// Every device should be reachable from the coordinator. Before the
// fix, downstream-router picked its child as its only link, leaving
// it (and its children) as a disconnected island in the chart even
// though the real network is fully connected.
const reachable = new Set(["coordinator"]);
const queue = ["coordinator"];
while (queue.length) {
const current = queue.shift()!;
for (const neighbor of adjacency.get(current) ?? []) {
if (!reachable.has(neighbor)) {
reachable.add(neighbor);
queue.push(neighbor);
}
}
}
expect(reachable.has("upstream-router")).toBe(true);
expect(reachable.has("downstream-router")).toBe(true);
expect(reachable.has("child-end-device")).toBe(true);
});
it("ranks NoneOfTheAbove above Child, and treats unrecognized relationships as better than PreviousChild", () => {
const router = device({
ieee: "router",
device_type: "Router",
nwk: 1,
neighbors: [
{
ieee: "n-child",
nwk: "0x0001",
lqi: "100",
depth: "1",
relationship: "Child",
},
{
ieee: "n-none-of-the-above",
nwk: "0x0002",
lqi: "100",
depth: "1",
relationship: "NoneOfTheAbove",
},
],
});
const unknownRelationship = device({
ieee: "router-2",
device_type: "Router",
nwk: 2,
neighbors: [
{
ieee: "n-unrecognized",
nwk: "0x0003",
lqi: "100",
depth: "1",
relationship: "SomeFutureValue",
},
{
ieee: "n-previous-child",
nwk: "0x0004",
lqi: "100",
depth: "1",
relationship: "PreviousChild",
},
],
});
const { links } = createZHANetworkChartData(
[router, unknownRelationship],
hass,
document.createElement("div")
);
const otherEndOf = (ieee: string) => {
const link = links.find((l) => l.source === ieee || l.target === ieee);
return link?.source === ieee ? link.target : link?.source;
};
expect(otherEndOf("router")).toBe("n-none-of-the-above");
expect(otherEndOf("router-2")).toBe("n-unrecognized");
});
it("connects every device regardless of backend device order (child listed before its parent router)", () => {
// Regression test for a subtler variant of the same bug: the fallback
// link is only computed for a device if it doesn't already have a
// link. If the backend lists a child end device before its parent
// router, the child claims the link first, and the router - now
// appearing to "already have a link" - never gets to evaluate its own
// (better) neighbor choice, splitting the graph exactly as before but
// triggered by device order instead of by LQI.
const coordinator = device({
ieee: "coordinator",
device_type: "Coordinator",
nwk: 0,
});
const upstreamRouter = device({
ieee: "upstream-router",
device_type: "Router",
nwk: 1,
neighbors: [
{
ieee: "coordinator",
nwk: "0x0000",
lqi: "200",
depth: "0",
relationship: "Parent",
},
],
});
const downstreamRouter = device({
ieee: "downstream-router",
device_type: "Router",
nwk: 2,
neighbors: [
{
ieee: "upstream-router",
nwk: "0x0001",
lqi: "80",
depth: "1",
relationship: "Sibling",
},
{
ieee: "child-end-device",
nwk: "0x0003",
lqi: "250",
depth: "2",
relationship: "Child",
},
],
});
const childEndDevice = device({
ieee: "child-end-device",
device_type: "EndDevice",
nwk: 3,
neighbors: [
{
ieee: "downstream-router",
nwk: "0x0002",
lqi: "250",
depth: "2",
relationship: "Parent",
},
],
});
// Same topology as the first test, but the child is listed before its
// parent router this time.
const { links } = createZHANetworkChartData(
[coordinator, upstreamRouter, childEndDevice, downstreamRouter],
hass,
document.createElement("div")
);
const adjacency = new Map<string, string[]>();
for (const link of links) {
adjacency.set(link.source, [
...(adjacency.get(link.source) ?? []),
link.target,
]);
adjacency.set(link.target, [
...(adjacency.get(link.target) ?? []),
link.source,
]);
}
const reachable = new Set(["coordinator"]);
const queue = ["coordinator"];
while (queue.length) {
const current = queue.shift()!;
for (const neighbor of adjacency.get(current) ?? []) {
if (!reachable.has(neighbor)) {
reachable.add(neighbor);
queue.push(neighbor);
}
}
}
expect(reachable.has("upstream-router")).toBe(true);
expect(reachable.has("downstream-router")).toBe(true);
expect(reachable.has("child-end-device")).toBe(true);
});
});
@@ -1,145 +0,0 @@
import { describe, expect, it } from "vitest";
import { filterLowBatteryEntities } from "../../../../src/panels/maintenance/strategies/maintenance-view-strategy";
import { mockEntity } from "../../../common/entity/context/context-mock";
import { createMockEntityState, createMockHass } from "../../../fixtures/hass";
describe("filterLowBatteryEntities", () => {
it("filters numeric battery entities by the low battery threshold", () => {
const hass = createMockHass({
"sensor.low_battery": createMockEntityState("sensor.low_battery", "20", {
device_class: "battery",
}),
"sensor.ok_battery": createMockEntityState("sensor.ok_battery", "21", {
device_class: "battery",
}),
});
expect(
filterLowBatteryEntities(hass, [
"sensor.low_battery",
"sensor.ok_battery",
])
).toEqual(["sensor.low_battery"]);
});
it("excludes a low battery when its device is charging", () => {
const entities = {
"sensor.device_1_battery": mockEntity({
entity_id: "sensor.device_1_battery",
device_id: "device_1",
}),
"binary_sensor.device_1_battery_charging": mockEntity({
entity_id: "binary_sensor.device_1_battery_charging",
device_id: "device_1",
}),
"sensor.device_2_battery": mockEntity({
entity_id: "sensor.device_2_battery",
device_id: "device_2",
}),
"binary_sensor.device_2_battery_charging": mockEntity({
entity_id: "binary_sensor.device_2_battery_charging",
device_id: "device_2",
}),
};
const hass = createMockHass(
{
"sensor.device_1_battery": createMockEntityState(
"sensor.device_1_battery",
"10",
{ device_class: "battery" }
),
"binary_sensor.device_1_battery_charging": createMockEntityState(
"binary_sensor.device_1_battery_charging",
"on",
{ device_class: "battery_charging" }
),
"sensor.device_2_battery": createMockEntityState(
"sensor.device_2_battery",
"10",
{ device_class: "battery" }
),
"binary_sensor.device_2_battery_charging": createMockEntityState(
"binary_sensor.device_2_battery_charging",
"off",
{ device_class: "battery_charging" }
),
},
{ entities }
);
expect(
filterLowBatteryEntities(hass, [
"sensor.device_1_battery",
"sensor.device_2_battery",
])
).toEqual(["sensor.device_2_battery"]);
});
it("keeps binary battery sensor behavior unchanged", () => {
const hass = createMockHass({
"binary_sensor.low_battery": createMockEntityState(
"binary_sensor.low_battery",
"on",
{ device_class: "battery" }
),
"binary_sensor.ok_battery": createMockEntityState(
"binary_sensor.ok_battery",
"off",
{ device_class: "battery" }
),
});
expect(
filterLowBatteryEntities(hass, [
"binary_sensor.low_battery",
"binary_sensor.ok_battery",
])
).toEqual(["binary_sensor.low_battery"]);
});
it("updates the device lookup when the entity registry changes", () => {
const states = {
"sensor.battery": createMockEntityState("sensor.battery", "10", {
device_class: "battery",
}),
"binary_sensor.battery_charging": createMockEntityState(
"binary_sensor.battery_charging",
"on",
{ device_class: "battery_charging" }
),
};
const firstHass = createMockHass(states, {
entities: {
"sensor.battery": mockEntity({
entity_id: "sensor.battery",
device_id: "device_1",
}),
"binary_sensor.battery_charging": mockEntity({
entity_id: "binary_sensor.battery_charging",
device_id: "device_1",
}),
},
});
expect(filterLowBatteryEntities(firstHass, ["sensor.battery"])).toEqual([]);
const secondHass = createMockHass(states, {
entities: {
"sensor.battery": mockEntity({
entity_id: "sensor.battery",
device_id: "device_1",
}),
"binary_sensor.battery_charging": mockEntity({
entity_id: "binary_sensor.battery_charging",
device_id: "device_2",
}),
},
});
expect(filterLowBatteryEntities(secondHass, ["sensor.battery"])).toEqual([
"sensor.battery",
]);
});
});
+120 -143
View File
@@ -3488,10 +3488,10 @@ __metadata:
languageName: node
linkType: hard
"@lokalise/node-api@npm:16.4.0":
version: 16.4.0
resolution: "@lokalise/node-api@npm:16.4.0"
checksum: 10/68bc43e4cdeec93b44ddbfc10999acda68938ef889c9f595a2849da7289e5a6b33e34f5c4d7234e12a730348eb3adff164145acfeba610785c6adc509b7e4cf4
"@lokalise/node-api@npm:16.3.0":
version: 16.3.0
resolution: "@lokalise/node-api@npm:16.3.0"
checksum: 10/9902033459cd3abb6dd0ad2846a4c464144caf890433456c2b966500201b6b692f751e9e23e925f968f9501f431ea6bbc07fdfb59f367fd6f3351d6bb7ae67bd
languageName: node
linkType: hard
@@ -3502,13 +3502,6 @@ __metadata:
languageName: node
linkType: hard
"@mapbox/mapbox-gl-rtl-text@npm:0.4.0":
version: 0.4.0
resolution: "@mapbox/mapbox-gl-rtl-text@npm:0.4.0"
checksum: 10/a678240e4cc6f589726ef2f35fac64a7eb073451a8686c09db58041d3fb351d2e72100bb12607591efc298d8ac5e19691c593b3c213d121b2a0e84978254e1a8
languageName: node
linkType: hard
"@mapbox/point-geometry@npm:^1.1.0, @mapbox/point-geometry@npm:~1.1.0":
version: 1.1.0
resolution: "@mapbox/point-geometry@npm:1.1.0"
@@ -5375,7 +5368,7 @@ __metadata:
languageName: node
linkType: hard
"@sveltejs/acorn-typescript@npm:^1.0.13":
"@sveltejs/acorn-typescript@npm:^1.0.10":
version: 1.0.13
resolution: "@sveltejs/acorn-typescript@npm:1.0.13"
peerDependencies:
@@ -5958,105 +5951,105 @@ __metadata:
languageName: node
linkType: hard
"@typescript-eslint/eslint-plugin@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/eslint-plugin@npm:8.68.0"
"@typescript-eslint/eslint-plugin@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/eslint-plugin@npm:8.67.0"
dependencies:
"@eslint-community/regexpp": "npm:^4.12.2"
"@typescript-eslint/scope-manager": "npm:8.68.0"
"@typescript-eslint/type-utils": "npm:8.68.0"
"@typescript-eslint/utils": "npm:8.68.0"
"@typescript-eslint/visitor-keys": "npm:8.68.0"
"@typescript-eslint/scope-manager": "npm:8.67.0"
"@typescript-eslint/type-utils": "npm:8.67.0"
"@typescript-eslint/utils": "npm:8.67.0"
"@typescript-eslint/visitor-keys": "npm:8.67.0"
ignore: "npm:^7.0.5"
natural-compare: "npm:^1.4.0"
ts-api-utils: "npm:^2.5.0"
peerDependencies:
"@typescript-eslint/parser": ^8.68.0
"@typescript-eslint/parser": ^8.67.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/8776fd8ffde8eceae3b1aeca57e528e8ee61a683223c3bef455ca3c5e28e52275e6cd6ebf8ee284a46883e6220bbf297312084e1ee85e00cef017a78c169a674
checksum: 10/d53f9e51c6be98ff4cdcb94de24e2951f5e2b7a1dfc56c4f8a2d81e61346c511c0478117626d9cfd5d5d63f5e7a0e0653dc1abe1e7d926a3f9806aaf1fa11bd2
languageName: node
linkType: hard
"@typescript-eslint/parser@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/parser@npm:8.68.0"
"@typescript-eslint/parser@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/parser@npm:8.67.0"
dependencies:
"@typescript-eslint/scope-manager": "npm:8.68.0"
"@typescript-eslint/types": "npm:8.68.0"
"@typescript-eslint/typescript-estree": "npm:8.68.0"
"@typescript-eslint/visitor-keys": "npm:8.68.0"
"@typescript-eslint/scope-manager": "npm:8.67.0"
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/typescript-estree": "npm:8.67.0"
"@typescript-eslint/visitor-keys": "npm:8.67.0"
debug: "npm:^4.4.3"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/979ae0d2c6a391fd5b9b0218189fda5112ff0b5ab2538eef1da4cd77ac8ae46f9b590db9779f2614bd193105b40cf12635b565c840f4cdbbb4a683e24cf5ec4b
checksum: 10/fa156ade066d0886ea0daa0f872940cdb450a2b5dbd1687e0df64ec19aac86711dde51e17e6b20e72387aa38daec811736821e9db950e8557affa7faea63d65c
languageName: node
linkType: hard
"@typescript-eslint/project-service@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/project-service@npm:8.68.0"
"@typescript-eslint/project-service@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/project-service@npm:8.67.0"
dependencies:
"@typescript-eslint/tsconfig-utils": "npm:^8.68.0"
"@typescript-eslint/types": "npm:^8.68.0"
"@typescript-eslint/tsconfig-utils": "npm:^8.67.0"
"@typescript-eslint/types": "npm:^8.67.0"
debug: "npm:^4.4.3"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/37521df3731f9069c7df55362342dd0027f43947241de33c2a1c029b4553fe092e2ac69a534153aaf08c862e6b2390d864cb6c88a0714fc276395390df276fb6
checksum: 10/3beccdee1e74060eac4e6c5e7edd14a800c8c98e3313dbfe5838a357e83babc22c41c3ebe637c331c4b93db2da50f5479ccf4677db7b6b1a760f3cd27795b36f
languageName: node
linkType: hard
"@typescript-eslint/scope-manager@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/scope-manager@npm:8.68.0"
"@typescript-eslint/scope-manager@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/scope-manager@npm:8.67.0"
dependencies:
"@typescript-eslint/types": "npm:8.68.0"
"@typescript-eslint/visitor-keys": "npm:8.68.0"
checksum: 10/494d499315b4f3ab8eaa114aa53a4b2b9f533b82eb007cd5b5617cececfd8ad6938edb74f734479ae6e40a4086f510b6b984de8e66d06c518cb47817549db780
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/visitor-keys": "npm:8.67.0"
checksum: 10/a0ca8d52e6c5b2538f7df860acd5a36437cce8e6d5bcededdfde04c02489f4b523cb178b502c266d138cff6758ea375ebe311b2b6af373b127ded4876a697ffb
languageName: node
linkType: hard
"@typescript-eslint/tsconfig-utils@npm:8.68.0, @typescript-eslint/tsconfig-utils@npm:^8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/tsconfig-utils@npm:8.68.0"
"@typescript-eslint/tsconfig-utils@npm:8.67.0, @typescript-eslint/tsconfig-utils@npm:^8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/tsconfig-utils@npm:8.67.0"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/b527a7c5deaad40bb72f78eec1bfea5997a7e792e64666984605e9a167246cf856c8d40290a7b3d39cefb3a242538e22a3a4fc136132e4669a314887e2de5f08
checksum: 10/736d0ab8a273b4e4e17d412b390386eec0171cd3f7ba451bacd92c4f931051bba2049e040fdae84603ea0e07430afb05b49c6d8b6d429f9012d3c6b03eb69f54
languageName: node
linkType: hard
"@typescript-eslint/type-utils@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/type-utils@npm:8.68.0"
"@typescript-eslint/type-utils@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/type-utils@npm:8.67.0"
dependencies:
"@typescript-eslint/types": "npm:8.68.0"
"@typescript-eslint/typescript-estree": "npm:8.68.0"
"@typescript-eslint/utils": "npm:8.68.0"
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/typescript-estree": "npm:8.67.0"
"@typescript-eslint/utils": "npm:8.67.0"
debug: "npm:^4.4.3"
ts-api-utils: "npm:^2.5.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/ed6b6fe74eee017a79c9dbfa72b3a08748dac8de5702314494d8849feee516c20e2907ad730fd82e50be81c5984c575b29e35eb8b5c99091445b4bc5102ce836
checksum: 10/a0b943e5a8f3c2e8490ec28599eb94561d7ad029fcf42f2e9a0e8d2685ce625898371cd0a7d3a906fe8b4077d72fee8329cad512a5b0521bc2167f7d88a984fb
languageName: node
linkType: hard
"@typescript-eslint/types@npm:8.68.0, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/types@npm:8.68.0"
checksum: 10/f9337e0263d95e6edca6e364f9b1f70d5840b9648b5aa0d02065bcee5809a50c92d024ba17b458182ec4e7fffe5efd05584f1667b1e43ff3e7121b218283a207
"@typescript-eslint/types@npm:8.67.0, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/types@npm:8.67.0"
checksum: 10/8edc1a14a52c7566409c19b34fb72ba3a62a3b409c98b1d496de9fcd83179fb128c8df2fde391c199316a54cdc5d314ba70ce7b440161dd850b73a44f3e54e6d
languageName: node
linkType: hard
"@typescript-eslint/typescript-estree@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/typescript-estree@npm:8.68.0"
"@typescript-eslint/typescript-estree@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/typescript-estree@npm:8.67.0"
dependencies:
"@typescript-eslint/project-service": "npm:8.68.0"
"@typescript-eslint/tsconfig-utils": "npm:8.68.0"
"@typescript-eslint/types": "npm:8.68.0"
"@typescript-eslint/visitor-keys": "npm:8.68.0"
"@typescript-eslint/project-service": "npm:8.67.0"
"@typescript-eslint/tsconfig-utils": "npm:8.67.0"
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/visitor-keys": "npm:8.67.0"
debug: "npm:^4.4.3"
minimatch: "npm:^10.2.2"
semver: "npm:^7.7.3"
@@ -6064,32 +6057,32 @@ __metadata:
ts-api-utils: "npm:^2.5.0"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/47d75cbc9c10842aeb3c1cc8d0625bd578b3b43b75d3dc86d3543a739b2fc8d0b524fd86b5af1f1493a3f0ae1b9e65c3200bf01a6bde0162efe6414708eac553
checksum: 10/31c5be812f67c997b9d1e9324c4289b6268a12e9948492403fdd9c9b5df12dcc00f845ae93ae37c78f717583519060175f2147f165b65112eef1e40acf44ad1d
languageName: node
linkType: hard
"@typescript-eslint/utils@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/utils@npm:8.68.0"
"@typescript-eslint/utils@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/utils@npm:8.67.0"
dependencies:
"@eslint-community/eslint-utils": "npm:^4.9.1"
"@typescript-eslint/scope-manager": "npm:8.68.0"
"@typescript-eslint/types": "npm:8.68.0"
"@typescript-eslint/typescript-estree": "npm:8.68.0"
"@typescript-eslint/scope-manager": "npm:8.67.0"
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/typescript-estree": "npm:8.67.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/6581a1e7ced23cce82279df001d643844c97408bc84826da77ec77d52b77047004b202ff8cfcfbc0b0fef15bf2cafb5ae21bafbda24c9b58102ce634f66c2af9
checksum: 10/8703468cdea3630275c6faacc031e7e4bd7fb8f8988e80ab987811ae3c63a9a9677496cee2b4811f49cb9bc9e6f6c0bdb91cb213353019ce31df0a2b351cea51
languageName: node
linkType: hard
"@typescript-eslint/visitor-keys@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/visitor-keys@npm:8.68.0"
"@typescript-eslint/visitor-keys@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/visitor-keys@npm:8.67.0"
dependencies:
"@typescript-eslint/types": "npm:8.68.0"
"@typescript-eslint/types": "npm:8.67.0"
eslint-visitor-keys: "npm:^5.0.0"
checksum: 10/d802bc2c4569495f3d83f9bcf169e3a432ac1c6672e243399886409dbc55d7fa30375d9d0b2cf0d9466b81fc2b01b991483eb56e8cdb8141721009978d3b078b
checksum: 10/c92c9b202105a8f398ab2ad4be58c8c8fd9af6b5ddd28cdcd57d75f7f04f69561a56a05aac9a008668c64faa7222ebb3fefe19097923a18eb2b9f7a90809a5be
languageName: node
linkType: hard
@@ -6794,7 +6787,7 @@ __metadata:
languageName: node
linkType: hard
"acorn@npm:^8.10.0, acorn@npm:^8.11.0, acorn@npm:^8.15.0, acorn@npm:^8.16.0, acorn@npm:^8.18.0":
"acorn@npm:^8.10.0, acorn@npm:^8.11.0, acorn@npm:^8.15.0, acorn@npm:^8.16.0, acorn@npm:^8.17.0":
version: 8.18.0
resolution: "acorn@npm:8.18.0"
bin:
@@ -7757,20 +7750,13 @@ __metadata:
languageName: node
linkType: hard
"cli-spinners@npm:^2.5.0":
"cli-spinners@npm:^2.5.0, cli-spinners@npm:^2.6.0":
version: 2.9.2
resolution: "cli-spinners@npm:2.9.2"
checksum: 10/a0a863f442df35ed7294424f5491fa1756bd8d2e4ff0c8736531d886cec0ece4d85e8663b77a5afaf1d296e3cbbebff92e2e99f52bbea89b667cbe789b994794
languageName: node
linkType: hard
"cli-spinners@npm:^3.4.0":
version: 3.4.0
resolution: "cli-spinners@npm:3.4.0"
checksum: 10/6a4021c1999011fc34ae714f055dcdafb56309abc1f8fb021ea7d9370dfc524485fe8684226015e5fe6053dd30544e74270184ff7edc3fa4d37043b8efd0a054
languageName: node
linkType: hard
"clipboardy@npm:3.0.0":
version: 3.0.0
resolution: "clipboardy@npm:3.0.0"
@@ -8702,7 +8688,7 @@ __metadata:
languageName: node
linkType: hard
"enquirer@npm:^2.4.1":
"enquirer@npm:^2.3.6":
version: 2.4.1
resolution: "enquirer@npm:2.4.1"
dependencies:
@@ -9111,9 +9097,9 @@ __metadata:
languageName: node
linkType: hard
"eslint@npm:10.9.1":
version: 10.9.1
resolution: "eslint@npm:10.9.1"
"eslint@npm:10.9.0":
version: 10.9.0
resolution: "eslint@npm:10.9.0"
dependencies:
"@eslint-community/eslint-utils": "npm:^4.8.0"
"@eslint-community/regexpp": "npm:^4.12.2"
@@ -9152,7 +9138,7 @@ __metadata:
optional: true
bin:
eslint: bin/eslint.js
checksum: 10/4cee237c7d2c445a05330e595b18521d7452fd3f419241bf1ccf81c769a6761c10ca5a06988197482d4842ed1a26b1dcb51b9526408990cee84559e6c08cb3ab
checksum: 10/1cd63bbf4f2c003ed759ab6ae9e2030be0abafdcd7b9ffb5be9e9c3aa3ea6142e6af0a337aac21044957750807fde06c9d052e2c9734cca6792dc2d095f9c180
languageName: node
linkType: hard
@@ -9689,23 +9675,24 @@ __metadata:
languageName: node
linkType: hard
"generate-license-file@npm:4.2.3":
version: 4.2.3
resolution: "generate-license-file@npm:4.2.3"
"generate-license-file@npm:4.2.1":
version: 4.2.1
resolution: "generate-license-file@npm:4.2.1"
dependencies:
"@commander-js/extra-typings": "npm:^14.0.0"
"@npmcli/arborist": "npm:^9.0.0"
cli-spinners: "npm:^3.4.0"
cli-spinners: "npm:^2.6.0"
commander: "npm:^14.0.2"
cosmiconfig: "npm:^9.0.0"
enquirer: "npm:^2.4.1"
enquirer: "npm:^2.3.6"
glob: "npm:^13.0.0"
json5: "npm:^2.2.3"
ora: "npm:^5.4.1"
tslib: "npm:^2.3.0"
zod: "npm:^4.0.0"
bin:
generate-license-file: bin/generate-license-file
checksum: 10/bdfd899c04b247f75115d9710b53124b6f202d85d4cacf3e9d2d46260cc7d75e9f93ebf2204105ecb49b051c5acfac6c05d1a9711763b7daed073bffd986ac69
checksum: 10/03876abeba28efea39a0fbd4d048e2c0fe1f59477c14e3a5e9f7022235f9197a3f451b7f26124dc09da5cc63140376f53b4ee9167f238eee58241ab954e62efd
languageName: node
linkType: hard
@@ -10160,8 +10147,7 @@ __metadata:
"@lit/context": "npm:1.1.6"
"@lit/reactive-element": "npm:2.1.2"
"@lit/task": "npm:1.0.3"
"@lokalise/node-api": "npm:16.4.0"
"@mapbox/mapbox-gl-rtl-text": "npm:0.4.0"
"@lokalise/node-api": "npm:16.3.0"
"@maplibre/maplibre-gl-leaflet": "npm:0.1.4"
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch"
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
@@ -10220,7 +10206,7 @@ __metadata:
echarts: "npm:6.1.0"
echarts-extension-chart2music: "npm:0.1.1"
element-internals-polyfill: "npm:3.0.2"
eslint: "npm:10.9.1"
eslint: "npm:10.9.0"
eslint-config-prettier: "npm:10.1.8"
eslint-import-resolver-webpack: "npm:0.13.11"
eslint-plugin-import-x: "npm:4.17.1"
@@ -10231,7 +10217,7 @@ __metadata:
fancy-log: "npm:2.0.0"
fs-extra: "npm:11.4.0"
fuse.js: "npm:7.5.0"
generate-license-file: "npm:4.2.3"
generate-license-file: "npm:4.2.1"
glob: "npm:13.0.6"
globals: "npm:17.11.0"
gulp: "npm:5.0.1"
@@ -10243,7 +10229,7 @@ __metadata:
husky: "npm:9.1.7"
idb-keyval: "npm:6.3.0"
intl-messageformat: "npm:11.2.14"
js-yaml: "npm:5.4.0"
js-yaml: "npm:5.3.0"
jsdom: "npm:30.0.1"
jszip: "npm:3.10.1"
leaflet: "npm:1.9.4"
@@ -10260,9 +10246,9 @@ __metadata:
luxon: "npm:3.7.2"
map-stream: "npm:0.0.7"
maplibre-gl: "npm:5.24.0"
marked: "npm:18.0.11"
marked: "npm:18.0.10"
memoize-one: "npm:6.0.0"
minify-literals: "npm:2.2.0"
minify-literals: "npm:2.1.0"
node-vibrant: "npm:4.0.4"
object-hash: "npm:3.0.0"
pinst: "npm:3.0.0"
@@ -10283,7 +10269,7 @@ __metadata:
tinykeys: "patch:tinykeys@npm%3A4.0.0#~/.yarn/patches/tinykeys-npm-4.0.0-a6ca3fd771.patch"
ts-lit-plugin: "npm:2.0.2"
typescript: "npm:6.0.3"
typescript-eslint: "npm:8.68.0"
typescript-eslint: "npm:8.67.0"
vite-tsconfig-paths: "npm:6.1.1"
vitest: "npm:4.1.11"
webpack-stats-plugin: "npm:1.1.3"
@@ -10341,9 +10327,9 @@ __metadata:
languageName: node
linkType: hard
"html-minifier-next@npm:^8.1.0":
version: 8.1.0
resolution: "html-minifier-next@npm:8.1.0"
"html-minifier-next@npm:^7.0.0":
version: 7.5.3
resolution: "html-minifier-next@npm:7.5.3"
dependencies:
commander: "npm:^15.0.0"
entities: "npm:^8.0.0"
@@ -10358,7 +10344,7 @@ __metadata:
bin:
hmn: cli.js
html-minifier-next: cli.js
checksum: 10/5badf1abd2c6fb9ada2f7dd4929be74f43155510298f7af323b0e39a2c20e2a33ed8de507f105bcea389118f2813e7776b559cf4b2b6397537c5871aa27f2125
checksum: 10/fe4ad84f5c577c2de936c7aad5c0a7619c5e7ce3763f3c2e15f1ecc9629d89ebc578a38c0e44883cc0388253de262962610f7dbc3101f8d16e363c477a6a9b07
languageName: node
linkType: hard
@@ -11191,14 +11177,14 @@ __metadata:
languageName: node
linkType: hard
"js-yaml@npm:5.4.0":
version: 5.4.0
resolution: "js-yaml@npm:5.4.0"
"js-yaml@npm:5.3.0":
version: 5.3.0
resolution: "js-yaml@npm:5.3.0"
dependencies:
argparse: "npm:^2.0.1"
bin:
js-yaml: bin/js-yaml.mjs
checksum: 10/d8216083212772b203a1ee16be2ea96a1638c6d7f0ff769ba4da274b5ad29e858bd26d96761feb8937fce1aa47e8acfa79b2cd21a8ced724cc5555c11b4d056c
checksum: 10/6c7e8ea8dd5643d9df73f4aa796f3572537284e9b819b74ef919467452bfa4624f8f7477cfc775c91db7e2f32387133b1b6c4e95aef9702be6bb042ae554884a
languageName: node
linkType: hard
@@ -11614,7 +11600,7 @@ __metadata:
languageName: node
linkType: hard
"lightningcss@npm:1.33.0, lightningcss@npm:^1.33.0":
"lightningcss@npm:1.33.0, lightningcss@npm:^1.32.0, lightningcss@npm:^1.33.0":
version: 1.33.0
resolution: "lightningcss@npm:1.33.0"
dependencies:
@@ -11868,15 +11854,6 @@ __metadata:
languageName: node
linkType: hard
"magic-string@npm:^1.2.2":
version: 1.2.2
resolution: "magic-string@npm:1.2.2"
dependencies:
"@jridgewell/sourcemap-codec": "npm:^1.5.5"
checksum: 10/23514d9fb744d30689037e41440125b6c4bc3fb5b8e6d992798e59f93d7452276f223d062ba9b1ce78fbee5f9cc365c1e0ae3c6babddb74cb3aa9074adb57b03
languageName: node
linkType: hard
"magicast@npm:^0.5.2":
version: 0.5.4
resolution: "magicast@npm:0.5.4"
@@ -11958,12 +11935,12 @@ __metadata:
languageName: node
linkType: hard
"marked@npm:18.0.11":
version: 18.0.11
resolution: "marked@npm:18.0.11"
"marked@npm:18.0.10":
version: 18.0.10
resolution: "marked@npm:18.0.10"
bin:
marked: bin/marked.js
checksum: 10/6da69f17820c7442358d751e902d3e0e06fef957e1178d634db938cf339c4504399c9f11a953b0a116b1e5bebc1c7fb52726777cc6c351f2c9f39e4a8276e21a
checksum: 10/0d4b560e0773fd6ba30a4e7560ba7ae1f05d47b23926ad8337d9f80c598dfdbffdf2f81e773a1789e8bc9d9c2c2d3d0c9143a54d32581c336a3bbb1bad80461c
languageName: node
linkType: hard
@@ -12081,16 +12058,16 @@ __metadata:
languageName: node
linkType: hard
"minify-literals@npm:2.2.0":
version: 2.2.0
resolution: "minify-literals@npm:2.2.0"
"minify-literals@npm:2.1.0":
version: 2.1.0
resolution: "minify-literals@npm:2.1.0"
dependencies:
"@sveltejs/acorn-typescript": "npm:^1.0.13"
acorn: "npm:^8.18.0"
html-minifier-next: "npm:^8.1.0"
lightningcss: "npm:^1.33.0"
magic-string: "npm:^1.2.2"
checksum: 10/73f17955820675f405b95810f3bf861a05baf426300e8ba46d174987ee45366c885ef1281c40842025529343a4ca71b6fd8294ce674bccbd58685e4a917b9448
"@sveltejs/acorn-typescript": "npm:^1.0.10"
acorn: "npm:^8.17.0"
html-minifier-next: "npm:^7.0.0"
lightningcss: "npm:^1.32.0"
magic-string: "npm:^0.30.21"
checksum: 10/95bcd25afcf638c00bcd8981c2e6409be8c2feb4fd8f954bc9a547b2ade917810cef17bb075ad95e5ceaac6fffc8e625c161ab77b2363c36de53da6d139de872
languageName: node
linkType: hard
@@ -15246,18 +15223,18 @@ __metadata:
languageName: node
linkType: hard
"typescript-eslint@npm:8.68.0":
version: 8.68.0
resolution: "typescript-eslint@npm:8.68.0"
"typescript-eslint@npm:8.67.0":
version: 8.67.0
resolution: "typescript-eslint@npm:8.67.0"
dependencies:
"@typescript-eslint/eslint-plugin": "npm:8.68.0"
"@typescript-eslint/parser": "npm:8.68.0"
"@typescript-eslint/typescript-estree": "npm:8.68.0"
"@typescript-eslint/utils": "npm:8.68.0"
"@typescript-eslint/eslint-plugin": "npm:8.67.0"
"@typescript-eslint/parser": "npm:8.67.0"
"@typescript-eslint/typescript-estree": "npm:8.67.0"
"@typescript-eslint/utils": "npm:8.67.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/451c64296ac5f48e4621a03324f5aaef4a26f1dd9d4df63bd3cb07f136e8379185a800731f83634eb5d909c696fad820ba74d31ab3d7cf263328504275d1305b
checksum: 10/4efc2cea5b067f65d58e010fda3888ec94f2e67ebfa289be78c3a02458787b7125cf8f6095776b912005fb69e8748ad4018777b16436a0658957c163ef213bf9
languageName: node
linkType: hard