mirror of
https://github.com/home-assistant/frontend.git
synced 2026-11-06 04:54:12 +00:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96d15065a9 | ||
|
|
ca456105e5 | ||
|
|
9f12b54a14 | ||
|
|
cf25121dda | ||
|
|
71f53ae3e2 | ||
|
|
8843596e42 | ||
|
|
d06ec13af6 | ||
|
|
1c428c1831 | ||
|
|
c20a90cd81 | ||
|
|
f64a602bc8 | ||
|
|
3354b5ffec | ||
|
|
fce263e6ab | ||
|
|
6317dad51b | ||
|
|
c350d46d44 | ||
|
|
e5a43b48c2 | ||
|
|
73a88453db | ||
|
|
fba2451e61 | ||
|
|
c33a452976 | ||
|
|
ec0533f8c5 | ||
|
|
b1b9c41bc1 | ||
|
|
685e7d51d2 | ||
|
|
315afd8ac2 | ||
|
|
bc9d0b5227 | ||
|
|
99c85c3407 | ||
|
|
e04e639100 | ||
|
|
ca7b91af35 | ||
|
|
ffd26ffc1a | ||
|
|
bd4ff71d29 |
No files matched your search
@@ -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,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": [
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Generated
+394
-394
File diff suppressed because it is too large.
Load diff
+2
-13
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -10,7 +10,7 @@ export const voiceAssistants = {
|
||||
},
|
||||
"cloud.google_assistant": {
|
||||
domain: "google_assistant",
|
||||
name: "Google Home",
|
||||
name: "Google Assistant",
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user