mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-22 14:47:56 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
043f42617a | ||
|
|
b2130a0cdc | ||
|
|
9481f7c948 | ||
|
|
c70ffc9060 |
+4
-5
@@ -101,13 +101,12 @@
|
||||
"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.14",
|
||||
"intl-messageformat": "11.2.13",
|
||||
"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",
|
||||
@@ -115,7 +114,7 @@
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"luxon": "3.7.2",
|
||||
"marked": "18.0.10",
|
||||
"marked": "18.0.9",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.4",
|
||||
"object-hash": "3.0.0",
|
||||
@@ -170,7 +169,7 @@
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
"@typescript/native": "npm:[email protected]",
|
||||
"@vitest/coverage-v8": "4.1.11",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-polyfill-corejs3": "1.0.0",
|
||||
"browserslist": "4.28.8",
|
||||
@@ -215,7 +214,7 @@
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.67.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.11",
|
||||
"vitest": "4.1.10",
|
||||
"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"
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
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();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -22,7 +22,6 @@ 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";
|
||||
@@ -48,8 +47,6 @@ 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";
|
||||
|
||||
@@ -149,17 +146,6 @@ 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;
|
||||
@@ -212,7 +198,6 @@ export class HaChartBase extends LitElement {
|
||||
while (this._listeners.length) {
|
||||
this._listeners.pop()!();
|
||||
}
|
||||
this._disposeSonification();
|
||||
this.chart?.dispose();
|
||||
this.chart = undefined;
|
||||
this._originalZrFlush = undefined;
|
||||
@@ -327,18 +312,6 @@ 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() };
|
||||
@@ -364,9 +337,6 @@ 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 })}"
|
||||
@@ -378,23 +348,8 @@ export class HaChartBase extends LitElement {
|
||||
height: this.height ? undefined : `${this._getDefaultHeight()}px`,
|
||||
})}
|
||||
>
|
||||
<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 class="chart"></div>
|
||||
</div>
|
||||
<div class="sonification-output"></div>
|
||||
${this._renderLegend()}
|
||||
<div class="top-controls ${classMap({ small: this.smallControls })}">
|
||||
<slot name="search"></slot>
|
||||
@@ -566,60 +521,6 @@ 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,
|
||||
@@ -632,9 +533,6 @@ 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();
|
||||
}
|
||||
@@ -1552,23 +1450,6 @@ 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);
|
||||
|
||||
@@ -163,15 +163,12 @@ 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
|
||||
? (start.getTime() + end.getTime()) / 2
|
||||
: start.getTime();
|
||||
? new Date((start.getTime() + end.getTime()) / 2)
|
||||
: start;
|
||||
// Whether a gap needs to be drawn before this data point (line charts).
|
||||
const drawGap =
|
||||
isLineChart &&
|
||||
@@ -185,10 +182,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!.getTime(), ...prevValues![i]!]);
|
||||
d.data!.push([prevEndTime!.getTime(), null]);
|
||||
d.data!.push([prevEndTime!, ...prevValues![i]!]);
|
||||
d.data!.push([prevEndTime!, null]);
|
||||
}
|
||||
d.data!.push([start.getTime(), ...dataValue!]);
|
||||
d.data!.push([start, ...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]);
|
||||
@@ -390,7 +387,7 @@ export function generateStatisticsChartData(
|
||||
const lastValues = prevValues;
|
||||
if (chartType === "line" && lastEndTime && lastValues) {
|
||||
statDataSets.forEach((d, i) => {
|
||||
d.data!.push([lastEndTime.getTime(), ...lastValues[i]!]);
|
||||
d.data!.push([lastEndTime, ...lastValues[i]!]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -426,7 +423,7 @@ export function generateStatisticsChartData(
|
||||
} else {
|
||||
val.push(currentValue);
|
||||
}
|
||||
statDataSets[i].data!.push([now.getTime(), ...val]);
|
||||
statDataSets[i].data!.push([now, ...val]);
|
||||
trackY(val[val.length - 1]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ 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 {
|
||||
@@ -25,7 +24,6 @@ import {
|
||||
import {
|
||||
configContext,
|
||||
connectionContext,
|
||||
internationalizationContext,
|
||||
statesContext,
|
||||
} from "../data/context";
|
||||
import { ConversationEntityFeature } from "../data/conversation";
|
||||
@@ -35,13 +33,8 @@ 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";
|
||||
@@ -74,17 +67,6 @@ 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;
|
||||
@@ -119,13 +101,6 @@ 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"];
|
||||
@@ -140,8 +115,6 @@ export class HaAssistChat extends LitElement {
|
||||
|
||||
private _conversationId: string | null = null;
|
||||
|
||||
private _greetingLoadToken = 0;
|
||||
|
||||
private _initialPromptSubmitted = false;
|
||||
|
||||
private _audioRecorder?: AudioRecorder;
|
||||
@@ -158,44 +131,17 @@ export class HaAssistChat extends LitElement {
|
||||
(changedProperties.has("pipeline") &&
|
||||
assistPipelineChanged(changedProperties.get("pipeline"), this.pipeline))
|
||||
) {
|
||||
this._conversation = [];
|
||||
this._loadGreeting();
|
||||
this._conversation = [
|
||||
{
|
||||
who: "hass",
|
||||
text: this._localize("ui.dialogs.voice_command.how_can_i_help"),
|
||||
thinking: "",
|
||||
tool_calls: {},
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
@@ -211,7 +157,7 @@ export class HaAssistChat extends LitElement {
|
||||
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
super.updated(changedProps);
|
||||
if (changedProps.has("_conversation") && this._conversation.length) {
|
||||
if (changedProps.has("_conversation")) {
|
||||
this._scrollMessagesBottom();
|
||||
}
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
+171
-54
@@ -21,7 +21,6 @@ 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";
|
||||
@@ -799,8 +798,8 @@ const clearEnergyCollectionPreferences = (hass: HomeAssistant) => {
|
||||
};
|
||||
|
||||
const scheduleHourlyRefresh = (collection: EnergyCollection) => {
|
||||
if (collection._refreshTimeout) {
|
||||
clearTimeout(collection._refreshTimeout);
|
||||
if (collection._refreshTimeout !== undefined) {
|
||||
window.clearTimeout(collection._refreshTimeout);
|
||||
}
|
||||
|
||||
if (collection._active && (!collection.end || collection.end > new Date())) {
|
||||
@@ -859,18 +858,92 @@ export const getEnergyDefaultPeriodStorageKey = (
|
||||
return `energy-default-period-${key}`;
|
||||
};
|
||||
|
||||
// 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.
|
||||
export const getNextEnergyPeriodStart = (
|
||||
// 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
|
||||
): Date => {
|
||||
const dayEnd = calcDate(now, endOfDay, locale, config);
|
||||
return midnightRollover ? addMilliseconds(dayEnd, 1) : addHours(dayEnd, 1);
|
||||
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);
|
||||
};
|
||||
|
||||
export const getEnergyDataCollection = (
|
||||
@@ -929,12 +1002,80 @@ 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);
|
||||
}
|
||||
@@ -942,79 +1083,55 @@ export const getEnergyDataCollection = (
|
||||
return () => {
|
||||
collection._active--;
|
||||
if (collection._active < 1) {
|
||||
clearTimeout(collection._refreshTimeout);
|
||||
collection._refreshTimeout = undefined;
|
||||
if (collection._refreshTimeout !== undefined) {
|
||||
window.clearTimeout(collection._refreshTimeout);
|
||||
collection._refreshTimeout = undefined;
|
||||
}
|
||||
clearUpdatePeriodTimeout();
|
||||
}
|
||||
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" && hour === "0" && !midnightRollover
|
||||
preferredPeriod === "today" &&
|
||||
shouldFallbackEnergyPeriodToYesterday(
|
||||
midnightRollover,
|
||||
now,
|
||||
hass.locale,
|
||||
hass.config
|
||||
)
|
||||
? "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);
|
||||
|
||||
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();
|
||||
followLiveDay = preferredPeriod === "today";
|
||||
|
||||
collection.isActive = () => !!collection._active;
|
||||
collection.clearPrefs = () => {
|
||||
collection.prefs = undefined;
|
||||
};
|
||||
collection.setPeriod = (newStart: Date, newEnd?: Date) => {
|
||||
if (collection._updatePeriodTimeout) {
|
||||
clearTimeout(collection._updatePeriodTimeout);
|
||||
collection._updatePeriodTimeout = undefined;
|
||||
}
|
||||
clearUpdatePeriodTimeout();
|
||||
collection.start = newStart;
|
||||
collection.end = newEnd;
|
||||
if (
|
||||
const periodNow = new Date();
|
||||
followLiveDay =
|
||||
collection.start.getTime() ===
|
||||
calcDate(new Date(), startOfDay, hass.locale, hass.config).getTime() &&
|
||||
calcDate(periodNow, startOfDay, hass.locale, hass.config).getTime() &&
|
||||
collection.end?.getTime() ===
|
||||
calcDate(new Date(), endOfDay, hass.locale, hass.config).getTime()
|
||||
) {
|
||||
calcDate(periodNow, endOfDay, hass.locale, hass.config).getTime();
|
||||
if (followLiveDay) {
|
||||
scheduleUpdatePeriod();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
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",
|
||||
@@ -177,3 +210,15 @@ 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,4 +34,12 @@ 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);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -5,6 +5,7 @@ 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";
|
||||
@@ -14,6 +15,7 @@ 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";
|
||||
@@ -24,7 +26,6 @@ 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;
|
||||
@@ -214,7 +215,7 @@ class HaMoreInfoDetails extends LitElement {
|
||||
stateObj: HassEntity
|
||||
): {
|
||||
stateEntries: DetailEntry[];
|
||||
attributes: { name: string; label: string }[];
|
||||
attributes: string[];
|
||||
yamlData: {
|
||||
state: {
|
||||
translated: string;
|
||||
@@ -227,14 +228,11 @@ class HaMoreInfoDetails extends LitElement {
|
||||
} => {
|
||||
const translatedState = this.hass.formatEntityState(stateObj);
|
||||
|
||||
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)
|
||||
);
|
||||
const detailsAttributes = computeShownAttributes(stateObj);
|
||||
const detailsAttributeSet = new Set(detailsAttributes);
|
||||
const builtInAttributes = Object.keys(stateObj.attributes).filter(
|
||||
(attribute) => !detailsAttributeSet.has(attribute)
|
||||
);
|
||||
|
||||
return {
|
||||
stateEntries: [
|
||||
@@ -255,7 +253,7 @@ class HaMoreInfoDetails extends LitElement {
|
||||
value: this._formatTimestamp(stateObj.last_updated),
|
||||
},
|
||||
],
|
||||
attributes,
|
||||
attributes: [...detailsAttributes, ...builtInAttributes],
|
||||
yamlData: {
|
||||
state: {
|
||||
translated: translatedState,
|
||||
@@ -291,7 +289,7 @@ class HaMoreInfoDetails extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
private _renderAttributes(attributes: { name: string; label: string }[]) {
|
||||
private _renderAttributes(attributes: string[]) {
|
||||
if (attributes.length === 0) {
|
||||
return html`<div class="empty">
|
||||
${this.hass.localize("ui.common.none")}
|
||||
@@ -306,13 +304,20 @@ class HaMoreInfoDetails extends LitElement {
|
||||
|
||||
return attributes.map(
|
||||
(attribute) => html`
|
||||
<ha-list-item-value .label=${attribute.label}>
|
||||
<ha-list-item-value
|
||||
.label=${computeAttributeNameDisplay(
|
||||
this.hass.localize,
|
||||
this._stateObj!,
|
||||
this.hass.entities,
|
||||
attribute
|
||||
)}
|
||||
>
|
||||
${
|
||||
attribute.name === "supported_features" && featureEnum
|
||||
attribute === "supported_features" && featureEnum
|
||||
? this._renderFeatures(featureEnum, this._stateObj!)
|
||||
: html`
|
||||
<ha-attribute-value
|
||||
.attribute=${attribute.name}
|
||||
.attribute=${attribute}
|
||||
.stateObj=${this._stateObj}
|
||||
></ha-attribute-value>
|
||||
`
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { consume } from "@lit/context";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property, 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";
|
||||
@@ -65,10 +63,8 @@ class HuiButtonCardFeature extends LitElement implements LovelaceCardFeature {
|
||||
|
||||
@state() private _config?: ButtonCardFeatureConfig;
|
||||
|
||||
@query("ha-action-result") private _result!: HaActionResult;
|
||||
|
||||
private _pressButton() {
|
||||
if (!this._stateObj || this._result.busy) return;
|
||||
if (!this._stateObj) return;
|
||||
|
||||
const domain = computeDomain(this._stateObj.entity_id);
|
||||
const service =
|
||||
@@ -103,7 +99,7 @@ class HuiButtonCardFeature extends LitElement implements LovelaceCardFeature {
|
||||
|
||||
forwardHaptic(this, "light");
|
||||
|
||||
this._result.run(this._api.callService(domain, service, serviceData));
|
||||
this._api.callService(domain, service, serviceData);
|
||||
}
|
||||
|
||||
static getStubConfig(): ButtonCardFeatureConfig {
|
||||
@@ -136,9 +132,7 @@ class HuiButtonCardFeature extends LitElement implements LovelaceCardFeature {
|
||||
class="press-button"
|
||||
@click=${this._pressButton}
|
||||
>
|
||||
<ha-action-result>
|
||||
${this._config.action_name ?? this._localize("ui.card.button.press")}
|
||||
</ha-action-result>
|
||||
${this._config.action_name ?? this._localize("ui.card.button.press")}
|
||||
</ha-control-button>
|
||||
</ha-control-button-group>
|
||||
`;
|
||||
|
||||
@@ -49,8 +49,6 @@ interface LovelacePanelConfig {
|
||||
mode: "yaml" | "storage";
|
||||
}
|
||||
|
||||
const EXTERNALLY_UPDATED_TOAST_ID = "lovelace-externally-updated";
|
||||
|
||||
let editorLoaded = false;
|
||||
let resourcesLoaded = false;
|
||||
|
||||
@@ -267,7 +265,6 @@ export class LovelacePanel extends LitElement {
|
||||
return;
|
||||
}
|
||||
showToast(this, {
|
||||
id: EXTERNALLY_UPDATED_TOAST_ID,
|
||||
message: this.hass!.localize(
|
||||
"ui.panel.lovelace.externally_updated_toast.message"
|
||||
),
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../components/entity/ha-entity-toggle";
|
||||
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")
|
||||
export class StateCardButton extends LitElement {
|
||||
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`
|
||||
@@ -34,9 +30,7 @@ export class StateCardButton extends LitElement {
|
||||
.disabled=${stateObj.state === UNAVAILABLE}
|
||||
@click=${this._pressButton}
|
||||
>
|
||||
<ha-action-result>
|
||||
${this.hass.localize("ui.card.button.press")}
|
||||
</ha-action-result>
|
||||
${this.hass.localize("ui.card.button.press")}
|
||||
</ha-control-button>
|
||||
</div>
|
||||
`;
|
||||
@@ -44,13 +38,9 @@ export class StateCardButton extends LitElement {
|
||||
|
||||
private _pressButton(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
if (this._result.busy) return;
|
||||
|
||||
this._result.run(
|
||||
this.hass.callService(computeDomain(this.stateObj.entity_id), "press", {
|
||||
entity_id: this.stateObj.entity_id,
|
||||
})
|
||||
);
|
||||
this.hass.callService("button", "press", {
|
||||
entity_id: this.stateObj.entity_id,
|
||||
});
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -1,8 +1,63 @@
|
||||
import { customElement } from "lit/decorators";
|
||||
import { StateCardButton } from "./state-card-button";
|
||||
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";
|
||||
|
||||
@customElement("state-card-input_button")
|
||||
class StateCardInputButton extends StateCardButton {}
|
||||
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);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
|
||||
@@ -1126,11 +1126,7 @@
|
||||
"zoom_reset": "Reset zoom",
|
||||
"expand_legend": "More",
|
||||
"collapse_legend": "Less",
|
||||
"toggle_visibility": "Toggle visibility",
|
||||
"chart": "Chart",
|
||||
"time": "Time",
|
||||
"value": "Value",
|
||||
"category": "Category"
|
||||
"toggle_visibility": "Toggle visibility"
|
||||
},
|
||||
"map": {
|
||||
"error": "Unable to load map"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,192 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,10 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } 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(
|
||||
@@ -45,25 +36,3 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
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());
|
||||
});
|
||||
});
|
||||
+408
-19
@@ -1,6 +1,6 @@
|
||||
import { startOfDay } from "date-fns";
|
||||
import { addDays, endOfDay, startOfDay } from "date-fns";
|
||||
import type { HassConfig } from "home-assistant-js-websocket";
|
||||
import { assert, describe, it } from "vitest";
|
||||
import { afterEach, assert, describe, it, vi } from "vitest";
|
||||
|
||||
import { calcDate } from "../../src/common/datetime/calc_date";
|
||||
import {
|
||||
@@ -20,6 +20,11 @@ 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";
|
||||
@@ -866,27 +871,55 @@ describe("Self-consumed solar gauge tests", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 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),
|
||||
};
|
||||
// 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;
|
||||
};
|
||||
|
||||
describe("getNextEnergyPeriodStart", () => {
|
||||
const isMidnight = (date: Date) =>
|
||||
calcDate(date, startOfDay, locale, config).getTime() === date.getTime();
|
||||
calcDate(
|
||||
date,
|
||||
startOfDay,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig
|
||||
).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, locale, config);
|
||||
const statistics = getNextEnergyPeriodStart(false, now, locale, config);
|
||||
const realTime = getNextEnergyPeriodStart(
|
||||
true,
|
||||
now,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig
|
||||
);
|
||||
const statistics = getNextEnergyPeriodStart(
|
||||
false,
|
||||
now,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig
|
||||
);
|
||||
|
||||
// Real-time rolls over exactly at the next midnight.
|
||||
assert.isTrue(isMidnight(realTime));
|
||||
@@ -896,9 +929,14 @@ describe("getNextEnergyPeriodStart", () => {
|
||||
);
|
||||
|
||||
// Statistics roll over an hour after midnight, on the same day boundary.
|
||||
assert.equal(statistics.getTime() - realTime.getTime(), 60 * 60 * 1000 - 1);
|
||||
assert.equal(statistics.getTime() - realTime.getTime(), 60 * 60 * 1000);
|
||||
assert.equal(
|
||||
calcDate(statistics, startOfDay, locale, config).getTime(),
|
||||
calcDate(
|
||||
statistics,
|
||||
startOfDay,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig
|
||||
).getTime(),
|
||||
realTime.getTime()
|
||||
);
|
||||
});
|
||||
@@ -906,7 +944,12 @@ 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, locale, config);
|
||||
const realTime = getNextEnergyPeriodStart(
|
||||
true,
|
||||
now,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig
|
||||
);
|
||||
|
||||
assert.isTrue(isMidnight(realTime));
|
||||
// Next midnight is June 21, not the already-passed June 20 midnight.
|
||||
@@ -915,6 +958,352 @@ 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", () => {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -2737,27 +2737,6 @@ __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"
|
||||
@@ -2765,33 +2744,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@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"
|
||||
"@formatjs/icu-messageformat-parser@npm:3.5.16":
|
||||
version: 3.5.16
|
||||
resolution: "@formatjs/icu-messageformat-parser@npm:3.5.16"
|
||||
dependencies:
|
||||
"@formatjs/icu-skeleton-parser": "npm:2.1.11"
|
||||
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
|
||||
checksum: 10/406cf08cf01a68e244c7077b726b199dde6ba4c95ecb2d807bff9ec62a9acc77b7f47fad196f02dfbd7a7b8110cccf5d5392e9693069e2484cf96f00899a728f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -2857,15 +2815,6 @@ __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"
|
||||
@@ -2913,24 +2862,6 @@ __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"
|
||||
@@ -6452,12 +6383,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/coverage-v8@npm:4.1.11":
|
||||
version: 4.1.11
|
||||
resolution: "@vitest/coverage-v8@npm:4.1.11"
|
||||
"@vitest/coverage-v8@npm:4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "@vitest/coverage-v8@npm:4.1.10"
|
||||
dependencies:
|
||||
"@bcoe/v8-coverage": "npm:^1.0.2"
|
||||
"@vitest/utils": "npm:4.1.11"
|
||||
"@vitest/utils": "npm:4.1.10"
|
||||
ast-v8-to-istanbul: "npm:^1.0.0"
|
||||
istanbul-lib-coverage: "npm:^3.2.2"
|
||||
istanbul-lib-report: "npm:^3.0.1"
|
||||
@@ -6467,34 +6398,34 @@ __metadata:
|
||||
std-env: "npm:^4.0.0-rc.1"
|
||||
tinyrainbow: "npm:^3.1.0"
|
||||
peerDependencies:
|
||||
"@vitest/browser": 4.1.11
|
||||
vitest: 4.1.11
|
||||
"@vitest/browser": 4.1.10
|
||||
vitest: 4.1.10
|
||||
peerDependenciesMeta:
|
||||
"@vitest/browser":
|
||||
optional: true
|
||||
checksum: 10/b6171ec592e0017c3b10954a9400b10af0becf944a0533af01b002a4cc35b6e562a353b4837451a44b73c5561de358c23a2c135d4e1e79bc9041ebb241a68440
|
||||
checksum: 10/e593f5205a65d10f200e68a99e720d7a9f5e9be65d8160e4f0b6b5f1a7a87f0453c03e79fd1c022dbd7fb26a22657ca4d9410ae27b36da90d41d7ca04f681ab1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/expect@npm:4.1.11":
|
||||
version: 4.1.11
|
||||
resolution: "@vitest/expect@npm:4.1.11"
|
||||
"@vitest/expect@npm:4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "@vitest/expect@npm:4.1.10"
|
||||
dependencies:
|
||||
"@standard-schema/spec": "npm:^1.1.0"
|
||||
"@types/chai": "npm:^5.2.2"
|
||||
"@vitest/spy": "npm:4.1.11"
|
||||
"@vitest/utils": "npm:4.1.11"
|
||||
"@vitest/spy": "npm:4.1.10"
|
||||
"@vitest/utils": "npm:4.1.10"
|
||||
chai: "npm:^6.2.2"
|
||||
tinyrainbow: "npm:^3.1.0"
|
||||
checksum: 10/9bfcfe5ad926ab58beea1c700dc057f17422f14516506f8fc12c9881ed3e81d4c2faadb768042c8497fdee7007e201c1bd3e7d2e91157dbb47fb5c07c4c02aaa
|
||||
checksum: 10/487fcad404a68968a54ae5fb9d099f12170cd793420a04b34a5606516317090c50a8303ab687c70166ee181864e3e138941d4a96d0405434dcd37696b3105350
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/mocker@npm:4.1.11":
|
||||
version: 4.1.11
|
||||
resolution: "@vitest/mocker@npm:4.1.11"
|
||||
"@vitest/mocker@npm:4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "@vitest/mocker@npm:4.1.10"
|
||||
dependencies:
|
||||
"@vitest/spy": "npm:4.1.11"
|
||||
"@vitest/spy": "npm:4.1.10"
|
||||
estree-walker: "npm:^3.0.3"
|
||||
magic-string: "npm:^0.30.21"
|
||||
peerDependencies:
|
||||
@@ -6505,56 +6436,56 @@ __metadata:
|
||||
optional: true
|
||||
vite:
|
||||
optional: true
|
||||
checksum: 10/00b6e1266d8403194b49313e3a9a1af0dff2c773f4b2df11f4955fa0f244fd4b59484cd23381dfef8af30298e2651c1aaa42b439fdbc871bb4bb911de38a9509
|
||||
checksum: 10/ae9645d1bcdad3ab7de7182feb4f1c9148a5ff97cef19581eec9257112aace94889eee9a1ad12e40ce59453ac05f52453b5fdb49ff76a31af8ccdbaaa4471ef3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/pretty-format@npm:4.1.11":
|
||||
version: 4.1.11
|
||||
resolution: "@vitest/pretty-format@npm:4.1.11"
|
||||
"@vitest/pretty-format@npm:4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "@vitest/pretty-format@npm:4.1.10"
|
||||
dependencies:
|
||||
tinyrainbow: "npm:^3.1.0"
|
||||
checksum: 10/2dfc2f20dbe1c4dbea33ec42e85a8b5648aa6585521bea47573406f5cefb81cd3b86b71f981c4e3d69946e77252cb42a700416c1dc656bb0478cb2932c953cdc
|
||||
checksum: 10/e4f6907143ab0e40dda29d70b17027586c92921d622091321f10512e660b3995dcee7aa56e17b750b72560f295e25f96035372348415f18ebfd39b66a55b4704
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/runner@npm:4.1.11":
|
||||
version: 4.1.11
|
||||
resolution: "@vitest/runner@npm:4.1.11"
|
||||
"@vitest/runner@npm:4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "@vitest/runner@npm:4.1.10"
|
||||
dependencies:
|
||||
"@vitest/utils": "npm:4.1.11"
|
||||
"@vitest/utils": "npm:4.1.10"
|
||||
pathe: "npm:^2.0.3"
|
||||
checksum: 10/5247df824fa28b458ba0102592dfec50707982193b62076db941fbe5d7c88fb7067e68a33c194db0092bbe35459cfbeaed33b3c667e5f02192c18baeb4f56239
|
||||
checksum: 10/2c962cb13af0880990036808a35679b7ac6657c8f542490234c2faa6ffd2ab080ac6bf21b487c64d84aa635cfb37b49eb679098c2003a100dfc6c4d5e87bf055
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/snapshot@npm:4.1.11":
|
||||
version: 4.1.11
|
||||
resolution: "@vitest/snapshot@npm:4.1.11"
|
||||
"@vitest/snapshot@npm:4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "@vitest/snapshot@npm:4.1.10"
|
||||
dependencies:
|
||||
"@vitest/pretty-format": "npm:4.1.11"
|
||||
"@vitest/utils": "npm:4.1.11"
|
||||
"@vitest/pretty-format": "npm:4.1.10"
|
||||
"@vitest/utils": "npm:4.1.10"
|
||||
magic-string: "npm:^0.30.21"
|
||||
pathe: "npm:^2.0.3"
|
||||
checksum: 10/5d096373fb4b102f65ff884844a18c2d2e7d88caf68a64a842a245573311b2d531ca5a94ebf7e4fe39324e71a78670f74c949d4ec2cad3764c3f4c272b84d982
|
||||
checksum: 10/7940d83ffd2fbebf9a04ea31e196b7e8bf981093ec739950959fe8dd29caa33c80823780fb4b1063d9459c44a0a8d8b2748c00dfb6941becd7404e6d687eea01
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/spy@npm:4.1.11":
|
||||
version: 4.1.11
|
||||
resolution: "@vitest/spy@npm:4.1.11"
|
||||
checksum: 10/d49a7ed7501080e5f817d61250a169a46fcc7901887e4985a1e08705ce79aea8d1edcffd74f4dc6669ea1bc3d717a39354ce89c67188d81a63dc439d42f195f6
|
||||
"@vitest/spy@npm:4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "@vitest/spy@npm:4.1.10"
|
||||
checksum: 10/7c1b79a95474338e0659f0f2e43be4df1ef7939ff5b37b044954e0287582947803bd417508f44a7f244809672309d9b3dd67660b704ec3fe7f323cc958ae47a3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/utils@npm:4.1.11":
|
||||
version: 4.1.11
|
||||
resolution: "@vitest/utils@npm:4.1.11"
|
||||
"@vitest/utils@npm:4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "@vitest/utils@npm:4.1.10"
|
||||
dependencies:
|
||||
"@vitest/pretty-format": "npm:4.1.11"
|
||||
"@vitest/pretty-format": "npm:4.1.10"
|
||||
convert-source-map: "npm:^2.0.0"
|
||||
tinyrainbow: "npm:^3.1.0"
|
||||
checksum: 10/f05381e12d0926db7b01bfaae9a577fa664d36b96c23df185e4b0f6dad3a9fb59ac00931613da53b4511ee6ab473a14ac500c72c5ec5e9b3c3042875051f20c4
|
||||
checksum: 10/95484aad55c7b00bbcd4963e27cbb86fe207620a6093973d68da9d0a06bad37c388d84c9ab43d5f35d88e46c8f376a5592d9c54c025c958361160f4802bb25ee
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -7570,15 +7501,6 @@ __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"
|
||||
@@ -8176,7 +8098,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"decimal.js@npm:^10.4.3, decimal.js@npm:^10.6.0":
|
||||
"decimal.js@npm:^10.6.0":
|
||||
version: 10.6.0
|
||||
resolution: "decimal.js@npm:10.6.0"
|
||||
checksum: 10/c0d45842d47c311d11b38ce7ccc911121953d4df3ebb1465d92b31970eb4f6738a065426a06094af59bee4b0d64e42e7c8984abd57b6767c64ea90cf90bb4a69
|
||||
@@ -8442,17 +8364,6 @@ __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"
|
||||
@@ -10047,7 +9958,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.11"
|
||||
"@vitest/coverage-v8": "npm:4.1.10"
|
||||
"@vvo/tzdb": "npm:6.198.0"
|
||||
"@webcomponents/scoped-custom-element-registry": "npm:0.0.10"
|
||||
"@webcomponents/webcomponentsjs": "npm:2.8.0"
|
||||
@@ -10068,7 +9979,6 @@ __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"
|
||||
@@ -10092,7 +10002,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.14"
|
||||
intl-messageformat: "npm:11.2.13"
|
||||
js-yaml: "npm:5.3.0"
|
||||
jsdom: "npm:30.0.1"
|
||||
jszip: "npm:3.10.1"
|
||||
@@ -10109,7 +10019,7 @@ __metadata:
|
||||
lodash.template: "npm:4.18.1"
|
||||
luxon: "npm:3.7.2"
|
||||
map-stream: "npm:0.0.7"
|
||||
marked: "npm:18.0.10"
|
||||
marked: "npm:18.0.9"
|
||||
memoize-one: "npm:6.0.0"
|
||||
minify-literals: "npm:2.1.0"
|
||||
node-vibrant: "npm:4.0.4"
|
||||
@@ -10134,7 +10044,7 @@ __metadata:
|
||||
typescript: "npm:6.0.3"
|
||||
typescript-eslint: "npm:8.67.0"
|
||||
vite-tsconfig-paths: "npm:6.1.1"
|
||||
vitest: "npm:4.1.11"
|
||||
vitest: "npm:4.1.10"
|
||||
webpack-stats-plugin: "npm:1.1.3"
|
||||
webpackbar: "npm:7.0.0"
|
||||
weekstart: "npm:2.0.0"
|
||||
@@ -10441,25 +10351,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"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"
|
||||
"intl-messageformat@npm:11.2.13":
|
||||
version: 11.2.13
|
||||
resolution: "intl-messageformat@npm:11.2.13"
|
||||
dependencies:
|
||||
"@formatjs/fast-memoize": "npm:3.1.7"
|
||||
"@formatjs/icu-messageformat-parser": "npm:3.5.17"
|
||||
checksum: 10/2720155c42bfd99a00d41f7bc9e2f5c2c83c2b6cb2353006b7d8e736a0f1ed20d0147406c1aeba0748731a0ce588c67304b52eb2fe92b42b928bf1ef6bffdcdf
|
||||
"@formatjs/icu-messageformat-parser": "npm:3.5.16"
|
||||
checksum: 10/7da1b2e01258ae310cd3aeabd6302497a70755ef860cc666405e89ede2927f5d5995d8a6f9be556d7dfc4f7b1eb0a13b26c9633234fdb4afea762013bb25af65
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -11757,12 +11655,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"marked@npm:18.0.10":
|
||||
version: 18.0.10
|
||||
resolution: "marked@npm:18.0.10"
|
||||
"marked@npm:18.0.9":
|
||||
version: 18.0.9
|
||||
resolution: "marked@npm:18.0.9"
|
||||
bin:
|
||||
marked: bin/marked.js
|
||||
checksum: 10/0d4b560e0773fd6ba30a4e7560ba7ae1f05d47b23926ad8337d9f80c598dfdbffdf2f81e773a1789e8bc9d9c2c2d3d0c9143a54d32581c336a3bbb1bad80461c
|
||||
checksum: 10/99d337c50acd57034734f8460f25b28e9658b15627f950092707cb443b840f0bd29fe343bf211b948e643303c34abdb5b5a12cf5245a846635750697524bb747
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -15470,17 +15368,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"vitest@npm:4.1.11":
|
||||
version: 4.1.11
|
||||
resolution: "vitest@npm:4.1.11"
|
||||
"vitest@npm:4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "vitest@npm:4.1.10"
|
||||
dependencies:
|
||||
"@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"
|
||||
"@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"
|
||||
es-module-lexer: "npm:^2.0.0"
|
||||
expect-type: "npm:^1.3.0"
|
||||
magic-string: "npm:^0.30.21"
|
||||
@@ -15498,12 +15396,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.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
|
||||
"@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
|
||||
happy-dom: "*"
|
||||
jsdom: "*"
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
@@ -15534,7 +15432,7 @@ __metadata:
|
||||
optional: false
|
||||
bin:
|
||||
vitest: ./vitest.mjs
|
||||
checksum: 10/054f1e25d90d911693b0b93c5b85a7c3105775aa3e39c3279c7d3e7af719e2d94070a898d6d74292445366c1215bb91d07063989ac0965973f0c5ae22ad3b06b
|
||||
checksum: 10/020843460fe696c23be2a363634dde4daf54625f1c443c24066ba3f87c478b0ccfdd5124343ba30eb092f54902ffea09f4bed0af4a16a9ee805e494ee2dce34e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user