Compare commits

..
28 Commits
Author SHA1 Message Date
copilot-swe-agent[bot]andpiitaya 96d15065a9 Merge remote-tracking branch 'origin/chart-range-selection' into statistic-dialog
# Conflicts:
#	gallery/src/pages/components/state-history-charts.ts
#	gallery/src/pages/components/statistics-chart.ts
#	src/components/chart/ha-chart-base.ts
#	src/components/chart/statistics-chart.ts

Co-authored-by: piitaya <[email protected]>
2026-11-05 14:06:36 +00:00
Paul Bottein ca456105e5 Clear stale chart masks after a data update 2026-11-05 14:57:44 +01:00
Paul Bottein 9f12b54a14 Move chart selection and highlight into ha-chart-base 2026-11-05 14:57:44 +01:00
Paul Bottein cf25121dda Unify chart selection with a fade mask and a clear button in the gallery 2026-11-05 14:57:44 +01:00
Paul Bottein 71f53ae3e2 Add range selection examples to the statistics chart gallery page 2026-11-05 14:57:44 +01:00
Paul Bottein 8843596e42 Keep range selection on release outside the chart and simplify the highlight 2026-11-05 14:57:44 +01:00
Paul Bottein d06ec13af6 Add range selection and highlight to charts 2026-11-05 14:57:44 +01:00
Paul Bottein 1c428c1831 Merge branch 'chart-range-selection' into statistic-dialog
# Conflicts:
#	src/panels/config/tools/statistics/dialog-statistics-adjust-sum.ts
#	src/translations/en.json
2026-11-05 10:01:26 +01:00
Paul Bottein c20a90cd81 Merge branch 'gallery-statistics-chart' into chart-range-selection 2026-11-05 10:00:24 +01:00
Paul Bottein f64a602bc8 Add history and statistics chart pages to the gallery 2026-11-05 09:59:56 +01:00
Paul Bottein 3354b5ffec Keep range selection on release outside the chart and simplify the highlight 2026-11-05 09:38:09 +01:00
Paul Bottein fce263e6ab Add range selection and highlight to charts 2026-11-05 09:38:09 +01:00
copilot-swe-agent[bot] 6317dad51b Merge remote-tracking branch 'origin/chart-range-selection' into statistic-dialog
Co-authored-by: piitaya <[email protected]>
2026-11-02 22:53:47 +00:00
Paul Bottein c350d46d44 Keep range selection on release outside the chart and simplify the highlight 2026-10-29 17:12:46 +01:00
Paul Bottein e5a43b48c2 Add range selection and highlight to charts 2026-10-29 17:12:46 +01:00
Paul Bottein 73a88453db Pass no highlight when nothing is selected 2026-10-29 17:05:42 +01:00
Paul Bottein fba2451e61 Merge the chart range selection fixes into the statistic dialog 2026-10-29 17:05:37 +01:00
Paul Bottein c33a452976 Keep range selection on release outside the chart and simplify the highlight 2026-10-29 17:05:26 +01:00
Paul Bottein ec0533f8c5 Merge the chart range selection into the statistic dialog 2026-10-29 16:45:50 +01:00
Paul Bottein b1b9c41bc1 Add range selection and highlight to charts 2026-10-29 16:45:13 +01:00
Paul Bottein 685e7d51d2 Merge the zoom slider mode base into the statistic dialog 2026-10-29 16:39:23 +01:00
Paul Bottein 315afd8ac2 Replace hide-zoom-controls with a zoom slider mode on charts 2026-10-29 16:30:40 +01:00
Paul Bottein bc9d0b5227 Fix stale adjustments, zoom and outlier selection in the statistic dialog 2026-10-29 16:26:18 +01:00
Paul Bottein 99c85c3407 Show the last 7 days by default in the statistic dialog 2026-10-29 16:23:32 +01:00
Paul Bottein e04e639100 Always show the zoom slider and keep outliers and selection after adjusting 2026-10-29 16:10:29 +01:00
Paul Bottein ca7b91af35 Show the graph loader only on slow loads 2026-10-29 15:49:51 +01:00
Paul Bottein ffd26ffc1a Add sum outliers, keep the graph while loading and fix review feedback 2026-10-29 15:48:10 +01:00
Paul Bottein bd4ff71d29 Open statistics in a dialog to view and correct their data 2026-10-29 14:57:51 +01:00
50 changed files with 2390 additions and 1996 deletions

No files matched your search

