mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-24 15:50:05 +00:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29b2a01299 | ||
|
|
19cd06bb8c | ||
|
|
fcca87b368 | ||
|
|
2eb3b025d8 | ||
|
|
43628c5d69 | ||
|
|
0f52c00821 | ||
|
|
20f70cde7a | ||
|
|
b38d7bcb26 | ||
|
|
0572d71699 | ||
|
|
bcd13fcaf7 | ||
|
|
308eac97ee | ||
|
|
6da851219d | ||
|
|
970be43c3d | ||
|
|
8bb718a486 | ||
|
|
0e0bc389f8 | ||
|
|
70a482e0ed | ||
|
|
5a0db4a796 | ||
|
|
622f3e97e6 | ||
|
|
3091362040 | ||
|
|
3c7560a4e2 | ||
|
|
a9e178cb0e | ||
|
|
23b83fbd27 | ||
|
|
62849e2630 | ||
|
|
aa3e11f6e9 | ||
|
|
82ed13b7fc |
+40
-24
@@ -1,36 +1,52 @@
|
||||
[modern]
|
||||
# Modern builds target recent browsers supporting the latest features to minimize transpilation, polyfills, etc.
|
||||
# It is served to browsers meeting the following requirements:
|
||||
# - released in the last year + current alpha/beta versions
|
||||
# - Firefox extended support release (ESR)
|
||||
# - with global utilization at or above 0.5%
|
||||
# - released in the last 2 years + current alpha/beta versions
|
||||
# - exclude dead browsers (no security maintenance for 2+ years)
|
||||
# - exclude KaiOS, QQ, and UC browsers due to lack of sufficient feature support data
|
||||
# - exclude QQ, and UC browsers due to lack of sufficient feature support data
|
||||
unreleased versions
|
||||
last 1 year
|
||||
Firefox ESR
|
||||
>= 0.5%
|
||||
last 2 years
|
||||
not dead
|
||||
not KaiOS > 0
|
||||
not QQAndroid > 0
|
||||
not UCAndroid > 0
|
||||
|
||||
[legacy]
|
||||
# Legacy builds are served when modern requirements are not met and support browsers:
|
||||
# - released in the last 7 years + current alpha/beta versionss
|
||||
# - with global utilization at or above 0.05%
|
||||
# - exclude dead browsers (no security maintenance for 2+ years)
|
||||
# - exclude Opera Mini which does not support web sockets
|
||||
unreleased versions
|
||||
last 7 years
|
||||
>= 0.05%
|
||||
not dead
|
||||
not op_mini all
|
||||
# Legacy builds are served when modern requirements are not met.
|
||||
# Floors are pinned explicitly (not usage-based) so the support policy is
|
||||
# deliberate and does not drift with global usage statistics, which do not
|
||||
# represent old tablets and wall displays used as Home Assistant dashboards:
|
||||
# - iOS/Safari >= 12: iPad Air 1 / mini 2 / mini 3 (last supported iOS).
|
||||
# Older iPads (iPad 2/3/mini 1 on iOS 9.3, iPad 4 on iOS 10.3) cannot run
|
||||
# the app: their engines lack custom elements, shadow DOM, and/or CSS grid.
|
||||
# - Chrome >= 59: Fire OS 5 tablets (Fire 7/HD 8/HD 10 through ~2017) have
|
||||
# their system WebView pinned at Chromium 59 and commonly run Fully Kiosk;
|
||||
# also covers old kiosk browsers and no-longer-updating webviews above it.
|
||||
# - Edge >= 79: all Chromium-based Edge. Costs nothing (above the Chrome
|
||||
# floor); mainly catches Edge 109 on Windows 7/8.1 and update-frozen
|
||||
# enterprise installs, whose engines can run the legacy build fine.
|
||||
# - Firefox >= 94: the zero-cost floor, not a chased population — pinning it
|
||||
# adds no babel transforms, core-js modules, or Lightning CSS prefixes to
|
||||
# the output. Mozilla-supported Firefox (incl. current ESR) always matches
|
||||
# [modern]; Firefox on old OSes is not supported (use Chrome 109 instead).
|
||||
# If Firefox ever becomes the pin forcing extra output, raise it first.
|
||||
# - Samsung >= 9: Samsung Internet on old Galaxy tablets
|
||||
Chrome >= 59
|
||||
ChromeAndroid >= 59
|
||||
Edge >= 79
|
||||
Firefox >= 94
|
||||
FirefoxAndroid >= 94
|
||||
iOS >= 12
|
||||
Safari >= 12
|
||||
Samsung >= 9
|
||||
|
||||
[legacy-sw]
|
||||
# Same as legacy plus supports service workers
|
||||
unreleased versions
|
||||
last 7 years
|
||||
>= 0.05% and supports serviceworkers
|
||||
not dead
|
||||
not op_mini all
|
||||
# Same as legacy, restricted to browsers that support service workers
|
||||
# (currently resolves to the same set; guards the service worker build if the legacy floor ever drops below them)
|
||||
Chrome >= 59 and supports serviceworkers
|
||||
ChromeAndroid >= 59 and supports serviceworkers
|
||||
Edge >= 79 and supports serviceworkers
|
||||
Firefox >= 94 and supports serviceworkers
|
||||
FirefoxAndroid >= 94 and supports serviceworkers
|
||||
iOS >= 12 and supports serviceworkers
|
||||
Safari >= 12 and supports serviceworkers
|
||||
Samsung >= 9 and supports serviceworkers
|
||||
|
||||
@@ -32,12 +32,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
|
||||
"frontend-modern": {
|
||||
"app": 585518,
|
||||
"core": 57048,
|
||||
"authorize": 552423,
|
||||
"onboarding": 666818
|
||||
"app": 576583,
|
||||
"core": 54790,
|
||||
"authorize": 543547,
|
||||
"onboarding": 655556
|
||||
},
|
||||
"frontend-legacy": {
|
||||
"app": 887384,
|
||||
"core": 244482,
|
||||
"authorize": 844995,
|
||||
"onboarding": 1008816
|
||||
"app": 717124,
|
||||
"core": 181583,
|
||||
"authorize": 699175,
|
||||
"onboarding": 816133
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,8 +63,10 @@ module.exports.htmlMinifierOptions = {
|
||||
};
|
||||
|
||||
module.exports.terserOptions = ({ latestBuild, isTestBuild }) => ({
|
||||
safari10: !latestBuild,
|
||||
ecma: latestBuild ? 2015 : 5,
|
||||
// Highest syntax the minifier may emit; it never downlevels. Every browser
|
||||
// in [modern] is well past ES2020 (universal since spring 2020); the
|
||||
// [legacy] floors (Chrome 59 / Safari 12) top out at ES2017.
|
||||
ecma: latestBuild ? 2020 : 2017,
|
||||
module: latestBuild,
|
||||
format: { comments: false },
|
||||
sourceMap: !isTestBuild,
|
||||
|
||||
@@ -57,7 +57,6 @@ export default [
|
||||
"ha-control-select",
|
||||
"ha-control-select-menu",
|
||||
"ha-hs-color-picker",
|
||||
"ha-color-temp-brightness-picker",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -25,6 +25,9 @@ title: Button
|
||||
<ha-button appearance="filled">
|
||||
filled button
|
||||
</ha-button>
|
||||
<ha-button appearance="outlined">
|
||||
outlined button
|
||||
</ha-button>
|
||||
|
||||
<ha-button size="s">
|
||||
small
|
||||
@@ -65,7 +68,7 @@ Check the [webawesome documentation](https://webawesome.com/docs/components/butt
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| ---------- | ---------------------------------------------- | -------- | --------------------------------------------------------------------------------- |
|
||||
| appearance | "accent"/"filled"/"plain" | "accent" | Sets the button appearance. |
|
||||
| appearance | "accent"/"filled"/"outlined"/"plain" | "accent" | Sets the button appearance. |
|
||||
| variants | "brand"/"danger"/"neutral"/"warning"/"success" | "brand" | Sets the button color variant. "brand" is default. |
|
||||
| size | "xs"/"s"/"m"/"l"/"xl" | "m" | Sets the button size. |
|
||||
| loading | Boolean | false | Shows a loading indicator instead of the buttons label and disable buttons click. |
|
||||
|
||||
@@ -9,7 +9,7 @@ import "../../../../src/components/ha-svg-icon";
|
||||
import { mdiHomeAssistant } from "../../../../src/resources/home-assistant-logo-svg";
|
||||
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
|
||||
|
||||
const appearances = ["accent", "filled", "plain"];
|
||||
const appearances = ["accent", "filled", "outlined", "plain"];
|
||||
const variants = ["brand", "danger", "neutral", "warning", "success"];
|
||||
|
||||
@customElement("demo-components-ha-button")
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
---
|
||||
title: Color temperature and brightness picker
|
||||
subtitle: Dual-axis pad controlling color temperature and brightness together
|
||||
---
|
||||
@@ -1,102 +0,0 @@
|
||||
import "../../../../src/components/ha-color-temp-brightness-picker";
|
||||
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
|
||||
import "../../../../src/components/ha-card";
|
||||
import "../../../../src/components/ha-slider";
|
||||
|
||||
const MIN_KELVIN = 2000;
|
||||
const MAX_KELVIN = 6500;
|
||||
|
||||
@customElement("demo-components-ha-color-temp-brightness-picker")
|
||||
export class DemoHaColorTempBrightnessPicker extends LitElement {
|
||||
@state()
|
||||
value: [number, number] = [3000, 50];
|
||||
|
||||
@state()
|
||||
liveValue?: [number, number];
|
||||
|
||||
private _valueCursor(ev) {
|
||||
this.liveValue = ev.detail.value;
|
||||
}
|
||||
|
||||
private _valueChanged(ev) {
|
||||
this.value = ev.detail.value;
|
||||
}
|
||||
|
||||
private _kelvinChanged(ev) {
|
||||
this.value = [Number(ev.target.value), this.value[1]];
|
||||
}
|
||||
|
||||
private _brightnessChanged(ev) {
|
||||
this.value = [this.value[0], Number(ev.target.value)];
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const [kelvin, brightness] = this.liveValue ?? this.value;
|
||||
|
||||
return html`
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<p class="value">${kelvin} K - ${brightness}%</p>
|
||||
<ha-color-temp-brightness-picker
|
||||
.value=${this.value}
|
||||
.minKelvin=${MIN_KELVIN}
|
||||
.maxKelvin=${MAX_KELVIN}
|
||||
@value-changed=${this._valueChanged}
|
||||
@cursor-moved=${this._valueCursor}
|
||||
></ha-color-temp-brightness-picker>
|
||||
<p>Color temperature: ${this.value[0]} K</p>
|
||||
<ha-slider
|
||||
labeled
|
||||
step="1"
|
||||
.min=${MIN_KELVIN}
|
||||
.max=${MAX_KELVIN}
|
||||
.value=${this.value[0]}
|
||||
@change=${this._kelvinChanged}
|
||||
>
|
||||
</ha-slider>
|
||||
<p>Brightness: ${this.value[1]}%</p>
|
||||
<ha-slider
|
||||
labeled
|
||||
step="1"
|
||||
min="1"
|
||||
max="100"
|
||||
.value=${this.value[1]}
|
||||
@change=${this._brightnessChanged}
|
||||
>
|
||||
</ha-slider>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-card {
|
||||
max-width: 600px;
|
||||
margin: 24px auto;
|
||||
}
|
||||
.card-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
ha-color-temp-brightness-picker {
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
}
|
||||
.value {
|
||||
font-size: var(--ha-font-size-xl);
|
||||
font-weight: var(--ha-font-weight-bold);
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-components-ha-color-temp-brightness-picker": DemoHaColorTempBrightnessPicker;
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,50 @@ const positions: Position[] = ["start", "end"];
|
||||
const selectedStates = [false, true];
|
||||
const disabledStates = [false, true];
|
||||
|
||||
interface TreeChild {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface TreeGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
children: TreeChild[];
|
||||
}
|
||||
|
||||
const treeGroups: TreeGroup[] = [
|
||||
{
|
||||
key: "binary_sensor",
|
||||
label: "Binary sensor",
|
||||
children: [
|
||||
{ key: "door", label: "Door" },
|
||||
{ key: "motion", label: "Motion" },
|
||||
{ key: "window", label: "Window" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "cover",
|
||||
label: "Cover",
|
||||
children: [
|
||||
{ key: "garage", label: "Garage" },
|
||||
{ key: "shutter", label: "Shutter" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface TreeRow {
|
||||
group: TreeGroup;
|
||||
child?: TreeChild;
|
||||
}
|
||||
|
||||
const treeRows: TreeRow[] = treeGroups.flatMap((group) => [
|
||||
{ group },
|
||||
...group.children.map((child) => ({ group, child })),
|
||||
]);
|
||||
|
||||
const treeKey = (group: TreeGroup, child: TreeChild) =>
|
||||
`${group.key}/${child.key}`;
|
||||
|
||||
@customElement("demo-components-ha-list")
|
||||
export class DemoHaList extends LitElement {
|
||||
@state() private _buttonClicks = 0;
|
||||
@@ -41,6 +85,8 @@ export class DemoHaList extends LitElement {
|
||||
|
||||
@state() private _multiCheckEnd: number | Set<number> = new Set();
|
||||
|
||||
@state() private _tree = new Set<string>();
|
||||
|
||||
private _options = ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"];
|
||||
|
||||
protected render(): TemplateResult {
|
||||
@@ -274,6 +320,45 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckStart))}</pre>
|
||||
selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre>
|
||||
</ha-card>
|
||||
|
||||
<ha-card header="Controlled selection with indeterminate groups">
|
||||
<ha-list-selectable
|
||||
multi
|
||||
controlled
|
||||
aria-label="Controlled tree"
|
||||
@ha-list-item-selected=${this._onTreeToggle}
|
||||
@ha-list-item-deselected=${this._onTreeToggle}
|
||||
>
|
||||
${treeGroups.map((group) => {
|
||||
const groupState = this._groupState(group);
|
||||
return html`
|
||||
<ha-list-item-option
|
||||
appearance="checkbox"
|
||||
selection-position="end"
|
||||
.value=${group.key}
|
||||
?selected=${groupState === "all"}
|
||||
?indeterminate=${groupState === "some"}
|
||||
>
|
||||
<span slot="headline">${group.label}</span>
|
||||
</ha-list-item-option>
|
||||
${group.children.map(
|
||||
(child) => html`
|
||||
<ha-list-item-option
|
||||
class="child"
|
||||
appearance="checkbox"
|
||||
selection-position="end"
|
||||
.value=${treeKey(group, child)}
|
||||
?selected=${this._tree.has(treeKey(group, child))}
|
||||
>
|
||||
<span slot="headline">${child.label}</span>
|
||||
</ha-list-item-option>
|
||||
`
|
||||
)}
|
||||
`;
|
||||
})}
|
||||
</ha-list-selectable>
|
||||
<pre>selected: ${JSON.stringify([...this._tree])}</pre>
|
||||
</ha-card>
|
||||
|
||||
<ha-card header="Option: all combinations">
|
||||
<div class="grid">
|
||||
${appearances.map((appearance) =>
|
||||
@@ -361,6 +446,43 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre>
|
||||
return next;
|
||||
}
|
||||
|
||||
private _groupState(group: TreeGroup): "none" | "some" | "all" {
|
||||
const selected = group.children.filter((child) =>
|
||||
this._tree.has(treeKey(group, child))
|
||||
).length;
|
||||
if (selected === 0) {
|
||||
return "none";
|
||||
}
|
||||
return selected === group.children.length ? "all" : "some";
|
||||
}
|
||||
|
||||
private _onTreeToggle = (ev: CustomEvent<number>) => {
|
||||
const row = treeRows[ev.detail];
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
const next = new Set(this._tree);
|
||||
if (row.child) {
|
||||
const key = treeKey(row.group, row.child);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
} else {
|
||||
const select = this._groupState(row.group) !== "all";
|
||||
row.group.children.forEach((child) => {
|
||||
const key = treeKey(row.group, child);
|
||||
if (select) {
|
||||
next.add(key);
|
||||
} else {
|
||||
next.delete(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
this._tree = next;
|
||||
};
|
||||
|
||||
private _onSingle = (ev: CustomEvent<number>) => {
|
||||
this._single = ev.detail;
|
||||
};
|
||||
@@ -443,6 +565,9 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre>
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
}
|
||||
.child::part(base) {
|
||||
padding-inline-start: var(--ha-space-12);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
+11
-8
@@ -42,14 +42,14 @@
|
||||
"@babel/runtime": "8.0.0",
|
||||
"@braintree/sanitize-url": "7.1.2",
|
||||
"@codemirror/autocomplete": "6.20.3",
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/commands": "6.11.0",
|
||||
"@codemirror/lang-jinja": "6.0.1",
|
||||
"@codemirror/lang-yaml": "6.1.3",
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/lint": "6.9.7",
|
||||
"@codemirror/search": "6.7.1",
|
||||
"@codemirror/state": "6.7.1",
|
||||
"@codemirror/view": "6.43.8",
|
||||
"@codemirror/view": "6.43.9",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.6.0",
|
||||
@@ -101,12 +101,13 @@
|
||||
"deep-freeze": "0.0.1",
|
||||
"dialog-polyfill": "0.5.6",
|
||||
"echarts": "6.1.0",
|
||||
"echarts-extension-chart2music": "0.1.1",
|
||||
"element-internals-polyfill": "3.0.2",
|
||||
"fuse.js": "7.5.0",
|
||||
"hls.js": "1.7.0",
|
||||
"hls.js": "1.7.1",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "11.2.13",
|
||||
"intl-messageformat": "11.2.14",
|
||||
"js-yaml": "5.3.0",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
@@ -114,7 +115,7 @@
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"luxon": "3.7.2",
|
||||
"marked": "18.0.9",
|
||||
"marked": "18.0.10",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.4",
|
||||
"object-hash": "3.0.0",
|
||||
@@ -145,7 +146,7 @@
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.2",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@gfx/zopfli": "1.0.15",
|
||||
"@html-eslint/eslint-plugin": "0.64.0",
|
||||
"@html-eslint/eslint-plugin": "0.65.0",
|
||||
"@lokalise/node-api": "16.3.0",
|
||||
"@octokit/auth-oauth-device": "8.0.4",
|
||||
"@octokit/plugin-retry": "8.1.1",
|
||||
@@ -169,9 +170,10 @@
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
"@typescript/native": "npm:[email protected]",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"@vitest/coverage-v8": "4.1.11",
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-polyfill-corejs3": "1.0.0",
|
||||
"browserslist": "4.28.8",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"del": "8.0.1",
|
||||
"eslint": "10.8.1",
|
||||
@@ -195,6 +197,7 @@
|
||||
"jsdom": "30.0.1",
|
||||
"jszip": "3.10.1",
|
||||
"license-checker-rseidelsohn": "5.0.1",
|
||||
"lightningcss": "1.33.0",
|
||||
"lint-staged": "17.3.0",
|
||||
"lit-analyzer": "2.0.3",
|
||||
"lodash.merge": "4.6.2",
|
||||
@@ -212,7 +215,7 @@
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.67.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.10",
|
||||
"vitest": "4.1.11",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
"webpackbar": "7.0.0",
|
||||
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import type {
|
||||
ReactiveController,
|
||||
ReactiveControllerHost,
|
||||
} from "@lit/reactive-element/reactive-controller";
|
||||
import { css, type LitElement } from "lit";
|
||||
import type { Ref } from "lit/directives/ref";
|
||||
import { parseAnimationDuration } from "../util/parse-animation-duration";
|
||||
|
||||
type FilterPanelHost = ReactiveControllerHost &
|
||||
LitElement & { expanded: boolean };
|
||||
|
||||
const EASING = "cubic-bezier(0.4, 0, 0.2, 1)";
|
||||
|
||||
/**
|
||||
* Layout the controller relies on: the filter is a flex column made of its
|
||||
* header (`ha-expansion-panel`) and a `.content` wrapper. Collapsed, it is as
|
||||
* tall as its header; expanded, it fills what is left of the pane and hands
|
||||
* that space down to the list through the wrapper.
|
||||
*/
|
||||
export const filterPanelStyles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
flex: none;
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
}
|
||||
ha-expansion-panel::part(summary) {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const panels = new Set<FilterPanelController>();
|
||||
|
||||
let pending: Set<FilterPanelController> | undefined;
|
||||
|
||||
const flush = () => {
|
||||
const batch = [...pending!].filter((panel) => panel.host.isConnected);
|
||||
pending = undefined;
|
||||
batch.forEach((panel) => panel.prepare());
|
||||
batch.forEach((panel) => panel.measure());
|
||||
batch.forEach((panel) => panel.play());
|
||||
};
|
||||
|
||||
// Filters that change in the same frame (one closing while another opens) are
|
||||
// animated as one batch: every filter is measured before any of them has
|
||||
// changed the DOM, and the closing ones are parked at their final height so
|
||||
// the opening one reads its own final height from the real layout.
|
||||
const enqueue = (panel: FilterPanelController) => {
|
||||
if (!pending) {
|
||||
pending = new Set();
|
||||
panels.forEach((other) => other.snapshot());
|
||||
requestAnimationFrame(flush);
|
||||
}
|
||||
pending.add(panel);
|
||||
};
|
||||
|
||||
const clearInlineStyles = (element?: HTMLElement) => {
|
||||
element?.style.removeProperty("height");
|
||||
element?.style.removeProperty("flex");
|
||||
element?.style.removeProperty("overflow");
|
||||
};
|
||||
|
||||
/**
|
||||
* Animates a filter of the filter pane between its collapsed and expanded
|
||||
* heights whenever `expanded` changes, and tells the host when to render its
|
||||
* content: from the moment it expands until its collapse animation has ended.
|
||||
*
|
||||
* During the animation the content keeps its final size and the host clips
|
||||
* it, so the list is revealed rather than resized.
|
||||
*/
|
||||
export class FilterPanelController implements ReactiveController {
|
||||
public showContent = false;
|
||||
|
||||
public host: FilterPanelHost;
|
||||
|
||||
private _content: Ref<HTMLElement>;
|
||||
|
||||
private _expanded?: boolean;
|
||||
|
||||
private _first = 0;
|
||||
|
||||
private _last = 0;
|
||||
|
||||
private _contentHeight = 0;
|
||||
|
||||
private _animation?: Animation;
|
||||
|
||||
constructor(host: FilterPanelHost, content: Ref<HTMLElement>) {
|
||||
this.host = host;
|
||||
this._content = content;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
public hostConnected() {
|
||||
panels.add(this);
|
||||
}
|
||||
|
||||
public hostDisconnected() {
|
||||
panels.delete(this);
|
||||
this._animation?.cancel();
|
||||
this._animation = undefined;
|
||||
clearInlineStyles(this.host);
|
||||
clearInlineStyles(this._content.value);
|
||||
}
|
||||
|
||||
public hostUpdate() {
|
||||
const expanded = this.host.expanded;
|
||||
if (this._expanded === undefined) {
|
||||
this._expanded = expanded;
|
||||
this.showContent = expanded;
|
||||
return;
|
||||
}
|
||||
if (expanded === this._expanded) {
|
||||
return;
|
||||
}
|
||||
this._expanded = expanded;
|
||||
if (!this.host.isConnected) {
|
||||
this.showContent = expanded;
|
||||
return;
|
||||
}
|
||||
if (expanded) {
|
||||
this.showContent = true;
|
||||
}
|
||||
enqueue(this);
|
||||
}
|
||||
|
||||
public snapshot() {
|
||||
this._first = this.host.getBoundingClientRect().height;
|
||||
}
|
||||
|
||||
public prepare() {
|
||||
this._animation?.cancel();
|
||||
this._animation = undefined;
|
||||
const host = this.host;
|
||||
const content = this._content.value;
|
||||
clearInlineStyles(host);
|
||||
clearInlineStyles(content);
|
||||
if (host.expanded) {
|
||||
return;
|
||||
}
|
||||
this._last =
|
||||
host.getBoundingClientRect().height -
|
||||
(content?.getBoundingClientRect().height ?? 0);
|
||||
this._contentHeight = this._first - this._last;
|
||||
host.style.flex = "none";
|
||||
host.style.height = `${this._last}px`;
|
||||
}
|
||||
|
||||
public measure() {
|
||||
if (!this.host.expanded) {
|
||||
return;
|
||||
}
|
||||
this._last = this.host.getBoundingClientRect().height;
|
||||
this._contentHeight =
|
||||
this._content.value?.getBoundingClientRect().height ?? 0;
|
||||
}
|
||||
|
||||
public play() {
|
||||
const host = this.host;
|
||||
if (this._first === this._last) {
|
||||
this._finish();
|
||||
return;
|
||||
}
|
||||
const content = this._content.value;
|
||||
host.style.flex = "none";
|
||||
host.style.overflow = "hidden";
|
||||
if (content) {
|
||||
content.style.flex = "none";
|
||||
content.style.height = `${this._contentHeight}px`;
|
||||
}
|
||||
const animation = host.animate(
|
||||
[{ height: `${this._first}px` }, { height: `${this._last}px` }],
|
||||
{
|
||||
duration:
|
||||
parseAnimationDuration(
|
||||
getComputedStyle(host).getPropertyValue(
|
||||
"--ha-animation-duration-normal"
|
||||
)
|
||||
) || 250,
|
||||
easing: EASING,
|
||||
fill: "forwards",
|
||||
}
|
||||
);
|
||||
animation.onfinish = () => this._finish(animation);
|
||||
this._animation = animation;
|
||||
}
|
||||
|
||||
private async _finish(animation?: Animation) {
|
||||
if (!this.host.expanded) {
|
||||
this.showContent = false;
|
||||
this.host.requestUpdate();
|
||||
await this.host.updateComplete;
|
||||
}
|
||||
if (this._animation !== animation) {
|
||||
return;
|
||||
}
|
||||
clearInlineStyles(this.host);
|
||||
clearInlineStyles(this._content.value);
|
||||
this._animation?.cancel();
|
||||
this._animation = undefined;
|
||||
}
|
||||
}
|
||||
@@ -12,13 +12,24 @@ import { getEntityContext } from "./context/get_entity_context";
|
||||
const DEFAULT_SEPARATOR = " ";
|
||||
|
||||
export const DEFAULT_ENTITY_NAME = [
|
||||
{ type: "parent_device" },
|
||||
{ type: "device" },
|
||||
{ type: "entity" },
|
||||
] satisfies EntityNameItem[];
|
||||
|
||||
export const ENTITY_NAME_TYPES = [
|
||||
"entity",
|
||||
"device",
|
||||
"parent_device",
|
||||
"area",
|
||||
"floor",
|
||||
] as const;
|
||||
|
||||
export type EntityNameType = (typeof ENTITY_NAME_TYPES)[number];
|
||||
|
||||
export type EntityNameItem =
|
||||
| {
|
||||
type: "entity" | "device" | "area" | "floor";
|
||||
type: EntityNameType;
|
||||
}
|
||||
| {
|
||||
type: "text";
|
||||
@@ -91,7 +102,7 @@ export const computeEntityNameList = (
|
||||
areas: HomeAssistant["areas"],
|
||||
floors: HomeAssistant["floors"]
|
||||
): (string | undefined)[] => {
|
||||
const { device, area, floor } = getEntityContext(
|
||||
const { device, parentDevice, area, floor } = getEntityContext(
|
||||
stateObj,
|
||||
entities,
|
||||
devices,
|
||||
@@ -105,6 +116,8 @@ export const computeEntityNameList = (
|
||||
return computeEntityName(stateObj, entities, devices);
|
||||
case "device":
|
||||
return device ? computeDeviceName(device) : undefined;
|
||||
case "parent_device":
|
||||
return parentDevice ? computeDeviceName(parentDevice) : undefined;
|
||||
case "area":
|
||||
return area ? computeAreaName(area) : undefined;
|
||||
case "floor":
|
||||
@@ -136,14 +149,20 @@ export const computeEntityPickerDisplay = (
|
||||
>,
|
||||
stateObj: HassEntity
|
||||
): EntityPickerDisplay => {
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
|
||||
const isRTL = computeRTL(
|
||||
hass.language,
|
||||
@@ -152,7 +171,7 @@ export const computeEntityPickerDisplay = (
|
||||
|
||||
const primary = entityName || deviceName || stateObj.entity_id;
|
||||
const secondary =
|
||||
[areaName, entityName ? deviceName : undefined]
|
||||
[areaName, parentDeviceName, entityName ? deviceName : undefined]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ") || undefined;
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getDeviceAreaId } from "./get_device_context";
|
||||
interface EntityContext {
|
||||
entity: EntityRegistryDisplayEntry | null;
|
||||
device: DeviceRegistryEntry | null;
|
||||
parentDevice: DeviceRegistryEntry | null;
|
||||
area: AreaRegistryEntry | null;
|
||||
floor: FloorRegistryEntry | null;
|
||||
}
|
||||
@@ -31,6 +32,7 @@ export const getEntityContext = (
|
||||
return {
|
||||
entity: null,
|
||||
device: null,
|
||||
parentDevice: null,
|
||||
area: null,
|
||||
floor: null,
|
||||
};
|
||||
@@ -65,6 +67,9 @@ export const getEntityEntryContext = (
|
||||
const entity = entities[entry.entity_id];
|
||||
const deviceId = entry?.device_id;
|
||||
const device = deviceId ? devices[deviceId] : undefined;
|
||||
const parentDevice = device?.parent_device_id
|
||||
? devices[device.parent_device_id]
|
||||
: undefined;
|
||||
const areaId =
|
||||
entry?.area_id || (device ? getDeviceAreaId(device, devices) : undefined);
|
||||
const area = areaId ? areas[areaId] : undefined;
|
||||
@@ -74,6 +79,7 @@ export const getEntityEntryContext = (
|
||||
return {
|
||||
entity: entity,
|
||||
device: device || null,
|
||||
parentDevice: parentDevice || null,
|
||||
area: area || null,
|
||||
floor: floor || null,
|
||||
};
|
||||
|
||||
@@ -31,8 +31,6 @@ export type FormatEntityAttributeNameFunc = (
|
||||
attribute: string
|
||||
) => string;
|
||||
|
||||
export type EntityNameType = "entity" | "device" | "area" | "floor";
|
||||
|
||||
export type FormatEntityNameFunc = (
|
||||
stateObj: HassEntity,
|
||||
name: EntityNameItem | EntityNameItem[],
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import type { HassConfig } from "home-assistant-js-websocket";
|
||||
import type { EChartsType } from "echarts/core";
|
||||
import type { XAXisOption, YAXisOption } from "echarts/types/dist/shared";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { formatDateTime } from "../../common/datetime/format_date_time";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import type { FrontendLocaleData } from "../../data/translation";
|
||||
import type {
|
||||
HaECSeries,
|
||||
HaECSeriesItem,
|
||||
} from "../../resources/echarts/echarts";
|
||||
|
||||
export interface ChartSonification {
|
||||
update: () => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
// Series types the Chart2Music ECharts extension can turn into data points. Our
|
||||
// other series (custom timelines, sankey, network graphs) have no equivalent, and
|
||||
// the extension refuses the whole chart if a single series is unsupported.
|
||||
const SONIFIABLE_SERIES_TYPES = new Set(["bar", "line", "pie", "scatter"]);
|
||||
|
||||
// Languages shipped by chart2music. Anything else falls back to its English.
|
||||
const SONIFICATION_LANGUAGES = new Set(["de", "en", "es", "fr", "hmn", "it"]);
|
||||
|
||||
// Fewer than this and there is nothing to walk between, so a focus stop would
|
||||
// lead nowhere.
|
||||
const MIN_NAVIGABLE_POINTS = 2;
|
||||
|
||||
// Mirrors the extension's own reading of a point: it takes `value` as [x, y] and
|
||||
// drops anything whose y is not a real number. That rejects gap-only series, and
|
||||
// also value-first pairs like the energy device charts' [amount, "sensor.foo"].
|
||||
// Counts no further than `limit` so this stays cheap on charts with many points.
|
||||
const countNumericPoints = (data: unknown, limit: number): number => {
|
||||
if (!Array.isArray(data)) {
|
||||
return 0;
|
||||
}
|
||||
let found = 0;
|
||||
for (const raw of data) {
|
||||
let y: unknown = raw;
|
||||
if (Array.isArray(raw)) {
|
||||
y = raw.length > 1 ? raw[1] : raw[0];
|
||||
} else if (raw && typeof raw === "object") {
|
||||
const { value } = raw as { value?: unknown };
|
||||
y = Array.isArray(value)
|
||||
? value.length > 1
|
||||
? value[1]
|
||||
: value[0]
|
||||
: value;
|
||||
}
|
||||
if (typeof y === "number" && !Number.isNaN(y)) {
|
||||
found += 1;
|
||||
if (found >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
const countNavigablePoints = (
|
||||
series: readonly ({ data?: unknown } | undefined)[]
|
||||
): number => {
|
||||
let total = 0;
|
||||
for (const s of series) {
|
||||
total += countNumericPoints(s?.data, MIN_NAVIGABLE_POINTS - total);
|
||||
if (total >= MIN_NAVIGABLE_POINTS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
};
|
||||
|
||||
export const canSonifyChart = (
|
||||
data: HaECSeries,
|
||||
// Legend-hidden series reach ECharts with their data stripped, so they cannot
|
||||
// be sonified either.
|
||||
hiddenDatasets?: ReadonlySet<string>
|
||||
): boolean => {
|
||||
const series = ensureArray(data);
|
||||
const visible = hiddenDatasets?.size
|
||||
? series.filter((s) => !hiddenDatasets.has(String(s.id ?? s.name)))
|
||||
: series;
|
||||
return (
|
||||
// Cards commonly push empty placeholder series, so judge the chart by the
|
||||
// points the extension can actually read — but every type has to be
|
||||
// convertible too.
|
||||
countNavigablePoints(visible) >= MIN_NAVIGABLE_POINTS &&
|
||||
series.every((s) => SONIFIABLE_SERIES_TYPES.has(s.type as string))
|
||||
);
|
||||
};
|
||||
|
||||
interface SonifyChartOptions {
|
||||
cc: HTMLElement;
|
||||
localize: LocalizeFunc;
|
||||
locale: FrontendLocaleData;
|
||||
config: HassConfig;
|
||||
onError: (error: string) => void;
|
||||
}
|
||||
|
||||
// Chart2Music appends its help and options dialogs straight to document.body, so
|
||||
// they can only be themed from a document-level stylesheet.
|
||||
let stylesAppended = false;
|
||||
const appendSonificationStyles = () => {
|
||||
if (stylesAppended) {
|
||||
return;
|
||||
}
|
||||
stylesAppended = true;
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
dialog.chart2music-dialog {
|
||||
box-sizing: border-box;
|
||||
max-width: min(600px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow: auto;
|
||||
padding: var(--ha-space-6);
|
||||
border: none;
|
||||
border-radius: var(--ha-border-radius-lg);
|
||||
background-color: var(--card-background-color);
|
||||
color: var(--primary-text-color);
|
||||
font-family: var(--ha-font-family-body);
|
||||
font-size: var(--ha-font-size-m);
|
||||
box-shadow: var(--ha-box-shadow-l);
|
||||
}
|
||||
dialog.chart2music-dialog::backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
dialog.chart2music-dialog h1 {
|
||||
font-size: var(--ha-font-size-2xl);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
margin-block: 0 var(--ha-space-4);
|
||||
padding-inline-end: var(--ha-space-8);
|
||||
}
|
||||
dialog.chart2music-dialog table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
dialog.chart2music-dialog th,
|
||||
dialog.chart2music-dialog td {
|
||||
text-align: start;
|
||||
padding: var(--ha-space-1) var(--ha-space-2);
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
dialog.chart2music-dialog a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
dialog.chart2music-dialog > button {
|
||||
/* The extension inlines "right", which does not mirror in RTL, and inline
|
||||
styles can only be beaten with !important. */
|
||||
inset-inline-end: var(--ha-space-4) !important;
|
||||
inset-inline-start: auto !important;
|
||||
top: var(--ha-space-4);
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
background-color: transparent;
|
||||
color: var(--primary-text-color);
|
||||
font: inherit;
|
||||
}
|
||||
`;
|
||||
document.head.append(style);
|
||||
};
|
||||
|
||||
export const sonifyChart = async (
|
||||
chart: EChartsType,
|
||||
options: SonifyChartOptions
|
||||
): Promise<ChartSonification | null> => {
|
||||
const { connect } = await import("echarts-extension-chart2music");
|
||||
const { localize, locale, config } = options;
|
||||
appendSonificationStyles();
|
||||
|
||||
// ECharts nulls its model on dispose, and the instance can be disposed while
|
||||
// the chunk is in flight.
|
||||
const chartOptions = chart.getOption() as ReturnType<
|
||||
EChartsType["getOption"]
|
||||
> | null;
|
||||
if (!chartOptions) {
|
||||
return null;
|
||||
}
|
||||
const xAxis = ensureArray(chartOptions.xAxis)[0] as XAXisOption | undefined;
|
||||
const yAxis = ensureArray(chartOptions.yAxis)[0] as YAXisOption | undefined;
|
||||
|
||||
// Chart2Music throws while validating a group with no points, which is what
|
||||
// placeholder, legend-hidden and all-null series turn into, so only offer it
|
||||
// the series carrying points it can read.
|
||||
const allSeries = ensureArray(chartOptions.series) as (
|
||||
HaECSeriesItem | undefined
|
||||
)[];
|
||||
const readable = allSeries.filter((s) => countNumericPoints(s?.data, 1));
|
||||
// A single point is not navigable, so it does not earn a focus stop either.
|
||||
if (countNavigablePoints(readable) < MIN_NAVIGABLE_POINTS) {
|
||||
return null;
|
||||
}
|
||||
const seriesIndex = readable.map((s) => allSeries.indexOf(s));
|
||||
|
||||
// Chart2Music always reads out an axis label, and the extension picks the wrong
|
||||
// axis to name when there is no category axis, so label both explicitly.
|
||||
const isTimeAxis = xAxis?.type === "time";
|
||||
const x = {
|
||||
label:
|
||||
xAxis?.name ||
|
||||
localize(
|
||||
isTimeAxis
|
||||
? "ui.components.history_charts.time"
|
||||
: "ui.components.history_charts.category"
|
||||
),
|
||||
// Time series carry raw timestamps, which would otherwise be announced as
|
||||
// epoch milliseconds.
|
||||
format: isTimeAxis
|
||||
? (value: number) => formatDateTime(new Date(value), locale, config)
|
||||
: undefined,
|
||||
};
|
||||
const y = {
|
||||
label: yAxis?.name || localize("ui.components.history_charts.value"),
|
||||
};
|
||||
|
||||
let connection: ReturnType<typeof connect>;
|
||||
try {
|
||||
connection = connect(chart, {
|
||||
cc: options.cc,
|
||||
seriesIndex,
|
||||
title: localize("ui.components.history_charts.chart"),
|
||||
lang: SONIFICATION_LANGUAGES.has(locale.language)
|
||||
? locale.language
|
||||
: "en",
|
||||
errorCallback: options.onError,
|
||||
axes: { x, y },
|
||||
});
|
||||
} catch (err) {
|
||||
options.onError(err instanceof Error ? err.message : String(err));
|
||||
return null;
|
||||
}
|
||||
if (!connection) {
|
||||
return null;
|
||||
}
|
||||
// Chart2Music bails out silently on mobile user agents, returning an instance
|
||||
// that never wired anything up. Turning the caption container into a live
|
||||
// region is the last thing it does, so use that as the "really connected" test
|
||||
// rather than leaving a focus stop that does nothing.
|
||||
if (!options.cc.hasAttribute("aria-live")) {
|
||||
connection.dispose();
|
||||
return null;
|
||||
}
|
||||
const connected = connection;
|
||||
|
||||
// The extension re-reads the chart from ECharts' own "finished" event. Run that
|
||||
// through a guard of our own so a conversion failure cannot escape into
|
||||
// ECharts' event dispatch and leave the chart half-rendered.
|
||||
const update = () => {
|
||||
try {
|
||||
connected.update();
|
||||
} catch (_err) {
|
||||
// Keep whatever Chart2Music last read successfully.
|
||||
}
|
||||
};
|
||||
chart.off("finished", connected.update);
|
||||
chart.on("finished", update);
|
||||
|
||||
return {
|
||||
update,
|
||||
dispose: () => {
|
||||
chart.off("finished", update);
|
||||
connected.dispose();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -22,6 +22,7 @@ import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { getAllGraphColors } from "../../common/color/colors";
|
||||
@@ -47,6 +48,8 @@ import { isMac } from "../../util/is_mac";
|
||||
import "../chips/ha-assist-chip";
|
||||
import "../ha-icon-button";
|
||||
import { formatTimeLabel } from "./axis-label";
|
||||
import type { ChartSonification } from "./chart-sonification";
|
||||
import { canSonifyChart, sonifyChart } from "./chart-sonification";
|
||||
import { downSampleLineData } from "./down-sample";
|
||||
import { wrapLitTooltipFormatter } from "./lit-tooltip-formatter";
|
||||
|
||||
@@ -146,6 +149,17 @@ export class HaChartBase extends LitElement {
|
||||
|
||||
@query(".chart") private _chartContainer?: HTMLDivElement;
|
||||
|
||||
@query(".sonification-output")
|
||||
private _sonificationOutput?: HTMLDivElement;
|
||||
|
||||
private _sonification?: ChartSonification;
|
||||
|
||||
@state() private _sonificationLoading = false;
|
||||
|
||||
@state() private _sonificationUnavailable = false;
|
||||
|
||||
@state() private _sonificationFocusHeld = false;
|
||||
|
||||
private _modifierPressed = false;
|
||||
|
||||
private _isTouchDevice = "ontouchstart" in window;
|
||||
@@ -198,6 +212,7 @@ export class HaChartBase extends LitElement {
|
||||
while (this._listeners.length) {
|
||||
this._listeners.pop()!();
|
||||
}
|
||||
this._disposeSonification();
|
||||
this.chart?.dispose();
|
||||
this.chart = undefined;
|
||||
this._originalZrFlush = undefined;
|
||||
@@ -312,6 +327,18 @@ export class HaChartBase extends LitElement {
|
||||
}
|
||||
if (changedProps.has("data") || changedProps.has("_hiddenDatasets")) {
|
||||
chartOptions.series = this._getSeries();
|
||||
// New data, or a series shown again, may well be convertible where the
|
||||
// last set was not.
|
||||
this._sonificationUnavailable = false;
|
||||
// The connection is built from the series that had data at the time, so
|
||||
// drop it and let the next focus rebuild it against the current set.
|
||||
if (
|
||||
this._sonification &&
|
||||
(changedProps.has("_hiddenDatasets") ||
|
||||
!canSonifyChart(this.data, this._hiddenDatasets))
|
||||
) {
|
||||
this._disposeSonification();
|
||||
}
|
||||
}
|
||||
if (changedProps.has("options")) {
|
||||
chartOptions = { ...chartOptions, ...this._createOptions() };
|
||||
@@ -337,6 +364,9 @@ export class HaChartBase extends LitElement {
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const sonifiable =
|
||||
!this._sonificationUnavailable &&
|
||||
canSonifyChart(this.data, this._hiddenDatasets);
|
||||
return html`
|
||||
<div
|
||||
class="container ${classMap({ "has-height": !!this.height })}"
|
||||
@@ -348,8 +378,23 @@ export class HaChartBase extends LitElement {
|
||||
height: this.height ? undefined : `${this._getDefaultHeight()}px`,
|
||||
})}
|
||||
>
|
||||
<div class="chart"></div>
|
||||
<div
|
||||
class="chart"
|
||||
role=${ifDefined(sonifiable ? "application" : undefined)}
|
||||
tabindex=${ifDefined(
|
||||
sonifiable ? "0" : this._sonificationFocusHeld ? "-1" : undefined
|
||||
)}
|
||||
aria-label=${ifDefined(
|
||||
sonifiable
|
||||
? this.hass.localize("ui.components.history_charts.chart")
|
||||
: undefined
|
||||
)}
|
||||
aria-busy=${ifDefined(this._sonificationLoading ? "true" : undefined)}
|
||||
@focus=${this._handleChartFocus}
|
||||
@blur=${this._handleChartBlur}
|
||||
></div>
|
||||
</div>
|
||||
<div class="sonification-output"></div>
|
||||
${this._renderLegend()}
|
||||
<div class="top-controls ${classMap({ small: this.smallControls })}">
|
||||
<slot name="search"></slot>
|
||||
@@ -521,6 +566,60 @@ export class HaChartBase extends LitElement {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Chart2Music adds ~45 kB gzipped, so it is only fetched once someone actually
|
||||
// moves keyboard focus into a chart.
|
||||
private async _handleChartFocus() {
|
||||
// Dropping tabindex off the active element resets focus to the document and
|
||||
// costs the user their place in the tab order, so stay programmatically
|
||||
// focusable for as long as we hold focus, however we stop being sonifiable.
|
||||
this._sonificationFocusHeld = true;
|
||||
if (this._sonification || this._sonificationLoading || !this.chart) {
|
||||
return;
|
||||
}
|
||||
this._sonificationLoading = true;
|
||||
try {
|
||||
const sonification = await sonifyChart(this.chart, {
|
||||
cc: this._sonificationOutput!,
|
||||
localize: this.hass.localize,
|
||||
locale: this.hass.locale,
|
||||
config: this.hass.config,
|
||||
onError: () => {
|
||||
// Charts the extension cannot describe stay silent rather than
|
||||
// dropping an error on someone who only pressed Tab.
|
||||
},
|
||||
});
|
||||
if (!this.isConnected || !this.chart) {
|
||||
sonification?.dispose();
|
||||
return;
|
||||
}
|
||||
if (!sonification) {
|
||||
// Nothing came back, so stop offering a focus stop that leads nowhere.
|
||||
this._sonificationUnavailable = true;
|
||||
return;
|
||||
}
|
||||
this._sonification = sonification;
|
||||
if (this.shadowRoot?.activeElement === this._chartContainer) {
|
||||
// Chart2Music reads its summary and key hints on focus, which already
|
||||
// happened while it was still being fetched.
|
||||
this._chartContainer!.dispatchEvent(new FocusEvent("focus"));
|
||||
}
|
||||
} catch (_err) {
|
||||
// Never let a failure here escape a focus handler. The tab stop stays, so
|
||||
// focusing the chart again retries.
|
||||
} finally {
|
||||
this._sonificationLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _handleChartBlur() {
|
||||
this._sonificationFocusHeld = false;
|
||||
}
|
||||
|
||||
private _disposeSonification() {
|
||||
this._sonification?.dispose();
|
||||
this._sonification = undefined;
|
||||
}
|
||||
|
||||
private _formatTimeLabel = (value: number | Date) =>
|
||||
formatTimeLabel(
|
||||
value,
|
||||
@@ -533,6 +632,9 @@ export class HaChartBase extends LitElement {
|
||||
if (this._loading) return;
|
||||
this._loading = true;
|
||||
try {
|
||||
// The connection holds a reference to the chart instance, so it cannot
|
||||
// outlive it. Focusing the chart again reconnects.
|
||||
this._disposeSonification();
|
||||
if (this.chart) {
|
||||
this.chart.dispose();
|
||||
}
|
||||
@@ -1450,6 +1552,23 @@ export class HaChartBase extends LitElement {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.chart:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
}
|
||||
/* Chart2Music renders its announcements here. It must stay in the layout for
|
||||
screen readers to pick up the live region, so hide it visually only. */
|
||||
.sonification-output {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
.top-controls {
|
||||
position: absolute;
|
||||
top: var(--ha-space-4);
|
||||
|
||||
@@ -163,12 +163,15 @@ export function generateStatisticsChartData(
|
||||
return;
|
||||
}
|
||||
const isLineChart = chartType === "line";
|
||||
// Points carry their time as epoch milliseconds, not Date objects:
|
||||
// ECharts accepts both, but Chart2Music only reads a numeric x, and a
|
||||
// Date would make it announce points by index instead of time.
|
||||
// For bar charts, optionally center the bar within its time range. The
|
||||
// centered time is shared by every series of this data point.
|
||||
const barTime =
|
||||
!isLineChart && centerBars
|
||||
? new Date((start.getTime() + end.getTime()) / 2)
|
||||
: start;
|
||||
? (start.getTime() + end.getTime()) / 2
|
||||
: start.getTime();
|
||||
// Whether a gap needs to be drawn before this data point (line charts).
|
||||
const drawGap =
|
||||
isLineChart &&
|
||||
@@ -182,10 +185,10 @@ export function generateStatisticsChartData(
|
||||
if (drawGap) {
|
||||
// if the end of the previous data doesn't match the start of the current data,
|
||||
// we have to draw a gap so add a value at the end time, and then an empty value.
|
||||
d.data!.push([prevEndTime!, ...prevValues![i]!]);
|
||||
d.data!.push([prevEndTime!, null]);
|
||||
d.data!.push([prevEndTime!.getTime(), ...prevValues![i]!]);
|
||||
d.data!.push([prevEndTime!.getTime(), null]);
|
||||
}
|
||||
d.data!.push([start, ...dataValue!]);
|
||||
d.data!.push([start.getTime(), ...dataValue!]);
|
||||
// For band-top rows dataValues[i] is [diff, top]; the actual Y is
|
||||
// the last element. For regular rows it's [value]. Same call works.
|
||||
trackY(dataValue[dataValue.length - 1]);
|
||||
@@ -387,7 +390,7 @@ export function generateStatisticsChartData(
|
||||
const lastValues = prevValues;
|
||||
if (chartType === "line" && lastEndTime && lastValues) {
|
||||
statDataSets.forEach((d, i) => {
|
||||
d.data!.push([lastEndTime, ...lastValues[i]!]);
|
||||
d.data!.push([lastEndTime.getTime(), ...lastValues[i]!]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -423,7 +426,7 @@ export function generateStatisticsChartData(
|
||||
} else {
|
||||
val.push(currentValue);
|
||||
}
|
||||
statDataSets[i].data!.push([now, ...val]);
|
||||
statDataSets[i].data!.push([now.getTime(), ...val]);
|
||||
trackY(val[val.length - 1]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,9 +7,12 @@ import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { EntityNameItem } from "../../common/entity/compute_entity_name_display";
|
||||
import {
|
||||
ENTITY_NAME_TYPES,
|
||||
type EntityNameItem,
|
||||
type EntityNameType,
|
||||
} from "../../common/entity/compute_entity_name_display";
|
||||
import { getEntityContext } from "../../common/entity/context/get_entity_context";
|
||||
import type { EntityNameType } from "../../common/translations/entity-state";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import "../chips/ha-assist-chip";
|
||||
@@ -35,9 +38,7 @@ const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
|
||||
const KNOWN_TYPES = new Set(["entity", "device", "area", "floor"]);
|
||||
|
||||
const UNIQUE_TYPES = new Set(["entity", "device", "area", "floor"]);
|
||||
const KNOWN_TYPES = new Set<string>(ENTITY_NAME_TYPES);
|
||||
|
||||
const formatOptionValue = (item: EntityNameItem) => {
|
||||
if (item.type === "text" && item.text) {
|
||||
@@ -387,6 +388,7 @@ export class HaEntityNamePicker extends LitElement {
|
||||
);
|
||||
|
||||
if (context.device) options.add("device");
|
||||
if (context.parentDevice) options.add("parent_device");
|
||||
if (context.area) options.add("area");
|
||||
if (context.floor) options.add("floor");
|
||||
return options;
|
||||
@@ -399,9 +401,7 @@ export class HaEntityNamePicker extends LitElement {
|
||||
|
||||
const types = this._validTypes(entityId);
|
||||
|
||||
const items = (
|
||||
["entity", "device", "area", "floor"] as const
|
||||
).map<PickerComboBoxItem>((name) => {
|
||||
const items = ENTITY_NAME_TYPES.map<PickerComboBoxItem>((name) => {
|
||||
const stateObj = this.hass.states[entityId];
|
||||
const isValid = types.has(name);
|
||||
const primary = this.hass.localize(
|
||||
@@ -464,7 +464,7 @@ export class HaEntityNamePicker extends LitElement {
|
||||
|
||||
const excludedValues = new Set(
|
||||
this._items
|
||||
.filter((item) => UNIQUE_TYPES.has(item.type))
|
||||
.filter((item) => KNOWN_TYPES.has(item.type))
|
||||
.map((item) => formatOptionValue(item))
|
||||
);
|
||||
|
||||
|
||||
@@ -178,6 +178,17 @@ export class HaStateContentPicker extends LitElement {
|
||||
),
|
||||
});
|
||||
}
|
||||
if (context.parentDevice) {
|
||||
contextItems.push({
|
||||
id: "parent_device_name",
|
||||
primary: this.hass.localize(
|
||||
"ui.components.state-content-picker.parent_device_name"
|
||||
),
|
||||
sorting_label: this.hass.localize(
|
||||
"ui.components.state-content-picker.parent_device_name"
|
||||
),
|
||||
});
|
||||
}
|
||||
if (context.area) {
|
||||
contextItems.push({
|
||||
id: "area_name",
|
||||
|
||||
@@ -52,6 +52,7 @@ const SEARCH_KEYS = [
|
||||
{ name: "search_labels.entityName", weight: 10 },
|
||||
{ name: "search_labels.friendlyName", weight: 9 },
|
||||
{ name: "search_labels.deviceName", weight: 8 },
|
||||
{ name: "search_labels.parentDeviceName", weight: 6 },
|
||||
{ name: "search_labels.areaName", weight: 6 },
|
||||
{ name: "search_labels.domainName", weight: 4 },
|
||||
{ name: "statisticId", weight: 3 },
|
||||
@@ -292,17 +293,27 @@ export class HaStatisticPicker extends LitElement {
|
||||
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || id;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
@@ -318,6 +329,7 @@ export class HaStatisticPicker extends LitElement {
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
friendlyName,
|
||||
},
|
||||
@@ -395,14 +407,20 @@ export class HaStatisticPicker extends LitElement {
|
||||
const stateObj = this.hass.states[statisticId];
|
||||
|
||||
if (stateObj) {
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
|
||||
const isRTL = computeRTL(
|
||||
this.hass.language,
|
||||
@@ -410,7 +428,11 @@ export class HaStatisticPicker extends LitElement {
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || statisticId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
@@ -427,6 +449,7 @@ export class HaStatisticPicker extends LitElement {
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
friendlyName,
|
||||
statisticId,
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { mdiAlertOctagram, mdiCheckBold } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import "./ha-spinner";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
type ActionResult = "success" | "error";
|
||||
|
||||
// Keep in sync with ha-progress-button
|
||||
const RESULT_DURATION = 2000;
|
||||
|
||||
// Long enough that fast actions go straight to their result without a flash
|
||||
const SPINNER_DELAY = 150;
|
||||
|
||||
/**
|
||||
* Home Assistant action result component
|
||||
*
|
||||
* @element ha-action-result
|
||||
*
|
||||
* @summary
|
||||
* Wraps the content of an action trigger, for example an `ha-control-button`,
|
||||
* and swaps it for a spinner while a slow action runs and for a success or
|
||||
* error icon once it settles.
|
||||
*
|
||||
* @slot - Content of the trigger.
|
||||
*/
|
||||
@customElement("ha-action-result")
|
||||
export class HaActionResult extends LitElement {
|
||||
@state() private _loading = false;
|
||||
|
||||
@state() private _showSpinner = false;
|
||||
|
||||
@state() private _result?: ActionResult;
|
||||
|
||||
private _timeout?: number;
|
||||
|
||||
private _spinnerTimeout?: number;
|
||||
|
||||
public get busy(): boolean {
|
||||
return this._loading;
|
||||
}
|
||||
|
||||
public async run(action: Promise<unknown>): Promise<void> {
|
||||
clearTimeout(this._timeout);
|
||||
clearTimeout(this._spinnerTimeout);
|
||||
this._result = undefined;
|
||||
this._loading = true;
|
||||
this._spinnerTimeout = window.setTimeout(() => {
|
||||
this._showSpinner = true;
|
||||
}, SPINNER_DELAY);
|
||||
try {
|
||||
await action;
|
||||
this._result = "success";
|
||||
} catch (_err) {
|
||||
this._result = "error";
|
||||
} finally {
|
||||
clearTimeout(this._spinnerTimeout);
|
||||
this._loading = false;
|
||||
this._showSpinner = false;
|
||||
this._timeout = window.setTimeout(() => {
|
||||
this._result = undefined;
|
||||
}, RESULT_DURATION);
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
clearTimeout(this._timeout);
|
||||
clearTimeout(this._spinnerTimeout);
|
||||
this._showSpinner = false;
|
||||
this._result = undefined;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const busy = this._showSpinner || this._result !== undefined;
|
||||
|
||||
return html`
|
||||
<span class="content ${busy ? "hidden" : ""}"><slot></slot></span>
|
||||
${
|
||||
busy
|
||||
? html`<div class="indicator">${this._renderIndicator()}</div>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderIndicator() {
|
||||
if (!this._result) {
|
||||
return html`<ha-spinner></ha-spinner>`;
|
||||
}
|
||||
return html`
|
||||
<ha-svg-icon
|
||||
class=${this._result}
|
||||
.path=${this._result === "success" ? mdiCheckBold : mdiAlertOctagram}
|
||||
></ha-svg-icon>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
/* Prefer no box so the host inherits the layout of the slot it sits in.
|
||||
A ::slotted() rule in the host component can still override this. */
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
.content {
|
||||
transition: opacity var(--ha-animation-duration-instant) ease-in-out;
|
||||
}
|
||||
.content.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
.indicator {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fade-in var(--ha-animation-duration-instant) ease-in-out;
|
||||
}
|
||||
ha-spinner {
|
||||
--ha-spinner-size: var(--mdc-icon-size, 24px);
|
||||
--track-width: 2px;
|
||||
}
|
||||
/* Overshoot so the icon lands with a small pop */
|
||||
ha-svg-icon {
|
||||
animation: scale var(--ha-animation-duration-fast)
|
||||
cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
ha-svg-icon.success {
|
||||
color: var(--ha-color-on-success-quiet);
|
||||
}
|
||||
ha-svg-icon.error {
|
||||
color: var(--ha-color-on-danger-quiet);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-action-result": HaActionResult;
|
||||
}
|
||||
}
|
||||
@@ -197,17 +197,27 @@ export class HaAreaControlsPicker extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this.hass!.entities,
|
||||
this.hass!.devices,
|
||||
this.hass!.areas,
|
||||
this.hass!.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this.hass!.entities,
|
||||
this.hass!.devices,
|
||||
this.hass!.areas,
|
||||
this.hass!.floors
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { transform } from "../common/decorators/transform";
|
||||
import { supportsFeature } from "../common/entity/supports-feature";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import {
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
import {
|
||||
configContext,
|
||||
connectionContext,
|
||||
internationalizationContext,
|
||||
statesContext,
|
||||
} from "../data/context";
|
||||
import { ConversationEntityFeature } from "../data/conversation";
|
||||
@@ -33,8 +35,13 @@ import type {
|
||||
HomeAssistant,
|
||||
HomeAssistantConfig,
|
||||
HomeAssistantConnection,
|
||||
HomeAssistantInternationalization,
|
||||
} from "../types";
|
||||
import { AudioRecorder } from "../util/audio-recorder";
|
||||
import {
|
||||
findAvailableLanguage,
|
||||
getTranslation,
|
||||
} from "../util/common-translation";
|
||||
import { documentationUrl } from "../util/documentation-url";
|
||||
import "./ha-alert";
|
||||
import "./ha-markdown";
|
||||
@@ -67,6 +74,17 @@ export const assistPipelineChanged = (
|
||||
current: AssistPipeline | undefined
|
||||
): boolean => previous?.id !== current?.id;
|
||||
|
||||
export const greetingTranslationLanguage = (
|
||||
pipelineLanguage: string | undefined,
|
||||
interfaceLanguage: string | undefined
|
||||
): string | undefined => {
|
||||
if (!pipelineLanguage || pipelineLanguage === interfaceLanguage) {
|
||||
return undefined;
|
||||
}
|
||||
const language = findAvailableLanguage(pipelineLanguage);
|
||||
return language && language !== interfaceLanguage ? language : undefined;
|
||||
};
|
||||
|
||||
@customElement("ha-assist-chat")
|
||||
export class HaAssistChat extends LitElement {
|
||||
@property({ attribute: false }) public pipeline?: AssistPipeline;
|
||||
@@ -101,6 +119,13 @@ export class HaAssistChat extends LitElement {
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
@transform<HomeAssistantInternationalization, string>({
|
||||
transformer: ({ language }) => language,
|
||||
})
|
||||
private _language!: string;
|
||||
|
||||
@state()
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
private _states!: HomeAssistant["states"];
|
||||
@@ -115,6 +140,8 @@ export class HaAssistChat extends LitElement {
|
||||
|
||||
private _conversationId: string | null = null;
|
||||
|
||||
private _greetingLoadToken = 0;
|
||||
|
||||
private _initialPromptSubmitted = false;
|
||||
|
||||
private _audioRecorder?: AudioRecorder;
|
||||
@@ -131,17 +158,44 @@ export class HaAssistChat extends LitElement {
|
||||
(changedProperties.has("pipeline") &&
|
||||
assistPipelineChanged(changedProperties.get("pipeline"), this.pipeline))
|
||||
) {
|
||||
this._conversation = [
|
||||
{
|
||||
who: "hass",
|
||||
text: this._localize("ui.dialogs.voice_command.how_can_i_help"),
|
||||
thinking: "",
|
||||
tool_calls: {},
|
||||
},
|
||||
];
|
||||
this._conversation = [];
|
||||
this._loadGreeting();
|
||||
}
|
||||
}
|
||||
|
||||
private async _loadGreeting(): Promise<void> {
|
||||
const token = ++this._greetingLoadToken;
|
||||
const language = greetingTranslationLanguage(
|
||||
this.pipeline?.language,
|
||||
this._language
|
||||
);
|
||||
let greeting: string | undefined;
|
||||
if (language) {
|
||||
try {
|
||||
const result = await getTranslation(null, language, false);
|
||||
if (result.language === language) {
|
||||
greeting = result.data["ui.dialogs.voice_command.how_can_i_help"];
|
||||
}
|
||||
} catch (_err) {
|
||||
// Translation failed to load; fall back to the interface language.
|
||||
}
|
||||
}
|
||||
if (token !== this._greetingLoadToken) {
|
||||
// The pipeline changed while loading; a newer load owns the greeting.
|
||||
return;
|
||||
}
|
||||
this._conversation = [
|
||||
{
|
||||
who: "hass",
|
||||
text:
|
||||
greeting || this._localize("ui.dialogs.voice_command.how_can_i_help"),
|
||||
thinking: "",
|
||||
tool_calls: {},
|
||||
},
|
||||
...this._conversation,
|
||||
];
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues<this>): void {
|
||||
super.firstUpdated(changedProperties);
|
||||
if (
|
||||
@@ -157,7 +211,7 @@ export class HaAssistChat extends LitElement {
|
||||
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
super.updated(changedProps);
|
||||
if (changedProps.has("_conversation")) {
|
||||
if (changedProps.has("_conversation") && this._conversation.length) {
|
||||
this._scrollMessagesBottom();
|
||||
}
|
||||
if (
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { computeAttributeNameDisplay } from "../common/entity/compute_attribute_display";
|
||||
import { computeStateDomain } from "../common/entity/compute_state_domain";
|
||||
import {
|
||||
STATE_ATTRIBUTES,
|
||||
STATE_ATTRIBUTES_DOMAIN_CLASS,
|
||||
} from "../data/entity/entity_attributes";
|
||||
import { haStyle } from "../resources/styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-attribute-value";
|
||||
import "./ha-expansion-panel";
|
||||
|
||||
@customElement("ha-attributes")
|
||||
class HaAttributes extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public stateObj?: HassEntity;
|
||||
|
||||
@property({ attribute: "extra-filters" }) public extraFilters?: string;
|
||||
|
||||
@state() private _expanded = false;
|
||||
|
||||
private get _filteredAttributes() {
|
||||
return this._computeDisplayAttributes(
|
||||
STATE_ATTRIBUTES.concat(
|
||||
this.extraFilters ? this.extraFilters.split(",") : [],
|
||||
(this.stateObj &&
|
||||
STATE_ATTRIBUTES_DOMAIN_CLASS[computeStateDomain(this.stateObj)]?.[
|
||||
this.stateObj.attributes?.device_class
|
||||
]) ||
|
||||
[]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
if (
|
||||
changedProperties.has("extraFilters") ||
|
||||
changedProperties.has("stateObj")
|
||||
) {
|
||||
this.toggleAttribute("empty", this._filteredAttributes.length === 0);
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.stateObj) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const attributes = this._filteredAttributes;
|
||||
|
||||
if (attributes.length === 0) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-expansion-panel
|
||||
.header=${this.hass.localize(
|
||||
"ui.components.attributes.expansion_header"
|
||||
)}
|
||||
outlined
|
||||
@expanded-will-change=${this._expandedChanged}
|
||||
>
|
||||
<div class="attribute-container">
|
||||
${
|
||||
this._expanded
|
||||
? html`
|
||||
${attributes.map(
|
||||
(attribute) => html`
|
||||
<div class="data-entry">
|
||||
<div class="key">
|
||||
${computeAttributeNameDisplay(
|
||||
this.hass.localize,
|
||||
this.stateObj!,
|
||||
this.hass.entities,
|
||||
attribute
|
||||
)}
|
||||
</div>
|
||||
<div class="value">
|
||||
<ha-attribute-value
|
||||
.attribute=${attribute}
|
||||
.stateObj=${this.stateObj}
|
||||
></ha-attribute-value>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
${
|
||||
this.stateObj.attributes.attribution
|
||||
? html`
|
||||
<div class="attribution">
|
||||
${this.stateObj.attributes.attribution}
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
css`
|
||||
.attribute-container {
|
||||
margin-bottom: 8px;
|
||||
direction: ltr;
|
||||
}
|
||||
.data-entry {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.data-entry .value {
|
||||
max-width: 60%;
|
||||
overflow-wrap: break-word;
|
||||
text-align: right;
|
||||
}
|
||||
.key {
|
||||
flex-grow: 1;
|
||||
}
|
||||
.attribution {
|
||||
color: var(--secondary-text-color);
|
||||
text-align: center;
|
||||
margin-top: 16px;
|
||||
}
|
||||
hr {
|
||||
border-color: var(--divider-color);
|
||||
border-bottom: none;
|
||||
margin: 16px 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
private _computeDisplayAttributes(filtersArray: string[]): string[] {
|
||||
if (!this.stateObj) {
|
||||
return [];
|
||||
}
|
||||
return Object.keys(this.stateObj.attributes).filter(
|
||||
(key) => filtersArray.indexOf(key) === -1
|
||||
);
|
||||
}
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this._expanded = ev.detail.expanded;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-attributes": HaAttributes;
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export type Appearance = "accent" | "filled" | "outlined" | "plain";
|
||||
*
|
||||
* @attr {("xs"|"s"|"m"|"l"|"xl")} size - Sets the button size.
|
||||
* @attr {("brand"|"neutral"|"danger"|"warning"|"success")} variant - Sets the button color variant. "primary" is default.
|
||||
* @attr {("accent"|"filled"|"plain")} appearance - Sets the button appearance.
|
||||
* @attr {("accent"|"filled"|"outlined"|"plain")} appearance - Sets the button appearance.
|
||||
* @attr {boolean} loading - shows a loading indicator instead of the buttons label and disable buttons click.
|
||||
* @attr {boolean} disabled - Disables the button and prevents user interaction.
|
||||
*/
|
||||
@@ -199,6 +199,7 @@ export class HaButton extends Button {
|
||||
:host([appearance~="outlined"]) .button.disabled {
|
||||
background-color: transparent;
|
||||
color: var(--ha-color-on-disabled-quiet);
|
||||
border-color: var(--ha-color-on-disabled-quiet);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
|
||||
@@ -761,6 +761,9 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
const deviceName = context.device
|
||||
? computeDeviceName(context.device)
|
||||
: undefined;
|
||||
const parentDeviceName = context.parentDevice
|
||||
? computeDeviceName(context.parentDevice)
|
||||
: undefined;
|
||||
const areaName = context.area ? computeAreaName(context.area) : undefined;
|
||||
const floorName = context.floor
|
||||
? computeFloorName(context.floor)
|
||||
@@ -788,6 +791,15 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
});
|
||||
}
|
||||
|
||||
if (parentDeviceName) {
|
||||
completionItems.push({
|
||||
label: this._i18n!.localize(
|
||||
"ui.components.device-picker.parent_device"
|
||||
),
|
||||
value: parentDeviceName,
|
||||
});
|
||||
}
|
||||
|
||||
if (deviceName) {
|
||||
completionItems.push({
|
||||
label: this._i18n!.localize("ui.components.device-picker.device"),
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
import { DIRECTION_ALL, Manager, Pan, Tap } from "@egjs/hammerjs";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { rgb2hex } from "../common/color/convert-color";
|
||||
import {
|
||||
DEFAULT_MAX_KELVIN,
|
||||
DEFAULT_MIN_KELVIN,
|
||||
temperature2rgb,
|
||||
} from "../common/color/convert-light-color";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { mainWindow } from "../common/dom/get_main_window";
|
||||
import { generateColorTemperatureGradient } from "../dialogs/more-info/components/lights/light-color-temp-picker";
|
||||
|
||||
const clamp = (value: number, min: number, max: number) =>
|
||||
Math.min(max, Math.max(min, value));
|
||||
|
||||
// Dark end of the vertical brightness gradient; not fully black so the
|
||||
// temperature hue stays visible at the bottom of the pad
|
||||
const PAD_DARK_COLOR = "rgb(26, 26, 26)";
|
||||
|
||||
// The marker color's luminance scales with brightness down to this floor,
|
||||
// so the marker never renders fully black at minimum brightness
|
||||
const MARKER_LUMINANCE_FLOOR = 0.2;
|
||||
|
||||
@customElement("ha-color-temp-brightness-picker")
|
||||
export class HaColorTempBrightnessPicker extends LitElement {
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public disabled = false;
|
||||
|
||||
/** [color temperature in kelvin, brightness percentage] */
|
||||
@property({ attribute: false })
|
||||
public value?: [number, number];
|
||||
|
||||
@property({ attribute: false })
|
||||
public minKelvin?: number;
|
||||
|
||||
@property({ attribute: false })
|
||||
public maxKelvin?: number;
|
||||
|
||||
@query("#pad") private _pad?: HTMLElement;
|
||||
|
||||
private _mc?: HammerManager;
|
||||
|
||||
@state()
|
||||
private _pressed?: string;
|
||||
|
||||
// Normalized physical position on the pad, [0, 1] left-to-right/top-to-bottom
|
||||
@state()
|
||||
private _cursorPosition?: [number, number];
|
||||
|
||||
@state()
|
||||
private _localValue?: [number, number];
|
||||
|
||||
private get _minKelvin() {
|
||||
return this.minKelvin ?? DEFAULT_MIN_KELVIN;
|
||||
}
|
||||
|
||||
private get _maxKelvin() {
|
||||
return this.maxKelvin ?? DEFAULT_MAX_KELVIN;
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>): void {
|
||||
super.firstUpdated(changedProps);
|
||||
this._setupListeners();
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._setupListeners();
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._destroyListeners();
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>): void {
|
||||
super.willUpdate(changedProps);
|
||||
const rangeChanged =
|
||||
changedProps.has("minKelvin") || changedProps.has("maxKelvin");
|
||||
if (
|
||||
rangeChanged ||
|
||||
(changedProps.has("value") &&
|
||||
(this._localValue?.[0] !== this.value?.[0] ||
|
||||
this._localValue?.[1] !== this.value?.[1]))
|
||||
) {
|
||||
this._resetPosition();
|
||||
}
|
||||
}
|
||||
|
||||
private _setupListeners() {
|
||||
if (this._pad && !this._mc) {
|
||||
this._mc = new Manager(this._pad);
|
||||
this._mc.add(
|
||||
new Pan({
|
||||
direction: DIRECTION_ALL,
|
||||
enable: true,
|
||||
})
|
||||
);
|
||||
|
||||
this._mc.add(new Tap({ event: "singletap" }));
|
||||
|
||||
let savedPosition;
|
||||
this._mc.on("panstart", (e) => {
|
||||
if (this.disabled) return;
|
||||
this._pressed = e.pointerType;
|
||||
savedPosition = this._cursorPosition;
|
||||
});
|
||||
this._mc.on("pancancel", () => {
|
||||
if (this.disabled) return;
|
||||
this._pressed = undefined;
|
||||
this._cursorPosition = savedPosition;
|
||||
});
|
||||
this._mc.on("panmove", (e) => {
|
||||
if (this.disabled) return;
|
||||
this._cursorPosition = this._getPositionFromEvent(e);
|
||||
this._localValue = this._getValueFromCoord(...this._cursorPosition);
|
||||
fireEvent(this, "cursor-moved", { value: this._localValue });
|
||||
});
|
||||
this._mc.on("panend", (e) => {
|
||||
if (this.disabled) return;
|
||||
this._pressed = undefined;
|
||||
this._cursorPosition = this._getPositionFromEvent(e);
|
||||
this._localValue = this._getValueFromCoord(...this._cursorPosition);
|
||||
fireEvent(this, "cursor-moved", { value: undefined });
|
||||
fireEvent(this, "value-changed", { value: this._localValue });
|
||||
});
|
||||
|
||||
this._mc.on("singletap", (e) => {
|
||||
if (this.disabled) return;
|
||||
this._cursorPosition = this._getPositionFromEvent(e);
|
||||
this._localValue = this._getValueFromCoord(...this._cursorPosition);
|
||||
fireEvent(this, "value-changed", { value: this._localValue });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _destroyListeners() {
|
||||
if (this._mc) {
|
||||
this._mc.destroy();
|
||||
this._mc = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _resetPosition() {
|
||||
if (this.value === undefined) {
|
||||
this._cursorPosition = undefined;
|
||||
this._localValue = undefined;
|
||||
return;
|
||||
}
|
||||
this._cursorPosition = this._getCoordsFromValue(this.value);
|
||||
this._localValue = this.value;
|
||||
}
|
||||
|
||||
private get _rtl() {
|
||||
return mainWindow.document.dir === "rtl";
|
||||
}
|
||||
|
||||
private _getCoordsFromValue = (value: [number, number]): [number, number] => {
|
||||
const xValue =
|
||||
(clamp(value[0], this._minKelvin, this._maxKelvin) - this._minKelvin) /
|
||||
(this._maxKelvin - this._minKelvin);
|
||||
const x = this._rtl ? 1 - xValue : xValue;
|
||||
const y = 1 - clamp(value[1], 1, 100) / 100;
|
||||
return [x, y];
|
||||
};
|
||||
|
||||
private _getValueFromCoord = (x: number, y: number): [number, number] => {
|
||||
const xValue = this._rtl ? 1 - x : x;
|
||||
const kelvin = Math.round(
|
||||
this._minKelvin + xValue * (this._maxKelvin - this._minKelvin)
|
||||
);
|
||||
const brightness = clamp(Math.round((1 - y) * 100), 1, 100);
|
||||
return [kelvin, brightness];
|
||||
};
|
||||
|
||||
private _getPositionFromEvent = (e: HammerInput): [number, number] => {
|
||||
const boundingRect = this._pad!.getBoundingClientRect();
|
||||
const x = clamp(
|
||||
(e.center.x - boundingRect.left) / boundingRect.width,
|
||||
0,
|
||||
1
|
||||
);
|
||||
const y = clamp(
|
||||
(e.center.y - boundingRect.top) / boundingRect.height,
|
||||
0,
|
||||
1
|
||||
);
|
||||
return [x, y];
|
||||
};
|
||||
|
||||
private _generateTemperatureGradient = memoizeOne(
|
||||
(min: number, max: number) => generateColorTemperatureGradient(min, max)
|
||||
);
|
||||
|
||||
render() {
|
||||
const gradient = this._generateTemperatureGradient(
|
||||
this._minKelvin,
|
||||
this._maxKelvin
|
||||
);
|
||||
|
||||
const [x, y] = this._cursorPosition ?? [0, 0];
|
||||
|
||||
const markerScale = this._pressed
|
||||
? this._pressed === "touch"
|
||||
? "2.5"
|
||||
: "1.5"
|
||||
: "1";
|
||||
const markerOffset = this._pressed === "touch" ? "0px, -24px" : "0px, 0px";
|
||||
|
||||
let markerColor = "#ffffff";
|
||||
if (this._localValue) {
|
||||
const rgb = temperature2rgb(this._localValue[0]);
|
||||
const factor =
|
||||
MARKER_LUMINANCE_FLOOR +
|
||||
(1 - MARKER_LUMINANCE_FLOOR) * (this._localValue[1] / 100);
|
||||
markerColor = rgb2hex([
|
||||
Math.round(rgb[0] * factor),
|
||||
Math.round(rgb[1] * factor),
|
||||
Math.round(rgb[2] * factor),
|
||||
]);
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="container ${classMap({ pressed: Boolean(this._pressed) })}">
|
||||
<div
|
||||
id="pad"
|
||||
style=${styleMap({
|
||||
"background-image": `linear-gradient(to top, ${PAD_DARK_COLOR}, white), linear-gradient(to ${this._rtl ? "left" : "right"}, ${gradient})`,
|
||||
})}
|
||||
></div>
|
||||
<div
|
||||
class="cursor"
|
||||
style=${styleMap({
|
||||
left: `${x * 100}%`,
|
||||
top: `${y * 100}%`,
|
||||
visibility: this._cursorPosition ? undefined : "hidden",
|
||||
})}
|
||||
>
|
||||
<div
|
||||
class="marker"
|
||||
style=${styleMap({
|
||||
"background-color": markerColor,
|
||||
transform: `translate(${markerOffset}) scale(${markerScale})`,
|
||||
})}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
outline: none;
|
||||
}
|
||||
.container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
#pad {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
clip-path: inset(0 round var(--ha-border-radius-6xl));
|
||||
background-blend-mode: multiply;
|
||||
background-repeat: no-repeat;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
}
|
||||
:host([disabled]) #pad {
|
||||
cursor: initial;
|
||||
filter: grayscale(1) opacity(0.5);
|
||||
}
|
||||
.cursor {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.marker {
|
||||
position: absolute;
|
||||
top: -16px;
|
||||
left: -16px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
border: 2px solid white;
|
||||
box-shadow:
|
||||
0 1px 4px rgba(0, 0, 0, 0.3),
|
||||
0 1px 6px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.container:not(.pressed) .marker {
|
||||
transition:
|
||||
transform var(--ha-animation-duration-instant) ease-in-out,
|
||||
background-color var(--ha-animation-duration-instant) ease-in-out;
|
||||
}
|
||||
.container:not(.pressed) .cursor {
|
||||
transition:
|
||||
left var(--ha-animation-duration-fast) ease-in-out,
|
||||
top var(--ha-animation-duration-fast) ease-in-out;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-color-temp-brightness-picker": HaColorTempBrightnessPicker;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,12 @@ import type { SelectedDetail } from "@material/mwc-list";
|
||||
import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
} from "../common/controllers/filter-panel-controller";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
@@ -34,11 +39,11 @@ export class HaFilterBlueprints extends LitElement {
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public expanded = false;
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
|
||||
@state() private _blueprints?: Blueprints;
|
||||
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
private _content = createRef<HTMLElement>();
|
||||
|
||||
private _panel = new FilterPanelController(this, this._content);
|
||||
|
||||
public willUpdate(properties: PropertyValues<this>) {
|
||||
super.willUpdate(properties);
|
||||
@@ -56,7 +61,6 @@ export class HaFilterBlueprints extends LitElement {
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-will-change=${this._expandedWillChange}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
@@ -71,29 +75,38 @@ export class HaFilterBlueprints extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this._blueprints && this._shouldRender
|
||||
? html`
|
||||
<ha-list
|
||||
@selected=${this._blueprintsSelected}
|
||||
multi
|
||||
class="ha-scrollbar"
|
||||
>
|
||||
${Object.entries(this._blueprints).map(([id, blueprint]) =>
|
||||
"error" in blueprint
|
||||
? nothing
|
||||
: html`<ha-check-list-item
|
||||
.value=${id}
|
||||
.selected=${(this.value || []).includes(id)}
|
||||
>
|
||||
${blueprint.metadata.name || id}
|
||||
</ha-check-list-item>`
|
||||
)}
|
||||
</ha-list>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-expansion-panel>
|
||||
${
|
||||
this._panel.showContent
|
||||
? html`
|
||||
<div class="content" ${ref(this._content)}>
|
||||
${
|
||||
this._blueprints
|
||||
? html`
|
||||
<ha-list
|
||||
@selected=${this._blueprintsSelected}
|
||||
multi
|
||||
class="ha-scrollbar"
|
||||
>
|
||||
${Object.entries(this._blueprints).map(
|
||||
([id, blueprint]) =>
|
||||
"error" in blueprint
|
||||
? nothing
|
||||
: html`<ha-check-list-item
|
||||
.value=${id}
|
||||
.selected=${(this.value || []).includes(id)}
|
||||
>
|
||||
${blueprint.metadata.name || id}
|
||||
</ha-check-list-item>`
|
||||
)}
|
||||
</ha-list>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -104,19 +117,6 @@ export class HaFilterBlueprints extends LitElement {
|
||||
this._blueprints = await fetchBlueprints(this.hass, this.type);
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (this.narrow || !this.expanded) return;
|
||||
this._list!.style.height = `${this.clientHeight - 49}px`;
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev) {
|
||||
this._shouldRender = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
@@ -191,17 +191,11 @@ export class HaFilterBlueprints extends LitElement {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
filterPanelStyles,
|
||||
css`
|
||||
:host {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
ha-list {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
|
||||
@@ -8,9 +8,14 @@ import {
|
||||
mdiTag,
|
||||
} from "@mdi/js";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
} from "../common/controllers/filter-panel-controller";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { stopPropagation } from "../common/dom/stop_propagation";
|
||||
import type { CategoryRegistryEntry } from "../data/category_registry";
|
||||
@@ -47,9 +52,9 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _categories: CategoryRegistryEntry[] = [];
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
private _content = createRef<HTMLElement>();
|
||||
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
private _panel = new FilterPanelController(this, this._content);
|
||||
|
||||
protected hassSubscribeRequiredHostProps = ["scope"];
|
||||
|
||||
@@ -70,7 +75,6 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-will-change=${this._expandedWillChange}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
@@ -85,9 +89,11 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this._shouldRender
|
||||
? html`
|
||||
</ha-expansion-panel>
|
||||
${
|
||||
this._panel.showContent
|
||||
? html`
|
||||
<div class="content" ${ref(this._content)}>
|
||||
<ha-list
|
||||
@selected=${this._categorySelected}
|
||||
class="ha-scrollbar"
|
||||
@@ -158,34 +164,17 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
</ha-list-item>`
|
||||
)}
|
||||
</ha-list>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-expansion-panel>
|
||||
${
|
||||
this.expanded
|
||||
? html`<ha-list-item
|
||||
graphic="icon"
|
||||
@click=${this._addCategory}
|
||||
class="add"
|
||||
>
|
||||
<ha-svg-icon slot="graphic" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize("ui.panel.config.category.editor.add")}
|
||||
</ha-list-item>`
|
||||
<ha-list-item graphic="icon" @click=${this._addCategory}>
|
||||
<ha-svg-icon slot="graphic" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize("ui.panel.config.category.editor.add")}
|
||||
</ha-list-item>
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this._list!.style.height = `${this.clientHeight - (49 + 48)}px`;
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
private _handleAction(ev: HaDropdownSelectEvent) {
|
||||
const categoryId = (ev.currentTarget as any).categoryId;
|
||||
const action = ev.detail.item.value;
|
||||
@@ -244,10 +233,6 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev) {
|
||||
this._shouldRender = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
@@ -287,19 +272,8 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
filterPanelStyles,
|
||||
css`
|
||||
:host {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
position: relative;
|
||||
}
|
||||
:host([expanded]) {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -325,6 +299,8 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
ha-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
--mdc-list-item-meta-size: auto;
|
||||
--mdc-list-side-padding-right: var(--ha-space-1);
|
||||
--mdc-list-side-padding-left: var(--ha-space-4);
|
||||
@@ -339,12 +315,6 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
.warning {
|
||||
color: var(--error-color);
|
||||
}
|
||||
.add {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -3,7 +3,12 @@ import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import memoizeOne from "memoize-one";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
} from "../common/controllers/filter-panel-controller";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeDeviceNameDisplay } from "../common/entity/compute_device_name";
|
||||
@@ -59,13 +64,15 @@ export class HaFilterDevices extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
|
||||
@state() private _filter?: string;
|
||||
|
||||
@query("ha-list-selectable-virtualized")
|
||||
private _listElement?: HaListSelectableVirtualized;
|
||||
|
||||
private _content = createRef<HTMLElement>();
|
||||
|
||||
private _panel = new FilterPanelController(this, this._content);
|
||||
|
||||
public willUpdate(properties: PropertyValues<this>) {
|
||||
super.willUpdate(properties);
|
||||
|
||||
@@ -77,26 +84,11 @@ export class HaFilterDevices extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded || !this._listElement) {
|
||||
return;
|
||||
}
|
||||
this._listElement.style.height = `${this.clientHeight - 49 - 4 - 38}px`;
|
||||
// 49px - height of a header + 1px
|
||||
// 4px - padding-top of the search-input
|
||||
// 38px - height of the search input
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-will-change=${this._expandedWillChange}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
@@ -112,31 +104,33 @@ export class HaFilterDevices extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this._shouldRender
|
||||
? html`<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
@keydown=${this._handleSearchKeydown}
|
||||
>
|
||||
</ha-input-search>
|
||||
<ha-list-selectable-virtualized
|
||||
multi
|
||||
.rows=${this._devices(
|
||||
this._devicesReg,
|
||||
this._filter || "",
|
||||
this._localize,
|
||||
this._states,
|
||||
this._i18n.locale.language
|
||||
)}
|
||||
.rowRenderer=${this._renderItem}
|
||||
@ha-list-item-selected=${this._handleAdded}
|
||||
@ha-list-item-deselected=${this._handleRemoved}
|
||||
></ha-list-selectable-virtualized>`
|
||||
: nothing
|
||||
}
|
||||
</ha-expansion-panel>
|
||||
${
|
||||
this._panel.showContent
|
||||
? html`<div class="content" ${ref(this._content)}>
|
||||
<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
@keydown=${this._handleSearchKeydown}
|
||||
>
|
||||
</ha-input-search>
|
||||
<ha-list-selectable-virtualized
|
||||
multi
|
||||
.rows=${this._devices(
|
||||
this._devicesReg,
|
||||
this._filter || "",
|
||||
this._localize,
|
||||
this._states,
|
||||
this._i18n.locale.language
|
||||
)}
|
||||
.rowRenderer=${this._renderItem}
|
||||
@ha-list-item-selected=${this._handleAdded}
|
||||
@ha-list-item-deselected=${this._handleRemoved}
|
||||
></ha-list-selectable-virtualized>
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -177,10 +171,6 @@ export class HaFilterDevices extends LitElement {
|
||||
this.value = (this.value ?? []).filter((deviceId) => deviceId !== id);
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev) {
|
||||
this._shouldRender = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
@@ -271,58 +261,43 @@ export class HaFilterDevices extends LitElement {
|
||||
this._listElement?.clearSelection();
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
}
|
||||
:host([expanded]) ha-expansion-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
ha-list-selectable-virtualized {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.header ha-icon-button {
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
margin-inline-start: 8px;
|
||||
margin-inline-end: 0;
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
padding: 0px 2px;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
ha-input-search {
|
||||
display: block;
|
||||
padding: var(--ha-space-1) var(--ha-space-2) 0;
|
||||
}
|
||||
`;
|
||||
static styles = [
|
||||
filterPanelStyles,
|
||||
css`
|
||||
ha-list-selectable-virtualized {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.header ha-icon-button {
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
margin-inline-start: 8px;
|
||||
margin-inline-end: 0;
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
padding: 0px 2px;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
ha-input-search {
|
||||
display: block;
|
||||
padding: var(--ha-space-1) var(--ha-space-2) 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import type { SelectedDetail } from "@material/mwc-list";
|
||||
import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
} from "../common/controllers/filter-panel-controller";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
@@ -41,18 +46,17 @@ export class HaFilterDomains extends LitElement {
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public expanded = false;
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
|
||||
@state() private _filter?: string;
|
||||
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
private _content = createRef<HTMLElement>();
|
||||
|
||||
private _panel = new FilterPanelController(this, this._content);
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-will-change=${this._expandedWillChange}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
@@ -67,46 +71,48 @@ export class HaFilterDomains extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this._shouldRender
|
||||
? html`<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
>
|
||||
</ha-input-search>
|
||||
<ha-list
|
||||
class="ha-scrollbar"
|
||||
@selected=${this._handleItemSelected}
|
||||
multi
|
||||
>
|
||||
${repeat(
|
||||
this._domains(
|
||||
this._states,
|
||||
this._localize,
|
||||
this._i18n.locale.language,
|
||||
this._filter,
|
||||
this.value
|
||||
),
|
||||
(i) => i,
|
||||
(domain) =>
|
||||
html`<ha-check-list-item
|
||||
.value=${domain}
|
||||
.selected=${(this.value || []).includes(domain)}
|
||||
graphic="icon"
|
||||
>
|
||||
<ha-domain-icon
|
||||
slot="graphic"
|
||||
.domain=${domain}
|
||||
brand-fallback
|
||||
></ha-domain-icon>
|
||||
${domainToName(this._localize, domain)}
|
||||
</ha-check-list-item>`
|
||||
)}
|
||||
</ha-list> `
|
||||
: nothing
|
||||
}
|
||||
</ha-expansion-panel>
|
||||
${
|
||||
this._panel.showContent
|
||||
? html`<div class="content" ${ref(this._content)}>
|
||||
<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
>
|
||||
</ha-input-search>
|
||||
<ha-list
|
||||
class="ha-scrollbar"
|
||||
@selected=${this._handleItemSelected}
|
||||
multi
|
||||
>
|
||||
${repeat(
|
||||
this._domains(
|
||||
this._states,
|
||||
this._localize,
|
||||
this._i18n.locale.language,
|
||||
this._filter,
|
||||
this.value
|
||||
),
|
||||
(i) => i,
|
||||
(domain) =>
|
||||
html`<ha-check-list-item
|
||||
.value=${domain}
|
||||
.selected=${(this.value || []).includes(domain)}
|
||||
graphic="icon"
|
||||
>
|
||||
<ha-domain-icon
|
||||
slot="graphic"
|
||||
.domain=${domain}
|
||||
brand-fallback
|
||||
></ha-domain-icon>
|
||||
${domainToName(this._localize, domain)}
|
||||
</ha-check-list-item>`
|
||||
)}
|
||||
</ha-list>
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -139,22 +145,6 @@ export class HaFilterDomains extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
|
||||
// 49px - height of a header + 1px
|
||||
// 4px - padding-top of the search-input
|
||||
// 32px - height of the search input
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev) {
|
||||
this._shouldRender = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
@@ -199,17 +189,11 @@ export class HaFilterDomains extends LitElement {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
filterPanelStyles,
|
||||
css`
|
||||
:host {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
ha-list {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
|
||||
@@ -2,8 +2,13 @@ import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import memoizeOne from "memoize-one";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
} from "../common/controllers/filter-panel-controller";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeStateDomain } from "../common/entity/compute_state_domain";
|
||||
@@ -52,11 +57,11 @@ export class HaFilterEntities extends LitElement {
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public expanded = false;
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
|
||||
@state() private _filter?: string;
|
||||
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
private _content = createRef<HTMLElement>();
|
||||
|
||||
private _panel = new FilterPanelController(this, this._content);
|
||||
|
||||
public willUpdate(properties: PropertyValues<this>) {
|
||||
super.willUpdate(properties);
|
||||
@@ -78,7 +83,6 @@ export class HaFilterEntities extends LitElement {
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-will-change=${this._expandedWillChange}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
@@ -93,9 +97,11 @@ export class HaFilterEntities extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this._shouldRender
|
||||
? html`
|
||||
</ha-expansion-panel>
|
||||
${
|
||||
this._panel.showContent
|
||||
? html`
|
||||
<div class="content" ${ref(this._content)}>
|
||||
<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@@ -118,25 +124,13 @@ export class HaFilterEntities extends LitElement {
|
||||
>
|
||||
</lit-virtualizer>
|
||||
</ha-list>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-expansion-panel>
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
|
||||
// 49px - height of a header + 1px
|
||||
// 4px - padding-top of the search-input
|
||||
// 32px - height of the search input
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
private _keyFunction = (entity) => entity?.entity_id;
|
||||
|
||||
private _renderItem = (entity) =>
|
||||
@@ -173,10 +167,6 @@ export class HaFilterEntities extends LitElement {
|
||||
listItem.selected = this.value?.includes(value);
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev) {
|
||||
this._shouldRender = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
@@ -255,17 +245,11 @@ export class HaFilterEntities extends LitElement {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
filterPanelStyles,
|
||||
css`
|
||||
:host {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
ha-list {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
|
||||
@@ -4,8 +4,13 @@ import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
} from "../common/controllers/filter-panel-controller";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeRTL } from "../common/util/compute_rtl";
|
||||
@@ -64,10 +69,12 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public expanded = false;
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
|
||||
@query("ha-list-selectable") private _list?: HaListSelectable;
|
||||
|
||||
private _content = createRef<HTMLElement>();
|
||||
|
||||
private _panel = new FilterPanelController(this, this._content);
|
||||
|
||||
public willUpdate(properties: PropertyValues<this>) {
|
||||
super.willUpdate(properties);
|
||||
|
||||
@@ -86,7 +93,6 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-will-change=${this._expandedWillChange}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
@@ -107,9 +113,11 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this._shouldRender
|
||||
? html`
|
||||
</ha-expansion-panel>
|
||||
${
|
||||
this._panel.showContent
|
||||
? html`
|
||||
<div class="content" ${ref(this._content)}>
|
||||
<ha-list-selectable
|
||||
class="ha-scrollbar"
|
||||
multi
|
||||
@@ -154,10 +162,10 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
(area) => this._renderArea(area)
|
||||
)}
|
||||
</ha-list-selectable>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-expansion-panel>
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -243,19 +251,6 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
};
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this._list!.style.height = `${this.clientHeight - 49}px`;
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev) {
|
||||
this._shouldRender = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
@@ -347,17 +342,11 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
filterPanelStyles,
|
||||
css`
|
||||
:host {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
ha-list-selectable {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import type { SelectedDetail } from "@material/mwc-list";
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
} from "../common/controllers/filter-panel-controller";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { stringCompare } from "../common/string/compare";
|
||||
@@ -44,11 +49,11 @@ export class HaFilterIntegrations extends LitElement {
|
||||
Object.values(manifests)
|
||||
);
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
|
||||
@state() private _filter?: string;
|
||||
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
private _content = createRef<HTMLElement>();
|
||||
|
||||
private _panel = new FilterPanelController(this, this._content);
|
||||
|
||||
protected render() {
|
||||
const manifests = this._manifests
|
||||
@@ -59,7 +64,6 @@ export class HaFilterIntegrations extends LitElement {
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-will-change=${this._expandedWillChange}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
@@ -74,67 +78,57 @@ export class HaFilterIntegrations extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
manifests && this._shouldRender
|
||||
? html`<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
>
|
||||
</ha-input-search>
|
||||
<ha-list
|
||||
class="ha-scrollbar"
|
||||
@selected=${this._itemSelected}
|
||||
multi
|
||||
>
|
||||
${repeat(
|
||||
this._integrations(
|
||||
this._localize,
|
||||
manifests,
|
||||
this._filter,
|
||||
this.value,
|
||||
this._i18n.locale.language
|
||||
),
|
||||
(i) => i.domain,
|
||||
(integration) =>
|
||||
html`<ha-check-list-item
|
||||
.value=${integration.domain}
|
||||
.selected=${(this.value || []).includes(
|
||||
integration.domain
|
||||
)}
|
||||
graphic="icon"
|
||||
>
|
||||
<ha-domain-icon
|
||||
slot="graphic"
|
||||
.domain=${integration.domain}
|
||||
brand-fallback
|
||||
></ha-domain-icon>
|
||||
${integration.name}
|
||||
</ha-check-list-item>`
|
||||
)}
|
||||
</ha-list> `
|
||||
: nothing
|
||||
}
|
||||
</ha-expansion-panel>
|
||||
${
|
||||
this._panel.showContent
|
||||
? html`<div class="content" ${ref(this._content)}>
|
||||
${
|
||||
manifests
|
||||
? html`<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
>
|
||||
</ha-input-search>
|
||||
<ha-list
|
||||
class="ha-scrollbar"
|
||||
@selected=${this._itemSelected}
|
||||
multi
|
||||
>
|
||||
${repeat(
|
||||
this._integrations(
|
||||
this._localize,
|
||||
manifests,
|
||||
this._filter,
|
||||
this.value,
|
||||
this._i18n.locale.language
|
||||
),
|
||||
(i) => i.domain,
|
||||
(integration) =>
|
||||
html`<ha-check-list-item
|
||||
.value=${integration.domain}
|
||||
.selected=${(this.value || []).includes(
|
||||
integration.domain
|
||||
)}
|
||||
graphic="icon"
|
||||
>
|
||||
<ha-domain-icon
|
||||
slot="graphic"
|
||||
.domain=${integration.domain}
|
||||
brand-fallback
|
||||
></ha-domain-icon>
|
||||
${integration.name}
|
||||
</ha-check-list-item>`
|
||||
)}
|
||||
</ha-list>`
|
||||
: nothing
|
||||
}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
|
||||
// 49px - height of a header + 1px
|
||||
// 4px - padding-top of the search-input
|
||||
// 32px - height of the search input
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev) {
|
||||
this._shouldRender = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
@@ -213,17 +207,11 @@ export class HaFilterIntegrations extends LitElement {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
filterPanelStyles,
|
||||
css`
|
||||
:host {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
ha-list {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import type { SelectedDetail } from "@material/mwc-list";
|
||||
import { mdiCog, mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
} from "../common/controllers/filter-panel-controller";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { navigate } from "../common/navigate";
|
||||
@@ -44,11 +49,11 @@ export class HaFilterLabels extends LitElement {
|
||||
@state()
|
||||
private _labels?: LabelRegistryEntry[];
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
|
||||
@state() private _filter?: string;
|
||||
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
private _content = createRef<HTMLElement>();
|
||||
|
||||
private _panel = new FilterPanelController(this, this._content);
|
||||
|
||||
private _filteredLabels = memoizeOne(
|
||||
// `_value` used to recalculate the memoization when the selection changes
|
||||
@@ -75,7 +80,6 @@ export class HaFilterLabels extends LitElement {
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-will-change=${this._expandedWillChange}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
@@ -90,89 +94,66 @@ export class HaFilterLabels extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this._shouldRender
|
||||
? html`<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
>
|
||||
</ha-input-search>
|
||||
<ha-list
|
||||
@selected=${this._labelSelected}
|
||||
class="ha-scrollbar"
|
||||
multi
|
||||
>
|
||||
${repeat(
|
||||
this._filteredLabels(
|
||||
this._labels || [],
|
||||
this._filter,
|
||||
this._i18n.locale.language,
|
||||
this.value
|
||||
),
|
||||
(label) => label.label_id,
|
||||
(label) =>
|
||||
html`<ha-check-list-item
|
||||
.value=${label.label_id}
|
||||
.selected=${(this.value || []).includes(label.label_id)}
|
||||
hasMeta
|
||||
>
|
||||
<ha-label
|
||||
.color=${label.color}
|
||||
.description=${label.description}
|
||||
>
|
||||
${
|
||||
label.icon
|
||||
? html`<ha-icon
|
||||
slot="icon"
|
||||
.icon=${label.icon}
|
||||
></ha-icon>`
|
||||
: nothing
|
||||
}
|
||||
${label.name}
|
||||
</ha-label>
|
||||
</ha-check-list-item>`
|
||||
)}
|
||||
</ha-list> `
|
||||
: nothing
|
||||
}
|
||||
</ha-expansion-panel>
|
||||
${
|
||||
this.expanded
|
||||
? html`<ha-list-item
|
||||
graphic="icon"
|
||||
@click=${this._manageLabels}
|
||||
class="add"
|
||||
>
|
||||
<ha-svg-icon slot="graphic" .path=${mdiCog}></ha-svg-icon>
|
||||
${this._localize("ui.panel.config.labels.manage_labels")}
|
||||
</ha-list-item>`
|
||||
this._panel.showContent
|
||||
? html`<div class="content" ${ref(this._content)}>
|
||||
<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
>
|
||||
</ha-input-search>
|
||||
<ha-list
|
||||
@selected=${this._labelSelected}
|
||||
class="ha-scrollbar"
|
||||
multi
|
||||
>
|
||||
${repeat(
|
||||
this._filteredLabels(
|
||||
this._labels || [],
|
||||
this._filter,
|
||||
this._i18n.locale.language,
|
||||
this.value
|
||||
),
|
||||
(label) => label.label_id,
|
||||
(label) =>
|
||||
html`<ha-check-list-item
|
||||
.value=${label.label_id}
|
||||
.selected=${(this.value || []).includes(label.label_id)}
|
||||
hasMeta
|
||||
>
|
||||
<ha-label
|
||||
.color=${label.color}
|
||||
.description=${label.description}
|
||||
>
|
||||
${
|
||||
label.icon
|
||||
? html`<ha-icon
|
||||
slot="icon"
|
||||
.icon=${label.icon}
|
||||
></ha-icon>`
|
||||
: nothing
|
||||
}
|
||||
${label.name}
|
||||
</ha-label>
|
||||
</ha-check-list-item>`
|
||||
)}
|
||||
</ha-list>
|
||||
<ha-list-item graphic="icon" @click=${this._manageLabels}>
|
||||
<ha-svg-icon slot="graphic" .path=${mdiCog}></ha-svg-icon>
|
||||
${this._localize("ui.panel.config.labels.manage_labels")}
|
||||
</ha-list-item>
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this._list!.style.height = `${this.clientHeight - (49 + 48 + 32 + 4)}px`;
|
||||
// 49px - height of a header + 1px
|
||||
// 4px - padding-top of the search-input
|
||||
// 32px - height of the search input
|
||||
// 48px - height of ha-list-item
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
private _manageLabels() {
|
||||
navigate("/config/labels");
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev) {
|
||||
this._shouldRender = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
@@ -227,18 +208,11 @@ export class HaFilterLabels extends LitElement {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
filterPanelStyles,
|
||||
css`
|
||||
:host {
|
||||
position: relative;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
ha-list {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
@@ -267,12 +241,6 @@ export class HaFilterLabels extends LitElement {
|
||||
.warning {
|
||||
color: var(--error-color);
|
||||
}
|
||||
.add {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
}
|
||||
ha-input-search {
|
||||
display: block;
|
||||
padding: var(--ha-space-1) var(--ha-space-2) 0;
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { List, SelectedDetail } from "@material/mwc-list";
|
||||
import type { SelectedDetail } from "@material/mwc-list";
|
||||
import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
} from "../common/controllers/filter-panel-controller";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import "./ha-check-list-item";
|
||||
@@ -27,9 +32,9 @@ export class HaFilterStates extends LitElement {
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public expanded = false;
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
private _content = createRef<HTMLElement>();
|
||||
|
||||
@query("ha-list") private _list!: List;
|
||||
private _panel = new FilterPanelController(this, this._content);
|
||||
|
||||
protected render() {
|
||||
if (!this.states) {
|
||||
@@ -40,7 +45,6 @@ export class HaFilterStates extends LitElement {
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-will-change=${this._expandedWillChange}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
@@ -55,9 +59,11 @@ export class HaFilterStates extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this._shouldRender
|
||||
? html`
|
||||
</ha-expansion-panel>
|
||||
${
|
||||
this._panel.showContent
|
||||
? html`
|
||||
<div class="content" ${ref(this._content)}>
|
||||
<ha-list
|
||||
@selected=${this._statesSelected}
|
||||
multi
|
||||
@@ -82,36 +88,13 @@ export class HaFilterStates extends LitElement {
|
||||
</ha-check-list-item>`
|
||||
)}
|
||||
</ha-list>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-expansion-panel>
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
protected willUpdate(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
this._shouldRender = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if ((changed.has("expanded") || changed.has("states")) && this.expanded) {
|
||||
setTimeout(async () => {
|
||||
if (!this.expanded) return;
|
||||
const list = this._list;
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
list.style.height = `${this.clientHeight - 49}px`;
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev) {
|
||||
this._shouldRender = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
@@ -152,17 +135,11 @@ export class HaFilterStates extends LitElement {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
filterPanelStyles,
|
||||
css`
|
||||
:host {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
ha-list {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
|
||||
@@ -2,8 +2,13 @@ import type { SelectedDetail } from "@material/mwc-list";
|
||||
import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
} from "../common/controllers/filter-panel-controller";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
@@ -34,16 +39,15 @@ export class HaFilterVoiceAssistants extends LitElement {
|
||||
|
||||
@state() private _voiceAssistantOptions: string[] = [];
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
private _content = createRef<HTMLElement>();
|
||||
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
private _panel = new FilterPanelController(this, this._content);
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-will-change=${this._expandedWillChange}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
@@ -58,9 +62,11 @@ export class HaFilterVoiceAssistants extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this._shouldRender
|
||||
? html`<ha-list
|
||||
</ha-expansion-panel>
|
||||
${
|
||||
this._panel.showContent
|
||||
? html`<div class="content" ${ref(this._content)}>
|
||||
<ha-list
|
||||
@selected=${this._assistantsSelected}
|
||||
class="ha-scrollbar"
|
||||
multi
|
||||
@@ -83,10 +89,10 @@ export class HaFilterVoiceAssistants extends LitElement {
|
||||
${voiceAssistants[voiceAssistantId].name}
|
||||
</ha-check-list-item>`
|
||||
)}
|
||||
</ha-list> `
|
||||
: nothing
|
||||
}
|
||||
</ha-expansion-panel>
|
||||
</ha-list>
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -95,19 +101,6 @@ export class HaFilterVoiceAssistants extends LitElement {
|
||||
this._voiceAssistantOptions = Object.keys(voiceAssistants);
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this._list!.style.height = `${this.clientHeight - 49}px`;
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev) {
|
||||
this._shouldRender = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
@@ -148,18 +141,11 @@ export class HaFilterVoiceAssistants extends LitElement {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
filterPanelStyles,
|
||||
css`
|
||||
:host {
|
||||
position: relative;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
ha-list {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
|
||||
@@ -24,6 +24,7 @@ export type HaListItemOptionSelectionPosition = "start" | "end";
|
||||
* @cssprop --ha-list-item-selected-background - Background color when selected (`appearance="line"`).
|
||||
*
|
||||
* @attr {boolean} selected - Whether the option is selected. Set by the parent `ha-list-selectable`.
|
||||
* @attr {boolean} indeterminate - Draws the checkbox in an indeterminate state, for a row that stands for a partially selected set.
|
||||
* @attr {string} value - Value identifying the option.
|
||||
* @attr {("line"|"checkbox")} appearance - Visual style. "line" highlights the row; "checkbox" renders an `ha-checkbox`.
|
||||
* @attr {("start"|"end")} selection-position - Side the checkbox sits on when `appearance="checkbox"`.
|
||||
@@ -32,6 +33,8 @@ export type HaListItemOptionSelectionPosition = "start" | "end";
|
||||
export class HaListItemOption extends HaListItemBase {
|
||||
@property({ type: Boolean, reflect: true }) public selected = false;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public indeterminate = false;
|
||||
|
||||
@property({ type: String }) public value?: string;
|
||||
|
||||
@property({ type: String, reflect: true })
|
||||
@@ -80,6 +83,7 @@ export class HaListItemOption extends HaListItemBase {
|
||||
return html`<div part="checkbox" class="checkbox" inert>
|
||||
<ha-checkbox
|
||||
.checked=${this.selected}
|
||||
.indeterminate=${this.indeterminate}
|
||||
.disabled=${this.disabled}
|
||||
></ha-checkbox>
|
||||
</div>`;
|
||||
|
||||
@@ -10,6 +10,8 @@ export const SelectableMixin = <T extends Constructor<HaListBase>>(
|
||||
class SelectableClass extends superClass {
|
||||
@property({ type: Boolean, reflect: true }) public multi = false;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public controlled = false;
|
||||
|
||||
protected override readonly hostRole = "listbox";
|
||||
|
||||
public connectedCallback(): void {
|
||||
@@ -67,15 +69,19 @@ export const SelectableMixin = <T extends Constructor<HaListBase>>(
|
||||
`ha-list-item-${el.selected ? "deselected" : "selected"}`,
|
||||
index
|
||||
);
|
||||
el.toggleAttribute("selected");
|
||||
if (!this.controlled) {
|
||||
el.toggleAttribute("selected");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!el.selected) {
|
||||
fireEvent(this, "ha-list-item-selected", index);
|
||||
// deselect the other optional selected item
|
||||
this.clearSelection();
|
||||
el.toggleAttribute("selected", true);
|
||||
if (!this.controlled) {
|
||||
// deselect the other optional selected item
|
||||
this.clearSelection();
|
||||
el.toggleAttribute("selected", true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ import { HaListVirtualized } from "./ha-list-virtualized";
|
||||
*
|
||||
* @attr {boolean} multi - Whether multiple options can be selected at once. In
|
||||
* single-select mode, selecting a row clears any previous selection.
|
||||
* @attr {boolean} controlled - Only notify on click, never toggle a row's `selected` state.
|
||||
* Set it when the consumer derives `selected` from its own state, for example when a row stands
|
||||
* for a group whose click also changes its children.
|
||||
*
|
||||
* @fires ha-list-item-selected - Fires when the user selects a row.
|
||||
* `detail` is the row's index (number).
|
||||
|
||||
@@ -11,6 +11,9 @@ import { SelectableMixin } from "./ha-list-selectable-mixin";
|
||||
* Toggle single vs multi selection via the `multi` attribute.
|
||||
*
|
||||
* @attr {boolean} multi - Whether multiple options can be selected at once.
|
||||
* @attr {boolean} controlled - Only notify on click, never toggle an option's `selected` state.
|
||||
* Set it when the consumer derives `selected` from its own state, for example when a row stands
|
||||
* for a group whose click also changes its children.
|
||||
*
|
||||
* @fires ha-list-item-selected - An option was selected. `detail: number` (option index).
|
||||
* @fires ha-list-item-deselected - An option was deselected (multi mode only). `detail: number` (option index).
|
||||
|
||||
@@ -129,21 +129,31 @@ export class HaMediaPlayerPicker extends LitElement {
|
||||
.filter(this._filterPlayerEntities)
|
||||
.map<MediaPlayerComboBoxItem>((stateObj) => {
|
||||
const friendlyName = computeStateName(stateObj);
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this._entities,
|
||||
this._devices,
|
||||
this._areas,
|
||||
this._floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this._entities,
|
||||
this._devices,
|
||||
this._areas,
|
||||
this._floors
|
||||
);
|
||||
const entityId = stateObj.entity_id;
|
||||
const domainName = domainToName(
|
||||
this._i18n.localize,
|
||||
computeDomain(entityId)
|
||||
);
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
@@ -157,6 +167,7 @@ export class HaMediaPlayerPicker extends LitElement {
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
domainName: domainName || null,
|
||||
friendlyName: friendlyName || null,
|
||||
|
||||
@@ -62,17 +62,27 @@ class HaMediaPlayerToggle extends LitElement {
|
||||
isRTL: boolean,
|
||||
stateObj: HassEntity
|
||||
) => {
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
|
||||
@@ -683,7 +683,7 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
const entityName = stateObject
|
||||
? computeEntityName(stateObject, this.hass.entities, this.hass.devices)
|
||||
: item;
|
||||
const { area, device } = stateObject
|
||||
const { area, device, parentDevice } = stateObject
|
||||
? getEntityContext(
|
||||
stateObject,
|
||||
this.hass.entities,
|
||||
@@ -691,10 +691,17 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
)
|
||||
: { area: undefined, device: undefined };
|
||||
: { area: undefined, device: undefined, parentDevice: undefined };
|
||||
const deviceName = device ? computeDeviceName(device) : undefined;
|
||||
const parentDeviceName = parentDevice
|
||||
? computeDeviceName(parentDevice)
|
||||
: undefined;
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
const context = [areaName, entityName ? deviceName : undefined]
|
||||
const context = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(
|
||||
computeRTL(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import type { HaFormSchema } from "../components/ha-form/types";
|
||||
import type { ConfigEntry } from "./config_entries";
|
||||
import type { RepairsIssue } from "./repairs";
|
||||
|
||||
export type FlowType =
|
||||
"config_flow" | "config_subentries_flow" | "options_flow" | "repair_flow";
|
||||
@@ -56,26 +57,31 @@ export interface DataEntryFlowStepExternal {
|
||||
translation_domain?: string;
|
||||
}
|
||||
|
||||
export interface DataEntryFlowStepCreateEntry {
|
||||
export interface DataEntryFlowStepCreateEntry<
|
||||
TResult extends ConfigEntry | RepairsIssue = ConfigEntry,
|
||||
> {
|
||||
type: "create_entry";
|
||||
version: number;
|
||||
flow_id: string;
|
||||
next_flow?: [FlowType, string]; // [flow_type, flow_id]
|
||||
handler: string;
|
||||
title: string;
|
||||
result?: ConfigEntry;
|
||||
result?: TResult;
|
||||
description: string;
|
||||
description_placeholders?: Record<string, string>;
|
||||
translation_domain?: string;
|
||||
}
|
||||
|
||||
export interface DataEntryFlowStepAbort {
|
||||
export interface DataEntryFlowStepAbort<
|
||||
TResult extends ConfigEntry | RepairsIssue = ConfigEntry,
|
||||
> {
|
||||
type: "abort";
|
||||
flow_id: string;
|
||||
handler: string;
|
||||
reason: string;
|
||||
description_placeholders?: Record<string, string>;
|
||||
translation_domain?: string;
|
||||
result?: TResult;
|
||||
next_flow?: [FlowType, string]; // [flow_type, flow_id]
|
||||
}
|
||||
|
||||
|
||||
+171
-54
@@ -21,7 +21,6 @@ import {
|
||||
} from "../common/datetime/calc_date";
|
||||
import type { DateRange } from "../common/datetime/calc_date_range";
|
||||
import { calcDateRange } from "../common/datetime/calc_date_range";
|
||||
import { formatTime24h } from "../common/datetime/format_time";
|
||||
import { DEFAULT_ENTITY_NAME } from "../common/entity/compute_entity_name_display";
|
||||
import { formatNumber } from "../common/number/format_number";
|
||||
import { normalizeValueBySIPrefix } from "../common/number/normalize-by-si-prefix";
|
||||
@@ -799,8 +798,8 @@ const clearEnergyCollectionPreferences = (hass: HomeAssistant) => {
|
||||
};
|
||||
|
||||
const scheduleHourlyRefresh = (collection: EnergyCollection) => {
|
||||
if (collection._refreshTimeout) {
|
||||
clearTimeout(collection._refreshTimeout);
|
||||
if (collection._refreshTimeout !== undefined) {
|
||||
window.clearTimeout(collection._refreshTimeout);
|
||||
}
|
||||
|
||||
if (collection._active && (!collection.end || collection.end > new Date())) {
|
||||
@@ -859,18 +858,92 @@ export const getEnergyDefaultPeriodStorageKey = (
|
||||
return `energy-default-period-${key}`;
|
||||
};
|
||||
|
||||
// When does the collection's day period need to roll over to the next day?
|
||||
// With `midnightRollover` (the real-time "Now" view) it rolls over right at
|
||||
// midnight. Otherwise it waits an hour, until the new day's first hourly
|
||||
// statistic exists — rolling over at midnight would show an empty graph.
|
||||
export const getNextEnergyPeriodStart = (
|
||||
// When today's first hourly statistic becomes available (01:00 in the
|
||||
// configured timezone). Rolling the statistics view over at midnight would
|
||||
// show an empty graph.
|
||||
export const getEnergyFirstStatisticAt = (
|
||||
now: Date,
|
||||
locale: HomeAssistant["locale"],
|
||||
config: HomeAssistant["config"]
|
||||
): Date => addHours(calcDate(now, startOfDay, locale, config), 1);
|
||||
|
||||
// The statistics Energy view shows yesterday until 01:00 so the graph is not
|
||||
// empty. The real-time "Now" view never does this — it has live data.
|
||||
export const shouldFallbackEnergyPeriodToYesterday = (
|
||||
midnightRollover: boolean,
|
||||
now: Date,
|
||||
locale: HomeAssistant["locale"],
|
||||
config: HomeAssistant["config"]
|
||||
): boolean =>
|
||||
!midnightRollover &&
|
||||
now.getTime() < getEnergyFirstStatisticAt(now, locale, config).getTime();
|
||||
|
||||
// Live day used while a rollover timer is scheduled (today, or the hour-0
|
||||
// yesterday fallback). Custom dates do not use this. If the user already
|
||||
// picked today during hour 0, keep today rather than snapping back.
|
||||
export const getEnergyLiveDayPeriod = (
|
||||
midnightRollover: boolean,
|
||||
now: Date,
|
||||
locale: HomeAssistant["locale"],
|
||||
config: HomeAssistant["config"],
|
||||
currentStart: Date
|
||||
): { start: Date; end: Date } => {
|
||||
const todayStart = calcDate(now, startOfDay, locale, config);
|
||||
if (
|
||||
shouldFallbackEnergyPeriodToYesterday(
|
||||
midnightRollover,
|
||||
now,
|
||||
locale,
|
||||
config
|
||||
) &&
|
||||
currentStart.getTime() !== todayStart.getTime()
|
||||
) {
|
||||
const yesterday = calcDate(now, addDays, locale, config, -1);
|
||||
return {
|
||||
start: calcDate(yesterday, startOfDay, locale, config),
|
||||
end: calcDate(yesterday, endOfDay, locale, config),
|
||||
};
|
||||
}
|
||||
return {
|
||||
start: todayStart,
|
||||
end: calcDate(now, endOfDay, locale, config),
|
||||
};
|
||||
};
|
||||
|
||||
// When does the collection's day period need to roll over to the next day?
|
||||
// With `midnightRollover` (the real-time "Now" view) it rolls over right at
|
||||
// midnight. Otherwise it waits an hour, until the new day's first hourly
|
||||
// statistic exists — rolling over at midnight would show an empty graph.
|
||||
// Pass `periodStart` when the collection is on a specific day: hour-0
|
||||
// yesterday (and any older stale live day) must wake at today 01:00, not
|
||||
// tomorrow 01:00. Keep tomorrow 01:00 only when the user already picked today.
|
||||
export const getNextEnergyPeriodStart = (
|
||||
midnightRollover: boolean,
|
||||
now: Date,
|
||||
locale: HomeAssistant["locale"],
|
||||
config: HomeAssistant["config"],
|
||||
periodStart?: Date
|
||||
): Date => {
|
||||
const dayEnd = calcDate(now, endOfDay, locale, config);
|
||||
return midnightRollover ? addMilliseconds(dayEnd, 1) : addHours(dayEnd, 1);
|
||||
const todayStart = calcDate(now, startOfDay, locale, config);
|
||||
if (
|
||||
periodStart &&
|
||||
shouldFallbackEnergyPeriodToYesterday(
|
||||
midnightRollover,
|
||||
now,
|
||||
locale,
|
||||
config
|
||||
) &&
|
||||
periodStart.getTime() !== todayStart.getTime()
|
||||
) {
|
||||
return getEnergyFirstStatisticAt(now, locale, config);
|
||||
}
|
||||
// Next midnight in the configured zone, not browser-local addDays, so a
|
||||
// DST transition cannot skip a server-tz day.
|
||||
const nextMidnight = addMilliseconds(
|
||||
calcDate(now, endOfDay, locale, config),
|
||||
1
|
||||
);
|
||||
return midnightRollover ? nextMidnight : addHours(nextMidnight, 1);
|
||||
};
|
||||
|
||||
export const getEnergyDataCollection = (
|
||||
@@ -929,12 +1002,80 @@ export const getEnergyDataCollection = (
|
||||
}
|
||||
) as EnergyCollection;
|
||||
|
||||
collection._active = 0;
|
||||
collection.prefs = options.prefs;
|
||||
|
||||
// True while the collection is tracking the rolling "today" (or hour-0
|
||||
// yesterday) day. Cleared when the user picks a custom range.
|
||||
let followLiveDay = false;
|
||||
|
||||
const applyLiveDayPeriod = (now: Date): boolean => {
|
||||
const live = getEnergyLiveDayPeriod(
|
||||
midnightRollover,
|
||||
now,
|
||||
hass.locale,
|
||||
hass.config,
|
||||
collection.start
|
||||
);
|
||||
const changed =
|
||||
collection.start.getTime() !== live.start.getTime() ||
|
||||
collection.end?.getTime() !== live.end.getTime();
|
||||
collection.start = live.start;
|
||||
collection.end = live.end;
|
||||
return changed;
|
||||
};
|
||||
|
||||
const clearUpdatePeriodTimeout = () => {
|
||||
if (collection._updatePeriodTimeout !== undefined) {
|
||||
window.clearTimeout(collection._updatePeriodTimeout);
|
||||
collection._updatePeriodTimeout = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleUpdatePeriod = () => {
|
||||
clearUpdatePeriodTimeout();
|
||||
const scheduledAt = new Date();
|
||||
collection._updatePeriodTimeout = window.setTimeout(
|
||||
() => {
|
||||
if (applyLiveDayPeriod(new Date())) {
|
||||
collection.refresh();
|
||||
}
|
||||
scheduleUpdatePeriod();
|
||||
},
|
||||
Math.max(
|
||||
0,
|
||||
getNextEnergyPeriodStart(
|
||||
midnightRollover,
|
||||
scheduledAt,
|
||||
hass.locale,
|
||||
hass.config,
|
||||
collection.start
|
||||
).getTime() - scheduledAt.getTime()
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const origSubscribe = collection.subscribe;
|
||||
|
||||
collection.subscribe = (subscriber: (data: EnergyData) => void) => {
|
||||
// Catch up before origSubscribe so the first fetch uses the live day.
|
||||
// Refresh only when state already exists: cold subscribe fetches via
|
||||
// origSubscribe; a re-subscribe inside the 5s unsub grace does not.
|
||||
const needsRefresh =
|
||||
followLiveDay &&
|
||||
applyLiveDayPeriod(new Date()) &&
|
||||
collection.state !== undefined;
|
||||
if (followLiveDay) {
|
||||
scheduleUpdatePeriod();
|
||||
}
|
||||
|
||||
const unsub = origSubscribe(subscriber);
|
||||
collection._active++;
|
||||
|
||||
if (needsRefresh) {
|
||||
collection.refresh();
|
||||
}
|
||||
|
||||
if (collection._refreshTimeout === undefined) {
|
||||
scheduleHourlyRefresh(collection);
|
||||
}
|
||||
@@ -942,79 +1083,55 @@ export const getEnergyDataCollection = (
|
||||
return () => {
|
||||
collection._active--;
|
||||
if (collection._active < 1) {
|
||||
clearTimeout(collection._refreshTimeout);
|
||||
collection._refreshTimeout = undefined;
|
||||
if (collection._refreshTimeout !== undefined) {
|
||||
window.clearTimeout(collection._refreshTimeout);
|
||||
collection._refreshTimeout = undefined;
|
||||
}
|
||||
clearUpdatePeriodTimeout();
|
||||
}
|
||||
unsub();
|
||||
};
|
||||
};
|
||||
|
||||
collection._active = 0;
|
||||
collection.prefs = options.prefs;
|
||||
|
||||
const now = new Date();
|
||||
const hour = formatTime24h(now, hass.locale, hass.config).split(":")[0];
|
||||
// Set start to start of today if we have data for today, otherwise yesterday.
|
||||
// The real-time "Now" view always tracks today; it shows live data even
|
||||
// before today's first statistic exists, so it never falls back to yesterday.
|
||||
const now = new Date();
|
||||
const preferredPeriod =
|
||||
(localStorage.getItem(
|
||||
getEnergyDefaultPeriodStorageKey(hass, options.key)
|
||||
) as DateRange) || "today";
|
||||
const period =
|
||||
preferredPeriod === "today" && hour === "0" && !midnightRollover
|
||||
preferredPeriod === "today" &&
|
||||
shouldFallbackEnergyPeriodToYesterday(
|
||||
midnightRollover,
|
||||
now,
|
||||
hass.locale,
|
||||
hass.config
|
||||
)
|
||||
? "yesterday"
|
||||
: preferredPeriod;
|
||||
|
||||
const [start, end] = calcDateRange(hass.locale, hass.config, period);
|
||||
collection.start = calcDate(start, startOfDay, hass.locale, hass.config);
|
||||
collection.end = calcDate(end, endOfDay, hass.locale, hass.config);
|
||||
|
||||
const scheduleUpdatePeriod = () => {
|
||||
collection._updatePeriodTimeout = window.setTimeout(
|
||||
() => {
|
||||
collection.start = calcDate(
|
||||
new Date(),
|
||||
startOfDay,
|
||||
hass.locale,
|
||||
hass.config
|
||||
);
|
||||
collection.end = calcDate(
|
||||
new Date(),
|
||||
endOfDay,
|
||||
hass.locale,
|
||||
hass.config
|
||||
);
|
||||
collection.refresh();
|
||||
scheduleUpdatePeriod();
|
||||
},
|
||||
getNextEnergyPeriodStart(
|
||||
midnightRollover,
|
||||
new Date(),
|
||||
hass.locale,
|
||||
hass.config
|
||||
).getTime() - Date.now()
|
||||
);
|
||||
};
|
||||
scheduleUpdatePeriod();
|
||||
followLiveDay = preferredPeriod === "today";
|
||||
|
||||
collection.isActive = () => !!collection._active;
|
||||
collection.clearPrefs = () => {
|
||||
collection.prefs = undefined;
|
||||
};
|
||||
collection.setPeriod = (newStart: Date, newEnd?: Date) => {
|
||||
if (collection._updatePeriodTimeout) {
|
||||
clearTimeout(collection._updatePeriodTimeout);
|
||||
collection._updatePeriodTimeout = undefined;
|
||||
}
|
||||
clearUpdatePeriodTimeout();
|
||||
collection.start = newStart;
|
||||
collection.end = newEnd;
|
||||
if (
|
||||
const periodNow = new Date();
|
||||
followLiveDay =
|
||||
collection.start.getTime() ===
|
||||
calcDate(new Date(), startOfDay, hass.locale, hass.config).getTime() &&
|
||||
calcDate(periodNow, startOfDay, hass.locale, hass.config).getTime() &&
|
||||
collection.end?.getTime() ===
|
||||
calcDate(new Date(), endOfDay, hass.locale, hass.config).getTime()
|
||||
) {
|
||||
calcDate(periodNow, endOfDay, hass.locale, hass.config).getTime();
|
||||
if (followLiveDay) {
|
||||
scheduleUpdatePeriod();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,38 +1,5 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { formatDurationDigital } from "../../common/datetime/format_duration";
|
||||
import type { FrontendLocaleData } from "../translation";
|
||||
import { computeStateDomain } from "../../common/entity/compute_state_domain";
|
||||
|
||||
export const STATE_ATTRIBUTES = [
|
||||
"entity_id",
|
||||
"assumed_state",
|
||||
"attribution",
|
||||
"custom_ui_more_info",
|
||||
"custom_ui_state_card",
|
||||
"device_class",
|
||||
"editable",
|
||||
"emulated_hue_name",
|
||||
"emulated_hue",
|
||||
"entity_picture",
|
||||
"event_types",
|
||||
"friendly_name",
|
||||
"haaska_hidden",
|
||||
"haaska_name",
|
||||
"icon",
|
||||
"initial_state",
|
||||
"last_reset",
|
||||
"restored",
|
||||
"state_class",
|
||||
"supported_features",
|
||||
"unit_of_measurement",
|
||||
"available_tones",
|
||||
];
|
||||
|
||||
export const STATE_ATTRIBUTES_DOMAIN_CLASS = {
|
||||
sensor: {
|
||||
enum: ["options"],
|
||||
},
|
||||
};
|
||||
|
||||
export const TEMPERATURE_ATTRIBUTES = new Set([
|
||||
"temperature",
|
||||
@@ -210,15 +177,3 @@ export const STATE_CONDITION_HIDDEN_ATTRIBUTES = [
|
||||
"swing_modes",
|
||||
"token",
|
||||
];
|
||||
|
||||
export const computeShownAttributes = (stateObj: HassEntity) => {
|
||||
const domain = computeStateDomain(stateObj);
|
||||
const filtersArray = STATE_ATTRIBUTES.concat(
|
||||
STATE_ATTRIBUTES_DOMAIN_CLASS[domain]?.[
|
||||
stateObj.attributes?.device_class
|
||||
] || []
|
||||
);
|
||||
return Object.keys(stateObj.attributes).filter(
|
||||
(key) => filtersArray.indexOf(key) === -1
|
||||
);
|
||||
};
|
||||
|
||||
@@ -31,6 +31,10 @@ export const entityComboBoxKeys: FuseWeightedKey[] = [
|
||||
name: "search_labels.deviceName",
|
||||
weight: 7,
|
||||
},
|
||||
{
|
||||
name: "search_labels.parentDeviceName",
|
||||
weight: 6,
|
||||
},
|
||||
{
|
||||
name: "search_labels.areaName",
|
||||
weight: 6,
|
||||
@@ -129,14 +133,20 @@ export const getEntities = (
|
||||
const stateObj = hass.states[entityId];
|
||||
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
|
||||
const domain = computeDomain(entityId);
|
||||
let domainName = domainNames.get(domain);
|
||||
@@ -146,7 +156,11 @@ export const getEntities = (
|
||||
}
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
@@ -159,6 +173,7 @@ export const getEntities = (
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
domainName: domainName || null,
|
||||
friendlyName: friendlyName || null,
|
||||
|
||||
+10
-3
@@ -126,11 +126,18 @@ export const listDatadisks = async (
|
||||
timeout: null,
|
||||
});
|
||||
|
||||
export const fetchHostDisksUsage = async (hass: HomeAssistant) =>
|
||||
// `disk` is "default" for the data disk, or a mount name. Omitting maxDepth
|
||||
// leaves the depth to the Supervisor, which defaults per target — walking a
|
||||
// mount costs a round trip per directory, so mounts want no depth at all.
|
||||
export const fetchHostDisksUsage = async (
|
||||
hass: HomeAssistant,
|
||||
disk = "default",
|
||||
maxDepth?: number
|
||||
) =>
|
||||
hass.callWS<HostDisksUsage>({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/host/disks/default/usage",
|
||||
endpoint: `/host/disks/${disk}/usage`,
|
||||
method: "get",
|
||||
timeout: 3600, // seconds. This can take a while
|
||||
params: { max_depth: 3 },
|
||||
...(maxDepth === undefined ? {} : { params: { max_depth: maxDepth } }),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { computeAreaName } from "../../common/entity/compute_area_name";
|
||||
import { DEFAULT_ENTITY_NAME } from "../../common/entity/compute_entity_name_display";
|
||||
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { Selector } from "../selector";
|
||||
@@ -82,10 +83,7 @@ export const formatSelectorValue = (
|
||||
if (!stateObj) {
|
||||
return entityId;
|
||||
}
|
||||
const name = hass.formatEntityName(stateObj, [
|
||||
{ type: "device" },
|
||||
{ type: "entity" },
|
||||
]);
|
||||
const name = hass.formatEntityName(stateObj, DEFAULT_ENTITY_NAME);
|
||||
return name || entityId;
|
||||
})
|
||||
.join(", ");
|
||||
|
||||
@@ -2,13 +2,37 @@ import type { HomeAssistant } from "../types";
|
||||
|
||||
export interface SerialPort {
|
||||
device: string;
|
||||
resolved_device: string | null;
|
||||
serial_number: string | null;
|
||||
manufacturer: string | null;
|
||||
description: string | null;
|
||||
interface_description?: string | null;
|
||||
interface_num?: number | null;
|
||||
vid?: string;
|
||||
pid?: string;
|
||||
bcd_device?: number | null;
|
||||
matching_integrations: string[];
|
||||
// Configured ports that are not currently connected are absent from a scan
|
||||
present: boolean;
|
||||
}
|
||||
|
||||
export interface SerialPortConsumer {
|
||||
kind: "config_entry" | "app";
|
||||
title: string;
|
||||
active: boolean;
|
||||
domain: string | null;
|
||||
config_entry_id: string | null;
|
||||
slug: string | null;
|
||||
}
|
||||
|
||||
export interface SerialPortDiscoveryFlow {
|
||||
flow_id: string;
|
||||
domain: string;
|
||||
}
|
||||
|
||||
export interface SerialPortUsage extends SerialPort {
|
||||
consumers: SerialPortConsumer[];
|
||||
discovery_flows: SerialPortDiscoveryFlow[];
|
||||
}
|
||||
|
||||
export const scanUSBDevices = (hass: HomeAssistant) =>
|
||||
@@ -16,3 +40,9 @@ export const scanUSBDevices = (hass: HomeAssistant) =>
|
||||
|
||||
export const listSerialPorts = (hass: HomeAssistant) =>
|
||||
hass.callWS<SerialPort[]>({ type: "usb/list_serial_ports" });
|
||||
|
||||
export const listSerialPortsWithUsage = (hass: HomeAssistant) =>
|
||||
hass.callWS<SerialPortUsage[]>({
|
||||
type: "usb/list_serial_ports",
|
||||
include_usage: true,
|
||||
});
|
||||
|
||||
@@ -12,12 +12,14 @@ import "../../components/ha-button";
|
||||
import "../../components/ha-dialog";
|
||||
import "../../components/ha-dialog-footer";
|
||||
import "../../components/ha-icon-button";
|
||||
import type { ConfigEntry } from "../../data/config_entries";
|
||||
import type { DataEntryFlowStep } from "../../data/data_entry_flow";
|
||||
import {
|
||||
subscribeDataEntryFlowProgress,
|
||||
subscribeDataEntryFlowProgressed,
|
||||
} from "../../data/data_entry_flow";
|
||||
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
|
||||
import type { RepairsIssue } from "../../data/repairs";
|
||||
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
|
||||
import { haStyleDialog } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
@@ -30,6 +32,7 @@ import type {
|
||||
} from "./show-dialog-data-entry-flow";
|
||||
import { showOptionsFlowDialog } from "./show-dialog-options-flow";
|
||||
import { showSubConfigFlowDialog } from "./show-dialog-sub-config-flow";
|
||||
import { showRepairsFlowDialog } from "../repairs-flow/show-dialog-repair-flow";
|
||||
import "./step-flow-abort";
|
||||
import "./step-flow-create-entry";
|
||||
import "./step-flow-external";
|
||||
@@ -218,7 +221,9 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
|
||||
this._params.dialogClosedCallback({
|
||||
flowFinished,
|
||||
entryId:
|
||||
"result" in this._step ? this._step.result?.entry_id : undefined,
|
||||
"result" in this._step
|
||||
? (this._step.result as ConfigEntry)?.entry_id
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -279,7 +284,7 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
|
||||
const devicesLength = this._devices(
|
||||
this._params.flowConfig.showDevices,
|
||||
Object.values(this.hass.devices),
|
||||
this._step.result?.entry_id,
|
||||
(this._step.result as ConfigEntry)?.entry_id,
|
||||
this._params.carryOverDevices
|
||||
).length;
|
||||
return this.hass.localize(
|
||||
@@ -485,7 +490,8 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
|
||||
.devices=${this._devices(
|
||||
this._params.flowConfig.showDevices,
|
||||
Object.values(this.hass.devices),
|
||||
this._step.result?.entry_id,
|
||||
(this._step.result as ConfigEntry)
|
||||
?.entry_id,
|
||||
this._params.carryOverDevices
|
||||
)}
|
||||
></step-flow-create-entry>
|
||||
@@ -575,7 +581,7 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
|
||||
const devices = this._devices(
|
||||
this._params!.flowConfig.showDevices,
|
||||
Object.values(this.hass.devices),
|
||||
this._step.result?.entry_id,
|
||||
(this._step.result as ConfigEntry)?.entry_id,
|
||||
this._params!.carryOverDevices
|
||||
);
|
||||
|
||||
@@ -681,21 +687,23 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
|
||||
dialogClosedCallback: this._params!.dialogClosedCallback,
|
||||
});
|
||||
} else if (_step.next_flow[0] === "options_flow") {
|
||||
if (_step.type === "create_entry") {
|
||||
showOptionsFlowDialog(this, _step.result!, {
|
||||
continueFlowId: _step.next_flow[1],
|
||||
navigateToResult: this._params!.navigateToResult,
|
||||
dialogClosedCallback: this._params!.dialogClosedCallback,
|
||||
});
|
||||
}
|
||||
showOptionsFlowDialog(this, _step.result!, {
|
||||
continueFlowId: _step.next_flow[1],
|
||||
navigateToResult: this._params!.navigateToResult,
|
||||
dialogClosedCallback: this._params!.dialogClosedCallback,
|
||||
});
|
||||
} else if (_step.next_flow[0] === "config_subentries_flow") {
|
||||
if (_step.type === "create_entry") {
|
||||
showSubConfigFlowDialog(this, _step.result!, "", {
|
||||
continueFlowId: _step.next_flow[1],
|
||||
navigateToResult: this._params!.navigateToResult,
|
||||
dialogClosedCallback: this._params!.dialogClosedCallback,
|
||||
});
|
||||
}
|
||||
showSubConfigFlowDialog(this, _step.result!, "", {
|
||||
continueFlowId: _step.next_flow[1],
|
||||
navigateToResult: this._params!.navigateToResult,
|
||||
dialogClosedCallback: this._params!.dialogClosedCallback,
|
||||
});
|
||||
} else if (_step.next_flow[0] === "repair_flow") {
|
||||
showRepairsFlowDialog(this, _step.result as unknown as RepairsIssue, {
|
||||
continueFlowId: _step.next_flow[1],
|
||||
navigateToResult: this._params!.navigateToResult,
|
||||
dialogClosedCallback: this._params!.dialogClosedCallback,
|
||||
});
|
||||
} else {
|
||||
this.closeDialog();
|
||||
showAlertDialog(this, {
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { HaInput } from "../../components/input/ha-input";
|
||||
import { assistSatelliteSupportsSetupFlow } from "../../data/assist_satellite";
|
||||
import { getConfigEntries } from "../../data/config_entries";
|
||||
import type { DataEntryFlowStepCreateEntry } from "../../data/data_entry_flow";
|
||||
import type { ConfigEntry } from "../../data/config_entries";
|
||||
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
|
||||
import { updateDeviceRegistryEntry } from "../../data/device/device_registry";
|
||||
import {
|
||||
@@ -40,7 +41,8 @@ class StepFlowCreateEntry extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public step!: DataEntryFlowStepCreateEntry;
|
||||
@property({ attribute: false })
|
||||
public step!: DataEntryFlowStepCreateEntry<ConfigEntry>;
|
||||
|
||||
@property({ attribute: false }) public devices!: DeviceRegistryEntry[];
|
||||
|
||||
@@ -79,7 +81,7 @@ class StepFlowCreateEntry extends LitElement {
|
||||
if (
|
||||
this.devices.length !== 1 ||
|
||||
this.devices[0].primary_config_entry !== this.step.result?.entry_id ||
|
||||
this.step.result.domain === "voip"
|
||||
this.step.result?.domain === "voip"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
+178
-62
@@ -1,29 +1,39 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import {
|
||||
customElement,
|
||||
property,
|
||||
query,
|
||||
queryAll,
|
||||
state,
|
||||
} from "lit/decorators";
|
||||
import deepClone from "deep-clone-simple";
|
||||
import { deepActiveElement } from "../../common/dom/deep-active-element";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-form/ha-form";
|
||||
import "../../components/ha-dialog-footer";
|
||||
import "../../components/ha-dialog";
|
||||
import "../../components/ha-dialog-footer";
|
||||
import "../../components/ha-form/ha-form";
|
||||
import type { HaDialog } from "../../components/ha-dialog";
|
||||
import type { HaForm } from "../../components/ha-form/ha-form";
|
||||
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
|
||||
import { haStyleDialog } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassDialog, ShowDialogParams } from "../make-dialog-manager";
|
||||
import type { FormDialogData, FormDialogParams } from "./show-form-dialog";
|
||||
import type { HaForm } from "../../components/ha-form/ha-form";
|
||||
|
||||
interface StackEntry {
|
||||
params: FormDialogParams;
|
||||
initialData: FormDialogData;
|
||||
data: FormDialogData;
|
||||
nestedField?: string;
|
||||
scrollTop: number;
|
||||
focusTarget?: Element;
|
||||
error?: Record<string, string>;
|
||||
}
|
||||
|
||||
@customElement("dialog-form")
|
||||
export class DialogForm
|
||||
extends DirtyStateProviderMixin<FormDialogData>()(LitElement)
|
||||
extends DirtyStateProviderMixin<FormDialogData[]>()(LitElement)
|
||||
implements HassDialog<FormDialogData>
|
||||
{
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
@@ -32,6 +42,8 @@ export class DialogForm
|
||||
|
||||
@state() private _data: FormDialogData = {};
|
||||
|
||||
private _initialData: FormDialogData = {};
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@state() private _closeState?: "canceled" | "submitted";
|
||||
@@ -40,14 +52,19 @@ export class DialogForm
|
||||
|
||||
@state() private _error?: Record<string, string>;
|
||||
|
||||
@query("ha-form") private _form?: HaForm;
|
||||
@query("ha-dialog") private _dialog?: HaDialog;
|
||||
|
||||
@query("ha-form:not([hidden])") private _form?: HaForm;
|
||||
|
||||
@queryAll("ha-form") private _forms!: NodeListOf<HaForm>;
|
||||
|
||||
public async showDialog(params: FormDialogParams): Promise<void> {
|
||||
this._params = params;
|
||||
this._data = params.data || {};
|
||||
this._initialData = deepClone(this._data);
|
||||
this._open = true;
|
||||
this._error = undefined;
|
||||
this._initDirtyTracking({ type: "deep" }, this._data);
|
||||
this._resetDirtyTracking();
|
||||
}
|
||||
|
||||
public closeDialog(): boolean {
|
||||
@@ -55,54 +72,119 @@ export class DialogForm
|
||||
return true;
|
||||
}
|
||||
|
||||
private _initialDirtyState(): FormDialogData[] {
|
||||
return [
|
||||
...this._stack.map((entry) => entry.initialData),
|
||||
this._initialData,
|
||||
];
|
||||
}
|
||||
|
||||
private _currentDirtyState(): FormDialogData[] {
|
||||
return [...this._stack.map((entry) => entry.data), this._data];
|
||||
}
|
||||
|
||||
private _resetDirtyTracking(): void {
|
||||
this._initDirtyTracking({ type: "deep" }, this._initialDirtyState());
|
||||
this._updateDirtyState(this._currentDirtyState());
|
||||
}
|
||||
|
||||
private _handleNestedShowDialog = (
|
||||
ev: HASSDomEvent<ShowDialogParams<unknown>>
|
||||
) => {
|
||||
if (ev.detail.dialogTag !== "dialog-form") {
|
||||
if (
|
||||
ev.detail.dialogTag !== "dialog-form" ||
|
||||
ev.currentTarget !== this._form
|
||||
) {
|
||||
return;
|
||||
}
|
||||
ev.stopPropagation();
|
||||
|
||||
const origin = ev.composedPath()[0] as HTMLElement & { name?: string };
|
||||
const nested = ev.detail.dialogParams as FormDialogParams;
|
||||
if (!nested.submit || !nested.cancel) {
|
||||
return;
|
||||
}
|
||||
|
||||
ev.stopPropagation();
|
||||
const focusTarget = deepActiveElement();
|
||||
|
||||
this._stack = [
|
||||
...this._stack,
|
||||
{
|
||||
params: this._params!,
|
||||
initialData: this._initialData,
|
||||
data: this._data,
|
||||
nestedField: origin?.name,
|
||||
scrollTop: this._dialog?.bodyContainer.scrollTop ?? 0,
|
||||
focusTarget: focusTarget ?? undefined,
|
||||
error: this._error,
|
||||
},
|
||||
];
|
||||
const nested = ev.detail.dialogParams as FormDialogParams;
|
||||
|
||||
this._params = nested;
|
||||
this._data = nested?.data || {};
|
||||
this._data = nested.data || {};
|
||||
this._initialData = deepClone(this._data);
|
||||
this._error = undefined;
|
||||
this._initDirtyTracking({ type: "deep" }, this._data);
|
||||
this._resetDirtyTracking();
|
||||
};
|
||||
|
||||
private _popStack(): string | undefined {
|
||||
private _popStack(): StackEntry | undefined {
|
||||
if (!this._stack.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const prev = this._stack[this._stack.length - 1];
|
||||
|
||||
this._stack = this._stack.slice(0, -1);
|
||||
this._params = prev.params;
|
||||
this._initialData = prev.initialData;
|
||||
this._data = prev.data;
|
||||
this._error = prev.error;
|
||||
this._initDirtyTracking({ type: "deep" }, this._data);
|
||||
return prev.nestedField;
|
||||
this._resetDirtyTracking();
|
||||
|
||||
return prev;
|
||||
}
|
||||
|
||||
private async _restoreFocusAndScroll(
|
||||
scrollTop: number,
|
||||
expectedParams: FormDialogParams,
|
||||
focusTarget?: Element
|
||||
): Promise<void> {
|
||||
await this.updateComplete;
|
||||
await this._form?.updateComplete;
|
||||
await new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => resolve());
|
||||
});
|
||||
|
||||
if (!this._open || this._params !== expectedParams || !this._dialog) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (focusTarget instanceof HTMLElement && focusTarget.isConnected) {
|
||||
focusTarget.focus();
|
||||
}
|
||||
|
||||
this._dialog.bodyContainer.scrollTop = scrollTop;
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
if (!this._closeState) {
|
||||
this._params?.cancel?.();
|
||||
|
||||
for (let index = this._stack.length - 1; index >= 0; index--) {
|
||||
this._stack[index].params.cancel?.();
|
||||
}
|
||||
}
|
||||
|
||||
if (this._closeState !== "submitted") {
|
||||
this._discardDirtyStateChanges();
|
||||
}
|
||||
|
||||
this._closeState = undefined;
|
||||
this._stack = [];
|
||||
this._params = undefined;
|
||||
this._initialData = {};
|
||||
this._data = {};
|
||||
this._open = false;
|
||||
this._error = undefined;
|
||||
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
@@ -114,53 +196,69 @@ export class DialogForm
|
||||
return;
|
||||
}
|
||||
|
||||
this._closeState = "submitted";
|
||||
const submit = this._params?.submit;
|
||||
const data = this._data;
|
||||
const nestedField = this._popStack();
|
||||
const stackEntry = this._popStack();
|
||||
|
||||
submit?.(data);
|
||||
|
||||
if (!nestedField) {
|
||||
if (!stackEntry) {
|
||||
this._closeState = "submitted";
|
||||
submit?.(data);
|
||||
this._markDirtyStateClean();
|
||||
this.closeDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
const schemaField = this._params?.schema.find(
|
||||
(f) => "selector" in f && f.name === nestedField
|
||||
submit!(data);
|
||||
void this._restoreFocusAndScroll(
|
||||
stackEntry.scrollTop,
|
||||
stackEntry.params,
|
||||
stackEntry.focusTarget
|
||||
);
|
||||
const isMultiple =
|
||||
schemaField &&
|
||||
"selector" in schemaField &&
|
||||
"object" in schemaField.selector &&
|
||||
schemaField.selector.object?.multiple === true;
|
||||
|
||||
const current = this._data[nestedField];
|
||||
const newValue = isMultiple
|
||||
? [...(Array.isArray(current) ? current : []), data]
|
||||
: data;
|
||||
|
||||
this._data = deepClone({ ...this._data, [nestedField]: newValue });
|
||||
this._error = undefined;
|
||||
this._updateDirtyState(this._data);
|
||||
}
|
||||
|
||||
private _cancel(): void {
|
||||
this._closeState = "canceled";
|
||||
const cancel = this._params?.cancel;
|
||||
const nestedField = this._popStack();
|
||||
const stackEntry = this._popStack();
|
||||
|
||||
cancel?.();
|
||||
|
||||
if (!nestedField) {
|
||||
if (!stackEntry) {
|
||||
this._closeState = "canceled";
|
||||
cancel?.();
|
||||
this.closeDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
cancel!();
|
||||
void this._restoreFocusAndScroll(
|
||||
stackEntry.scrollTop,
|
||||
stackEntry.params,
|
||||
stackEntry.focusTarget
|
||||
);
|
||||
}
|
||||
|
||||
private _valueChanged(ev: CustomEvent): void {
|
||||
this._data = ev.detail.value;
|
||||
this._error = undefined;
|
||||
this._updateDirtyState(this._data);
|
||||
const levelIndex = Array.from(this._forms).indexOf(
|
||||
ev.currentTarget as HaForm
|
||||
);
|
||||
|
||||
if (levelIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = ev.detail.value as FormDialogData;
|
||||
|
||||
if (levelIndex === this._stack.length) {
|
||||
this._data = data;
|
||||
this._error = undefined;
|
||||
this._updateDirtyState(this._currentDirtyState());
|
||||
return;
|
||||
}
|
||||
|
||||
if (levelIndex < this._stack.length) {
|
||||
this._stack = this._stack.map((entry, index) =>
|
||||
index === levelIndex ? { ...entry, data, error: undefined } : entry
|
||||
);
|
||||
this._updateDirtyState(this._currentDirtyState());
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -168,35 +266,53 @@ export class DialogForm
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const params = this._params;
|
||||
const levels = [
|
||||
...this._stack,
|
||||
{
|
||||
params,
|
||||
initialData: this._initialData,
|
||||
data: this._data,
|
||||
error: this._error,
|
||||
},
|
||||
];
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
header-title=${this._params.title}
|
||||
header-title=${params.title}
|
||||
.preventScrimClose=${this.isDirtyState}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<ha-form
|
||||
autofocus
|
||||
.hass=${this.hass}
|
||||
.computeLabel=${this._params.computeLabel}
|
||||
.computeHelper=${this._params.computeHelper}
|
||||
.data=${this._data}
|
||||
.schema=${this._params.schema}
|
||||
.error=${this._error}
|
||||
@value-changed=${this._valueChanged}
|
||||
@show-dialog=${this._handleNestedShowDialog}
|
||||
>
|
||||
</ha-form>
|
||||
${levels.map((level, index) => {
|
||||
const isActive = index === levels.length - 1;
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
?hidden=${!isActive}
|
||||
?autofocus=${isActive}
|
||||
.hass=${this.hass}
|
||||
.computeLabel=${level.params.computeLabel}
|
||||
.computeHelper=${level.params.computeHelper}
|
||||
.data=${level.data}
|
||||
.schema=${level.params.schema}
|
||||
.error=${level.error}
|
||||
@value-changed=${this._valueChanged}
|
||||
@show-dialog=${this._handleNestedShowDialog}
|
||||
>
|
||||
</ha-form>
|
||||
`;
|
||||
})}
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
appearance="plain"
|
||||
@click=${this._cancel}
|
||||
>
|
||||
${this._params.cancelText || this.hass.localize("ui.common.cancel")}
|
||||
${params.cancelText || this.hass.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button slot="primaryAction" @click=${this._submit}>
|
||||
${this._params.submitText || this.hass.localize("ui.common.save")}
|
||||
${params.submitText || this.hass.localize("ui.common.save")}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-dialog>
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { throttle } from "../../../../common/util/throttle";
|
||||
import "../../../../components/ha-color-temp-brightness-picker";
|
||||
import { apiContext } from "../../../../data/context";
|
||||
import { UNAVAILABLE } from "../../../../data/entity/entity";
|
||||
import type { LightEntity } from "../../../../data/light";
|
||||
import { LightColorMode } from "../../../../data/light";
|
||||
|
||||
@customElement("light-color-temp-brightness-picker")
|
||||
class LightColorTempBrightnessPicker extends LitElement {
|
||||
@state()
|
||||
@consume({ context: apiContext, subscribe: true })
|
||||
private _api!: ContextType<typeof apiContext>;
|
||||
|
||||
@property({ attribute: false }) public stateObj!: LightEntity;
|
||||
|
||||
@state() private _value?: [number, number];
|
||||
|
||||
@state() private _isInteracting?: boolean;
|
||||
|
||||
protected render() {
|
||||
if (!this.stateObj) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-color-temp-brightness-picker
|
||||
.value=${this._value}
|
||||
.minKelvin=${this.stateObj.attributes.min_color_temp_kelvin}
|
||||
.maxKelvin=${this.stateObj.attributes.max_color_temp_kelvin}
|
||||
.disabled=${this.stateObj.state === UNAVAILABLE}
|
||||
@cursor-moved=${this._cursorMoved}
|
||||
@value-changed=${this._valueChanged}
|
||||
>
|
||||
</ha-color-temp-brightness-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
private _updateValues() {
|
||||
const stateObj = this.stateObj;
|
||||
|
||||
if (stateObj.state !== "on") {
|
||||
this._value = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const kelvin =
|
||||
stateObj.attributes.color_mode === LightColorMode.COLOR_TEMP
|
||||
? stateObj.attributes.color_temp_kelvin
|
||||
: undefined;
|
||||
const brightness = stateObj.attributes.brightness;
|
||||
|
||||
this._value =
|
||||
kelvin != null && brightness != null
|
||||
? [kelvin, Math.max(Math.round((brightness * 100) / 255), 1)]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
if (this._isInteracting || !changedProps.has("stateObj")) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._updateValues();
|
||||
}
|
||||
|
||||
private _cursorMoved(ev: CustomEvent) {
|
||||
const value = ev.detail.value;
|
||||
|
||||
this._isInteracting = value !== undefined;
|
||||
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._value = value;
|
||||
|
||||
this._throttleUpdateLight();
|
||||
}
|
||||
|
||||
private _throttleUpdateLight = throttle(() => {
|
||||
this._updateLight();
|
||||
}, 500);
|
||||
|
||||
private _valueChanged(ev: CustomEvent) {
|
||||
const value = ev.detail.value;
|
||||
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._value = value;
|
||||
|
||||
this._throttleUpdateLight.cancel();
|
||||
this._updateLight();
|
||||
}
|
||||
|
||||
private _updateLight() {
|
||||
const [color_temp_kelvin, brightness_pct] = this._value!;
|
||||
|
||||
this._api.callService("light", "turn_on", {
|
||||
entity_id: this.stateObj!.entity_id,
|
||||
color_temp_kelvin,
|
||||
brightness_pct,
|
||||
});
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
ha-color-temp-brightness-picker {
|
||||
height: 45vh;
|
||||
max-height: 320px;
|
||||
min-height: 200px;
|
||||
aspect-ratio: 1;
|
||||
max-width: 100%;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"light-color-temp-brightness-picker": LightColorTempBrightnessPicker;
|
||||
}
|
||||
}
|
||||
@@ -34,12 +34,4 @@ export const moreInfoControlStyle = css`
|
||||
.buttons > * {
|
||||
margin: var(--ha-space-2);
|
||||
}
|
||||
|
||||
ha-attributes {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
ha-more-info-control-select-container + ha-attributes:not([empty]) {
|
||||
margin-top: var(--ha-space-4);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -41,13 +41,11 @@ import "../components/ha-more-info-state-header";
|
||||
import "../components/lights/ha-favorite-color-button";
|
||||
import "../components/lights/ha-more-info-light-favorite-colors";
|
||||
import "../components/lights/light-color-rgb-picker";
|
||||
import "../components/lights/light-color-temp-brightness-picker";
|
||||
import "../components/lights/light-color-temp-picker";
|
||||
import { moreInfoControlStyle } from "../components/more-info-control-style";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
|
||||
type MainControl =
|
||||
"brightness" | "color_temp" | "color" | "color_temp_brightness";
|
||||
type MainControl = "brightness" | "color_temp" | "color";
|
||||
|
||||
@customElement("more-info-light")
|
||||
class MoreInfoLight extends LitElement {
|
||||
@@ -80,31 +78,12 @@ class MoreInfoLight extends LitElement {
|
||||
.attributeValue=${value}
|
||||
></ha-attribute-icon>`;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>): void {
|
||||
super.willUpdate(changedProps);
|
||||
if (
|
||||
changedProps.has("stateObj") &&
|
||||
this.stateObj &&
|
||||
this._mainControl === "color_temp_brightness" &&
|
||||
!this._supportsDualAxis(this.stateObj)
|
||||
) {
|
||||
this._mainControl = "brightness";
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>): void {
|
||||
if (changedProps.has("stateObj")) {
|
||||
this._effect = this.stateObj?.attributes.effect;
|
||||
}
|
||||
}
|
||||
|
||||
private _supportsDualAxis(stateObj: LightEntity): boolean {
|
||||
return (
|
||||
lightSupportsBrightness(stateObj) &&
|
||||
lightSupportsColorMode(stateObj, LightColorMode.COLOR_TEMP)
|
||||
);
|
||||
}
|
||||
|
||||
private _setMainControl(ev: any) {
|
||||
ev.stopPropagation();
|
||||
this._mainControl = ev.currentTarget.control;
|
||||
@@ -149,8 +128,6 @@ class MoreInfoLight extends LitElement {
|
||||
LightEntityFeature.EFFECT
|
||||
);
|
||||
|
||||
const supportsDualAxis = this._supportsDualAxis(this.stateObj);
|
||||
|
||||
const showFavoriteColors = Boolean(
|
||||
this.entry &&
|
||||
(this.editMode ||
|
||||
@@ -206,17 +183,6 @@ class MoreInfoLight extends LitElement {
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
supportsDualAxis &&
|
||||
this._mainControl === "color_temp_brightness"
|
||||
? html`
|
||||
<light-color-temp-brightness-picker
|
||||
.stateObj=${this.stateObj}
|
||||
>
|
||||
</light-color-temp-brightness-picker>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<ha-icon-button-group>
|
||||
${
|
||||
supportsBrightness
|
||||
@@ -288,26 +254,6 @@ class MoreInfoLight extends LitElement {
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
supportsDualAxis
|
||||
? html`
|
||||
<ha-icon-button-toggle
|
||||
border-only
|
||||
.selected=${
|
||||
this._mainControl === "color_temp_brightness"
|
||||
}
|
||||
.disabled=${this.stateObj!.state === UNAVAILABLE}
|
||||
.label=${this._localize(
|
||||
"ui.dialogs.more_info_control.light.color_temp_brightness"
|
||||
)}
|
||||
.control=${"color_temp_brightness"}
|
||||
@click=${this._setMainControl}
|
||||
>
|
||||
<span class="wheel color-temp-brightness"></span>
|
||||
</ha-icon-button-toggle>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
supportsWhite
|
||||
? html`
|
||||
@@ -438,15 +384,6 @@ class MoreInfoLight extends LitElement {
|
||||
rgb(255, 160, 0) 100%
|
||||
);
|
||||
}
|
||||
.wheel.color-temp-brightness {
|
||||
background-image:
|
||||
linear-gradient(to top, rgb(102, 102, 102), white),
|
||||
linear-gradient(90deg, rgb(255, 160, 0), white, rgb(166, 209, 255));
|
||||
background-blend-mode: multiply;
|
||||
/* Mask the blended result as one layer; radius clipping bleeds
|
||||
per background layer at the edge in Gecko */
|
||||
clip-path: inset(0 round var(--ha-border-radius-circle));
|
||||
}
|
||||
*[disabled] .wheel {
|
||||
filter: grayscale(1) opacity(0.5);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { computeAreaName } from "../../common/entity/compute_area_name";
|
||||
import { computeAttributeNameDisplay } from "../../common/entity/compute_attribute_display";
|
||||
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
|
||||
import { computeFloorName } from "../../common/entity/compute_floor_name";
|
||||
import { getEntityContext } from "../../common/entity/context/get_entity_context";
|
||||
@@ -15,7 +14,6 @@ import "../../components/ha-attribute-value";
|
||||
import "../../components/item/ha-list-item-value";
|
||||
import "../../components/list/ha-grouped-list";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
import { computeShownAttributes } from "../../data/entity/entity_attributes";
|
||||
import { labelsContext } from "../../data/context";
|
||||
import type { ExtEntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
import type { LabelRegistryEntry } from "../../data/label/label_registry";
|
||||
@@ -26,6 +24,7 @@ import type { FeatureEnum } from "../../common/entity/get_domain_features";
|
||||
import { getFeatures } from "../../common/entity/get_domain_features";
|
||||
import { supportsFeature } from "../../common/entity/supports-feature";
|
||||
import { titleCase } from "../../common/string/title-case";
|
||||
import { stringCompare } from "../../common/string/compare";
|
||||
|
||||
interface DetailsViewParams {
|
||||
entityId: string;
|
||||
@@ -75,7 +74,7 @@ class HaMoreInfoDetails extends LitElement {
|
||||
attributes,
|
||||
yamlData: stateYamlData,
|
||||
} = this._getDetailData(this._stateObj);
|
||||
const { floor, area, device } = getEntityContext(
|
||||
const { floor, area, device, parentDevice } = getEntityContext(
|
||||
this._stateObj,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
@@ -84,6 +83,13 @@ class HaMoreInfoDetails extends LitElement {
|
||||
);
|
||||
const floorName = floor ? computeFloorName(floor) : undefined;
|
||||
const areaName = area ? (computeAreaName(area) ?? area.area_id) : undefined;
|
||||
const parentDeviceName = parentDevice
|
||||
? computeDeviceNameDisplay(
|
||||
parentDevice,
|
||||
this.hass.localize,
|
||||
this.hass.states
|
||||
)
|
||||
: undefined;
|
||||
const deviceName = device
|
||||
? computeDeviceNameDisplay(device, this.hass.localize, this.hass.states)
|
||||
: undefined;
|
||||
@@ -113,6 +119,13 @@ class HaMoreInfoDetails extends LitElement {
|
||||
href: `/config/areas/area/${area.area_id}`,
|
||||
});
|
||||
}
|
||||
if (parentDevice && parentDeviceName) {
|
||||
contextEntries.push({
|
||||
translationKey: "ui.dialogs.more_info_control.parent_device",
|
||||
value: parentDeviceName,
|
||||
href: `/config/devices/device/${parentDevice.id}`,
|
||||
});
|
||||
}
|
||||
if (device && deviceName) {
|
||||
contextEntries.push({
|
||||
translationKey: "ui.components.related-items.device",
|
||||
@@ -146,6 +159,7 @@ class HaMoreInfoDetails extends LitElement {
|
||||
context: {
|
||||
...(floorName ? { floor: floorName } : {}),
|
||||
...(areaName ? { area: areaName } : {}),
|
||||
...(parentDeviceName ? { parent_device: parentDeviceName } : {}),
|
||||
...(deviceName ? { device: deviceName } : {}),
|
||||
...(integrationName ? { integration: integrationName } : {}),
|
||||
},
|
||||
@@ -215,7 +229,7 @@ class HaMoreInfoDetails extends LitElement {
|
||||
stateObj: HassEntity
|
||||
): {
|
||||
stateEntries: DetailEntry[];
|
||||
attributes: string[];
|
||||
attributes: { name: string; label: string }[];
|
||||
yamlData: {
|
||||
state: {
|
||||
translated: string;
|
||||
@@ -228,11 +242,14 @@ class HaMoreInfoDetails extends LitElement {
|
||||
} => {
|
||||
const translatedState = this.hass.formatEntityState(stateObj);
|
||||
|
||||
const detailsAttributes = computeShownAttributes(stateObj);
|
||||
const detailsAttributeSet = new Set(detailsAttributes);
|
||||
const builtInAttributes = Object.keys(stateObj.attributes).filter(
|
||||
(attribute) => !detailsAttributeSet.has(attribute)
|
||||
);
|
||||
const attributes = Object.keys(stateObj.attributes)
|
||||
.map((a) => ({
|
||||
name: a,
|
||||
label: this.hass.formatEntityAttributeName(stateObj, a),
|
||||
}))
|
||||
.sort((a, b) =>
|
||||
stringCompare(a.label, b.label, this.hass.locale.language)
|
||||
);
|
||||
|
||||
return {
|
||||
stateEntries: [
|
||||
@@ -253,7 +270,7 @@ class HaMoreInfoDetails extends LitElement {
|
||||
value: this._formatTimestamp(stateObj.last_updated),
|
||||
},
|
||||
],
|
||||
attributes: [...detailsAttributes, ...builtInAttributes],
|
||||
attributes,
|
||||
yamlData: {
|
||||
state: {
|
||||
translated: translatedState,
|
||||
@@ -289,7 +306,7 @@ class HaMoreInfoDetails extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
private _renderAttributes(attributes: string[]) {
|
||||
private _renderAttributes(attributes: { name: string; label: string }[]) {
|
||||
if (attributes.length === 0) {
|
||||
return html`<div class="empty">
|
||||
${this.hass.localize("ui.common.none")}
|
||||
@@ -304,20 +321,13 @@ class HaMoreInfoDetails extends LitElement {
|
||||
|
||||
return attributes.map(
|
||||
(attribute) => html`
|
||||
<ha-list-item-value
|
||||
.label=${computeAttributeNameDisplay(
|
||||
this.hass.localize,
|
||||
this._stateObj!,
|
||||
this.hass.entities,
|
||||
attribute
|
||||
)}
|
||||
>
|
||||
<ha-list-item-value .label=${attribute.label}>
|
||||
${
|
||||
attribute === "supported_features" && featureEnum
|
||||
attribute.name === "supported_features" && featureEnum
|
||||
? this._renderFeatures(featureEnum, this._stateObj!)
|
||||
: html`
|
||||
<ha-attribute-value
|
||||
.attribute=${attribute}
|
||||
.attribute=${attribute.name}
|
||||
.stateObj=${this._stateObj}
|
||||
></ha-attribute-value>
|
||||
`
|
||||
|
||||
@@ -615,11 +615,17 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
const deviceName = context?.device
|
||||
? computeDeviceName(context.device)
|
||||
: undefined;
|
||||
const parentDeviceName = context?.parentDevice
|
||||
? computeDeviceName(context.parentDevice)
|
||||
: undefined;
|
||||
const areaName = context?.area ? computeAreaName(context.area) : undefined;
|
||||
|
||||
const breadcrumb = [areaName, deviceName, entityName].filter(
|
||||
(v): v is string => Boolean(v)
|
||||
);
|
||||
const breadcrumb = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
deviceName,
|
||||
entityName,
|
||||
].filter((v): v is string => Boolean(v));
|
||||
const defaultTitle = breadcrumb.pop() || entityId;
|
||||
const addToTitle = this.hass.localize(
|
||||
"ui.dialogs.more_info_control.add_to.title",
|
||||
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { domainToName } from "../../../data/integration";
|
||||
import type { RepairsIssue } from "../../../data/repairs";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import type { RepairsIssue } from "../../data/repairs";
|
||||
|
||||
@customElement("dialog-repairs-issue-subtitle")
|
||||
class DialogRepairsIssueSubtitle extends LitElement {
|
||||
+9
-8
@@ -1,18 +1,19 @@
|
||||
import { html, nothing } from "lit";
|
||||
import type { DataEntryFlowStep } from "../../../data/data_entry_flow";
|
||||
import { domainToName } from "../../../data/integration";
|
||||
import type { RepairsIssue } from "../../../data/repairs";
|
||||
import type { DataEntryFlowStep } from "../../data/data_entry_flow";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import type { RepairsIssue } from "../../data/repairs";
|
||||
import {
|
||||
createRepairsFlow,
|
||||
deleteRepairsFlow,
|
||||
fetchRepairsFlow,
|
||||
handleRepairsFlowStep,
|
||||
} from "../../../data/repairs";
|
||||
} from "../../data/repairs";
|
||||
import type { DataEntryFlowDialogParams } from "../config-flow/show-dialog-data-entry-flow";
|
||||
import {
|
||||
loadDataEntryFlowDialog,
|
||||
showFlowDialog,
|
||||
} from "../../../dialogs/config-flow/show-dialog-data-entry-flow";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
} from "../config-flow/show-dialog-data-entry-flow";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "./dialog-repairs-issue-subtitle";
|
||||
|
||||
const mergePlaceholders = (issue: RepairsIssue, step: DataEntryFlowStep) =>
|
||||
@@ -36,14 +37,14 @@ export const loadRepairFlowDialog = loadDataEntryFlowDialog;
|
||||
export const showRepairsFlowDialog = (
|
||||
element: HTMLElement,
|
||||
issue: RepairsIssue,
|
||||
dialogClosedCallback?: (params: { flowFinished: boolean }) => void
|
||||
dialogParams?: Omit<DataEntryFlowDialogParams, "flowConfig">
|
||||
): void =>
|
||||
showFlowDialog(
|
||||
element,
|
||||
{
|
||||
startFlowHandler: issue.domain,
|
||||
domain: issue.domain,
|
||||
dialogClosedCallback,
|
||||
...dialogParams,
|
||||
},
|
||||
{
|
||||
flowType: "repair_flow",
|
||||
@@ -28,8 +28,8 @@ import type {
|
||||
SortingDirection,
|
||||
} from "../components/data-table/ha-data-table";
|
||||
import { showDataTableSettingsDialog } from "../components/data-table/show-dialog-data-table-settings";
|
||||
import "../components/ha-adaptive-dialog";
|
||||
import "../components/ha-button";
|
||||
import "../components/ha-dialog";
|
||||
import "../components/ha-dialog-footer";
|
||||
import "../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../components/ha-dropdown";
|
||||
@@ -566,16 +566,17 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
</hass-tabs-subpage>
|
||||
${
|
||||
this.showFilters && !showPane
|
||||
? html`<ha-dialog
|
||||
.open=${true}
|
||||
? html`<ha-adaptive-dialog
|
||||
open
|
||||
flexcontent
|
||||
width="full"
|
||||
header-title=${localize("ui.components.subpage-data-table.filters")}
|
||||
@closed=${this._closeFilters}
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="headerNavigationIcon"
|
||||
data-dialog="close"
|
||||
.path=${mdiClose}
|
||||
@click=${this._closeFilters}
|
||||
.label=${localize(
|
||||
"ui.components.subpage-data-table.close_filter"
|
||||
)}
|
||||
@@ -596,13 +597,13 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
<slot name="filter-pane"></slot>
|
||||
</div>
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button slot="primaryAction" @click=${this._closeFilters}>
|
||||
<ha-button slot="primaryAction" data-dialog="close">
|
||||
${localize("ui.components.subpage-data-table.show_results", {
|
||||
number: this.data.length,
|
||||
})}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-dialog>`
|
||||
</ha-adaptive-dialog>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
@@ -934,20 +935,19 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
--md-assist-chip-trailing-space: 8px;
|
||||
}
|
||||
|
||||
ha-dialog {
|
||||
ha-adaptive-dialog {
|
||||
--dialog-content-padding: 0;
|
||||
/* Fixed height so the sheet does not resize while filtering. */
|
||||
--ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12));
|
||||
--ha-dialog-min-height: calc(var(--safe-height) - var(--ha-space-20));
|
||||
}
|
||||
|
||||
.filter-dialog-content {
|
||||
height: calc(
|
||||
100vh -
|
||||
70px - var(--header-height, 0px) - var(
|
||||
--safe-area-inset-top,
|
||||
0px
|
||||
) - var(--safe-area-inset-bottom, 0px)
|
||||
);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
ha-dropdown ha-assist-chip {
|
||||
|
||||
@@ -977,16 +977,26 @@ class DialogAddAutomationElement
|
||||
);
|
||||
} else {
|
||||
const stateObj = this.hass.states[targetId];
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
|
||||
subtitle = [areaName, entityName ? deviceName : undefined]
|
||||
subtitle = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(
|
||||
computeRTL(
|
||||
|
||||
@@ -124,7 +124,7 @@ class HaBackupConfigData extends LitElement {
|
||||
|
||||
private async _fetchStorageInfo() {
|
||||
try {
|
||||
this._storageInfo = await fetchHostDisksUsage(this.hass);
|
||||
this._storageInfo = await fetchHostDisksUsage(this.hass, "default", 3);
|
||||
} catch (_err: any) {
|
||||
this._storageInfo = null;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
mdiBackupRestore,
|
||||
mdiBadgeAccountHorizontal,
|
||||
mdiBluetooth,
|
||||
mdiCableData,
|
||||
mdiCellphoneCog,
|
||||
mdiCog,
|
||||
mdiDatabase,
|
||||
@@ -168,6 +169,14 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
translationKey: "bluetooth",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/serial",
|
||||
iconPath: mdiCableData,
|
||||
iconColor: "#607D8B",
|
||||
component: "usb",
|
||||
translationKey: "serial",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/infrared",
|
||||
iconPath: mdiRemote,
|
||||
|
||||
@@ -31,6 +31,7 @@ const SEARCH_KEYS = [
|
||||
{ name: "search_labels.entityName", weight: 10 },
|
||||
{ name: "search_labels.friendlyName", weight: 9 },
|
||||
{ name: "search_labels.deviceName", weight: 8 },
|
||||
{ name: "search_labels.parentDeviceName", weight: 6 },
|
||||
{ name: "search_labels.areaName", weight: 6 },
|
||||
{ name: "search_labels.domainName", weight: 4 },
|
||||
{ name: "id", weight: 2 },
|
||||
@@ -64,14 +65,20 @@ export class HaEnergyUpstreamDevicePicker extends LitElement {
|
||||
const stateObj = this.hass.states[statisticId];
|
||||
|
||||
if (stateObj) {
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
|
||||
@@ -89,6 +96,7 @@ export class HaEnergyUpstreamDevicePicker extends LitElement {
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
friendlyName,
|
||||
},
|
||||
|
||||
@@ -160,11 +160,17 @@ export class DialogVacuumSegmentMapping
|
||||
const deviceName = context?.device
|
||||
? computeDeviceName(context.device)
|
||||
: undefined;
|
||||
const parentDeviceName = context?.parentDevice
|
||||
? computeDeviceName(context.parentDevice)
|
||||
: undefined;
|
||||
const areaName = context?.area ? computeAreaName(context.area) : undefined;
|
||||
|
||||
const breadcrumb = [areaName, deviceName, entityName].filter(
|
||||
(v): v is string => Boolean(v)
|
||||
);
|
||||
const breadcrumb = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
deviceName,
|
||||
entityName,
|
||||
].filter((v): v is string => Boolean(v));
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
|
||||
@@ -1393,7 +1393,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
invert: this._switchAsInvert,
|
||||
target_domain: this._switchAsDomain,
|
||||
}
|
||||
)) as DataEntryFlowStepCreateEntry;
|
||||
)) as DataEntryFlowStepCreateEntry<ConfigEntry>;
|
||||
if (configFlowResult.result?.entry_id) {
|
||||
try {
|
||||
const entry = await this._waitForEntityRegistryUpdate(
|
||||
@@ -1459,7 +1459,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
invert: this._switchAsInvert,
|
||||
target_domain: this._switchAsDomain,
|
||||
}
|
||||
)) as DataEntryFlowStepCreateEntry;
|
||||
)) as DataEntryFlowStepCreateEntry<ConfigEntry>;
|
||||
if (configFlowResult.result?.entry_id) {
|
||||
try {
|
||||
const entry = await this._waitForEntityRegistryUpdate(
|
||||
|
||||
@@ -212,6 +212,11 @@ class HaPanelConfig extends HassRouterPage {
|
||||
load: () =>
|
||||
import("./integrations/integration-panels/infrared/infrared-config-dashboard-router"),
|
||||
},
|
||||
serial: {
|
||||
tag: "serial-config-dashboard",
|
||||
load: () =>
|
||||
import("./integrations/integration-panels/serial/serial-config-dashboard"),
|
||||
},
|
||||
dhcp: {
|
||||
tag: "dhcp-config-panel",
|
||||
load: () =>
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import { copyToClipboard } from "../../../../../common/util/copy-clipboard";
|
||||
import "../../../../../components/ha-button";
|
||||
import "../../../../../components/ha-dialog";
|
||||
import "../../../../../components/ha-dialog-footer";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import { showToast } from "../../../../../util/toast";
|
||||
import type { SerialPortInfoDialogParams } from "./show-dialog-serial-port-info";
|
||||
|
||||
@customElement("dialog-serial-port-info")
|
||||
class DialogSerialPortInfo extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _params?: SerialPortInfoDialogParams;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
public showDialog(params: SerialPortInfoDialogParams): void {
|
||||
this._params = params;
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
public closeDialog(): void {
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._params = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
private async _copyToClipboard(): Promise<void> {
|
||||
await copyToClipboard(JSON.stringify(this._params!.port, null, 2));
|
||||
showToast(this, {
|
||||
message: this.hass.localize("ui.common.copied_clipboard"),
|
||||
});
|
||||
}
|
||||
|
||||
private _fields(): [string, string][] {
|
||||
const port = this._params!.port;
|
||||
const localize = this.hass.localize;
|
||||
|
||||
const fields: [string, string | null | undefined][] = [
|
||||
[localize("ui.panel.config.serial.fields.device"), port.device],
|
||||
[
|
||||
localize("ui.panel.config.serial.fields.resolved_device"),
|
||||
port.resolved_device === port.device ? undefined : port.resolved_device,
|
||||
],
|
||||
[localize("ui.panel.config.serial.fields.description"), port.description],
|
||||
[
|
||||
localize("ui.panel.config.serial.fields.manufacturer"),
|
||||
port.manufacturer,
|
||||
],
|
||||
[
|
||||
localize("ui.panel.config.serial.fields.interface_description"),
|
||||
port.interface_description,
|
||||
],
|
||||
[
|
||||
localize("ui.panel.config.serial.fields.interface_number"),
|
||||
port.interface_num?.toString(),
|
||||
],
|
||||
[
|
||||
localize("ui.panel.config.serial.fields.serial_number"),
|
||||
port.serial_number,
|
||||
],
|
||||
[localize("ui.panel.config.serial.fields.vid"), port.vid],
|
||||
[localize("ui.panel.config.serial.fields.pid"), port.pid],
|
||||
[
|
||||
localize("ui.panel.config.serial.fields.bcd_device"),
|
||||
port.bcd_device == null
|
||||
? undefined
|
||||
: port.bcd_device.toString(16).padStart(4, "0").toUpperCase(),
|
||||
],
|
||||
];
|
||||
|
||||
return fields.filter(([, value]) => value) as [string, string][];
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | typeof nothing {
|
||||
if (!this._params) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
header-title=${this.hass.localize(
|
||||
"ui.panel.config.serial.port_information"
|
||||
)}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<table>
|
||||
<tbody>
|
||||
${this._fields().map(
|
||||
([label, value]) => html`
|
||||
<tr>
|
||||
<th>${label}</th>
|
||||
<td>${value}</td>
|
||||
</tr>
|
||||
`
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
appearance="plain"
|
||||
@click=${this._copyToClipboard}
|
||||
>
|
||||
${this.hass.localize("ui.common.copy")}
|
||||
</ha-button>
|
||||
<ha-button slot="primaryAction" @click=${this.closeDialog}>
|
||||
${this.hass.localize("ui.common.close")}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
static readonly styles: CSSResultGroup = css`
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: start;
|
||||
vertical-align: top;
|
||||
white-space: nowrap;
|
||||
padding-inline-end: var(--ha-space-4);
|
||||
color: var(--secondary-text-color);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
}
|
||||
|
||||
td {
|
||||
width: 100%;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
tr:not(:first-child) th,
|
||||
tr:not(:first-child) td {
|
||||
padding-top: var(--ha-space-2);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"dialog-serial-port-info": DialogSerialPortInfo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
import {
|
||||
mdiAlertCircleOutline,
|
||||
mdiCableData,
|
||||
mdiCheck,
|
||||
mdiConnection,
|
||||
mdiInformationOutline,
|
||||
mdiMemory,
|
||||
mdiPowerPlugOff,
|
||||
mdiPuzzle,
|
||||
mdiRefresh,
|
||||
mdiUsb,
|
||||
} from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { caseInsensitiveStringCompare } from "../../../../../common/string/compare";
|
||||
import "../../../../../components/ha-alert";
|
||||
import "../../../../../components/ha-card";
|
||||
import "../../../../../components/ha-icon-button";
|
||||
import "../../../../../components/ha-icon-next";
|
||||
import "../../../../../components/ha-md-list";
|
||||
import "../../../../../components/ha-md-list-item";
|
||||
import "../../../../../components/ha-spinner";
|
||||
import "../../../../../components/ha-svg-icon";
|
||||
import { domainToName } from "../../../../../data/integration";
|
||||
import type {
|
||||
SerialPort,
|
||||
SerialPortConsumer,
|
||||
SerialPortDiscoveryFlow,
|
||||
SerialPortUsage,
|
||||
} from "../../../../../data/usb";
|
||||
import { listSerialPortsWithUsage } from "../../../../../data/usb";
|
||||
import { showConfigFlowDialog } from "../../../../../dialogs/config-flow/show-dialog-config-flow";
|
||||
import { mdiEsphomeLogo } from "../../../../../resources/esphome-logo-svg";
|
||||
import { showSerialPortInfoDialog } from "./show-dialog-serial-port-info";
|
||||
import "../../../../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../../../../resources/styles";
|
||||
import type { HomeAssistant, Route } from "../../../../../types";
|
||||
import { brandsUrl } from "../../../../../util/brands-url";
|
||||
|
||||
const ESPHOME_HASS_SCHEME = "esphome-hass://";
|
||||
|
||||
type SerialPortType =
|
||||
"serial_proxy" | "integration" | "usb" | "embedded" | "unnamed";
|
||||
|
||||
const TYPE_ICONS: Record<SerialPortType, string> = {
|
||||
serial_proxy: mdiEsphomeLogo,
|
||||
integration: mdiConnection,
|
||||
usb: mdiUsb,
|
||||
embedded: mdiMemory,
|
||||
unnamed: mdiMemory,
|
||||
};
|
||||
|
||||
const getPortType = (port: SerialPort): SerialPortType => {
|
||||
if (port.device.startsWith(ESPHOME_HASS_SCHEME)) {
|
||||
return "serial_proxy";
|
||||
}
|
||||
if (port.device.includes("://")) {
|
||||
return "integration";
|
||||
}
|
||||
if (port.vid || port.pid) {
|
||||
return "usb";
|
||||
}
|
||||
if (port.description || port.manufacturer) {
|
||||
return "embedded";
|
||||
}
|
||||
return "unnamed";
|
||||
};
|
||||
|
||||
interface PortListItem {
|
||||
icon: string;
|
||||
primary: string;
|
||||
serialNumber?: string;
|
||||
matchingIntegrations: string[];
|
||||
consumers: SerialPortConsumer[];
|
||||
discoveryFlows: SerialPortDiscoveryFlow[];
|
||||
port: SerialPortUsage;
|
||||
}
|
||||
|
||||
@customElement("serial-config-dashboard")
|
||||
export class SerialConfigDashboard extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
|
||||
|
||||
@state() private _ports?: SerialPortUsage[];
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
protected firstUpdated(): void {
|
||||
this._fetchPorts();
|
||||
}
|
||||
|
||||
private async _fetchPorts(): Promise<void> {
|
||||
try {
|
||||
this._ports = await listSerialPortsWithUsage(this.hass);
|
||||
this._error = undefined;
|
||||
} catch (err: any) {
|
||||
this._error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
private _portListItem(
|
||||
port: SerialPortUsage,
|
||||
localize: HomeAssistant["localize"]
|
||||
): PortListItem {
|
||||
const type = getPortType(port);
|
||||
|
||||
const productManufacturer =
|
||||
port.description && port.manufacturer
|
||||
? `${port.description} — ${port.manufacturer}`
|
||||
: port.description || port.manufacturer;
|
||||
|
||||
let primary: string;
|
||||
if (
|
||||
port.interface_description &&
|
||||
port.interface_description !== port.description
|
||||
) {
|
||||
primary = port.interface_description;
|
||||
} else {
|
||||
primary = productManufacturer || port.device;
|
||||
}
|
||||
|
||||
return {
|
||||
icon: port.present ? TYPE_ICONS[type] : mdiPowerPlugOff,
|
||||
primary,
|
||||
// The full port details are shown in the info dialog
|
||||
serialNumber: port.serial_number
|
||||
? localize("ui.panel.config.serial.serial_number", {
|
||||
serial_number: port.serial_number,
|
||||
})
|
||||
: undefined,
|
||||
matchingIntegrations: port.matching_integrations,
|
||||
consumers: port.consumers,
|
||||
discoveryFlows: port.discovery_flows,
|
||||
port,
|
||||
};
|
||||
}
|
||||
|
||||
private _sortedPorts = memoizeOne(
|
||||
(
|
||||
ports: SerialPortUsage[],
|
||||
localize: HomeAssistant["localize"],
|
||||
language: string
|
||||
): {
|
||||
available: PortListItem[];
|
||||
connected: PortListItem[];
|
||||
disconnected: PortListItem[];
|
||||
} => {
|
||||
const available: PortListItem[] = [];
|
||||
const connected: PortListItem[] = [];
|
||||
const disconnected: PortListItem[] = [];
|
||||
|
||||
for (const port of ports) {
|
||||
let bucket: PortListItem[];
|
||||
if (!port.present) {
|
||||
bucket = disconnected;
|
||||
} else if (port.consumers.length) {
|
||||
bucket = connected;
|
||||
} else {
|
||||
bucket = available;
|
||||
}
|
||||
bucket.push(this._portListItem(port, localize));
|
||||
}
|
||||
|
||||
const byPrimary = (a: PortListItem, b: PortListItem) =>
|
||||
caseInsensitiveStringCompare(a.primary, b.primary, language);
|
||||
available.sort(byPrimary);
|
||||
connected.sort(byPrimary);
|
||||
disconnected.sort(byPrimary);
|
||||
|
||||
return { available, connected, disconnected };
|
||||
}
|
||||
);
|
||||
|
||||
private _consumerName(consumer: SerialPortConsumer): string {
|
||||
const name =
|
||||
consumer.title ||
|
||||
(consumer.domain
|
||||
? domainToName(this.hass.localize, consumer.domain)
|
||||
: consumer.slug!);
|
||||
|
||||
return consumer.active
|
||||
? name
|
||||
: this.hass.localize("ui.panel.config.serial.consumer_not_running", {
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
private _renderConsumer(consumer: SerialPortConsumer): TemplateResult {
|
||||
const href =
|
||||
consumer.kind === "config_entry"
|
||||
? `/config/integrations/integration/${consumer.domain}#config_entry=${consumer.config_entry_id}`
|
||||
: `/config/app/${consumer.slug}/info`;
|
||||
|
||||
return html`
|
||||
<ha-md-list-item type="link" href=${href} class="consumer">
|
||||
${
|
||||
consumer.kind === "config_entry"
|
||||
? html`<img
|
||||
slot="start"
|
||||
.src=${brandsUrl(
|
||||
{
|
||||
domain: consumer.domain!,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
alt=${consumer.domain!}
|
||||
/>`
|
||||
: html`<ha-svg-icon slot="start" .path=${mdiPuzzle}></ha-svg-icon>`
|
||||
}
|
||||
<div slot="headline">${this._consumerName(consumer)}</div>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-md-list-item>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderDiscoveryFlow(flow: SerialPortDiscoveryFlow): TemplateResult {
|
||||
return html`
|
||||
<ha-md-list-item
|
||||
type="button"
|
||||
class="consumer"
|
||||
.flowId=${flow.flow_id}
|
||||
@click=${this._continueFlow}
|
||||
>
|
||||
<img
|
||||
slot="start"
|
||||
.src=${brandsUrl(
|
||||
{
|
||||
domain: flow.domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
alt=${flow.domain}
|
||||
/>
|
||||
<div slot="headline">
|
||||
${this.hass.localize("ui.panel.config.serial.discovered_by", {
|
||||
integration: domainToName(this.hass.localize, flow.domain),
|
||||
})}
|
||||
</div>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-md-list-item>
|
||||
`;
|
||||
}
|
||||
|
||||
private _continueFlow(ev: Event): void {
|
||||
showConfigFlowDialog(this, {
|
||||
continueFlowId: (ev.currentTarget as any).flowId,
|
||||
dialogClosedCallback: () => {
|
||||
this._fetchPorts();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _renderPortItem(item: PortListItem): TemplateResult {
|
||||
const discoveredDomains = new Set(
|
||||
item.discoveryFlows.map((flow) => flow.domain)
|
||||
);
|
||||
const remainingIntegrations = item.matchingIntegrations.filter(
|
||||
(domain) => !discoveredDomains.has(domain)
|
||||
);
|
||||
|
||||
const matchingIntegrations =
|
||||
!item.consumers.length && remainingIntegrations.length
|
||||
? remainingIntegrations
|
||||
.map((domain) => domainToName(this.hass.localize, domain))
|
||||
.join(", ")
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
<ha-md-list-item class="port">
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
class=${item.port.present ? "" : "disconnected"}
|
||||
.path=${item.icon}
|
||||
></ha-svg-icon>
|
||||
<div slot="headline">${item.primary}</div>
|
||||
${
|
||||
item.serialNumber
|
||||
? html`<div slot="supporting-text">${item.serialNumber}</div>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
matchingIntegrations
|
||||
? html`<div slot="supporting-text">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.serial.can_be_used_with",
|
||||
{
|
||||
integrations: matchingIntegrations,
|
||||
}
|
||||
)}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
item.port.present
|
||||
? html`<ha-icon-button
|
||||
slot="end"
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.serial.port_information"
|
||||
)}
|
||||
.path=${mdiInformationOutline}
|
||||
.port=${item.port}
|
||||
@click=${this._showPortInfo}
|
||||
></ha-icon-button>`
|
||||
: nothing
|
||||
}
|
||||
</ha-md-list-item>
|
||||
${item.consumers.map((consumer) => this._renderConsumer(consumer))}
|
||||
${item.discoveryFlows.map((flow) => this._renderDiscoveryFlow(flow))}
|
||||
`;
|
||||
}
|
||||
|
||||
private _showPortInfo(ev: Event): void {
|
||||
showSerialPortInfoDialog(this, {
|
||||
port: (ev.currentTarget as any).port,
|
||||
});
|
||||
}
|
||||
|
||||
private _renderPortsCard(
|
||||
header: string,
|
||||
description: string,
|
||||
items: PortListItem[]
|
||||
): TemplateResult {
|
||||
return html`
|
||||
<ha-card class="ports">
|
||||
<div class="card-header">${header}</div>
|
||||
<div class="card-content">
|
||||
<div class="description">${description}</div>
|
||||
<ha-md-list>
|
||||
${items.map((item) => this._renderPortItem(item))}
|
||||
</ha-md-list>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderStatusCard(
|
||||
inUse: number,
|
||||
available: number,
|
||||
disconnected: number
|
||||
): TemplateResult {
|
||||
const status = disconnected ? "disconnected" : "ok";
|
||||
|
||||
let summary = this.hass.localize("ui.panel.config.serial.status_summary", {
|
||||
in_use: inUse,
|
||||
total: inUse + available,
|
||||
});
|
||||
|
||||
if (disconnected) {
|
||||
summary += ` · ${this.hass.localize(
|
||||
"ui.panel.config.serial.status_summary_disconnected",
|
||||
{ count: disconnected }
|
||||
)}`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-card class="status">
|
||||
<div class="card-content">
|
||||
<div class="heading">
|
||||
<div class="icon ${status}">
|
||||
<ha-svg-icon
|
||||
.path=${disconnected ? mdiAlertCircleOutline : mdiCheck}
|
||||
></ha-svg-icon>
|
||||
</div>
|
||||
<div class="details">
|
||||
${this.hass.localize(`ui.panel.config.serial.status_${status}`)}
|
||||
<br />
|
||||
<small>${summary}</small>
|
||||
</div>
|
||||
<ha-svg-icon class="logo" .path=${mdiCableData}></ha-svg-icon>
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.header=${this.hass.localize("ui.panel.config.serial.title")}
|
||||
back-path="/config"
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="toolbar-icon"
|
||||
.label=${this.hass.localize("ui.common.refresh")}
|
||||
.path=${mdiRefresh}
|
||||
@click=${this._handleRefresh}
|
||||
></ha-icon-button>
|
||||
<div class="container">${this._renderContent()}</div>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderContent(): TemplateResult {
|
||||
if (this._error !== undefined) {
|
||||
return html`
|
||||
<ha-alert
|
||||
alert-type="error"
|
||||
.title=${this.hass.localize("ui.panel.config.serial.loading_error")}
|
||||
>
|
||||
${this._error}
|
||||
</ha-alert>
|
||||
`;
|
||||
}
|
||||
|
||||
if (!this._ports) {
|
||||
return html`
|
||||
<div class="loading">
|
||||
<ha-spinner></ha-spinner>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const { available, connected, disconnected } = this._sortedPorts(
|
||||
this._ports,
|
||||
this.hass.localize,
|
||||
this.hass.locale.language
|
||||
);
|
||||
|
||||
if (!available.length && !connected.length && !disconnected.length) {
|
||||
return html`
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<div class="empty">
|
||||
${this.hass.localize("ui.panel.config.serial.no_ports")}
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
${this._renderStatusCard(
|
||||
connected.length,
|
||||
available.length,
|
||||
disconnected.length
|
||||
)}
|
||||
${
|
||||
connected.length
|
||||
? this._renderPortsCard(
|
||||
this.hass.localize("ui.panel.config.serial.connected"),
|
||||
this.hass.localize(
|
||||
"ui.panel.config.serial.connected_description"
|
||||
),
|
||||
connected
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
available.length
|
||||
? this._renderPortsCard(
|
||||
this.hass.localize("ui.panel.config.serial.available"),
|
||||
this.hass.localize(
|
||||
"ui.panel.config.serial.available_description"
|
||||
),
|
||||
available
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
disconnected.length
|
||||
? this._renderPortsCard(
|
||||
this.hass.localize("ui.panel.config.serial.disconnected"),
|
||||
this.hass.localize(
|
||||
"ui.panel.config.serial.disconnected_description"
|
||||
),
|
||||
disconnected
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleRefresh(): void {
|
||||
this._fetchPorts();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
css`
|
||||
.container {
|
||||
padding: var(--ha-space-2) var(--ha-space-4) var(--ha-space-4);
|
||||
}
|
||||
|
||||
ha-card,
|
||||
ha-alert {
|
||||
display: block;
|
||||
margin: 0px auto var(--ha-space-4);
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
ha-card:first-child {
|
||||
margin-top: var(--ha-space-6);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--ha-space-12) 0;
|
||||
}
|
||||
|
||||
ha-card.ports {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
ha-card.ports .card-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
ha-card.ports .card-header {
|
||||
padding-bottom: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.status div.heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.status div.heading .logo {
|
||||
margin-inline-start: auto;
|
||||
--mdc-icon-size: 40px;
|
||||
}
|
||||
|
||||
.status div.heading .icon {
|
||||
position: relative;
|
||||
border-radius: var(--ha-border-radius-2xl);
|
||||
width: var(--ha-space-10);
|
||||
height: var(--ha-space-10);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
--icon-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.status div.heading .icon.ok {
|
||||
--icon-color: var(--success-color);
|
||||
}
|
||||
|
||||
.status div.heading .icon.disconnected {
|
||||
--icon-color: var(--warning-color);
|
||||
}
|
||||
|
||||
.status div.heading .icon::before {
|
||||
display: block;
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: var(--icon-color);
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.status div.heading .icon ha-svg-icon {
|
||||
color: var(--icon-color);
|
||||
width: var(--ha-space-6);
|
||||
height: var(--ha-space-6);
|
||||
}
|
||||
|
||||
.status div.heading .details {
|
||||
font-size: var(--ha-font-size-xl);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.status small {
|
||||
font-size: var(--ha-font-size-m);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
letter-spacing: 0.25px;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
ha-md-list {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
ha-md-list-item {
|
||||
--md-list-item-top-space: var(--ha-space-2);
|
||||
--md-list-item-bottom-space: var(--ha-space-2);
|
||||
--md-list-item-one-line-container-height: 0;
|
||||
--md-list-item-two-line-container-height: 0;
|
||||
--md-list-item-three-line-container-height: 0;
|
||||
}
|
||||
|
||||
ha-md-list-item.port:not(:first-child) {
|
||||
border-top: 1px solid var(--divider-color);
|
||||
margin-top: var(--ha-space-2);
|
||||
--md-list-item-top-space: var(--ha-space-4);
|
||||
}
|
||||
|
||||
ha-md-list-item .disconnected {
|
||||
color: var(--disabled-text-color);
|
||||
}
|
||||
|
||||
ha-md-list-item.consumer {
|
||||
--md-list-item-leading-space: var(--ha-space-14);
|
||||
}
|
||||
|
||||
ha-md-list-item.consumer img[slot="start"] {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.description {
|
||||
padding: 0 var(--ha-space-4) var(--ha-space-2);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: var(--ha-space-4);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"serial-config-dashboard": SerialConfigDashboard;
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import type { SerialPortUsage } from "../../../../../data/usb";
|
||||
|
||||
export interface SerialPortInfoDialogParams {
|
||||
port: SerialPortUsage;
|
||||
}
|
||||
|
||||
export const loadSerialPortInfoDialog = () =>
|
||||
import("./dialog-serial-port-info");
|
||||
|
||||
export const showSerialPortInfoDialog = (
|
||||
element: HTMLElement,
|
||||
serialPortInfoDialogParams: SerialPortInfoDialogParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "dialog-serial-port-info",
|
||||
dialogImport: loadSerialPortInfoDialog,
|
||||
dialogParams: serialPortInfoDialogParams,
|
||||
});
|
||||
};
|
||||
@@ -13,7 +13,7 @@ import "../../../components/ha-dialog";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import "../../../components/ha-dialog-footer";
|
||||
import "./dialog-repairs-issue-subtitle";
|
||||
import "../../../dialogs/repairs-flow/dialog-repairs-issue-subtitle";
|
||||
import "../../../components/ha-markdown";
|
||||
import type { RepairsIssue } from "../../../data/repairs";
|
||||
import { ignoreRepairsIssue } from "../../../data/repairs";
|
||||
|
||||
@@ -20,7 +20,7 @@ import type { HomeAssistant } from "../../../types";
|
||||
import { brandsUrl } from "../../../util/brands-url";
|
||||
import { fixStatisticsIssue } from "../tools/statistics/fix-statistics";
|
||||
import { showVacuumSegmentMappingDialog } from "../entities/dialogs/show-dialog-vacuum-segment-mapping";
|
||||
import { showRepairsFlowDialog } from "./show-dialog-repair-flow";
|
||||
import { showRepairsFlowDialog } from "../../../dialogs/repairs-flow/show-dialog-repair-flow";
|
||||
import { showRepairsIssueDialog } from "./show-repair-issue-dialog";
|
||||
|
||||
@customElement("ha-config-repairs")
|
||||
|
||||
@@ -9,10 +9,12 @@ import {
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import { blankBeforePercent } from "../../../common/translations/blank_before_percent";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-bar";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-icon-next";
|
||||
@@ -20,6 +22,7 @@ import "../../../components/ha-list";
|
||||
import "../../../components/ha-list-item";
|
||||
import "../../../components/ha-segmented-bar";
|
||||
import type { Segment } from "../../../components/ha-segmented-bar";
|
||||
import "../../../components/ha-spinner";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import { extractApiErrorMessage } from "../../../data/hassio/common";
|
||||
import type { HassioHostInfo, HostDisksUsage } from "../../../data/hassio/host";
|
||||
@@ -41,6 +44,7 @@ import {
|
||||
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||
import "../../../layouts/hass-subpage";
|
||||
import type { HomeAssistant, Route } from "../../../types";
|
||||
import { bytesToString } from "../../../util/bytes-to-string";
|
||||
import "../core/ha-config-analytics";
|
||||
import { showMoveDatadiskDialog } from "./show-dialog-move-datadisk";
|
||||
import { showMountViewDialog } from "./show-dialog-view-mount";
|
||||
@@ -62,6 +66,13 @@ class HaConfigSectionStorage extends LitElement {
|
||||
|
||||
@state() private _mountsInfo?: SupervisorMounts | null;
|
||||
|
||||
// Keyed by mount name. A missing key means the request is still in flight;
|
||||
// null means it failed, and that row simply shows no usage.
|
||||
@state() private _mountUsage: Record<string, HostDisksUsage | null> = {};
|
||||
|
||||
// Guards against a slow response from a previous reload landing in a newer one.
|
||||
private _mountUsageGeneration = 0;
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
if (isComponentLoaded(this.hass.config, "hassio")) {
|
||||
@@ -173,6 +184,7 @@ class HaConfigSectionStorage extends LitElement {
|
||||
graphic="avatar"
|
||||
.mount=${mount}
|
||||
twoline
|
||||
multiline-secondary
|
||||
hasMeta
|
||||
@click=${this._changeMount}
|
||||
>
|
||||
@@ -193,13 +205,16 @@ class HaConfigSectionStorage extends LitElement {
|
||||
${mount.name}
|
||||
</span>
|
||||
<span slot="secondary">
|
||||
${mount.server}${
|
||||
mount.port ? `:${mount.port}` : nothing
|
||||
}${
|
||||
mount.type === SupervisorMountType.NFS
|
||||
? mount.path
|
||||
: `:${mount.share}`
|
||||
}
|
||||
<span class="mount-address">
|
||||
${mount.server}${
|
||||
mount.port ? `:${mount.port}` : ""
|
||||
}${
|
||||
mount.type === SupervisorMountType.NFS
|
||||
? mount.path
|
||||
: `:${mount.share}`
|
||||
}
|
||||
</span>
|
||||
${this._renderMountUsage(mount)}
|
||||
</span>
|
||||
${
|
||||
mount.state !== SupervisorMountState.ACTIVE
|
||||
@@ -289,6 +304,39 @@ class HaConfigSectionStorage extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderMountUsage(mount: SupervisorMount) {
|
||||
if (mount.state !== SupervisorMountState.ACTIVE) {
|
||||
return nothing;
|
||||
}
|
||||
if (!(mount.name in this._mountUsage)) {
|
||||
return html`<div class="mount-usage">
|
||||
<ha-spinner size="tiny"></ha-spinner>
|
||||
</div>`;
|
||||
}
|
||||
const usage = this._mountUsage[mount.name];
|
||||
// Without a total there is no ratio to show, so show nothing rather than a
|
||||
// bar that means something else.
|
||||
if (!usage?.total_bytes) {
|
||||
return nothing;
|
||||
}
|
||||
const percent = (usage.used_bytes / usage.total_bytes) * 100;
|
||||
return html`<div class="mount-usage">
|
||||
<ha-bar
|
||||
class=${classMap({
|
||||
"target-warning": percent > 85,
|
||||
"target-critical": percent > 95,
|
||||
})}
|
||||
.value=${percent}
|
||||
></ha-bar>
|
||||
<span>
|
||||
${this.hass.localize("ui.panel.config.storage.detailed_description", {
|
||||
used: bytesToString(usage.used_bytes),
|
||||
total: bytesToString(usage.total_bytes),
|
||||
})}
|
||||
</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private async _load() {
|
||||
this._loadStorageInfo();
|
||||
try {
|
||||
@@ -305,7 +353,7 @@ class HaConfigSectionStorage extends LitElement {
|
||||
|
||||
private async _loadStorageInfo() {
|
||||
try {
|
||||
this._storageInfo = await fetchHostDisksUsage(this.hass);
|
||||
this._storageInfo = await fetchHostDisksUsage(this.hass, "default", 3);
|
||||
} catch (err: any) {
|
||||
this._error = err.message || err;
|
||||
this._storageInfo = null;
|
||||
@@ -361,6 +409,34 @@ class HaConfigSectionStorage extends LitElement {
|
||||
this._error = err.message || err;
|
||||
this._mountsInfo = null;
|
||||
}
|
||||
this._loadMountUsage();
|
||||
}
|
||||
|
||||
// Deliberately not awaited: a mount on a slow or unreachable server can take
|
||||
// ~30 s to answer, and the rows must paint before then. Only active mounts are
|
||||
// asked, since the endpoint has nothing to report for the others.
|
||||
private _loadMountUsage(): void {
|
||||
const generation = ++this._mountUsageGeneration;
|
||||
this._mountUsage = {};
|
||||
this._mountsInfo?.mounts
|
||||
.filter((mount) => mount.state === SupervisorMountState.ACTIVE)
|
||||
.forEach((mount) => {
|
||||
fetchHostDisksUsage(this.hass, mount.name).then(
|
||||
(usage) => this._setMountUsage(generation, mount.name, usage),
|
||||
() => this._setMountUsage(generation, mount.name, null)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private _setMountUsage(
|
||||
generation: number,
|
||||
name: string,
|
||||
usage: HostDisksUsage | null
|
||||
): void {
|
||||
if (generation !== this._mountUsageGeneration) {
|
||||
return;
|
||||
}
|
||||
this._mountUsage = { ...this._mountUsage, [name]: usage };
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
@@ -409,6 +485,43 @@ class HaConfigSectionStorage extends LitElement {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
/* multiline-secondary lets the secondary slot wrap, so the address keeps its
|
||||
own single ellipsized line and only the usage sits below it. */
|
||||
.mount-address {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mount-usage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
margin-top: var(--ha-space-1);
|
||||
}
|
||||
|
||||
.mount-usage ha-bar {
|
||||
flex: 0 0 72px;
|
||||
display: flex;
|
||||
align-self: center;
|
||||
--ha-bar-primary-color: var(--metric-bar-ok-color, var(--success-color));
|
||||
}
|
||||
|
||||
.mount-usage ha-bar.target-warning {
|
||||
--ha-bar-primary-color: var(
|
||||
--metric-bar-warning-color,
|
||||
var(--warning-color)
|
||||
);
|
||||
}
|
||||
|
||||
.mount-usage ha-bar.target-critical {
|
||||
--ha-bar-primary-color: var(
|
||||
--metric-bar-critical-color,
|
||||
var(--error-color)
|
||||
);
|
||||
}
|
||||
|
||||
.mounts-not-supported {
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
@@ -195,7 +195,12 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
|
||||
|
||||
const nameList = computeEntityNameList(
|
||||
entity,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this._registries.entities,
|
||||
this._registries.devices,
|
||||
this._registries.areas,
|
||||
@@ -218,13 +223,18 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
|
||||
continue;
|
||||
}
|
||||
|
||||
const [, deviceName, areaName] = nameList;
|
||||
const [, deviceName, parentDeviceName, areaName] = nameList;
|
||||
|
||||
if (deviceName?.toLowerCase().includes(lowerFilter)) {
|
||||
result.push({ entity, nameList });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parentDeviceName?.toLowerCase().includes(lowerFilter)) {
|
||||
result.push({ entity, nameList });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (areaName?.toLowerCase().includes(lowerFilter)) {
|
||||
result.push({ entity, nameList });
|
||||
continue;
|
||||
@@ -237,14 +247,18 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
|
||||
|
||||
private _renderItem = (item: FilteredEntity) => {
|
||||
const { entity: entityState, nameList } = item;
|
||||
const [entityName, deviceName, areaName] = nameList;
|
||||
const [entityName, deviceName, parentDeviceName, areaName] = nameList;
|
||||
|
||||
const isRTL = computeRTL(
|
||||
this._i18n.language,
|
||||
this._i18n.translationMetadata.translations
|
||||
);
|
||||
const primary = entityName || deviceName || entityState.entity_id;
|
||||
const context = [areaName, entityName ? deviceName : undefined]
|
||||
const context = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
const showEntityId = this._config?.userData?.showEntityIdPicker;
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { consume } from "@lit/context";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import {
|
||||
consumeEntityState,
|
||||
consumeLocalize,
|
||||
} from "../../../common/decorators/consume-context-entry";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import "../../../components/ha-action-result";
|
||||
import type { HaActionResult } from "../../../components/ha-action-result";
|
||||
import "../../../components/ha-control-button";
|
||||
import "../../../components/ha-control-button-group";
|
||||
import { apiContext, servicesContext } from "../../../data/context";
|
||||
@@ -63,8 +65,10 @@ class HuiButtonCardFeature extends LitElement implements LovelaceCardFeature {
|
||||
|
||||
@state() private _config?: ButtonCardFeatureConfig;
|
||||
|
||||
@query("ha-action-result") private _result!: HaActionResult;
|
||||
|
||||
private _pressButton() {
|
||||
if (!this._stateObj) return;
|
||||
if (!this._stateObj || this._result.busy) return;
|
||||
|
||||
const domain = computeDomain(this._stateObj.entity_id);
|
||||
const service =
|
||||
@@ -99,7 +103,7 @@ class HuiButtonCardFeature extends LitElement implements LovelaceCardFeature {
|
||||
|
||||
forwardHaptic(this, "light");
|
||||
|
||||
this._api.callService(domain, service, serviceData);
|
||||
this._result.run(this._api.callService(domain, service, serviceData));
|
||||
}
|
||||
|
||||
static getStubConfig(): ButtonCardFeatureConfig {
|
||||
@@ -132,7 +136,9 @@ class HuiButtonCardFeature extends LitElement implements LovelaceCardFeature {
|
||||
class="press-button"
|
||||
@click=${this._pressButton}
|
||||
>
|
||||
${this._config.action_name ?? this._localize("ui.card.button.press")}
|
||||
<ha-action-result>
|
||||
${this._config.action_name ?? this._localize("ui.card.button.press")}
|
||||
</ha-action-result>
|
||||
</ha-control-button>
|
||||
</ha-control-button-group>
|
||||
`;
|
||||
|
||||
@@ -675,6 +675,16 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
this._updateCollectionPeriod();
|
||||
|
||||
// "yesterday" is the only preset whose range never includes today, making
|
||||
// it the only remembered default that keeps reopening in the past.
|
||||
const storageKey = getEnergyDefaultPeriodStorageKey(
|
||||
this.hass,
|
||||
this.collectionKey
|
||||
);
|
||||
if (localStorage.getItem(storageKey) === "yesterday") {
|
||||
localStorage.setItem(storageKey, "today");
|
||||
}
|
||||
}
|
||||
|
||||
private _pickPrevious() {
|
||||
|
||||
@@ -63,8 +63,8 @@ export class HuiEntityEditor extends LitElement {
|
||||
this.hass.formatEntityName(
|
||||
stateObj,
|
||||
useDeviceName
|
||||
? [{ type: "area" }]
|
||||
: [{ type: "area" }, { type: "device" }],
|
||||
? [{ type: "area" }, { type: "parent_device" }]
|
||||
: [{ type: "area" }, { type: "parent_device" }, { type: "device" }],
|
||||
{
|
||||
separator: isRTL ? " ◂ " : " ▸ ",
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
|
||||
import "../badges/hui-entity-badge";
|
||||
import "../badges/hui-error-badge";
|
||||
import {
|
||||
createLovelaceElement,
|
||||
getLovelaceElementClass,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "../heading-badges/hui-button-heading-badge";
|
||||
import "../heading-badges/hui-entity-heading-badge";
|
||||
import "../heading-badges/hui-error-heading-badge";
|
||||
|
||||
import {
|
||||
createLovelaceElement,
|
||||
|
||||
@@ -32,6 +32,7 @@ interface EntityPickerTableRowData extends DataTableRowData {
|
||||
name: string;
|
||||
entity_name?: string;
|
||||
device_name?: string;
|
||||
parent_device_name?: string;
|
||||
area_name?: string;
|
||||
domain_name: string;
|
||||
last_changed: string;
|
||||
@@ -60,14 +61,20 @@ export class HuiEntityPickerTable extends LitElement {
|
||||
(entity) => {
|
||||
const stateObj = this.hass.states[entity];
|
||||
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
const name = [deviceName, entityName].filter(Boolean).join(" ");
|
||||
const domain = computeDomain(entity);
|
||||
|
||||
@@ -78,6 +85,7 @@ export class HuiEntityPickerTable extends LitElement {
|
||||
name: name,
|
||||
entity_name: entityName,
|
||||
device_name: deviceName,
|
||||
parent_device_name: parentDeviceName,
|
||||
area_name: areaName,
|
||||
domain_name: domainToName(localize, domain),
|
||||
last_changed: stateObj!.last_changed,
|
||||
@@ -152,6 +160,7 @@ export class HuiEntityPickerTable extends LitElement {
|
||||
entity.entity_name || entity.device_name || entity.entity_id;
|
||||
const secondary = [
|
||||
entity.area_name,
|
||||
entity.parent_device_name,
|
||||
entity.entity_name ? entity.device_name : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -191,6 +200,12 @@ export class HuiEntityPickerTable extends LitElement {
|
||||
hidden: true,
|
||||
};
|
||||
|
||||
columns.parent_device_name = {
|
||||
title: "parent_device_name",
|
||||
filterable: true,
|
||||
hidden: true,
|
||||
};
|
||||
|
||||
columns.area_name = {
|
||||
title: "area_name",
|
||||
filterable: true,
|
||||
|
||||
@@ -172,14 +172,20 @@ export class HuiHeadingBadgesEditor extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
|
||||
const isRTL = computeRTL(
|
||||
this.hass.language,
|
||||
@@ -187,7 +193,11 @@ export class HuiHeadingBadgesEditor extends LitElement {
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [entityName ? deviceName : undefined, areaName]
|
||||
const secondary = [
|
||||
entityName ? deviceName : undefined,
|
||||
parentDeviceName,
|
||||
areaName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { array, literal, object, string, union } from "superstruct";
|
||||
import { array, enums, literal, object, string, union } from "superstruct";
|
||||
import { ENTITY_NAME_TYPES } from "../../../../common/entity/compute_entity_name_display";
|
||||
|
||||
const entityNameItemStruct = union([
|
||||
object({
|
||||
@@ -6,12 +7,7 @@ const entityNameItemStruct = union([
|
||||
text: string(),
|
||||
}),
|
||||
object({
|
||||
type: union([
|
||||
literal("entity"),
|
||||
literal("device"),
|
||||
literal("area"),
|
||||
literal("floor"),
|
||||
]),
|
||||
type: enums(ENTITY_NAME_TYPES),
|
||||
}),
|
||||
string(),
|
||||
]);
|
||||
|
||||
@@ -49,6 +49,8 @@ interface LovelacePanelConfig {
|
||||
mode: "yaml" | "storage";
|
||||
}
|
||||
|
||||
const EXTERNALLY_UPDATED_TOAST_ID = "lovelace-externally-updated";
|
||||
|
||||
let editorLoaded = false;
|
||||
let resourcesLoaded = false;
|
||||
|
||||
@@ -265,6 +267,7 @@ export class LovelacePanel extends LitElement {
|
||||
return;
|
||||
}
|
||||
showToast(this, {
|
||||
id: EXTERNALLY_UPDATED_TOAST_ID,
|
||||
message: this.hass!.localize(
|
||||
"ui.panel.lovelace.externally_updated_toast.message"
|
||||
),
|
||||
|
||||
@@ -5,9 +5,9 @@ export const waColorStyles = css`
|
||||
--wa-color-brand-fill-loud: var(--ha-color-fill-primary-loud-resting);
|
||||
--wa-color-brand-fill-normal: var(--ha-color-fill-primary-normal-resting);
|
||||
--wa-color-brand-fill-quiet: var(--ha-color-fill-primary-quiet-hover);
|
||||
--wa-color-brand-border-loud: var(--ha-color-border-loud);
|
||||
--wa-color-brand-border-loud: var(--ha-color-border-primary-loud);
|
||||
--wa-color-brand-border-normal: var(--ha-color-primary-50);
|
||||
--wa-color-brand-border-quiet: var(--ha-color-border-quiet);
|
||||
--wa-color-brand-border-quiet: var(--ha-color-border-primary-quiet);
|
||||
--wa-color-brand-on-loud: var(--ha-color-on-primary-loud);
|
||||
--wa-color-brand-on-normal: var(--ha-color-on-primary-normal);
|
||||
--wa-color-brand-on-quiet: var(--ha-color-on-primary-quiet);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { join } from "lit/directives/join";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import type { EntityNameType } from "../common/entity/compute_entity_name_display";
|
||||
import { computeStateDomain } from "../common/entity/compute_state_domain";
|
||||
import {
|
||||
STRINGS_SEPARATOR_DOT,
|
||||
@@ -56,6 +57,13 @@ export const DEFAULT_STATE_CONTENT_DOMAINS: Record<string, StateContent> = {
|
||||
valve: ["state", "current_position"],
|
||||
};
|
||||
|
||||
const NAME_CONTENT_TYPES: Partial<Record<string, EntityNameType>> = {
|
||||
device_name: "device",
|
||||
parent_device_name: "parent_device",
|
||||
area_name: "area",
|
||||
floor_name: "floor",
|
||||
};
|
||||
|
||||
const TIMESTAMP_STATE_PROPS = ["last_updated", "last_changed"];
|
||||
|
||||
const TIMESTAMP_CONTENTS = [...TIMESTAMP_STATE_PROPS, "last_triggered"];
|
||||
@@ -190,13 +198,11 @@ class StateDisplay extends LitElement {
|
||||
if (content === "entity-id") {
|
||||
return stateObj.entity_id;
|
||||
}
|
||||
if (
|
||||
content === "device_name" ||
|
||||
content === "area_name" ||
|
||||
content === "floor_name"
|
||||
) {
|
||||
const type = content.replace("_name", "") as "device" | "area" | "floor";
|
||||
return this.hass.formatEntityName(stateObj, { type }) || undefined;
|
||||
const nameType = NAME_CONTENT_TYPES[content];
|
||||
if (nameType) {
|
||||
return (
|
||||
this.hass.formatEntityName(stateObj, { type: nameType }) || undefined
|
||||
);
|
||||
}
|
||||
|
||||
let relativeDateTime: string | number | undefined;
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../components/entity/ha-entity-toggle";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import "../components/entity/state-info";
|
||||
import "../components/ha-action-result";
|
||||
import type { HaActionResult } from "../components/ha-action-result";
|
||||
import "../components/ha-control-button";
|
||||
import { UNAVAILABLE } from "../data/entity/entity";
|
||||
import { haStyle } from "../resources/styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
@customElement("state-card-button")
|
||||
class StateCardButton extends LitElement {
|
||||
export class StateCardButton extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public stateObj!: HassEntity;
|
||||
|
||||
@property({ attribute: "in-dialog", type: Boolean }) public inDialog = false;
|
||||
|
||||
@query("ha-action-result") private _result!: HaActionResult;
|
||||
|
||||
protected render() {
|
||||
const stateObj = this.stateObj;
|
||||
return html`
|
||||
@@ -30,7 +34,9 @@ class StateCardButton extends LitElement {
|
||||
.disabled=${stateObj.state === UNAVAILABLE}
|
||||
@click=${this._pressButton}
|
||||
>
|
||||
${this.hass.localize("ui.card.button.press")}
|
||||
<ha-action-result>
|
||||
${this.hass.localize("ui.card.button.press")}
|
||||
</ha-action-result>
|
||||
</ha-control-button>
|
||||
</div>
|
||||
`;
|
||||
@@ -38,9 +44,13 @@ class StateCardButton extends LitElement {
|
||||
|
||||
private _pressButton(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
this.hass.callService("button", "press", {
|
||||
entity_id: this.stateObj.entity_id,
|
||||
});
|
||||
if (this._result.busy) return;
|
||||
|
||||
this._result.run(
|
||||
this.hass.callService(computeDomain(this.stateObj.entity_id), "press", {
|
||||
entity_id: this.stateObj.entity_id,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -1,63 +1,8 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../components/entity/ha-entity-toggle";
|
||||
import "../components/entity/state-info";
|
||||
import "../components/ha-control-button";
|
||||
import { UNAVAILABLE } from "../data/entity/entity";
|
||||
import { haStyle } from "../resources/styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { StateCardButton } from "./state-card-button";
|
||||
|
||||
@customElement("state-card-input_button")
|
||||
class StateCardInputButton extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public stateObj!: HassEntity;
|
||||
|
||||
@property({ attribute: "in-dialog", type: Boolean }) public inDialog = false;
|
||||
|
||||
protected render() {
|
||||
const stateObj = this.stateObj;
|
||||
return html`
|
||||
<div class="horizontal justified layout">
|
||||
<state-info
|
||||
.hass=${this.hass}
|
||||
.stateObj=${stateObj}
|
||||
.inDialog=${this.inDialog}
|
||||
></state-info>
|
||||
<ha-control-button
|
||||
.disabled=${stateObj.state === UNAVAILABLE}
|
||||
@click=${this._pressButton}
|
||||
>
|
||||
${this.hass.localize("ui.card.button.press")}
|
||||
</ha-control-button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _pressButton(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
this.hass.callService("input_button", "press", {
|
||||
entity_id: this.stateObj.entity_id,
|
||||
});
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
css`
|
||||
ha-control-button {
|
||||
width: auto;
|
||||
min-width: 40px;
|
||||
--control-button-padding: 0 var(--ha-space-4);
|
||||
--control-button-focus-color: var(--primary-text-color);
|
||||
--control-button-icon-color: var(--feature-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
class StateCardInputButton extends StateCardButton {}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
|
||||
@@ -764,10 +764,12 @@
|
||||
"types": {
|
||||
"floor": "Floor",
|
||||
"area": "Area",
|
||||
"parent_device": "[%key:ui::components::device-picker::parent_device%]",
|
||||
"device": "Device",
|
||||
"entity": "Entity",
|
||||
"area_missing": "No area assigned",
|
||||
"floor_missing": "No floor assigned",
|
||||
"parent_device_missing": "No parent device",
|
||||
"device_missing": "No related device"
|
||||
},
|
||||
"mode_composed": "Composed",
|
||||
@@ -916,6 +918,7 @@
|
||||
"no_devices": "No devices available",
|
||||
"no_match": "No devices found for {term}",
|
||||
"device": "Device",
|
||||
"parent_device": "Parent device",
|
||||
"unnamed_device": "Unnamed device",
|
||||
"no_area": "No area",
|
||||
"placeholder": "Select a device",
|
||||
@@ -1126,7 +1129,11 @@
|
||||
"zoom_reset": "Reset zoom",
|
||||
"expand_legend": "More",
|
||||
"collapse_legend": "Less",
|
||||
"toggle_visibility": "Toggle visibility"
|
||||
"toggle_visibility": "Toggle visibility",
|
||||
"chart": "Chart",
|
||||
"time": "Time",
|
||||
"value": "Value",
|
||||
"category": "Category"
|
||||
},
|
||||
"map": {
|
||||
"error": "Unable to load map"
|
||||
@@ -1442,6 +1449,7 @@
|
||||
"remaining_time": "Remaining time",
|
||||
"install_status": "Install status",
|
||||
"device_name": "Device name",
|
||||
"parent_device_name": "Parent device name",
|
||||
"area_name": "Area name",
|
||||
"floor_name": "Floor name"
|
||||
},
|
||||
@@ -1611,6 +1619,7 @@
|
||||
"thread": "[%key:ui::panel::config::dashboard::thread::main%]",
|
||||
"bluetooth": "[%key:ui::panel::config::dashboard::bluetooth::main%]",
|
||||
"radio_frequency": "[%key:ui::panel::config::dashboard::radio_frequency::main%]",
|
||||
"serial": "[%key:ui::panel::config::dashboard::serial::main%]",
|
||||
"knx": "[%key:ui::panel::config::dashboard::knx::main%]",
|
||||
"insteon": "[%key:ui::panel::config::dashboard::insteon::main%]",
|
||||
"voice-assistants": "[%key:ui::panel::config::dashboard::voice_assistants::main%]",
|
||||
@@ -1668,6 +1677,7 @@
|
||||
"context": "Context",
|
||||
"entity": "Entity",
|
||||
"floor": "Floor",
|
||||
"parent_device": "[%key:ui::components::device-picker::parent_device%]",
|
||||
"entity_id": "Entity ID",
|
||||
"labels": "Labels",
|
||||
"toggle_yaml_mode": "Toggle YAML mode",
|
||||
@@ -1816,7 +1826,6 @@
|
||||
"toggle": "Toggle",
|
||||
"color": "Color",
|
||||
"color_temp": "Temperature",
|
||||
"color_temp_brightness": "Temperature and brightness",
|
||||
"set_white": "Set white",
|
||||
"select_effect": "Select effect",
|
||||
"color_picker": {
|
||||
@@ -2751,6 +2760,10 @@
|
||||
"main": "Radio frequency",
|
||||
"secondary": "Control radio-based devices."
|
||||
},
|
||||
"serial": {
|
||||
"main": "Serial",
|
||||
"secondary": "Serial ports and the integrations and apps using them"
|
||||
},
|
||||
"knx": {
|
||||
"main": "KNX",
|
||||
"secondary": "Building automation standard"
|
||||
@@ -7517,6 +7530,38 @@
|
||||
"last_used": "Last used",
|
||||
"devices_navigation": "Devices"
|
||||
},
|
||||
"serial": {
|
||||
"title": "Serial",
|
||||
"status_ok": "All ports connected",
|
||||
"status_disconnected": "Ports disconnected",
|
||||
"status_summary": "{in_use}/{total} connected ports in use",
|
||||
"status_summary_disconnected": "{count} disconnected",
|
||||
"connected": "Connected",
|
||||
"connected_description": "These ports are used by at least one integration or app.",
|
||||
"available": "Available",
|
||||
"available_description": "These ports are connected but not used by any integration or app.",
|
||||
"disconnected": "Disconnected",
|
||||
"disconnected_description": "These ports are used by an integration or app but are currently not connected.",
|
||||
"no_ports": "No serial ports found",
|
||||
"loading_error": "Failed to load serial ports",
|
||||
"serial_number": "S/N: {serial_number}",
|
||||
"consumer_not_running": "{name} (not running)",
|
||||
"can_be_used_with": "Can be used with {integrations}",
|
||||
"discovered_by": "Discovered by {integration}, set it up",
|
||||
"port_information": "Port information",
|
||||
"fields": {
|
||||
"device": "Device",
|
||||
"resolved_device": "Resolved device",
|
||||
"description": "Description",
|
||||
"manufacturer": "Manufacturer",
|
||||
"interface_description": "Interface description",
|
||||
"interface_number": "Interface number",
|
||||
"serial_number": "Serial number",
|
||||
"vid": "Vendor ID",
|
||||
"pid": "Product ID",
|
||||
"bcd_device": "Device revision"
|
||||
}
|
||||
},
|
||||
"dhcp": {
|
||||
"title": "DHCP discovery",
|
||||
"mac_address": "MAC address",
|
||||
|
||||
@@ -245,6 +245,43 @@ describe("computeEntityNameDisplay", () => {
|
||||
expect(result).toBe("Kitchen Smart Light");
|
||||
});
|
||||
|
||||
it("returns parent device name for an entity on a child device", () => {
|
||||
const stateObj = mockStateObj({ entity_id: "switch.outlet_1" });
|
||||
const hass = {
|
||||
entities: {
|
||||
"switch.outlet_1": mockEntity({
|
||||
entity_id: "switch.outlet_1",
|
||||
name: "Switch",
|
||||
device_id: "child_1",
|
||||
}),
|
||||
},
|
||||
devices: {
|
||||
child_1: mockDevice({
|
||||
id: "child_1",
|
||||
name: "Outlet 1",
|
||||
parent_device_id: "parent_1",
|
||||
}),
|
||||
parent_1: mockDevice({
|
||||
id: "parent_1",
|
||||
name: "Power strip",
|
||||
}),
|
||||
},
|
||||
areas: {},
|
||||
floors: {},
|
||||
} as unknown as HomeAssistant;
|
||||
|
||||
const result = computeEntityNameDisplay(
|
||||
stateObj,
|
||||
[{ type: "parent_device" }, { type: "device" }, { type: "entity" }],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
|
||||
expect(result).toBe("Power strip Outlet 1 Switch");
|
||||
});
|
||||
|
||||
it("returns floor name", () => {
|
||||
const stateObj = mockStateObj({ entity_id: "light.kitchen" });
|
||||
const hass = {
|
||||
|
||||
@@ -37,6 +37,7 @@ describe("getEntityContext", () => {
|
||||
expect(result).toEqual({
|
||||
entity,
|
||||
device: null,
|
||||
parentDevice: null,
|
||||
area: null,
|
||||
floor: null,
|
||||
});
|
||||
@@ -88,6 +89,7 @@ describe("getEntityContext", () => {
|
||||
expect(result).toEqual({
|
||||
entity,
|
||||
device,
|
||||
parentDevice: null,
|
||||
area,
|
||||
floor,
|
||||
});
|
||||
@@ -144,6 +146,7 @@ describe("getEntityContext", () => {
|
||||
expect(result).toEqual({
|
||||
entity,
|
||||
device: childDevice,
|
||||
parentDevice,
|
||||
area,
|
||||
floor,
|
||||
});
|
||||
@@ -184,6 +187,7 @@ describe("getEntityContext", () => {
|
||||
expect(result).toEqual({
|
||||
entity,
|
||||
device: null,
|
||||
parentDevice: null,
|
||||
area,
|
||||
floor,
|
||||
});
|
||||
@@ -223,8 +227,38 @@ describe("getEntityContext", () => {
|
||||
expect(result).toEqual({
|
||||
entity,
|
||||
device: null,
|
||||
parentDevice: null,
|
||||
area,
|
||||
floor: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should not resolve a parent device for an entity on a main device", () => {
|
||||
const entity = mockEntity({
|
||||
entity_id: "switch.strip",
|
||||
device_id: "parent_1",
|
||||
});
|
||||
const parentDevice = mockDevice({
|
||||
id: "parent_1",
|
||||
});
|
||||
const stateObj = mockStateObj({
|
||||
entity_id: "switch.strip",
|
||||
});
|
||||
|
||||
const result = getEntityContext(
|
||||
stateObj,
|
||||
{ "switch.strip": entity },
|
||||
{ parent_1: parentDevice },
|
||||
{},
|
||||
{}
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
entity,
|
||||
device: parentDevice,
|
||||
parentDevice: null,
|
||||
area: null,
|
||||
floor: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,192 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canSonifyChart } from "../../../src/components/chart/chart-sonification";
|
||||
import type { HaECSeries } from "../../../src/resources/echarts/echarts";
|
||||
|
||||
const series = (
|
||||
items: { type: string; id?: string; name?: string; data?: unknown[] }[]
|
||||
) => items as unknown as HaECSeries;
|
||||
|
||||
describe("canSonifyChart", () => {
|
||||
it("accepts line and bar series that carry data", () => {
|
||||
expect(
|
||||
canSonifyChart(
|
||||
series([
|
||||
{
|
||||
type: "line",
|
||||
data: [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
],
|
||||
},
|
||||
])
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
canSonifyChart(
|
||||
series([
|
||||
{
|
||||
type: "bar",
|
||||
data: [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
],
|
||||
},
|
||||
])
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a chart whose empty placeholder series sits beside real data", () => {
|
||||
expect(
|
||||
canSonifyChart(
|
||||
series([
|
||||
{ type: "bar", data: [] },
|
||||
{
|
||||
type: "bar",
|
||||
data: [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
],
|
||||
},
|
||||
])
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects series types the extension cannot convert", () => {
|
||||
expect(
|
||||
canSonifyChart(
|
||||
series([
|
||||
{
|
||||
type: "custom",
|
||||
data: [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
],
|
||||
},
|
||||
])
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
canSonifyChart(
|
||||
series([
|
||||
{
|
||||
type: "line",
|
||||
data: [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "sankey",
|
||||
data: [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
],
|
||||
},
|
||||
])
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects charts with nothing plotted", () => {
|
||||
expect(canSonifyChart(series([]))).toBe(false);
|
||||
expect(canSonifyChart(series([{ type: "line", data: [] }]))).toBe(false);
|
||||
expect(canSonifyChart(series([{ type: "line" }]))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects value-first pairs, which the extension reads as a non-numeric y", () => {
|
||||
// The energy device charts encode [amount, categoryName]. Chart2Music takes
|
||||
// value as [x, y], so every one of those points is dropped.
|
||||
expect(
|
||||
canSonifyChart(
|
||||
series([
|
||||
{
|
||||
type: "bar",
|
||||
data: [
|
||||
{ value: [12.5, "sensor.a"] },
|
||||
{ value: [8.25, "sensor.b"] },
|
||||
],
|
||||
},
|
||||
])
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a chart left with a single readable point", () => {
|
||||
// The device pie's slices are all unreadable, leaving only its one-number
|
||||
// total series — nothing the arrow keys could move between.
|
||||
expect(
|
||||
canSonifyChart(
|
||||
series([
|
||||
{ type: "pie", data: [{ value: [12.5, "sensor.a"] }] },
|
||||
{ type: "pie", data: [24.5] },
|
||||
])
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores the points of legend-hidden series", () => {
|
||||
// Hiding a series strips its data before it reaches ECharts, so it cannot
|
||||
// be navigated either.
|
||||
const chart = series([
|
||||
{ type: "line", id: "a", data: [[0, 1]] },
|
||||
{ type: "line", name: "b", data: [[1, 2]] },
|
||||
]);
|
||||
expect(canSonifyChart(chart, new Set())).toBe(true);
|
||||
expect(canSonifyChart(chart, new Set(["b"]))).toBe(false);
|
||||
expect(canSonifyChart(chart, new Set(["a", "b"]))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a series whose points are all gaps", () => {
|
||||
// Chart2Music drops any point without a numeric y, so a series of nothing
|
||||
// but nulls converts to an empty group and makes it throw.
|
||||
expect(
|
||||
canSonifyChart(
|
||||
series([
|
||||
{
|
||||
type: "line",
|
||||
data: [
|
||||
[0, null],
|
||||
[1, null],
|
||||
],
|
||||
},
|
||||
])
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a series that only becomes numeric partway through", () => {
|
||||
expect(
|
||||
canSonifyChart(
|
||||
series([
|
||||
{
|
||||
type: "line",
|
||||
data: [
|
||||
[0, null],
|
||||
[1, 21.5],
|
||||
[2, 21.9],
|
||||
],
|
||||
},
|
||||
])
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("reads the y out of object-form points", () => {
|
||||
expect(
|
||||
canSonifyChart(
|
||||
series([
|
||||
{ type: "bar", data: [{ value: [0, 0.28] }, { value: [1, 0.31] }] },
|
||||
])
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
canSonifyChart(
|
||||
series([
|
||||
{ type: "bar", data: [{ value: [0, null] }, { value: [1, null] }] },
|
||||
])
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AssistPipeline } from "../../src/data/assist_pipeline";
|
||||
import {
|
||||
assistPipelineChanged,
|
||||
greetingTranslationLanguage,
|
||||
initialPromptToSubmit,
|
||||
} from "../../src/components/ha-assist-chat";
|
||||
|
||||
// common-translation depends on build-time defines and generated translation
|
||||
// metadata that are not available in unit tests.
|
||||
vi.mock("../../src/util/common-translation", () => ({
|
||||
findAvailableLanguage: (language: string) =>
|
||||
({ en: "en", "en-US": "en", nl: "nl", pl: "pl" })[language],
|
||||
getTranslation: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("initialPromptToSubmit", () => {
|
||||
it("returns a trimmed prompt when submission is requested", () => {
|
||||
expect(initialPromptToSubmit(" Turn on the lights ", true)).toBe(
|
||||
@@ -36,3 +45,25 @@ describe("ha-assist-chat pipeline updates", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("greetingTranslationLanguage", () => {
|
||||
it("returns the pipeline language when it differs from the interface language", () => {
|
||||
expect(greetingTranslationLanguage("pl", "en")).toBe("pl");
|
||||
});
|
||||
|
||||
it("returns undefined when the pipeline language matches the interface language", () => {
|
||||
expect(greetingTranslationLanguage("nl", "nl")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when the pipeline language resolves to the interface language", () => {
|
||||
expect(greetingTranslationLanguage("en-US", "en")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when there is no pipeline language", () => {
|
||||
expect(greetingTranslationLanguage(undefined, "en")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when the pipeline language has no available translation", () => {
|
||||
expect(greetingTranslationLanguage("xx", "en")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import type { HaObjectSelector } from "../../../src/components/ha-selector/ha-selector-object";
|
||||
import type { FormDialogParams } from "../../../src/dialogs/form/show-form-dialog";
|
||||
import "../../../src/components/ha-selector/ha-selector-object";
|
||||
|
||||
vi.mock("../../../src/components/ha-input-helper-text", () => {
|
||||
customElements.define("ha-input-helper-text", class extends HTMLElement {});
|
||||
return {};
|
||||
});
|
||||
|
||||
vi.mock("../../../src/components/ha-md-list", () => {
|
||||
customElements.define("ha-md-list", class extends HTMLElement {});
|
||||
return {};
|
||||
});
|
||||
|
||||
vi.mock("../../../src/components/ha-md-list-item", () => {
|
||||
customElements.define("ha-md-list-item", class extends HTMLElement {});
|
||||
return {};
|
||||
});
|
||||
|
||||
vi.mock("../../../src/components/ha-sortable", () => {
|
||||
customElements.define("ha-sortable", class extends HTMLElement {});
|
||||
return {};
|
||||
});
|
||||
|
||||
vi.mock("../../../src/components/ha-yaml-editor", () => {
|
||||
customElements.define("ha-yaml-editor", class extends HTMLElement {});
|
||||
return {};
|
||||
});
|
||||
|
||||
const hass = {
|
||||
localize: (key: string) => key,
|
||||
locale: "en-US",
|
||||
floors: {},
|
||||
areas: {},
|
||||
devices: {},
|
||||
states: {},
|
||||
formatEntityName: () => "",
|
||||
} as unknown as HomeAssistant;
|
||||
|
||||
const selectorConfig = {
|
||||
object: {
|
||||
multiple: true,
|
||||
fields: {
|
||||
name: { selector: { text: {} } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const getInternals = (selector: HaObjectSelector) =>
|
||||
selector as unknown as Record<string, unknown>;
|
||||
|
||||
const mountSelector = async (value: Record<string, string>[]) => {
|
||||
const selector = document.createElement(
|
||||
"ha-selector-object"
|
||||
) as HaObjectSelector;
|
||||
selector.hass = hass;
|
||||
selector.selector = selectorConfig;
|
||||
selector.value = value;
|
||||
document.body.append(selector);
|
||||
await selector.updateComplete;
|
||||
return selector;
|
||||
};
|
||||
|
||||
const resolveFormDialog = async (
|
||||
selector: HaObjectSelector,
|
||||
action: "_addItem" | "_editItem",
|
||||
result: Record<string, string> | null,
|
||||
item?: Record<string, string>,
|
||||
index?: number
|
||||
) => {
|
||||
let params: FormDialogParams | undefined;
|
||||
const dialogShown = new Promise<void>((resolve) => {
|
||||
selector.addEventListener(
|
||||
"show-dialog",
|
||||
(event) => {
|
||||
params = event.detail.dialogParams as FormDialogParams;
|
||||
if (result === null) {
|
||||
params.cancel!();
|
||||
} else {
|
||||
params.submit!(result);
|
||||
}
|
||||
resolve();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
|
||||
const event = {
|
||||
stopPropagation: vi.fn(),
|
||||
currentTarget: { item, index },
|
||||
};
|
||||
const operation = (
|
||||
getInternals(selector)[action] as (ev: typeof event) => Promise<void>
|
||||
)(event);
|
||||
await dialogShown;
|
||||
await operation;
|
||||
return params!;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ha-selector-object form dialog flow", () => {
|
||||
it("appends an item through the real Add flow", async () => {
|
||||
const first = { name: "A" };
|
||||
const selector = await mountSelector([first]);
|
||||
const valueChanged = vi.fn();
|
||||
selector.addEventListener("value-changed", valueChanged);
|
||||
|
||||
await resolveFormDialog(selector, "_addItem", { name: "B" });
|
||||
|
||||
expect(valueChanged).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
detail: { value: [first, { name: "B" }] },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces an item at its original index through the real Edit flow", async () => {
|
||||
const first = { name: "A" };
|
||||
const second = { name: "B" };
|
||||
const selector = await mountSelector([first, second]);
|
||||
const valueChanged = vi.fn();
|
||||
selector.addEventListener("value-changed", valueChanged);
|
||||
|
||||
await resolveFormDialog(
|
||||
selector,
|
||||
"_editItem",
|
||||
{ name: "B updated" },
|
||||
second,
|
||||
1
|
||||
);
|
||||
|
||||
const value = valueChanged.mock.calls[0][0].detail.value;
|
||||
expect(value).toEqual([first, { name: "B updated" }]);
|
||||
expect(value).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("leaves the array unchanged when Add or Edit is canceled", async () => {
|
||||
const first = { name: "A" };
|
||||
const selector = await mountSelector([first]);
|
||||
const valueChanged = vi.fn();
|
||||
selector.addEventListener("value-changed", valueChanged);
|
||||
|
||||
await resolveFormDialog(selector, "_addItem", null);
|
||||
await resolveFormDialog(selector, "_editItem", null, first, 0);
|
||||
|
||||
expect(valueChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
addDays,
|
||||
addHours,
|
||||
addMilliseconds,
|
||||
endOfDay,
|
||||
startOfDay,
|
||||
} from "date-fns";
|
||||
import type { HassConfig } from "home-assistant-js-websocket";
|
||||
import { afterAll, assert, beforeAll, describe, it } from "vitest";
|
||||
|
||||
import { calcDate } from "../../src/common/datetime/calc_date";
|
||||
import {
|
||||
type FrontendLocaleData,
|
||||
NumberFormat,
|
||||
TimeFormat,
|
||||
FirstWeekday,
|
||||
DateFormat,
|
||||
TimeZone,
|
||||
} from "../../src/data/translation";
|
||||
import {
|
||||
getEnergyFirstStatisticAt,
|
||||
getEnergyLiveDayPeriod,
|
||||
getNextEnergyPeriodStart,
|
||||
} from "../../src/data/energy";
|
||||
|
||||
const locale: FrontendLocaleData = {
|
||||
language: "en",
|
||||
number_format: NumberFormat.language,
|
||||
time_format: TimeFormat.language,
|
||||
date_format: DateFormat.language,
|
||||
time_zone: TimeZone.server,
|
||||
first_weekday: FirstWeekday.language,
|
||||
};
|
||||
const tokyoConfig = { time_zone: "Asia/Tokyo" } as HassConfig;
|
||||
|
||||
// Dedicated file so Europe/Berlin can be pinned without leaking into other
|
||||
// suites. Vitest's default TZ is Etc/UTC, where browser-local addDays and
|
||||
// server-zone math often agree — so UTC CI would miss this regression.
|
||||
describe("energy period DST (Europe/Berlin local TZ)", () => {
|
||||
const originalTz = process.env.TZ;
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.TZ = "Europe/Berlin";
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env.TZ = originalTz;
|
||||
});
|
||||
|
||||
it("uses a DST-fallback browser zone for this file", () => {
|
||||
// 24 Oct 2026 is still CEST. If TZ pinning failed, offset is 0 (UTC).
|
||||
assert.equal(
|
||||
new Date("2026-10-24T14:30:00.000Z").getTimezoneOffset(),
|
||||
-120
|
||||
);
|
||||
});
|
||||
|
||||
it("schedules tomorrow 01:00 in the server zone, not via browser-local addDays", () => {
|
||||
// 23:30 JST on 24 Oct 2026. Europe/Berlin falls back on 25 Oct; local
|
||||
// addDays(now, 1) then startOfDay in Tokyo skips to 26 Oct 01:00 JST.
|
||||
const now = new Date("2026-10-24T14:30:00.000Z");
|
||||
|
||||
// Compare to the tz-internal formula rather than a hardcoded instant:
|
||||
// the formula is the production invariant, and a pinned ISO string would
|
||||
// not explain why UTC CI cannot catch a raw addDays(now, 1) regression.
|
||||
const tzInternal = addHours(
|
||||
addMilliseconds(calcDate(now, endOfDay, locale, tokyoConfig), 1),
|
||||
1
|
||||
);
|
||||
const browserLocalAddDays = getEnergyFirstStatisticAt(
|
||||
addDays(now, 1),
|
||||
locale,
|
||||
tokyoConfig
|
||||
);
|
||||
const actual = getNextEnergyPeriodStart(false, now, locale, tokyoConfig);
|
||||
|
||||
assert.equal(actual.getTime(), tzInternal.getTime());
|
||||
assert.notEqual(actual.getTime(), browserLocalAddDays.getTime());
|
||||
assert.equal(
|
||||
actual.getTime(),
|
||||
new Date("2026-10-24T16:00:00.000Z").getTime()
|
||||
);
|
||||
assert.equal(
|
||||
browserLocalAddDays.getTime(),
|
||||
new Date("2026-10-25T16:00:00.000Z").getTime()
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves yesterday in the server zone, not via browser-local addDays", () => {
|
||||
// 00:30 JST on 26 Oct 2026. Browser-local addDays can land two days back.
|
||||
const now = new Date("2026-10-25T15:30:00.000Z");
|
||||
|
||||
const tzInternal = calcDate(
|
||||
calcDate(now, addDays, locale, tokyoConfig, -1),
|
||||
startOfDay,
|
||||
locale,
|
||||
tokyoConfig
|
||||
);
|
||||
const browserLocalAddDays = calcDate(
|
||||
addDays(now, -1),
|
||||
startOfDay,
|
||||
locale,
|
||||
tokyoConfig
|
||||
);
|
||||
const live = getEnergyLiveDayPeriod(
|
||||
false,
|
||||
now,
|
||||
locale,
|
||||
tokyoConfig,
|
||||
new Date(0)
|
||||
);
|
||||
|
||||
assert.equal(live.start.getTime(), tzInternal.getTime());
|
||||
assert.notEqual(live.start.getTime(), browserLocalAddDays.getTime());
|
||||
});
|
||||
});
|
||||
+408
-19
@@ -1,6 +1,6 @@
|
||||
import { startOfDay } from "date-fns";
|
||||
import { addDays, endOfDay, startOfDay } from "date-fns";
|
||||
import type { HassConfig } from "home-assistant-js-websocket";
|
||||
import { assert, describe, it } from "vitest";
|
||||
import { afterEach, assert, describe, it, vi } from "vitest";
|
||||
|
||||
import { calcDate } from "../../src/common/datetime/calc_date";
|
||||
import {
|
||||
@@ -20,6 +20,11 @@ import {
|
||||
formatPowerShort,
|
||||
getNextEnergyPeriodStart,
|
||||
getEnergyDefaultPeriodStorageKey,
|
||||
getEnergyFirstStatisticAt,
|
||||
getEnergyLiveDayPeriod,
|
||||
shouldFallbackEnergyPeriodToYesterday,
|
||||
getEnergyDataCollection,
|
||||
EMPTY_PREFERENCES,
|
||||
} from "../../src/data/energy";
|
||||
import type { DeviceRegistryEntry } from "../../src/data/device/device_registry";
|
||||
import type { EntityRegistryDisplayEntry } from "../../src/data/entity/entity_registry";
|
||||
@@ -866,27 +871,55 @@ describe("Self-consumed solar gauge tests", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNextEnergyPeriodStart", () => {
|
||||
const locale: FrontendLocaleData = {
|
||||
language: "en",
|
||||
number_format: NumberFormat.language,
|
||||
time_format: TimeFormat.language,
|
||||
date_format: DateFormat.language,
|
||||
time_zone: TimeZone.server,
|
||||
first_weekday: FirstWeekday.language,
|
||||
// Pin the time zone (via TimeZone.server) so energy period tests do not
|
||||
// depend on the machine's local zone.
|
||||
const energyPeriodLocale: FrontendLocaleData = {
|
||||
language: "en",
|
||||
number_format: NumberFormat.language,
|
||||
time_format: TimeFormat.language,
|
||||
date_format: DateFormat.language,
|
||||
time_zone: TimeZone.server,
|
||||
first_weekday: FirstWeekday.language,
|
||||
};
|
||||
const energyPeriodConfig = { time_zone: "America/New_York" } as HassConfig;
|
||||
const energyPeriodDay = (now: Date, offset = 0) => {
|
||||
const day = calcDate(
|
||||
now,
|
||||
addDays,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig,
|
||||
offset
|
||||
);
|
||||
return {
|
||||
start: calcDate(day, startOfDay, energyPeriodLocale, energyPeriodConfig),
|
||||
end: calcDate(day, endOfDay, energyPeriodLocale, energyPeriodConfig),
|
||||
};
|
||||
// Pin the time zone (via TimeZone.server) so the test does not depend on the
|
||||
// machine's local zone.
|
||||
const config = { time_zone: "America/New_York" } as HassConfig;
|
||||
};
|
||||
|
||||
describe("getNextEnergyPeriodStart", () => {
|
||||
const isMidnight = (date: Date) =>
|
||||
calcDate(date, startOfDay, locale, config).getTime() === date.getTime();
|
||||
calcDate(
|
||||
date,
|
||||
startOfDay,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig
|
||||
).getTime() === date.getTime();
|
||||
|
||||
it("rolls the real-time view over at midnight, statistics an hour later", () => {
|
||||
const now = new Date("2026-06-19T15:30:00-04:00");
|
||||
|
||||
const realTime = getNextEnergyPeriodStart(true, now, locale, config);
|
||||
const statistics = getNextEnergyPeriodStart(false, now, locale, config);
|
||||
const realTime = getNextEnergyPeriodStart(
|
||||
true,
|
||||
now,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig
|
||||
);
|
||||
const statistics = getNextEnergyPeriodStart(
|
||||
false,
|
||||
now,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig
|
||||
);
|
||||
|
||||
// Real-time rolls over exactly at the next midnight.
|
||||
assert.isTrue(isMidnight(realTime));
|
||||
@@ -896,9 +929,14 @@ describe("getNextEnergyPeriodStart", () => {
|
||||
);
|
||||
|
||||
// Statistics roll over an hour after midnight, on the same day boundary.
|
||||
assert.equal(statistics.getTime() - realTime.getTime(), 60 * 60 * 1000 - 1);
|
||||
assert.equal(statistics.getTime() - realTime.getTime(), 60 * 60 * 1000);
|
||||
assert.equal(
|
||||
calcDate(statistics, startOfDay, locale, config).getTime(),
|
||||
calcDate(
|
||||
statistics,
|
||||
startOfDay,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig
|
||||
).getTime(),
|
||||
realTime.getTime()
|
||||
);
|
||||
});
|
||||
@@ -906,7 +944,12 @@ describe("getNextEnergyPeriodStart", () => {
|
||||
it("advances the real-time view to the next midnight when called after midnight", () => {
|
||||
const now = new Date("2026-06-20T00:30:00-04:00");
|
||||
|
||||
const realTime = getNextEnergyPeriodStart(true, now, locale, config);
|
||||
const realTime = getNextEnergyPeriodStart(
|
||||
true,
|
||||
now,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig
|
||||
);
|
||||
|
||||
assert.isTrue(isMidnight(realTime));
|
||||
// Next midnight is June 21, not the already-passed June 20 midnight.
|
||||
@@ -915,6 +958,352 @@ describe("getNextEnergyPeriodStart", () => {
|
||||
new Date("2026-06-21T00:00:00-04:00").getTime()
|
||||
);
|
||||
});
|
||||
|
||||
it("wakes a non-today live day at today 01:00 during hour 0", () => {
|
||||
const now = new Date("2026-06-20T00:30:00-04:00");
|
||||
const todayOne = new Date("2026-06-20T01:00:00-04:00").getTime();
|
||||
|
||||
for (const offset of [-1, -2]) {
|
||||
assert.equal(
|
||||
getNextEnergyPeriodStart(
|
||||
false,
|
||||
now,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig,
|
||||
energyPeriodDay(now, offset).start
|
||||
).getTime(),
|
||||
todayOne
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps tomorrow 01:00 when statistics is already on today during hour 0", () => {
|
||||
const now = new Date("2026-06-20T00:30:00-04:00");
|
||||
|
||||
assert.equal(
|
||||
getNextEnergyPeriodStart(
|
||||
false,
|
||||
now,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig,
|
||||
energyPeriodDay(now).start
|
||||
).getTime(),
|
||||
new Date("2026-06-21T01:00:00-04:00").getTime()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldFallbackEnergyPeriodToYesterday", () => {
|
||||
it("is true for the statistics view before 01:00", () => {
|
||||
const now = new Date("2026-06-20T00:30:00-04:00");
|
||||
assert.isTrue(
|
||||
shouldFallbackEnergyPeriodToYesterday(
|
||||
false,
|
||||
now,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig
|
||||
)
|
||||
);
|
||||
assert.equal(
|
||||
getEnergyFirstStatisticAt(
|
||||
now,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig
|
||||
).getTime(),
|
||||
new Date("2026-06-20T01:00:00-04:00").getTime()
|
||||
);
|
||||
});
|
||||
|
||||
it("is false at 01:00 and for the real-time view", () => {
|
||||
const atOne = new Date("2026-06-20T01:00:00-04:00");
|
||||
const beforeOne = new Date("2026-06-20T00:30:00-04:00");
|
||||
assert.isFalse(
|
||||
shouldFallbackEnergyPeriodToYesterday(
|
||||
false,
|
||||
atOne,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig
|
||||
)
|
||||
);
|
||||
assert.isFalse(
|
||||
shouldFallbackEnergyPeriodToYesterday(
|
||||
true,
|
||||
beforeOne,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEnergyLiveDayPeriod", () => {
|
||||
it("keeps yesterday during hour 0 when that is the current period", () => {
|
||||
const now = new Date("2026-06-20T00:30:00-04:00");
|
||||
const { start, end } = energyPeriodDay(now, -1);
|
||||
const live = getEnergyLiveDayPeriod(
|
||||
false,
|
||||
now,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig,
|
||||
start
|
||||
);
|
||||
assert.equal(live.start.getTime(), start.getTime());
|
||||
assert.equal(live.end.getTime(), end.getTime());
|
||||
});
|
||||
|
||||
it("keeps today during hour 0 when the user already picked today", () => {
|
||||
const now = new Date("2026-06-20T00:30:00-04:00");
|
||||
const { start, end } = energyPeriodDay(now);
|
||||
const live = getEnergyLiveDayPeriod(
|
||||
false,
|
||||
now,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig,
|
||||
start
|
||||
);
|
||||
assert.equal(live.start.getTime(), start.getTime());
|
||||
assert.equal(live.end.getTime(), end.getTime());
|
||||
});
|
||||
|
||||
it("advances a stale yesterday to today after 01:00", () => {
|
||||
const now = new Date("2026-06-20T10:00:00-04:00");
|
||||
const live = getEnergyLiveDayPeriod(
|
||||
false,
|
||||
now,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig,
|
||||
energyPeriodDay(now, -1).start
|
||||
);
|
||||
const expected = energyPeriodDay(now);
|
||||
assert.equal(live.start.getTime(), expected.start.getTime());
|
||||
assert.equal(live.end.getTime(), expected.end.getTime());
|
||||
});
|
||||
|
||||
it("advances a two-day-old live day to today", () => {
|
||||
const now = new Date("2026-06-20T10:00:00-04:00");
|
||||
const live = getEnergyLiveDayPeriod(
|
||||
false,
|
||||
now,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig,
|
||||
energyPeriodDay(now, -2).start
|
||||
);
|
||||
const expected = energyPeriodDay(now);
|
||||
assert.equal(live.start.getTime(), expected.start.getTime());
|
||||
assert.equal(live.end.getTime(), expected.end.getTime());
|
||||
});
|
||||
|
||||
it("falls back to yesterday for a stale live day during hour 0", () => {
|
||||
const now = new Date("2026-06-20T00:30:00-04:00");
|
||||
const live = getEnergyLiveDayPeriod(
|
||||
false,
|
||||
now,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig,
|
||||
energyPeriodDay(now, -2).start
|
||||
);
|
||||
const expected = energyPeriodDay(now, -1);
|
||||
assert.equal(live.start.getTime(), expected.start.getTime());
|
||||
assert.equal(live.end.getTime(), expected.end.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEnergyDataCollection live day", () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const createCollection = (
|
||||
key: string,
|
||||
preset?: string,
|
||||
midnightRollover = false
|
||||
) => {
|
||||
const hass = createMockHass();
|
||||
hass.locale = energyPeriodLocale;
|
||||
hass.config = { ...hass.config, time_zone: "America/New_York" };
|
||||
const callWS = vi.fn(async (msg: { type: string }) => {
|
||||
if (msg.type === "energy/info") {
|
||||
return { cost_sensors: {}, solar_forecast_domains: [] };
|
||||
}
|
||||
throw new Error(`unexpected ${msg.type}`);
|
||||
});
|
||||
Object.assign(hass, {
|
||||
connection: {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
connected: true,
|
||||
},
|
||||
callWS,
|
||||
});
|
||||
if (preset) {
|
||||
localStorage.setItem(getEnergyDefaultPeriodStorageKey(hass, key), preset);
|
||||
}
|
||||
return {
|
||||
collection: getEnergyDataCollection(hass, {
|
||||
key,
|
||||
prefs: EMPTY_PREFERENCES,
|
||||
midnightRollover,
|
||||
}),
|
||||
callWS,
|
||||
};
|
||||
};
|
||||
|
||||
const energyInfoFetches = (callWS: ReturnType<typeof vi.fn>) =>
|
||||
callWS.mock.calls.filter((call) => call[0].type === "energy/info");
|
||||
|
||||
it("advances hour-0 yesterday to today at 01:00 and fetches the new day", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-20T00:30:00-04:00"));
|
||||
|
||||
const { collection, callWS } = createCollection("energy_timer");
|
||||
const refresh = vi.spyOn(collection, "refresh");
|
||||
const unsub = collection.subscribe(() => undefined);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
refresh.mockClear();
|
||||
callWS.mockClear();
|
||||
|
||||
assert.equal(
|
||||
collection.start.getTime(),
|
||||
energyPeriodDay(new Date(), -1).start.getTime()
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30 * 60 * 1000);
|
||||
|
||||
assert.equal(
|
||||
collection.start.getTime(),
|
||||
energyPeriodDay(new Date()).start.getTime()
|
||||
);
|
||||
// Cards render EnergyData from the websocket store, not collection.start.
|
||||
// The 01:00 callback must refresh() so getEnergyData runs for today.
|
||||
assert.equal(refresh.mock.calls.length, 1);
|
||||
assert.equal(energyInfoFetches(callWS).length, 1);
|
||||
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("catches up a stale live day on resubscribe", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-18T15:00:00-04:00"));
|
||||
|
||||
const { collection, callWS } = createCollection("energy_catchup");
|
||||
const refresh = vi.spyOn(collection, "refresh");
|
||||
let unsub = collection.subscribe(() => undefined);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
refresh.mockClear();
|
||||
callWS.mockClear();
|
||||
|
||||
unsub();
|
||||
vi.setSystemTime(new Date("2026-06-20T10:00:00-04:00"));
|
||||
unsub = collection.subscribe(() => undefined);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
assert.equal(
|
||||
collection.start.getTime(),
|
||||
energyPeriodDay(new Date()).start.getTime()
|
||||
);
|
||||
assert.equal(refresh.mock.calls.length, 1);
|
||||
assert.equal(energyInfoFetches(callWS).length, 1);
|
||||
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("does not double-refresh on a cold subscribe after the unsub grace", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-18T15:00:00-04:00"));
|
||||
|
||||
const { collection, callWS } = createCollection("energy_cold_refresh");
|
||||
const refresh = vi.spyOn(collection, "refresh");
|
||||
let unsub = collection.subscribe(() => undefined);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
unsub();
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
refresh.mockClear();
|
||||
callWS.mockClear();
|
||||
|
||||
vi.setSystemTime(new Date("2026-06-20T10:00:00-04:00"));
|
||||
unsub = collection.subscribe(() => undefined);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
assert.equal(
|
||||
collection.start.getTime(),
|
||||
energyPeriodDay(new Date()).start.getTime()
|
||||
);
|
||||
// The library's first fetch is not collection.refresh(); this spy only
|
||||
// sees the extra refresh used during the unsub-grace re-subscribe.
|
||||
assert.equal(refresh.mock.calls.length, 0);
|
||||
assert.equal(energyInfoFetches(callWS).length, 1);
|
||||
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("keeps a custom setPeriod range overnight", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-18T15:00:00-04:00"));
|
||||
|
||||
const { collection } = createCollection("energy_custom_period");
|
||||
let unsub = collection.subscribe(() => undefined);
|
||||
const custom = energyPeriodDay(new Date(), -5);
|
||||
collection.setPeriod(custom.start, custom.end);
|
||||
unsub();
|
||||
|
||||
vi.setSystemTime(new Date("2026-06-20T10:00:00-04:00"));
|
||||
unsub = collection.subscribe(() => undefined);
|
||||
|
||||
assert.equal(collection.start.getTime(), custom.start.getTime());
|
||||
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("does not advance the period after the last subscriber leaves", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-20T00:30:00-04:00"));
|
||||
|
||||
const { collection } = createCollection("energy_unsub_timer");
|
||||
const unsub = collection.subscribe(() => undefined);
|
||||
const start = collection.start.getTime();
|
||||
unsub();
|
||||
|
||||
vi.advanceTimersByTime(48 * 60 * 60 * 1000);
|
||||
|
||||
assert.equal(collection.start.getTime(), start);
|
||||
});
|
||||
|
||||
it("does not roll a remembered week preset over to today", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-18T15:00:00-04:00"));
|
||||
|
||||
const { collection } = createCollection("energy_week_stored", "this_week");
|
||||
const weekStart = collection.start.getTime();
|
||||
const unsub = collection.subscribe(() => undefined);
|
||||
|
||||
vi.setSystemTime(new Date("2026-06-20T10:00:00-04:00"));
|
||||
vi.advanceTimersByTime(48 * 60 * 60 * 1000);
|
||||
|
||||
assert.equal(collection.start.getTime(), weekStart);
|
||||
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("does not roll a remembered week preset over to today with midnightRollover", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-18T15:00:00-04:00"));
|
||||
|
||||
const { collection } = createCollection(
|
||||
"energy_week_stored_now",
|
||||
"this_week",
|
||||
true
|
||||
);
|
||||
const weekStart = collection.start.getTime();
|
||||
const unsub = collection.subscribe(() => undefined);
|
||||
|
||||
vi.setSystemTime(new Date("2026-06-20T10:00:00-04:00"));
|
||||
vi.advanceTimersByTime(48 * 60 * 60 * 1000);
|
||||
|
||||
assert.equal(collection.start.getTime(), weekStart);
|
||||
|
||||
unsub();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEnergyDefaultPeriodStorageKey", () => {
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { computeShownAttributes } from "../../src/data/entity/entity_attributes";
|
||||
|
||||
describe("computeShownAttributes", () => {
|
||||
it("filters globally hidden attributes", () => {
|
||||
const stateObj = {
|
||||
entity_id: "sensor.temperature",
|
||||
attributes: {
|
||||
friendly_name: "Office temperature",
|
||||
unit_of_measurement: "°C",
|
||||
temperature: 21,
|
||||
custom_value: "shown",
|
||||
},
|
||||
} as unknown as HassEntity;
|
||||
|
||||
expect(computeShownAttributes(stateObj)).toEqual([
|
||||
"temperature",
|
||||
"custom_value",
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters domain and device class specific attributes", () => {
|
||||
const stateObj = {
|
||||
entity_id: "sensor.status",
|
||||
attributes: {
|
||||
device_class: "enum",
|
||||
options: ["home", "away"],
|
||||
current_option: "home",
|
||||
},
|
||||
} as unknown as HassEntity;
|
||||
|
||||
expect(computeShownAttributes(stateObj)).toEqual(["current_option"]);
|
||||
});
|
||||
|
||||
it("keeps device-class attributes for other device classes", () => {
|
||||
const stateObj = {
|
||||
entity_id: "sensor.status",
|
||||
attributes: {
|
||||
device_class: "temperature",
|
||||
options: ["home", "away"],
|
||||
current_option: "home",
|
||||
},
|
||||
} as unknown as HassEntity;
|
||||
|
||||
expect(computeShownAttributes(stateObj)).toEqual([
|
||||
"options",
|
||||
"current_option",
|
||||
]);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user