mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-11 09:02:57 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cbce1c10b |
@@ -101,7 +101,6 @@
|
||||
"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.6.17",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -146,15 +146,13 @@ export const updateDeviceRegistryEntry = (
|
||||
...updates,
|
||||
});
|
||||
|
||||
export const removeConfigEntryFromDevice = (
|
||||
export const removeDeviceFromRegistry = (
|
||||
hass: HomeAssistant,
|
||||
deviceId: string,
|
||||
configEntryId: string
|
||||
deviceId: string
|
||||
) =>
|
||||
hass.callWS<DeviceRegistryEntry>({
|
||||
type: "config/device_registry/remove_config_entry",
|
||||
hass.callWS<null>({
|
||||
type: "config/device_registry/remove",
|
||||
device_id: deviceId,
|
||||
config_entry_id: configEntryId,
|
||||
});
|
||||
|
||||
export const sortDeviceRegistryByName = (
|
||||
|
||||
@@ -65,7 +65,7 @@ import {
|
||||
import { fireRelatedContext, fullEntitiesContext } from "../../../data/context";
|
||||
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
|
||||
import {
|
||||
removeConfigEntryFromDevice,
|
||||
removeDeviceFromRegistry,
|
||||
updateDeviceRegistryEntry,
|
||||
} from "../../../data/device/device_registry";
|
||||
import type { DiagnosticInfo } from "../../../data/diagnostics";
|
||||
@@ -1218,11 +1218,7 @@ export class HaConfigDevicePage extends LitElement {
|
||||
}
|
||||
|
||||
try {
|
||||
await removeConfigEntryFromDevice(
|
||||
this.hass,
|
||||
this.deviceId,
|
||||
entry.entry_id
|
||||
);
|
||||
await removeDeviceFromRegistry(this.hass, this.deviceId);
|
||||
} catch (err: unknown) {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
|
||||
@@ -65,7 +65,7 @@ import type {
|
||||
DeviceRegistryEntry,
|
||||
} from "../../../data/device/device_registry";
|
||||
import {
|
||||
removeConfigEntryFromDevice,
|
||||
removeDeviceFromRegistry,
|
||||
updateDeviceRegistryEntry,
|
||||
} from "../../../data/device/device_registry";
|
||||
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
|
||||
@@ -1206,19 +1206,9 @@ ${rejected
|
||||
dismissText: this.hass.localize("ui.common.cancel"),
|
||||
destructive: true,
|
||||
confirm: async () => {
|
||||
const proms: Promise<DeviceRegistryEntry>[] = [];
|
||||
const proms: Promise<null>[] = [];
|
||||
this._selectedCanDelete.forEach((deviceId) => {
|
||||
const entries = this.hass!.devices[deviceId]?.config_entries;
|
||||
entries.forEach((entryId) => {
|
||||
if (
|
||||
this.entries.find((entry) => entry.entry_id === entryId)
|
||||
?.supports_remove_device
|
||||
) {
|
||||
proms.push(
|
||||
removeConfigEntryFromDevice(this.hass!, deviceId, entryId)
|
||||
);
|
||||
}
|
||||
});
|
||||
proms.push(removeDeviceFromRegistry(this.hass!, deviceId));
|
||||
});
|
||||
const results = await Promise.allSettled(proms);
|
||||
if (hasRejectedItems(results)) {
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
type DisableConfigEntryResult,
|
||||
} from "../../../data/config_entries";
|
||||
import {
|
||||
removeConfigEntryFromDevice,
|
||||
removeDeviceFromRegistry,
|
||||
updateDeviceRegistryEntry,
|
||||
type DeviceRegistryEntry,
|
||||
} from "../../../data/device/device_registry";
|
||||
@@ -315,7 +315,6 @@ class HaConfigEntryDeviceRow extends LitElement {
|
||||
};
|
||||
|
||||
private _handleDeleteDevice = async () => {
|
||||
const entry = this.entry;
|
||||
const confirmed = await showConfirmationDialog(this, {
|
||||
text: this.hass.localize("ui.panel.config.devices.confirm_delete"),
|
||||
confirmText: this.hass.localize("ui.common.delete"),
|
||||
@@ -328,11 +327,7 @@ class HaConfigEntryDeviceRow extends LitElement {
|
||||
}
|
||||
|
||||
try {
|
||||
await removeConfigEntryFromDevice(
|
||||
this.hass!,
|
||||
this.device.id,
|
||||
entry.entry_id
|
||||
);
|
||||
await removeDeviceFromRegistry(this.hass!, this.device.id);
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize("ui.panel.config.devices.error_delete"),
|
||||
|
||||
@@ -1129,11 +1129,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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -2765,27 +2765,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"
|
||||
@@ -2793,17 +2772,6 @@ __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.16":
|
||||
version: 3.5.16
|
||||
resolution: "@formatjs/icu-messageformat-parser@npm:3.5.16"
|
||||
@@ -2813,16 +2781,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/icu-skeleton-parser@npm:1.8.14":
|
||||
version: 1.8.14
|
||||
resolution: "@formatjs/icu-skeleton-parser@npm:1.8.14"
|
||||
dependencies:
|
||||
"@formatjs/ecma402-abstract": "npm:2.3.4"
|
||||
tslib: "npm:^2.8.0"
|
||||
checksum: 10/2fbe3155c310358820b118d8c9844f314eff3500a82f1c65402434a3095823e1afeaab8d1762b4a59cc5679d82dc4c8c134683565d7cdae4daace23251f46a47
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/icu-skeleton-parser@npm:2.1.11":
|
||||
version: 2.1.11
|
||||
resolution: "@formatjs/icu-skeleton-parser@npm:2.1.11"
|
||||
@@ -2885,15 +2843,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"
|
||||
@@ -2941,24 +2890,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"
|
||||
@@ -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"
|
||||
@@ -10067,7 +9978,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"
|
||||
@@ -10439,18 +10349,6 @@ __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.13":
|
||||
version: 11.2.13
|
||||
resolution: "intl-messageformat@npm:11.2.13"
|
||||
|
||||
Reference in New Issue
Block a user