Compare commits

..
8 Commits
Author SHA1 Message Date
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
bcd13fcaf7 Update vitest monorepo to v4.1.11 (#53732)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-21 21:35:37 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
308eac97ee Update dependency marked to v18.0.10 (#53725)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-21 06:37:46 +02:00
Paul BotteinandGitHub 6da851219d Fix stacked toasts when a dashboard is updated in another session (#53719) 2026-08-20 21:34:08 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
970be43c3d Update dependency intl-messageformat to v11.2.14 (#53722)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-20 21:01:27 +02:00
karwostsandGitHub 8bb718a486 Cleanup some attributes code (#53713) 2026-08-20 20:54:59 +02:00
Josef ZweckandGitHub 0e0bc389f8 Show result of action for button presses (#53645)
* Show result of action on button feature

* Add button entity

* Add to button entities

* add to tile icon

* Delay for spinner

* Remove result indicator from tile icon
2026-08-20 16:49:55 +02:00
Petar PetrovandGitHub 70a482e0ed Add keyboard and screen reader navigation to charts (#53533)
* Add keyboard and screen reader navigation to charts via Chart2Music

* Mark charts as busy while the sonification chunk loads

* Skip chart sonification when there is nothing to navigate

* Drop the direct chart2music dependency

* Exclude legend-hidden series when judging chart sonifiability

* Push statistics chart times as numbers so sonification reads real dates
2026-08-20 16:55:11 +03:00
Maarten LakerveldandGitHub 5a0db4a796 Show Assist greeting in the assistant's language (#53707)
The greeting sits in the assistant's chat bubble, so render it in the pipeline's language instead of the interface language. Falls back to the interface language when no translation is available (issue #53703).
2026-08-20 12:49:40 +02:00
23 changed files with 1446 additions and 1460 deletions
+5 -4
View File
@@ -101,12 +101,13 @@
"deep-freeze": "0.0.1",
"dialog-polyfill": "0.5.6",
"echarts": "6.1.0",
"echarts-extension-chart2music": "0.1.0",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.5.0",
"hls.js": "1.7.0",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.13",
"intl-messageformat": "11.2.14",
"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",
@@ -114,7 +115,7 @@
"lit": "3.3.3",
"lit-html": "3.3.3",
"luxon": "3.7.2",
"marked": "18.0.9",
"marked": "18.0.10",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -169,7 +170,7 @@
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@typescript/native": "npm:[email protected]",
"@vitest/coverage-v8": "4.1.10",
"@vitest/coverage-v8": "4.1.11",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist": "4.28.8",
@@ -214,7 +215,7 @@
"typescript": "6.0.3",
"typescript-eslint": "8.67.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.10",
"vitest": "4.1.11",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
+268
View File
@@ -0,0 +1,268 @@
import type { HassConfig } from "home-assistant-js-websocket";
import type { EChartsType } from "echarts/core";
import type { XAXisOption, YAXisOption } from "echarts/types/dist/shared";
import { ensureArray } from "../../common/array/ensure-array";
import { formatDateTime } from "../../common/datetime/format_date_time";
import type { LocalizeFunc } from "../../common/translations/localize";
import type { FrontendLocaleData } from "../../data/translation";
import type {
HaECSeries,
HaECSeriesItem,
} from "../../resources/echarts/echarts";
export interface ChartSonification {
update: () => void;
dispose: () => void;
}
// Series types the Chart2Music ECharts extension can turn into data points. Our
// other series (custom timelines, sankey, network graphs) have no equivalent, and
// the extension refuses the whole chart if a single series is unsupported.
const SONIFIABLE_SERIES_TYPES = new Set(["bar", "line", "pie", "scatter"]);
// Languages shipped by chart2music. Anything else falls back to its English.
const SONIFICATION_LANGUAGES = new Set(["de", "en", "es", "fr", "hmn", "it"]);
// Fewer than this and there is nothing to walk between, so a focus stop would
// lead nowhere.
const MIN_NAVIGABLE_POINTS = 2;
// Mirrors the extension's own reading of a point: it takes `value` as [x, y] and
// drops anything whose y is not a real number. That rejects gap-only series, and
// also value-first pairs like the energy device charts' [amount, "sensor.foo"].
// Counts no further than `limit` so this stays cheap on charts with many points.
const countNumericPoints = (data: unknown, limit: number): number => {
if (!Array.isArray(data)) {
return 0;
}
let found = 0;
for (const raw of data) {
let y: unknown = raw;
if (Array.isArray(raw)) {
y = raw.length > 1 ? raw[1] : raw[0];
} else if (raw && typeof raw === "object") {
const { value } = raw as { value?: unknown };
y = Array.isArray(value)
? value.length > 1
? value[1]
: value[0]
: value;
}
if (typeof y === "number" && !Number.isNaN(y)) {
found += 1;
if (found >= limit) {
break;
}
}
}
return found;
};
const countNavigablePoints = (
series: readonly ({ data?: unknown } | undefined)[]
): number => {
let total = 0;
for (const s of series) {
total += countNumericPoints(s?.data, MIN_NAVIGABLE_POINTS - total);
if (total >= MIN_NAVIGABLE_POINTS) {
break;
}
}
return total;
};
export const canSonifyChart = (
data: HaECSeries,
// Legend-hidden series reach ECharts with their data stripped, so they cannot
// be sonified either.
hiddenDatasets?: ReadonlySet<string>
): boolean => {
const series = ensureArray(data);
const visible = hiddenDatasets?.size
? series.filter((s) => !hiddenDatasets.has(String(s.id ?? s.name)))
: series;
return (
// Cards commonly push empty placeholder series, so judge the chart by the
// points the extension can actually read — but every type has to be
// convertible too.
countNavigablePoints(visible) >= MIN_NAVIGABLE_POINTS &&
series.every((s) => SONIFIABLE_SERIES_TYPES.has(s.type as string))
);
};
interface SonifyChartOptions {
cc: HTMLElement;
localize: LocalizeFunc;
locale: FrontendLocaleData;
config: HassConfig;
onError: (error: string) => void;
}
// Chart2Music appends its help and options dialogs straight to document.body, so
// they can only be themed from a document-level stylesheet.
let stylesAppended = false;
const appendSonificationStyles = () => {
if (stylesAppended) {
return;
}
stylesAppended = true;
const style = document.createElement("style");
style.textContent = `
dialog.chart2music-dialog {
box-sizing: border-box;
max-width: min(600px, calc(100vw - 32px));
max-height: calc(100vh - 32px);
overflow: auto;
padding: var(--ha-space-6);
border: none;
border-radius: var(--ha-border-radius-lg);
background-color: var(--card-background-color);
color: var(--primary-text-color);
font-family: var(--ha-font-family-body);
font-size: var(--ha-font-size-m);
box-shadow: var(--ha-box-shadow-l);
}
dialog.chart2music-dialog::backdrop {
background-color: rgba(0, 0, 0, 0.5);
}
dialog.chart2music-dialog h1 {
font-size: var(--ha-font-size-2xl);
font-weight: var(--ha-font-weight-normal);
margin-block: 0 var(--ha-space-4);
padding-inline-end: var(--ha-space-8);
}
dialog.chart2music-dialog table {
border-collapse: collapse;
width: 100%;
}
dialog.chart2music-dialog th,
dialog.chart2music-dialog td {
text-align: start;
padding: var(--ha-space-1) var(--ha-space-2);
border-bottom: 1px solid var(--divider-color);
}
dialog.chart2music-dialog a {
color: var(--primary-color);
}
dialog.chart2music-dialog > button {
/* The extension inlines "right", which does not mirror in RTL, and inline
styles can only be beaten with !important. */
inset-inline-end: var(--ha-space-4) !important;
inset-inline-start: auto !important;
top: var(--ha-space-4);
min-width: 32px;
min-height: 32px;
cursor: pointer;
border: 1px solid var(--divider-color);
border-radius: var(--ha-border-radius-sm);
background-color: transparent;
color: var(--primary-text-color);
font: inherit;
}
`;
document.head.append(style);
};
export const sonifyChart = async (
chart: EChartsType,
options: SonifyChartOptions
): Promise<ChartSonification | null> => {
const { connect } = await import("echarts-extension-chart2music");
const { localize, locale, config } = options;
appendSonificationStyles();
// ECharts nulls its model on dispose, and the instance can be disposed while
// the chunk is in flight.
const chartOptions = chart.getOption() as ReturnType<
EChartsType["getOption"]
> | null;
if (!chartOptions) {
return null;
}
const xAxis = ensureArray(chartOptions.xAxis)[0] as XAXisOption | undefined;
const yAxis = ensureArray(chartOptions.yAxis)[0] as YAXisOption | undefined;
// Chart2Music throws while validating a group with no points, which is what
// placeholder, legend-hidden and all-null series turn into, so only offer it
// the series carrying points it can read.
const allSeries = ensureArray(chartOptions.series) as (
HaECSeriesItem | undefined
)[];
const readable = allSeries.filter((s) => countNumericPoints(s?.data, 1));
// A single point is not navigable, so it does not earn a focus stop either.
if (countNavigablePoints(readable) < MIN_NAVIGABLE_POINTS) {
return null;
}
const seriesIndex = readable.map((s) => allSeries.indexOf(s));
// Chart2Music always reads out an axis label, and the extension picks the wrong
// axis to name when there is no category axis, so label both explicitly.
const isTimeAxis = xAxis?.type === "time";
const x = {
label:
xAxis?.name ||
localize(
isTimeAxis
? "ui.components.history_charts.time"
: "ui.components.history_charts.category"
),
// Time series carry raw timestamps, which would otherwise be announced as
// epoch milliseconds.
format: isTimeAxis
? (value: number) => formatDateTime(new Date(value), locale, config)
: undefined,
};
const y = {
label: yAxis?.name || localize("ui.components.history_charts.value"),
};
let connection: ReturnType<typeof connect>;
try {
connection = connect(chart, {
cc: options.cc,
seriesIndex,
title: localize("ui.components.history_charts.chart"),
lang: SONIFICATION_LANGUAGES.has(locale.language)
? locale.language
: "en",
errorCallback: options.onError,
axes: { x, y },
});
} catch (err) {
options.onError(err instanceof Error ? err.message : String(err));
return null;
}
if (!connection) {
return null;
}
// Chart2Music bails out silently on mobile user agents, returning an instance
// that never wired anything up. Turning the caption container into a live
// region is the last thing it does, so use that as the "really connected" test
// rather than leaving a focus stop that does nothing.
if (!options.cc.hasAttribute("aria-live")) {
connection.dispose();
return null;
}
const connected = connection;
// The extension re-reads the chart from ECharts' own "finished" event. Run that
// through a guard of our own so a conversion failure cannot escape into
// ECharts' event dispatch and leave the chart half-rendered.
const update = () => {
try {
connected.update();
} catch (_err) {
// Keep whatever Chart2Music last read successfully.
}
};
chart.off("finished", connected.update);
chart.on("finished", update);
return {
update,
dispose: () => {
chart.off("finished", update);
connected.dispose();
},
};
};
+120 -1
View File
@@ -22,6 +22,7 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { ensureArray } from "../../common/array/ensure-array";
import { getAllGraphColors } from "../../common/color/colors";
@@ -47,6 +48,8 @@ import { isMac } from "../../util/is_mac";
import "../chips/ha-assist-chip";
import "../ha-icon-button";
import { formatTimeLabel } from "./axis-label";
import type { ChartSonification } from "./chart-sonification";
import { canSonifyChart, sonifyChart } from "./chart-sonification";
import { downSampleLineData } from "./down-sample";
import { wrapLitTooltipFormatter } from "./lit-tooltip-formatter";
@@ -146,6 +149,17 @@ export class HaChartBase extends LitElement {
@query(".chart") private _chartContainer?: HTMLDivElement;
@query(".sonification-output")
private _sonificationOutput?: HTMLDivElement;
private _sonification?: ChartSonification;
@state() private _sonificationLoading = false;
@state() private _sonificationUnavailable = false;
@state() private _sonificationFocusHeld = false;
private _modifierPressed = false;
private _isTouchDevice = "ontouchstart" in window;
@@ -198,6 +212,7 @@ export class HaChartBase extends LitElement {
while (this._listeners.length) {
this._listeners.pop()!();
}
this._disposeSonification();
this.chart?.dispose();
this.chart = undefined;
this._originalZrFlush = undefined;
@@ -312,6 +327,18 @@ export class HaChartBase extends LitElement {
}
if (changedProps.has("data") || changedProps.has("_hiddenDatasets")) {
chartOptions.series = this._getSeries();
// New data, or a series shown again, may well be convertible where the
// last set was not.
this._sonificationUnavailable = false;
// The connection is built from the series that had data at the time, so
// drop it and let the next focus rebuild it against the current set.
if (
this._sonification &&
(changedProps.has("_hiddenDatasets") ||
!canSonifyChart(this.data, this._hiddenDatasets))
) {
this._disposeSonification();
}
}
if (changedProps.has("options")) {
chartOptions = { ...chartOptions, ...this._createOptions() };
@@ -337,6 +364,9 @@ export class HaChartBase extends LitElement {
}
protected render() {
const sonifiable =
!this._sonificationUnavailable &&
canSonifyChart(this.data, this._hiddenDatasets);
return html`
<div
class="container ${classMap({ "has-height": !!this.height })}"
@@ -348,8 +378,23 @@ export class HaChartBase extends LitElement {
height: this.height ? undefined : `${this._getDefaultHeight()}px`,
})}
>
<div class="chart"></div>
<div
class="chart"
role=${ifDefined(sonifiable ? "application" : undefined)}
tabindex=${ifDefined(
sonifiable ? "0" : this._sonificationFocusHeld ? "-1" : undefined
)}
aria-label=${ifDefined(
sonifiable
? this.hass.localize("ui.components.history_charts.chart")
: undefined
)}
aria-busy=${ifDefined(this._sonificationLoading ? "true" : undefined)}
@focus=${this._handleChartFocus}
@blur=${this._handleChartBlur}
></div>
</div>
<div class="sonification-output"></div>
${this._renderLegend()}
<div class="top-controls ${classMap({ small: this.smallControls })}">
<slot name="search"></slot>
@@ -521,6 +566,60 @@ export class HaChartBase extends LitElement {
</div>`;
}
// Chart2Music adds ~45 kB gzipped, so it is only fetched once someone actually
// moves keyboard focus into a chart.
private async _handleChartFocus() {
// Dropping tabindex off the active element resets focus to the document and
// costs the user their place in the tab order, so stay programmatically
// focusable for as long as we hold focus, however we stop being sonifiable.
this._sonificationFocusHeld = true;
if (this._sonification || this._sonificationLoading || !this.chart) {
return;
}
this._sonificationLoading = true;
try {
const sonification = await sonifyChart(this.chart, {
cc: this._sonificationOutput!,
localize: this.hass.localize,
locale: this.hass.locale,
config: this.hass.config,
onError: () => {
// Charts the extension cannot describe stay silent rather than
// dropping an error on someone who only pressed Tab.
},
});
if (!this.isConnected || !this.chart) {
sonification?.dispose();
return;
}
if (!sonification) {
// Nothing came back, so stop offering a focus stop that leads nowhere.
this._sonificationUnavailable = true;
return;
}
this._sonification = sonification;
if (this.shadowRoot?.activeElement === this._chartContainer) {
// Chart2Music reads its summary and key hints on focus, which already
// happened while it was still being fetched.
this._chartContainer!.dispatchEvent(new FocusEvent("focus"));
}
} catch (_err) {
// Never let a failure here escape a focus handler. The tab stop stays, so
// focusing the chart again retries.
} finally {
this._sonificationLoading = false;
}
}
private _handleChartBlur() {
this._sonificationFocusHeld = false;
}
private _disposeSonification() {
this._sonification?.dispose();
this._sonification = undefined;
}
private _formatTimeLabel = (value: number | Date) =>
formatTimeLabel(
value,
@@ -533,6 +632,9 @@ export class HaChartBase extends LitElement {
if (this._loading) return;
this._loading = true;
try {
// The connection holds a reference to the chart instance, so it cannot
// outlive it. Focusing the chart again reconnects.
this._disposeSonification();
if (this.chart) {
this.chart.dispose();
}
@@ -1450,6 +1552,23 @@ export class HaChartBase extends LitElement {
height: 100%;
width: 100%;
}
.chart:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
border-radius: var(--ha-border-radius-sm);
}
/* Chart2Music renders its announcements here. It must stay in the layout for
screen readers to pick up the live region, so hide it visually only. */
.sonification-output {
position: absolute;
overflow: hidden;
clip: rect(0 0 0 0);
height: 1px;
width: 1px;
margin: -1px;
padding: 0;
border: 0;
}
.top-controls {
position: absolute;
top: var(--ha-space-4);
+10 -7
View File
@@ -163,12 +163,15 @@ export function generateStatisticsChartData(
return;
}
const isLineChart = chartType === "line";
// Points carry their time as epoch milliseconds, not Date objects:
// ECharts accepts both, but Chart2Music only reads a numeric x, and a
// Date would make it announce points by index instead of time.
// For bar charts, optionally center the bar within its time range. The
// centered time is shared by every series of this data point.
const barTime =
!isLineChart && centerBars
? new Date((start.getTime() + end.getTime()) / 2)
: start;
? (start.getTime() + end.getTime()) / 2
: start.getTime();
// Whether a gap needs to be drawn before this data point (line charts).
const drawGap =
isLineChart &&
@@ -182,10 +185,10 @@ export function generateStatisticsChartData(
if (drawGap) {
// if the end of the previous data doesn't match the start of the current data,
// we have to draw a gap so add a value at the end time, and then an empty value.
d.data!.push([prevEndTime!, ...prevValues![i]!]);
d.data!.push([prevEndTime!, null]);
d.data!.push([prevEndTime!.getTime(), ...prevValues![i]!]);
d.data!.push([prevEndTime!.getTime(), null]);
}
d.data!.push([start, ...dataValue!]);
d.data!.push([start.getTime(), ...dataValue!]);
// For band-top rows dataValues[i] is [diff, top]; the actual Y is
// the last element. For regular rows it's [value]. Same call works.
trackY(dataValue[dataValue.length - 1]);
@@ -387,7 +390,7 @@ export function generateStatisticsChartData(
const lastValues = prevValues;
if (chartType === "line" && lastEndTime && lastValues) {
statDataSets.forEach((d, i) => {
d.data!.push([lastEndTime, ...lastValues[i]!]);
d.data!.push([lastEndTime.getTime(), ...lastValues[i]!]);
});
}
@@ -423,7 +426,7 @@ export function generateStatisticsChartData(
} else {
val.push(currentValue);
}
statDataSets[i].data!.push([now, ...val]);
statDataSets[i].data!.push([now.getTime(), ...val]);
trackY(val[val.length - 1]);
});
}
+141
View File
@@ -0,0 +1,141 @@
import { mdiAlertOctagram, mdiCheckBold } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import "./ha-spinner";
import "./ha-svg-icon";
type ActionResult = "success" | "error";
// Keep in sync with ha-progress-button
const RESULT_DURATION = 2000;
// Long enough that fast actions go straight to their result without a flash
const SPINNER_DELAY = 150;
/**
* Home Assistant action result component
*
* @element ha-action-result
*
* @summary
* Wraps the content of an action trigger, for example an `ha-control-button`,
* and swaps it for a spinner while a slow action runs and for a success or
* error icon once it settles.
*
* @slot - Content of the trigger.
*/
@customElement("ha-action-result")
export class HaActionResult extends LitElement {
@state() private _loading = false;
@state() private _showSpinner = false;
@state() private _result?: ActionResult;
private _timeout?: number;
private _spinnerTimeout?: number;
public get busy(): boolean {
return this._loading;
}
public async run(action: Promise<unknown>): Promise<void> {
clearTimeout(this._timeout);
clearTimeout(this._spinnerTimeout);
this._result = undefined;
this._loading = true;
this._spinnerTimeout = window.setTimeout(() => {
this._showSpinner = true;
}, SPINNER_DELAY);
try {
await action;
this._result = "success";
} catch (_err) {
this._result = "error";
} finally {
clearTimeout(this._spinnerTimeout);
this._loading = false;
this._showSpinner = false;
this._timeout = window.setTimeout(() => {
this._result = undefined;
}, RESULT_DURATION);
}
}
public disconnectedCallback(): void {
super.disconnectedCallback();
clearTimeout(this._timeout);
clearTimeout(this._spinnerTimeout);
this._showSpinner = false;
this._result = undefined;
}
protected render() {
const busy = this._showSpinner || this._result !== undefined;
return html`
<span class="content ${busy ? "hidden" : ""}"><slot></slot></span>
${
busy
? html`<div class="indicator">${this._renderIndicator()}</div>`
: nothing
}
`;
}
private _renderIndicator() {
if (!this._result) {
return html`<ha-spinner></ha-spinner>`;
}
return html`
<ha-svg-icon
class=${this._result}
.path=${this._result === "success" ? mdiCheckBold : mdiAlertOctagram}
></ha-svg-icon>
`;
}
static styles = css`
/* Prefer no box so the host inherits the layout of the slot it sits in.
A ::slotted() rule in the host component can still override this. */
:host {
display: contents;
}
.content {
transition: opacity var(--ha-animation-duration-instant) ease-in-out;
}
.content.hidden {
opacity: 0;
}
.indicator {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
animation: fade-in var(--ha-animation-duration-instant) ease-in-out;
}
ha-spinner {
--ha-spinner-size: var(--mdc-icon-size, 24px);
--track-width: 2px;
}
/* Overshoot so the icon lands with a small pop */
ha-svg-icon {
animation: scale var(--ha-animation-duration-fast)
cubic-bezier(0.34, 1.56, 0.64, 1);
}
ha-svg-icon.success {
color: var(--ha-color-on-success-quiet);
}
ha-svg-icon.error {
color: var(--ha-color-on-danger-quiet);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-action-result": HaActionResult;
}
}
+63 -9
View File
@@ -12,6 +12,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { transform } from "../common/decorators/transform";
import { supportsFeature } from "../common/entity/supports-feature";
import type { LocalizeFunc } from "../common/translations/localize";
import {
@@ -24,6 +25,7 @@ import {
import {
configContext,
connectionContext,
internationalizationContext,
statesContext,
} from "../data/context";
import { ConversationEntityFeature } from "../data/conversation";
@@ -33,8 +35,13 @@ import type {
HomeAssistant,
HomeAssistantConfig,
HomeAssistantConnection,
HomeAssistantInternationalization,
} from "../types";
import { AudioRecorder } from "../util/audio-recorder";
import {
findAvailableLanguage,
getTranslation,
} from "../util/common-translation";
import { documentationUrl } from "../util/documentation-url";
import "./ha-alert";
import "./ha-markdown";
@@ -67,6 +74,17 @@ export const assistPipelineChanged = (
current: AssistPipeline | undefined
): boolean => previous?.id !== current?.id;
export const greetingTranslationLanguage = (
pipelineLanguage: string | undefined,
interfaceLanguage: string | undefined
): string | undefined => {
if (!pipelineLanguage || pipelineLanguage === interfaceLanguage) {
return undefined;
}
const language = findAvailableLanguage(pipelineLanguage);
return language && language !== interfaceLanguage ? language : undefined;
};
@customElement("ha-assist-chat")
export class HaAssistChat extends LitElement {
@property({ attribute: false }) public pipeline?: AssistPipeline;
@@ -101,6 +119,13 @@ export class HaAssistChat extends LitElement {
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, string>({
transformer: ({ language }) => language,
})
private _language!: string;
@state()
@consume({ context: statesContext, subscribe: true })
private _states!: HomeAssistant["states"];
@@ -115,6 +140,8 @@ export class HaAssistChat extends LitElement {
private _conversationId: string | null = null;
private _greetingLoadToken = 0;
private _initialPromptSubmitted = false;
private _audioRecorder?: AudioRecorder;
@@ -131,17 +158,44 @@ export class HaAssistChat extends LitElement {
(changedProperties.has("pipeline") &&
assistPipelineChanged(changedProperties.get("pipeline"), this.pipeline))
) {
this._conversation = [
{
who: "hass",
text: this._localize("ui.dialogs.voice_command.how_can_i_help"),
thinking: "",
tool_calls: {},
},
];
this._conversation = [];
this._loadGreeting();
}
}
private async _loadGreeting(): Promise<void> {
const token = ++this._greetingLoadToken;
const language = greetingTranslationLanguage(
this.pipeline?.language,
this._language
);
let greeting: string | undefined;
if (language) {
try {
const result = await getTranslation(null, language, false);
if (result.language === language) {
greeting = result.data["ui.dialogs.voice_command.how_can_i_help"];
}
} catch (_err) {
// Translation failed to load; fall back to the interface language.
}
}
if (token !== this._greetingLoadToken) {
// The pipeline changed while loading; a newer load owns the greeting.
return;
}
this._conversation = [
{
who: "hass",
text:
greeting || this._localize("ui.dialogs.voice_command.how_can_i_help"),
thinking: "",
tool_calls: {},
},
...this._conversation,
];
}
protected firstUpdated(changedProperties: PropertyValues<this>): void {
super.firstUpdated(changedProperties);
if (
@@ -157,7 +211,7 @@ export class HaAssistChat extends LitElement {
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.has("_conversation")) {
if (changedProps.has("_conversation") && this._conversation.length) {
this._scrollMessagesBottom();
}
if (
-161
View File
@@ -1,161 +0,0 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { computeAttributeNameDisplay } from "../common/entity/compute_attribute_display";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import {
STATE_ATTRIBUTES,
STATE_ATTRIBUTES_DOMAIN_CLASS,
} from "../data/entity/entity_attributes";
import { haStyle } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-attribute-value";
import "./ha-expansion-panel";
@customElement("ha-attributes")
class HaAttributes extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj?: HassEntity;
@property({ attribute: "extra-filters" }) public extraFilters?: string;
@state() private _expanded = false;
private get _filteredAttributes() {
return this._computeDisplayAttributes(
STATE_ATTRIBUTES.concat(
this.extraFilters ? this.extraFilters.split(",") : [],
(this.stateObj &&
STATE_ATTRIBUTES_DOMAIN_CLASS[computeStateDomain(this.stateObj)]?.[
this.stateObj.attributes?.device_class
]) ||
[]
)
);
}
protected willUpdate(changedProperties: PropertyValues<this>): void {
if (
changedProperties.has("extraFilters") ||
changedProperties.has("stateObj")
) {
this.toggleAttribute("empty", this._filteredAttributes.length === 0);
}
}
protected render() {
if (!this.stateObj) {
return nothing;
}
const attributes = this._filteredAttributes;
if (attributes.length === 0) {
return nothing;
}
return html`
<ha-expansion-panel
.header=${this.hass.localize(
"ui.components.attributes.expansion_header"
)}
outlined
@expanded-will-change=${this._expandedChanged}
>
<div class="attribute-container">
${
this._expanded
? html`
${attributes.map(
(attribute) => html`
<div class="data-entry">
<div class="key">
${computeAttributeNameDisplay(
this.hass.localize,
this.stateObj!,
this.hass.entities,
attribute
)}
</div>
<div class="value">
<ha-attribute-value
.attribute=${attribute}
.stateObj=${this.stateObj}
></ha-attribute-value>
</div>
</div>
`
)}
`
: ""
}
</div>
</ha-expansion-panel>
${
this.stateObj.attributes.attribution
? html`
<div class="attribution">
${this.stateObj.attributes.attribution}
</div>
`
: ""
}
`;
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
.attribute-container {
margin-bottom: 8px;
direction: ltr;
}
.data-entry {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.data-entry .value {
max-width: 60%;
overflow-wrap: break-word;
text-align: right;
}
.key {
flex-grow: 1;
}
.attribution {
color: var(--secondary-text-color);
text-align: center;
margin-top: 16px;
}
hr {
border-color: var(--divider-color);
border-bottom: none;
margin: 16px 0;
}
`,
];
}
private _computeDisplayAttributes(filtersArray: string[]): string[] {
if (!this.stateObj) {
return [];
}
return Object.keys(this.stateObj.attributes).filter(
(key) => filtersArray.indexOf(key) === -1
);
}
private _expandedChanged(ev) {
this._expanded = ev.detail.expanded;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-attributes": HaAttributes;
}
}
+50 -167
View File
@@ -21,6 +21,7 @@ import {
} from "../common/datetime/calc_date";
import type { DateRange } from "../common/datetime/calc_date_range";
import { calcDateRange } from "../common/datetime/calc_date_range";
import { formatTime24h } from "../common/datetime/format_time";
import { DEFAULT_ENTITY_NAME } from "../common/entity/compute_entity_name_display";
import { formatNumber } from "../common/number/format_number";
import { normalizeValueBySIPrefix } from "../common/number/normalize-by-si-prefix";
@@ -798,8 +799,8 @@ const clearEnergyCollectionPreferences = (hass: HomeAssistant) => {
};
const scheduleHourlyRefresh = (collection: EnergyCollection) => {
if (collection._refreshTimeout !== undefined) {
window.clearTimeout(collection._refreshTimeout);
if (collection._refreshTimeout) {
clearTimeout(collection._refreshTimeout);
}
if (collection._active && (!collection.end || collection.end > new Date())) {
@@ -858,92 +859,18 @@ export const getEnergyDefaultPeriodStorageKey = (
return `energy-default-period-${key}`;
};
// When today's first hourly statistic becomes available (01:00 in the
// configured timezone). Rolling the statistics view over at midnight would
// show an empty graph.
export const getEnergyFirstStatisticAt = (
now: Date,
locale: HomeAssistant["locale"],
config: HomeAssistant["config"]
): Date => addHours(calcDate(now, startOfDay, locale, config), 1);
// The statistics Energy view shows yesterday until 01:00 so the graph is not
// empty. The real-time "Now" view never does this — it has live data.
export const shouldFallbackEnergyPeriodToYesterday = (
midnightRollover: boolean,
now: Date,
locale: HomeAssistant["locale"],
config: HomeAssistant["config"]
): boolean =>
!midnightRollover &&
now.getTime() < getEnergyFirstStatisticAt(now, locale, config).getTime();
// Live day used while a rollover timer is scheduled (today, or the hour-0
// yesterday fallback). Custom dates do not use this. If the user already
// picked today during hour 0, keep today rather than snapping back.
export const getEnergyLiveDayPeriod = (
midnightRollover: boolean,
now: Date,
locale: HomeAssistant["locale"],
config: HomeAssistant["config"],
currentStart: Date
): { start: Date; end: Date } => {
const todayStart = calcDate(now, startOfDay, locale, config);
if (
shouldFallbackEnergyPeriodToYesterday(
midnightRollover,
now,
locale,
config
) &&
currentStart.getTime() !== todayStart.getTime()
) {
const yesterday = calcDate(now, addDays, locale, config, -1);
return {
start: calcDate(yesterday, startOfDay, locale, config),
end: calcDate(yesterday, endOfDay, locale, config),
};
}
return {
start: todayStart,
end: calcDate(now, endOfDay, locale, config),
};
};
// When does the collection's day period need to roll over to the next day?
// With `midnightRollover` (the real-time "Now" view) it rolls over right at
// midnight. Otherwise it waits an hour, until the new day's first hourly
// statistic exists — rolling over at midnight would show an empty graph.
// Pass `periodStart` when the collection is on a specific day: hour-0
// yesterday (and any older stale live day) must wake at today 01:00, not
// tomorrow 01:00. Keep tomorrow 01:00 only when the user already picked today.
export const getNextEnergyPeriodStart = (
midnightRollover: boolean,
now: Date,
locale: HomeAssistant["locale"],
config: HomeAssistant["config"],
periodStart?: Date
config: HomeAssistant["config"]
): Date => {
const todayStart = calcDate(now, startOfDay, locale, config);
if (
periodStart &&
shouldFallbackEnergyPeriodToYesterday(
midnightRollover,
now,
locale,
config
) &&
periodStart.getTime() !== todayStart.getTime()
) {
return getEnergyFirstStatisticAt(now, locale, config);
}
// Next midnight in the configured zone, not browser-local addDays, so a
// DST transition cannot skip a server-tz day.
const nextMidnight = addMilliseconds(
calcDate(now, endOfDay, locale, config),
1
);
return midnightRollover ? nextMidnight : addHours(nextMidnight, 1);
const dayEnd = calcDate(now, endOfDay, locale, config);
return midnightRollover ? addMilliseconds(dayEnd, 1) : addHours(dayEnd, 1);
};
export const getEnergyDataCollection = (
@@ -1002,80 +929,12 @@ export const getEnergyDataCollection = (
}
) as EnergyCollection;
collection._active = 0;
collection.prefs = options.prefs;
// True while the collection is tracking the rolling "today" (or hour-0
// yesterday) day. Cleared when the user picks a custom range.
let followLiveDay = false;
const applyLiveDayPeriod = (now: Date): boolean => {
const live = getEnergyLiveDayPeriod(
midnightRollover,
now,
hass.locale,
hass.config,
collection.start
);
const changed =
collection.start.getTime() !== live.start.getTime() ||
collection.end?.getTime() !== live.end.getTime();
collection.start = live.start;
collection.end = live.end;
return changed;
};
const clearUpdatePeriodTimeout = () => {
if (collection._updatePeriodTimeout !== undefined) {
window.clearTimeout(collection._updatePeriodTimeout);
collection._updatePeriodTimeout = undefined;
}
};
const scheduleUpdatePeriod = () => {
clearUpdatePeriodTimeout();
const scheduledAt = new Date();
collection._updatePeriodTimeout = window.setTimeout(
() => {
if (applyLiveDayPeriod(new Date())) {
collection.refresh();
}
scheduleUpdatePeriod();
},
Math.max(
0,
getNextEnergyPeriodStart(
midnightRollover,
scheduledAt,
hass.locale,
hass.config,
collection.start
).getTime() - scheduledAt.getTime()
)
);
};
const origSubscribe = collection.subscribe;
collection.subscribe = (subscriber: (data: EnergyData) => void) => {
// Catch up before origSubscribe so the first fetch uses the live day.
// Refresh only when state already exists: cold subscribe fetches via
// origSubscribe; a re-subscribe inside the 5s unsub grace does not.
const needsRefresh =
followLiveDay &&
applyLiveDayPeriod(new Date()) &&
collection.state !== undefined;
if (followLiveDay) {
scheduleUpdatePeriod();
}
const unsub = origSubscribe(subscriber);
collection._active++;
if (needsRefresh) {
collection.refresh();
}
if (collection._refreshTimeout === undefined) {
scheduleHourlyRefresh(collection);
}
@@ -1083,55 +942,79 @@ export const getEnergyDataCollection = (
return () => {
collection._active--;
if (collection._active < 1) {
if (collection._refreshTimeout !== undefined) {
window.clearTimeout(collection._refreshTimeout);
collection._refreshTimeout = undefined;
}
clearUpdatePeriodTimeout();
clearTimeout(collection._refreshTimeout);
collection._refreshTimeout = undefined;
}
unsub();
};
};
collection._active = 0;
collection.prefs = options.prefs;
const now = new Date();
const hour = formatTime24h(now, hass.locale, hass.config).split(":")[0];
// Set start to start of today if we have data for today, otherwise yesterday.
// The real-time "Now" view always tracks today; it shows live data even
// before today's first statistic exists, so it never falls back to yesterday.
const now = new Date();
const preferredPeriod =
(localStorage.getItem(
getEnergyDefaultPeriodStorageKey(hass, options.key)
) as DateRange) || "today";
const period =
preferredPeriod === "today" &&
shouldFallbackEnergyPeriodToYesterday(
midnightRollover,
now,
hass.locale,
hass.config
)
preferredPeriod === "today" && hour === "0" && !midnightRollover
? "yesterday"
: preferredPeriod;
const [start, end] = calcDateRange(hass.locale, hass.config, period);
collection.start = calcDate(start, startOfDay, hass.locale, hass.config);
collection.end = calcDate(end, endOfDay, hass.locale, hass.config);
followLiveDay = preferredPeriod === "today";
const scheduleUpdatePeriod = () => {
collection._updatePeriodTimeout = window.setTimeout(
() => {
collection.start = calcDate(
new Date(),
startOfDay,
hass.locale,
hass.config
);
collection.end = calcDate(
new Date(),
endOfDay,
hass.locale,
hass.config
);
collection.refresh();
scheduleUpdatePeriod();
},
getNextEnergyPeriodStart(
midnightRollover,
new Date(),
hass.locale,
hass.config
).getTime() - Date.now()
);
};
scheduleUpdatePeriod();
collection.isActive = () => !!collection._active;
collection.clearPrefs = () => {
collection.prefs = undefined;
};
collection.setPeriod = (newStart: Date, newEnd?: Date) => {
clearUpdatePeriodTimeout();
if (collection._updatePeriodTimeout) {
clearTimeout(collection._updatePeriodTimeout);
collection._updatePeriodTimeout = undefined;
}
collection.start = newStart;
collection.end = newEnd;
const periodNow = new Date();
followLiveDay =
if (
collection.start.getTime() ===
calcDate(periodNow, startOfDay, hass.locale, hass.config).getTime() &&
calcDate(new Date(), startOfDay, hass.locale, hass.config).getTime() &&
collection.end?.getTime() ===
calcDate(periodNow, endOfDay, hass.locale, hass.config).getTime();
if (followLiveDay) {
calcDate(new Date(), endOfDay, hass.locale, hass.config).getTime()
) {
scheduleUpdatePeriod();
}
};
-45
View File
@@ -1,38 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { formatDurationDigital } from "../../common/datetime/format_duration";
import type { FrontendLocaleData } from "../translation";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
export const STATE_ATTRIBUTES = [
"entity_id",
"assumed_state",
"attribution",
"custom_ui_more_info",
"custom_ui_state_card",
"device_class",
"editable",
"emulated_hue_name",
"emulated_hue",
"entity_picture",
"event_types",
"friendly_name",
"haaska_hidden",
"haaska_name",
"icon",
"initial_state",
"last_reset",
"restored",
"state_class",
"supported_features",
"unit_of_measurement",
"available_tones",
];
export const STATE_ATTRIBUTES_DOMAIN_CLASS = {
sensor: {
enum: ["options"],
},
};
export const TEMPERATURE_ATTRIBUTES = new Set([
"temperature",
@@ -210,15 +177,3 @@ export const STATE_CONDITION_HIDDEN_ATTRIBUTES = [
"swing_modes",
"token",
];
export const computeShownAttributes = (stateObj: HassEntity) => {
const domain = computeStateDomain(stateObj);
const filtersArray = STATE_ATTRIBUTES.concat(
STATE_ATTRIBUTES_DOMAIN_CLASS[domain]?.[
stateObj.attributes?.device_class
] || []
);
return Object.keys(stateObj.attributes).filter(
(key) => filtersArray.indexOf(key) === -1
);
};
@@ -34,12 +34,4 @@ export const moreInfoControlStyle = css`
.buttons > * {
margin: var(--ha-space-2);
}
ha-attributes {
display: block;
width: 100%;
}
ha-more-info-control-select-container + ha-attributes:not([empty]) {
margin-top: var(--ha-space-4);
}
`;
+15 -20
View File
@@ -5,7 +5,6 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeAttributeNameDisplay } from "../../common/entity/compute_attribute_display";
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
import { computeFloorName } from "../../common/entity/compute_floor_name";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
@@ -15,7 +14,6 @@ import "../../components/ha-attribute-value";
import "../../components/item/ha-list-item-value";
import "../../components/list/ha-grouped-list";
import type { LocalizeKeys } from "../../common/translations/localize";
import { computeShownAttributes } from "../../data/entity/entity_attributes";
import { labelsContext } from "../../data/context";
import type { ExtEntityRegistryEntry } from "../../data/entity/entity_registry";
import type { LabelRegistryEntry } from "../../data/label/label_registry";
@@ -26,6 +24,7 @@ import type { FeatureEnum } from "../../common/entity/get_domain_features";
import { getFeatures } from "../../common/entity/get_domain_features";
import { supportsFeature } from "../../common/entity/supports-feature";
import { titleCase } from "../../common/string/title-case";
import { stringCompare } from "../../common/string/compare";
interface DetailsViewParams {
entityId: string;
@@ -215,7 +214,7 @@ class HaMoreInfoDetails extends LitElement {
stateObj: HassEntity
): {
stateEntries: DetailEntry[];
attributes: string[];
attributes: { name: string; label: string }[];
yamlData: {
state: {
translated: string;
@@ -228,11 +227,14 @@ class HaMoreInfoDetails extends LitElement {
} => {
const translatedState = this.hass.formatEntityState(stateObj);
const detailsAttributes = computeShownAttributes(stateObj);
const detailsAttributeSet = new Set(detailsAttributes);
const builtInAttributes = Object.keys(stateObj.attributes).filter(
(attribute) => !detailsAttributeSet.has(attribute)
);
const attributes = Object.keys(stateObj.attributes)
.map((a) => ({
name: a,
label: this.hass.formatEntityAttributeName(stateObj, a),
}))
.sort((a, b) =>
stringCompare(a.label, b.label, this.hass.locale.language)
);
return {
stateEntries: [
@@ -253,7 +255,7 @@ class HaMoreInfoDetails extends LitElement {
value: this._formatTimestamp(stateObj.last_updated),
},
],
attributes: [...detailsAttributes, ...builtInAttributes],
attributes,
yamlData: {
state: {
translated: translatedState,
@@ -289,7 +291,7 @@ class HaMoreInfoDetails extends LitElement {
);
}
private _renderAttributes(attributes: string[]) {
private _renderAttributes(attributes: { name: string; label: string }[]) {
if (attributes.length === 0) {
return html`<div class="empty">
${this.hass.localize("ui.common.none")}
@@ -304,20 +306,13 @@ class HaMoreInfoDetails extends LitElement {
return attributes.map(
(attribute) => html`
<ha-list-item-value
.label=${computeAttributeNameDisplay(
this.hass.localize,
this._stateObj!,
this.hass.entities,
attribute
)}
>
<ha-list-item-value .label=${attribute.label}>
${
attribute === "supported_features" && featureEnum
attribute.name === "supported_features" && featureEnum
? this._renderFeatures(featureEnum, this._stateObj!)
: html`
<ha-attribute-value
.attribute=${attribute}
.attribute=${attribute.name}
.stateObj=${this._stateObj}
></ha-attribute-value>
`
@@ -1,13 +1,15 @@
import { consume } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import {
consumeEntityState,
consumeLocalize,
} from "../../../common/decorators/consume-context-entry";
import { computeDomain } from "../../../common/entity/compute_domain";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-action-result";
import type { HaActionResult } from "../../../components/ha-action-result";
import "../../../components/ha-control-button";
import "../../../components/ha-control-button-group";
import { apiContext, servicesContext } from "../../../data/context";
@@ -63,8 +65,10 @@ class HuiButtonCardFeature extends LitElement implements LovelaceCardFeature {
@state() private _config?: ButtonCardFeatureConfig;
@query("ha-action-result") private _result!: HaActionResult;
private _pressButton() {
if (!this._stateObj) return;
if (!this._stateObj || this._result.busy) return;
const domain = computeDomain(this._stateObj.entity_id);
const service =
@@ -99,7 +103,7 @@ class HuiButtonCardFeature extends LitElement implements LovelaceCardFeature {
forwardHaptic(this, "light");
this._api.callService(domain, service, serviceData);
this._result.run(this._api.callService(domain, service, serviceData));
}
static getStubConfig(): ButtonCardFeatureConfig {
@@ -132,7 +136,9 @@ class HuiButtonCardFeature extends LitElement implements LovelaceCardFeature {
class="press-button"
@click=${this._pressButton}
>
${this._config.action_name ?? this._localize("ui.card.button.press")}
<ha-action-result>
${this._config.action_name ?? this._localize("ui.card.button.press")}
</ha-action-result>
</ha-control-button>
</ha-control-button-group>
`;
+3
View File
@@ -49,6 +49,8 @@ interface LovelacePanelConfig {
mode: "yaml" | "storage";
}
const EXTERNALLY_UPDATED_TOAST_ID = "lovelace-externally-updated";
let editorLoaded = false;
let resourcesLoaded = false;
@@ -265,6 +267,7 @@ export class LovelacePanel extends LitElement {
return;
}
showToast(this, {
id: EXTERNALLY_UPDATED_TOAST_ID,
message: this.hass!.localize(
"ui.panel.lovelace.externally_updated_toast.message"
),
+17 -7
View File
@@ -1,22 +1,26 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../components/entity/ha-entity-toggle";
import { customElement, property, query } from "lit/decorators";
import { computeDomain } from "../common/entity/compute_domain";
import "../components/entity/state-info";
import "../components/ha-action-result";
import type { HaActionResult } from "../components/ha-action-result";
import "../components/ha-control-button";
import { UNAVAILABLE } from "../data/entity/entity";
import { haStyle } from "../resources/styles";
import type { HomeAssistant } from "../types";
@customElement("state-card-button")
class StateCardButton extends LitElement {
export class StateCardButton extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj!: HassEntity;
@property({ attribute: "in-dialog", type: Boolean }) public inDialog = false;
@query("ha-action-result") private _result!: HaActionResult;
protected render() {
const stateObj = this.stateObj;
return html`
@@ -30,7 +34,9 @@ class StateCardButton extends LitElement {
.disabled=${stateObj.state === UNAVAILABLE}
@click=${this._pressButton}
>
${this.hass.localize("ui.card.button.press")}
<ha-action-result>
${this.hass.localize("ui.card.button.press")}
</ha-action-result>
</ha-control-button>
</div>
`;
@@ -38,9 +44,13 @@ class StateCardButton extends LitElement {
private _pressButton(ev: Event) {
ev.stopPropagation();
this.hass.callService("button", "press", {
entity_id: this.stateObj.entity_id,
});
if (this._result.busy) return;
this._result.run(
this.hass.callService(computeDomain(this.stateObj.entity_id), "press", {
entity_id: this.stateObj.entity_id,
})
);
}
static get styles(): CSSResultGroup {
+3 -58
View File
@@ -1,63 +1,8 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../components/entity/ha-entity-toggle";
import "../components/entity/state-info";
import "../components/ha-control-button";
import { UNAVAILABLE } from "../data/entity/entity";
import { haStyle } from "../resources/styles";
import type { HomeAssistant } from "../types";
import { customElement } from "lit/decorators";
import { StateCardButton } from "./state-card-button";
@customElement("state-card-input_button")
class StateCardInputButton extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj!: HassEntity;
@property({ attribute: "in-dialog", type: Boolean }) public inDialog = false;
protected render() {
const stateObj = this.stateObj;
return html`
<div class="horizontal justified layout">
<state-info
.hass=${this.hass}
.stateObj=${stateObj}
.inDialog=${this.inDialog}
></state-info>
<ha-control-button
.disabled=${stateObj.state === UNAVAILABLE}
@click=${this._pressButton}
>
${this.hass.localize("ui.card.button.press")}
</ha-control-button>
</div>
`;
}
private _pressButton(ev: Event) {
ev.stopPropagation();
this.hass.callService("input_button", "press", {
entity_id: this.stateObj.entity_id,
});
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
ha-control-button {
width: auto;
min-width: 40px;
--control-button-padding: 0 var(--ha-space-4);
--control-button-focus-color: var(--primary-text-color);
--control-button-icon-color: var(--feature-color);
}
`,
];
}
}
class StateCardInputButton extends StateCardButton {}
declare global {
interface HTMLElementTagNameMap {
+5 -1
View File
@@ -1126,7 +1126,11 @@
"zoom_reset": "Reset zoom",
"expand_legend": "More",
"collapse_legend": "Less",
"toggle_visibility": "Toggle visibility"
"toggle_visibility": "Toggle visibility",
"chart": "Chart",
"time": "Time",
"value": "Value",
"category": "Category"
},
"map": {
"error": "Unable to load map"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,192 @@
import { describe, expect, it } from "vitest";
import { canSonifyChart } from "../../../src/components/chart/chart-sonification";
import type { HaECSeries } from "../../../src/resources/echarts/echarts";
const series = (
items: { type: string; id?: string; name?: string; data?: unknown[] }[]
) => items as unknown as HaECSeries;
describe("canSonifyChart", () => {
it("accepts line and bar series that carry data", () => {
expect(
canSonifyChart(
series([
{
type: "line",
data: [
[0, 1],
[1, 2],
],
},
])
)
).toBe(true);
expect(
canSonifyChart(
series([
{
type: "bar",
data: [
[0, 1],
[1, 2],
],
},
])
)
).toBe(true);
});
it("accepts a chart whose empty placeholder series sits beside real data", () => {
expect(
canSonifyChart(
series([
{ type: "bar", data: [] },
{
type: "bar",
data: [
[0, 1],
[1, 2],
],
},
])
)
).toBe(true);
});
it("rejects series types the extension cannot convert", () => {
expect(
canSonifyChart(
series([
{
type: "custom",
data: [
[0, 1],
[1, 2],
],
},
])
)
).toBe(false);
expect(
canSonifyChart(
series([
{
type: "line",
data: [
[0, 1],
[1, 2],
],
},
{
type: "sankey",
data: [
[0, 1],
[1, 2],
],
},
])
)
).toBe(false);
});
it("rejects charts with nothing plotted", () => {
expect(canSonifyChart(series([]))).toBe(false);
expect(canSonifyChart(series([{ type: "line", data: [] }]))).toBe(false);
expect(canSonifyChart(series([{ type: "line" }]))).toBe(false);
});
it("rejects value-first pairs, which the extension reads as a non-numeric y", () => {
// The energy device charts encode [amount, categoryName]. Chart2Music takes
// value as [x, y], so every one of those points is dropped.
expect(
canSonifyChart(
series([
{
type: "bar",
data: [
{ value: [12.5, "sensor.a"] },
{ value: [8.25, "sensor.b"] },
],
},
])
)
).toBe(false);
});
it("rejects a chart left with a single readable point", () => {
// The device pie's slices are all unreadable, leaving only its one-number
// total series — nothing the arrow keys could move between.
expect(
canSonifyChart(
series([
{ type: "pie", data: [{ value: [12.5, "sensor.a"] }] },
{ type: "pie", data: [24.5] },
])
)
).toBe(false);
});
it("ignores the points of legend-hidden series", () => {
// Hiding a series strips its data before it reaches ECharts, so it cannot
// be navigated either.
const chart = series([
{ type: "line", id: "a", data: [[0, 1]] },
{ type: "line", name: "b", data: [[1, 2]] },
]);
expect(canSonifyChart(chart, new Set())).toBe(true);
expect(canSonifyChart(chart, new Set(["b"]))).toBe(false);
expect(canSonifyChart(chart, new Set(["a", "b"]))).toBe(false);
});
it("rejects a series whose points are all gaps", () => {
// Chart2Music drops any point without a numeric y, so a series of nothing
// but nulls converts to an empty group and makes it throw.
expect(
canSonifyChart(
series([
{
type: "line",
data: [
[0, null],
[1, null],
],
},
])
)
).toBe(false);
});
it("accepts a series that only becomes numeric partway through", () => {
expect(
canSonifyChart(
series([
{
type: "line",
data: [
[0, null],
[1, 21.5],
[2, 21.9],
],
},
])
)
).toBe(true);
});
it("reads the y out of object-form points", () => {
expect(
canSonifyChart(
series([
{ type: "bar", data: [{ value: [0, 0.28] }, { value: [1, 0.31] }] },
])
)
).toBe(true);
expect(
canSonifyChart(
series([
{ type: "bar", data: [{ value: [0, null] }, { value: [1, null] }] },
])
)
).toBe(false);
});
});
+32 -1
View File
@@ -1,10 +1,19 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { AssistPipeline } from "../../src/data/assist_pipeline";
import {
assistPipelineChanged,
greetingTranslationLanguage,
initialPromptToSubmit,
} from "../../src/components/ha-assist-chat";
// common-translation depends on build-time defines and generated translation
// metadata that are not available in unit tests.
vi.mock("../../src/util/common-translation", () => ({
findAvailableLanguage: (language: string) =>
({ en: "en", "en-US": "en", nl: "nl", pl: "pl" })[language],
getTranslation: vi.fn(),
}));
describe("initialPromptToSubmit", () => {
it("returns a trimmed prompt when submission is requested", () => {
expect(initialPromptToSubmit(" Turn on the lights ", true)).toBe(
@@ -36,3 +45,25 @@ describe("ha-assist-chat pipeline updates", () => {
).toBe(true);
});
});
describe("greetingTranslationLanguage", () => {
it("returns the pipeline language when it differs from the interface language", () => {
expect(greetingTranslationLanguage("pl", "en")).toBe("pl");
});
it("returns undefined when the pipeline language matches the interface language", () => {
expect(greetingTranslationLanguage("nl", "nl")).toBeUndefined();
});
it("returns undefined when the pipeline language resolves to the interface language", () => {
expect(greetingTranslationLanguage("en-US", "en")).toBeUndefined();
});
it("returns undefined when there is no pipeline language", () => {
expect(greetingTranslationLanguage(undefined, "en")).toBeUndefined();
});
it("returns undefined when the pipeline language has no available translation", () => {
expect(greetingTranslationLanguage("xx", "en")).toBeUndefined();
});
});
-116
View File
@@ -1,116 +0,0 @@
import {
addDays,
addHours,
addMilliseconds,
endOfDay,
startOfDay,
} from "date-fns";
import type { HassConfig } from "home-assistant-js-websocket";
import { afterAll, assert, beforeAll, describe, it } from "vitest";
import { calcDate } from "../../src/common/datetime/calc_date";
import {
type FrontendLocaleData,
NumberFormat,
TimeFormat,
FirstWeekday,
DateFormat,
TimeZone,
} from "../../src/data/translation";
import {
getEnergyFirstStatisticAt,
getEnergyLiveDayPeriod,
getNextEnergyPeriodStart,
} from "../../src/data/energy";
const locale: FrontendLocaleData = {
language: "en",
number_format: NumberFormat.language,
time_format: TimeFormat.language,
date_format: DateFormat.language,
time_zone: TimeZone.server,
first_weekday: FirstWeekday.language,
};
const tokyoConfig = { time_zone: "Asia/Tokyo" } as HassConfig;
// Dedicated file so Europe/Berlin can be pinned without leaking into other
// suites. Vitest's default TZ is Etc/UTC, where browser-local addDays and
// server-zone math often agree — so UTC CI would miss this regression.
describe("energy period DST (Europe/Berlin local TZ)", () => {
const originalTz = process.env.TZ;
beforeAll(() => {
process.env.TZ = "Europe/Berlin";
});
afterAll(() => {
process.env.TZ = originalTz;
});
it("uses a DST-fallback browser zone for this file", () => {
// 24 Oct 2026 is still CEST. If TZ pinning failed, offset is 0 (UTC).
assert.equal(
new Date("2026-10-24T14:30:00.000Z").getTimezoneOffset(),
-120
);
});
it("schedules tomorrow 01:00 in the server zone, not via browser-local addDays", () => {
// 23:30 JST on 24 Oct 2026. Europe/Berlin falls back on 25 Oct; local
// addDays(now, 1) then startOfDay in Tokyo skips to 26 Oct 01:00 JST.
const now = new Date("2026-10-24T14:30:00.000Z");
// Compare to the tz-internal formula rather than a hardcoded instant:
// the formula is the production invariant, and a pinned ISO string would
// not explain why UTC CI cannot catch a raw addDays(now, 1) regression.
const tzInternal = addHours(
addMilliseconds(calcDate(now, endOfDay, locale, tokyoConfig), 1),
1
);
const browserLocalAddDays = getEnergyFirstStatisticAt(
addDays(now, 1),
locale,
tokyoConfig
);
const actual = getNextEnergyPeriodStart(false, now, locale, tokyoConfig);
assert.equal(actual.getTime(), tzInternal.getTime());
assert.notEqual(actual.getTime(), browserLocalAddDays.getTime());
assert.equal(
actual.getTime(),
new Date("2026-10-24T16:00:00.000Z").getTime()
);
assert.equal(
browserLocalAddDays.getTime(),
new Date("2026-10-25T16:00:00.000Z").getTime()
);
});
it("resolves yesterday in the server zone, not via browser-local addDays", () => {
// 00:30 JST on 26 Oct 2026. Browser-local addDays can land two days back.
const now = new Date("2026-10-25T15:30:00.000Z");
const tzInternal = calcDate(
calcDate(now, addDays, locale, tokyoConfig, -1),
startOfDay,
locale,
tokyoConfig
);
const browserLocalAddDays = calcDate(
addDays(now, -1),
startOfDay,
locale,
tokyoConfig
);
const live = getEnergyLiveDayPeriod(
false,
now,
locale,
tokyoConfig,
new Date(0)
);
assert.equal(live.start.getTime(), tzInternal.getTime());
assert.notEqual(live.start.getTime(), browserLocalAddDays.getTime());
});
});
+20 -409
View File
@@ -1,6 +1,6 @@
import { addDays, endOfDay, startOfDay } from "date-fns";
import { startOfDay } from "date-fns";
import type { HassConfig } from "home-assistant-js-websocket";
import { afterEach, assert, describe, it, vi } from "vitest";
import { assert, describe, it } from "vitest";
import { calcDate } from "../../src/common/datetime/calc_date";
import {
@@ -20,11 +20,6 @@ import {
formatPowerShort,
getNextEnergyPeriodStart,
getEnergyDefaultPeriodStorageKey,
getEnergyFirstStatisticAt,
getEnergyLiveDayPeriod,
shouldFallbackEnergyPeriodToYesterday,
getEnergyDataCollection,
EMPTY_PREFERENCES,
} from "../../src/data/energy";
import type { DeviceRegistryEntry } from "../../src/data/device/device_registry";
import type { EntityRegistryDisplayEntry } from "../../src/data/entity/entity_registry";
@@ -871,55 +866,27 @@ describe("Self-consumed solar gauge tests", () => {
});
});
// Pin the time zone (via TimeZone.server) so energy period tests do not
// depend on the machine's local zone.
const energyPeriodLocale: FrontendLocaleData = {
language: "en",
number_format: NumberFormat.language,
time_format: TimeFormat.language,
date_format: DateFormat.language,
time_zone: TimeZone.server,
first_weekday: FirstWeekday.language,
};
const energyPeriodConfig = { time_zone: "America/New_York" } as HassConfig;
const energyPeriodDay = (now: Date, offset = 0) => {
const day = calcDate(
now,
addDays,
energyPeriodLocale,
energyPeriodConfig,
offset
);
return {
start: calcDate(day, startOfDay, energyPeriodLocale, energyPeriodConfig),
end: calcDate(day, endOfDay, energyPeriodLocale, energyPeriodConfig),
};
};
describe("getNextEnergyPeriodStart", () => {
const locale: FrontendLocaleData = {
language: "en",
number_format: NumberFormat.language,
time_format: TimeFormat.language,
date_format: DateFormat.language,
time_zone: TimeZone.server,
first_weekday: FirstWeekday.language,
};
// Pin the time zone (via TimeZone.server) so the test does not depend on the
// machine's local zone.
const config = { time_zone: "America/New_York" } as HassConfig;
const isMidnight = (date: Date) =>
calcDate(
date,
startOfDay,
energyPeriodLocale,
energyPeriodConfig
).getTime() === date.getTime();
calcDate(date, startOfDay, locale, config).getTime() === date.getTime();
it("rolls the real-time view over at midnight, statistics an hour later", () => {
const now = new Date("2026-06-19T15:30:00-04:00");
const realTime = getNextEnergyPeriodStart(
true,
now,
energyPeriodLocale,
energyPeriodConfig
);
const statistics = getNextEnergyPeriodStart(
false,
now,
energyPeriodLocale,
energyPeriodConfig
);
const realTime = getNextEnergyPeriodStart(true, now, locale, config);
const statistics = getNextEnergyPeriodStart(false, now, locale, config);
// Real-time rolls over exactly at the next midnight.
assert.isTrue(isMidnight(realTime));
@@ -929,14 +896,9 @@ describe("getNextEnergyPeriodStart", () => {
);
// Statistics roll over an hour after midnight, on the same day boundary.
assert.equal(statistics.getTime() - realTime.getTime(), 60 * 60 * 1000);
assert.equal(statistics.getTime() - realTime.getTime(), 60 * 60 * 1000 - 1);
assert.equal(
calcDate(
statistics,
startOfDay,
energyPeriodLocale,
energyPeriodConfig
).getTime(),
calcDate(statistics, startOfDay, locale, config).getTime(),
realTime.getTime()
);
});
@@ -944,12 +906,7 @@ describe("getNextEnergyPeriodStart", () => {
it("advances the real-time view to the next midnight when called after midnight", () => {
const now = new Date("2026-06-20T00:30:00-04:00");
const realTime = getNextEnergyPeriodStart(
true,
now,
energyPeriodLocale,
energyPeriodConfig
);
const realTime = getNextEnergyPeriodStart(true, now, locale, config);
assert.isTrue(isMidnight(realTime));
// Next midnight is June 21, not the already-passed June 20 midnight.
@@ -958,352 +915,6 @@ describe("getNextEnergyPeriodStart", () => {
new Date("2026-06-21T00:00:00-04:00").getTime()
);
});
it("wakes a non-today live day at today 01:00 during hour 0", () => {
const now = new Date("2026-06-20T00:30:00-04:00");
const todayOne = new Date("2026-06-20T01:00:00-04:00").getTime();
for (const offset of [-1, -2]) {
assert.equal(
getNextEnergyPeriodStart(
false,
now,
energyPeriodLocale,
energyPeriodConfig,
energyPeriodDay(now, offset).start
).getTime(),
todayOne
);
}
});
it("keeps tomorrow 01:00 when statistics is already on today during hour 0", () => {
const now = new Date("2026-06-20T00:30:00-04:00");
assert.equal(
getNextEnergyPeriodStart(
false,
now,
energyPeriodLocale,
energyPeriodConfig,
energyPeriodDay(now).start
).getTime(),
new Date("2026-06-21T01:00:00-04:00").getTime()
);
});
});
describe("shouldFallbackEnergyPeriodToYesterday", () => {
it("is true for the statistics view before 01:00", () => {
const now = new Date("2026-06-20T00:30:00-04:00");
assert.isTrue(
shouldFallbackEnergyPeriodToYesterday(
false,
now,
energyPeriodLocale,
energyPeriodConfig
)
);
assert.equal(
getEnergyFirstStatisticAt(
now,
energyPeriodLocale,
energyPeriodConfig
).getTime(),
new Date("2026-06-20T01:00:00-04:00").getTime()
);
});
it("is false at 01:00 and for the real-time view", () => {
const atOne = new Date("2026-06-20T01:00:00-04:00");
const beforeOne = new Date("2026-06-20T00:30:00-04:00");
assert.isFalse(
shouldFallbackEnergyPeriodToYesterday(
false,
atOne,
energyPeriodLocale,
energyPeriodConfig
)
);
assert.isFalse(
shouldFallbackEnergyPeriodToYesterday(
true,
beforeOne,
energyPeriodLocale,
energyPeriodConfig
)
);
});
});
describe("getEnergyLiveDayPeriod", () => {
it("keeps yesterday during hour 0 when that is the current period", () => {
const now = new Date("2026-06-20T00:30:00-04:00");
const { start, end } = energyPeriodDay(now, -1);
const live = getEnergyLiveDayPeriod(
false,
now,
energyPeriodLocale,
energyPeriodConfig,
start
);
assert.equal(live.start.getTime(), start.getTime());
assert.equal(live.end.getTime(), end.getTime());
});
it("keeps today during hour 0 when the user already picked today", () => {
const now = new Date("2026-06-20T00:30:00-04:00");
const { start, end } = energyPeriodDay(now);
const live = getEnergyLiveDayPeriod(
false,
now,
energyPeriodLocale,
energyPeriodConfig,
start
);
assert.equal(live.start.getTime(), start.getTime());
assert.equal(live.end.getTime(), end.getTime());
});
it("advances a stale yesterday to today after 01:00", () => {
const now = new Date("2026-06-20T10:00:00-04:00");
const live = getEnergyLiveDayPeriod(
false,
now,
energyPeriodLocale,
energyPeriodConfig,
energyPeriodDay(now, -1).start
);
const expected = energyPeriodDay(now);
assert.equal(live.start.getTime(), expected.start.getTime());
assert.equal(live.end.getTime(), expected.end.getTime());
});
it("advances a two-day-old live day to today", () => {
const now = new Date("2026-06-20T10:00:00-04:00");
const live = getEnergyLiveDayPeriod(
false,
now,
energyPeriodLocale,
energyPeriodConfig,
energyPeriodDay(now, -2).start
);
const expected = energyPeriodDay(now);
assert.equal(live.start.getTime(), expected.start.getTime());
assert.equal(live.end.getTime(), expected.end.getTime());
});
it("falls back to yesterday for a stale live day during hour 0", () => {
const now = new Date("2026-06-20T00:30:00-04:00");
const live = getEnergyLiveDayPeriod(
false,
now,
energyPeriodLocale,
energyPeriodConfig,
energyPeriodDay(now, -2).start
);
const expected = energyPeriodDay(now, -1);
assert.equal(live.start.getTime(), expected.start.getTime());
assert.equal(live.end.getTime(), expected.end.getTime());
});
});
describe("getEnergyDataCollection live day", () => {
afterEach(() => {
localStorage.clear();
vi.useRealTimers();
});
const createCollection = (
key: string,
preset?: string,
midnightRollover = false
) => {
const hass = createMockHass();
hass.locale = energyPeriodLocale;
hass.config = { ...hass.config, time_zone: "America/New_York" };
const callWS = vi.fn(async (msg: { type: string }) => {
if (msg.type === "energy/info") {
return { cost_sensors: {}, solar_forecast_domains: [] };
}
throw new Error(`unexpected ${msg.type}`);
});
Object.assign(hass, {
connection: {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
connected: true,
},
callWS,
});
if (preset) {
localStorage.setItem(getEnergyDefaultPeriodStorageKey(hass, key), preset);
}
return {
collection: getEnergyDataCollection(hass, {
key,
prefs: EMPTY_PREFERENCES,
midnightRollover,
}),
callWS,
};
};
const energyInfoFetches = (callWS: ReturnType<typeof vi.fn>) =>
callWS.mock.calls.filter((call) => call[0].type === "energy/info");
it("advances hour-0 yesterday to today at 01:00 and fetches the new day", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-20T00:30:00-04:00"));
const { collection, callWS } = createCollection("energy_timer");
const refresh = vi.spyOn(collection, "refresh");
const unsub = collection.subscribe(() => undefined);
await vi.advanceTimersByTimeAsync(0);
refresh.mockClear();
callWS.mockClear();
assert.equal(
collection.start.getTime(),
energyPeriodDay(new Date(), -1).start.getTime()
);
await vi.advanceTimersByTimeAsync(30 * 60 * 1000);
assert.equal(
collection.start.getTime(),
energyPeriodDay(new Date()).start.getTime()
);
// Cards render EnergyData from the websocket store, not collection.start.
// The 01:00 callback must refresh() so getEnergyData runs for today.
assert.equal(refresh.mock.calls.length, 1);
assert.equal(energyInfoFetches(callWS).length, 1);
unsub();
});
it("catches up a stale live day on resubscribe", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-18T15:00:00-04:00"));
const { collection, callWS } = createCollection("energy_catchup");
const refresh = vi.spyOn(collection, "refresh");
let unsub = collection.subscribe(() => undefined);
await vi.advanceTimersByTimeAsync(0);
refresh.mockClear();
callWS.mockClear();
unsub();
vi.setSystemTime(new Date("2026-06-20T10:00:00-04:00"));
unsub = collection.subscribe(() => undefined);
await vi.advanceTimersByTimeAsync(0);
assert.equal(
collection.start.getTime(),
energyPeriodDay(new Date()).start.getTime()
);
assert.equal(refresh.mock.calls.length, 1);
assert.equal(energyInfoFetches(callWS).length, 1);
unsub();
});
it("does not double-refresh on a cold subscribe after the unsub grace", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-18T15:00:00-04:00"));
const { collection, callWS } = createCollection("energy_cold_refresh");
const refresh = vi.spyOn(collection, "refresh");
let unsub = collection.subscribe(() => undefined);
await vi.advanceTimersByTimeAsync(0);
unsub();
await vi.advanceTimersByTimeAsync(5000);
refresh.mockClear();
callWS.mockClear();
vi.setSystemTime(new Date("2026-06-20T10:00:00-04:00"));
unsub = collection.subscribe(() => undefined);
await vi.advanceTimersByTimeAsync(0);
assert.equal(
collection.start.getTime(),
energyPeriodDay(new Date()).start.getTime()
);
// The library's first fetch is not collection.refresh(); this spy only
// sees the extra refresh used during the unsub-grace re-subscribe.
assert.equal(refresh.mock.calls.length, 0);
assert.equal(energyInfoFetches(callWS).length, 1);
unsub();
});
it("keeps a custom setPeriod range overnight", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-18T15:00:00-04:00"));
const { collection } = createCollection("energy_custom_period");
let unsub = collection.subscribe(() => undefined);
const custom = energyPeriodDay(new Date(), -5);
collection.setPeriod(custom.start, custom.end);
unsub();
vi.setSystemTime(new Date("2026-06-20T10:00:00-04:00"));
unsub = collection.subscribe(() => undefined);
assert.equal(collection.start.getTime(), custom.start.getTime());
unsub();
});
it("does not advance the period after the last subscriber leaves", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-20T00:30:00-04:00"));
const { collection } = createCollection("energy_unsub_timer");
const unsub = collection.subscribe(() => undefined);
const start = collection.start.getTime();
unsub();
vi.advanceTimersByTime(48 * 60 * 60 * 1000);
assert.equal(collection.start.getTime(), start);
});
it("does not roll a remembered week preset over to today", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-18T15:00:00-04:00"));
const { collection } = createCollection("energy_week_stored", "this_week");
const weekStart = collection.start.getTime();
const unsub = collection.subscribe(() => undefined);
vi.setSystemTime(new Date("2026-06-20T10:00:00-04:00"));
vi.advanceTimersByTime(48 * 60 * 60 * 1000);
assert.equal(collection.start.getTime(), weekStart);
unsub();
});
it("does not roll a remembered week preset over to today with midnightRollover", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-18T15:00:00-04:00"));
const { collection } = createCollection(
"energy_week_stored_now",
"this_week",
true
);
const weekStart = collection.start.getTime();
const unsub = collection.subscribe(() => undefined);
vi.setSystemTime(new Date("2026-06-20T10:00:00-04:00"));
vi.advanceTimersByTime(48 * 60 * 60 * 1000);
assert.equal(collection.start.getTime(), weekStart);
unsub();
});
});
describe("getEnergyDefaultPeriodStorageKey", () => {
-52
View File
@@ -1,52 +0,0 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { describe, expect, it } from "vitest";
import { computeShownAttributes } from "../../src/data/entity/entity_attributes";
describe("computeShownAttributes", () => {
it("filters globally hidden attributes", () => {
const stateObj = {
entity_id: "sensor.temperature",
attributes: {
friendly_name: "Office temperature",
unit_of_measurement: "°C",
temperature: 21,
custom_value: "shown",
},
} as unknown as HassEntity;
expect(computeShownAttributes(stateObj)).toEqual([
"temperature",
"custom_value",
]);
});
it("filters domain and device class specific attributes", () => {
const stateObj = {
entity_id: "sensor.status",
attributes: {
device_class: "enum",
options: ["home", "away"],
current_option: "home",
},
} as unknown as HassEntity;
expect(computeShownAttributes(stateObj)).toEqual(["current_option"]);
});
it("keeps device-class attributes for other device classes", () => {
const stateObj = {
entity_id: "sensor.status",
attributes: {
device_class: "temperature",
options: ["home", "away"],
current_option: "home",
},
} as unknown as HassEntity;
expect(computeShownAttributes(stateObj)).toEqual([
"options",
"current_option",
]);
});
});
+179 -77
View File
@@ -2737,6 +2737,27 @@ __metadata:
languageName: node
linkType: hard
"@formatjs/ecma402-abstract@npm:2.3.4":
version: 2.3.4
resolution: "@formatjs/ecma402-abstract@npm:2.3.4"
dependencies:
"@formatjs/fast-memoize": "npm:2.2.7"
"@formatjs/intl-localematcher": "npm:0.6.1"
decimal.js: "npm:^10.4.3"
tslib: "npm:^2.8.0"
checksum: 10/573971ffc291096a4b9fcc80b4708124e89bf2e3ac50e0f78b41eb797e9aa1b842f4dc3665e4467a853c738386821769d9e40408a1d25bc73323a1f057a16cf2
languageName: node
linkType: hard
"@formatjs/fast-memoize@npm:2.2.7":
version: 2.2.7
resolution: "@formatjs/fast-memoize@npm:2.2.7"
dependencies:
tslib: "npm:^2.8.0"
checksum: 10/e7e6efc677d63a13d99a854305db471b69f64cbfebdcb6dbe507dab9aa7eaae482ca5de86f343c856ca0a2c8f251672bd1f37c572ce14af602c0287378097d43
languageName: node
linkType: hard
"@formatjs/fast-memoize@npm:3.1.7":
version: 3.1.7
resolution: "@formatjs/fast-memoize@npm:3.1.7"
@@ -2744,12 +2765,33 @@ __metadata:
languageName: node
linkType: hard
"@formatjs/icu-messageformat-parser@npm:3.5.16":
version: 3.5.16
resolution: "@formatjs/icu-messageformat-parser@npm:3.5.16"
"@formatjs/icu-messageformat-parser@npm:2.11.2":
version: 2.11.2
resolution: "@formatjs/icu-messageformat-parser@npm:2.11.2"
dependencies:
"@formatjs/ecma402-abstract": "npm:2.3.4"
"@formatjs/icu-skeleton-parser": "npm:1.8.14"
tslib: "npm:^2.8.0"
checksum: 10/e919eb2a132ac1d54fb1a7e3a3254007649b55196d3818090df92a4268dcddf20cbdf863c06039fbbe7a35a8a3f17bdc172dade99d1f17c1d8a95dcec444c3e3
languageName: node
linkType: hard
"@formatjs/icu-messageformat-parser@npm:3.5.17":
version: 3.5.17
resolution: "@formatjs/icu-messageformat-parser@npm:3.5.17"
dependencies:
"@formatjs/icu-skeleton-parser": "npm:2.1.11"
checksum: 10/406cf08cf01a68e244c7077b726b199dde6ba4c95ecb2d807bff9ec62a9acc77b7f47fad196f02dfbd7a7b8110cccf5d5392e9693069e2484cf96f00899a728f
checksum: 10/cbb9daf23f65e4ef3697eae3be4c6888eda942fcde18929dfc6e96ef6c45052409d20340ece13476eeadd062e222809c46a2f26e3b453c9cbd4979bb6c0b0ae5
languageName: node
linkType: hard
"@formatjs/icu-skeleton-parser@npm:1.8.14":
version: 1.8.14
resolution: "@formatjs/icu-skeleton-parser@npm:1.8.14"
dependencies:
"@formatjs/ecma402-abstract": "npm:2.3.4"
tslib: "npm:^2.8.0"
checksum: 10/2fbe3155c310358820b118d8c9844f314eff3500a82f1c65402434a3095823e1afeaab8d1762b4a59cc5679d82dc4c8c134683565d7cdae4daace23251f46a47
languageName: node
linkType: hard
@@ -2815,6 +2857,15 @@ __metadata:
languageName: node
linkType: hard
"@formatjs/intl-localematcher@npm:0.6.1":
version: 0.6.1
resolution: "@formatjs/intl-localematcher@npm:0.6.1"
dependencies:
tslib: "npm:^2.8.0"
checksum: 10/c7b3bc8395d18670677f207b2fd107561fff5d6394a9b4273c29e0bea920300ec3a2eefead600ebb7761c04a770cada28f78ac059f84d00520bfb57a9db36998
languageName: node
linkType: hard
"@formatjs/intl-localematcher@npm:0.8.13":
version: 0.8.13
resolution: "@formatjs/intl-localematcher@npm:0.8.13"
@@ -2862,6 +2913,24 @@ __metadata:
languageName: node
linkType: hard
"@formatjs/intl@npm:3.1.6":
version: 3.1.6
resolution: "@formatjs/intl@npm:3.1.6"
dependencies:
"@formatjs/ecma402-abstract": "npm:2.3.4"
"@formatjs/fast-memoize": "npm:2.2.7"
"@formatjs/icu-messageformat-parser": "npm:2.11.2"
intl-messageformat: "npm:10.7.16"
tslib: "npm:^2.8.0"
peerDependencies:
typescript: ^5.6.0
peerDependenciesMeta:
typescript:
optional: true
checksum: 10/10ebdce088898ad7de59c10890f7c02fa9f5aa50518ed3fae7e0ae1c392f3973da00464d2a42229cce97916c2eff1e41630e95c18bab01713d2b6ef5c7fd80c1
languageName: node
linkType: hard
"@fullcalendar/core@npm:6.1.21":
version: 6.1.21
resolution: "@fullcalendar/core@npm:6.1.21"
@@ -6383,12 +6452,12 @@ __metadata:
languageName: node
linkType: hard
"@vitest/coverage-v8@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/coverage-v8@npm:4.1.10"
"@vitest/coverage-v8@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/coverage-v8@npm:4.1.11"
dependencies:
"@bcoe/v8-coverage": "npm:^1.0.2"
"@vitest/utils": "npm:4.1.10"
"@vitest/utils": "npm:4.1.11"
ast-v8-to-istanbul: "npm:^1.0.0"
istanbul-lib-coverage: "npm:^3.2.2"
istanbul-lib-report: "npm:^3.0.1"
@@ -6398,34 +6467,34 @@ __metadata:
std-env: "npm:^4.0.0-rc.1"
tinyrainbow: "npm:^3.1.0"
peerDependencies:
"@vitest/browser": 4.1.10
vitest: 4.1.10
"@vitest/browser": 4.1.11
vitest: 4.1.11
peerDependenciesMeta:
"@vitest/browser":
optional: true
checksum: 10/e593f5205a65d10f200e68a99e720d7a9f5e9be65d8160e4f0b6b5f1a7a87f0453c03e79fd1c022dbd7fb26a22657ca4d9410ae27b36da90d41d7ca04f681ab1
checksum: 10/b6171ec592e0017c3b10954a9400b10af0becf944a0533af01b002a4cc35b6e562a353b4837451a44b73c5561de358c23a2c135d4e1e79bc9041ebb241a68440
languageName: node
linkType: hard
"@vitest/expect@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/expect@npm:4.1.10"
"@vitest/expect@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/expect@npm:4.1.11"
dependencies:
"@standard-schema/spec": "npm:^1.1.0"
"@types/chai": "npm:^5.2.2"
"@vitest/spy": "npm:4.1.10"
"@vitest/utils": "npm:4.1.10"
"@vitest/spy": "npm:4.1.11"
"@vitest/utils": "npm:4.1.11"
chai: "npm:^6.2.2"
tinyrainbow: "npm:^3.1.0"
checksum: 10/487fcad404a68968a54ae5fb9d099f12170cd793420a04b34a5606516317090c50a8303ab687c70166ee181864e3e138941d4a96d0405434dcd37696b3105350
checksum: 10/9bfcfe5ad926ab58beea1c700dc057f17422f14516506f8fc12c9881ed3e81d4c2faadb768042c8497fdee7007e201c1bd3e7d2e91157dbb47fb5c07c4c02aaa
languageName: node
linkType: hard
"@vitest/mocker@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/mocker@npm:4.1.10"
"@vitest/mocker@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/mocker@npm:4.1.11"
dependencies:
"@vitest/spy": "npm:4.1.10"
"@vitest/spy": "npm:4.1.11"
estree-walker: "npm:^3.0.3"
magic-string: "npm:^0.30.21"
peerDependencies:
@@ -6436,56 +6505,56 @@ __metadata:
optional: true
vite:
optional: true
checksum: 10/ae9645d1bcdad3ab7de7182feb4f1c9148a5ff97cef19581eec9257112aace94889eee9a1ad12e40ce59453ac05f52453b5fdb49ff76a31af8ccdbaaa4471ef3
checksum: 10/00b6e1266d8403194b49313e3a9a1af0dff2c773f4b2df11f4955fa0f244fd4b59484cd23381dfef8af30298e2651c1aaa42b439fdbc871bb4bb911de38a9509
languageName: node
linkType: hard
"@vitest/pretty-format@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/pretty-format@npm:4.1.10"
"@vitest/pretty-format@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/pretty-format@npm:4.1.11"
dependencies:
tinyrainbow: "npm:^3.1.0"
checksum: 10/e4f6907143ab0e40dda29d70b17027586c92921d622091321f10512e660b3995dcee7aa56e17b750b72560f295e25f96035372348415f18ebfd39b66a55b4704
checksum: 10/2dfc2f20dbe1c4dbea33ec42e85a8b5648aa6585521bea47573406f5cefb81cd3b86b71f981c4e3d69946e77252cb42a700416c1dc656bb0478cb2932c953cdc
languageName: node
linkType: hard
"@vitest/runner@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/runner@npm:4.1.10"
"@vitest/runner@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/runner@npm:4.1.11"
dependencies:
"@vitest/utils": "npm:4.1.10"
"@vitest/utils": "npm:4.1.11"
pathe: "npm:^2.0.3"
checksum: 10/2c962cb13af0880990036808a35679b7ac6657c8f542490234c2faa6ffd2ab080ac6bf21b487c64d84aa635cfb37b49eb679098c2003a100dfc6c4d5e87bf055
checksum: 10/5247df824fa28b458ba0102592dfec50707982193b62076db941fbe5d7c88fb7067e68a33c194db0092bbe35459cfbeaed33b3c667e5f02192c18baeb4f56239
languageName: node
linkType: hard
"@vitest/snapshot@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/snapshot@npm:4.1.10"
"@vitest/snapshot@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/snapshot@npm:4.1.11"
dependencies:
"@vitest/pretty-format": "npm:4.1.10"
"@vitest/utils": "npm:4.1.10"
"@vitest/pretty-format": "npm:4.1.11"
"@vitest/utils": "npm:4.1.11"
magic-string: "npm:^0.30.21"
pathe: "npm:^2.0.3"
checksum: 10/7940d83ffd2fbebf9a04ea31e196b7e8bf981093ec739950959fe8dd29caa33c80823780fb4b1063d9459c44a0a8d8b2748c00dfb6941becd7404e6d687eea01
checksum: 10/5d096373fb4b102f65ff884844a18c2d2e7d88caf68a64a842a245573311b2d531ca5a94ebf7e4fe39324e71a78670f74c949d4ec2cad3764c3f4c272b84d982
languageName: node
linkType: hard
"@vitest/spy@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/spy@npm:4.1.10"
checksum: 10/7c1b79a95474338e0659f0f2e43be4df1ef7939ff5b37b044954e0287582947803bd417508f44a7f244809672309d9b3dd67660b704ec3fe7f323cc958ae47a3
"@vitest/spy@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/spy@npm:4.1.11"
checksum: 10/d49a7ed7501080e5f817d61250a169a46fcc7901887e4985a1e08705ce79aea8d1edcffd74f4dc6669ea1bc3d717a39354ce89c67188d81a63dc439d42f195f6
languageName: node
linkType: hard
"@vitest/utils@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/utils@npm:4.1.10"
"@vitest/utils@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/utils@npm:4.1.11"
dependencies:
"@vitest/pretty-format": "npm:4.1.10"
"@vitest/pretty-format": "npm:4.1.11"
convert-source-map: "npm:^2.0.0"
tinyrainbow: "npm:^3.1.0"
checksum: 10/95484aad55c7b00bbcd4963e27cbb86fe207620a6093973d68da9d0a06bad37c388d84c9ab43d5f35d88e46c8f376a5592d9c54c025c958361160f4802bb25ee
checksum: 10/f05381e12d0926db7b01bfaae9a577fa664d36b96c23df185e4b0f6dad3a9fb59ac00931613da53b4511ee6ab473a14ac500c72c5ec5e9b3c3042875051f20c4
languageName: node
linkType: hard
@@ -7501,6 +7570,15 @@ __metadata:
languageName: node
linkType: hard
"chart2music@npm:^1.20.0":
version: 1.20.0
resolution: "chart2music@npm:1.20.0"
dependencies:
"@formatjs/intl": "npm:3.1.6"
checksum: 10/ed421708740e3644a72356c9f313d3bcb7cac26d94a7fb209fa4bc8f9bc24061ed63f98da89277684e71f9402b5414cd45bb60f01c01e576803ba3dca755f4ba
languageName: node
linkType: hard
"chokidar@npm:^3.5.3":
version: 3.6.0
resolution: "chokidar@npm:3.6.0"
@@ -8098,7 +8176,7 @@ __metadata:
languageName: node
linkType: hard
"decimal.js@npm:^10.6.0":
"decimal.js@npm:^10.4.3, decimal.js@npm:^10.6.0":
version: 10.6.0
resolution: "decimal.js@npm:10.6.0"
checksum: 10/c0d45842d47c311d11b38ce7ccc911121953d4df3ebb1465d92b31970eb4f6738a065426a06094af59bee4b0d64e42e7c8984abd57b6767c64ea90cf90bb4a69
@@ -8364,6 +8442,17 @@ __metadata:
languageName: node
linkType: hard
"echarts-extension-chart2music@npm:0.1.0":
version: 0.1.0
resolution: "echarts-extension-chart2music@npm:0.1.0"
dependencies:
chart2music: "npm:^1.20.0"
peerDependencies:
echarts: ">=5.0.0 <7"
checksum: 10/55a82c770355228b2a899007e488e5b60171cbaf5d83363a298c4214d9e4a5df1afd1c7805495f42f2ad6edd7c062c8b366f1f8ba7b1a902f2db7eb47fbfcccf
languageName: node
linkType: hard
"echarts@npm:6.1.0":
version: 6.1.0
resolution: "echarts@npm:6.1.0"
@@ -9958,7 +10047,7 @@ __metadata:
"@types/tar": "npm:7.0.87"
"@typescript/native": "npm:[email protected]"
"@vibrant/color": "npm:4.0.4"
"@vitest/coverage-v8": "npm:4.1.10"
"@vitest/coverage-v8": "npm:4.1.11"
"@vvo/tzdb": "npm:6.198.0"
"@webcomponents/scoped-custom-element-registry": "npm:0.0.10"
"@webcomponents/webcomponentsjs": "npm:2.8.0"
@@ -9979,6 +10068,7 @@ __metadata:
del: "npm:8.0.1"
dialog-polyfill: "npm:0.5.6"
echarts: "npm:6.1.0"
echarts-extension-chart2music: "npm:0.1.0"
element-internals-polyfill: "npm:3.0.2"
eslint: "npm:10.8.1"
eslint-config-prettier: "npm:10.1.8"
@@ -10002,7 +10092,7 @@ __metadata:
html-minifier-terser: "npm:7.2.0"
husky: "npm:9.1.7"
idb-keyval: "npm:6.3.0"
intl-messageformat: "npm:11.2.13"
intl-messageformat: "npm:11.2.14"
js-yaml: "npm:5.3.0"
jsdom: "npm:30.0.1"
jszip: "npm:3.10.1"
@@ -10019,7 +10109,7 @@ __metadata:
lodash.template: "npm:4.18.1"
luxon: "npm:3.7.2"
map-stream: "npm:0.0.7"
marked: "npm:18.0.9"
marked: "npm:18.0.10"
memoize-one: "npm:6.0.0"
minify-literals: "npm:2.1.0"
node-vibrant: "npm:4.0.4"
@@ -10044,7 +10134,7 @@ __metadata:
typescript: "npm:6.0.3"
typescript-eslint: "npm:8.67.0"
vite-tsconfig-paths: "npm:6.1.1"
vitest: "npm:4.1.10"
vitest: "npm:4.1.11"
webpack-stats-plugin: "npm:1.1.3"
webpackbar: "npm:7.0.0"
weekstart: "npm:2.0.0"
@@ -10351,13 +10441,25 @@ __metadata:
languageName: node
linkType: hard
"intl-messageformat@npm:11.2.13":
version: 11.2.13
resolution: "intl-messageformat@npm:11.2.13"
"intl-messageformat@npm:10.7.16":
version: 10.7.16
resolution: "intl-messageformat@npm:10.7.16"
dependencies:
"@formatjs/ecma402-abstract": "npm:2.3.4"
"@formatjs/fast-memoize": "npm:2.2.7"
"@formatjs/icu-messageformat-parser": "npm:2.11.2"
tslib: "npm:^2.8.0"
checksum: 10/c19b77c5e495ce8b0d1aa0d95444bf3a4f73886805f1e08d7159b364abcf2f63686b2ccf202eaafb0e39a0e9fde61848b8dd2db1679efd4f6ec8f6a3d0e77928
languageName: node
linkType: hard
"intl-messageformat@npm:11.2.14":
version: 11.2.14
resolution: "intl-messageformat@npm:11.2.14"
dependencies:
"@formatjs/fast-memoize": "npm:3.1.7"
"@formatjs/icu-messageformat-parser": "npm:3.5.16"
checksum: 10/7da1b2e01258ae310cd3aeabd6302497a70755ef860cc666405e89ede2927f5d5995d8a6f9be556d7dfc4f7b1eb0a13b26c9633234fdb4afea762013bb25af65
"@formatjs/icu-messageformat-parser": "npm:3.5.17"
checksum: 10/2720155c42bfd99a00d41f7bc9e2f5c2c83c2b6cb2353006b7d8e736a0f1ed20d0147406c1aeba0748731a0ce588c67304b52eb2fe92b42b928bf1ef6bffdcdf
languageName: node
linkType: hard
@@ -11655,12 +11757,12 @@ __metadata:
languageName: node
linkType: hard
"marked@npm:18.0.9":
version: 18.0.9
resolution: "marked@npm:18.0.9"
"marked@npm:18.0.10":
version: 18.0.10
resolution: "marked@npm:18.0.10"
bin:
marked: bin/marked.js
checksum: 10/99d337c50acd57034734f8460f25b28e9658b15627f950092707cb443b840f0bd29fe343bf211b948e643303c34abdb5b5a12cf5245a846635750697524bb747
checksum: 10/0d4b560e0773fd6ba30a4e7560ba7ae1f05d47b23926ad8337d9f80c598dfdbffdf2f81e773a1789e8bc9d9c2c2d3d0c9143a54d32581c336a3bbb1bad80461c
languageName: node
linkType: hard
@@ -15368,17 +15470,17 @@ __metadata:
languageName: node
linkType: hard
"vitest@npm:4.1.10":
version: 4.1.10
resolution: "vitest@npm:4.1.10"
"vitest@npm:4.1.11":
version: 4.1.11
resolution: "vitest@npm:4.1.11"
dependencies:
"@vitest/expect": "npm:4.1.10"
"@vitest/mocker": "npm:4.1.10"
"@vitest/pretty-format": "npm:4.1.10"
"@vitest/runner": "npm:4.1.10"
"@vitest/snapshot": "npm:4.1.10"
"@vitest/spy": "npm:4.1.10"
"@vitest/utils": "npm:4.1.10"
"@vitest/expect": "npm:4.1.11"
"@vitest/mocker": "npm:4.1.11"
"@vitest/pretty-format": "npm:4.1.11"
"@vitest/runner": "npm:4.1.11"
"@vitest/snapshot": "npm:4.1.11"
"@vitest/spy": "npm:4.1.11"
"@vitest/utils": "npm:4.1.11"
es-module-lexer: "npm:^2.0.0"
expect-type: "npm:^1.3.0"
magic-string: "npm:^0.30.21"
@@ -15396,12 +15498,12 @@ __metadata:
"@edge-runtime/vm": "*"
"@opentelemetry/api": ^1.9.0
"@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0
"@vitest/browser-playwright": 4.1.10
"@vitest/browser-preview": 4.1.10
"@vitest/browser-webdriverio": 4.1.10
"@vitest/coverage-istanbul": 4.1.10
"@vitest/coverage-v8": 4.1.10
"@vitest/ui": 4.1.10
"@vitest/browser-playwright": 4.1.11
"@vitest/browser-preview": 4.1.11
"@vitest/browser-webdriverio": 4.1.11
"@vitest/coverage-istanbul": 4.1.11
"@vitest/coverage-v8": 4.1.11
"@vitest/ui": 4.1.11
happy-dom: "*"
jsdom: "*"
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -15432,7 +15534,7 @@ __metadata:
optional: false
bin:
vitest: ./vitest.mjs
checksum: 10/020843460fe696c23be2a363634dde4daf54625f1c443c24066ba3f87c478b0ccfdd5124343ba30eb092f54902ffea09f4bed0af4a16a9ee805e494ee2dce34e
checksum: 10/054f1e25d90d911693b0b93c5b85a7c3105775aa3e39c3279c7d3e7af719e2d94070a898d6d74292445366c1215bb91d07063989ac0965973f0c5ae22ad3b06b
languageName: node
linkType: hard