+5 -5
View File
@@ -1,5 +1,5 @@
/*
Cache-Control: public, max-age=0, s-maxage=3600, must-revalidate
Cache-Control: public, max-age: 0, s-maxage=3600, must-revalidate
Content-Security-Policy: form-action https:
Feature-Policy: vibrate 'none'; geolocation 'none'; midi 'none'; microphone 'none'; camera 'none'; magnetometer 'none'; gyroscope 'none'; speaker 'none'; vibrate 'none'; payment 'none'
Referrer-Policy: no-referrer-when-downgrade
@@ -8,13 +8,13 @@
X-XSS-Protection: 1; mode=block
/images/*
Cache-Control: public, max-age=604800, s-maxage=604800
Cache-Control: public, max-age: 604800, s-maxage=604800
/manifest.json
Cache-Control: public, max-age=3600, s-maxage=3600
Cache-Control: public, max-age: 3600, s-maxage=3600
/frontend_es5/*
Cache-Control: public, max-age=604800, s-maxage=604800
Cache-Control: public, max-age: 604800, s-maxage=604800
/frontend_latest/*
Cache-Control: public, max-age=604800, s-maxage=604800
Cache-Control: public, max-age: 604800, s-maxage=604800
+1 -1
View File
@@ -1,6 +1,6 @@
{
"background_color": "#FFFFFF",
"description": "Show Home Assistant on your Chromecast or Google Home devices with a screen.",
"description": "Show Home Assistant on your Chromecast or Google Assistant devices with a screen.",
"dir": "ltr",
"display": "standalone",
"icons": [
+2 -2
View File
@@ -5,7 +5,7 @@
<meta property="og:type" content="website" />
<meta
property="og:description"
content="Show Home Assistant on your Chromecast or Google Home devices with a screen."
content="Show Home Assistant on your Chromecast or Google Assistant devices with a screen."
/>
<meta
property="og:image"
@@ -16,7 +16,7 @@
<meta name="twitter:title" content="Home Assistant Cast" />
<meta
name="twitter:description"
content="Show Home Assistant on your Chromecast or Google Home devices with a screen."
content="Show Home Assistant on your Chromecast or Google Assistant devices with a screen."
/>
<meta
name="twitter:image"
+1 -1
View File
@@ -52,7 +52,7 @@
<p>
Home Assistant Cast allows you to show your Home Assistant data on a
Chromecast device and allows you to interact with Home Assistant on
Google Home devices with a screen.
Google Assistant devices with a screen.
</p>
</div>
+1 -1
View File
@@ -48,7 +48,7 @@ const translateErr = (err) =>
const INTRO = html`
<p>
Home Assistant Cast allows you to cast your Home Assistant installation to
Chromecast video devices and to Google Home devices with a screen.
Chromecast video devices and to Google Assistant devices with a screen.
</p>
<p>
For more information, see the
+1 -1
View File
@@ -6,7 +6,7 @@ const playerManager = castContext.getPlayerManager();
playerManager.setMessageInterceptor("LOAD", (loadRequestData) => {
const media = loadRequestData.media;
// Special handling if it came from Google Home
// Special handling if it came from Google Assistant
if (media.entity) {
media.contentId = media.entity;
media.streamType = "LIVE";
+1 -1
View File
@@ -13,7 +13,7 @@ export const castDemoLovelace: () => LovelaceConfig = () => {
{
type: "markdown",
title: "Home Assistant Cast",
content: `With Home Assistant you can easily create interfaces (just like this one) which can be shown on Chromecast devices connected to TVs or Google Home devices with a screen.${
content: `With Home Assistant you can easily create interfaces (just like this one) which can be shown on Chromecast devices connected to TVs or Google Assistant devices with a screen.${
touchSupported
? "\n\nYou are able to interact with this demo using the touch screen."
: "\n\nOn a Google Nest Hub you are able to interact with Home Assistant Cast via the touch screen."
+1 -1
View File
@@ -117,7 +117,7 @@ playerManager.setMessageInterceptor("LOAD", (loadRequestData) => {
// We received a play media command, hide Lovelace and show media player
showMediaPlayer();
const media = loadRequestData.media;
// Special handling if it came from Google Home
// Special handling if it came from Google Assistant
if (media.entity) {
media.contentId = media.entity;
media.streamType = "LIVE";
+5 -5
View File
@@ -1,21 +1,21 @@
/*
Cache-Control: public, max-age=0, s-maxage=3600, must-revalidate
Cache-Control: public, max-age: 0, s-maxage=3600, must-revalidate
Content-Security-Policy: form-action https:
Referrer-Policy: no-referrer-when-downgrade
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
/api/*
Cache-Control: public, max-age=604800, s-maxage=604800
Cache-Control: public, max-age: 604800, s-maxage=604800
/assets/*
Cache-Control: public, max-age=604800, s-maxage=604800
Cache-Control: public, max-age: 604800, s-maxage=604800
/frontend_es5/*
Cache-Control: public, max-age=604800, s-maxage=604800
Cache-Control: public, max-age: 604800, s-maxage=604800
/frontend_latest/*
Cache-Control: public, max-age=604800, s-maxage=604800
Cache-Control: public, max-age: 604800, s-maxage=604800
Access-Control-Allow-Origin: *
/embed.js
+1 -5
View File
@@ -168,8 +168,7 @@ export class HaEmbedProvider extends LitElement {
hass.mockWS("config/device_registry/list", (_msg, currentHass) =>
Object.values(currentHass.devices)
);
// The registry of the entities, with the options each entity sets, such
// as timer presets. Other settings, such as calendar colors, are not set.
// The registry of the entities, without settings such as calendar colors
const registryEntries = (currentHass: MockHomeAssistant) =>
Object.values(currentHass.entities).map(
(entity): EntityRegistryEntry => ({
@@ -180,9 +179,6 @@ export class HaEmbedProvider extends LitElement {
}),
area_id: entity.area_id ?? null,
device_id: entity.device_id ?? null,
options:
this.entities.find((item) => item.entity_id === entity.entity_id)
?.options ?? null,
})
);
hass.mockWS("config/entity_registry/list", (_msg, currentHass) =>
+19
View File
@@ -0,0 +1,19 @@
import type { ReactiveElement } from "lit";
import { ContextSubscriptionController } from "../../../src/common/decorators/consume";
import { uiContext } from "../../../src/data/context";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
// A demo that provides its own hass hides the gallery theme from its children,
// so pass the gallery light or dark mode on to it.
export const followGalleryTheme = (
host: ReactiveElement,
hass: MockHomeAssistant
) =>
new ContextSubscriptionController(host, uiContext, (ui) => {
if (ui) {
hass.updateHass({
themes: ui.themes,
selectedTheme: ui.selectedTheme,
});
}
});
+2 -2
View File
@@ -4,7 +4,7 @@ title: "Our story"
## Open source home automation that puts local control and privacy first
Home Assistant is a free and open-source software for home automation that is designed to be the central control system for smart home devices with a focus on local control and privacy. It can be accessed via a web-based user interface, via apps for Android and iOS, or using voice commands via a supported virtual assistant like Google Home and Amazon Alexa.
Home Assistant is a free and open-source software for home automation that is designed to be the central control system for smart home devices with a focus on local control and privacy. It can be accessed via a web-based user interface, via apps for Android and iOS, or using voice commands via a supported virtual assistant like Google Assistant and Amazon Alexa.
IoT devices and services are supported by modular support for controlling proprietary ecosystems if they provide public access via an Open API for third-party integrations and protocols like Bluetooth, MQTT, Zigbee, and Z-Wave, After the Home Assistant software application is installed as a computer appliance it will act as a central control system for home automation. Information from all entities it sees can be used and controlled from within scripts trigger automations using scheduling and "blueprint" subroutines, e.g. for controlling lighting, climate, entertainment systems, and appliances.
@@ -36,6 +36,6 @@ Durability for the Open Home means that devices are designed and built to keep w
The project was started as a Python application by Paulus Schoutsen in September 2013 and first published publicly on GitHub in November 2013. In July 2017, a managed operating system called Hass.io was initially introduced to make it easier use to use Home Assistant on single-board computers like the Raspberry Pi series. Its bundled "supervisor" management system allowed users to manage, backup, and update the local installation and introduced the option to extend the functionality of the software with add-ons.
An optional subscription service was introduced in December 2017 for $5/month to solve the complexities associated with secured remote access, as well as linking to Amazon Alexa and Google Home. Nabu Casa, Inc. was formed in September 2018 to take over the subscription service. The company's funding is based solely on revenue from the subscription service. It is used to finance the project's infrastructure and to pay for full-time employees contributing to the project.
An optional subscription service was introduced in December 2017 for $5/month to solve the complexities associated with secured remote access, as well as linking to Amazon Alexa and Google Assistant. Nabu Casa, Inc. was formed in September 2018 to take over the subscription service. The company's funding is based solely on revenue from the subscription service. It is used to finance the project's infrastructure and to pay for full-time employees contributing to the project.
In January 2020, branding was adjusted to make it easier to refer to different parts of the project. The main piece of software was renamed to Home Assistant Core, while the full suite of software with the embedded operating system and bundled "supervisor" management system was renamed to Home Assistant.
@@ -10,6 +10,7 @@ import type {
} from "../../../../src/data/history";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
import { followGalleryTheme } from "../../data/follow-gallery-theme";
const HOUR = 60 * 60 * 1000;
const HOURS = 48;
@@ -166,6 +167,7 @@ export class DemoStateHistoryCharts extends LitElement {
super.firstUpdated(changedProps);
const hass = provideHass(this);
hass.updateTranslations(null, "en");
followGalleryTheme(this, hass);
hass.addEntities(ENTITIES);
}
@@ -1,7 +1,8 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import "../../../../src/components/chart/statistics-chart";
import "../../../../src/components/ha-button";
import "../../../../src/components/ha-card";
import type {
Statistics,
@@ -11,7 +12,9 @@ import type {
} from "../../../../src/data/recorder";
import { StatisticMeanType } from "../../../../src/data/recorder";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
import type { HomeAssistant } from "../../../../src/types";
import { followGalleryTheme } from "../../data/follow-gallery-theme";
const HOUR = 60 * 60 * 1000;
const HOURS = 48;
@@ -108,6 +111,64 @@ interface Example {
height?: string;
}
interface TimeRange {
start: number;
end: number;
}
interface RangeExample {
id: string;
title: string;
description: string;
statistics: Statistics;
statTypes: StatisticType[];
chartType: "line" | "bar";
unit: string;
selectRange: boolean;
hint: string;
}
const RANGE_EXAMPLES: RangeExample[] = [
{
id: "point",
title: "Point selection",
description:
"A click selects the hour that contains it, and everything outside fades. Dragging still zooms.",
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP },
statTypes: ["change"],
chartType: "bar",
unit: "kWh",
selectRange: false,
hint: "Click on a bar to select its hour.",
},
{
id: "range-bar",
title: "Range selection on bars",
description:
"With select-range, dragging selects a time range instead of zooming, so several bars can be selected at once. A click still selects one bar.",
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP },
statTypes: ["change"],
chartType: "bar",
unit: "kWh",
selectRange: true,
hint: "Click or drag on the chart to select one or more bars.",
},
{
id: "range",
title: "Range selection on lines",
description: "The same on a line chart.",
statistics: { [LIVING_ROOM_ID]: LIVING_ROOM },
statTypes: ["mean", "min", "max"],
chartType: "line",
unit: "°C",
selectRange: true,
hint: "Click or drag on the chart to select a period.",
},
];
const formatRange = (range: TimeRange) =>
`${new Date(range.start).toLocaleString()} to ${new Date(range.end).toLocaleString()}`;
const EXAMPLES: Example[] = [
{
title: "Sum as bars",
@@ -159,17 +220,20 @@ const EXAMPLES: Example[] = [
export class DemoStatisticsChart extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _selections: Record<string, TimeRange | undefined> = {};
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
const hass = provideHass(this);
hass.updateTranslations(null, "en");
followGalleryTheme(this, hass);
}
protected render() {
if (!this.hass) {
return nothing;
}
return EXAMPLES.map(
return html`${EXAMPLES.map(
(example) => html`
<ha-card header=${example.title}>
<div class="card-content">
@@ -191,7 +255,66 @@ export class DemoStatisticsChart extends LitElement {
</div>
</ha-card>
`
);
)}
${RANGE_EXAMPLES.map((example) => this._renderRangeExample(example))}`;
}
private _renderRangeExample(example: RangeExample) {
const selection = this._selections[example.id];
return html`
<ha-card header=${example.title}>
<div class="card-content">
<p>${example.description}</p>
<statistics-chart
.statisticsData=${example.statistics}
.metadata=${METADATA}
.names=${NAMES}
.statTypes=${example.statTypes}
.chartType=${example.chartType}
period="hour"
.unit=${example.unit}
.startTime=${START}
.endTime=${END}
.highlight=${selection}
.clickForMoreInfo=${false}
data-example=${example.id}
.selectRange=${example.selectRange}
hide-legend
@chart-select=${this._rangeSelected}
></statistics-chart>
<div class="selection">
${
selection
? html`
<span>${formatRange(selection)}</span>
<ha-button
appearance="plain"
size="s"
data-example=${example.id}
@click=${this._clearSelection}
>
Clear
</ha-button>
`
: html`<span class="hint">${example.hint}</span>`
}
</div>
</div>
</ha-card>
`;
}
private _rangeSelected(ev: HASSDomEvent<HASSDomEvents["chart-select"]>) {
const id = (ev.currentTarget as HTMLElement).dataset.example!;
this._selections = {
...this._selections,
[id]: ev.detail,
};
}
private _clearSelection(ev: Event) {
const id = (ev.currentTarget as HTMLElement).dataset.example!;
this._selections = { ...this._selections, [id]: undefined };
}
static styles = css`
@@ -202,6 +325,20 @@ export class DemoStatisticsChart extends LitElement {
p {
margin-top: 0;
}
.selection {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ha-space-2);
min-height: 48px;
margin-top: var(--ha-space-2);
padding: 0 var(--ha-space-2) 0 var(--ha-space-4);
border-radius: var(--ha-border-radius-lg);
background-color: var(--secondary-background-color);
}
.hint {
color: var(--secondary-text-color);
}
`;
}
+1 -1
View File
@@ -121,7 +121,7 @@ const brandEntries: ConfigEntryExtended[] = (
["tuya", "Tuya", "setup_error"],
["unifi", "UniFi Network", "not_loaded"],
["met", "Meteorologisk institutt (Met.no)", "setup_error"],
["google_assistant", "Google Home", "not_loaded"],
["google_assistant", "Google Assistant", "not_loaded"],
["homekit_controller", "HomeKit Device", "setup_retry"],
] as [string, string, ConfigEntryExtended["state"]][]
).map(([domain, name, entryState]) =>
+5 -5
View File
@@ -40,7 +40,7 @@
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "8.1.0",
"@babel/runtime": "8.0.10",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.4",
"@codemirror/commands": "6.11.1",
@@ -80,7 +80,7 @@
"@swc/helpers": "0.5.23",
"@tsparticles/engine": "4.5.0",
"@tsparticles/preset-links": "4.5.0",
"@versatiles/style": "8.0.0",
"@versatiles/style": "7.1.0",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.202.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
@@ -137,7 +137,7 @@
},
"devDependencies": {
"@ampproject/remapping": "2.3.0",
"@babel/core": "8.1.0",
"@babel/core": "8.0.10",
"@babel/helper-compilation-targets": "8.0.7",
"@babel/helper-define-polyfill-provider": "1.0.0",
"@babel/plugin-transform-class-properties": "8.0.1",
@@ -170,7 +170,7 @@
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@typescript/native": "npm:[email protected]",
"@vitest/coverage-v8": "5.1.0",
"@vitest/coverage-v8": "5.0.5",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist": "4.29.5",
@@ -217,7 +217,7 @@
"typescript-eslint": "8.72.1",
"vite": "8.3.5",
"vite-tsconfig-paths": "6.1.1",
"vitest": "5.1.0",
"vitest": "5.0.5",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "7.4.1"
+394 -394
View File
File diff suppressed because it is too large. Load diff
+2 -13
View File
@@ -16,13 +16,6 @@ declare global {
export interface NavigateOptions {
replace?: boolean;
data?: Record<string, unknown>;
/**
* Leave open dialogs open. For a replace that only canonicalizes the current
* URL, like a router filling in its default page: a dialog restored from the
* URL, such as a more-info deep link, may already be open at that point, and
* the entry keeps its state so back still closes it.
*/
keepDialogs?: boolean;
}
// max time to wait for dialogs to close before navigating
@@ -203,8 +196,7 @@ const buildHistoryState = (
};
const performNavigation = async (path: string, options?: NavigateOptions) => {
const keepDialogs = options?.keepDialogs || false;
const canProceed = await (keepDialogs || ensureDialogsClosed(Date.now()));
const canProceed = await ensureDialogsClosed(Date.now());
if (!canProceed) {
return false;
}
@@ -223,10 +215,7 @@ const performNavigation = async (path: string, options?: NavigateOptions) => {
const { history } = mainWindow;
if (replace && keepDialogs) {
// Same entry under its canonical URL: its state, dialog included, stays.
history.replaceState(history.state, "", path);
} else if (replace) {
if (replace) {
// A replaced entry keeps its predecessor, so it keeps `from`.
const { root, from } = history.state ?? {};
const data = root ? { root: true } : options?.data;
+173 -5
View File
@@ -7,7 +7,10 @@ import {
mdiRestart,
} from "@mdi/js";
import { differenceInMinutes } from "date-fns";
import type { DataZoomComponentOption } from "echarts/components";
import type {
DataZoomComponentOption,
MarkAreaComponentOption,
} from "echarts/components";
import type { EChartsType } from "echarts/core";
import type {
ECElementEvent,
@@ -90,6 +93,12 @@ export const DEFAULT_CHART_WIDTH = 500;
// is short enough for a whole screenful; on a desktop that would cover the page.
const VISIBILITY_ROOT_MARGIN_NARROW = "100%";
const VISIBILITY_ROOT_MARGIN = "300px";
const CLICK_DRAG_TOLERANCE = 4;
// The selection is shown by the mask, not by the brush
const BRUSH_STYLE = {
color: "rgba(0, 0, 0, 0)",
borderColor: "rgba(0, 0, 0, 0)",
};
export type ZoomSliderMode = "auto" | "always" | "never";
const DEFERRED_PROPS = [
@@ -97,7 +106,14 @@ const DEFERRED_PROPS = [
"data",
"_hiddenDatasets",
"_isZoomed",
"highlight",
] as const;
const MASK_OPACITY = 0.7;
export interface ChartTimeRange {
start: number;
end: number;
}
type RawSeriesOption = Exclude<
NonNullable<ECOption["series"]>,
@@ -176,6 +192,16 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
@property({ attribute: "grow-for-zoom-slider", type: Boolean })
public growForZoomSlider = false;
// Dragging selects a time range instead of zooming
@property({ attribute: "select-range", type: Boolean })
public selectRange = false;
// Everything outside this time range fades
@property({ attribute: false }) public highlight?: ChartTimeRange;
// Rounds a selection to the periods of the data, or drops it
@property({ attribute: false })
public snapSelection?: (range: ChartTimeRange) => ChartTimeRange | undefined;
// extraComponents is not reactive and should not trigger updates
public extraComponents?: any[];
@@ -201,6 +227,10 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
@state() private _isZoomed = false;
private _pointerDownX?: number;
private _selectingRange?: ChartTimeRange;
// Separate from _isZoomed, whose touch handling reruns graph layouts
@state() private _isGraphRoamed = false;
@@ -454,6 +484,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
if (!this.chart) {
return;
}
if (changedProps.has("selectRange")) {
this._updateSelectRange();
}
const themeChanged = changedProps.has("_themes") && this.hasUpdated;
if (!themeChanged && !DEFERRED_PROPS.some((p) => changedProps.has(p))) {
return;
@@ -499,6 +532,8 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
) {
this._disposeSonification();
}
} else if (changedProps.has("highlight")) {
this._updateMask();
}
if (changedProps.has("options")) {
chartOptions = { ...chartOptions, ...this._createOptions() };
@@ -905,6 +940,54 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
this.chart.on("click", (e: ECElementEvent) => {
fireEvent(this, "chart-click", e);
});
this.chart.getZr().on("mousedown", (e: ECElementEvent) => {
this._pointerDownX = e.offsetX;
});
this.chart.getZr().on("click", (e: ECElementEvent) => {
const point = [e.offsetX, e.offsetY];
// A drag that ends on the plot is not a click
const dragged =
this._pointerDownX !== undefined &&
Math.abs(e.offsetX - this._pointerDownX) > CLICK_DRAG_TOLERANCE;
if (dragged || !this.chart?.containPixel("grid", point)) {
return;
}
const [x, y] = this.chart.convertFromPixel("grid", point) as number[];
fireEvent(this, "chart-grid-click", { x, y });
const selection = this._snapRange({ start: x, end: x });
if (selection) {
fireEvent(this, "chart-select", selection);
}
});
this.chart.on("brush", (e: any) => {
const range = e.areas?.[0]?.coordRange as number[] | undefined;
if (range) {
this._selectingRange = this._snapRange({
start: range[0],
end: range[1],
});
this._updateMask();
}
});
this.chart.on("brushEnd", (e: any) => {
const range = e.areas?.[0]?.coordRange as number[] | undefined;
this.chart?.dispatchAction({ type: "brush", areas: [] });
this._selectingRange = undefined;
this._updateMask();
if (range) {
fireEvent(this, "chart-range-select", {
start: range[0],
end: range[1],
});
const selection = this._snapRange({
start: range[0],
end: range[1],
});
if (selection) {
fireEvent(this, "chart-select", selection);
}
}
});
this.chart.on("sankeyroam", () => {
const option = this.chart!.getOption();
const series = option.series as any[];
@@ -970,6 +1053,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
this._legendItemCount = this._getLegendItems()?.length;
this._updateSankeyRoam();
this._updateDragToZoom();
if (this.selectRange) {
this._updateSelectRange();
}
if (zoomStart !== 0 || zoomEnd !== 100) {
this.zoom(zoomStart, zoomEnd, true);
}
@@ -1021,7 +1107,8 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
// A chart can switch type (e.g. pie and bar) without being rebuilt
private _updateDragToZoom() {
const active = !this._touchInput && this._hasZoomableXAxis();
const active =
!this._touchInput && this._hasZoomableXAxis() && !this.selectRange;
if (!this.chart || active === this._dragToZoom) {
return;
}
@@ -1033,6 +1120,73 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
});
}
private _snapRange(range: ChartTimeRange) {
return this.snapSelection ? this.snapSelection(range) : range;
}
private _getMaskArea(): MarkAreaComponentOption | undefined {
const range = this._selectingRange ?? this.highlight;
if (!range) {
return undefined;
}
const style = getComputedStyle(this);
return {
silent: true,
z: 10,
itemStyle: {
color:
style.getPropertyValue("--card-background-color").trim() ||
style.getPropertyValue("--primary-background-color").trim(),
opacity: MASK_OPACITY,
},
data: [
[{ xAxis: -Infinity }, { xAxis: range.start }],
[{ xAxis: range.end }, { xAxis: Infinity }],
],
};
}
// Only the first series carries the mask, merged in without resending data
private _updateMask() {
this.chart?.setOption({
series: [{ markArea: this._getMaskArea() ?? { data: [] } }],
});
}
private _updateSelectRange() {
if (!this.chart) {
return;
}
if (this.selectRange) {
this.chart.setOption({
brush: {
xAxisIndex: 0,
// Only measures the range, the mask fades what is outside
seriesIndex: [],
transformable: false,
brushStyle: BRUSH_STYLE,
},
});
}
const brushCursor = {
type: "takeGlobalCursor",
key: "brush",
brushOption: {
brushType: this.selectRange ? "lineX" : false,
brushMode: "single",
brushStyle: BRUSH_STYLE,
},
};
// Brush and drag to zoom share the global cursor, the last one taken wins
if (this.selectRange) {
this._updateDragToZoom();
this.chart.dispatchAction(brushCursor);
} else {
this.chart.dispatchAction(brushCursor);
this._updateDragToZoom();
}
}
private _hasZoomableXAxis() {
const xAxis = ensureArray(this.options?.xAxis)?.[0] as
XAXisOption | undefined;
@@ -1475,7 +1629,8 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
private _getSeries() {
const xAxis = (this.options?.xAxis?.[0] ?? this.options?.xAxis) as
XAXisOption | undefined;
const series = ensureArray(this.data).map((s) => {
const maskArea = this._getMaskArea();
const series = ensureArray(this.data).map((s, index) => {
const data = this._hiddenDatasets.has(String(s.id ?? s.name))
? undefined
: s.data;
@@ -1513,6 +1668,16 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
} as HaECSeriesItem;
}
}
// ECharts merges series by id and would keep a mask left out here
if (s.type === "line" || s.type === "bar") {
result = {
...result,
markArea:
index === 0 && maskArea
? maskArea
: ((s as LineSeriesOption).markArea ?? { data: [] }),
} as HaECSeriesItem;
}
return processSeriesTooltipFormatter(result);
});
return series as ECOption["series"];
@@ -1549,7 +1714,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
}
private _handleClickZoom = (e: ECElementEvent) => {
if (!this.chart) {
if (!this.chart || this.selectRange) {
return;
}
// Handle sankey chart double-click zoom
@@ -1762,7 +1927,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
// it, so a quick drag out would lose the release and its selection
if (
ev.type === "pointerdown" &&
this._dragToZoom &&
(this._dragToZoom || this.selectRange) &&
this._isZoomPress(ev)
) {
this.chart
@@ -2337,6 +2502,9 @@ declare global {
"dataset-hidden": { id: string };
"dataset-unhidden": { id: string };
"chart-click": ECElementEvent;
"chart-grid-click": { x: number; y: number };
"chart-range-select": { start: number; end: number };
"chart-select": ChartTimeRange;
"legend-label-click": { id: string };
"chart-zoom": {
start: number;
+52 -2
View File
@@ -5,7 +5,7 @@ import type {
import type { HassConfig, HassEntities } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
@@ -49,7 +49,12 @@ import type {
HomeAssistantUI,
} from "../../types";
import { getPeriodicAxisLabelConfig } from "./axis-label";
import type { CustomLegendOption } from "./ha-chart-base";
import type {
ChartTimeRange,
CustomLegendOption,
HaChartBase,
ZoomSliderMode,
} from "./ha-chart-base";
import "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
@@ -128,6 +133,19 @@ export class StatisticsChart extends LitElement {
@property({ attribute: "expand-legend", type: Boolean })
public expandLegend?: boolean;
@property({ attribute: "select-range", type: Boolean })
public selectRange = false;
@property({ attribute: "zoom-slider" })
public zoomSlider: ZoomSliderMode = "auto";
@property({ attribute: false }) public highlight?: {
start: number;
end: number;
};
@query("ha-chart-base") private _chartBase?: HaChartBase;
@state() private _chartData: (LineSeriesOption | BarSeriesOption)[] = [];
@state() private _legendData: CustomLegendOption["data"];
@@ -244,8 +262,12 @@ export class StatisticsChart extends LitElement {
return html`
<ha-chart-base
.data=${this._chartData}
.highlight=${this.highlight}
.snapSelection=${this._snapSelection}
.options=${this._chartOptions}
.height=${this.height}
.selectRange=${this.selectRange}
.zoomSlider=${this.zoomSlider}
style=${styleMap({ height: this.height })}
@dataset-hidden=${this._datasetHidden}
@dataset-unhidden=${this._datasetUnhidden}
@@ -259,6 +281,34 @@ export class StatisticsChart extends LitElement {
`;
}
public resetZoom() {
this._chartBase?.zoom(0, 100);
}
// A click selects the period that contains it, a drag every period it
// touches
private _snapSelection = (
range: ChartTimeRange
): ChartTimeRange | undefined => {
let start: number | undefined;
let end: number | undefined;
Object.values(this.statisticsData ?? {}).forEach((values) =>
values.forEach((value) => {
const touched =
range.start === range.end
? value.start <= range.start && range.start < value.end
: value.start < range.end && value.end > range.start;
if (touched) {
start = Math.min(start ?? value.start, value.start);
end = Math.max(end ?? value.end, value.end);
}
})
);
return start === undefined || end === undefined
? undefined
: { start, end };
};
private _datasetHidden(ev: HASSDomEvent<HASSDomEvents["dataset-hidden"]>) {
this._hiddenStats.add(ev.detail.id);
this.requestUpdate("_hiddenStats");
@@ -107,6 +107,9 @@ export class HaDateRangePicker extends LitElement {
@property({ attribute: false }) public ranges?: DateRangePickerRanges | false;
// Added after the default presets
@property({ attribute: false }) public extraRanges?: DateRangePickerRanges;
@state() private _ranges?: DateRangePickerRanges;
@property({ attribute: "time-picker", type: Boolean })
@@ -166,7 +169,8 @@ export class HaDateRangePicker extends LitElement {
if (
changedProps.has("_i18n") ||
changedProps.has("_hassConfig") ||
changedProps.has("extendedPresets")
changedProps.has("extendedPresets") ||
changedProps.has("extraRanges")
) {
const rangeKeys = this.extendedPresets
? [...RANGE_KEYS, ...EXTENDED_RANGE_KEYS]
@@ -178,7 +182,7 @@ export class HaDateRangePicker extends LitElement {
this._i18n.localize(`ui.components.date-range-picker.ranges.${key}`)
] = calcDateRange(this._i18n.locale, this._hassConfig, key);
});
this._ranges = ranges;
this._ranges = { ...ranges, ...this.extraRanges };
}
}
-29
View File
@@ -390,32 +390,3 @@ export const getAutomaticEntityIds = (
type: "config/entity_registry/get_automatic_entity_ids",
entity_ids,
});
/**
* Set the area of entities and regenerate their entity IDs, which can include
* the area. Returns the entity ID of each entity after the change.
*/
export const updateEntitiesArea = async (
hass: HomeAssistant,
entityIds: string[],
areaId: string
): Promise<Record<string, string>> => {
await Promise.all(
entityIds.map((entityId) =>
updateEntityRegistryEntry(hass, entityId, { area_id: areaId })
)
);
const newEntityIds = await getAutomaticEntityIds(hass, entityIds);
const result: Record<string, string> = {};
for (const entityId of entityIds) {
const newEntityId = newEntityIds[entityId];
if (newEntityId) {
// eslint-disable-next-line no-await-in-loop
await updateEntityRegistryEntry(hass, entityId, {
new_entity_id: newEntityId,
});
}
result[entityId] = newEntityId || entityId;
}
return result;
};
+1 -2
View File
@@ -19,8 +19,7 @@ export interface ESPHomeZWaveProxyCapabilities {
export interface ESPHomeSerialProxy {
name: string;
port_type: ESPHomeSerialPortType | null;
// `null` when no device is present (e.g. unpopulated USB port)
url: string | null;
url: string;
}
export interface ESPHomeDeviceCapabilities {
+1 -1
View File
@@ -10,7 +10,7 @@ export const voiceAssistants = {
},
"cloud.google_assistant": {
domain: "google_assistant",
name: "Google Home",
name: "Google Assistant",
},
} as const;
+1 -1
View File
@@ -349,7 +349,7 @@ export const statisticsMetaHasType = (
};
export const adjustStatisticsSum = (
hass: HomeAssistant,
hass: Pick<HomeAssistant, "callWS">,
statistic_id: string,
start_time: number,
adjustment: number,
@@ -481,7 +481,6 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
.flowConfig=${this._params.flowConfig}
.step=${this._step}
.hass=${this.hass}
.manifest=${this._params.manifest}
.navigateToResult=${
this._params.navigateToResult ?? false
}
@@ -839,9 +838,6 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
return [
haStyleDialog,
css`
ha-dialog {
--ha-dialog-header-title-height: auto;
}
.dialog-title {
overflow: hidden;
text-overflow: ellipsis;
+15 -160
View File
@@ -20,18 +20,13 @@ import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import { updateDeviceRegistryEntry } from "../../data/device/device_registry";
import {
getAutomaticEntityIds,
getExtendedEntityRegistryEntries,
updateEntitiesArea,
updateEntityRegistryEntry,
type EntityRegistryDisplayEntry,
type ExtEntityRegistryEntry,
} from "../../data/entity/entity_registry";
import { domainToName, type IntegrationManifest } from "../../data/integration";
import { findRelated } from "../../data/search";
import { domainToName } from "../../data/integration";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import { showAlertDialog } from "../generic/show-dialog-box";
import { showMoreInfoDialog } from "../more-info/show-ha-more-info-dialog";
import { showVoiceAssistantSetupDialog } from "../voice-assistant-setup/show-voice-assistant-setup-dialog";
import type { FlowConfig } from "./show-dialog-data-entry-flow";
import { configFlowContentStyles } from "./styles";
@@ -40,28 +35,6 @@ interface DeviceTarget {
device: string;
}
interface HelperResult {
deviceId?: string;
entityId?: string;
}
const computeHelperResult = (
entities: ExtEntityRegistryEntry[]
): HelperResult | undefined => {
const deviceIds = [
...new Set(
entities.flatMap((entity) => (entity.device_id ? [entity.device_id] : []))
),
];
if (deviceIds.length === 1) {
return { deviceId: deviceIds[0] };
}
if (!deviceIds.length && entities.length === 1) {
return { entityId: entities[0].entity_id };
}
return undefined;
};
@customElement("step-flow-create-entry")
class StepFlowCreateEntry extends LitElement {
@property({ attribute: false }) public flowConfig!: FlowConfig;
@@ -73,18 +46,10 @@ class StepFlowCreateEntry extends LitElement {
@property({ attribute: false }) public devices!: DeviceRegistryEntry[];
@property({ attribute: false }) public manifest?: IntegrationManifest | null;
private _domains: Record<string, string> = {};
public navigateToResult = false;
private _helperEntitiesLookup?: Promise<ExtEntityRegistryEntry[]>;
@state() private _helperEntities?: ExtEntityRegistryEntry[];
@state() private _helperArea?: string;
@state() private _deviceUpdate: Record<
string,
{ name?: string; area?: string }
@@ -108,18 +73,6 @@ class StepFlowCreateEntry extends LitElement {
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this._loadDomains();
if (
this.flowConfig.flowType === "config_flow" &&
this.step.result &&
this.manifest?.integration_type === "helper"
) {
this._helperEntitiesLookup = this._findHelperEntities(
this.step.result.entry_id
);
this._helperEntitiesLookup.then((entities) => {
this._helperEntities = entities;
});
}
}
protected willUpdate(changedProps: PropertyValues<this>) {
@@ -180,23 +133,11 @@ class StepFlowCreateEntry extends LitElement {
? nothing
: this.devices.length === 0
? html`<p>
${localize(
"ui.panel.config.integrations.config_flow.created_config",
{ name: html`<b>${this.step.title}</b>` }
)}
</p>
${
this._entitiesWithoutDevice(this._helperEntities).length
? html`<ha-area-picker
.value=${this._helperArea}
.disabled=${this._loading}
.helper=${localize(
"ui.panel.config.integrations.config_flow.helper_area"
)}
@value-changed=${this._helperAreaPicked}
></ha-area-picker>`
: nothing
}`
${localize(
"ui.panel.config.integrations.config_flow.created_config",
{ name: this.step.title }
)}
</p>`
: html`
<div class="devices">
${this.devices.map(
@@ -273,11 +214,9 @@ class StepFlowCreateEntry extends LitElement {
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (changedProps.has("_deviceUpdate") || changedProps.has("_helperArea")) {
if (changedProps.has("_deviceUpdate")) {
fireEvent(this, "flow-step-footer-state-changed", {
hasPendingUpdates:
Object.keys(this._deviceUpdate).length > 0 ||
this._helperArea !== undefined,
hasPendingUpdates: Object.keys(this._deviceUpdate).length > 0,
});
}
if (changedProps.has("_loading")) {
@@ -287,11 +226,6 @@ class StepFlowCreateEntry extends LitElement {
}
}
private _entitiesWithoutDevice = memoizeOne(
(entities?: ExtEntityRegistryEntry[]): ExtEntityRegistryEntry[] =>
(entities ?? []).filter((entity) => !entity.device_id)
);
private async _loadDomains() {
const entries = await getConfigEntries(this.hass);
this._domains = Object.fromEntries(
@@ -356,97 +290,18 @@ class StepFlowCreateEntry extends LitElement {
await Promise.allSettled(entityUpdates);
}
// Helpers link their entities to an existing device instead of creating one
let helperEntities =
this._helperEntitiesLookup && !this.devices.length
? await this._helperEntitiesLookup
: [];
if (this._helperArea) {
helperEntities = await this._saveHelperArea(
helperEntities,
this._helperArea
);
}
const entry = this.navigateToResult ? this.step.result : undefined;
const helperResult = entry
? computeHelperResult(helperEntities)
: undefined;
// The dialog was closed while waiting
if (!this.isConnected) {
return;
}
fireEvent(this, "flow-update", { step: undefined });
if (!entry) {
return;
}
const deviceId =
this.devices.length === 1 ? this.devices[0].id : helperResult?.deviceId;
if (deviceId) {
navigate(`/config/devices/device/${deviceId}`);
} else if (helperResult?.entityId) {
showMoreInfoDialog(this, { entityId: helperResult.entityId });
} else {
navigate(
`/config/integrations/integration/${entry.domain}#config_entry=${entry.entry_id}`
);
}
}
private async _findHelperEntities(
entryId: string
): Promise<ExtEntityRegistryEntry[]> {
try {
const { entity: entityIds = [] } = await findRelated(
this.hass,
"config_entry",
entryId
);
if (!entityIds.length) {
return [];
if (this.step.result && this.navigateToResult) {
if (this.devices.length === 1) {
navigate(`/config/devices/device/${this.devices[0].id}`);
} else {
navigate(
`/config/integrations/integration/${this.step.result.domain}#config_entry=${this.step.result.entry_id}`
);
}
return Object.values(
await getExtendedEntityRegistryEntries(this.hass, entityIds)
).filter(
(entity): entity is ExtEntityRegistryEntry =>
entity?.config_entry_id === entryId
);
} catch (_err) {
return [];
}
}
private async _saveHelperArea(
entities: ExtEntityRegistryEntry[],
areaId: string
): Promise<ExtEntityRegistryEntry[]> {
try {
const newEntityIds = await updateEntitiesArea(
this.hass,
this._entitiesWithoutDevice(entities).map((entity) => entity.entity_id),
areaId
);
return entities.map((entity) =>
entity.entity_id in newEntityIds
? { ...entity, entity_id: newEntityIds[entity.entity_id] }
: entity
);
} catch (err: unknown) {
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.config.integrations.config_flow.error_saving_entity",
{ error: (err as { message?: string })?.message ?? "Unknown error" }
),
});
return entities;
}
}
private _helperAreaPicked(ev: ValueChangedEvent<string | undefined>): void {
this._helperArea = ev.detail.value || undefined;
}
public async finish(): Promise<void> {
if (this._loading) {
return;
@@ -690,7 +690,7 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
* is always in the scan, so a missing or absent port means it is offline.
* Without the usb integration nothing is scanned, so a missing port says nothing.
*/
private _serialPortUsage(url: string | null) {
private _serialPortUsage(url: string) {
if (!this._serialPorts) {
return undefined;
}
@@ -375,16 +375,21 @@ export class HaVoiceAssistantSetupStepSuccess extends LitElement {
margin-top: 24px;
}
.rows {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--ha-space-4) var(--ha-space-1);
align-items: center;
gap: var(--ha-space-4);
display: flex;
flex-direction: column;
}
.row {
display: contents;
display: flex;
justify-content: space-between;
align-items: center;
}
.row > :only-child {
grid-column: 1 / -1;
.row > *:first-child {
flex: 1;
margin-right: 4px;
}
.row ha-button {
width: 82px;
}
ha-select {
-6
View File
@@ -2,7 +2,6 @@ import type {
HassEntity,
HassEntityAttributeBase,
} from "home-assistant-js-websocket";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
/** Base attributes type that all entities share */
export type EntityAttributes = HassEntityAttributeBase & Record<string, any>;
@@ -18,11 +17,6 @@ export type EntityInput = Pick<
device_id?: string;
/** Integration that provides the entity, defaults to "demo" */
platform?: string;
/**
* Options of the entity in the mocked entity registry, such as timer
* presets or favorite positions
*/
options?: EntityRegistryEntry["options"];
/** When the state last changed, a random recent time by default */
last_changed?: string;
};
-2
View File
@@ -104,7 +104,6 @@ export class HassRouterPage extends ReactiveElement {
const queryParams = window.location.search;
navigate(`${route.prefix}/${defaultPage}${queryParams}`, {
replace: true,
keepDialogs: true,
});
}
@@ -135,7 +134,6 @@ export class HassRouterPage extends ReactiveElement {
if (route) {
navigate(`${route.prefix}/${result}${location.search}`, {
replace: true,
keepDialogs: true,
});
}
}
+1 -1
View File
@@ -189,7 +189,7 @@ class PartialPanelResolver extends HassRouterPage {
const { pathname, search, hash } = mainWindow.location;
navigate(
`${pathname.replace(/^\/hacs/, "/marketplace")}${search}${hash}`,
{ replace: true, keepDialogs: true }
{ replace: true }
);
return undefined;
}
@@ -1742,7 +1742,7 @@ ${rejected
}
}
// Alexa and Google Home can't be exposed to from the UI when they
// Alexa and Google Assistant can't be exposed to from the UI when they
// are configured manually in YAML, same as on the voice assistants expose page
private _exposableAssistants = memoize(
(cloudStatus: CloudStatus | undefined, hass: HomeAssistant) =>
+12 -110
View File
@@ -13,7 +13,6 @@ import type {
import { fireEvent } from "../../../common/dom/fire_event";
import { stopPropagation } from "../../../common/dom/stop_propagation";
import { stringCompare } from "../../../common/string/compare";
import "../../../components/ha-area-picker";
import "../../../components/ha-button";
import "../../../components/ha-dialog";
import "../../../components/ha-dialog-footer";
@@ -29,12 +28,6 @@ import type { HaListSelectable } from "../../../components/list/ha-list-selectab
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { getConfigFlowHandlers } from "../../../data/config_flow";
import { createCounter } from "../../../data/counter";
import {
fetchEntityRegistry,
getExtendedEntityRegistryEntry,
updateEntitiesArea,
type EntityRegistryEntry,
} from "../../../data/entity/entity_registry";
import { createInputBoolean } from "../../../data/input_boolean";
import { createInputButton } from "../../../data/input_button";
import { createInputDateTime } from "../../../data/input_datetime";
@@ -50,9 +43,8 @@ import { createSchedule } from "../../../data/schedule";
import { createTimer } from "../../../data/timer";
import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
import { haStyleDialog, haStyleScrollbar } from "../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import type { HomeAssistant } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
import type { Helper, HelperDomain, HelperFormItem } from "./const";
import { isHelperDomain } from "./const";
@@ -118,14 +110,12 @@ const HELPERS: HelperCreators = {
@customElement("dialog-helper-detail")
export class DialogHelperDetail extends DirtyStateProviderMixin<
{ item?: HelperFormItem; area?: string } | undefined
HelperFormItem | undefined
>()(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _item?: HelperFormItem;
@state() private _area?: string;
@state() private _open = false;
@state() private _domain?: string;
@@ -147,7 +137,6 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
startFlowHandler: string;
manifest: IntegrationManifest;
dialogClosedCallback?: ShowDialogHelperDetailParams["dialogClosedCallback"];
navigateToResult?: boolean;
};
private _params?: ShowDialogHelperDetailParams;
@@ -156,15 +145,11 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
this._params = params;
this._domain = params.domain;
this._item = params.item;
this._area = undefined;
if (this._domain && this._domain in HELPERS) {
this._loading = true;
try {
await HELPERS[this._domain].import();
this._initDirtyTracking(
{ type: "deep" },
{ item: this._item, area: undefined }
);
this._initDirtyTracking({ type: "deep" }, undefined);
} finally {
this._loading = false;
}
@@ -200,7 +185,6 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
startFlowHandler: pendingConfigFlow.startFlowHandler,
manifest: pendingConfigFlow.manifest,
dialogClosedCallback: pendingConfigFlow.dialogClosedCallback,
navigateToResult: pendingConfigFlow.navigateToResult,
});
}
}
@@ -222,11 +206,6 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
new: true,
autofocus: true,
})}
<ha-area-picker
.value=${this._area}
.disabled=${this._submitting}
@value-changed=${this._areaChanged}
></ha-area-picker>
</div>
`;
footer = html`
@@ -401,45 +380,28 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
private _valueChanged(ev: CustomEvent): void {
this._item = ev.detail.value;
this._updateDirtyState({ item: this._item, area: this._area });
this._updateDirtyState(this._item);
}
private async _createItem(): Promise<void> {
if (!this._domain || !this._item) {
return;
}
const domain = this._domain;
const params = this._params;
const area = this._area;
this._submitting = true;
this._error = "";
try {
const createdEntity = await HELPERS[domain].create(this.hass, this._item);
// The dialog was closed or reopened while waiting
if (this._params !== params) {
return;
}
let entityId: string | undefined;
if (createdEntity.id && (area || params?.navigateToResult)) {
entityId = await this._findEntityId(domain, createdEntity.id);
if (area) {
entityId = await this._saveArea(entityId, area);
}
if (this._params !== params) {
return;
}
}
if (params?.dialogClosedCallback && createdEntity.id) {
params.dialogClosedCallback({
const createdEntity = await HELPERS[this._domain].create(
this.hass,
this._item
);
if (this._params?.dialogClosedCallback && createdEntity.id) {
this._params.dialogClosedCallback({
flowFinished: true,
entityId: entityId ?? `${domain}.${createdEntity.id}`,
entityId: `${this._domain}.${createdEntity.id}`,
});
}
this._markDirtyStateClean();
this.closeDialog();
if (params?.navigateToResult && entityId) {
showMoreInfoDialog(this, { entityId });
}
} catch (err: any) {
this._error = err.message || "Unknown error";
} finally {
@@ -447,57 +409,6 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
}
}
private async _findEntityId(
domain: string,
uniqueId: string
): Promise<string | undefined> {
const isCreated = (entry: EntityRegistryEntry) =>
entry.platform === domain && entry.unique_id === uniqueId;
try {
// The entity ID usually matches the helper ID, unless it was taken
const entry = await getExtendedEntityRegistryEntry(
this.hass,
`${domain}.${uniqueId}`
).catch(() => undefined);
if (entry && isCreated(entry)) {
return entry.entity_id;
}
return (await fetchEntityRegistry(this.hass.connection)).find(isCreated)
?.entity_id;
} catch (_err) {
return undefined;
}
}
private async _saveArea(
entityId: string | undefined,
areaId: string
): Promise<string | undefined> {
let error = "Entity not found";
if (entityId) {
try {
return (await updateEntitiesArea(this.hass, [entityId], areaId))[
entityId
];
} catch (err: unknown) {
error = (err as { message?: string })?.message ?? "Unknown error";
}
}
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.config.integrations.config_flow.error_saving_entity",
{ error }
),
});
return entityId;
}
private _areaChanged(ev: ValueChangedEvent<string | undefined>): void {
ev.stopPropagation();
this._area = ev.detail.value || undefined;
this._updateDirtyState({ item: this._item, area: this._area });
}
private async _domainPicked(
ev: HASSDomEvent<number> & HASSDomCurrentTargetEvent<HaListSelectable>
): Promise<void> {
@@ -524,10 +435,7 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
try {
await HELPERS[domain].import();
this._domain = domain;
this._initDirtyTracking(
{ type: "deep" },
{ item: this._item, area: undefined }
);
this._initDirtyTracking({ type: "deep" }, undefined);
} finally {
this._loading = false;
}
@@ -536,7 +444,6 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
startFlowHandler: domain,
manifest: await fetchIntegrationManifest(this.hass, domain),
dialogClosedCallback: this._params?.dialogClosedCallback,
navigateToResult: this._params?.navigateToResult,
};
this.closeDialog();
}
@@ -545,7 +452,6 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
private async _goBack() {
this._domain = undefined;
this._item = undefined;
this._area = undefined;
this._error = undefined;
await this.updateComplete;
await this._focusSearchInput();
@@ -584,10 +490,6 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
.form {
padding: var(--ha-space-6);
}
.form ha-area-picker {
display: block;
margin-top: var(--ha-space-4);
}
ha-input-search {
margin: 0 var(--ha-space-4) var(--ha-space-3);
}
@@ -1300,7 +1300,6 @@ ${rejected
if (isHelperDomain(domain)) {
showHelperDetailDialog(this, {
domain,
navigateToResult: true,
});
return;
}
@@ -1344,7 +1343,6 @@ ${rejected
showConfigFlowDialog(this, {
startFlowHandler: domain,
manifest: await fetchIntegrationManifest(this.hass, domain),
navigateToResult: true,
});
}
@@ -1570,12 +1568,11 @@ ${rejected
{ name: config.name }
),
},
navigateToResult: true,
});
}
private _createHelper() {
showHelperDetailDialog(this, { navigateToResult: true });
showHelperDetailDialog(this, {});
}
private _bulkCreateCategory = () => {
@@ -11,7 +11,6 @@ export interface ShowDialogHelperDetailParams {
entryId?: string;
entityId?: string;
}) => void;
navigateToResult?: boolean;
}
export const showHelperDetailDialog = (
File diff suppressed because it is too large. Load diff
@@ -1,670 +0,0 @@
import { formatISO9075 } from "date-fns";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { formatDateTime } from "../../../../common/datetime/format_date_time";
import { fireEvent } from "../../../../common/dom/fire_event";
import { formatNumberWithUnit } from "../../../../common/number/format_number";
import "../../../../components/ha-spinner";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-dialog";
import "../../../../components/ha-button";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-selector/ha-selector-datetime";
import "../../../../components/ha-selector/ha-selector-number";
import "../../../../components/item/ha-list-item-button";
import "../../../../components/list/ha-list-base";
import type { StatisticValue } from "../../../../data/recorder";
import {
adjustStatisticsSum,
fetchStatistics,
getDisplayUnit,
} from "../../../../data/recorder";
import type {
DateTimeSelector,
NumberSelector,
} from "../../../../data/selector";
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showToast } from "../../../../util/toast";
import type { DialogStatisticsAdjustSumParams } from "./show-dialog-statistics-adjust-sum";
interface CombinedStat {
hour: StatisticValue | null;
fiveMin: StatisticValue[];
}
interface FetchedStats {
hour: StatisticValue[];
fiveMin: StatisticValue[];
}
interface AdjustState {
amount: number | undefined;
}
@customElement("dialog-statistics-adjust-sum")
export class DialogStatisticsFixUnsupportedUnitMetadata extends DirtyStateProviderMixin<AdjustState>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: DialogStatisticsAdjustSumParams;
@state() private _open = false;
@state() private _busy = false;
@state() private _moment?: string;
@state() private _stats5min?: StatisticValue[];
@state() private _statsHour?: StatisticValue[];
@state() private _chosenStat?: StatisticValue;
@state() private _amount?: number;
private _showingOutliers = false;
// Adjustments made while the dialog is open, keyed by period. The recorder
// processes adjustments asynchronously, so a fetch right after adjusting can
// still return the old values.
private _adjustedChanges = new Map<string, number>();
// Incremented for every fetch, so results of outdated fetches are ignored
private _fetchId = 0;
private _dateTimeSelector: DateTimeSelector = {
datetime: {},
};
private _precision = 2;
private _amountSelector = memoizeOne(
(
unit_of_measurement: string | undefined,
precision: number
): NumberSelector => ({
number: {
step: 10 ** -precision,
unit_of_measurement,
mode: "box",
},
})
);
public showDialog(params: DialogStatisticsAdjustSumParams): void {
this._params = params;
this._open = true;
// moment is in format YYYY-MM-DD HH:mm:ss because of selector
// Here we create a date with minutes set to %5
const now = new Date();
now.setMinutes(now.getMinutes() - (now.getMinutes() % 5), 0);
this._moment = formatISO9075(now);
this._showingOutliers = false;
this._adjustedChanges.clear();
this._refreshStats();
const entry = this.hass.entities[params.statistic.statistic_id];
this._precision = Math.max(entry?.display_precision ?? 0, 2);
}
public closeDialog(): void {
this._open = false;
}
private _dialogClosed(): void {
this._open = false;
this._params = undefined;
this._moment = undefined;
this._stats5min = undefined;
this._statsHour = undefined;
this._amount = undefined;
this._chosenStat = undefined;
this._initDirtyTracking({ type: "deep" }, { amount: undefined });
this._busy = false;
this._showingOutliers = false;
this._adjustedChanges.clear();
this._fetchId++;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params) {
return nothing;
}
let content: TemplateResult;
let footer: TemplateResult;
if (!this._chosenStat) {
content = this._renderPickStatistic();
footer = html`
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this._showOutliers}
>
${this.hass.localize(
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.outliers"
)}
</ha-button>
<ha-button slot="primaryAction" data-dialog="close">
${this.hass.localize("ui.common.close")}
</ha-button>
`;
} else {
content = this._renderAdjustStat();
footer = html`
<ha-button
slot="secondaryAction"
.disabled=${this._busy}
@click=${this._clearChosenStatistic}
appearance="plain"
>
${this.hass.localize("ui.common.back")}</ha-button
>
<ha-button
slot="primaryAction"
.disabled=${this._busy || !this.isDirtyState}
@click=${this._fixIssue}
>
${this.hass.localize(
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.adjust"
)}</ha-button
>
`;
}
return html`
<ha-dialog
.open=${this._open}
header-title=${this.hass.localize(
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.title"
)}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
${content}
<ha-dialog-footer slot="footer">${footer}</ha-dialog-footer>
</ha-dialog>
`;
}
protected shouldUpdate(changedProps: PropertyValues<this>): boolean {
if (changedProps.size !== 1 || !changedProps.has("hass")) {
return true;
}
// We only respond to hass changes if the translations or locale changed
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
return (
!oldHass ||
oldHass.localize !== this.hass.localize ||
oldHass.locale !== this.hass.locale
);
}
private _renderPickStatistic() {
let stats: TemplateResult;
if (!this._stats5min || !this._statsHour) {
stats = html`<ha-spinner></ha-spinner>`;
} else if (this._statsHour.length < 1 && this._stats5min.length < 1) {
stats = html`<p>
${this.hass.localize(
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.no_statistics_found"
)}
</p>`;
} else {
const data =
this._stats5min.length >= 1 ? this._stats5min : this._statsHour;
const unit = getDisplayUnit(
this.hass.states,
this._params!.statistic.statistic_id,
this._params!.statistic
);
const rows: TemplateResult[] = [];
for (const stat of data) {
const digits = this._fractionDigits(stat.change);
const change = formatNumberWithUnit(
stat.change!,
unit,
this.hass.locale,
{
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}
);
rows.push(html`
<ha-list-item-button
.headline=${change}
.supportingText=${formatDateTime(
new Date(stat.start),
this.hass.locale,
this.hass.config
)}
.stat=${stat}
@click=${this._setChosenStatistic}
>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
`);
}
stats = html`<ha-list-base
aria-label=${this.hass.localize(
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.statistic_values"
)}
>${rows}</ha-list-base
>`;
}
return html`
<div class="text-content">
${this.hass.localize(
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.info_text_1"
)}
</div>
<div class="text-content">
<b
>${this.hass.localize(
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.statistic"
)}</b
>
${this._params!.statistic.statistic_id}
</div>
<ha-selector-datetime
.label=${this.hass.localize(
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.pick_a_time"
)}
.selector=${this._dateTimeSelector}
.value=${this._moment}
@value-changed=${this._dateTimeSelectorChanged}
></ha-selector-datetime>
<div class="stat-list">${stats}</div>
`;
}
private _clearChosenStatistic() {
this._chosenStat = undefined;
this._initDirtyTracking({ type: "deep" }, { amount: undefined });
}
// Small changes get more digits, so they do not round to zero.
private _fractionDigits(value: number | null | undefined): number {
if (!value || Math.abs(value) >= 10 ** -this._precision) {
return this._precision;
}
return Math.min(Math.ceil(-Math.log10(Math.abs(value))) + 1, 20);
}
private _setChosenStatistic(ev) {
const stat = ev.currentTarget.stat;
const multiple = 10 ** this._fractionDigits(stat.change);
const growth = Math.round(stat.change! * multiple) / multiple;
this._chosenStat = stat;
this._amount = growth;
this._initDirtyTracking({ type: "deep" }, { amount: growth });
}
private _dateTimeSelectorChanged(ev) {
this._moment = ev.detail.value;
this._showingOutliers = false;
this._refreshStats();
}
private _showOutliers() {
this._showingOutliers = true;
this._refreshStats();
}
private async _refreshStats(): Promise<void> {
const fetchId = ++this._fetchId;
this._stats5min = undefined;
this._statsHour = undefined;
try {
const stats = this._showingOutliers
? await this._fetchOutliers()
: await this._fetchStats();
if (fetchId !== this._fetchId) {
return;
}
this._statsHour = stats.hour;
this._stats5min = stats.fiveMin;
} catch (err: any) {
if (fetchId !== this._fetchId) {
return;
}
this._stats5min = [];
this._statsHour = [];
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.error_loading_statistics",
{ message: err.message || err }
),
});
}
}
private _applyAdjustedChanges(stats: StatisticValue[]): StatisticValue[] {
if (!this._adjustedChanges.size) {
return stats;
}
return stats.map((stat) => {
const change = this._adjustedChanges.get(`${stat.start}-${stat.end}`);
return change === undefined ? stat : { ...stat, change };
});
}
private _renderAdjustStat() {
const unit = getDisplayUnit(
this.hass.states,
this._params!.statistic.statistic_id,
this._params!.statistic
);
return html`
<div class="text-content">
<b
>${this.hass.localize(
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.statistic"
)}</b
>
${this._params!.statistic.statistic_id}
</div>
<div class="table-row">
<span
>${this.hass.localize(
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.start"
)}</span
>
<span
>${formatDateTime(
new Date(this._chosenStat!.start),
this.hass.locale,
this.hass.config
)}</span
>
</div>
<div class="table-row">
<span
>${this.hass.localize(
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.end"
)}</span
>
<span
>${formatDateTime(
new Date(this._chosenStat!.end),
this.hass.locale,
this.hass.config
)}</span
>
</div>
<ha-selector-number
.label=${this.hass.localize(
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.new_value"
)}
.selector=${this._amountSelector(
unit || undefined,
this._fractionDigits(this._chosenStat!.change)
)}
.value=${this._amount}
.disabled=${this._busy}
@value-changed=${this._amountChanged}
></ha-selector-number>
`;
}
private _amountChanged(ev) {
this._amount = ev.detail.value;
this._updateDirtyState({ amount: this._amount });
}
private async _fetchStats(): Promise<FetchedStats> {
const statId = this._params!.statistic.statistic_id;
// moment is in format YYYY-MM-DD HH:mm:ss because of selector
// Here we convert it to an ISO string.
const moment = new Date(this._moment!.replace(" ", "T"));
// Search 2 hours before and 3 hours after chosen time
const hourStatStart = new Date(moment.getTime());
hourStatStart.setTime(hourStatStart.getTime() - 2 * 3600 * 1000);
const hourStatEnd = new Date(moment.getTime());
hourStatEnd.setTime(hourStatEnd.getTime() + 3 * 3600 * 1000);
const statsHourData = await fetchStatistics(
this.hass.callWS,
hourStatStart,
hourStatEnd,
[statId],
"hour"
);
const statsHour =
statId in statsHourData
? this._applyAdjustedChanges(statsHourData[statId].slice(0, 5))
: [];
// Can't have 5 min data if no hourly data
if (statsHour.length === 0) {
return { hour: [], fiveMin: [] };
}
// Search 10 minutes before and 15 minutes after chosen time
const minStatStart = new Date(moment.getTime());
minStatStart.setTime(minStatStart.getTime() - 10 * 60 * 1000);
const minStatEnd = new Date(moment.getTime());
minStatEnd.setTime(minStatEnd.getTime() + 15 * 60 * 1000);
const stats5MinData = await fetchStatistics(
this.hass.callWS,
minStatStart,
minStatEnd,
[statId],
"5minute"
);
return {
hour: statsHour,
fiveMin:
statId in stats5MinData
? this._applyAdjustedChanges(stats5MinData[statId].slice(0, 5))
: [],
};
}
private async _fetchOutliers(): Promise<FetchedStats> {
const statId = this._params!.statistic.statistic_id;
// Get all the data
const start = new Date(0);
const end = new Date();
const statsHourData = await fetchStatistics(
this.hass.callWS,
start,
end,
[statId],
"hour"
);
const statsHour =
statId in statsHourData
? this._applyAdjustedChanges(statsHourData[statId])
: [];
if (statsHour.length === 0) {
return { hour: [], fiveMin: [] };
}
const stats5MinData = await fetchStatistics(
this.hass.callWS,
start,
end,
[statId],
"5minute"
);
const stats5Min =
statId in stats5MinData
? this._applyAdjustedChanges(stats5MinData[statId])
: [];
// First datapoint of 5 minute data in the history is always junk since it counts the entire sum
// as the change, which we don't want here.
stats5Min.shift();
const combinedStatsData: CombinedStat[] = [];
statsHour.forEach((s) => {
combinedStatsData.push({ hour: s, fiveMin: [] });
});
const lasthour: CombinedStat = { hour: null, fiveMin: [] };
let i = 0;
stats5Min.forEach((s) => {
let matched = false;
for (i; i < combinedStatsData.length; i++) {
const hour = combinedStatsData[i].hour;
if (hour && s.start >= hour.start && s.end <= hour.end) {
combinedStatsData[i].fiveMin.push(s);
matched = true;
break;
}
}
if (!matched) {
lasthour.fiveMin.push(s);
}
});
combinedStatsData.push(lasthour);
let statsOutliers: StatisticValue[] = [];
let min = 0;
const numOutliers = 10;
// Track the top 10 values.
const addOutlier = (s) => {
const val = Math.abs(s.change ?? 0);
if (statsOutliers.length < numOutliers || val > min) {
statsOutliers.push(s);
statsOutliers = statsOutliers.sort(
(a, b) => Math.abs(b.change ?? 0) - Math.abs(a.change ?? 0)
);
statsOutliers = statsOutliers.slice(0, numOutliers);
min = statsOutliers[statsOutliers.length - 1].change ?? 0;
}
};
// If an hour has incomplete five minute data, add the hour value
// Otherwise, add the 5 minute values and ignore the hour value
combinedStatsData.forEach((c) => {
if (c.fiveMin.length !== 12 && c.hour) {
addOutlier(c.hour);
} else {
c.fiveMin.forEach((s) => {
addOutlier(s);
});
}
});
// Outliers are a possible mix of hour/5minute data, but the distinction
// is not relevant here, as long as only one array is populated.
return { hour: statsOutliers, fiveMin: [] };
}
private async _fixIssue(): Promise<void> {
const params = this._params!;
const chosenStat = this._chosenStat!;
const amount = this._amount!;
const unit = getDisplayUnit(
this.hass.states,
params.statistic.statistic_id,
params.statistic
);
this._busy = true;
try {
await adjustStatisticsSum(
this.hass,
params.statistic.statistic_id,
chosenStat.start,
// The shown value is rounded, so compare with the stored value.
amount - chosenStat.change!,
unit || null
);
} catch (err: any) {
// Report the error even if the dialog was closed or reopened while
// adjusting, but leave the state of a newer invocation alone
if (this._params === params) {
this._busy = false;
}
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.error_sum_adjusted",
{ message: err.message || err }
),
});
return;
}
showToast(this, {
message: this.hass.localize(
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.sum_adjusted"
),
});
// The dialog was closed or reopened while adjusting
if (this._params !== params) {
return;
}
this._adjustedChanges.set(`${chosenStat.start}-${chosenStat.end}`, amount);
this._busy = false;
this._clearChosenStatistic();
this._refreshStats();
}
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleDialog,
css`
.text-content,
ha-selector-datetime,
ha-selector-number {
margin-bottom: var(--ha-space-5);
}
ha-list-base {
margin: 0 -24px;
}
ha-list-item-button {
--ha-row-item-padding-inline: var(--ha-space-6);
}
ha-list-item-button ha-icon-next {
color: var(--secondary-text-color);
}
.table-row {
display: flex;
justify-content: space-between;
margin-bottom: var(--ha-space-5);
}
.stat-list {
margin-top: var(--ha-space-2);
min-height: 360px;
display: flex;
flex-direction: column;
}
.stat-list ha-spinner {
margin: 0 auto;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-statistics-adjust-sum": DialogStatisticsFixUnsupportedUnitMetadata;
}
}
@@ -1,277 +0,0 @@
import type { ContextType } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { consume } from "../../../../common/decorators/consume";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import "../../../../components/chart/statistics-chart";
import "../../../../components/date-picker/ha-date-range-picker";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-spinner";
import {
apiContext,
internationalizationContext,
} from "../../../../data/context";
import type { Statistics, StatisticType } from "../../../../data/recorder";
import {
clearStatistics,
fetchStatistics,
StatisticMeanType,
} from "../../../../data/recorder";
import { DialogMixin } from "../../../../dialogs/dialog-mixin";
import { haStyleDialog } from "../../../../resources/styles";
import { showToast } from "../../../../util/toast";
import type { DialogStatisticsClearRangeParams } from "./show-dialog-statistics-clear-range";
// Longer ranges use hourly statistics to keep the preview fast.
const MAX_5MINUTE_PREVIEW_MS = 3 * 24 * 60 * 60 * 1000;
type PreviewPeriod = "5minute" | "hour";
// The recorder also clears the period in which the range starts, so the
// preview starts at the beginning of that period.
const periodStart = (date: Date, period: PreviewPeriod) => {
const periodMs = (period === "hour" ? 60 : 5) * 60 * 1000;
return new Date(Math.floor(date.getTime() / periodMs) * periodMs);
};
@customElement("dialog-statistics-clear-range")
class DialogStatisticsClearRange extends DialogMixin<DialogStatisticsClearRangeParams>(
LitElement
) {
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state() private _start = new Date();
@state() private _end = new Date();
@state() private _statistics?: Statistics;
@state() private _previewStart?: Date;
@state() private _busy = false;
@state() private _error?: string;
// Incremented for every fetch, so results of outdated fetches are ignored
private _fetchId = 0;
connectedCallback() {
super.connectedCallback();
this._end.setMinutes(
this._end.getMinutes() - (this._end.getMinutes() % 5),
0,
0
);
this._start = new Date(this._end.getTime() - 24 * 60 * 60 * 1000);
this._fetchStatistics();
}
private get _validRange() {
return this._start.getTime() < this._end.getTime();
}
private get _statTypes(): StatisticType[] {
return this.params!.statistic.mean_type !== StatisticMeanType.NONE
? ["mean", "min", "max"]
: ["change"];
}
protected render() {
if (!this.params) {
return nothing;
}
const localize = this._i18n.localize;
const statisticId = this.params.statistic.statistic_id;
const hasData = Boolean(this._statistics?.[statisticId]?.length);
return html`
<ha-dialog
open
width="large"
.headerTitle=${localize(
"ui.panel.config.tools.tabs.statistics.clear_range.title"
)}
.preventScrimClose=${this._busy}
>
<p>
${localize(
"ui.panel.config.tools.tabs.statistics.clear_range.info_text",
{ name: html`<b>${this.params.name}</b>` }
)}
</p>
<ha-date-range-picker
time-picker
.startDate=${this._start}
.endDate=${this._end}
.disabled=${this._busy}
@value-changed=${this._rangeChanged}
></ha-date-range-picker>
${
this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing
}
${
!this._validRange
? html`<ha-alert alert-type="error">
${localize(
"ui.panel.config.tools.tabs.statistics.clear_range.invalid_range"
)}
</ha-alert>`
: !this._statistics
? html`<div class="preview center">
<ha-spinner></ha-spinner>
</div>`
: !hasData
? html`<div class="preview center">
${localize(
"ui.panel.config.tools.tabs.statistics.clear_range.no_statistics_found"
)}
</div>`
: html`<div class="preview">
<statistics-chart
.statisticsData=${this._statistics}
.metadata=${{ [statisticId]: this.params.statistic }}
.names=${{ [statisticId]: this.params.name }}
.statTypes=${this._statTypes}
.chartType=${
this._statTypes[0] === "change" ? "bar" : "line"
}
.startTime=${this._previewStart}
.endTime=${this._end}
hide-legend
.clickForMoreInfo=${false}
height="240px"
></statistics-chart>
</div>`
}
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
.disabled=${this._busy}
@click=${this.closeDialog}
>
${localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
variant="danger"
.loading=${this._busy}
.disabled=${this._busy || !this._validRange || !hasData}
@click=${this._delete}
>
${localize("ui.common.delete")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
`;
}
private _rangeChanged(
ev: HASSDomEvent<{ value: { startDate: Date; endDate: Date } }>
) {
this._start = ev.detail.value.startDate;
this._end = ev.detail.value.endDate;
this._fetchStatistics();
}
private async _fetchStatistics() {
const fetchId = ++this._fetchId;
this._statistics = undefined;
this._error = undefined;
if (!this._validRange) {
return;
}
const statisticId = this.params!.statistic.statistic_id;
const fetchPreview = async (period: PreviewPeriod) => {
const start = periodStart(this._start, period);
const statistics = await fetchStatistics(
this._api.callWS,
start,
this._end,
[statisticId],
period,
undefined,
this._statTypes.includes("change") ? ["change"] : ["mean", "min", "max"]
);
return { start, statistics };
};
try {
const shortRange =
this._end.getTime() - this._start.getTime() <= MAX_5MINUTE_PREVIEW_MS;
let preview = await fetchPreview(shortRange ? "5minute" : "hour");
// Short term statistics are purged before the hourly statistics.
if (shortRange && !preview.statistics[statisticId]?.length) {
preview = await fetchPreview("hour");
}
if (fetchId === this._fetchId) {
this._previewStart = preview.start;
this._statistics = preview.statistics;
}
} catch (err: any) {
if (fetchId === this._fetchId) {
this._statistics = {};
this._error = err.message || err;
}
}
}
private async _delete() {
this._busy = true;
this._error = undefined;
try {
await clearStatistics(this._api, [this.params!.statistic.statistic_id], {
start: this._start,
end: this._end,
});
} catch (err: any) {
this._busy = false;
this._error = err.message || err;
return;
}
showToast(this, {
message: this._i18n.localize(
"ui.panel.config.tools.tabs.statistics.clear_range.deleted"
),
});
this.closeDialog();
}
static styles = [
haStyleDialog,
css`
p {
margin-top: 0;
}
ha-date-range-picker {
display: block;
margin-bottom: var(--ha-space-4);
}
.preview {
min-height: 240px;
}
.center {
display: flex;
align-items: center;
justify-content: center;
color: var(--secondary-text-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"dialog-statistics-clear-range": DialogStatisticsClearRange;
}
}
@@ -2,6 +2,30 @@ import type { StatisticsValidationResult } from "../../../../data/recorder";
import { showFixStatisticsDialog } from "./show-dialog-statistics-fix";
import { showFixStatisticsUnitsChangedDialog } from "./show-dialog-statistics-fix-units-changed";
const FIX_ISSUES_ORDER: Record<StatisticsValidationResult["type"], number> = {
no_state: 0,
entity_no_longer_recorded: 1,
entity_not_recorded: 1,
state_class_removed: 2,
units_changed: 3,
mean_type_changed: 4,
};
export const FIXABLE_ISSUES: StatisticsValidationResult["type"][] = [
"no_state",
"entity_no_longer_recorded",
"state_class_removed",
"units_changed",
"mean_type_changed",
];
export const sortStatisticsIssues = (issues: StatisticsValidationResult[]) =>
[...issues].sort(
(itemA, itemB) =>
(FIX_ISSUES_ORDER[itemA.type] ?? 99) -
(FIX_ISSUES_ORDER[itemB.type] ?? 99)
);
export const fixStatisticsIssue = async (
element: HTMLElement,
issue: StatisticsValidationResult
@@ -0,0 +1,27 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type {
StatisticsMetaData,
StatisticsValidationResult,
} from "../../../../data/recorder";
export const loadStatisticDialog = () => import("./dialog-statistic");
export interface DialogStatisticParams {
statistic: StatisticsMetaData;
name: string;
area?: string;
device?: string;
issues?: StatisticsValidationResult[];
issueFixedCallback?: () => void;
}
export const showStatisticDialog = (
element: HTMLElement,
dialogParams: DialogStatisticParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-statistic",
dialogImport: loadStatisticDialog,
dialogParams,
});
};
@@ -1,20 +0,0 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { StatisticsMetaData } from "../../../../data/recorder";
export const loadAdjustSumDialog = () =>
import("./dialog-statistics-adjust-sum");
export interface DialogStatisticsAdjustSumParams {
statistic: StatisticsMetaData;
}
export const showStatisticsAdjustSumDialog = (
element: HTMLElement,
detailParams: DialogStatisticsAdjustSumParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-statistics-adjust-sum",
dialogImport: loadAdjustSumDialog,
dialogParams: detailParams,
});
};
@@ -1,21 +0,0 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { StatisticsMetaData } from "../../../../data/recorder";
export const loadClearRangeDialog = () =>
import("./dialog-statistics-clear-range");
export interface DialogStatisticsClearRangeParams {
statistic: StatisticsMetaData;
name: string;
}
export const showStatisticsClearRangeDialog = (
element: HTMLElement,
dialogParams: DialogStatisticsClearRangeParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-statistics-clear-range",
dialogImport: loadClearRangeDialog,
dialogParams,
});
};
@@ -2,10 +2,8 @@ import {
mdiArrowDown,
mdiArrowUp,
mdiClose,
mdiDeleteClock,
mdiFormatListChecks,
mdiMenuDown,
mdiSlopeUphill,
mdiTableCog,
mdiUnfoldLessHorizontal,
mdiUnfoldMoreHorizontal,
@@ -28,11 +26,9 @@ import { consume } from "../../../../common/decorators/consume";
import { QueryParamsController } from "../../../../common/controllers/query-params-controller";
import { transform } from "../../../../common/decorators/transform";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
HASSDomTargetEvent,
} from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeAreaName } from "../../../../common/entity/compute_area_name";
import {
computeDeviceName,
@@ -87,26 +83,7 @@ import type {
HomeAssistantRegistries,
} from "../../../../types";
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
import { fixStatisticsIssue } from "./fix-statistics";
import { showStatisticsAdjustSumDialog } from "./show-dialog-statistics-adjust-sum";
import { showStatisticsClearRangeDialog } from "./show-dialog-statistics-clear-range";
const FIX_ISSUES_ORDER: Record<StatisticsValidationResult["type"], number> = {
no_state: 0,
entity_no_longer_recorded: 1,
entity_not_recorded: 1,
state_class_removed: 2,
units_changed: 3,
mean_type_changed: 4,
};
const FIXABLE_ISSUES: StatisticsValidationResult["type"][] = [
"no_state",
"entity_no_longer_recorded",
"state_class_removed",
"units_changed",
"mean_type_changed",
];
import { showStatisticDialog } from "./show-dialog-statistic";
type StatisticData = StatisticsMetaData & {
issues?: StatisticsValidationResult[];
@@ -318,70 +295,6 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
localize("ui.panel.config.tools.tabs.statistics.no_issue")
}`,
},
fix: {
title: "",
label: localize("ui.panel.config.tools.tabs.statistics.fix_issue.fix"),
type: "icon",
template: (statistic) =>
html`${
statistic.issues
? html`<ha-button
@click=${this._fixIssue}
.data=${statistic.issues}
appearance="plain"
size="s"
>
${localize(
statistic.issues.some((issue) =>
FIXABLE_ISSUES.includes(issue.type)
)
? "ui.panel.config.tools.tabs.statistics.fix_issue.fix"
: "ui.panel.config.tools.tabs.statistics.fix_issue.info"
)}
</ha-button>`
: "—"
}`,
minWidth: "113px",
maxWidth: "113px",
showNarrow: true,
},
clear_range: {
title: "",
label: localize(
"ui.panel.config.tools.tabs.statistics.clear_range.title"
),
type: "icon-button",
showNarrow: true,
template: (statistic) => html`
<ha-icon-button
.label=${localize(
"ui.panel.config.tools.tabs.statistics.clear_range.title"
)}
.path=${mdiDeleteClock}
.statistic=${statistic}
@click=${this._showStatisticsClearRangeDialog}
></ha-icon-button>
`,
},
actions: {
title: "",
label: localize("ui.panel.config.tools.tabs.statistics.adjust_sum"),
type: "icon-button",
showNarrow: true,
template: (statistic) =>
statistic.has_sum
? html`
<ha-icon-button
.label=${localize(
"ui.panel.config.tools.tabs.statistics.adjust_sum"
)}
.path=${mdiSlopeUphill}
.statistic=${statistic}
@click=${this._showStatisticsAdjustSumDialog}
></ha-icon-button>
`
: nothing,
},
})
);
@@ -825,35 +738,28 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
this._dataTable.selectAll((statistic) => statistic.issues);
}
private _showStatisticsAdjustSumDialog(
ev: HASSDomCurrentTargetEvent<HTMLElement & { statistic: StatisticData }>
) {
ev.stopPropagation();
showStatisticsAdjustSumDialog(this, {
statistic: ev.currentTarget.statistic,
});
}
private _showStatisticsClearRangeDialog(
ev: HASSDomCurrentTargetEvent<
HTMLElement & { statistic: DisplayedStatisticData }
>
) {
ev.stopPropagation();
const statistic = ev.currentTarget.statistic;
showStatisticsClearRangeDialog(this, {
private _rowClicked(ev: HASSDomEvent<RowClickedEvent>) {
const statistic = this._displayData(
this._data,
this._i18n.localize,
this._registries.entities,
this._registries.devices,
this._registries.areas,
this._formatEntityName
).find((stat) => stat.statistic_id === ev.detail.id);
if (!statistic) {
return;
}
showStatisticDialog(this, {
statistic,
name: statistic.displayName,
area: statistic.area,
device: statistic.device,
issues: statistic.issues,
issueFixedCallback: () => this._validateStatistics(),
});
}
private _rowClicked(ev: HASSDomEvent<RowClickedEvent>) {
const id = ev.detail.id;
if (id in this._states) {
fireEvent(this, "hass-more-info", { entityId: id });
}
}
private async _validateStatistics() {
try {
const [statisticIds, issues] = await Promise.all([
@@ -918,21 +824,6 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
});
};
private _fixIssue = async (
ev: HASSDomCurrentTargetEvent<
HTMLElement & { data: StatisticsValidationResult[] }
>
) => {
const issues = ev.currentTarget.data.sort(
(itemA, itemB) =>
(FIX_ISSUES_ORDER[itemA.type] ?? 99) -
(FIX_ISSUES_ORDER[itemB.type] ?? 99)
);
const issue = issues[0];
await fixStatisticsIssue(this, issue);
this._validateStatistics();
};
protected supportedShortcuts(): SupportedShortcuts {
return {
f: () => this._searchInput.focus(),
+2 -8
View File
@@ -291,12 +291,6 @@ export class LovelacePanel extends LitElement {
this._fetchConfig(true);
}
private _redirectToHome() {
// Keeps the query and any open dialog: a more-info deep link may be
// restored from the URL around this redirect.
navigate(`/home${location.search}`, { replace: true, keepDialogs: true });
}
private async _fetchConfig(forceDiskRefresh: boolean) {
this._loading = true;
@@ -306,7 +300,7 @@ export class LovelacePanel extends LitElement {
// If no mode, redirect to /home as there is no "lovelace" dashboard
if (!confMode) {
this._redirectToHome();
navigate("/home", { replace: true });
return;
}
@@ -367,7 +361,7 @@ export class LovelacePanel extends LitElement {
const dashboards = await fetchDashboards(this.hass!);
const dashboard = dashboards.find((d) => d.url_path === "lovelace");
if (!dashboard) {
this._redirectToHome();
navigate("/home", { replace: true });
return;
}
}
+8 -13
View File
@@ -32,7 +32,6 @@ import { UndoRedoController } from "../../common/controllers/undo-redo-controlle
import { fireEvent } from "../../common/dom/fire_event";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { ctrlOrCmdLabel } from "../../common/keyboard/ctrl-or-cmd";
import type { NavigateOptions } from "../../common/navigate";
import { goBack, navigate, replaceCurrentUrl } from "../../common/navigate";
import type { LocalizeKeys } from "../../common/translations/localize";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
@@ -832,12 +831,7 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
if (!viewPath && views.length) {
newSelectView = views.findIndex(this._isVisible);
// Filling in the default view keeps the entry as is: a dialog restored
// from the URL, like a more-info deep link, can already be open.
this._navigateToView(views[newSelectView].path || newSelectView, {
replace: true,
keepDialogs: true,
});
this._navigateToView(views[newSelectView].path || newSelectView, true);
} else if (viewPath === "hass-unused-entities") {
newSelectView = "hass-unused-entities";
} else if (viewPath) {
@@ -871,9 +865,10 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
viewPath === "hass-unused-entities"
) {
newSelectView = views.findIndex(this._isVisible);
this._navigateToView(views[newSelectView].path || newSelectView, {
replace: true,
});
this._navigateToView(
views[newSelectView].path || newSelectView,
true
);
}
}
@@ -1202,7 +1197,7 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
});
}
private _navigateToView(path: string | number, options?: NavigateOptions) {
private _navigateToView(path: string | number, replace?: boolean) {
if (this.embedded) {
fireEvent(this, "view-navigate", { path: String(path) });
@@ -1213,7 +1208,7 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
const currentUrl = `${location.pathname}${location.search}`;
if (currentUrl !== url) {
navigate(url, options);
navigate(url, { replace });
}
}
@@ -1283,7 +1278,7 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
newCurView = indexAfterMove(curView, oldIndex, newIndex);
// Views without a path are addressed by their index in the URL
if (newCurView !== curView && !this.config.views[curView].path) {
this._navigateToView(newCurView, { replace: true });
this._navigateToView(newCurView, true);
}
}
lovelace.saveConfig(moveView(lovelace.config, oldIndex, newIndex));
+8
View File
@@ -15,6 +15,8 @@ import {
DataZoomComponent,
VisualMapComponent,
ToolboxComponent,
MarkAreaComponent,
BrushComponent,
} from "echarts/components";
// Features like Universal Transition and Label Layout
@@ -48,6 +50,8 @@ import type {
GridComponentOption,
DataZoomComponentOption,
VisualMapComponentOption,
MarkAreaComponentOption,
BrushComponentOption,
} from "echarts/components";
import type { ComposeOption } from "echarts/core";
@@ -62,6 +66,8 @@ export type ECOption = ComposeOption<
| GridComponentOption
| DataZoomComponentOption
| VisualMapComponentOption
| MarkAreaComponentOption
| BrushComponentOption
| SankeySeriesOption
| GraphSeriesOption
| SunburstSeriesOption
@@ -91,6 +97,8 @@ echarts.use([
UniversalTransition,
CanvasRenderer,
ToolboxComponent,
MarkAreaComponent,
BrushComponent,
]);
export { LinearGradient };
+39 -36
View File
@@ -2347,7 +2347,7 @@
"aliases_description": "Aliases are the names voice assistants use for this entity.",
"aliases_description_assist": "Assist uses all aliases equally.",
"aliases_description_alexa": "Amazon Alexa uses only the first alias.",
"aliases_description_google": "Google Home uses the first alias as the main name and the rest as alternatives.",
"aliases_description_google": "Google Assistant uses the first alias as the main name and the rest as alternatives.",
"aliases_no_unique_id": "Aliases are not supported for entities without a unique ID. See the {faq_link} for more detail.",
"entity_name_alias_description": "Default name. When enabled, it is used as the first alias.",
"ask_pin": "Ask for PIN",
@@ -4513,28 +4513,32 @@
"info_text_1": "The mean type of ''{name}'' ({statistic_id}) changed from ''{metadata_mean_type}'' to ''{state_mean_type}''.",
"info_text_2": "Statistics cannot be generated until the old statistics are deleted, or the mean type matches the old statistics data again.",
"info_text_3": "Do you want to permanently delete the long term statistics of {statistic_id} from your database?"
},
"adjust_sum": {
"title": "Adjust a statistic",
"no_statistics_found": "No statistics found for this period.",
"info_text_1": "Sometimes the statistics end up being incorrect for a specific point in time. This can mess up your beautiful graphs! Select a time below to find the bad moment and adjust the data.",
"pick_a_time": "Pick a time",
"statistic_values": "Statistic values",
"statistic": "Statistic:",
"start": "Start",
"end": "End",
"new_value": "New value",
"adjust": "Adjust",
"outliers": "Outliers",
"sum_adjusted": "Statistic sum adjusted",
"error_sum_adjusted": "Error adjusting sum: {message}",
"error_loading_statistics": "Error loading statistics: {message}"
}
},
"adjust_sum": "Adjust sum",
"clear_range": {
"title": "Delete statistics in a time range",
"info_text": "Delete the statistics of {name} between the start and end time. Use this to remove bad measurements from your graphs. Deleted statistics cannot be restored.",
"statistic": {
"more_info": "[%key:ui::panel::config::tools::tabs::states::more_info%]",
"all_data": "All data",
"show_all_data": "Show all data",
"data_range": "Recorded data",
"correct_mode": "Correction mode",
"no_selection": "No period selected",
"outliers": "Outliers",
"no_outliers": "[%key:ui::common::none%]",
"correct": "Correct data",
"correct_sum_hint": "Select the bar with the wrong value.",
"correct_mean_hint": "Select the period with wrong values, or drag across the graph to select several periods.",
"current_value": "Current value",
"period": "From {start} to {end}",
"mean": "[%key:ui::panel::lovelace::editor::card::statistic::stat_type_labels::mean%]",
"min": "[%key:ui::panel::lovelace::editor::card::statistic::stat_type_labels::min%]",
"max": "[%key:ui::panel::lovelace::editor::card::statistic::stat_type_labels::max%]",
"new_value": "New value",
"adjust": "Adjust",
"adjusted": "Statistic adjusted",
"delete": "Delete period",
"delete_confirm_title": "Delete these statistics?",
"delete_confirm_text": "Deleted statistics cannot be restored.",
"delete_confirm_text_5minute": "The hourly statistics that overlap this period are deleted too. Deleted statistics cannot be restored.",
"invalid_range": "The end time must be after the start time.",
"no_statistics_found": "No statistics found for this period.",
"deleted": "Statistics deleted"
@@ -5400,8 +5404,8 @@
"title": "Get the best assistant with {home_assistant_cloud}",
"features": {
"assistants": {
"title": "Google Home and Amazon Alexa",
"text": "Control all your Home Assistant devices via any Google Home-enabled or Alexa-enabled device."
"title": "Google Assistant and Amazon Alexa",
"text": "Control all your Home Assistant devices via any Google Assistant-enabled or Alexa-enabled device."
},
"speech": {
"title": "Amazing speech options for Assist",
@@ -7008,7 +7012,7 @@
"cloud": {
"description_login": "Logged in and connected",
"description_not_login": "Not logged in",
"description_features": "Control home when away and integrate with Alexa and Google Home",
"description_features": "Control home when away and integrate with Alexa and Google Assistant",
"login": {
"title": "Cloud login",
"landing_title": "[%key:ui::panel::config::cloud::account::onboarding::ha_cloud_title%]",
@@ -7112,7 +7116,7 @@
"alexa_google_both": "Alexa and Google linked",
"alexa_google_alexa": "Alexa linked",
"alexa_google_google": "Google linked",
"alexa_google_none": "Control your home with Amazon Alexa and Google Home",
"alexa_google_none": "Control your home with Amazon Alexa and Google Assistant",
"feature_cameras": "Cameras and streaming",
"feature_webhooks": "Webhooks",
"webhooks_active": "{count} active",
@@ -7149,7 +7153,7 @@
"backup_body": "Keep an encrypted off-site copy of your latest backup, ready to restore on first boot.",
"voice_title": "Voice assistants",
"voice_helper": "Alexa, Google, and Home Assistant voice",
"voice_body": "Talk to your home your way. Home Assistant Cloud powers fast, private voice for Assist out of the box, and you can add Amazon Alexa or Google Home.",
"voice_body": "Talk to your home your way. Home Assistant Cloud powers fast, private voice for Assist out of the box, and you can add Amazon Alexa or Google Assistant.",
"streaming_title": "Smoother streaming",
"streaming_helper": "Faster, more reliable camera feeds",
"streaming_body": "Make camera feeds and Music Assistant playback faster and more reliable, over a secure connection.",
@@ -7164,7 +7168,7 @@
"alexa_tagline": "Voice control with Echo devices",
"alexa_bullet1": "Control devices from your Echo by voice",
"alexa_bullet2": "Use Home Assistant in your Alexa routines",
"google_name": "Google Home",
"google_name": "Google Assistant",
"google_tagline": "Hands-free control with Nest devices",
"google_bullet1": "Control devices from your Nest speakers by voice",
"google_bullet2": "Cast your cameras to a Nest Hub display"
@@ -7209,7 +7213,7 @@
"support_package_privacy_warning": "This file may contain personal data about your home. Avoid sharing them with unverified or untrusted parties.",
"reset_cloud_data": "Reset cloud data",
"reset_data_confirm_title": "Reset cloud data?",
"reset_data_confirm_text": "This will reset all your cloud settings. This includes your remote connection, Google Home, and Amazon Alexa integrations. This action cannot be undone.",
"reset_data_confirm_text": "This will reset all your cloud settings. This includes your remote connection, Google Assistant, and Amazon Alexa integrations. This action cannot be undone.",
"reset": "Reset",
"reset_data_failed": "Failed to reset cloud data",
"thank_you_note": "It's because of people like you that we are able to make a great home automation experience for everyone.",
@@ -7222,7 +7226,7 @@
"integrations_introduction": "Integrations for Home Assistant Cloud allow you to connect with services in the cloud without having to expose your Home Assistant instance publicly on the internet.",
"integrations_introduction2": "Check the website for ",
"integrations_link_all_features": " all available features",
"tip_moved_voice_assistants": "Looking for Google Home and Amazon Alexa settings? They are moved to the new voice assistants page.",
"tip_moved_voice_assistants": "Looking for Google Assistant and Amazon Alexa settings? They are moved to the new voice assistants page.",
"connected": "Connected",
"connecting": "Connecting…",
"not_connected": "Not connected",
@@ -7309,26 +7313,26 @@
"expose_new_entities_info": "Should new entities be exposed to Alexa automatically? Exposes supported devices that are not classified as security devices."
},
"google": {
"title": "Google Home",
"info": "With the Google Home integration for Home Assistant Cloud, you'll be able to control all your Home Assistant devices via any Google Home-enabled device.",
"title": "Google Assistant",
"info": "With the Google Assistant integration for Home Assistant Cloud, you'll be able to control all your Home Assistant devices via any Google Assistant-enabled device.",
"http_use_ssl_warning_title": "Local communication unavailable",
"http_use_ssl_warning_text": "Google devices will not be able to talk locally with Home Assistant, because you have configured an SSL certificate for your HTTP integration.",
"config_documentation": "Configuration documentation",
"enable_state_reporting": "Enable state reporting",
"info_state_reporting": "If you enable state reporting, Home Assistant will send all state changes of exposed entities to Google. This speeds up voice commands and allows you to always see the latest states in the Google app.",
"security_devices": "Security devices",
"enter_pin_info": "Please enter a PIN to interact with security devices. Security devices are doors, garage doors, and locks. You will be asked to say/enter this PIN when interacting with security devices via Google Home.",
"enter_pin_info": "Please enter a PIN to interact with security devices. Security devices are doors, garage doors, and locks. You will be asked to say/enter this PIN when interacting with security devices via Google Assistant.",
"devices_pin": "Security devices PIN",
"enter_pin_hint": "Enter a PIN to use security devices",
"show_entities": "Show entities",
"exposed_entities": "{number} {number, plural,\n one {entity}\n other {entities}\n} exposed",
"manual_config": "Editing which entities are exposed via the UI is disabled because you have configured entity filters in configuration.yaml.",
"enter_pin_error": "Unable to store PIN:",
"not_configured_title": "Continue setting up Google Home",
"not_configured_text": "To start using Google Home, add the 'Home Assistant Cloud by Nabu Casa' service in the Google Home app on your smartphone.",
"not_configured_title": "Continue setting up Google Assistant",
"not_configured_text": "To start using Google Assistant, add the 'Home Assistant Cloud by Nabu Casa' service in the Google Home app on your smartphone.",
"link_learn_how_it_works": "[%key:ui::panel::config::cloud::account::remote::link_learn_how_it_works%]",
"expose_new_entities": "Expose new entities",
"expose_new_entities_info": "Should new entities be exposed to Google Home automatically? Exposes supported devices that are not classified as security devices."
"expose_new_entities_info": "Should new entities be exposed to Google Assistant automatically? Exposes supported devices that are not classified as security devices."
},
"webhooks": {
"title": "Webhooks",
@@ -7974,7 +7978,6 @@
"error_saving_device": "Error updating device: {error}",
"error_saving_entity": "Error updating entity: {error}",
"created_config": "Created configuration for {name}.",
"helper_area": "This helper isn't part of a device, so you can add it to an area.",
"external_step": {
"description": "This step requires you to visit an external website to be completed.",
"open_site": "Open website"
+2 -43
View File
@@ -12,15 +12,10 @@ import {
registerUnsavedChangesGuard,
unregisterUnsavedChangesGuard,
} from "../../src/common/navigate";
import { closeAllDialogs } from "../../src/dialogs/make-dialog-manager";
// navigate() closes open dialogs before touching history. A closed dialog
// drops its state from the entry, which is what navigate() waits for.
// navigate() closes open dialogs before touching history.
vi.mock("../../src/dialogs/make-dialog-manager", () => ({
closeAllDialogs: vi.fn(async () => {
window.history.replaceState(null, "");
return true;
}),
closeAllDialogs: vi.fn(async () => true),
}));
// Fabricates a raw entry like a document load, which navigate() never produces.
@@ -61,7 +56,6 @@ const trackDeferredGuard = (isDirty: () => boolean = () => true) => {
afterEach(() => {
registeredGuards.splice(0).forEach(unregisterUnsavedChangesGuard);
vi.mocked(closeAllDialogs).mockClear();
});
describe("navigate", () => {
@@ -103,41 +97,6 @@ describe("navigate", () => {
expect(window.history.state).toBeNull();
expect(canGoBack()).toBe(false);
});
it("closes an open dialog before replacing the entry", async () => {
setEntry("/home", { dialog: "ha-more-info-dialog" });
await navigate("/home/overview", { replace: true });
expect(closeAllDialogs).toHaveBeenCalledOnce();
expect(window.location.pathname).toEqual("/home/overview");
expect(window.history.state).toBeNull();
});
it("keeps an open dialog and the entry state when asked to", async () => {
setEntry("/home", { dialog: "ha-more-info-dialog", from: "/" });
await navigate("/home/overview", { replace: true, keepDialogs: true });
expect(closeAllDialogs).not.toHaveBeenCalled();
expect(window.location.pathname).toEqual("/home/overview");
expect(window.history.state).toEqual({
dialog: "ha-more-info-dialog",
from: "/",
});
});
it("does not drop a navigation right after one that keeps dialogs", async () => {
setEntry("/home");
const results = await Promise.all([
navigate("/home/overview", { replace: true, keepDialogs: true }),
navigate("/config"),
]);
expect(results).toEqual([true, true]);
expect(window.location.pathname).toEqual("/config");
});
});
describe("unsaved changes guard", () => {
+1 -1
View File
@@ -45,7 +45,7 @@ describe("partial-panel-resolver", () => {
// The Marketplace loads its translations for the path it is at
expect(navigate).toHaveBeenCalledWith(
"/marketplace/repository/1?category=theme#top",
{ replace: true, keepDialogs: true }
{ replace: true }
);
});