mirror of
https://github.com/home-assistant/frontend.git
synced 2026-02-26 11:27:45 +00:00
Compare commits
126 Commits
card-featu
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ff4993e0b | ||
|
|
4e6fbacccc | ||
|
|
2958d49e36 | ||
|
|
92289dc7ea | ||
|
|
f6c1a890e4 | ||
|
|
d06321ed43 | ||
|
|
3c3d8d9974 | ||
|
|
4f39fa482d | ||
|
|
5d0fe3236c | ||
|
|
b86142ae50 | ||
|
|
5d2f3ee5e8 | ||
|
|
e3f7c631a7 | ||
|
|
49f9d95853 | ||
|
|
db3d7701b5 | ||
|
|
3e55acf531 | ||
|
|
f102618d9d | ||
|
|
a3c02b511d | ||
|
|
74111d248e | ||
|
|
f8161b3505 | ||
|
|
6070c1907a | ||
|
|
ce5991582c | ||
|
|
d17217fc90 | ||
|
|
86b4bd0013 | ||
|
|
108ba3abd6 | ||
|
|
d38a2894c4 | ||
|
|
4c70376a62 | ||
|
|
8d69bd1401 | ||
|
|
5dfecd3693 | ||
|
|
efd51d2234 | ||
|
|
668299c16a | ||
|
|
5e155a4030 | ||
|
|
809fa10135 | ||
|
|
1cbc38f231 | ||
|
|
9ed39bb523 | ||
|
|
4e3d66cf40 | ||
|
|
2eaad79d1c | ||
|
|
afef7a2c0f | ||
|
|
18d5224002 | ||
|
|
dbffdfeaca | ||
|
|
0a4b7917ab | ||
|
|
e1524358d9 | ||
|
|
8774f9c3fc | ||
|
|
f9a9aeacab | ||
|
|
b798fee116 | ||
|
|
b25f731f0f | ||
|
|
26a7372c5e | ||
|
|
70d3409d62 | ||
|
|
0711ecddab | ||
|
|
bcfaa67eba | ||
|
|
1b60e6e04e | ||
|
|
a1a634f6dc | ||
|
|
55f48fbb56 | ||
|
|
ca4d66b94c | ||
|
|
51fd2eedd9 | ||
|
|
434a7c2e93 | ||
|
|
b849fecf0b | ||
|
|
3a48e1996f | ||
|
|
8299386737 | ||
|
|
5e58ff476f | ||
|
|
758d955053 | ||
|
|
1efd5d26f0 | ||
|
|
36979f10cc | ||
|
|
812c59fcb4 | ||
|
|
0c34165bcf | ||
|
|
8c2bfbe9ce | ||
|
|
d98e373f64 | ||
|
|
649516c9fa | ||
|
|
bbc4fb96b2 | ||
|
|
0ae639aeb0 | ||
|
|
0e7e41065e | ||
|
|
685843f112 | ||
|
|
5e1a99d94a | ||
|
|
d843349865 | ||
|
|
ec23164aa9 | ||
|
|
e74ef11101 | ||
|
|
a222f6a736 | ||
|
|
ef3dd16d45 | ||
|
|
5d4e1d205e | ||
|
|
1ee5ebbe75 | ||
|
|
59d705aa3d | ||
|
|
332e108dae | ||
|
|
3c15b29d0a | ||
|
|
130c708e23 | ||
|
|
588a14a8a7 | ||
|
|
a1ef6ad266 | ||
|
|
a6c1f87730 | ||
|
|
49252a3808 | ||
|
|
c7877fe38f | ||
|
|
e355a61d8f | ||
|
|
f2e19e51ce | ||
|
|
fd9ab8f561 | ||
|
|
faa1b3c98f | ||
|
|
acc4a84fc9 | ||
|
|
4d723dac37 | ||
|
|
f1d4d0ef98 | ||
|
|
88180a2708 | ||
|
|
258d87e3d5 | ||
|
|
55f22ba61a | ||
|
|
812f3ca8b9 | ||
|
|
7f880d11a0 | ||
|
|
6b2452c538 | ||
|
|
c2cbf8bd21 | ||
|
|
224bcece9c | ||
|
|
dc84b7698f | ||
|
|
bc22e6a9bd | ||
|
|
d44874783a | ||
|
|
8d1bb5c867 | ||
|
|
da1b528eee | ||
|
|
756138408a | ||
|
|
3c8f112565 | ||
|
|
2521f3dde4 | ||
|
|
56390aa01a | ||
|
|
9aac5b19da | ||
|
|
24afc3dc88 | ||
|
|
873c7b2947 | ||
|
|
648db4276b | ||
|
|
f86c3e7856 | ||
|
|
1d0251cc28 | ||
|
|
518cf87847 | ||
|
|
81a9216c44 | ||
|
|
f0e10e0058 | ||
|
|
5df8ea4f07 | ||
|
|
73f081f5cc | ||
|
|
f0d1db1da6 | ||
|
|
c658eb414b | ||
|
|
bac493b72b |
@@ -21,8 +21,8 @@ type DialogType =
|
||||
| "basic"
|
||||
| "basic-subtitle-below"
|
||||
| "basic-subtitle-above"
|
||||
| "allow-mode-change"
|
||||
| "form"
|
||||
| "form-block-mode"
|
||||
| "actions"
|
||||
| "large"
|
||||
| "small";
|
||||
@@ -69,8 +69,8 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
<ha-button @click=${this._handleOpenDialog("form")}
|
||||
>Adaptive dialog with form</ha-button
|
||||
>
|
||||
<ha-button @click=${this._handleOpenDialog("form-block-mode")}
|
||||
>Adaptive dialog with form (block mode change)</ha-button
|
||||
<ha-button @click=${this._handleOpenDialog("allow-mode-change")}
|
||||
>Adaptive dialog with allow mode change</ha-button
|
||||
>
|
||||
<ha-button @click=${this._handleOpenDialog("actions")}
|
||||
>Adaptive dialog with actions</ha-button
|
||||
@@ -164,27 +164,15 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
|
||||
<ha-adaptive-dialog
|
||||
.hass=${this._hass}
|
||||
.open=${this._openDialog === "form-block-mode"}
|
||||
header-title="Adaptive dialog with form (block mode change)"
|
||||
header-subtitle="This form will not reset when the viewport size changes"
|
||||
block-mode-change
|
||||
.allowModeChange=${this._openDialog === "allow-mode-change"}
|
||||
header-title="Adaptive dialog with allow mode change"
|
||||
header-subtitle="Resize the window while this dialog is open"
|
||||
@closed=${this._handleClosed}
|
||||
>
|
||||
<ha-form autofocus .schema=${SCHEMA}></ha-form>
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
@click=${this._handleClosed}
|
||||
slot="secondaryAction"
|
||||
variant="plain"
|
||||
>Cancel</ha-button
|
||||
>
|
||||
<ha-button
|
||||
@click=${this._handleClosed}
|
||||
slot="primaryAction"
|
||||
variant="accent"
|
||||
>Submit</ha-button
|
||||
>
|
||||
</ha-dialog-footer>
|
||||
<div>
|
||||
This dialog can switch between dialog mode and bottom sheet mode
|
||||
while open.
|
||||
</div>
|
||||
</ha-adaptive-dialog>
|
||||
|
||||
<ha-adaptive-dialog
|
||||
@@ -225,10 +213,9 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
The mode is determined automatically and updates when the window is
|
||||
resized. To prevent mode changes after the initial mount (useful for
|
||||
preventing form resets), use the <code>block-mode-change</code>
|
||||
attribute.
|
||||
By default, the mode is determined at mount time and then stays fixed
|
||||
while the dialog is open. To allow switching modes while the viewport
|
||||
changes, use the <code>allow-mode-change</code> attribute.
|
||||
</p>
|
||||
|
||||
<h3>Width</h3>
|
||||
@@ -399,10 +386,10 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Use the <code>block-mode-change</code> attribute when you want to
|
||||
prevent the dialog from switching modes after it's opened. This is
|
||||
especially useful for forms, as it prevents form data from being lost
|
||||
when users resize their browser window.
|
||||
Use the <code>allow-mode-change</code> attribute when you want the
|
||||
dialog to switch between modes as the viewport changes after opening.
|
||||
For forms, you can keep the default behavior to avoid resetting fields
|
||||
on resize.
|
||||
</p>
|
||||
|
||||
<h3>Example usage</h3>
|
||||
@@ -426,23 +413,6 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
</ha-dialog-footer>
|
||||
</ha-adaptive-dialog></code></pre>
|
||||
|
||||
<p>Example with <code>block-mode-change</code> for forms:</p>
|
||||
|
||||
<pre><code><ha-adaptive-dialog
|
||||
.hass=\${this.hass}
|
||||
open
|
||||
header-title="Edit configuration"
|
||||
block-mode-change
|
||||
>
|
||||
<ha-form .schema=\${schema} .data=\${data}></ha-form>
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button slot="secondaryAction" variant="plain"
|
||||
>Cancel</ha-button
|
||||
>
|
||||
<ha-button slot="primaryAction" variant="accent">Save</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-adaptive-dialog></code></pre>
|
||||
|
||||
<h3>API</h3>
|
||||
|
||||
<p>
|
||||
@@ -520,12 +490,10 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>block-mode-change</code></td>
|
||||
<td><code>allow-mode-change</code></td>
|
||||
<td>
|
||||
When set, the mode is determined at mount time based on the
|
||||
current screen size, but subsequent mode changes are blocked.
|
||||
Useful for preventing forms from resetting when the viewport
|
||||
size changes.
|
||||
When set, the dialog can switch between modes as the viewport
|
||||
size changes while it is open.
|
||||
</td>
|
||||
<td><code>false</code></td>
|
||||
<td><code>false</code>, <code>true</code></td>
|
||||
|
||||
40
package.json
40
package.json
@@ -34,10 +34,10 @@
|
||||
"@codemirror/legacy-modes": "6.5.2",
|
||||
"@codemirror/search": "6.6.0",
|
||||
"@codemirror/state": "6.5.4",
|
||||
"@codemirror/view": "6.39.12",
|
||||
"@codemirror/view": "6.39.15",
|
||||
"@date-fns/tz": "1.4.1",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.2.1",
|
||||
"@formatjs/intl-datetimeformat": "7.2.2",
|
||||
"@formatjs/intl-displaynames": "7.2.1",
|
||||
"@formatjs/intl-durationformat": "0.10.1",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.1",
|
||||
@@ -52,7 +52,7 @@
|
||||
"@fullcalendar/list": "6.1.20",
|
||||
"@fullcalendar/luxon3": "6.1.20",
|
||||
"@fullcalendar/timegrid": "6.1.20",
|
||||
"@home-assistant/webawesome": "3.2.1-ha.2",
|
||||
"@home-assistant/webawesome": "3.2.1-ha.3",
|
||||
"@lezer/highlight": "1.2.3",
|
||||
"@lit-labs/motion": "1.1.0",
|
||||
"@lit-labs/observers": "2.1.0",
|
||||
@@ -83,7 +83,7 @@
|
||||
"@mdi/js": "7.4.47",
|
||||
"@mdi/svg": "7.4.47",
|
||||
"@replit/codemirror-indentation-markers": "6.5.3",
|
||||
"@swc/helpers": "0.5.18",
|
||||
"@swc/helpers": "0.5.19",
|
||||
"@thomasloven/round-slider": "0.6.0",
|
||||
"@tsparticles/engine": "3.9.1",
|
||||
"@tsparticles/preset-links": "3.2.0",
|
||||
@@ -118,7 +118,7 @@
|
||||
"lit": "3.3.2",
|
||||
"lit-html": "3.3.2",
|
||||
"luxon": "3.7.2",
|
||||
"marked": "17.0.1",
|
||||
"marked": "17.0.3",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.4",
|
||||
"object-hash": "3.0.0",
|
||||
@@ -148,14 +148,14 @@
|
||||
"@babel/helper-define-polyfill-provider": "0.6.6",
|
||||
"@babel/plugin-transform-runtime": "7.29.0",
|
||||
"@babel/preset-env": "7.29.0",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.21.9",
|
||||
"@html-eslint/eslint-plugin": "0.55.0",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.21.10",
|
||||
"@html-eslint/eslint-plugin": "0.56.0",
|
||||
"@lokalise/node-api": "15.6.1",
|
||||
"@octokit/auth-oauth-device": "8.0.3",
|
||||
"@octokit/plugin-retry": "8.0.3",
|
||||
"@octokit/plugin-retry": "8.1.0",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@rsdoctor/rspack-plugin": "1.5.2",
|
||||
"@rspack/core": "1.7.5",
|
||||
"@rspack/core": "1.7.6",
|
||||
"@rspack/dev-server": "1.2.1",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.25",
|
||||
@@ -172,7 +172,7 @@
|
||||
"@types/mocha": "10.0.10",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "6.1.13",
|
||||
"@types/tar": "7.0.87",
|
||||
"@types/ua-parser-js": "0.7.39",
|
||||
"@types/webspeechapi": "0.0.29",
|
||||
"@vitest/coverage-v8": "4.0.18",
|
||||
@@ -180,25 +180,25 @@
|
||||
"babel-plugin-template-html-minifier": "4.1.0",
|
||||
"browserslist-useragent-regexp": "4.1.3",
|
||||
"del": "8.0.1",
|
||||
"eslint": "9.39.2",
|
||||
"eslint": "9.39.3",
|
||||
"eslint-config-airbnb-base": "15.0.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.10",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-lit": "2.1.1",
|
||||
"eslint-plugin-lit": "2.2.1",
|
||||
"eslint-plugin-lit-a11y": "5.1.1",
|
||||
"eslint-plugin-unused-imports": "4.3.0",
|
||||
"eslint-plugin-wc": "3.0.2",
|
||||
"eslint-plugin-unused-imports": "4.4.1",
|
||||
"eslint-plugin-wc": "3.1.0",
|
||||
"fancy-log": "2.0.0",
|
||||
"fs-extra": "11.3.3",
|
||||
"glob": "13.0.1",
|
||||
"glob": "13.0.6",
|
||||
"gulp": "5.0.1",
|
||||
"gulp-brotli": "3.0.0",
|
||||
"gulp-json-transform": "0.5.0",
|
||||
"gulp-rename": "2.1.0",
|
||||
"html-minifier-terser": "7.2.0",
|
||||
"husky": "9.1.7",
|
||||
"jsdom": "28.0.0",
|
||||
"jsdom": "28.1.0",
|
||||
"jszip": "3.10.1",
|
||||
"lint-staged": "16.2.7",
|
||||
"lit-analyzer": "2.0.3",
|
||||
@@ -210,12 +210,12 @@
|
||||
"rspack-manifest-plugin": "5.2.1",
|
||||
"serve": "14.2.5",
|
||||
"sinon": "21.0.1",
|
||||
"tar": "7.5.8",
|
||||
"tar": "7.5.9",
|
||||
"terser-webpack-plugin": "5.3.16",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.54.0",
|
||||
"vite-tsconfig-paths": "6.0.5",
|
||||
"typescript-eslint": "8.56.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.0.18",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
"webpackbar": "7.0.0",
|
||||
@@ -235,6 +235,6 @@
|
||||
},
|
||||
"packageManager": "yarn@4.12.0",
|
||||
"volta": {
|
||||
"node": "24.13.1"
|
||||
"node": "24.14.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,3 +210,39 @@ const formatDateWeekdayShortMem = memoizeOne(
|
||||
timeZone: resolveTimeZone(locale.time_zone, serverTimeZone),
|
||||
})
|
||||
);
|
||||
|
||||
// Mon, Aug 10
|
||||
export const formatDateWeekdayVeryShortDate = (
|
||||
dateObj: Date,
|
||||
locale: FrontendLocaleData,
|
||||
config: HassConfig
|
||||
) =>
|
||||
formatDateWeekdayVeryShortDateMem(locale, config.time_zone).format(dateObj);
|
||||
|
||||
const formatDateWeekdayVeryShortDateMem = memoizeOne(
|
||||
(locale: FrontendLocaleData, serverTimeZone: string) =>
|
||||
new Intl.DateTimeFormat(locale.language, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: resolveTimeZone(locale.time_zone, serverTimeZone),
|
||||
})
|
||||
);
|
||||
|
||||
// Mon, Aug 10, 2021
|
||||
export const formatDateWeekdayShortDate = (
|
||||
dateObj: Date,
|
||||
locale: FrontendLocaleData,
|
||||
config: HassConfig
|
||||
) => formatDateWeekdayShortDateMem(locale, config.time_zone).format(dateObj);
|
||||
|
||||
const formatDateWeekdayShortDateMem = memoizeOne(
|
||||
(locale: FrontendLocaleData, serverTimeZone: string) =>
|
||||
new Intl.DateTimeFormat(locale.language, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
timeZone: resolveTimeZone(locale.time_zone, serverTimeZone),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -32,11 +32,13 @@ export class DialogDataTableSettings extends LitElement {
|
||||
|
||||
@state() private _hiddenColumns?: string[];
|
||||
|
||||
private _lastFixedKeys: string[] = [];
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
public showDialog(params: DataTableSettingsDialogParams) {
|
||||
this._params = params;
|
||||
this._columnOrder = params.columnOrder;
|
||||
this._columnOrder = this._preserveLastFixed(params.columnOrder);
|
||||
this._hiddenColumns = params.hiddenColumns;
|
||||
this._open = true;
|
||||
}
|
||||
@@ -50,6 +52,29 @@ export class DialogDataTableSettings extends LitElement {
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
private _lastFixedCount(): number {
|
||||
const lastFixedKeys = Object.keys(this._params!.columns).filter(
|
||||
(col) => this._params!.columns[col].lastFixed
|
||||
);
|
||||
if (lastFixedKeys.length) {
|
||||
this._lastFixedKeys = lastFixedKeys;
|
||||
}
|
||||
return lastFixedKeys.length;
|
||||
}
|
||||
|
||||
private _preserveLastFixed(columnOrder) {
|
||||
let strippedColumnOrder;
|
||||
const lastFixedCount = this._lastFixedCount();
|
||||
if (lastFixedCount && columnOrder) {
|
||||
strippedColumnOrder = [...columnOrder];
|
||||
strippedColumnOrder.splice(
|
||||
columnOrder.length - lastFixedCount,
|
||||
lastFixedCount
|
||||
);
|
||||
}
|
||||
return strippedColumnOrder;
|
||||
}
|
||||
|
||||
private _sortedColumns = memoizeOne(
|
||||
(
|
||||
columns: DataTableColumnContainer,
|
||||
@@ -57,7 +82,7 @@ export class DialogDataTableSettings extends LitElement {
|
||||
hiddenColumns: string[] | undefined
|
||||
) =>
|
||||
Object.keys(columns)
|
||||
.filter((col) => !columns[col].hidden)
|
||||
.filter((col) => !columns[col].hidden && !columns[col].lastFixed)
|
||||
.sort((a, b) => {
|
||||
const orderA = columnOrder?.indexOf(a) ?? -1;
|
||||
const orderB = columnOrder?.indexOf(b) ?? -1;
|
||||
@@ -195,7 +220,8 @@ export class DialogDataTableSettings extends LitElement {
|
||||
|
||||
this._columnOrder = columnOrder;
|
||||
|
||||
this._params!.onUpdate(this._columnOrder, this._hiddenColumns);
|
||||
const reportedOrder = columnOrder.concat(this._lastFixedKeys);
|
||||
this._params!.onUpdate(reportedOrder, this._hiddenColumns);
|
||||
}
|
||||
|
||||
private _toggle(ev) {
|
||||
@@ -276,7 +302,8 @@ export class DialogDataTableSettings extends LitElement {
|
||||
|
||||
this._hiddenColumns = hidden;
|
||||
|
||||
this._params!.onUpdate(this._columnOrder, this._hiddenColumns);
|
||||
const reportedOrder = this._columnOrder.concat(this._lastFixedKeys);
|
||||
this._params!.onUpdate(reportedOrder, this._hiddenColumns);
|
||||
}
|
||||
|
||||
private _reset() {
|
||||
|
||||
@@ -86,6 +86,7 @@ export interface DataTableColumnData<T = any> extends DataTableSortColumnData {
|
||||
flex?: number;
|
||||
forceLTR?: boolean;
|
||||
hidden?: boolean;
|
||||
lastFixed?: boolean;
|
||||
}
|
||||
|
||||
export type ClonedDataTableColumnData = Omit<DataTableColumnData, "title"> & {
|
||||
@@ -135,9 +136,6 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public searchLabel?: string;
|
||||
|
||||
@property({ type: Boolean, attribute: "no-label-float" })
|
||||
public noLabelFloat? = false;
|
||||
|
||||
@property({ type: String }) public filter = "";
|
||||
|
||||
@property({ attribute: false }) public groupColumn?: string;
|
||||
@@ -359,6 +357,11 @@ export class HaDataTable extends LitElement {
|
||||
.sort((a, b) => {
|
||||
const orderA = columnOrder!.indexOf(a);
|
||||
const orderB = columnOrder!.indexOf(b);
|
||||
const fixedA = Boolean(columns[a].lastFixed);
|
||||
const fixedB = Boolean(columns[b].lastFixed);
|
||||
if (fixedA !== fixedB) {
|
||||
return fixedA ? 1 : -1;
|
||||
}
|
||||
if (orderA !== orderB) {
|
||||
if (orderA === -1) {
|
||||
return 1;
|
||||
@@ -394,7 +397,6 @@ export class HaDataTable extends LitElement {
|
||||
.hass=${this.hass}
|
||||
@value-changed=${this._handleSearchChange}
|
||||
.label=${this.searchLabel}
|
||||
.noLabelFloat=${this.noLabelFloat}
|
||||
></search-input>
|
||||
</div>
|
||||
`
|
||||
@@ -428,9 +430,9 @@ export class HaDataTable extends LitElement {
|
||||
<ha-checkbox
|
||||
class="mdc-data-table__row-checkbox"
|
||||
@change=${this._handleHeaderRowCheckboxClick}
|
||||
.indeterminate=${this._checkedRows.length &&
|
||||
.indeterminate=${!!this._checkedRows.length &&
|
||||
this._checkedRows.length !== this._checkableRowsCount}
|
||||
.checked=${this._checkedRows.length &&
|
||||
.checked=${!!this._checkedRows.length &&
|
||||
this._checkedRows.length === this._checkableRowsCount}
|
||||
>
|
||||
</ha-checkbox>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import "../ha-generic-picker";
|
||||
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
|
||||
import type { PickerValueRenderer } from "../ha-picker-field";
|
||||
|
||||
@customElement("ha-entity-attribute-picker")
|
||||
class HaEntityAttributePicker extends LitElement {
|
||||
@@ -94,12 +95,19 @@ class HaEntityAttributePicker extends LitElement {
|
||||
.helper=${this.helper}
|
||||
.allowCustomValue=${this.allowCustomValue}
|
||||
.getItems=${this._getItems}
|
||||
.valueRenderer=${this._valueRenderer}
|
||||
@value-changed=${this._valueChanged}
|
||||
>
|
||||
</ha-generic-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
private _valueRenderer: PickerValueRenderer = (value: string) => {
|
||||
const items = this._getItems();
|
||||
const item = items.find((option) => option.id === value);
|
||||
return html`<span slot="headline">${item?.primary ?? value}</span>`;
|
||||
};
|
||||
|
||||
private _valueChanged(ev: ValueChangedEvent<string>) {
|
||||
ev.stopPropagation();
|
||||
const newValue = ev.detail.value;
|
||||
|
||||
@@ -15,6 +15,7 @@ import { iconColorCSS } from "../../common/style/icon_color_css";
|
||||
import { cameraUrlWithWidthHeight } from "../../data/camera";
|
||||
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { addBrandsAuth } from "../../util/brands-url";
|
||||
import "../ha-state-icon";
|
||||
|
||||
@customElement("state-badge")
|
||||
@@ -137,6 +138,7 @@ export class StateBadge extends LitElement {
|
||||
let imageUrl =
|
||||
stateObj.attributes.entity_picture_local ||
|
||||
stateObj.attributes.entity_picture;
|
||||
imageUrl = addBrandsAuth(imageUrl);
|
||||
if (this.hass) {
|
||||
imageUrl = this.hass.hassUrl(imageUrl);
|
||||
}
|
||||
|
||||
@@ -672,11 +672,11 @@ export class HaAssistChat extends LitElement {
|
||||
--markdown-code-background-color: var(--primary-background-color);
|
||||
--markdown-code-text-color: var(--primary-text-color);
|
||||
--markdown-list-indent: 1.15em;
|
||||
&:not(:has(ha-markdown-element)) {
|
||||
min-height: 1lh;
|
||||
min-width: 1lh;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
ha-markdown:not(:has(ha-markdown-element)) {
|
||||
min-height: 1lh;
|
||||
min-width: 1lh;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.bouncer {
|
||||
width: 48px;
|
||||
|
||||
@@ -141,6 +141,9 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
|
||||
private _handleKeyDown = (ev: KeyboardEvent) => {
|
||||
if (ev.key === "Escape") {
|
||||
this._escapePressed = true;
|
||||
if (this.preventScrimClose) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
ev.stopPropagation();
|
||||
(ev.currentTarget as WaDrawer).open = false;
|
||||
}
|
||||
|
||||
@@ -84,6 +84,9 @@ export class HaButton extends Button {
|
||||
--button-color-fill-loud-hover: var(
|
||||
--ha-color-fill-primary-loud-hover
|
||||
);
|
||||
--button-color-fill-quiet-active: var(
|
||||
--ha-color-fill-primary-quiet-active
|
||||
);
|
||||
}
|
||||
|
||||
:host([variant="neutral"]) {
|
||||
@@ -99,6 +102,9 @@ export class HaButton extends Button {
|
||||
--button-color-fill-loud-hover: var(
|
||||
--ha-color-fill-neutral-loud-hover
|
||||
);
|
||||
--button-color-fill-quiet-active: var(
|
||||
--ha-color-fill-neutral-normal-active
|
||||
);
|
||||
}
|
||||
|
||||
:host([variant="success"]) {
|
||||
@@ -114,6 +120,9 @@ export class HaButton extends Button {
|
||||
--button-color-fill-loud-hover: var(
|
||||
--ha-color-fill-success-loud-hover
|
||||
);
|
||||
--button-color-fill-quiet-active: var(
|
||||
--ha-color-fill-success-quiet-active
|
||||
);
|
||||
}
|
||||
|
||||
:host([variant="warning"]) {
|
||||
@@ -129,6 +138,9 @@ export class HaButton extends Button {
|
||||
--button-color-fill-loud-hover: var(
|
||||
--ha-color-fill-warning-loud-hover
|
||||
);
|
||||
--button-color-fill-quiet-active: var(
|
||||
--ha-color-fill-warning-quiet-active
|
||||
);
|
||||
}
|
||||
|
||||
:host([variant="danger"]) {
|
||||
@@ -144,6 +156,9 @@ export class HaButton extends Button {
|
||||
--button-color-fill-loud-hover: var(
|
||||
--ha-color-fill-danger-loud-hover
|
||||
);
|
||||
--button-color-fill-quiet-active: var(
|
||||
--ha-color-fill-danger-quiet-active
|
||||
);
|
||||
}
|
||||
|
||||
:host([appearance~="plain"]) .button {
|
||||
@@ -187,6 +202,10 @@ export class HaButton extends Button {
|
||||
background-color: var(--ha-color-fill-disabled-normal-resting);
|
||||
color: var(--ha-color-on-disabled-normal);
|
||||
}
|
||||
:host([appearance~="plain"])
|
||||
.button:not(.disabled):not(.loading):active {
|
||||
background-color: var(--button-color-fill-quiet-active);
|
||||
}
|
||||
|
||||
:host([appearance~="accent"]) .button {
|
||||
background-color: var(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { STATE_RUNNING } from "home-assistant-js-websocket";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { computeStateName } from "../common/entity/compute_state_name";
|
||||
import { supportsFeature } from "../common/entity/supports-feature";
|
||||
@@ -58,12 +59,22 @@ export class HaCameraStream extends LitElement {
|
||||
@state() private _webRtcStreams?: { hasAudio: boolean; hasVideo: boolean };
|
||||
|
||||
public willUpdate(changedProps: PropertyValues): void {
|
||||
if (
|
||||
const entityChanged =
|
||||
changedProps.has("stateObj") &&
|
||||
this.stateObj &&
|
||||
(changedProps.get("stateObj") as CameraEntity | undefined)?.entity_id !==
|
||||
this.stateObj.entity_id
|
||||
) {
|
||||
this.stateObj.entity_id;
|
||||
|
||||
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
|
||||
const backendStarted =
|
||||
changedProps.has("hass") &&
|
||||
this.hass &&
|
||||
this.stateObj &&
|
||||
oldHass &&
|
||||
this.hass.config.state === STATE_RUNNING &&
|
||||
oldHass.config?.state !== STATE_RUNNING;
|
||||
|
||||
if (entityChanged || backendStarted) {
|
||||
this._getCapabilities();
|
||||
this._getPosterUrl();
|
||||
}
|
||||
|
||||
@@ -93,6 +93,8 @@ export class HaDateRangePicker extends LitElement {
|
||||
| "center"
|
||||
| "inline";
|
||||
|
||||
@state() private _calcedVerticalOpeningDirection?: "up" | "down";
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues) {
|
||||
if (
|
||||
(!this.hasUpdated && this.ranges === undefined) ||
|
||||
@@ -134,7 +136,9 @@ export class HaDateRangePicker extends LitElement {
|
||||
opening-direction=${ifDefined(
|
||||
this.openingDirection || this._calcedOpeningDirection
|
||||
)}
|
||||
opens-vertical=${ifDefined(this.verticalOpeningDirection)}
|
||||
opens-vertical=${ifDefined(
|
||||
this.verticalOpeningDirection || this._calcedVerticalOpeningDirection
|
||||
)}
|
||||
first-day=${firstWeekdayIndex(this.hass.locale)}
|
||||
language=${this.hass.locale.language}
|
||||
@change=${this._handleChange}
|
||||
@@ -328,17 +332,24 @@ export class HaDateRangePicker extends LitElement {
|
||||
|
||||
private _handleClick() {
|
||||
// calculate opening direction if not set
|
||||
if (!this._dateRangePicker.open && !this.openingDirection) {
|
||||
const datePickerPosition = this.getBoundingClientRect().x;
|
||||
let opens: "right" | "left" | "center" | "inline";
|
||||
if (datePickerPosition > (2 * window.innerWidth) / 3) {
|
||||
opens = "left";
|
||||
} else if (datePickerPosition < window.innerWidth / 3) {
|
||||
opens = "right";
|
||||
} else {
|
||||
opens = "center";
|
||||
if (!this._dateRangePicker.open) {
|
||||
if (!this.openingDirection) {
|
||||
const datePickerPosition = this.getBoundingClientRect().x;
|
||||
let opens: "right" | "left" | "center" | "inline";
|
||||
if (datePickerPosition > (2 * window.innerWidth) / 3) {
|
||||
opens = "left";
|
||||
} else if (datePickerPosition < window.innerWidth / 3) {
|
||||
opens = "right";
|
||||
} else {
|
||||
opens = "center";
|
||||
}
|
||||
this._calcedOpeningDirection = opens;
|
||||
}
|
||||
if (!this.verticalOpeningDirection) {
|
||||
const rect = this.getBoundingClientRect();
|
||||
this._calcedVerticalOpeningDirection =
|
||||
rect.top > window.innerHeight / 2 ? "up" : "down";
|
||||
}
|
||||
this._calcedOpeningDirection = opens;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ import "./ha-icon-button";
|
||||
|
||||
export type DialogWidth = "small" | "medium" | "large" | "full";
|
||||
|
||||
type DialogHideEvent = CustomEvent<{ source?: Element }>;
|
||||
|
||||
/**
|
||||
* Home Assistant dialog component
|
||||
*
|
||||
@@ -217,7 +219,7 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
|
||||
fireEvent(this, "after-show");
|
||||
};
|
||||
|
||||
private _handleAfterHide = (ev: CustomEvent<{ source: Element }>) => {
|
||||
private _handleAfterHide = (ev: DialogHideEvent) => {
|
||||
if (ev.eventPhase === Event.AT_TARGET) {
|
||||
this._open = false;
|
||||
fireEvent(this, "closed");
|
||||
@@ -237,13 +239,16 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
|
||||
private _handleKeyDown(ev: KeyboardEvent) {
|
||||
if (ev.key === "Escape") {
|
||||
this._escapePressed = true;
|
||||
if (this.preventScrimClose) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
ev.stopPropagation();
|
||||
(ev.currentTarget as WaDialog).open = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _handleHide(ev: CustomEvent<{ source: Element }>) {
|
||||
const sourceIsDialog = ev.detail.source === (ev.target as WaDialog).dialog;
|
||||
private _handleHide(ev: DialogHideEvent) {
|
||||
const sourceIsDialog = ev.detail?.source === (ev.target as WaDialog).dialog;
|
||||
|
||||
if (this.preventScrimClose && this._escapePressed && sourceIsDialog) {
|
||||
ev.preventDefault();
|
||||
@@ -332,29 +337,29 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
|
||||
@media all and (max-width: 450px), all and (max-height: 500px) {
|
||||
:host([type="standard"]) {
|
||||
--ha-dialog-border-radius: 0;
|
||||
}
|
||||
|
||||
wa-dialog {
|
||||
/* Make the container fill the whole screen width and not the safe width */
|
||||
--full-width: var(--ha-dialog-width-full, 100vw);
|
||||
--width: var(--full-width);
|
||||
}
|
||||
:host([type="standard"]) wa-dialog {
|
||||
/* Make the container fill the whole screen width and not the safe width */
|
||||
--full-width: var(--ha-dialog-width-full, 100vw);
|
||||
--width: var(--full-width);
|
||||
}
|
||||
|
||||
wa-dialog::part(dialog) {
|
||||
/* Make the dialog fill the whole screen height and not the safe height */
|
||||
min-height: var(--ha-dialog-min-height, 100vh);
|
||||
min-height: var(--ha-dialog-min-height, 100dvh);
|
||||
max-height: var(--ha-dialog-max-height, 100vh);
|
||||
max-height: var(--ha-dialog-max-height, 100dvh);
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
/* Use safe area as padding instead of the container size */
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
padding-bottom: var(--safe-area-inset-bottom);
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
/* Reset the transform to center the dialog */
|
||||
transform: none;
|
||||
}
|
||||
:host([type="standard"]) wa-dialog::part(dialog) {
|
||||
/* Make the dialog fill the whole screen height and not the safe height */
|
||||
min-height: var(--ha-dialog-min-height, 100vh);
|
||||
min-height: var(--ha-dialog-min-height, 100dvh);
|
||||
max-height: var(--ha-dialog-max-height, 100vh);
|
||||
max-height: var(--ha-dialog-max-height, 100dvh);
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
/* Use safe area as padding instead of the container size */
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
padding-bottom: var(--safe-area-inset-bottom);
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
/* Reset the transform to center the dialog */
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,13 +84,11 @@ export class HaMarkdown extends LitElement {
|
||||
ha-markdown-element > :is(ol, ul) {
|
||||
padding-inline-start: var(--markdown-list-indent, revert);
|
||||
}
|
||||
li {
|
||||
&:has(input[type="checkbox"]) {
|
||||
list-style: none;
|
||||
& > input[type="checkbox"] {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
li:has(input[type="checkbox"]) {
|
||||
list-style: none;
|
||||
}
|
||||
li:has(input[type="checkbox"]) > input[type="checkbox"] {
|
||||
margin-left: 0;
|
||||
}
|
||||
svg {
|
||||
background-color: var(--markdown-svg-background-color, none);
|
||||
@@ -137,10 +135,10 @@ export class HaMarkdown extends LitElement {
|
||||
--markdown-table-border-width: 0;
|
||||
--markdown-table-padding-inline: 0;
|
||||
--markdown-table-padding-block: 0;
|
||||
th,
|
||||
td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
table[role="presentation"] th,
|
||||
table[role="presentation"] td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
table[role="presentation"] td[valign="top"],
|
||||
table[role="presentation"] th[valign="top"] {
|
||||
|
||||
@@ -64,7 +64,7 @@ export class HaEntitySelector extends LitElement {
|
||||
if (!this.selector.entity?.multiple) {
|
||||
return html`<ha-entity-picker
|
||||
.hass=${this.hass}
|
||||
.value=${this.value}
|
||||
.value=${typeof this.value === "string" ? this.value : ""}
|
||||
.label=${this.label}
|
||||
.placeholder=${this.placeholder}
|
||||
.helper=${this.helper}
|
||||
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
} from "../../data/media-player";
|
||||
import type { MediaSelector, MediaSelectorValue } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { brandsUrl, extractDomainFromBrandUrl } from "../../util/brands-url";
|
||||
import {
|
||||
brandsUrl,
|
||||
extractDomainFromBrandUrl,
|
||||
isBrandUrl,
|
||||
} from "../../util/brands-url";
|
||||
import "../ha-alert";
|
||||
import "../ha-form/ha-form";
|
||||
import type { SchemaUnion } from "../ha-form/types";
|
||||
@@ -72,16 +76,7 @@ export class HaMediaSelector extends LitElement {
|
||||
if (thumbnail === oldThumbnail) {
|
||||
return;
|
||||
}
|
||||
if (thumbnail && thumbnail.startsWith("/")) {
|
||||
this._thumbnailUrl = undefined;
|
||||
// Thumbnails served by local API require authentication
|
||||
getSignedPath(this.hass, thumbnail).then((signedPath) => {
|
||||
this._thumbnailUrl = signedPath.path;
|
||||
});
|
||||
} else if (
|
||||
thumbnail &&
|
||||
thumbnail.startsWith("https://brands.home-assistant.io")
|
||||
) {
|
||||
if (thumbnail && isBrandUrl(thumbnail)) {
|
||||
// The backend is not aware of the theme used by the users,
|
||||
// so we rewrite the URL to show a proper icon
|
||||
this._thumbnailUrl = brandsUrl({
|
||||
@@ -89,6 +84,12 @@ export class HaMediaSelector extends LitElement {
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
});
|
||||
} else if (thumbnail && thumbnail.startsWith("/")) {
|
||||
this._thumbnailUrl = undefined;
|
||||
// Thumbnails served by local API require authentication
|
||||
getSignedPath(this.hass, thumbnail).then((signedPath) => {
|
||||
this._thumbnailUrl = signedPath.path;
|
||||
});
|
||||
} else {
|
||||
this._thumbnailUrl = thumbnail;
|
||||
}
|
||||
|
||||
@@ -221,7 +221,7 @@ export class HaSelectSelector extends LitElement {
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.getItems=${this._getItems(options)}
|
||||
.value=${this.value as string | undefined}
|
||||
.value=${typeof this.value === "string" ? this.value : undefined}
|
||||
@value-changed=${this._comboBoxValueChanged}
|
||||
allow-custom-value
|
||||
></ha-generic-picker>
|
||||
@@ -231,7 +231,7 @@ export class HaSelectSelector extends LitElement {
|
||||
return html`
|
||||
<ha-select
|
||||
.label=${this.label ?? ""}
|
||||
.value=${(this.value as string) ?? ""}
|
||||
.value=${typeof this.value === "string" ? this.value : ""}
|
||||
.helper=${this.helper ?? ""}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
|
||||
@@ -69,6 +69,10 @@ const SELECTOR_SCHEMAS = {
|
||||
default: true,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "allow_negative",
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
] as const,
|
||||
entity: [
|
||||
{
|
||||
|
||||
@@ -144,6 +144,7 @@ export const computePanels = memoizeOne(
|
||||
if (
|
||||
!isDefaultPanel &&
|
||||
(!panel.title ||
|
||||
panel.show_in_sidebar === false ||
|
||||
hiddenPanels.includes(panel.url_path) ||
|
||||
(panel.default_visible === false &&
|
||||
!panelsOrder.includes(panel.url_path)))
|
||||
|
||||
@@ -765,6 +765,16 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (isBrandUrl(thumbnailUrl)) {
|
||||
// The backend is not aware of the theme used by the users,
|
||||
// so we rewrite the URL to show a proper icon
|
||||
return brandsUrl({
|
||||
domain: extractDomainFromBrandUrl(thumbnailUrl),
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
});
|
||||
}
|
||||
|
||||
if (thumbnailUrl.startsWith("/")) {
|
||||
// Thumbnails served by local API require authentication
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -787,16 +797,6 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
if (isBrandUrl(thumbnailUrl)) {
|
||||
// The backend is not aware of the theme used by the users,
|
||||
// so we rewrite the URL to show a proper icon
|
||||
thumbnailUrl = brandsUrl({
|
||||
domain: extractDomainFromBrandUrl(thumbnailUrl),
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
});
|
||||
}
|
||||
|
||||
return thumbnailUrl;
|
||||
}
|
||||
|
||||
|
||||
@@ -159,6 +159,9 @@ export interface GasSourceTypeEnergyPreference {
|
||||
// kWh/volume meter
|
||||
stat_energy_from: string;
|
||||
|
||||
// Flow rate (m³/h, L/min, etc.)
|
||||
stat_rate?: string;
|
||||
|
||||
// $ meter
|
||||
stat_cost: string | null;
|
||||
|
||||
@@ -174,6 +177,9 @@ export interface WaterSourceTypeEnergyPreference {
|
||||
// volume meter
|
||||
stat_energy_from: string;
|
||||
|
||||
// Flow rate (L/min, gal/min, m³/h, etc.)
|
||||
stat_rate?: string;
|
||||
|
||||
// $ meter
|
||||
stat_cost: string | null;
|
||||
|
||||
@@ -368,6 +374,9 @@ export const getReferencedStatisticIdsPower = (
|
||||
|
||||
for (const source of prefs.energy_sources) {
|
||||
if (source.type === "gas" || source.type === "water") {
|
||||
if (source.stat_rate) {
|
||||
statIDs.push(source.stat_rate);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -389,6 +398,7 @@ export const getReferencedStatisticIdsPower = (
|
||||
}
|
||||
}
|
||||
statIDs.push(...prefs.device_consumption.map((d) => d.stat_rate));
|
||||
statIDs.push(...prefs.device_consumption_water.map((d) => d.stat_rate));
|
||||
|
||||
return statIDs.filter(Boolean) as string[];
|
||||
};
|
||||
@@ -1391,6 +1401,80 @@ export const calculateSolarConsumedGauge = (
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Conversion factors from each flow rate unit to L/min.
|
||||
* All HA-supported UnitOfVolumeFlowRate values are covered.
|
||||
*
|
||||
* m³/h → 1000/60 = 16.6667 L/min
|
||||
* m³/min → 1000 L/min
|
||||
* m³/s → 60000 L/min
|
||||
* ft³/min→ 28.3168 L/min
|
||||
* L/h → 1/60 L/min
|
||||
* L/min → 1 L/min
|
||||
* L/s → 60 L/min
|
||||
* gal/h → 3.78541/60 L/min
|
||||
* gal/min→ 3.78541 L/min
|
||||
* gal/d → 3.78541/1440 L/min
|
||||
* mL/s → 0.06 L/min
|
||||
*/
|
||||
|
||||
/** Exact number of liters in one US gallon */
|
||||
const LITERS_PER_GALLON = 3.785411784;
|
||||
|
||||
const FLOW_RATE_TO_LMIN: Record<string, number> = {
|
||||
"m³/h": 1000 / 60,
|
||||
"m³/min": 1000,
|
||||
"m³/s": 60000,
|
||||
"ft³/min": 28.316846592,
|
||||
"L/h": 1 / 60,
|
||||
"L/min": 1,
|
||||
"L/s": 60,
|
||||
"gal/h": LITERS_PER_GALLON / 60,
|
||||
"gal/min": LITERS_PER_GALLON,
|
||||
"gal/d": LITERS_PER_GALLON / 1440,
|
||||
"mL/s": 60 / 1000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current flow rate from an entity state, converted to L/min.
|
||||
* @returns Flow rate in L/min, or undefined if unavailable/invalid.
|
||||
*/
|
||||
export const getFlowRateFromState = (
|
||||
stateObj?: HassEntity
|
||||
): number | undefined => {
|
||||
if (!stateObj) {
|
||||
return undefined;
|
||||
}
|
||||
const value = parseFloat(stateObj.state);
|
||||
if (isNaN(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const unit = stateObj.attributes.unit_of_measurement;
|
||||
const factor = unit ? FLOW_RATE_TO_LMIN[unit] : undefined;
|
||||
if (factor === undefined) {
|
||||
// Unknown unit – return raw value as-is (best effort)
|
||||
return value;
|
||||
}
|
||||
return value * factor;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a flow rate value (in L/min) to a human-readable string using
|
||||
* the preferred unit system: metric → L/min, imperial → gal/min.
|
||||
*/
|
||||
export const formatFlowRateShort = (
|
||||
hassLocale: HomeAssistant["locale"],
|
||||
lengthUnitSystem: string,
|
||||
litersPerMin: number
|
||||
): string => {
|
||||
const isMetric = lengthUnitSystem === "km";
|
||||
if (isMetric) {
|
||||
return `${formatNumber(litersPerMin, hassLocale, { maximumFractionDigits: 1 })} L/min`;
|
||||
}
|
||||
const galPerMin = litersPerMin / LITERS_PER_GALLON;
|
||||
return `${formatNumber(galPerMin, hassLocale, { maximumFractionDigits: 1 })} gal/min`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current power value from entity state, normalized to watts (W)
|
||||
* @param stateObj - The entity state object to get power value from
|
||||
|
||||
@@ -3,7 +3,6 @@ import { formatDurationDigital } from "../../common/datetime/format_duration";
|
||||
import type { FrontendLocaleData } from "../translation";
|
||||
import { computeStateDomain } from "../../common/entity/compute_state_domain";
|
||||
|
||||
// These attributes are hidden from the more-info window for all entities.
|
||||
export const STATE_ATTRIBUTES = [
|
||||
"entity_id",
|
||||
"assumed_state",
|
||||
@@ -29,8 +28,6 @@ export const STATE_ATTRIBUTES = [
|
||||
"available_tones",
|
||||
];
|
||||
|
||||
// These attributes are hidden from the more-info window for entities of the
|
||||
// matching domain and device_class.
|
||||
export const STATE_ATTRIBUTES_DOMAIN_CLASS = {
|
||||
sensor: {
|
||||
enum: ["options"],
|
||||
|
||||
@@ -37,6 +37,11 @@ export interface LovelaceViewHeaderConfig {
|
||||
badges_wrap?: "wrap" | "scroll";
|
||||
}
|
||||
|
||||
export interface LovelaceViewFooterConfig {
|
||||
card?: LovelaceCardConfig;
|
||||
column_span?: number;
|
||||
}
|
||||
|
||||
export interface LovelaceViewSidebarConfig {
|
||||
sections?: LovelaceSectionConfig[];
|
||||
content_label?: string;
|
||||
@@ -68,6 +73,7 @@ export interface LovelaceViewConfig extends LovelaceBaseViewConfig {
|
||||
cards?: LovelaceCardConfig[];
|
||||
sections?: LovelaceSectionRawConfig[];
|
||||
header?: LovelaceViewHeaderConfig;
|
||||
footer?: LovelaceViewFooterConfig;
|
||||
// Only used for section view, it should move to a section view config type when the views will have dedicated editor.
|
||||
sidebar?: LovelaceViewSidebarConfig;
|
||||
}
|
||||
|
||||
@@ -8,12 +8,17 @@ import {
|
||||
mdiPlayBoxMultiple,
|
||||
mdiTooltipAccount,
|
||||
} from "@mdi/js";
|
||||
import type { HomeAssistant, PanelInfo } from "../types";
|
||||
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
|
||||
import type { LocalizeKeys } from "../common/translations/localize";
|
||||
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
|
||||
import type { HomeAssistant, PanelInfo } from "../types";
|
||||
|
||||
export const HOME_PANEL = "home";
|
||||
export const NOT_FOUND_PANEL = "notfound";
|
||||
export const PROFILE_PANEL = "profile";
|
||||
export const LOVELACE_PANEL = "lovelace";
|
||||
|
||||
/** Panel to show when no panel is picked. */
|
||||
export const DEFAULT_PANEL = "home";
|
||||
export const DEFAULT_PANEL = HOME_PANEL;
|
||||
|
||||
export const hasLegacyOverviewPanel = (hass: HomeAssistant): boolean =>
|
||||
Boolean(hass.panels.lovelace?.config);
|
||||
@@ -30,7 +35,7 @@ export const getDefaultPanelUrlPath = (hass: HomeAssistant): string => {
|
||||
getLegacyDefaultPanelUrlPath() ||
|
||||
DEFAULT_PANEL;
|
||||
// If default panel is lovelace and no old overview exists, fall back to home
|
||||
if (defaultPanel === "lovelace" && !hasLegacyOverviewPanel(hass)) {
|
||||
if (defaultPanel === LOVELACE_PANEL && !hasLegacyOverviewPanel(hass)) {
|
||||
return DEFAULT_PANEL;
|
||||
}
|
||||
return defaultPanel;
|
||||
@@ -39,12 +44,16 @@ export const getDefaultPanelUrlPath = (hass: HomeAssistant): string => {
|
||||
export const getDefaultPanel = (hass: HomeAssistant): PanelInfo => {
|
||||
const panel = getDefaultPanelUrlPath(hass);
|
||||
|
||||
return (panel ? hass.panels[panel] : undefined) ?? hass.panels[DEFAULT_PANEL];
|
||||
return (
|
||||
(panel ? hass.panels[panel] : undefined) ??
|
||||
hass.panels[DEFAULT_PANEL] ??
|
||||
hass.panels[NOT_FOUND_PANEL]
|
||||
);
|
||||
};
|
||||
|
||||
export const getPanelNameTranslationKey = (panel: PanelInfo) => {
|
||||
if (panel.url_path === "profile") {
|
||||
return "panel.profile" as const;
|
||||
if ([PROFILE_PANEL, NOT_FOUND_PANEL].includes(panel.url_path)) {
|
||||
return `panel.${panel.url_path}` as const;
|
||||
}
|
||||
|
||||
return `panel.${panel.title}` as const;
|
||||
@@ -137,4 +146,22 @@ export const PANEL_ICON_PATHS = {
|
||||
export const getPanelIconPath = (panel: PanelInfo): string | undefined =>
|
||||
PANEL_ICON_PATHS[panel.url_path];
|
||||
|
||||
export const FIXED_PANELS = ["profile", "config"];
|
||||
export const FIXED_PANELS = [PROFILE_PANEL, "config", NOT_FOUND_PANEL];
|
||||
|
||||
export interface PanelMutableParams {
|
||||
title?: string | null;
|
||||
icon?: string | null;
|
||||
require_admin?: boolean | null;
|
||||
show_in_sidebar?: boolean | null;
|
||||
}
|
||||
|
||||
export const updatePanel = (
|
||||
hass: HomeAssistant,
|
||||
urlPath: string,
|
||||
updates: PanelMutableParams
|
||||
) =>
|
||||
hass.callWS({
|
||||
type: "frontend/update_panel",
|
||||
url_path: urlPath,
|
||||
...updates,
|
||||
});
|
||||
|
||||
@@ -1,136 +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 "../../components/ha-attribute-value";
|
||||
import "../../components/ha-card";
|
||||
import { computeShownAttributes } from "../../data/entity/entity_attributes";
|
||||
import type { ExtEntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
|
||||
interface AttributesViewParams {
|
||||
entityId: string;
|
||||
}
|
||||
|
||||
@customElement("ha-more-info-attributes")
|
||||
class HaMoreInfoAttributes extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public entry?: ExtEntityRegistryEntry | null;
|
||||
|
||||
@property({ attribute: false }) public params?: AttributesViewParams;
|
||||
|
||||
@state() private _stateObj?: HassEntity;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
super.willUpdate(changedProps);
|
||||
if (changedProps.has("params") || changedProps.has("hass")) {
|
||||
if (this.params?.entityId && this.hass) {
|
||||
this._stateObj = this.hass.states[this.params.entityId];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.params || !this._stateObj) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const attributes = computeShownAttributes(this._stateObj);
|
||||
|
||||
return html`
|
||||
<div class="content">
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
${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
|
||||
.hass=${this.hass}
|
||||
.attribute=${attribute}
|
||||
.stateObj=${this._stateObj}
|
||||
></ha-attribute-value>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</ha-card>
|
||||
${this._stateObj.attributes.attribution
|
||||
? html`
|
||||
<div class="attribution">
|
||||
${this._stateObj.attributes.attribution}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles: CSSResultGroup = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: var(--ha-space-6);
|
||||
padding-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-6));
|
||||
}
|
||||
|
||||
ha-card {
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: var(--ha-space-2) var(--ha-space-4);
|
||||
}
|
||||
|
||||
.data-entry {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: var(--ha-space-2) 0;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.data-entry:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.data-entry .value {
|
||||
max-width: 60%;
|
||||
overflow-wrap: break-word;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.key {
|
||||
flex-grow: 1;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.attribution {
|
||||
color: var(--secondary-text-color);
|
||||
text-align: center;
|
||||
margin-top: var(--ha-space-4);
|
||||
font-size: var(--ha-font-size-s);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-more-info-attributes": HaMoreInfoAttributes;
|
||||
}
|
||||
}
|
||||
189
src/dialogs/more-info/ha-more-info-details.ts
Normal file
189
src/dialogs/more-info/ha-more-info-details.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
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 "../../components/ha-attribute-value";
|
||||
import "../../components/ha-card";
|
||||
import { computeShownAttributes } from "../../data/entity/entity_attributes";
|
||||
import type { ExtEntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
|
||||
interface DetailsViewParams {
|
||||
entityId: string;
|
||||
}
|
||||
|
||||
@customElement("ha-more-info-details")
|
||||
class HaMoreInfoDetails extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public entry?: ExtEntityRegistryEntry | null;
|
||||
|
||||
@property({ attribute: false }) public params?: DetailsViewParams;
|
||||
|
||||
@state() private _stateObj?: HassEntity;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
super.willUpdate(changedProps);
|
||||
if (changedProps.has("params") || changedProps.has("hass")) {
|
||||
if (this.params?.entityId && this.hass) {
|
||||
this._stateObj = this.hass.states[this.params.entityId];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.params || !this._stateObj) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const translatedState = this.hass.formatEntityState(this._stateObj);
|
||||
const detailsAttributes = computeShownAttributes(this._stateObj);
|
||||
const detailsAttributeSet = new Set(detailsAttributes);
|
||||
const builtInAttributes = Object.keys(this._stateObj.attributes).filter(
|
||||
(attribute) => !detailsAttributeSet.has(attribute)
|
||||
);
|
||||
const allAttributes = [...detailsAttributes, ...builtInAttributes];
|
||||
|
||||
return html`
|
||||
<div class="content">
|
||||
<section class="section">
|
||||
<h2 class="section-title">
|
||||
${this.hass.localize(
|
||||
"ui.components.entity.entity-state-picker.state"
|
||||
)}
|
||||
</h2>
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<div class="attribute-group">
|
||||
<div class="data-entry">
|
||||
<div class="key">
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.translated"
|
||||
)}
|
||||
</div>
|
||||
<div class="value">${translatedState}</div>
|
||||
</div>
|
||||
<div class="data-entry">
|
||||
<div class="key">
|
||||
${this.hass.localize("ui.dialogs.more_info_control.raw")}
|
||||
</div>
|
||||
<div class="value">${this._stateObj.state}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2 class="section-title">
|
||||
${this.hass.localize("ui.dialogs.more_info_control.attributes")}
|
||||
</h2>
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<div class="attribute-group">
|
||||
${this._renderAttributes(allAttributes)}
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderAttributes(attributes: string[]) {
|
||||
if (attributes.length === 0) {
|
||||
return html`<div class="empty">
|
||||
${this.hass.localize("ui.common.none")}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return 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
|
||||
.hass=${this.hass}
|
||||
.attribute=${attribute}
|
||||
.stateObj=${this._stateObj}
|
||||
></ha-attribute-value>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
static styles: CSSResultGroup = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: var(--ha-space-6);
|
||||
padding-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-6));
|
||||
}
|
||||
|
||||
.section + .section {
|
||||
margin-top: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 var(--ha-space-2);
|
||||
font-size: var(--ha-font-size-m);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
|
||||
ha-card {
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: var(--ha-space-2) var(--ha-space-4);
|
||||
}
|
||||
|
||||
.data-entry {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: var(--ha-space-2) 0;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.attribute-group .data-entry:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.data-entry .value {
|
||||
max-width: 60%;
|
||||
overflow-wrap: break-word;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.key {
|
||||
flex-grow: 1;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--secondary-text-color);
|
||||
text-align: center;
|
||||
padding: var(--ha-space-2) 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-more-info-details": HaMoreInfoDetails;
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,6 @@ import "../../components/ha-dropdown-item";
|
||||
import "../../components/ha-icon-button";
|
||||
import "../../components/ha-icon-button-prev";
|
||||
import "../../components/ha-related-items";
|
||||
import { computeShownAttributes } from "../../data/entity/entity_attributes";
|
||||
import type {
|
||||
EntityRegistryEntry,
|
||||
ExtEntityRegistryEntry,
|
||||
@@ -344,31 +343,21 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
|
||||
case "info":
|
||||
this._resetInitialView();
|
||||
break;
|
||||
case "attributes":
|
||||
this._showAttributes();
|
||||
case "details":
|
||||
this._showDetails();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private _showAttributes(): void {
|
||||
import("./ha-more-info-attributes");
|
||||
private _showDetails(): void {
|
||||
import("./ha-more-info-details");
|
||||
this._childView = {
|
||||
viewTag: "ha-more-info-attributes",
|
||||
viewTag: "ha-more-info-details",
|
||||
viewTitle: this.hass.localize("ui.dialogs.more_info_control.details"),
|
||||
viewParams: { entityId: this._entityId },
|
||||
};
|
||||
}
|
||||
|
||||
private _hasDisplayableAttributes(): boolean {
|
||||
if (!this._entityId) {
|
||||
return false;
|
||||
}
|
||||
const stateObj = this.hass.states[this._entityId];
|
||||
if (!stateObj) {
|
||||
return false;
|
||||
}
|
||||
return computeShownAttributes(stateObj).length > 0;
|
||||
}
|
||||
|
||||
private _goToAddEntityTo(ev) {
|
||||
// Only check for request-selected events (from menu items), not regular clicks (from icon button)
|
||||
if (ev.type === "request-selected" && !shouldHandleRequestSelectedEvent(ev))
|
||||
@@ -590,19 +579,15 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
|
||||
"ui.dialogs.more_info_control.related"
|
||||
)}
|
||||
</ha-dropdown-item>
|
||||
${this._hasDisplayableAttributes()
|
||||
? html`
|
||||
<ha-dropdown-item value="attributes">
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiFormatListBulletedSquare}
|
||||
></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.attributes"
|
||||
)}
|
||||
</ha-dropdown-item>
|
||||
`
|
||||
: nothing}
|
||||
<ha-dropdown-item value="details">
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiFormatListBulletedSquare}
|
||||
></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.details"
|
||||
)}
|
||||
</ha-dropdown-item>
|
||||
${this._shouldShowAddEntityTo()
|
||||
? html`
|
||||
<ha-dropdown-item value="add_to">
|
||||
|
||||
@@ -32,21 +32,28 @@ const initRouting = () => {
|
||||
new CacheFirst({ matchOptions: { ignoreSearch: true } })
|
||||
);
|
||||
|
||||
// Cache any brand images used for 30 days
|
||||
// Use revalidation so cache is always available during an extended outage
|
||||
// Cache any brand images used for 1 day
|
||||
// Brands are proxied via the local API with backend caching.
|
||||
// Strip the rotating access token from cache keys so token rotation
|
||||
// doesn't bust the cache, while preserving other params like "placeholder".
|
||||
registerRoute(
|
||||
({ url, request }) =>
|
||||
url.origin === "https://brands.home-assistant.io" &&
|
||||
url.pathname.startsWith("/api/brands/") &&
|
||||
request.destination === "image",
|
||||
new StaleWhileRevalidate({
|
||||
cacheName: "brands",
|
||||
// CORS must be forced to work for CSS images
|
||||
fetchOptions: { mode: "cors", credentials: "omit" },
|
||||
plugins: [
|
||||
{
|
||||
cacheKeyWillBeUsed: async ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
url.searchParams.delete("token");
|
||||
return url.href;
|
||||
},
|
||||
},
|
||||
// Add 404 so we quickly respond to domains with missing images
|
||||
new CacheableResponsePlugin({ statuses: [0, 200, 404] }),
|
||||
new ExpirationPlugin({
|
||||
maxAgeSeconds: 60 * 60 * 24 * 30,
|
||||
maxAgeSeconds: 60 * 60 * 24,
|
||||
purgeOnQuotaError: true,
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -51,8 +51,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
|
||||
@property({ type: Boolean }) public supervisor = false;
|
||||
|
||||
@property({ type: Boolean, attribute: "main-page" }) public mainPage = false;
|
||||
|
||||
@property({ attribute: false }) public initialCollapsedGroups: string[] = [];
|
||||
@@ -322,7 +320,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
? html`
|
||||
<ha-dropdown-item
|
||||
.value=${id}
|
||||
.clickAction=${this._handleGroupBy}
|
||||
.selected=${id === this._groupColumn}
|
||||
class=${classMap({ selected: id === this._groupColumn })}
|
||||
>
|
||||
@@ -383,7 +380,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
.route=${this.route}
|
||||
.tabs=${this.tabs}
|
||||
.mainPage=${this.mainPage}
|
||||
.supervisor=${this.supervisor}
|
||||
.pane=${showPane && this.showFilters}
|
||||
@sorting-changed=${this._sortingChanged}
|
||||
>
|
||||
@@ -489,7 +485,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
: ""}
|
||||
<ha-data-table
|
||||
.hass=${this.hass}
|
||||
.localize=${localize}
|
||||
.narrow=${this.narrow}
|
||||
.columns=${this.columns}
|
||||
.data=${this.data}
|
||||
|
||||
@@ -5,7 +5,8 @@ import { classMap } from "lit/directives/class-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { canShowPage } from "../common/config/can_show_page";
|
||||
import { restoreScroll } from "../common/decorators/restore-scroll";
|
||||
import { goBack } from "../common/navigate";
|
||||
import { isNavigationClick } from "../common/dom/is-navigation-click";
|
||||
import { goBack, navigate } from "../common/navigate";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import "../components/ha-icon-button-arrow-prev";
|
||||
import "../components/ha-menu-button";
|
||||
@@ -14,6 +15,11 @@ import "../components/ha-tab";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import type { HomeAssistant, Route } from "../types";
|
||||
|
||||
const normalizePathname = (pathname: string): string =>
|
||||
pathname.endsWith("/") && pathname.length > 1
|
||||
? pathname.slice(0, -1)
|
||||
: pathname;
|
||||
|
||||
export interface PageNavigation {
|
||||
path: string;
|
||||
translationKey?: string;
|
||||
@@ -88,9 +94,8 @@ class HassTabsSubpage extends LitElement {
|
||||
|
||||
return shownTabs.map(
|
||||
(page) => html`
|
||||
<a href=${page.path}>
|
||||
<a href=${page.path} @click=${this._tabClicked}>
|
||||
<ha-tab
|
||||
.hass=${this.hass}
|
||||
.active=${page.path === activeTab?.path}
|
||||
.narrow=${this.narrow}
|
||||
.name=${page.translationKey
|
||||
@@ -112,8 +117,9 @@ class HassTabsSubpage extends LitElement {
|
||||
|
||||
public willUpdate(changedProperties: PropertyValues) {
|
||||
if (changedProperties.has("route")) {
|
||||
const currentPath = `${this.route.prefix}${this.route.path}`;
|
||||
this._activeTab = this.tabs.find((tab) =>
|
||||
`${this.route.prefix}${this.route.path}`.includes(tab.path)
|
||||
this._isActiveTabPath(tab.path, currentPath)
|
||||
);
|
||||
}
|
||||
super.willUpdate(changedProperties);
|
||||
@@ -209,6 +215,36 @@ class HassTabsSubpage extends LitElement {
|
||||
goBack();
|
||||
}
|
||||
|
||||
private _isActiveTabPath(tabPath: string, currentPath: string): boolean {
|
||||
try {
|
||||
const tabUrl = new URL(tabPath, window.location.origin);
|
||||
const currentUrl = new URL(currentPath, window.location.origin);
|
||||
|
||||
const tabPathname = normalizePathname(tabUrl.pathname);
|
||||
const currentPathname = normalizePathname(currentUrl.pathname);
|
||||
|
||||
if (
|
||||
currentPathname === tabPathname ||
|
||||
currentPathname.startsWith(`${tabPathname}/`)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (_err) {
|
||||
return currentPath === tabPath || currentPath.startsWith(`${tabPath}/`);
|
||||
}
|
||||
}
|
||||
|
||||
private async _tabClicked(ev: MouseEvent): Promise<void> {
|
||||
const href = isNavigationClick(ev);
|
||||
if (!href) {
|
||||
return;
|
||||
}
|
||||
|
||||
await navigate(href, { replace: true });
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
|
||||
@@ -35,6 +35,7 @@ const COMPONENTS = {
|
||||
security: () => import("../panels/security/ha-panel-security"),
|
||||
climate: () => import("../panels/climate/ha-panel-climate"),
|
||||
home: () => import("../panels/home/ha-panel-home"),
|
||||
notfound: () => import("../panels/notfound/ha-panel-notfound"),
|
||||
};
|
||||
|
||||
@customElement("partial-panel-resolver")
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { mdiMenu } from "@mdi/js";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { navigate } from "../../common/navigate";
|
||||
import { computeRouteTail } from "../../common/url/route";
|
||||
import { nextRender } from "../../common/util/render-status";
|
||||
import "../../components/ha-icon-button";
|
||||
import type { HassioAddonDetails } from "../../data/hassio/addon";
|
||||
@@ -24,7 +25,6 @@ import {
|
||||
showConfirmationDialog,
|
||||
} from "../../dialogs/generic/show-dialog-box";
|
||||
import "../../layouts/hass-loading-screen";
|
||||
import { computeRouteTail } from "../../common/url/route";
|
||||
import type { HomeAssistant, PanelInfo, Route } from "../../types";
|
||||
|
||||
interface AppPanelConfig {
|
||||
@@ -43,7 +43,7 @@ class HaPanelApp extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public panel!: PanelInfo<AppPanelConfig>;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
|
||||
@state() private _addon?: HassioAddonDetails;
|
||||
|
||||
@@ -119,7 +119,7 @@ class HaPanelApp extends LitElement {
|
||||
${!this._kioskMode &&
|
||||
(this.narrow || this.hass.dockedSidebar === "always_hidden")
|
||||
? html`
|
||||
<div class="header ${classMap({ narrow: this.narrow })}">
|
||||
<div class="header">
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize("ui.sidebar.sidebar_toggle")}
|
||||
.path=${mdiMenu}
|
||||
@@ -130,7 +130,10 @@ class HaPanelApp extends LitElement {
|
||||
`
|
||||
: nothing}
|
||||
<iframe
|
||||
class=${classMap({ loaded: this._iframeLoaded })}
|
||||
class=${classMap({
|
||||
loaded: this._iframeLoaded,
|
||||
"kiosk-mode": this._kioskMode,
|
||||
})}
|
||||
title=${this._addon.name}
|
||||
src=${this._addon.ingress_url!}
|
||||
@load=${this._checkLoaded}
|
||||
@@ -451,6 +454,16 @@ class HaPanelApp extends LitElement {
|
||||
height: calc(100% - 40px);
|
||||
}
|
||||
|
||||
:host([narrow]) iframe {
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
height: calc(100% - var(--safe-area-inset-top, 0px));
|
||||
}
|
||||
|
||||
:host([narrow]) .header + iframe {
|
||||
padding-top: 0;
|
||||
height: calc(100% - 40px - var(--safe-area-inset-top, 0px));
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -466,6 +479,11 @@ class HaPanelApp extends LitElement {
|
||||
--mdc-icon-size: 20px;
|
||||
}
|
||||
|
||||
:host([narrow]) .header {
|
||||
height: calc(40px + var(--safe-area-inset-top, 0px));
|
||||
padding-top: var(--safe-area-inset-top, 0);
|
||||
}
|
||||
|
||||
.main-title {
|
||||
margin-inline-start: var(--ha-space-6);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
|
||||
@@ -58,7 +58,8 @@ class PanelClimate extends LitElement {
|
||||
oldHass.entities !== this.hass.entities ||
|
||||
oldHass.devices !== this.hass.devices ||
|
||||
oldHass.areas !== this.hass.areas ||
|
||||
oldHass.floors !== this.hass.floors
|
||||
oldHass.floors !== this.hass.floors ||
|
||||
oldHass.panels !== this.hass.panels
|
||||
) {
|
||||
if (this.hass.config.state === "RUNNING") {
|
||||
this._debounceRegistriesChanged();
|
||||
|
||||
@@ -103,10 +103,12 @@ const processAreasForClimate = (
|
||||
heading_style: "subtitle",
|
||||
type: "heading",
|
||||
heading: area.name,
|
||||
tap_action: {
|
||||
action: "navigate",
|
||||
navigation_path: `/home/areas-${area.area_id}`,
|
||||
},
|
||||
tap_action: hass.panels.home
|
||||
? {
|
||||
action: "navigate",
|
||||
navigation_path: `/home/areas-${area.area_id}`,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
cards.push(...areaCards);
|
||||
}
|
||||
|
||||
@@ -106,12 +106,11 @@ export class HaConfigApplicationCredentials extends LitElement {
|
||||
filterable: true,
|
||||
},
|
||||
actions: {
|
||||
lastFixed: true,
|
||||
title: "",
|
||||
label: localize("ui.panel.config.generic.headers.actions"),
|
||||
type: "overflow-menu",
|
||||
showNarrow: true,
|
||||
hideable: false,
|
||||
moveable: false,
|
||||
template: (credential) => html`
|
||||
<ha-icon-overflow-menu
|
||||
.hass=${this.hass}
|
||||
|
||||
@@ -345,12 +345,11 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
`,
|
||||
},
|
||||
actions: {
|
||||
lastFixed: true,
|
||||
title: "",
|
||||
label: this.hass.localize("ui.panel.config.generic.headers.actions"),
|
||||
type: "icon-button",
|
||||
showNarrow: true,
|
||||
moveable: false,
|
||||
hideable: false,
|
||||
template: (automation) => html`
|
||||
<ha-icon-button
|
||||
.automation=${automation}
|
||||
|
||||
@@ -27,7 +27,6 @@ export class HaNumericStateTrigger extends LitElement {
|
||||
private _schema = memoizeOne(
|
||||
(
|
||||
localize: LocalizeFunc,
|
||||
entityId: string | string[],
|
||||
inputAboveIsEntity?: boolean,
|
||||
inputBelowIsEntity?: boolean
|
||||
) =>
|
||||
@@ -39,9 +38,9 @@ export class HaNumericStateTrigger extends LitElement {
|
||||
},
|
||||
{
|
||||
name: "attribute",
|
||||
context: { filter_entity: "entity_id" },
|
||||
selector: {
|
||||
attribute: {
|
||||
entity_id: entityId ? entityId[0] : undefined,
|
||||
hide_attributes: [
|
||||
"access_token",
|
||||
"auto_update",
|
||||
@@ -275,7 +274,6 @@ export class HaNumericStateTrigger extends LitElement {
|
||||
public render() {
|
||||
const schema = this._schema(
|
||||
this.hass.localize,
|
||||
this.trigger.entity_id,
|
||||
this._inputAboveIsEntity,
|
||||
this._inputBelowIsEntity
|
||||
);
|
||||
|
||||
@@ -255,11 +255,10 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
lastFixed: true,
|
||||
title: "",
|
||||
label: localize("ui.panel.config.generic.headers.actions"),
|
||||
showNarrow: true,
|
||||
moveable: false,
|
||||
hideable: false,
|
||||
type: "overflow-menu",
|
||||
template: (backup) => html`
|
||||
<ha-icon-button
|
||||
|
||||
@@ -232,12 +232,11 @@ class HaBlueprintOverview extends LitElement {
|
||||
hidden: true,
|
||||
},
|
||||
actions: {
|
||||
lastFixed: true,
|
||||
title: "",
|
||||
label: this.hass.localize("ui.panel.config.generic.headers.actions"),
|
||||
type: "overflow-menu",
|
||||
showNarrow: true,
|
||||
moveable: false,
|
||||
hideable: false,
|
||||
template: (blueprint) =>
|
||||
blueprint.error
|
||||
? html`<ha-svg-icon
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
|
||||
import type { EnergySettingsDeviceWaterDialogParams } from "./show-dialogs-energy";
|
||||
|
||||
const volumeUnitClasses = ["volume"];
|
||||
const flowRateUnitClasses = ["volume_flow_rate"];
|
||||
|
||||
@customElement("dialog-energy-device-settings-water")
|
||||
export class DialogEnergyDeviceSettingsWater
|
||||
@@ -36,10 +37,14 @@ export class DialogEnergyDeviceSettingsWater
|
||||
|
||||
@state() private _volume_units?: string[];
|
||||
|
||||
@state() private _flow_rate_units?: string[];
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
private _excludeList?: string[];
|
||||
|
||||
private _excludeListFlowRate?: string[];
|
||||
|
||||
private _possibleParents: DeviceConsumptionEnergyPreference[] = [];
|
||||
|
||||
public async showDialog(
|
||||
@@ -51,9 +56,15 @@ export class DialogEnergyDeviceSettingsWater
|
||||
this._volume_units = (
|
||||
await getSensorDeviceClassConvertibleUnits(this.hass, "water")
|
||||
).units;
|
||||
this._flow_rate_units = (
|
||||
await getSensorDeviceClassConvertibleUnits(this.hass, "volume_flow_rate")
|
||||
).units;
|
||||
this._excludeList = this._params.device_consumptions
|
||||
.map((entry) => entry.stat_consumption)
|
||||
.filter((id) => id !== this._device?.stat_consumption);
|
||||
this._excludeListFlowRate = this._params.device_consumptions
|
||||
.map((entry) => entry.stat_rate)
|
||||
.filter((id) => id && id !== this._device?.stat_rate) as string[];
|
||||
|
||||
this._open = true;
|
||||
}
|
||||
@@ -92,6 +103,7 @@ export class DialogEnergyDeviceSettingsWater
|
||||
this._device = undefined;
|
||||
this._error = undefined;
|
||||
this._excludeList = undefined;
|
||||
this._excludeListFlowRate = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
@@ -134,12 +146,6 @@ export class DialogEnergyDeviceSettingsWater
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
${this._error ? html`<p class="error">${this._error}</p>` : ""}
|
||||
<div>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption_water.dialog.selected_stat_intro",
|
||||
{ unit: pickableUnit }
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ha-statistic-picker
|
||||
.hass=${this.hass}
|
||||
@@ -151,9 +157,28 @@ export class DialogEnergyDeviceSettingsWater
|
||||
)}
|
||||
.excludeStatistics=${this._excludeList}
|
||||
@value-changed=${this._statisticChanged}
|
||||
.helper=${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption_water.dialog.selected_stat_intro",
|
||||
{ unit: pickableUnit }
|
||||
)}
|
||||
autofocus
|
||||
></ha-statistic-picker>
|
||||
|
||||
<ha-statistic-picker
|
||||
.hass=${this.hass}
|
||||
.includeUnitClass=${flowRateUnitClasses}
|
||||
.value=${this._device?.stat_rate}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption_water.dialog.device_consumption_water_flow_rate"
|
||||
)}
|
||||
.excludeStatistics=${this._excludeListFlowRate}
|
||||
@value-changed=${this._flowRateStatisticChanged}
|
||||
.helper=${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption_water.dialog.selected_stat_intro",
|
||||
{ unit: this._flow_rate_units?.join(", ") || "" }
|
||||
)}
|
||||
></ha-statistic-picker>
|
||||
|
||||
<ha-textfield
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption_water.dialog.display_name"
|
||||
@@ -216,6 +241,20 @@ export class DialogEnergyDeviceSettingsWater
|
||||
this._computePossibleParents();
|
||||
}
|
||||
|
||||
private _flowRateStatisticChanged(ev: ValueChangedEvent<string>) {
|
||||
if (!this._device) {
|
||||
return;
|
||||
}
|
||||
const newDevice = {
|
||||
...this._device,
|
||||
stat_rate: ev.detail.value,
|
||||
} as DeviceConsumptionEnergyPreference;
|
||||
if (!newDevice.stat_rate) {
|
||||
delete newDevice.stat_rate;
|
||||
}
|
||||
this._device = newDevice;
|
||||
}
|
||||
|
||||
private _nameChanged(ev) {
|
||||
const newDevice = {
|
||||
...this._device!,
|
||||
@@ -252,7 +291,9 @@ export class DialogEnergyDeviceSettingsWater
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-statistic-picker {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
ha-select {
|
||||
display: block;
|
||||
|
||||
@@ -30,6 +30,7 @@ import type { EnergySettingsGasDialogParams } from "./show-dialogs-energy";
|
||||
|
||||
const gasDeviceClasses = ["gas", "energy"];
|
||||
const gasUnitClasses = ["volume", "energy"];
|
||||
const flowRateUnitClasses = ["volume_flow_rate"];
|
||||
|
||||
@customElement("dialog-energy-gas-settings")
|
||||
export class DialogEnergyGasSettings
|
||||
@@ -52,10 +53,14 @@ export class DialogEnergyGasSettings
|
||||
|
||||
@state() private _gas_units?: string[];
|
||||
|
||||
@state() private _flow_rate_units?: string[];
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
private _excludeList?: string[];
|
||||
|
||||
private _excludeListFlowRate?: string[];
|
||||
|
||||
public async showDialog(
|
||||
params: EnergySettingsGasDialogParams
|
||||
): Promise<void> {
|
||||
@@ -81,9 +86,15 @@ export class DialogEnergyGasSettings
|
||||
this._gas_units = (
|
||||
await getSensorDeviceClassConvertibleUnits(this.hass, "gas")
|
||||
).units;
|
||||
this._flow_rate_units = (
|
||||
await getSensorDeviceClassConvertibleUnits(this.hass, "volume_flow_rate")
|
||||
).units;
|
||||
this._excludeList = this._params.gas_sources
|
||||
.map((entry) => entry.stat_energy_from)
|
||||
.filter((id) => id !== this._source?.stat_energy_from);
|
||||
this._excludeListFlowRate = this._params.gas_sources
|
||||
.map((entry) => entry.stat_rate)
|
||||
.filter((id) => id && id !== this._source?.stat_rate) as string[];
|
||||
|
||||
this._open = true;
|
||||
}
|
||||
@@ -99,6 +110,7 @@ export class DialogEnergyGasSettings
|
||||
this._pickedDisplayUnit = undefined;
|
||||
this._error = undefined;
|
||||
this._excludeList = undefined;
|
||||
this._excludeListFlowRate = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
@@ -146,12 +158,6 @@ export class DialogEnergyGasSettings
|
||||
<p>
|
||||
${this.hass.localize("ui.panel.config.energy.gas.dialog.paragraph")}
|
||||
</p>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.energy.gas.dialog.entity_para",
|
||||
{ unit: pickableUnit }
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
${this.hass.localize("ui.panel.config.energy.gas.dialog.note_para")}
|
||||
</p>
|
||||
@@ -169,9 +175,28 @@ export class DialogEnergyGasSettings
|
||||
)}
|
||||
.excludeStatistics=${this._excludeList}
|
||||
@value-changed=${this._statisticChanged}
|
||||
.helper=${this.hass.localize(
|
||||
"ui.panel.config.energy.gas.dialog.entity_para",
|
||||
{ unit: pickableUnit }
|
||||
)}
|
||||
autofocus
|
||||
></ha-statistic-picker>
|
||||
|
||||
<ha-statistic-picker
|
||||
.hass=${this.hass}
|
||||
.includeUnitClass=${flowRateUnitClasses}
|
||||
.value=${this._source.stat_rate}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.gas.dialog.gas_flow_rate"
|
||||
)}
|
||||
.excludeStatistics=${this._excludeListFlowRate}
|
||||
@value-changed=${this._flowRateStatisticChanged}
|
||||
.helper=${this.hass.localize(
|
||||
"ui.panel.config.energy.gas.dialog.flow_rate_para",
|
||||
{ unit: this._flow_rate_units?.join(", ") || "" }
|
||||
)}
|
||||
></ha-statistic-picker>
|
||||
|
||||
<p>
|
||||
${this.hass.localize("ui.panel.config.energy.gas.dialog.cost_para")}
|
||||
</p>
|
||||
@@ -341,6 +366,13 @@ export class DialogEnergyGasSettings
|
||||
};
|
||||
}
|
||||
|
||||
private _flowRateStatisticChanged(ev: ValueChangedEvent<string>) {
|
||||
this._source = {
|
||||
...this._source!,
|
||||
stat_rate: ev.detail.value || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private async _statisticChanged(ev: ValueChangedEvent<string>) {
|
||||
if (ev.detail.value) {
|
||||
const metadata = await getStatisticMetadata(this.hass, [ev.detail.value]);
|
||||
@@ -380,6 +412,10 @@ export class DialogEnergyGasSettings
|
||||
haStyle,
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-statistic-picker {
|
||||
display: block;
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
ha-formfield {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ import { haStyle, haStyleDialog } from "../../../../resources/styles";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
|
||||
import type { EnergySettingsWaterDialogParams } from "./show-dialogs-energy";
|
||||
|
||||
const flowRateUnitClasses = ["volume_flow_rate"];
|
||||
|
||||
@customElement("dialog-energy-water-settings")
|
||||
export class DialogEnergyWaterSettings
|
||||
extends LitElement
|
||||
@@ -41,10 +43,14 @@ export class DialogEnergyWaterSettings
|
||||
|
||||
@state() private _water_units?: string[];
|
||||
|
||||
@state() private _flow_rate_units?: string[];
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
private _excludeList?: string[];
|
||||
|
||||
private _excludeListFlowRate?: string[];
|
||||
|
||||
public async showDialog(
|
||||
params: EnergySettingsWaterDialogParams
|
||||
): Promise<void> {
|
||||
@@ -62,9 +68,15 @@ export class DialogEnergyWaterSettings
|
||||
this._water_units = (
|
||||
await getSensorDeviceClassConvertibleUnits(this.hass, "water")
|
||||
).units;
|
||||
this._flow_rate_units = (
|
||||
await getSensorDeviceClassConvertibleUnits(this.hass, "volume_flow_rate")
|
||||
).units;
|
||||
this._excludeList = this._params.water_sources
|
||||
.map((entry) => entry.stat_energy_from)
|
||||
.filter((id) => id !== this._source?.stat_energy_from);
|
||||
this._excludeListFlowRate = this._params.water_sources
|
||||
.map((entry) => entry.stat_rate)
|
||||
.filter((id) => id && id !== this._source?.stat_rate) as string[];
|
||||
|
||||
this._open = true;
|
||||
}
|
||||
@@ -79,6 +91,7 @@ export class DialogEnergyWaterSettings
|
||||
this._source = undefined;
|
||||
this._error = undefined;
|
||||
this._excludeList = undefined;
|
||||
this._excludeListFlowRate = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
@@ -108,19 +121,6 @@ export class DialogEnergyWaterSettings
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
${this._error ? html`<p class="error">${this._error}</p>` : ""}
|
||||
<div>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.energy.water.dialog.paragraph"
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.energy.water.dialog.entity_para",
|
||||
{ unit: pickableUnit }
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ha-statistic-picker
|
||||
.hass=${this.hass}
|
||||
@@ -133,9 +133,28 @@ export class DialogEnergyWaterSettings
|
||||
)}
|
||||
.excludeStatistics=${this._excludeList}
|
||||
@value-changed=${this._statisticChanged}
|
||||
.helper=${this.hass.localize(
|
||||
"ui.panel.config.energy.water.dialog.entity_para",
|
||||
{ unit: pickableUnit }
|
||||
)}
|
||||
autofocus
|
||||
></ha-statistic-picker>
|
||||
|
||||
<ha-statistic-picker
|
||||
.hass=${this.hass}
|
||||
.includeUnitClass=${flowRateUnitClasses}
|
||||
.value=${this._source.stat_rate}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.water.dialog.water_flow_rate"
|
||||
)}
|
||||
.excludeStatistics=${this._excludeListFlowRate}
|
||||
@value-changed=${this._flowRateStatisticChanged}
|
||||
.helper=${this.hass.localize(
|
||||
"ui.panel.config.energy.water.dialog.flow_rate_para",
|
||||
{ unit: this._flow_rate_units?.join(", ") || "" }
|
||||
)}
|
||||
></ha-statistic-picker>
|
||||
|
||||
<p>
|
||||
${this.hass.localize("ui.panel.config.energy.water.dialog.cost_para")}
|
||||
</p>
|
||||
@@ -287,6 +306,13 @@ export class DialogEnergyWaterSettings
|
||||
};
|
||||
}
|
||||
|
||||
private _flowRateStatisticChanged(ev: ValueChangedEvent<string>) {
|
||||
this._source = {
|
||||
...this._source!,
|
||||
stat_rate: ev.detail.value || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private async _statisticChanged(ev: ValueChangedEvent<string>) {
|
||||
if (
|
||||
ev.detail.value &&
|
||||
@@ -320,6 +346,10 @@ export class DialogEnergyWaterSettings
|
||||
haStyle,
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-statistic-picker {
|
||||
display: block;
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
ha-formfield {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import "../../../layouts/hass-error-screen";
|
||||
import { mdiDownload } from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { mdiDownload, mdiFire, mdiLightningBolt, mdiWater } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { cache } from "lit/directives/cache";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import type {
|
||||
EnergyPreferencesValidation,
|
||||
EnergyInfo,
|
||||
@@ -17,7 +19,7 @@ import {
|
||||
import type { StatisticsMetaData } from "../../../data/recorder";
|
||||
import { getStatisticMetadata } from "../../../data/recorder";
|
||||
import "../../../layouts/hass-loading-screen";
|
||||
import "../../../layouts/hass-subpage";
|
||||
import "../../../layouts/hass-tabs-subpage";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant, Route } from "../../../types";
|
||||
import "../../../components/ha-alert";
|
||||
@@ -29,6 +31,7 @@ import "./components/ha-energy-battery-settings";
|
||||
import "./components/ha-energy-gas-settings";
|
||||
import "./components/ha-energy-water-settings";
|
||||
import { fileDownload } from "../../../util/file_download";
|
||||
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
|
||||
|
||||
const INITIAL_CONFIG: EnergyPreferences = {
|
||||
energy_sources: [],
|
||||
@@ -36,6 +39,27 @@ const INITIAL_CONFIG: EnergyPreferences = {
|
||||
device_consumption_water: [],
|
||||
};
|
||||
|
||||
const TABS: PageNavigation[] = [
|
||||
{
|
||||
path: "/config/energy/electricity",
|
||||
translationKey: "ui.panel.config.energy.tabs.electricity",
|
||||
iconPath: mdiLightningBolt,
|
||||
iconColor: "#F1C447",
|
||||
},
|
||||
{
|
||||
path: "/config/energy/gas",
|
||||
translationKey: "ui.panel.config.energy.tabs.gas",
|
||||
iconPath: mdiFire,
|
||||
iconColor: "#F1C447",
|
||||
},
|
||||
{
|
||||
path: "/config/energy/water",
|
||||
translationKey: "ui.panel.config.energy.tabs.water",
|
||||
iconPath: mdiWater,
|
||||
iconColor: "#F1C447",
|
||||
},
|
||||
];
|
||||
|
||||
@customElement("ha-config-energy")
|
||||
class HaConfigEnergy extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -60,6 +84,19 @@ class HaConfigEnergy extends LitElement {
|
||||
|
||||
@state() private _statsMetadata?: Record<string, StatisticsMetaData>;
|
||||
|
||||
private get _currTab(): string {
|
||||
return this.route.path.substring(1) || "electricity";
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues) {
|
||||
if (changedProperties.has("route")) {
|
||||
const tab = this.route.path.substring(1);
|
||||
if (!tab || !TABS.some((t) => t.path.endsWith(`/${tab}`))) {
|
||||
navigate(`${this.route.prefix}/electricity`, { replace: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
this._fetchConfig();
|
||||
}
|
||||
@@ -81,13 +118,14 @@ class HaConfigEnergy extends LitElement {
|
||||
}
|
||||
|
||||
return html`
|
||||
<hass-subpage
|
||||
<hass-tabs-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.backPath=${this._searchParms.has("historyBack")
|
||||
? undefined
|
||||
: "/config/lovelace/dashboards"}
|
||||
.header=${this.hass.localize("ui.panel.config.energy.caption")}
|
||||
.route=${this.route}
|
||||
.tabs=${TABS}
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="toolbar-icon"
|
||||
@@ -100,7 +138,15 @@ class HaConfigEnergy extends LitElement {
|
||||
<ha-alert>
|
||||
${this.hass.localize("ui.panel.config.energy.new_device_info")}
|
||||
</ha-alert>
|
||||
<div class="content">
|
||||
<div class="content">${cache(this._renderTabContent())}</div>
|
||||
</hass-tabs-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderTabContent(): TemplateResult | typeof nothing {
|
||||
switch (this._currTab) {
|
||||
case "electricity":
|
||||
return html`
|
||||
<ha-energy-grid-settings
|
||||
.hass=${this.hass}
|
||||
.preferences=${this._preferences!}
|
||||
@@ -123,20 +169,6 @@ class HaConfigEnergy extends LitElement {
|
||||
.validationResult=${this._validationResult}
|
||||
@value-changed=${this._prefsChanged}
|
||||
></ha-energy-battery-settings>
|
||||
<ha-energy-gas-settings
|
||||
.hass=${this.hass}
|
||||
.preferences=${this._preferences!}
|
||||
.statsMetadata=${this._statsMetadata}
|
||||
.validationResult=${this._validationResult}
|
||||
@value-changed=${this._prefsChanged}
|
||||
></ha-energy-gas-settings>
|
||||
<ha-energy-water-settings
|
||||
.hass=${this.hass}
|
||||
.preferences=${this._preferences!}
|
||||
.statsMetadata=${this._statsMetadata}
|
||||
.validationResult=${this._validationResult}
|
||||
@value-changed=${this._prefsChanged}
|
||||
></ha-energy-water-settings>
|
||||
<ha-energy-device-settings
|
||||
.hass=${this.hass}
|
||||
.preferences=${this._preferences!}
|
||||
@@ -144,6 +176,26 @@ class HaConfigEnergy extends LitElement {
|
||||
.validationResult=${this._validationResult}
|
||||
@value-changed=${this._prefsChanged}
|
||||
></ha-energy-device-settings>
|
||||
`;
|
||||
case "gas":
|
||||
return html`
|
||||
<ha-energy-gas-settings
|
||||
.hass=${this.hass}
|
||||
.preferences=${this._preferences!}
|
||||
.statsMetadata=${this._statsMetadata}
|
||||
.validationResult=${this._validationResult}
|
||||
@value-changed=${this._prefsChanged}
|
||||
></ha-energy-gas-settings>
|
||||
`;
|
||||
case "water":
|
||||
return html`
|
||||
<ha-energy-water-settings
|
||||
.hass=${this.hass}
|
||||
.preferences=${this._preferences!}
|
||||
.statsMetadata=${this._statsMetadata}
|
||||
.validationResult=${this._validationResult}
|
||||
@value-changed=${this._prefsChanged}
|
||||
></ha-energy-water-settings>
|
||||
<ha-energy-device-settings-water
|
||||
.hass=${this.hass}
|
||||
.preferences=${this._preferences!}
|
||||
@@ -151,9 +203,10 @@ class HaConfigEnergy extends LitElement {
|
||||
.validationResult=${this._validationResult}
|
||||
@value-changed=${this._prefsChanged}
|
||||
></ha-energy-device-settings-water>
|
||||
</div>
|
||||
</hass-subpage>
|
||||
`;
|
||||
`;
|
||||
default:
|
||||
return nothing;
|
||||
}
|
||||
}
|
||||
|
||||
private async _fetchConfig() {
|
||||
|
||||
@@ -380,11 +380,10 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
localize("ui.panel.config.entities.picker.status.unmanageable")
|
||||
),
|
||||
actions: {
|
||||
lastFixed: true,
|
||||
title: "",
|
||||
label: this.hass.localize("ui.panel.config.generic.headers.actions"),
|
||||
type: "overflow-menu",
|
||||
hideable: false,
|
||||
moveable: false,
|
||||
showNarrow: true,
|
||||
template: (helper) => html`
|
||||
<ha-icon-overflow-menu
|
||||
|
||||
@@ -578,7 +578,6 @@ class AddIntegrationDialog extends LitElement {
|
||||
}
|
||||
return html`
|
||||
<ha-integration-list-item
|
||||
brand
|
||||
.hass=${this.hass}
|
||||
.integration=${integration}
|
||||
tabindex="0"
|
||||
|
||||
@@ -30,8 +30,6 @@ export class HaIntegrationListItem extends ListItemBase {
|
||||
// eslint-disable-next-line lit/attribute-names
|
||||
@property({ type: Boolean }) hasMeta = true;
|
||||
|
||||
@property({ type: Boolean }) brand = false;
|
||||
|
||||
// @ts-expect-error
|
||||
protected override renderSingleLine() {
|
||||
if (!this.integration) {
|
||||
@@ -68,7 +66,6 @@ export class HaIntegrationListItem extends ListItemBase {
|
||||
domain: this.integration.domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
brand: this.brand,
|
||||
})}
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
|
||||
@@ -158,6 +158,7 @@ export class BluetoothConfigDashboard extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.header=${this.hass.localize("ui.panel.config.bluetooth.title")}
|
||||
back-path="/config"
|
||||
>
|
||||
<div class="container">
|
||||
<ha-card class="content network-status">
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
import { mdiAlertCircle, mdiCheckCircle, mdiPlus } from "@mdi/js";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import {
|
||||
mdiAlertCircleOutline,
|
||||
mdiCheck,
|
||||
mdiDevices,
|
||||
mdiPlus,
|
||||
mdiShape,
|
||||
mdiTune,
|
||||
} from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../../../../common/config/is_component_loaded";
|
||||
import "../../../../../components/ha-alert";
|
||||
import "../../../../../components/ha-button";
|
||||
import "../../../../../components/ha-card";
|
||||
import "../../../../../components/ha-expansion-panel";
|
||||
import "../../../../../components/ha-fab";
|
||||
import "../../../../../components/ha-icon-next";
|
||||
import "../../../../../components/ha-md-list";
|
||||
import "../../../../../components/ha-md-list-item";
|
||||
import "../../../../../components/ha-svg-icon";
|
||||
import type { ConfigEntry } from "../../../../../data/config_entries";
|
||||
import { getConfigEntries } from "../../../../../data/config_entries";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import {
|
||||
acceptSharedMatterDevice,
|
||||
canCommissionMatterExternal,
|
||||
commissionMatterDevice,
|
||||
matterSetThread,
|
||||
matterSetWifi,
|
||||
redirectOnNewMatterDevice,
|
||||
startExternalCommissioning,
|
||||
} from "../../../../../data/matter";
|
||||
import { showPromptDialog } from "../../../../../dialogs/generic/show-dialog-box";
|
||||
import "../../../../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
|
||||
const THREAD_ICON =
|
||||
"m 17.126982,8.0730792 c 0,-0.7297242 -0.593746,-1.32357 -1.323637,-1.32357 -0.729454,0 -1.323199,0.5938458 -1.323199,1.32357 v 1.3234242 l 1.323199,1.458e-4 c 0.729891,0 1.323637,-0.5937006 1.323637,-1.32357 z M 11.999709,0 C 5.3829818,0 0,5.3838955 0,12.001455 0,18.574352 5.3105455,23.927406 11.865164,24 V 12.012075 l -3.9275642,-2.91e-4 c -1.1669814,0 -2.1169453,0.949979 -2.1169453,2.118323 0,1.16718 0.9499639,2.116868 2.1169453,2.116868 v 2.615717 c -2.6093089,0 -4.732218,-2.12327 -4.732218,-4.732585 0,-2.61048 2.1229091,-4.7343308 4.732218,-4.7343308 l 3.9275642,5.82e-4 v -1.323279 c 0,-2.172296 1.766691,-3.9395777 3.938181,-3.9395777 2.171928,0 3.9392,1.7672817 3.9392,3.9395777 0,2.1721498 -1.767272,3.9395768 -3.9392,3.9395768 l -1.323199,-1.45e-4 V 23.744102 C 19.911127,22.597726 24,17.768833 24,12.001455 24,5.3838955 18.616727,0 11.999709,0 Z";
|
||||
|
||||
@customElement("matter-config-dashboard")
|
||||
export class MatterConfigDashboard extends LitElement {
|
||||
@@ -35,15 +35,6 @@ export class MatterConfigDashboard extends LitElement {
|
||||
|
||||
@state() private _configEntry?: ConfigEntry;
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
private _unsub?: UnsubscribeFunc;
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._stopRedirect();
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues) {
|
||||
super.firstUpdated(changedProperties);
|
||||
if (this.hass) {
|
||||
@@ -51,10 +42,26 @@ export class MatterConfigDashboard extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _matterDeviceCount = memoizeOne(
|
||||
(devices: HomeAssistant["devices"]): number =>
|
||||
Object.values(devices).filter((device) =>
|
||||
device.identifiers.some((identifier) => identifier[0] === "matter")
|
||||
private _matterDeviceIds = memoizeOne(
|
||||
(
|
||||
devices: HomeAssistant["devices"],
|
||||
configEntryId?: string
|
||||
): Set<string> => {
|
||||
if (!configEntryId) {
|
||||
return new Set();
|
||||
}
|
||||
return new Set(
|
||||
Object.values(devices)
|
||||
.filter((device) => device.config_entries.includes(configEntryId))
|
||||
.map((device) => device.id)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
private _entityCount = memoizeOne(
|
||||
(entities: HomeAssistant["entities"], deviceIds: Set<string>): number =>
|
||||
Object.values(entities).filter(
|
||||
(entity) => entity.device_id && deviceIds.has(entity.device_id)
|
||||
).length
|
||||
);
|
||||
|
||||
@@ -63,122 +70,24 @@ export class MatterConfigDashboard extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
const isOnline = this._configEntry.state === "loaded";
|
||||
const deviceIds = this._matterDeviceIds(
|
||||
this.hass.devices,
|
||||
this._configEntry.entry_id
|
||||
);
|
||||
const entityCount = this._entityCount(this.hass.entities, deviceIds);
|
||||
|
||||
return html`
|
||||
<hass-subpage
|
||||
.narrow=${this.narrow}
|
||||
.hass=${this.hass}
|
||||
header="Matter"
|
||||
back-path="/config"
|
||||
has-fab
|
||||
>
|
||||
${isComponentLoaded(this.hass, "thread")
|
||||
? html`
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
href="/config/thread"
|
||||
slot="toolbar-icon"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.thread_panel"
|
||||
)}</ha-button
|
||||
>
|
||||
`
|
||||
: nothing}
|
||||
<div class="container">
|
||||
<ha-card class="network-status">
|
||||
<div class="card-content">
|
||||
<div class="heading">
|
||||
<div class="icon">
|
||||
<ha-svg-icon
|
||||
.path=${isOnline ? mdiCheckCircle : mdiAlertCircle}
|
||||
class=${isOnline ? "online" : "offline"}
|
||||
></ha-svg-icon>
|
||||
</div>
|
||||
<div class="details">
|
||||
Matter
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.status_title"
|
||||
)}:
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.matter.panel.status_${isOnline ? "online" : "offline"}`
|
||||
)}<br />
|
||||
<small>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.devices",
|
||||
{ count: this._matterDeviceCount(this.hass.devices) }
|
||||
)}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<ha-button
|
||||
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
|
||||
appearance="plain"
|
||||
size="small"
|
||||
>
|
||||
${this.hass.localize("ui.panel.config.devices.caption")}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
href=${`/config/entities/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
|
||||
>
|
||||
${this.hass.localize("ui.panel.config.entities.caption")}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
<ha-expansion-panel
|
||||
outlined
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.developer_tools_title"
|
||||
)}
|
||||
.secondary=${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.developer_tools_description"
|
||||
)}
|
||||
>
|
||||
${this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: nothing}
|
||||
<div class="dev-tools-content">
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.developer_tools_info"
|
||||
)}
|
||||
</p>
|
||||
<div class="dev-tools-actions">
|
||||
${canCommissionMatterExternal(this.hass)
|
||||
? html`<ha-button
|
||||
appearance="plain"
|
||||
@click=${this._startMobileCommissioning}
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.mobile_app_commisioning"
|
||||
)}</ha-button
|
||||
>`
|
||||
: nothing}
|
||||
<ha-button appearance="plain" @click=${this._commission}
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.commission_device"
|
||||
)}</ha-button
|
||||
>
|
||||
<ha-button appearance="plain" @click=${this._acceptSharedDevice}
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.add_shared_device"
|
||||
)}</ha-button
|
||||
>
|
||||
<ha-button appearance="plain" @click=${this._setWifi}
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.set_wifi_credentials"
|
||||
)}</ha-button
|
||||
>
|
||||
<ha-button appearance="plain" @click=${this._setThread}
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.set_thread_credentials"
|
||||
)}</ha-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
${this._renderNetworkStatus(isOnline, deviceIds.size)}
|
||||
${this._renderMyNetworkCard(deviceIds.size, entityCount)}
|
||||
${this._renderNavigationCard()}
|
||||
</div>
|
||||
|
||||
<a href="/config/matter/add" slot="fab">
|
||||
@@ -195,138 +104,111 @@ export class MatterConfigDashboard extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _redirectOnNewMatterDevice() {
|
||||
if (this._unsub) {
|
||||
return;
|
||||
}
|
||||
this._unsub = redirectOnNewMatterDevice(this.hass, () => {
|
||||
this._unsub = undefined;
|
||||
});
|
||||
private _renderNetworkStatus(isOnline: boolean, deviceCount: number) {
|
||||
return html`
|
||||
<ha-card class="content network-status">
|
||||
<div class="card-content">
|
||||
<div class="heading">
|
||||
<div class="icon ${isOnline ? "success" : "error"}">
|
||||
<ha-svg-icon
|
||||
.path=${isOnline ? mdiCheck : mdiAlertCircleOutline}
|
||||
></ha-svg-icon>
|
||||
</div>
|
||||
<div class="details">
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.matter.panel.status_${isOnline ? "online" : "offline"}`
|
||||
)}<br />
|
||||
<small>
|
||||
${this.hass.localize("ui.panel.config.matter.panel.devices", {
|
||||
count: deviceCount,
|
||||
})}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _stopRedirect() {
|
||||
this._unsub?.();
|
||||
this._unsub = undefined;
|
||||
private _renderMyNetworkCard(deviceCount: number, entityCount: number) {
|
||||
return html`
|
||||
<ha-card class="nav-card">
|
||||
<div class="card-header">
|
||||
${this.hass.localize("ui.panel.config.matter.panel.my_network_title")}
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<ha-md-list>
|
||||
<ha-md-list-item
|
||||
type="link"
|
||||
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.device_count",
|
||||
{ count: deviceCount }
|
||||
)}
|
||||
</div>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item
|
||||
type="link"
|
||||
href=${`/config/entities/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiShape}></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.entity_count",
|
||||
{ count: entityCount }
|
||||
)}
|
||||
</div>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-md-list-item>
|
||||
</ha-md-list>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _startMobileCommissioning() {
|
||||
this._redirectOnNewMatterDevice();
|
||||
startExternalCommissioning(this.hass);
|
||||
}
|
||||
|
||||
private async _setWifi(): Promise<void> {
|
||||
this._error = undefined;
|
||||
const networkName = await showPromptDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.matter.panel.prompts.network_name.title"
|
||||
),
|
||||
inputLabel: this.hass.localize(
|
||||
"ui.panel.config.matter.panel.prompts.network_name.input_label"
|
||||
),
|
||||
inputType: "string",
|
||||
confirmText: this.hass.localize(
|
||||
"ui.panel.config.matter.panel.prompts.network_name.confirm"
|
||||
),
|
||||
});
|
||||
if (!networkName) {
|
||||
return;
|
||||
}
|
||||
const psk = await showPromptDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.matter.panel.prompts.passcode.title"
|
||||
),
|
||||
inputLabel: this.hass.localize(
|
||||
"ui.panel.config.matter.panel.prompts.passcode.input_label"
|
||||
),
|
||||
inputType: "password",
|
||||
confirmText: this.hass.localize(
|
||||
"ui.panel.config.matter.panel.prompts.passcode.confirm"
|
||||
),
|
||||
});
|
||||
if (!psk) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await matterSetWifi(this.hass, networkName, psk);
|
||||
} catch (err: any) {
|
||||
this._error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
private async _commission(): Promise<void> {
|
||||
const code = await showPromptDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.matter.panel.prompts.commission_device.title"
|
||||
),
|
||||
inputLabel: this.hass.localize(
|
||||
"ui.panel.config.matter.panel.prompts.commission_device.input_label"
|
||||
),
|
||||
inputType: "string",
|
||||
confirmText: this.hass.localize(
|
||||
"ui.panel.config.matter.panel.prompts.commission_device.confirm"
|
||||
),
|
||||
});
|
||||
if (!code) {
|
||||
return;
|
||||
}
|
||||
this._error = undefined;
|
||||
this._redirectOnNewMatterDevice();
|
||||
try {
|
||||
await commissionMatterDevice(this.hass, code);
|
||||
} catch (err: any) {
|
||||
this._error = err.message;
|
||||
this._stopRedirect();
|
||||
}
|
||||
}
|
||||
|
||||
private async _acceptSharedDevice(): Promise<void> {
|
||||
const code = await showPromptDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.matter.panel.prompts.add_shared_device.title"
|
||||
),
|
||||
inputLabel: this.hass.localize(
|
||||
"ui.panel.config.matter.panel.prompts.add_shared_device.input_label"
|
||||
),
|
||||
inputType: "number",
|
||||
confirmText: this.hass.localize(
|
||||
"ui.panel.config.matter.panel.prompts.add_shared_device.confirm"
|
||||
),
|
||||
});
|
||||
if (!code) {
|
||||
return;
|
||||
}
|
||||
this._error = undefined;
|
||||
this._redirectOnNewMatterDevice();
|
||||
try {
|
||||
await acceptSharedMatterDevice(this.hass, Number(code));
|
||||
} catch (err: any) {
|
||||
this._error = err.message;
|
||||
this._stopRedirect();
|
||||
}
|
||||
}
|
||||
|
||||
private async _setThread(): Promise<void> {
|
||||
const code = await showPromptDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.matter.panel.prompts.set_thread.title"
|
||||
),
|
||||
inputLabel: this.hass.localize(
|
||||
"ui.panel.config.matter.panel.prompts.set_thread.input_label"
|
||||
),
|
||||
inputType: "string",
|
||||
confirmText: this.hass.localize(
|
||||
"ui.panel.config.matter.panel.prompts.set_thread.confirm"
|
||||
),
|
||||
});
|
||||
if (!code) {
|
||||
return;
|
||||
}
|
||||
this._error = undefined;
|
||||
try {
|
||||
await matterSetThread(this.hass, code);
|
||||
} catch (err: any) {
|
||||
this._error = err.message;
|
||||
}
|
||||
private _renderNavigationCard() {
|
||||
return html`
|
||||
<ha-card class="nav-card">
|
||||
<div class="card-content">
|
||||
<ha-md-list>
|
||||
<ha-md-list-item type="link" href="/config/matter/options">
|
||||
<ha-svg-icon slot="start" .path=${mdiTune}></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.options_title"
|
||||
)}
|
||||
</div>
|
||||
<div slot="supporting-text">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.options_description"
|
||||
)}
|
||||
</div>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-md-list-item>
|
||||
${isComponentLoaded(this.hass, "thread")
|
||||
? html`<ha-md-list-item type="link" href="/config/thread">
|
||||
<ha-svg-icon slot="start" .path=${THREAD_ICON}></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.thread_panel"
|
||||
)}
|
||||
</div>
|
||||
<div slot="supporting-text">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.thread_panel_description"
|
||||
)}
|
||||
</div>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-md-list-item>`
|
||||
: nothing}
|
||||
</ha-md-list>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _fetchConfigEntry(): Promise<void> {
|
||||
@@ -343,79 +225,95 @@ export class MatterConfigDashboard extends LitElement {
|
||||
haStyle,
|
||||
css`
|
||||
ha-card {
|
||||
margin: auto;
|
||||
margin-top: var(--ha-space-4);
|
||||
max-width: 500px;
|
||||
margin: 0 auto var(--ha-space-4);
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
ha-card .card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
.nav-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nav-card .card-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nav-card .card-header {
|
||||
padding-bottom: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.content {
|
||||
margin-top: var(--ha-space-6);
|
||||
}
|
||||
|
||||
ha-md-list {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
ha-md-list-item {
|
||||
--md-item-overflow: visible;
|
||||
}
|
||||
|
||||
.network-status div.heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.network-status div.heading .icon {
|
||||
margin-inline-end: var(--ha-space-4);
|
||||
position: relative;
|
||||
border-radius: var(--ha-border-radius-2xl);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
--icon-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.network-status div.heading ha-svg-icon {
|
||||
--mdc-icon-size: 48px;
|
||||
.network-status div.heading .icon.success {
|
||||
--icon-color: var(--success-color);
|
||||
}
|
||||
|
||||
.network-status div.heading .icon.error {
|
||||
--icon-color: var(--error-color);
|
||||
}
|
||||
|
||||
.network-status div.heading .icon::before {
|
||||
display: block;
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: var(--icon-color);
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.network-status div.heading .icon ha-svg-icon {
|
||||
color: var(--icon-color);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.network-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);
|
||||
}
|
||||
|
||||
.network-status small {
|
||||
font-size: var(--ha-font-size-m);
|
||||
}
|
||||
|
||||
.network-status .online {
|
||||
color: var(--state-on-color, var(--success-color));
|
||||
}
|
||||
|
||||
.network-status .offline {
|
||||
color: var(--error-color, var(--error-color));
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
letter-spacing: 0.25px;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: var(--ha-space-2) var(--ha-space-4) var(--ha-space-4);
|
||||
}
|
||||
|
||||
ha-expansion-panel {
|
||||
margin: auto;
|
||||
margin-top: var(--ha-space-4);
|
||||
max-width: 500px;
|
||||
background: var(--card-background-color);
|
||||
border-radius: var(
|
||||
--ha-card-border-radius,
|
||||
var(--ha-border-radius-lg)
|
||||
);
|
||||
--expansion-panel-summary-padding: var(--ha-space-2) var(--ha-space-4);
|
||||
--expansion-panel-content-padding: 0 var(--ha-space-4);
|
||||
}
|
||||
|
||||
.dev-tools-content {
|
||||
padding: var(--ha-space-3) 0;
|
||||
}
|
||||
|
||||
.dev-tools-content p {
|
||||
margin: 0 0 var(--ha-space-4);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.dev-tools-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
a[slot="toolbar-icon"] {
|
||||
text-decoration: none;
|
||||
padding: var(--ha-space-2) var(--ha-space-4)
|
||||
calc(var(--ha-space-16) + var(--safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
a[slot="fab"] {
|
||||
|
||||
@@ -1,23 +1,8 @@
|
||||
import { mdiServerNetwork, mdiTextBoxOutline } from "@mdi/js";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { RouterOptions } from "../../../../../layouts/hass-router-page";
|
||||
import { HassRouterPage } from "../../../../../layouts/hass-router-page";
|
||||
import type { PageNavigation } from "../../../../../layouts/hass-tabs-subpage";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
|
||||
export const configTabs: PageNavigation[] = [
|
||||
{
|
||||
translationKey: "ui.panel.config.zwave_js.navigation.network",
|
||||
path: `/config/zwave_js/dashboard`,
|
||||
iconPath: mdiServerNetwork,
|
||||
},
|
||||
{
|
||||
translationKey: "ui.panel.config.zwave_js.navigation.logs",
|
||||
path: `/config/zwave_js/logs`,
|
||||
iconPath: mdiTextBoxOutline,
|
||||
},
|
||||
];
|
||||
|
||||
@customElement("matter-config-panel")
|
||||
class MatterConfigRouter extends HassRouterPage {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -38,6 +23,10 @@ class MatterConfigRouter extends HassRouterPage {
|
||||
tag: "matter-add-device",
|
||||
load: () => import("./matter-add-device"),
|
||||
},
|
||||
options: {
|
||||
tag: "matter-options-page",
|
||||
load: () => import("./matter-options-page"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import "../../../../../components/ha-alert";
|
||||
import "../../../../../components/ha-button";
|
||||
import "../../../../../components/ha-card";
|
||||
import "../../../../../components/ha-md-list";
|
||||
import "../../../../../components/ha-md-list-item";
|
||||
import {
|
||||
acceptSharedMatterDevice,
|
||||
canCommissionMatterExternal,
|
||||
commissionMatterDevice,
|
||||
matterSetThread,
|
||||
matterSetWifi,
|
||||
redirectOnNewMatterDevice,
|
||||
startExternalCommissioning,
|
||||
} from "../../../../../data/matter";
|
||||
import { showPromptDialog } from "../../../../../dialogs/generic/show-dialog-box";
|
||||
import "../../../../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../../../../resources/styles";
|
||||
import type { HomeAssistant, Route } from "../../../../../types";
|
||||
|
||||
@customElement("matter-options-page")
|
||||
class MatterOptionsPage extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
private _unsub?: UnsubscribeFunc;
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._stopRedirect();
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.options_title"
|
||||
)}
|
||||
back-path="/config/matter/dashboard"
|
||||
>
|
||||
<div class="container">
|
||||
<ha-card>
|
||||
${this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: nothing}
|
||||
<ha-md-list>
|
||||
${canCommissionMatterExternal(this.hass)
|
||||
? html`<ha-md-list-item>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.mobile_app_commisioning"
|
||||
)}
|
||||
</span>
|
||||
<span slot="supporting-text">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.mobile_app_commisioning_description"
|
||||
)}
|
||||
</span>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
slot="end"
|
||||
size="small"
|
||||
@click=${this._startMobileCommissioning}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.mobile_app_commisioning_action"
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-md-list-item>`
|
||||
: nothing}
|
||||
<ha-md-list-item>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.commission_device"
|
||||
)}
|
||||
</span>
|
||||
<span slot="supporting-text">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.commission_device_description"
|
||||
)}
|
||||
</span>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
slot="end"
|
||||
size="small"
|
||||
@click=${this._commission}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.commission_device_action"
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.add_shared_device"
|
||||
)}
|
||||
</span>
|
||||
<span slot="supporting-text">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.add_shared_device_description"
|
||||
)}
|
||||
</span>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
slot="end"
|
||||
size="small"
|
||||
@click=${this._acceptSharedDevice}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.add_shared_device_action"
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.set_wifi_credentials"
|
||||
)}
|
||||
</span>
|
||||
<span slot="supporting-text">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.set_wifi_credentials_description"
|
||||
)}
|
||||
</span>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
slot="end"
|
||||
size="small"
|
||||
@click=${this._setWifi}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.set_wifi_credentials_action"
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.set_thread_credentials"
|
||||
)}
|
||||
</span>
|
||||
<span slot="supporting-text">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.set_thread_credentials_description"
|
||||
)}
|
||||
</span>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
slot="end"
|
||||
size="small"
|
||||
@click=${this._setThread}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.panel.set_thread_credentials_action"
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-md-list-item>
|
||||
</ha-md-list>
|
||||
</ha-card>
|
||||
</div>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
private _redirectOnNewMatterDevice() {
|
||||
if (this._unsub) {
|
||||
return;
|
||||
}
|
||||
this._unsub = redirectOnNewMatterDevice(this.hass, () => {
|
||||
this._unsub = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
private _stopRedirect() {
|
||||
this._unsub?.();
|
||||
this._unsub = undefined;
|
||||
}
|
||||
|
||||
private _startMobileCommissioning() {
|
||||
this._redirectOnNewMatterDevice();
|
||||
startExternalCommissioning(this.hass);
|
||||
}
|
||||
|
||||
private async _commission(): Promise<void> {
|
||||
const code = await showPromptDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.matter.panel.prompts.commission_device.title"
|
||||
),
|
||||
inputLabel: this.hass.localize(
|
||||
"ui.panel.config.matter.panel.prompts.commission_device.input_label"
|
||||
),
|
||||
inputType: "string",
|
||||
confirmText: this.hass.localize(
|
||||
"ui.panel.config.matter.panel.prompts.commission_device.confirm"
|
||||
),
|
||||
});
|
||||
if (!code) {
|
||||
return;
|
||||
}
|
||||
this._error = undefined;
|
||||
this._redirectOnNewMatterDevice();
|
||||
try {
|
||||
await commissionMatterDevice(this.hass, code);
|
||||
} catch (err: any) {
|
||||
this._error = err.message;
|
||||
this._stopRedirect();
|
||||
}
|
||||
}
|
||||
|
||||
private async _acceptSharedDevice(): Promise<void> {
|
||||
const code = await showPromptDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.matter.panel.prompts.add_shared_device.title"
|
||||
),
|
||||
inputLabel: this.hass.localize(
|
||||
"ui.panel.config.matter.panel.prompts.add_shared_device.input_label"
|
||||
),
|
||||
inputType: "number",
|
||||
confirmText: this.hass.localize(
|
||||
"ui.panel.config.matter.panel.prompts.add_shared_device.confirm"
|
||||
),
|
||||
});
|
||||
if (!code) {
|
||||
return;
|
||||
}
|
||||
this._error = undefined;
|
||||
this._redirectOnNewMatterDevice();
|
||||
try {
|
||||
await acceptSharedMatterDevice(this.hass, Number(code));
|
||||
} catch (err: any) {
|
||||
this._error = err.message;
|
||||
this._stopRedirect();
|
||||
}
|
||||
}
|
||||
|
||||
private async _setWifi(): Promise<void> {
|
||||
this._error = undefined;
|
||||
const networkName = await showPromptDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.matter.panel.prompts.network_name.title"
|
||||
),
|
||||
inputLabel: this.hass.localize(
|
||||
"ui.panel.config.matter.panel.prompts.network_name.input_label"
|
||||
),
|
||||
inputType: "string",
|
||||
confirmText: this.hass.localize(
|
||||
"ui.panel.config.matter.panel.prompts.network_name.confirm"
|
||||
),
|
||||
});
|
||||
if (!networkName) {
|
||||
return;
|
||||
}
|
||||
const psk = await showPromptDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.matter.panel.prompts.passcode.title"
|
||||
),
|
||||
inputLabel: this.hass.localize(
|
||||
"ui.panel.config.matter.panel.prompts.passcode.input_label"
|
||||
),
|
||||
inputType: "password",
|
||||
confirmText: this.hass.localize(
|
||||
"ui.panel.config.matter.panel.prompts.passcode.confirm"
|
||||
),
|
||||
});
|
||||
if (!psk) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await matterSetWifi(this.hass, networkName, psk);
|
||||
} catch (err: any) {
|
||||
this._error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
private async _setThread(): Promise<void> {
|
||||
const code = await showPromptDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.matter.panel.prompts.set_thread.title"
|
||||
),
|
||||
inputLabel: this.hass.localize(
|
||||
"ui.panel.config.matter.panel.prompts.set_thread.input_label"
|
||||
),
|
||||
inputType: "string",
|
||||
confirmText: this.hass.localize(
|
||||
"ui.panel.config.matter.panel.prompts.set_thread.confirm"
|
||||
),
|
||||
});
|
||||
if (!code) {
|
||||
return;
|
||||
}
|
||||
this._error = undefined;
|
||||
try {
|
||||
await matterSetThread(this.hass, code);
|
||||
} catch (err: any) {
|
||||
this._error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
css`
|
||||
.container {
|
||||
padding: var(--ha-space-2) var(--ha-space-4) var(--ha-space-4);
|
||||
}
|
||||
|
||||
ha-card {
|
||||
max-width: 600px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
ha-md-list {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
ha-md-list-item {
|
||||
--md-item-overflow: visible;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"matter-options-page": MatterOptionsPage;
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,12 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) {
|
||||
const networks = this._groupRoutersByNetwork(this._routers, this._datasets);
|
||||
|
||||
return html`
|
||||
<hass-subpage .narrow=${this.narrow} .hass=${this.hass} header="Thread">
|
||||
<hass-subpage
|
||||
.narrow=${this.narrow}
|
||||
.hass=${this.hass}
|
||||
header="Thread"
|
||||
back-path="/config"
|
||||
>
|
||||
<ha-dropdown slot="toolbar-icon">
|
||||
<ha-icon-button
|
||||
.path=${mdiDotsVertical}
|
||||
@@ -219,7 +224,6 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) {
|
||||
slot="graphic"
|
||||
.src=${brandsUrl({
|
||||
domain: router.brand,
|
||||
brand: true,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
})}
|
||||
|
||||
@@ -10,6 +10,7 @@ import "../../../../../components/ha-dialog";
|
||||
import "../../../../../components/ha-select";
|
||||
import type { HaSelectSelectEvent } from "../../../../../components/ha-select";
|
||||
import { changeZHANetworkChannel } from "../../../../../data/zha";
|
||||
import type { HassDialog } from "../../../../../dialogs/make-dialog-manager";
|
||||
import { showAlertDialog } from "../../../../../dialogs/generic/show-dialog-box";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import type { ZHAChangeChannelDialogParams } from "./show-dialog-zha-change-channel";
|
||||
@@ -35,7 +36,10 @@ const VALID_CHANNELS = [
|
||||
];
|
||||
|
||||
@customElement("dialog-zha-change-channel")
|
||||
class DialogZHAChangeChannel extends LitElement {
|
||||
class DialogZHAChangeChannel
|
||||
extends LitElement
|
||||
implements HassDialog<ZHAChangeChannelDialogParams>
|
||||
{
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _migrationInProgress = false;
|
||||
@@ -46,19 +50,24 @@ class DialogZHAChangeChannel extends LitElement {
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
public async showDialog(params: ZHAChangeChannelDialogParams): Promise<void> {
|
||||
public showDialog(params: ZHAChangeChannelDialogParams): void {
|
||||
this._params = params;
|
||||
this._newChannel = "auto";
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
public closeDialog() {
|
||||
public closeDialog(): boolean {
|
||||
if (this._migrationInProgress) {
|
||||
return false;
|
||||
}
|
||||
this._open = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private _dialogClosed() {
|
||||
private _dialogClosed(): void {
|
||||
this._params = undefined;
|
||||
this._newChannel = undefined;
|
||||
this._migrationInProgress = false;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
@@ -77,7 +86,12 @@ class DialogZHAChangeChannel extends LitElement {
|
||||
prevent-scrim-close
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<ha-alert alert-type="warning">
|
||||
<ha-alert
|
||||
alert-type="warning"
|
||||
.title=${this.hass.localize(
|
||||
"ui.panel.config.zha.change_channel_dialog.migration_warning_title"
|
||||
)}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.zha.change_channel_dialog.migration_warning"
|
||||
)}
|
||||
@@ -95,25 +109,25 @@ class DialogZHAChangeChannel extends LitElement {
|
||||
)}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<ha-select
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.zha.change_channel_dialog.new_channel"
|
||||
)}
|
||||
@selected=${this._newChannelChosen}
|
||||
.value=${String(this._newChannel)}
|
||||
.options=${VALID_CHANNELS.map((channel) => ({
|
||||
value: String(channel),
|
||||
label:
|
||||
channel === "auto"
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.zha.change_channel_dialog.channel_auto"
|
||||
)
|
||||
: String(channel),
|
||||
}))}
|
||||
>
|
||||
</ha-select>
|
||||
</p>
|
||||
<ha-select
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.zha.change_channel_dialog.new_channel"
|
||||
)}
|
||||
autofocus
|
||||
@selected=${this._newChannelChosen}
|
||||
.value=${String(this._newChannel)}
|
||||
.options=${VALID_CHANNELS.map((channel) => ({
|
||||
value: String(channel),
|
||||
label:
|
||||
channel === "auto"
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.zha.change_channel_dialog.channel_auto"
|
||||
)
|
||||
: String(channel),
|
||||
}))}
|
||||
>
|
||||
</ha-select>
|
||||
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
|
||||
@@ -6,11 +6,10 @@ import "../../../../../components/ha-spinner";
|
||||
import "../../../../../components/ha-textarea";
|
||||
import type { ZHADevice } from "../../../../../data/zha";
|
||||
import { DEVICE_MESSAGE_TYPES, LOG_OUTPUT } from "../../../../../data/zha";
|
||||
import "../../../../../layouts/hass-tabs-subpage";
|
||||
import "../../../../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../../../../resources/styles";
|
||||
import type { HomeAssistant, Route } from "../../../../../types";
|
||||
import { documentationUrl } from "../../../../../util/documentation-url";
|
||||
import { zhaTabs } from "./zha-config-dashboard";
|
||||
import "./zha-device-pairing-status-card";
|
||||
|
||||
@customElement("zha-add-devices-page")
|
||||
@@ -74,11 +73,10 @@ class ZHAAddDevicesPage extends LitElement {
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<hass-tabs-subpage
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.route=${this.route!}
|
||||
.tabs=${zhaTabs}
|
||||
.header=${this.hass.localize("ui.panel.config.zha.add_device")}
|
||||
>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
@@ -168,7 +166,7 @@ class ZHAAddDevicesPage extends LitElement {
|
||||
>
|
||||
</ha-textarea>`
|
||||
: ""}
|
||||
</hass-tabs-subpage>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,18 @@ class ZHAConfigDashboardRouter extends HassRouterPage {
|
||||
tag: "zha-network-visualization-page",
|
||||
load: () => import("./zha-network-visualization-page"),
|
||||
},
|
||||
options: {
|
||||
tag: "zha-options-page",
|
||||
load: () => import("./zha-options-page"),
|
||||
},
|
||||
"network-info": {
|
||||
tag: "zha-network-info-page",
|
||||
load: () => import("./zha-network-info-page"),
|
||||
},
|
||||
section: {
|
||||
tag: "zha-config-section-page",
|
||||
load: () => import("./zha-config-section-page"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -53,6 +65,8 @@ class ZHAConfigDashboardRouter extends HassRouterPage {
|
||||
el.ieee = this.routeTail.path.substr(1);
|
||||
} else if (this._currentPage === "visualization") {
|
||||
el.zoomedDeviceIdFromURL = this.routeTail.path.substr(1);
|
||||
} else if (this._currentPage === "section") {
|
||||
el.sectionId = this.routeTail.path.substr(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
import {
|
||||
mdiAlertCircle,
|
||||
mdiCheckCircle,
|
||||
mdiAlertCircleOutline,
|
||||
mdiCheck,
|
||||
mdiDevices,
|
||||
mdiDownload,
|
||||
mdiFolderMultipleOutline,
|
||||
mdiLan,
|
||||
mdiNetwork,
|
||||
mdiPencil,
|
||||
mdiInformationOutline,
|
||||
mdiPlus,
|
||||
mdiShape,
|
||||
mdiTune,
|
||||
mdiVectorPolyline,
|
||||
} from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import "../../../../../components/buttons/ha-progress-button";
|
||||
import "../../../../../components/ha-alert";
|
||||
import "../../../../../components/ha-button";
|
||||
import "../../../../../components/ha-card";
|
||||
import "../../../../../components/ha-fab";
|
||||
import "../../../../../components/ha-form/ha-form";
|
||||
import "../../../../../components/ha-icon-button";
|
||||
import "../../../../../components/ha-icon-next";
|
||||
import "../../../../../components/ha-settings-row";
|
||||
import "../../../../../components/ha-md-list";
|
||||
import "../../../../../components/ha-md-list-item";
|
||||
import "../../../../../components/ha-svg-icon";
|
||||
import type { ConfigEntry } from "../../../../../data/config_entries";
|
||||
import { getConfigEntries } from "../../../../../data/config_entries";
|
||||
@@ -30,40 +31,16 @@ import type {
|
||||
import {
|
||||
createZHANetworkBackup,
|
||||
fetchDevices,
|
||||
fetchGroups,
|
||||
fetchZHAConfiguration,
|
||||
fetchZHANetworkSettings,
|
||||
updateZHAConfiguration,
|
||||
} from "../../../../../data/zha";
|
||||
import { showOptionsFlowDialog } from "../../../../../dialogs/config-flow/show-dialog-options-flow";
|
||||
import { showAlertDialog } from "../../../../../dialogs/generic/show-dialog-box";
|
||||
import "../../../../../layouts/hass-tabs-subpage";
|
||||
import type { PageNavigation } from "../../../../../layouts/hass-tabs-subpage";
|
||||
import "../../../../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../../../../resources/styles";
|
||||
import type { HomeAssistant, Route } from "../../../../../types";
|
||||
import { fileDownload } from "../../../../../util/file_download";
|
||||
import "../../../ha-config-section";
|
||||
import { showZHAChangeChannelDialog } from "./show-dialog-zha-change-channel";
|
||||
import type { HaProgressButton } from "../../../../../components/buttons/ha-progress-button";
|
||||
|
||||
const MULTIPROTOCOL_ADDON_URL = "socket://core-silabs-multiprotocol:9999";
|
||||
|
||||
export const zhaTabs: PageNavigation[] = [
|
||||
{
|
||||
translationKey: "ui.panel.config.zha.network.caption",
|
||||
path: `/config/zha/dashboard`,
|
||||
iconPath: mdiNetwork,
|
||||
},
|
||||
{
|
||||
translationKey: "ui.panel.config.zha.groups.caption",
|
||||
path: `/config/zha/groups`,
|
||||
iconPath: mdiFolderMultipleOutline,
|
||||
},
|
||||
{
|
||||
translationKey: "ui.panel.config.zha.visualization.caption",
|
||||
path: `/config/zha/visualization`,
|
||||
iconPath: mdiLan,
|
||||
},
|
||||
];
|
||||
|
||||
@customElement("zha-config-dashboard")
|
||||
class ZHAConfigDashboard extends LitElement {
|
||||
@@ -79,15 +56,15 @@ class ZHAConfigDashboard extends LitElement {
|
||||
|
||||
@state() private _configuration?: ZHAConfiguration;
|
||||
|
||||
@state() private _networkSettings?: ZHANetworkSettings;
|
||||
|
||||
@state() private _totalDevices = 0;
|
||||
|
||||
@state() private _offlineDevices = 0;
|
||||
|
||||
@state() private _error?: string;
|
||||
@state() private _totalGroups = 0;
|
||||
|
||||
@state() private _generatingBackup = false;
|
||||
@state() private _networkSettings?: ZHANetworkSettings;
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues) {
|
||||
super.firstUpdated(changedProperties);
|
||||
@@ -95,8 +72,9 @@ class ZHAConfigDashboard extends LitElement {
|
||||
this.hass.loadBackendTranslation("config_panel", "zha", false);
|
||||
this._fetchConfigEntry();
|
||||
this._fetchConfiguration();
|
||||
this._fetchSettings();
|
||||
this._fetchDevicesAndUpdateStatus();
|
||||
this._fetchGroups();
|
||||
this._fetchNetworkSettings();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,204 +82,17 @@ class ZHAConfigDashboard extends LitElement {
|
||||
const deviceOnline =
|
||||
this._offlineDevices < this._totalDevices || this._totalDevices === 0;
|
||||
return html`
|
||||
<hass-tabs-subpage
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.route=${this.route}
|
||||
.tabs=${zhaTabs}
|
||||
.header=${this.hass.localize("ui.panel.config.zha.network.caption")}
|
||||
back-path="/config"
|
||||
has-fab
|
||||
>
|
||||
<div class="container">
|
||||
<ha-card class="content network-status">
|
||||
${this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: nothing}
|
||||
<div class="card-content">
|
||||
<div class="heading">
|
||||
<div class="icon">
|
||||
<ha-svg-icon
|
||||
.path=${deviceOnline ? mdiCheckCircle : mdiAlertCircle}
|
||||
class=${deviceOnline ? "online" : "offline"}
|
||||
></ha-svg-icon>
|
||||
</div>
|
||||
<div class="details">
|
||||
ZHA
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.status_title"
|
||||
)}:
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.zha.configuration_page.status_${deviceOnline ? "online" : "offline"}`
|
||||
)}<br />
|
||||
<small>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.devices",
|
||||
{ count: this._totalDevices }
|
||||
)}
|
||||
</small>
|
||||
<small class="offline">
|
||||
${this._offlineDevices > 0
|
||||
? html`(${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.devices_offline",
|
||||
{ count: this._offlineDevices }
|
||||
)})`
|
||||
: nothing}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<ha-button
|
||||
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
|
||||
appearance="plain"
|
||||
size="small"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.devices.caption"
|
||||
)}</ha-button
|
||||
>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
href=${`/config/entities/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.entities.caption"
|
||||
)}</ha-button
|
||||
>
|
||||
</div>
|
||||
</ha-card>
|
||||
<ha-card
|
||||
class="network-settings"
|
||||
header=${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.network_settings_title"
|
||||
)}
|
||||
>
|
||||
${this._networkSettings
|
||||
? html`<div class="card-content">
|
||||
<ha-settings-row>
|
||||
<span slot="description">PAN ID</span>
|
||||
<span slot="heading"
|
||||
>${this._networkSettings.settings.network_info
|
||||
.pan_id}</span
|
||||
>
|
||||
</ha-settings-row>
|
||||
|
||||
<ha-settings-row>
|
||||
<span slot="heading"
|
||||
>${this._networkSettings.settings.network_info
|
||||
.extended_pan_id}</span
|
||||
>
|
||||
<span slot="description">Extended PAN ID</span>
|
||||
</ha-settings-row>
|
||||
|
||||
<ha-settings-row>
|
||||
<span slot="description">Channel</span>
|
||||
<span slot="heading"
|
||||
>${this._networkSettings.settings.network_info
|
||||
.channel}</span
|
||||
>
|
||||
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.change_channel"
|
||||
)}
|
||||
.path=${mdiPencil}
|
||||
@click=${this._showChannelMigrationDialog}
|
||||
>
|
||||
</ha-icon-button>
|
||||
</ha-settings-row>
|
||||
|
||||
<ha-settings-row>
|
||||
<span slot="description">Coordinator IEEE</span>
|
||||
<span slot="heading"
|
||||
>${this._networkSettings.settings.node_info.ieee}</span
|
||||
>
|
||||
</ha-settings-row>
|
||||
|
||||
<ha-settings-row>
|
||||
<span slot="description">Radio type</span>
|
||||
<span slot="heading"
|
||||
>${this._networkSettings.radio_type}</span
|
||||
>
|
||||
</ha-settings-row>
|
||||
|
||||
<ha-settings-row>
|
||||
<span slot="description">Serial port</span>
|
||||
<span slot="heading"
|
||||
>${this._networkSettings.device.path}</span
|
||||
>
|
||||
</ha-settings-row>
|
||||
|
||||
${this._networkSettings.device.baudrate &&
|
||||
!this._networkSettings.device.path.startsWith("socket://")
|
||||
? html`
|
||||
<ha-settings-row>
|
||||
<span slot="description">Baudrate</span>
|
||||
<span slot="heading"
|
||||
>${this._networkSettings.device.baudrate}</span
|
||||
>
|
||||
</ha-settings-row>
|
||||
`
|
||||
: nothing}
|
||||
</div>`
|
||||
: nothing}
|
||||
<div class="card-actions">
|
||||
<ha-progress-button
|
||||
appearance="plain"
|
||||
@click=${this._createAndDownloadBackup}
|
||||
.progress=${this._generatingBackup}
|
||||
.disabled=${!this._networkSettings || this._generatingBackup}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.download_backup"
|
||||
)}
|
||||
</ha-progress-button>
|
||||
<ha-button
|
||||
appearance="filled"
|
||||
variant="brand"
|
||||
@click=${this._openOptionFlow}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.migrate_radio"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
${this._configuration
|
||||
? Object.entries(this._configuration.schemas).map(
|
||||
([section, schema]) =>
|
||||
html`<ha-card
|
||||
header=${this.hass.localize(
|
||||
`component.zha.config_panel.${section}.title`
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.schema=${schema}
|
||||
.data=${this._configuration!.data[section]}
|
||||
@value-changed=${this._dataChanged}
|
||||
.section=${section}
|
||||
.computeLabel=${this._computeLabelCallback(
|
||||
this.hass.localize,
|
||||
section
|
||||
)}
|
||||
></ha-form>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<ha-progress-button
|
||||
appearance="filled"
|
||||
variant="brand"
|
||||
@click=${this._updateConfiguration}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.update_button"
|
||||
)}
|
||||
</ha-progress-button>
|
||||
</div>
|
||||
</ha-card>`
|
||||
)
|
||||
: nothing}
|
||||
${this._renderNetworkStatus(deviceOnline)}
|
||||
${this._renderMyNetworkCard()} ${this._renderNavigationCard()}
|
||||
${this._renderBackupCard()}
|
||||
</div>
|
||||
|
||||
<a href="/config/zha/add" slot="fab">
|
||||
@@ -312,7 +103,240 @@ class ZHAConfigDashboard extends LitElement {
|
||||
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
|
||||
</ha-fab>
|
||||
</a>
|
||||
</hass-tabs-subpage>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderNetworkStatus(deviceOnline: boolean) {
|
||||
return html`
|
||||
<ha-card class="content network-status">
|
||||
${this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: nothing}
|
||||
<div class="card-content">
|
||||
<div class="heading">
|
||||
<div class="icon ${deviceOnline ? "success" : "error"}">
|
||||
<ha-svg-icon
|
||||
.path=${deviceOnline ? mdiCheck : mdiAlertCircleOutline}
|
||||
></ha-svg-icon>
|
||||
</div>
|
||||
<div class="details">
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.zha.configuration_page.status_${deviceOnline ? "online" : "offline"}`
|
||||
)}<br />
|
||||
<small>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.devices",
|
||||
{ count: this._totalDevices }
|
||||
)}
|
||||
</small>
|
||||
<small class="offline">
|
||||
${this._offlineDevices > 0
|
||||
? html`(${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.devices_offline",
|
||||
{ count: this._offlineDevices }
|
||||
)})`
|
||||
: nothing}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderMyNetworkCard() {
|
||||
const deviceIds = this._configEntry
|
||||
? new Set(
|
||||
Object.values(this.hass.devices)
|
||||
.filter((device) =>
|
||||
device.config_entries.includes(this._configEntry!.entry_id)
|
||||
)
|
||||
.map((device) => device.id)
|
||||
)
|
||||
: new Set<string>();
|
||||
const entityCount = Object.values(this.hass.entities).filter(
|
||||
(entity) => entity.device_id && deviceIds.has(entity.device_id)
|
||||
).length;
|
||||
|
||||
return html`
|
||||
<ha-card class="nav-card">
|
||||
<div class="card-header">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.my_network_title"
|
||||
)}
|
||||
<ha-button appearance="filled" href="/config/zha/visualization">
|
||||
<ha-svg-icon slot="start" .path=${mdiVectorPolyline}></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.show_map"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<ha-md-list>
|
||||
<ha-md-list-item
|
||||
type="link"
|
||||
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.device_count",
|
||||
{ count: deviceIds.size }
|
||||
)}
|
||||
</div>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item
|
||||
type="link"
|
||||
href=${`/config/entities/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiShape}></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.entity_count",
|
||||
{ count: entityCount }
|
||||
)}
|
||||
</div>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item type="link" href="/config/zha/groups">
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiFolderMultipleOutline}
|
||||
></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.group_count",
|
||||
{ count: this._totalGroups }
|
||||
)}
|
||||
</div>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-md-list-item>
|
||||
</ha-md-list>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderNavigationCard() {
|
||||
const dynamicSections = this._configuration
|
||||
? Object.keys(this._configuration.schemas).filter(
|
||||
(section) => section !== "zha_options"
|
||||
)
|
||||
: [];
|
||||
|
||||
return html`
|
||||
<ha-card class="nav-card">
|
||||
<div class="card-content">
|
||||
<ha-md-list>
|
||||
<ha-md-list-item type="link" href="/config/zha/options">
|
||||
<ha-svg-icon slot="start" .path=${mdiTune}></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.options_title"
|
||||
)}
|
||||
</div>
|
||||
<div slot="supporting-text">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.options_description"
|
||||
)}
|
||||
</div>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item type="link" href="/config/zha/network-info">
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiInformationOutline}
|
||||
></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.network_info_title"
|
||||
)}
|
||||
</div>
|
||||
<div slot="supporting-text">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.network_info_description"
|
||||
)}
|
||||
</div>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-md-list-item>
|
||||
${dynamicSections.map(
|
||||
(section) => html`
|
||||
<ha-md-list-item
|
||||
type="link"
|
||||
href=${`/config/zha/section/${section}`}
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiTune}></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
`component.zha.config_panel.${section}.title`
|
||||
) || section}
|
||||
</div>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-md-list-item>
|
||||
`
|
||||
)}
|
||||
</ha-md-list>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderBackupCard() {
|
||||
return html`
|
||||
<ha-card class="nav-card">
|
||||
<div class="card-content">
|
||||
<ha-md-list>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.download_backup"
|
||||
)}
|
||||
</span>
|
||||
<span slot="supporting-text">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.download_backup_description"
|
||||
)}
|
||||
</span>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
slot="end"
|
||||
size="small"
|
||||
@click=${this._createAndDownloadBackup}
|
||||
.disabled=${!this._networkSettings}
|
||||
>
|
||||
<ha-svg-icon .path=${mdiDownload} slot="start"></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.download_backup_action"
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.migrate_radio"
|
||||
)}
|
||||
</span>
|
||||
<span slot="supporting-text">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.migrate_radio_description"
|
||||
)}
|
||||
</span>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
slot="end"
|
||||
size="small"
|
||||
@click=${this._openOptionFlow}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.migrate_radio_action"
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-md-list-item>
|
||||
</ha-md-list>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -329,45 +353,13 @@ class ZHAConfigDashboard extends LitElement {
|
||||
this._configuration = await fetchZHAConfiguration(this.hass!);
|
||||
}
|
||||
|
||||
private async _fetchSettings(): Promise<void> {
|
||||
private async _fetchNetworkSettings(): Promise<void> {
|
||||
this._networkSettings = await fetchZHANetworkSettings(this.hass!);
|
||||
}
|
||||
|
||||
private async _fetchDevicesAndUpdateStatus(): Promise<void> {
|
||||
try {
|
||||
const devices = await fetchDevices(this.hass);
|
||||
this._totalDevices = devices.length;
|
||||
this._offlineDevices =
|
||||
this._totalDevices - devices.filter((d) => d.available).length;
|
||||
} catch (err: any) {
|
||||
this._error = err.message || err;
|
||||
}
|
||||
}
|
||||
|
||||
private async _showChannelMigrationDialog(): Promise<void> {
|
||||
if (this._networkSettings!.device.path === MULTIPROTOCOL_ADDON_URL) {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.channel_dialog.title"
|
||||
),
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.channel_dialog.text"
|
||||
),
|
||||
warning: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
showZHAChangeChannelDialog(this, {
|
||||
currentChannel: this._networkSettings!.settings.network_info.channel,
|
||||
});
|
||||
}
|
||||
|
||||
private async _createAndDownloadBackup(): Promise<void> {
|
||||
let backup_and_metadata: ZHANetworkBackupAndMetadata;
|
||||
|
||||
this._generatingBackup = true;
|
||||
|
||||
try {
|
||||
backup_and_metadata = await createZHANetworkBackup(this.hass!);
|
||||
} catch (err: any) {
|
||||
@@ -377,8 +369,6 @@ class ZHAConfigDashboard extends LitElement {
|
||||
warning: true,
|
||||
});
|
||||
return;
|
||||
} finally {
|
||||
this._generatingBackup = false;
|
||||
}
|
||||
|
||||
if (!backup_and_metadata.is_complete) {
|
||||
@@ -410,28 +400,24 @@ class ZHAConfigDashboard extends LitElement {
|
||||
showOptionsFlowDialog(this, this._configEntry);
|
||||
}
|
||||
|
||||
private _dataChanged(ev) {
|
||||
this._configuration!.data[ev.currentTarget!.section] = ev.detail.value;
|
||||
}
|
||||
|
||||
private async _updateConfiguration(ev): Promise<any> {
|
||||
const button = ev.currentTarget as HaProgressButton;
|
||||
button.progress = true;
|
||||
private async _fetchGroups(): Promise<void> {
|
||||
try {
|
||||
await updateZHAConfiguration(this.hass!, this._configuration!.data);
|
||||
button.actionSuccess();
|
||||
} catch (_err: any) {
|
||||
button.actionError();
|
||||
} finally {
|
||||
button.progress = false;
|
||||
const groups = await fetchGroups(this.hass);
|
||||
this._totalGroups = groups.length;
|
||||
} catch (_err) {
|
||||
// Groups are optional
|
||||
}
|
||||
}
|
||||
|
||||
private _computeLabelCallback(localize, section: string) {
|
||||
// Returns a callback for ha-form to calculate labels per schema object
|
||||
return (schema) =>
|
||||
localize(`component.zha.config_panel.${section}.${schema.name}`) ||
|
||||
schema.name;
|
||||
private async _fetchDevicesAndUpdateStatus(): Promise<void> {
|
||||
try {
|
||||
const devices = await fetchDevices(this.hass);
|
||||
this._totalDevices = devices.length;
|
||||
this._offlineDevices =
|
||||
this._totalDevices - devices.filter((d) => d.available).length;
|
||||
} catch (err: any) {
|
||||
this._error = err.message || err;
|
||||
}
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
@@ -440,75 +426,102 @@ class ZHAConfigDashboard extends LitElement {
|
||||
css`
|
||||
ha-card {
|
||||
margin: auto;
|
||||
margin-top: 16px;
|
||||
max-width: 500px;
|
||||
margin-top: var(--ha-space-4);
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
ha-card .card-actions {
|
||||
.nav-card .card-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.network-settings ha-settings-row {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
padding-inline-start: 0;
|
||||
padding-inline-end: 0;
|
||||
.nav-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.network-settings ha-settings-row span[slot="heading"] {
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
text-indent: -1em;
|
||||
padding-left: 1em;
|
||||
padding-inline-start: 1em;
|
||||
padding-inline-end: initial;
|
||||
}
|
||||
|
||||
.network-settings ha-settings-row ha-icon-button {
|
||||
margin-top: -16px;
|
||||
margin-bottom: -16px;
|
||||
.nav-card .card-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
margin-top: 24px;
|
||||
margin-top: var(--ha-space-6);
|
||||
}
|
||||
|
||||
ha-md-list {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
ha-md-list-item {
|
||||
--md-item-overflow: visible;
|
||||
}
|
||||
|
||||
ha-button[size="small"] ha-svg-icon {
|
||||
--mdc-icon-size: 16px;
|
||||
}
|
||||
|
||||
.network-status div.heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.network-status div.heading .icon {
|
||||
margin-inline-end: 16px;
|
||||
position: relative;
|
||||
border-radius: var(--ha-border-radius-2xl);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
--icon-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.network-status div.heading ha-svg-icon {
|
||||
--mdc-icon-size: 48px;
|
||||
.network-status div.heading .icon.success {
|
||||
--icon-color: var(--success-color);
|
||||
}
|
||||
|
||||
.network-status div.heading .icon.error {
|
||||
--icon-color: var(--error-color);
|
||||
}
|
||||
|
||||
.network-status div.heading .icon::before {
|
||||
display: block;
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: var(--icon-color);
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.network-status div.heading .icon ha-svg-icon {
|
||||
color: var(--icon-color);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.network-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);
|
||||
}
|
||||
|
||||
.network-status small {
|
||||
font-size: var(--ha-font-size-m);
|
||||
}
|
||||
|
||||
.network-status small.offline {
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
letter-spacing: 0.25px;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.network-status .online {
|
||||
color: var(--state-on-color, var(--success-color));
|
||||
}
|
||||
|
||||
.network-status .offline {
|
||||
color: var(--error-color, var(--error-color));
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: var(--ha-space-2) var(--ha-space-4) var(--ha-space-4);
|
||||
padding: var(--ha-space-2) var(--ha-space-4)
|
||||
calc(var(--ha-space-20) + var(--safe-area-inset-bottom, 0px));
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import "../../../../../components/buttons/ha-progress-button";
|
||||
import "../../../../../components/ha-card";
|
||||
import "../../../../../components/ha-form/ha-form";
|
||||
import type { ZHAConfiguration } from "../../../../../data/zha";
|
||||
import {
|
||||
fetchZHAConfiguration,
|
||||
updateZHAConfiguration,
|
||||
} from "../../../../../data/zha";
|
||||
import "../../../../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../../../../resources/styles";
|
||||
import type { HomeAssistant, Route } from "../../../../../types";
|
||||
|
||||
@customElement("zha-config-section-page")
|
||||
class ZHAConfigSectionPage 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;
|
||||
|
||||
@property({ attribute: "section-id" }) public sectionId!: string;
|
||||
|
||||
@state() private _configuration?: ZHAConfiguration;
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues) {
|
||||
super.firstUpdated(changedProperties);
|
||||
if (this.hass) {
|
||||
this.hass.loadBackendTranslation("config_panel", "zha", false);
|
||||
this._fetchConfiguration();
|
||||
}
|
||||
}
|
||||
|
||||
private async _fetchConfiguration(): Promise<void> {
|
||||
this._configuration = await fetchZHAConfiguration(this.hass!);
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const schema = this._configuration?.schemas[this.sectionId];
|
||||
const data = this._configuration?.data[this.sectionId];
|
||||
|
||||
return html`
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.header=${this.hass.localize(
|
||||
`component.zha.config_panel.${this.sectionId}.title`
|
||||
) || this.sectionId}
|
||||
back-path="/config/zha/dashboard"
|
||||
>
|
||||
<div class="container">
|
||||
<ha-card>
|
||||
${schema && data
|
||||
? html`
|
||||
<div class="card-content">
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.schema=${schema}
|
||||
.data=${data}
|
||||
@value-changed=${this._dataChanged}
|
||||
.computeLabel=${this._computeLabelCallback(
|
||||
this.hass.localize,
|
||||
this.sectionId
|
||||
)}
|
||||
></ha-form>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<ha-progress-button
|
||||
appearance="filled"
|
||||
variant="brand"
|
||||
@click=${this._updateConfiguration}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.update_button"
|
||||
)}
|
||||
</ha-progress-button>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</ha-card>
|
||||
</div>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
private _dataChanged(ev) {
|
||||
this._configuration!.data[this.sectionId] = ev.detail.value;
|
||||
}
|
||||
|
||||
private async _updateConfiguration(ev: Event): Promise<void> {
|
||||
const button = ev.currentTarget as HTMLElement & {
|
||||
progress: boolean;
|
||||
actionSuccess: () => void;
|
||||
actionError: () => void;
|
||||
};
|
||||
button.progress = true;
|
||||
try {
|
||||
await updateZHAConfiguration(this.hass!, this._configuration!.data);
|
||||
button.actionSuccess();
|
||||
} catch (_err: any) {
|
||||
button.actionError();
|
||||
} finally {
|
||||
button.progress = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _computeLabelCallback(localize, section: string) {
|
||||
return (schema) =>
|
||||
localize(`component.zha.config_panel.${section}.${schema.name}`) ||
|
||||
schema.name;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
css`
|
||||
.container {
|
||||
padding: var(--ha-space-2) var(--ha-space-4) var(--ha-space-4);
|
||||
}
|
||||
|
||||
ha-card {
|
||||
max-width: 600px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"zha-config-section-page": ZHAConfigSectionPage;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mdiPlus } from "@mdi/js";
|
||||
import { mdiFolderMultipleOutline, mdiPlus } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -15,10 +15,18 @@ import "../../../../../components/ha-icon-button";
|
||||
import type { ZHAGroup } from "../../../../../data/zha";
|
||||
import { fetchGroups } from "../../../../../data/zha";
|
||||
import "../../../../../layouts/hass-tabs-subpage-data-table";
|
||||
import type { PageNavigation } from "../../../../../layouts/hass-tabs-subpage";
|
||||
import { haStyle } from "../../../../../resources/styles";
|
||||
import type { HomeAssistant, Route } from "../../../../../types";
|
||||
import { formatAsPaddedHex, sortZHAGroups } from "./functions";
|
||||
import { zhaTabs } from "./zha-config-dashboard";
|
||||
|
||||
const groupsTab: PageNavigation[] = [
|
||||
{
|
||||
translationKey: "ui.panel.config.zha.groups.caption",
|
||||
path: "/config/zha/groups",
|
||||
iconPath: mdiFolderMultipleOutline,
|
||||
},
|
||||
];
|
||||
|
||||
export interface GroupRowData extends ZHAGroup {
|
||||
group?: GroupRowData;
|
||||
@@ -100,7 +108,8 @@ export class ZHAGroupsDashboard extends LitElement {
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<hass-tabs-subpage-data-table
|
||||
.tabs=${zhaTabs}
|
||||
.tabs=${groupsTab}
|
||||
back-path="/config/zha/dashboard"
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.route=${this.route}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { getDeviceContext } from "../../../../../common/entity/context/get_device_context";
|
||||
import type {
|
||||
NetworkData,
|
||||
NetworkLink,
|
||||
NetworkNode,
|
||||
} from "../../../../../components/chart/ha-network-graph";
|
||||
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
|
||||
import type { ZHADevice } from "../../../../../data/zha";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
|
||||
function getLQIWidth(lqi: number): number {
|
||||
return lqi > 200 ? 3 : lqi > 100 ? 2 : 1;
|
||||
}
|
||||
|
||||
export function createZHANetworkChartData(
|
||||
devices: ZHADevice[],
|
||||
hass: HomeAssistant,
|
||||
element: Element
|
||||
): NetworkData {
|
||||
const style = getComputedStyle(element);
|
||||
|
||||
const primaryColor = style.getPropertyValue("--primary-color");
|
||||
const routerColor = style.getPropertyValue("--cyan-color");
|
||||
const endDeviceColor = style.getPropertyValue("--teal-color");
|
||||
const offlineColor = style.getPropertyValue("--error-color");
|
||||
const nodes: NetworkNode[] = [];
|
||||
const links: NetworkLink[] = [];
|
||||
const categories = [
|
||||
{
|
||||
name: hass.localize("ui.panel.config.zha.visualization.coordinator"),
|
||||
symbol: "roundRect",
|
||||
itemStyle: { color: primaryColor },
|
||||
},
|
||||
{
|
||||
name: hass.localize("ui.panel.config.zha.visualization.router"),
|
||||
symbol: "circle",
|
||||
itemStyle: { color: routerColor },
|
||||
},
|
||||
{
|
||||
name: hass.localize("ui.panel.config.zha.visualization.end_device"),
|
||||
symbol: "circle",
|
||||
itemStyle: { color: endDeviceColor },
|
||||
},
|
||||
{
|
||||
name: hass.localize("ui.panel.config.zha.visualization.offline"),
|
||||
symbol: "circle",
|
||||
itemStyle: { color: offlineColor },
|
||||
},
|
||||
];
|
||||
|
||||
// Create all the nodes and links
|
||||
devices.forEach((device) => {
|
||||
const isCoordinator = device.device_type === "Coordinator";
|
||||
let category: number;
|
||||
if (!device.available) {
|
||||
category = 3; // Offline
|
||||
} else if (isCoordinator) {
|
||||
category = 0;
|
||||
} else if (device.device_type === "Router") {
|
||||
category = 1;
|
||||
} else {
|
||||
category = 2; // End Device
|
||||
}
|
||||
|
||||
const haDevice = hass.devices[device.device_reg_id] as
|
||||
| DeviceRegistryEntry
|
||||
| undefined;
|
||||
const area = haDevice ? getDeviceContext(haDevice, hass).area : undefined;
|
||||
// Create node
|
||||
nodes.push({
|
||||
id: device.ieee,
|
||||
name: device.user_given_name || device.name || device.ieee,
|
||||
context: area?.name,
|
||||
category,
|
||||
value: isCoordinator ? 3 : device.device_type === "Router" ? 2 : 1,
|
||||
symbolSize: isCoordinator
|
||||
? 40
|
||||
: device.device_type === "Router"
|
||||
? 30
|
||||
: 20,
|
||||
symbol: isCoordinator ? "roundRect" : "circle",
|
||||
itemStyle: {
|
||||
color: device.available
|
||||
? isCoordinator
|
||||
? primaryColor
|
||||
: device.device_type === "Router"
|
||||
? routerColor
|
||||
: endDeviceColor
|
||||
: offlineColor,
|
||||
},
|
||||
polarDistance: category === 0 ? 0 : category === 1 ? 0.5 : 0.9,
|
||||
fixed: isCoordinator,
|
||||
});
|
||||
|
||||
// Create links (edges)
|
||||
const existingLinks = links.filter(
|
||||
(link) => link.source === device.ieee || link.target === device.ieee
|
||||
);
|
||||
if (device.routes && device.routes.length > 0) {
|
||||
device.routes.forEach((route) => {
|
||||
const neighbor = device.neighbors.find((n) => n.nwk === route.next_hop);
|
||||
if (!neighbor) {
|
||||
return;
|
||||
}
|
||||
const existingLink = existingLinks.find(
|
||||
(link) =>
|
||||
link.source === neighbor.ieee || link.target === neighbor.ieee
|
||||
);
|
||||
|
||||
if (existingLink) {
|
||||
if (existingLink.source === device.ieee) {
|
||||
existingLink.value = Math.max(
|
||||
existingLink.value!,
|
||||
parseInt(neighbor.lqi)
|
||||
);
|
||||
} else {
|
||||
existingLink.reverseValue = Math.max(
|
||||
existingLink.reverseValue ?? 0,
|
||||
parseInt(neighbor.lqi)
|
||||
);
|
||||
}
|
||||
const width = getLQIWidth(parseInt(neighbor.lqi));
|
||||
existingLink.symbolSize = (width / 4) * 6 + 3; // range 3-9
|
||||
existingLink.lineStyle = {
|
||||
...existingLink.lineStyle,
|
||||
width,
|
||||
color:
|
||||
route.route_status === "Active"
|
||||
? primaryColor
|
||||
: existingLink.lineStyle!.color,
|
||||
type: ["Child", "Parent"].includes(neighbor.relationship)
|
||||
? "solid"
|
||||
: existingLink.lineStyle!.type,
|
||||
};
|
||||
} else {
|
||||
// Create a new link
|
||||
const width = getLQIWidth(parseInt(neighbor.lqi));
|
||||
const link: NetworkLink = {
|
||||
source: device.ieee,
|
||||
target: neighbor.ieee,
|
||||
value: parseInt(neighbor.lqi),
|
||||
lineStyle: {
|
||||
width,
|
||||
color:
|
||||
route.route_status === "Active"
|
||||
? primaryColor
|
||||
: style.getPropertyValue("--dark-primary-color"),
|
||||
type: ["Child", "Parent"].includes(neighbor.relationship)
|
||||
? "solid"
|
||||
: "dotted",
|
||||
},
|
||||
symbolSize: (width / 4) * 6 + 3, // range 3-9
|
||||
// By default, all links should be ignored for force layout
|
||||
// unless it's a route to the coordinator
|
||||
ignoreForceLayout: route.dest_nwk !== "0x0000",
|
||||
};
|
||||
links.push(link);
|
||||
existingLinks.push(link);
|
||||
}
|
||||
});
|
||||
} else if (existingLinks.length === 0) {
|
||||
// If there are no links, create a link to the closest neighbor
|
||||
const neighbors: { ieee: string; lqi: string }[] = device.neighbors ?? [];
|
||||
if (neighbors.length === 0) {
|
||||
// If there are no neighbors, look for links from other devices
|
||||
devices.forEach((d) => {
|
||||
if (d.neighbors && d.neighbors.length > 0) {
|
||||
const neighbor = d.neighbors.find((n) => n.ieee === device.ieee);
|
||||
if (neighbor) {
|
||||
neighbors.push({ ieee: d.ieee, lqi: neighbor.lqi });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
const closestNeighbor = neighbors.sort(
|
||||
(a, b) => parseInt(b.lqi) - parseInt(a.lqi)
|
||||
)[0];
|
||||
if (closestNeighbor) {
|
||||
links.push({
|
||||
source: device.ieee,
|
||||
target: closestNeighbor.ieee,
|
||||
value: parseInt(closestNeighbor.lqi),
|
||||
symbolSize: 5,
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: style.getPropertyValue("--dark-primary-color"),
|
||||
type: "dotted",
|
||||
},
|
||||
ignoreForceLayout: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Now set ignoreForceLayout to false for the best connection of each device
|
||||
// Except for the coordinator which can have multiple strong connections
|
||||
devices.forEach((device) => {
|
||||
if (device.device_type === "Coordinator") {
|
||||
links.forEach((link) => {
|
||||
if (link.source === device.ieee || link.target === device.ieee) {
|
||||
link.ignoreForceLayout = false;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Find the link that corresponds to this strongest connection
|
||||
let bestLink: NetworkLink | undefined;
|
||||
const alreadyHasBestLink = links.some((link) => {
|
||||
if (link.source === device.ieee || link.target === device.ieee) {
|
||||
if (!link.ignoreForceLayout) {
|
||||
return true;
|
||||
}
|
||||
if (link.value! > (bestLink?.value ?? -1)) {
|
||||
bestLink = link;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!alreadyHasBestLink && bestLink) {
|
||||
bestLink.ignoreForceLayout = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { nodes, links, categories };
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { mdiPencil } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import "../../../../../components/ha-card";
|
||||
import "../../../../../components/ha-icon-button";
|
||||
import "../../../../../components/ha-md-list";
|
||||
import "../../../../../components/ha-md-list-item";
|
||||
import type { ZHANetworkSettings } from "../../../../../data/zha";
|
||||
import { fetchZHANetworkSettings } from "../../../../../data/zha";
|
||||
import { showAlertDialog } from "../../../../../dialogs/generic/show-dialog-box";
|
||||
import "../../../../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../../../../resources/styles";
|
||||
import type { HomeAssistant, Route } from "../../../../../types";
|
||||
import { showZHAChangeChannelDialog } from "./show-dialog-zha-change-channel";
|
||||
|
||||
const MULTIPROTOCOL_ADDON_URL = "socket://core-silabs-multiprotocol:9999";
|
||||
|
||||
@customElement("zha-network-info-page")
|
||||
class ZHANetworkInfoPage 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 _networkSettings?: ZHANetworkSettings;
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues) {
|
||||
super.firstUpdated(changedProperties);
|
||||
if (this.hass) {
|
||||
this._fetchSettings();
|
||||
}
|
||||
}
|
||||
|
||||
private async _fetchSettings(): Promise<void> {
|
||||
this._networkSettings = await fetchZHANetworkSettings(this.hass!);
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.network_info_title"
|
||||
)}
|
||||
back-path="/config/zha/dashboard"
|
||||
>
|
||||
<div class="container">
|
||||
<ha-card>
|
||||
${this._networkSettings
|
||||
? html`<ha-md-list>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.channel_label"
|
||||
)}</span
|
||||
>
|
||||
<span slot="supporting-text"
|
||||
>${this._networkSettings.settings.network_info
|
||||
.channel}</span
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="end"
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.change_channel"
|
||||
)}
|
||||
.path=${mdiPencil}
|
||||
@click=${this._showChannelMigrationDialog}
|
||||
></ha-icon-button>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline">PAN ID</span>
|
||||
<span slot="supporting-text"
|
||||
>${this._networkSettings.settings.network_info
|
||||
.pan_id}</span
|
||||
>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline">Extended PAN ID</span>
|
||||
<span slot="supporting-text"
|
||||
>${this._networkSettings.settings.network_info
|
||||
.extended_pan_id}</span
|
||||
>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline">Coordinator IEEE</span>
|
||||
<span slot="supporting-text"
|
||||
>${this._networkSettings.settings.node_info.ieee}</span
|
||||
>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.radio_type"
|
||||
)}</span
|
||||
>
|
||||
<span slot="supporting-text"
|
||||
>${this._networkSettings.radio_type}</span
|
||||
>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.serial_port"
|
||||
)}</span
|
||||
>
|
||||
<span slot="supporting-text"
|
||||
>${this._networkSettings.device.path}</span
|
||||
>
|
||||
</ha-md-list-item>
|
||||
${this._networkSettings.device.baudrate &&
|
||||
!this._networkSettings.device.path.startsWith("socket://")
|
||||
? html`
|
||||
<ha-md-list-item>
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.baudrate"
|
||||
)}</span
|
||||
>
|
||||
<span slot="supporting-text"
|
||||
>${this._networkSettings.device.baudrate}</span
|
||||
>
|
||||
</ha-md-list-item>
|
||||
`
|
||||
: nothing}
|
||||
</ha-md-list>`
|
||||
: nothing}
|
||||
</ha-card>
|
||||
</div>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _showChannelMigrationDialog(): Promise<void> {
|
||||
if (this._networkSettings!.device.path === MULTIPROTOCOL_ADDON_URL) {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.channel_dialog.title"
|
||||
),
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.channel_dialog.text"
|
||||
),
|
||||
warning: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
showZHAChangeChannelDialog(this, {
|
||||
currentChannel: this._networkSettings!.settings.network_info.channel,
|
||||
});
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
css`
|
||||
.container {
|
||||
padding: var(--ha-space-2) var(--ha-space-4) var(--ha-space-4);
|
||||
}
|
||||
|
||||
ha-card {
|
||||
max-width: 600px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
ha-md-list {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
ha-md-list-item {
|
||||
--md-item-overflow: visible;
|
||||
--md-list-item-supporting-text-size: var(
|
||||
--md-list-item-label-text-size,
|
||||
var(--md-sys-typescale-body-large-size, 1rem)
|
||||
);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"zha-network-info-page": ZHANetworkInfoPage;
|
||||
}
|
||||
}
|
||||
@@ -9,18 +9,14 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { getDeviceContext } from "../../../../../common/entity/context/get_device_context";
|
||||
import { navigate } from "../../../../../common/navigate";
|
||||
import "../../../../../components/chart/ha-network-graph";
|
||||
import type {
|
||||
NetworkData,
|
||||
NetworkLink,
|
||||
NetworkNode,
|
||||
} from "../../../../../components/chart/ha-network-graph";
|
||||
import type { NetworkData } from "../../../../../components/chart/ha-network-graph";
|
||||
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
|
||||
import type { ZHADevice } from "../../../../../data/zha";
|
||||
import { fetchDevices, refreshTopology } from "../../../../../data/zha";
|
||||
import "../../../../../layouts/hass-tabs-subpage";
|
||||
import "../../../../../layouts/hass-subpage";
|
||||
import type { HomeAssistant, Route } from "../../../../../types";
|
||||
import { formatAsPaddedHex } from "./functions";
|
||||
import { zhaTabs } from "./zha-config-dashboard";
|
||||
import { createZHANetworkChartData } from "./zha-network-data";
|
||||
|
||||
@customElement("zha-network-visualization-page")
|
||||
export class ZHANetworkVisualizationPage extends LitElement {
|
||||
@@ -52,13 +48,12 @@ export class ZHANetworkVisualizationPage extends LitElement {
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<hass-tabs-subpage
|
||||
.tabs=${zhaTabs}
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.isWide=${this.isWide}
|
||||
.route=${this.route}
|
||||
header=${this.hass.localize("ui.panel.config.zha.visualization.header")}
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.zha.visualization.header"
|
||||
)}
|
||||
>
|
||||
<ha-network-graph
|
||||
.hass=${this.hass}
|
||||
@@ -76,13 +71,17 @@ export class ZHANetworkVisualizationPage extends LitElement {
|
||||
)}
|
||||
></ha-icon-button>
|
||||
</ha-network-graph>
|
||||
</hass-tabs-subpage>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _fetchData() {
|
||||
this._devices = await fetchDevices(this.hass!);
|
||||
this._networkData = this._createChartData(this._devices);
|
||||
this._networkData = createZHANetworkChartData(
|
||||
this._devices,
|
||||
this.hass,
|
||||
this
|
||||
);
|
||||
}
|
||||
|
||||
private _tooltipFormatter = (params: TopLevelFormatterParams): string => {
|
||||
@@ -158,228 +157,6 @@ export class ZHANetworkVisualizationPage extends LitElement {
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
private _createChartData(devices: ZHADevice[]): NetworkData {
|
||||
const style = getComputedStyle(this);
|
||||
|
||||
const primaryColor = style.getPropertyValue("--primary-color");
|
||||
const routerColor = style.getPropertyValue("--cyan-color");
|
||||
const endDeviceColor = style.getPropertyValue("--teal-color");
|
||||
const offlineColor = style.getPropertyValue("--error-color");
|
||||
const nodes: NetworkNode[] = [];
|
||||
const links: NetworkLink[] = [];
|
||||
const categories = [
|
||||
{
|
||||
name: this.hass.localize(
|
||||
"ui.panel.config.zha.visualization.coordinator"
|
||||
),
|
||||
symbol: "roundRect",
|
||||
itemStyle: { color: primaryColor },
|
||||
},
|
||||
{
|
||||
name: this.hass.localize("ui.panel.config.zha.visualization.router"),
|
||||
symbol: "circle",
|
||||
itemStyle: { color: routerColor },
|
||||
},
|
||||
{
|
||||
name: this.hass.localize(
|
||||
"ui.panel.config.zha.visualization.end_device"
|
||||
),
|
||||
symbol: "circle",
|
||||
itemStyle: { color: endDeviceColor },
|
||||
},
|
||||
{
|
||||
name: this.hass.localize("ui.panel.config.zha.visualization.offline"),
|
||||
symbol: "circle",
|
||||
itemStyle: { color: offlineColor },
|
||||
},
|
||||
];
|
||||
|
||||
// Create all the nodes and links
|
||||
devices.forEach((device) => {
|
||||
const isCoordinator = device.device_type === "Coordinator";
|
||||
let category: number;
|
||||
if (!device.available) {
|
||||
category = 3; // Offline
|
||||
} else if (isCoordinator) {
|
||||
category = 0;
|
||||
} else if (device.device_type === "Router") {
|
||||
category = 1;
|
||||
} else {
|
||||
category = 2; // End Device
|
||||
}
|
||||
|
||||
const haDevice = this.hass.devices[device.device_reg_id] as
|
||||
| DeviceRegistryEntry
|
||||
| undefined;
|
||||
const area = haDevice
|
||||
? getDeviceContext(haDevice, this.hass).area
|
||||
: undefined;
|
||||
// Create node
|
||||
nodes.push({
|
||||
id: device.ieee,
|
||||
name: device.user_given_name || device.name || device.ieee,
|
||||
context: area?.name,
|
||||
category,
|
||||
value: isCoordinator ? 3 : device.device_type === "Router" ? 2 : 1,
|
||||
symbolSize: isCoordinator
|
||||
? 40
|
||||
: device.device_type === "Router"
|
||||
? 30
|
||||
: 20,
|
||||
symbol: isCoordinator ? "roundRect" : "circle",
|
||||
itemStyle: {
|
||||
color: device.available
|
||||
? isCoordinator
|
||||
? primaryColor
|
||||
: device.device_type === "Router"
|
||||
? routerColor
|
||||
: endDeviceColor
|
||||
: offlineColor,
|
||||
},
|
||||
polarDistance: category === 0 ? 0 : category === 1 ? 0.5 : 0.9,
|
||||
fixed: isCoordinator,
|
||||
});
|
||||
|
||||
// Create links (edges)
|
||||
const existingLinks = links.filter(
|
||||
(link) => link.source === device.ieee || link.target === device.ieee
|
||||
);
|
||||
if (device.routes && device.routes.length > 0) {
|
||||
device.routes.forEach((route) => {
|
||||
const neighbor = device.neighbors.find(
|
||||
(n) => n.nwk === route.next_hop
|
||||
);
|
||||
if (!neighbor) {
|
||||
return;
|
||||
}
|
||||
const existingLink = existingLinks.find(
|
||||
(link) =>
|
||||
link.source === neighbor.ieee || link.target === neighbor.ieee
|
||||
);
|
||||
|
||||
if (existingLink) {
|
||||
if (existingLink.source === device.ieee) {
|
||||
existingLink.value = Math.max(
|
||||
existingLink.value!,
|
||||
parseInt(neighbor.lqi)
|
||||
);
|
||||
} else {
|
||||
existingLink.reverseValue = Math.max(
|
||||
existingLink.reverseValue ?? 0,
|
||||
parseInt(neighbor.lqi)
|
||||
);
|
||||
}
|
||||
const width = this._getLQIWidth(parseInt(neighbor.lqi));
|
||||
existingLink.symbolSize = (width / 4) * 6 + 3; // range 3-9
|
||||
existingLink.lineStyle = {
|
||||
...existingLink.lineStyle,
|
||||
width,
|
||||
color:
|
||||
route.route_status === "Active"
|
||||
? primaryColor
|
||||
: existingLink.lineStyle!.color,
|
||||
type: ["Child", "Parent"].includes(neighbor.relationship)
|
||||
? "solid"
|
||||
: existingLink.lineStyle!.type,
|
||||
};
|
||||
} else {
|
||||
// Create a new link
|
||||
const width = this._getLQIWidth(parseInt(neighbor.lqi));
|
||||
const link: NetworkLink = {
|
||||
source: device.ieee,
|
||||
target: neighbor.ieee,
|
||||
value: parseInt(neighbor.lqi),
|
||||
lineStyle: {
|
||||
width,
|
||||
color:
|
||||
route.route_status === "Active"
|
||||
? primaryColor
|
||||
: style.getPropertyValue("--dark-primary-color"),
|
||||
type: ["Child", "Parent"].includes(neighbor.relationship)
|
||||
? "solid"
|
||||
: "dotted",
|
||||
},
|
||||
symbolSize: (width / 4) * 6 + 3, // range 3-9
|
||||
// By default, all links should be ignored for force layout
|
||||
// unless it's a route to the coordinator
|
||||
ignoreForceLayout: route.dest_nwk !== "0x0000",
|
||||
};
|
||||
links.push(link);
|
||||
existingLinks.push(link);
|
||||
}
|
||||
});
|
||||
} else if (existingLinks.length === 0) {
|
||||
// If there are no links, create a link to the closest neighbor
|
||||
const neighbors: { ieee: string; lqi: string }[] =
|
||||
device.neighbors ?? [];
|
||||
if (neighbors.length === 0) {
|
||||
// If there are no neighbors, look for links from other devices
|
||||
devices.forEach((d) => {
|
||||
if (d.neighbors && d.neighbors.length > 0) {
|
||||
const neighbor = d.neighbors.find((n) => n.ieee === device.ieee);
|
||||
if (neighbor) {
|
||||
neighbors.push({ ieee: d.ieee, lqi: neighbor.lqi });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
const closestNeighbor = neighbors.sort(
|
||||
(a, b) => parseInt(b.lqi) - parseInt(a.lqi)
|
||||
)[0];
|
||||
if (closestNeighbor) {
|
||||
links.push({
|
||||
source: device.ieee,
|
||||
target: closestNeighbor.ieee,
|
||||
value: parseInt(closestNeighbor.lqi),
|
||||
symbolSize: 5,
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: style.getPropertyValue("--dark-primary-color"),
|
||||
type: "dotted",
|
||||
},
|
||||
ignoreForceLayout: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Now set ignoreForceLayout to false for the best connection of each device
|
||||
// Except for the coordinator which can have multiple strong connections
|
||||
devices.forEach((device) => {
|
||||
if (device.device_type === "Coordinator") {
|
||||
links.forEach((link) => {
|
||||
if (link.source === device.ieee || link.target === device.ieee) {
|
||||
link.ignoreForceLayout = false;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Find the link that corresponds to this strongest connection
|
||||
let bestLink: NetworkLink | undefined;
|
||||
const alreadyHasBestLink = links.some((link) => {
|
||||
if (link.source === device.ieee || link.target === device.ieee) {
|
||||
if (!link.ignoreForceLayout) {
|
||||
return true;
|
||||
}
|
||||
if (link.value! > (bestLink?.value ?? -1)) {
|
||||
bestLink = link;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!alreadyHasBestLink && bestLink) {
|
||||
bestLink.ignoreForceLayout = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { nodes, links, categories };
|
||||
}
|
||||
|
||||
private _getLQIWidth(lqi: number): number {
|
||||
return lqi > 200 ? 3 : lqi > 100 ? 2 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import "../../../../../components/buttons/ha-progress-button";
|
||||
import "../../../../../components/ha-card";
|
||||
import "../../../../../components/ha-md-list";
|
||||
import "../../../../../components/ha-md-list-item";
|
||||
import "../../../../../components/ha-select";
|
||||
import "../../../../../components/ha-textfield";
|
||||
import "../../../../../components/ha-switch";
|
||||
import type { ZHAConfiguration } from "../../../../../data/zha";
|
||||
import {
|
||||
fetchZHAConfiguration,
|
||||
updateZHAConfiguration,
|
||||
} from "../../../../../data/zha";
|
||||
import "../../../../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../../../../resources/styles";
|
||||
import type { HomeAssistant, Route } from "../../../../../types";
|
||||
|
||||
const PREDEFINED_TIMEOUTS = [1800, 3600, 7200, 21600, 43200, 86400];
|
||||
|
||||
@customElement("zha-options-page")
|
||||
class ZHAOptionsPage 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 _configuration?: ZHAConfiguration;
|
||||
|
||||
@state() private _customMains = false;
|
||||
|
||||
@state() private _customBattery = false;
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues) {
|
||||
super.firstUpdated(changedProperties);
|
||||
if (this.hass) {
|
||||
this._fetchConfiguration();
|
||||
}
|
||||
}
|
||||
|
||||
private async _fetchConfiguration(): Promise<void> {
|
||||
this._configuration = await fetchZHAConfiguration(this.hass!);
|
||||
const mainsValue = this._configuration.data.zha_options
|
||||
?.consider_unavailable_mains as number | undefined;
|
||||
const batteryValue = this._configuration.data.zha_options
|
||||
?.consider_unavailable_battery as number | undefined;
|
||||
this._customMains =
|
||||
mainsValue !== undefined && !PREDEFINED_TIMEOUTS.includes(mainsValue);
|
||||
this._customBattery =
|
||||
batteryValue !== undefined && !PREDEFINED_TIMEOUTS.includes(batteryValue);
|
||||
}
|
||||
|
||||
private _getUnavailableTimeoutOptions(defaultSeconds: number) {
|
||||
const defaultLabel = ` (${this.hass.localize("ui.panel.config.zha.configuration_page.timeout_default")})`;
|
||||
const options: { value: string; seconds: number; key: string }[] = [
|
||||
{ value: "1800", seconds: 1800, key: "timeout_30_min" },
|
||||
{ value: "3600", seconds: 3600, key: "timeout_1_hour" },
|
||||
{ value: "7200", seconds: 7200, key: "timeout_2_hours" },
|
||||
{ value: "21600", seconds: 21600, key: "timeout_6_hours" },
|
||||
{ value: "43200", seconds: 43200, key: "timeout_12_hours" },
|
||||
{ value: "86400", seconds: 86400, key: "timeout_24_hours" },
|
||||
];
|
||||
return [
|
||||
...options.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: this.hass.localize(
|
||||
`ui.panel.config.zha.configuration_page.${opt.key}`,
|
||||
{ default: opt.seconds === defaultSeconds ? defaultLabel : "" }
|
||||
),
|
||||
})),
|
||||
{
|
||||
value: "custom",
|
||||
label: this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.timeout_custom"
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private _getUnavailableDropdownValue(
|
||||
seconds: unknown,
|
||||
isCustom: boolean
|
||||
): string {
|
||||
if (isCustom) {
|
||||
return "custom";
|
||||
}
|
||||
const value = (seconds as number) ?? 7200;
|
||||
if (PREDEFINED_TIMEOUTS.includes(value)) {
|
||||
return String(value);
|
||||
}
|
||||
return "custom";
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.options_title"
|
||||
)}
|
||||
back-path="/config/zha/dashboard"
|
||||
>
|
||||
<div class="container">
|
||||
<ha-card>
|
||||
${this._configuration
|
||||
? html`
|
||||
<ha-md-list>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.enable_identify_on_join_label"
|
||||
)}</span
|
||||
>
|
||||
<span slot="supporting-text"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.enable_identify_on_join_description"
|
||||
)}</span
|
||||
>
|
||||
<ha-switch
|
||||
slot="end"
|
||||
.checked=${(this._configuration.data.zha_options
|
||||
?.enable_identify_on_join as boolean) ?? true}
|
||||
@change=${this._enableIdentifyOnJoinChanged}
|
||||
></ha-switch>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.default_light_transition_label"
|
||||
)}</span
|
||||
>
|
||||
<span slot="supporting-text"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.default_light_transition_description"
|
||||
)}</span
|
||||
>
|
||||
<ha-textfield
|
||||
slot="end"
|
||||
type="number"
|
||||
.value=${String(
|
||||
(this._configuration.data.zha_options
|
||||
?.default_light_transition as number) ?? 0
|
||||
)}
|
||||
.suffix=${"s"}
|
||||
.min=${0}
|
||||
.step=${0.5}
|
||||
@change=${this._defaultLightTransitionChanged}
|
||||
></ha-textfield>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.enhanced_light_transition_label"
|
||||
)}</span
|
||||
>
|
||||
<span slot="supporting-text"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.enhanced_light_transition_description"
|
||||
)}</span
|
||||
>
|
||||
<ha-switch
|
||||
slot="end"
|
||||
.checked=${(this._configuration.data.zha_options
|
||||
?.enhanced_light_transition as boolean) ?? false}
|
||||
@change=${this._enhancedLightTransitionChanged}
|
||||
></ha-switch>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.light_transitioning_flag_label"
|
||||
)}</span
|
||||
>
|
||||
<span slot="supporting-text"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.light_transitioning_flag_description"
|
||||
)}</span
|
||||
>
|
||||
<ha-switch
|
||||
slot="end"
|
||||
.checked=${(this._configuration.data.zha_options
|
||||
?.light_transitioning_flag as boolean) ?? true}
|
||||
@change=${this._lightTransitioningFlagChanged}
|
||||
></ha-switch>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.group_members_assume_state_label"
|
||||
)}</span
|
||||
>
|
||||
<span slot="supporting-text"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.group_members_assume_state_description"
|
||||
)}</span
|
||||
>
|
||||
<ha-switch
|
||||
slot="end"
|
||||
.checked=${(this._configuration.data.zha_options
|
||||
?.group_members_assume_state as boolean) ?? true}
|
||||
@change=${this._groupMembersAssumeStateChanged}
|
||||
></ha-switch>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.consider_unavailable_mains_label"
|
||||
)}</span
|
||||
>
|
||||
<span slot="supporting-text"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.consider_unavailable_mains_description"
|
||||
)}</span
|
||||
>
|
||||
<ha-select
|
||||
slot="end"
|
||||
.value=${this._getUnavailableDropdownValue(
|
||||
this._configuration.data.zha_options
|
||||
?.consider_unavailable_mains,
|
||||
this._customMains
|
||||
)}
|
||||
.options=${this._getUnavailableTimeoutOptions(7200)}
|
||||
@selected=${this._mainsUnavailableChanged}
|
||||
></ha-select>
|
||||
</ha-md-list-item>
|
||||
${this._customMains
|
||||
? html`
|
||||
<ha-md-list-item>
|
||||
<ha-textfield
|
||||
slot="end"
|
||||
type="number"
|
||||
.value=${String(
|
||||
(this._configuration.data.zha_options
|
||||
?.consider_unavailable_mains as number) ??
|
||||
7200
|
||||
)}
|
||||
.suffix=${"s"}
|
||||
.min=${1}
|
||||
.step=${1}
|
||||
@change=${this._customMainsSecondsChanged}
|
||||
></ha-textfield>
|
||||
</ha-md-list-item>
|
||||
`
|
||||
: nothing}
|
||||
<ha-md-list-item>
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.consider_unavailable_battery_label"
|
||||
)}</span
|
||||
>
|
||||
<span slot="supporting-text"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.consider_unavailable_battery_description"
|
||||
)}</span
|
||||
>
|
||||
<ha-select
|
||||
slot="end"
|
||||
.value=${this._getUnavailableDropdownValue(
|
||||
this._configuration.data.zha_options
|
||||
?.consider_unavailable_battery,
|
||||
this._customBattery
|
||||
)}
|
||||
.options=${this._getUnavailableTimeoutOptions(21600)}
|
||||
@selected=${this._batteryUnavailableChanged}
|
||||
></ha-select>
|
||||
</ha-md-list-item>
|
||||
${this._customBattery
|
||||
? html`
|
||||
<ha-md-list-item>
|
||||
<ha-textfield
|
||||
slot="end"
|
||||
type="number"
|
||||
.value=${String(
|
||||
(this._configuration.data.zha_options
|
||||
?.consider_unavailable_battery as number) ??
|
||||
21600
|
||||
)}
|
||||
.suffix=${"s"}
|
||||
.min=${1}
|
||||
.step=${1}
|
||||
@change=${this._customBatterySecondsChanged}
|
||||
></ha-textfield>
|
||||
</ha-md-list-item>
|
||||
`
|
||||
: nothing}
|
||||
<ha-md-list-item>
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.enable_mains_startup_polling_label"
|
||||
)}</span
|
||||
>
|
||||
<span slot="supporting-text"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.enable_mains_startup_polling_description"
|
||||
)}</span
|
||||
>
|
||||
<ha-switch
|
||||
slot="end"
|
||||
.checked=${(this._configuration.data.zha_options
|
||||
?.enable_mains_startup_polling as boolean) ?? true}
|
||||
@change=${this._enableMainsStartupPollingChanged}
|
||||
></ha-switch>
|
||||
</ha-md-list-item>
|
||||
</ha-md-list>
|
||||
<div class="card-actions">
|
||||
<ha-progress-button
|
||||
appearance="filled"
|
||||
variant="brand"
|
||||
@click=${this._updateConfiguration}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.update_button"
|
||||
)}
|
||||
</ha-progress-button>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</ha-card>
|
||||
</div>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
private _enableIdentifyOnJoinChanged(ev: Event): void {
|
||||
const checked = (ev.target as HTMLInputElement).checked;
|
||||
this._configuration!.data.zha_options.enable_identify_on_join = checked;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private _enhancedLightTransitionChanged(ev: Event): void {
|
||||
const checked = (ev.target as HTMLInputElement).checked;
|
||||
this._configuration!.data.zha_options.enhanced_light_transition = checked;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private _lightTransitioningFlagChanged(ev: Event): void {
|
||||
const checked = (ev.target as HTMLInputElement).checked;
|
||||
this._configuration!.data.zha_options.light_transitioning_flag = checked;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private _groupMembersAssumeStateChanged(ev: Event): void {
|
||||
const checked = (ev.target as HTMLInputElement).checked;
|
||||
this._configuration!.data.zha_options.group_members_assume_state = checked;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private _enableMainsStartupPollingChanged(ev: Event): void {
|
||||
const checked = (ev.target as HTMLInputElement).checked;
|
||||
this._configuration!.data.zha_options.enable_mains_startup_polling =
|
||||
checked;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private _defaultLightTransitionChanged(ev: Event): void {
|
||||
const value = Number((ev.target as HTMLInputElement).value);
|
||||
this._configuration!.data.zha_options.default_light_transition = value;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private _customMainsSecondsChanged(ev: Event): void {
|
||||
const seconds = Number((ev.target as HTMLInputElement).value);
|
||||
this._configuration!.data.zha_options.consider_unavailable_mains = seconds;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private _customBatterySecondsChanged(ev: Event): void {
|
||||
const seconds = Number((ev.target as HTMLInputElement).value);
|
||||
this._configuration!.data.zha_options.consider_unavailable_battery =
|
||||
seconds;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private _mainsUnavailableChanged(ev: CustomEvent): void {
|
||||
const value = ev.detail.value;
|
||||
if (value === "custom") {
|
||||
this._customMains = true;
|
||||
} else {
|
||||
this._customMains = false;
|
||||
this._configuration!.data.zha_options.consider_unavailable_mains =
|
||||
Number(value);
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private _batteryUnavailableChanged(ev: CustomEvent): void {
|
||||
const value = ev.detail.value;
|
||||
if (value === "custom") {
|
||||
this._customBattery = true;
|
||||
} else {
|
||||
this._customBattery = false;
|
||||
this._configuration!.data.zha_options.consider_unavailable_battery =
|
||||
Number(value);
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private async _updateConfiguration(ev: Event): Promise<void> {
|
||||
const button = ev.currentTarget as HTMLElement & {
|
||||
progress: boolean;
|
||||
actionSuccess: () => void;
|
||||
actionError: () => void;
|
||||
};
|
||||
button.progress = true;
|
||||
try {
|
||||
await updateZHAConfiguration(this.hass!, this._configuration!.data);
|
||||
button.actionSuccess();
|
||||
} catch (_err: any) {
|
||||
button.actionError();
|
||||
} finally {
|
||||
button.progress = false;
|
||||
}
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
css`
|
||||
.container {
|
||||
padding: var(--ha-space-2) var(--ha-space-4) var(--ha-space-4);
|
||||
}
|
||||
|
||||
ha-card {
|
||||
max-width: 600px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
ha-md-list {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
ha-md-list-item {
|
||||
--md-item-overflow: visible;
|
||||
}
|
||||
|
||||
ha-select,
|
||||
ha-textfield {
|
||||
min-width: 210px;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media all and (max-width: 450px) {
|
||||
ha-select,
|
||||
ha-textfield {
|
||||
min-width: 160px;
|
||||
width: 160px;
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"zha-options-page": ZHAOptionsPage;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { goBack } from "../../../../../common/navigate";
|
||||
import "../../../../../components/ha-button";
|
||||
import "../../../../../components/ha-card";
|
||||
import "../../../../../components/ha-fab";
|
||||
@@ -28,7 +29,6 @@ import "../../../../../components/ha-md-list-item";
|
||||
import "../../../../../components/ha-progress-ring";
|
||||
import "../../../../../components/ha-spinner";
|
||||
import "../../../../../components/ha-svg-icon";
|
||||
import { goBack } from "../../../../../common/navigate";
|
||||
import type { ConfigEntry } from "../../../../../data/config_entries";
|
||||
import {
|
||||
ERROR_STATES,
|
||||
@@ -144,6 +144,7 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.zwave_js.navigation.general"
|
||||
)}
|
||||
back-path="/config"
|
||||
has-fab
|
||||
>
|
||||
<ha-icon-button
|
||||
@@ -967,7 +968,8 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: var(--ha-space-2) var(--ha-space-4) var(--ha-space-4);
|
||||
padding: var(--ha-space-2) var(--ha-space-4)
|
||||
calc(var(--ha-space-16) + var(--safe-area-inset-bottom, 0px));
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -147,11 +147,10 @@ export class HaConfigLabels extends LitElement {
|
||||
created_at: getCreatedAtTableColumn(localize, this.hass),
|
||||
modified_at: getModifiedAtTableColumn(localize, this.hass),
|
||||
actions: {
|
||||
lastFixed: true,
|
||||
title: "",
|
||||
label: localize("ui.panel.config.generic.headers.actions"),
|
||||
showNarrow: true,
|
||||
moveable: false,
|
||||
hideable: false,
|
||||
type: "overflow-menu",
|
||||
template: (label) => html`
|
||||
<ha-icon-button
|
||||
|
||||
@@ -102,11 +102,15 @@ export class DialogLovelaceDashboardDetail extends LitElement {
|
||||
: html`
|
||||
<ha-form
|
||||
autofocus
|
||||
.schema=${this._schema(this._params)}
|
||||
.schema=${this._schema(
|
||||
this._params,
|
||||
this._data?.require_admin
|
||||
)}
|
||||
.data=${this._data}
|
||||
.hass=${this.hass}
|
||||
.error=${this._error}
|
||||
.computeLabel=${this._computeLabel}
|
||||
.computeHelper=${this._computeHelper}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
`}
|
||||
@@ -155,7 +159,7 @@ export class DialogLovelaceDashboardDetail extends LitElement {
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(params: LovelaceDashboardDetailsDialogParams) =>
|
||||
(params: LovelaceDashboardDetailsDialogParams, requireAdmin?: boolean) =>
|
||||
[
|
||||
{
|
||||
name: "title",
|
||||
@@ -183,6 +187,7 @@ export class DialogLovelaceDashboardDetail extends LitElement {
|
||||
{
|
||||
name: "require_admin",
|
||||
required: true,
|
||||
disabled: params.isDefault && !requireAdmin,
|
||||
selector: {
|
||||
boolean: {},
|
||||
},
|
||||
@@ -210,6 +215,15 @@ export class DialogLovelaceDashboardDetail extends LitElement {
|
||||
}`
|
||||
);
|
||||
|
||||
private _computeHelper = (
|
||||
entry: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
): string =>
|
||||
entry.name === "require_admin" && entry.disabled
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.lovelace.dashboards.panel_detail.require_admin_helper"
|
||||
)
|
||||
: "";
|
||||
|
||||
private _valueChanged(ev: CustomEvent) {
|
||||
this._error = undefined;
|
||||
const value = ev.detail.value;
|
||||
|
||||
247
src/panels/config/lovelace/dashboards/dialog-panel-detail.ts
Normal file
247
src/panels/config/lovelace/dashboards/dialog-panel-detail.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { mdiDotsVertical, mdiRestart } from "@mdi/js";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-dialog-footer";
|
||||
import "../../../../components/ha-dropdown";
|
||||
import "../../../../components/ha-dropdown-item";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
import "../../../../components/ha-icon-button";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/ha-dialog";
|
||||
import type { SchemaUnion } from "../../../../components/ha-form/types";
|
||||
import type { PanelMutableParams } from "../../../../data/panel";
|
||||
import { haStyleDialog } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { PanelDetailDialogParams } from "./show-dialog-panel-detail";
|
||||
|
||||
interface PanelDetailData {
|
||||
title: string;
|
||||
icon?: string;
|
||||
require_admin: boolean;
|
||||
show_in_sidebar: boolean;
|
||||
}
|
||||
|
||||
@customElement("dialog-panel-detail")
|
||||
export class DialogPanelDetail extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _params?: PanelDetailDialogParams;
|
||||
|
||||
@state() private _data?: PanelDetailData;
|
||||
|
||||
@state() private _error?: Record<string, string>;
|
||||
|
||||
@state() private _submitting = false;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
public showDialog(params: PanelDetailDialogParams): void {
|
||||
this._params = params;
|
||||
this._error = undefined;
|
||||
this._data = {
|
||||
title: params.title,
|
||||
icon: params.icon,
|
||||
require_admin: params.requireAdmin,
|
||||
show_in_sidebar: params.showInSidebar,
|
||||
};
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
public closeDialog(): void {
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._params = undefined;
|
||||
this._data = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params || !this._data) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const titleInvalid = !this._data.title || !this._data.title.trim();
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
.hass=${this.hass}
|
||||
.open=${this._open}
|
||||
prevent-scrim-close
|
||||
header-title=${this.hass.localize(
|
||||
"ui.panel.config.lovelace.dashboards.panel_detail.edit_panel"
|
||||
)}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<ha-dropdown slot="headerActionItems" placement="bottom-end">
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
.label=${this.hass.localize("ui.common.menu")}
|
||||
.path=${mdiDotsVertical}
|
||||
></ha-icon-button>
|
||||
<ha-dropdown-item @click=${this._resetPanel}>
|
||||
<ha-svg-icon slot="icon" .path=${mdiRestart}></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.lovelace.dashboards.panel_detail.reset_to_default"
|
||||
)}
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
<ha-form
|
||||
autofocus
|
||||
.schema=${this._schema(
|
||||
this._params.isDefault,
|
||||
this._data.require_admin
|
||||
)}
|
||||
.data=${this._data}
|
||||
.hass=${this.hass}
|
||||
.error=${this._error}
|
||||
.computeLabel=${this._computeLabel}
|
||||
.computeHelper=${this._computeHelper}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
slot="secondaryAction"
|
||||
@click=${this.closeDialog}
|
||||
>
|
||||
${this.hass.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._updatePanel}
|
||||
.disabled=${titleInvalid || this._submitting}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.lovelace.dashboards.detail.update"
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(isDefault: boolean, requireAdmin: boolean) =>
|
||||
[
|
||||
{
|
||||
name: "title",
|
||||
required: true,
|
||||
selector: { text: {} },
|
||||
},
|
||||
{
|
||||
name: "icon",
|
||||
required: false,
|
||||
selector: { icon: {} },
|
||||
},
|
||||
{
|
||||
name: "require_admin",
|
||||
required: true,
|
||||
disabled: isDefault && !requireAdmin,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "show_in_sidebar",
|
||||
required: true,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
] as const
|
||||
);
|
||||
|
||||
private _computeLabel = (
|
||||
entry: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
): string =>
|
||||
this.hass.localize(
|
||||
`ui.panel.config.lovelace.dashboards.panel_detail.${entry.name}`
|
||||
);
|
||||
|
||||
private _computeHelper = (
|
||||
entry: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
): string =>
|
||||
entry.name === "require_admin" && entry.disabled
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.lovelace.dashboards.panel_detail.require_admin_helper"
|
||||
)
|
||||
: "";
|
||||
|
||||
private _valueChanged(ev: CustomEvent) {
|
||||
this._error = undefined;
|
||||
this._data = ev.detail.value;
|
||||
}
|
||||
|
||||
private async _handleError(err: any) {
|
||||
let localizedErrorMessage: string | undefined;
|
||||
if (err?.translation_domain && err?.translation_key) {
|
||||
const localize = await this.hass.loadBackendTranslation(
|
||||
"exceptions",
|
||||
err.translation_domain
|
||||
);
|
||||
localizedErrorMessage = localize(
|
||||
`component.${err.translation_domain}.exceptions.${err.translation_key}.message`,
|
||||
err.translation_placeholders
|
||||
);
|
||||
}
|
||||
this._error = {
|
||||
base: localizedErrorMessage || err?.message || "Unknown error",
|
||||
};
|
||||
}
|
||||
|
||||
private async _resetPanel() {
|
||||
this._submitting = true;
|
||||
try {
|
||||
await this._params!.updatePanel({
|
||||
title: null,
|
||||
icon: null,
|
||||
require_admin: null,
|
||||
show_in_sidebar: null,
|
||||
});
|
||||
this.closeDialog();
|
||||
} catch (err: any) {
|
||||
this._handleError(err);
|
||||
} finally {
|
||||
this._submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async _updatePanel() {
|
||||
this._submitting = true;
|
||||
try {
|
||||
const updates: PanelMutableParams = {};
|
||||
|
||||
if (this._data!.title !== this._params!.title) {
|
||||
updates.title = this._data!.title;
|
||||
}
|
||||
if ((this._data!.icon || undefined) !== this._params!.icon) {
|
||||
updates.icon = this._data!.icon || null;
|
||||
}
|
||||
if (this._data!.require_admin !== this._params!.requireAdmin) {
|
||||
updates.require_admin = this._data!.require_admin;
|
||||
}
|
||||
if (this._data!.show_in_sidebar !== this._params!.showInSidebar) {
|
||||
updates.show_in_sidebar = this._data!.show_in_sidebar;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await this._params!.updatePanel(updates);
|
||||
}
|
||||
this.closeDialog();
|
||||
} catch (err: any) {
|
||||
this._handleError(err);
|
||||
} finally {
|
||||
this._submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
static styles = haStyleDialog;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"dialog-panel-detail": DialogPanelDetail;
|
||||
}
|
||||
}
|
||||
@@ -50,8 +50,12 @@ import {
|
||||
DEFAULT_PANEL,
|
||||
getPanelIcon,
|
||||
getPanelTitle,
|
||||
updatePanel,
|
||||
} from "../../../../data/panel";
|
||||
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
|
||||
import {
|
||||
showAlertDialog,
|
||||
showConfirmationDialog,
|
||||
} from "../../../../dialogs/generic/show-dialog-box";
|
||||
import "../../../../layouts/hass-loading-screen";
|
||||
import "../../../../layouts/hass-tabs-subpage-data-table";
|
||||
import type { HomeAssistant, Route } from "../../../../types";
|
||||
@@ -60,6 +64,7 @@ import { showNewDashboardDialog } from "../../dashboard/show-dialog-new-dashboar
|
||||
import { lovelaceTabs } from "../ha-config-lovelace";
|
||||
import { showDashboardConfigureStrategyDialog } from "./show-dialog-lovelace-dashboard-configure-strategy";
|
||||
import { showDashboardDetailDialog } from "./show-dialog-lovelace-dashboard-detail";
|
||||
import { showPanelDetailDialog } from "./show-dialog-panel-detail";
|
||||
|
||||
export const PANEL_DASHBOARDS = [
|
||||
"home",
|
||||
@@ -282,6 +287,17 @@ export class HaConfigLovelaceDashboards extends LitElement {
|
||||
action: () => this._handleSetAsDefault(dashboard),
|
||||
disabled: dashboard.default,
|
||||
},
|
||||
...(dashboard.type === "built_in"
|
||||
? [
|
||||
{
|
||||
path: mdiPencil,
|
||||
label: this.hass.localize(
|
||||
"ui.panel.config.lovelace.dashboards.picker.edit"
|
||||
),
|
||||
action: () => this._handleEditPanel(dashboard),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(dashboard.type === "user_created" &&
|
||||
dashboard.mode === "storage"
|
||||
? [
|
||||
@@ -313,23 +329,27 @@ export class HaConfigLovelaceDashboards extends LitElement {
|
||||
);
|
||||
|
||||
private _getItems = memoize(
|
||||
(dashboards: LovelaceDashboard[], defaultUrlPath: string | null) => {
|
||||
(
|
||||
dashboards: LovelaceDashboard[],
|
||||
defaultUrlPath: string | null,
|
||||
panels: HomeAssistant["panels"]
|
||||
) => {
|
||||
const result: DataTableItem[] = [];
|
||||
|
||||
PANEL_DASHBOARDS.forEach((panel) => {
|
||||
const panelInfo = this.hass.panels[panel];
|
||||
const panelInfo = panels[panel];
|
||||
if (!panelInfo) {
|
||||
return;
|
||||
}
|
||||
const item: DataTableItem = {
|
||||
icon: getPanelIcon(panelInfo),
|
||||
title: getPanelTitle(this.hass, panelInfo) || panelInfo.url_path,
|
||||
show_in_sidebar: true,
|
||||
show_in_sidebar: panelInfo.show_in_sidebar || false,
|
||||
mode: "storage",
|
||||
url_path: panelInfo.url_path,
|
||||
filename: "",
|
||||
default: defaultUrlPath === panelInfo.url_path,
|
||||
require_admin: false,
|
||||
require_admin: panelInfo.require_admin || false,
|
||||
type: "built_in",
|
||||
localized_type: this._localizeType("built_in"),
|
||||
};
|
||||
@@ -381,7 +401,11 @@ export class HaConfigLovelaceDashboards extends LitElement {
|
||||
this._dashboards,
|
||||
this.hass.localize
|
||||
)}
|
||||
.data=${this._getItems(this._dashboards, defaultPanel)}
|
||||
.data=${this._getItems(
|
||||
this._dashboards,
|
||||
defaultPanel,
|
||||
this.hass.panels
|
||||
)}
|
||||
.initialGroupColumn=${this._activeGrouping}
|
||||
.initialCollapsedGroups=${this._activeCollapsed}
|
||||
.initialSorting=${this._activeSorting}
|
||||
@@ -452,11 +476,42 @@ export class HaConfigLovelaceDashboards extends LitElement {
|
||||
this._openDetailDialog(dashboard, urlPath);
|
||||
}
|
||||
|
||||
private _handleEditPanel(item: DataTableItem) {
|
||||
const panelInfo = this.hass.panels[item.url_path];
|
||||
if (!panelInfo) {
|
||||
return;
|
||||
}
|
||||
const defaultPanel = this.hass.systemData?.default_panel || DEFAULT_PANEL;
|
||||
showPanelDetailDialog(this, {
|
||||
urlPath: panelInfo.url_path,
|
||||
title: getPanelTitle(this.hass, panelInfo) || panelInfo.url_path,
|
||||
icon: getPanelIcon(panelInfo),
|
||||
requireAdmin: panelInfo.require_admin || false,
|
||||
showInSidebar: panelInfo.show_in_sidebar || false,
|
||||
isDefault: panelInfo.url_path === defaultPanel,
|
||||
updatePanel: async (values) => {
|
||||
await updatePanel(this.hass!, panelInfo.url_path, values);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _handleSetAsDefault = async (item: DataTableItem) => {
|
||||
if (item.default) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.require_admin) {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.lovelace.dashboards.detail.set_default_admin_only_title"
|
||||
),
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.lovelace.dashboards.detail.set_default_admin_only_text"
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const confirm = await showConfirmationDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.lovelace.dashboards.detail.set_default_confirm_title"
|
||||
@@ -524,9 +579,11 @@ export class HaConfigLovelaceDashboards extends LitElement {
|
||||
urlPath?: string,
|
||||
defaultConfig?: LovelaceRawConfig
|
||||
): Promise<void> {
|
||||
const defaultPanel = this.hass.systemData?.default_panel || DEFAULT_PANEL;
|
||||
showDashboardDetailDialog(this, {
|
||||
dashboard,
|
||||
urlPath,
|
||||
isDefault: dashboard?.url_path === defaultPanel,
|
||||
createDashboard: async (values: LovelaceDashboardCreateParams) => {
|
||||
const created = await createDashboard(this.hass!, values);
|
||||
this._dashboards = this._dashboards!.concat(created).sort(
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
export interface LovelaceDashboardDetailsDialogParams {
|
||||
dashboard?: LovelaceDashboard;
|
||||
urlPath?: string;
|
||||
isDefault?: boolean;
|
||||
createDashboard?: (values: LovelaceDashboardCreateParams) => Promise<unknown>;
|
||||
updateDashboard: (
|
||||
updates: Partial<LovelaceDashboardMutableParams>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { PanelMutableParams } from "../../../../data/panel";
|
||||
|
||||
export interface PanelDetailDialogParams {
|
||||
urlPath: string;
|
||||
title: string;
|
||||
icon?: string;
|
||||
requireAdmin: boolean;
|
||||
showInSidebar: boolean;
|
||||
isDefault: boolean;
|
||||
updatePanel: (updates: PanelMutableParams) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export const loadPanelDetailDialog = () => import("./dialog-panel-detail");
|
||||
|
||||
export const showPanelDetailDialog = (
|
||||
element: HTMLElement,
|
||||
dialogParams: PanelDetailDialogParams
|
||||
) => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "dialog-panel-detail",
|
||||
dialogImport: loadPanelDetailDialog,
|
||||
dialogParams,
|
||||
});
|
||||
};
|
||||
@@ -143,7 +143,8 @@ class HaConfigRepairs extends LitElement {
|
||||
}
|
||||
} else if (
|
||||
issue.domain === "vacuum" &&
|
||||
issue.translation_key === "segments_changed"
|
||||
(issue.translation_key === "segments_changed" ||
|
||||
issue.translation_key === "segments_mapping_not_configured")
|
||||
) {
|
||||
const data = await fetchRepairsIssueData(
|
||||
this.hass.connection,
|
||||
|
||||
@@ -324,12 +324,11 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
|
||||
localize("ui.panel.config.scene.picker.only_editable")
|
||||
),
|
||||
actions: {
|
||||
lastFixed: true,
|
||||
title: "",
|
||||
label: this.hass.localize("ui.panel.config.generic.headers.actions"),
|
||||
type: "overflow-menu",
|
||||
showNarrow: true,
|
||||
moveable: false,
|
||||
hideable: false,
|
||||
template: (scene) => html`
|
||||
<ha-icon-overflow-menu
|
||||
.hass=${this.hass}
|
||||
|
||||
@@ -318,12 +318,11 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
|
||||
labels: getLabelsTableColumn(),
|
||||
last_triggered: getTriggeredAtTableColumn(localize, this.hass),
|
||||
actions: {
|
||||
lastFixed: true,
|
||||
title: "",
|
||||
label: this.hass.localize("ui.panel.config.generic.headers.actions"),
|
||||
type: "overflow-menu",
|
||||
showNarrow: true,
|
||||
moveable: false,
|
||||
hideable: false,
|
||||
template: (script) => html`
|
||||
<ha-icon-overflow-menu
|
||||
.hass=${this.hass}
|
||||
|
||||
@@ -21,12 +21,12 @@ export function getAssistantsTableColumn<T>(
|
||||
defaultHidden: !visible,
|
||||
sortable: true,
|
||||
showNarrow: true,
|
||||
minWidth: "160px",
|
||||
maxWidth: "160px",
|
||||
minWidth: "112px",
|
||||
maxWidth: "112px",
|
||||
valueColumn: "assistants_sortable_key",
|
||||
template: (entry: any) =>
|
||||
html`${entry.assistants.length !== 0
|
||||
? html`<div style="display: flex; gap: var(--ha-space-4);">
|
||||
? html`<div style="display: flex; gap: var(--ha-space-1);">
|
||||
${availableAssistants.map((vaId) => {
|
||||
const supported =
|
||||
!supportedEntities?.[vaId] ||
|
||||
|
||||
@@ -321,7 +321,16 @@ class PanelEnergy extends LitElement {
|
||||
|
||||
private _navigateConfig(ev?: Event) {
|
||||
ev?.stopPropagation();
|
||||
navigate("/config/energy?historyBack=1");
|
||||
const viewPath = this.route?.path?.split("/")[1] || "";
|
||||
const tabMap: Record<string, string> = {
|
||||
overview: "electricity",
|
||||
electricity: "electricity",
|
||||
gas: "gas",
|
||||
water: "water",
|
||||
now: "electricity",
|
||||
};
|
||||
const tab = tabMap[viewPath] || "electricity";
|
||||
navigate(`/config/energy/${tab}?historyBack=1`);
|
||||
}
|
||||
|
||||
private _reloadConfig() {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { ReactiveElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { getEnergyDataCollection } from "../../../data/energy";
|
||||
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
|
||||
import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy";
|
||||
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { DEFAULT_ENERGY_COLLECTION_KEY } from "../ha-panel-energy";
|
||||
import { shouldShowFloorsAndAreas } from "./show-floors-and-areas";
|
||||
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
|
||||
import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
|
||||
|
||||
@customElement("power-view-strategy")
|
||||
export class PowerViewStrategy extends ReactiveElement {
|
||||
@@ -14,11 +15,6 @@ export class PowerViewStrategy extends ReactiveElement {
|
||||
_config: LovelaceStrategyConfig,
|
||||
hass: HomeAssistant
|
||||
): Promise<LovelaceViewConfig> {
|
||||
const view: LovelaceViewConfig = {
|
||||
type: "sections",
|
||||
sections: [{ type: "grid", cards: [] }],
|
||||
};
|
||||
|
||||
const collectionKey =
|
||||
_config.collection_key || DEFAULT_ENERGY_COLLECTION_KEY;
|
||||
|
||||
@@ -39,16 +35,46 @@ export class PowerViewStrategy extends ReactiveElement {
|
||||
const hasPowerDevices = prefs?.device_consumption.some(
|
||||
(device) => device.stat_rate
|
||||
);
|
||||
const hasWaterDevices = prefs?.device_consumption_water.some(
|
||||
(device) => device.stat_rate
|
||||
);
|
||||
const hasWaterSources = prefs?.energy_sources.some(
|
||||
(source) => source.type === "water" && source.stat_rate
|
||||
);
|
||||
const hasGasSources = prefs?.energy_sources.some(
|
||||
(source) => source.type === "gas" && source.stat_rate
|
||||
);
|
||||
|
||||
// No power sources configured
|
||||
if (!prefs || (!hasPowerSources && !hasPowerDevices)) {
|
||||
const chartsSection: LovelaceSectionConfig = {
|
||||
type: "grid",
|
||||
cards: [],
|
||||
};
|
||||
const badges: LovelaceBadgeConfig[] = [];
|
||||
|
||||
const view: LovelaceViewConfig = {
|
||||
type: "sections",
|
||||
sections: [chartsSection],
|
||||
};
|
||||
|
||||
// No sources configured
|
||||
if (
|
||||
!prefs ||
|
||||
(!hasPowerSources &&
|
||||
!hasPowerDevices &&
|
||||
!hasWaterDevices &&
|
||||
!hasWaterSources &&
|
||||
!hasGasSources)
|
||||
) {
|
||||
return view;
|
||||
}
|
||||
|
||||
const section = view.sections![0] as LovelaceSectionConfig;
|
||||
|
||||
if (hasPowerSources) {
|
||||
section.cards!.push({
|
||||
badges.push({
|
||||
type: "power-total",
|
||||
collection_key: collectionKey,
|
||||
});
|
||||
|
||||
chartsSection.cards!.push({
|
||||
title: hass.localize("ui.panel.energy.cards.power_sources_graph_title"),
|
||||
type: "power-sources-graph",
|
||||
collection_key: collectionKey,
|
||||
@@ -58,13 +84,27 @@ export class PowerViewStrategy extends ReactiveElement {
|
||||
});
|
||||
}
|
||||
|
||||
if (hasGasSources) {
|
||||
badges.push({
|
||||
type: "gas-total",
|
||||
collection_key: collectionKey,
|
||||
});
|
||||
}
|
||||
|
||||
if (hasWaterSources) {
|
||||
badges.push({
|
||||
type: "water-total",
|
||||
collection_key: collectionKey,
|
||||
});
|
||||
}
|
||||
|
||||
if (hasPowerDevices) {
|
||||
const showFloorsAndAreas = shouldShowFloorsAndAreas(
|
||||
prefs.device_consumption,
|
||||
hass,
|
||||
(d) => d.stat_rate
|
||||
);
|
||||
section.cards!.push({
|
||||
chartsSection.cards!.push({
|
||||
title: hass.localize("ui.panel.energy.cards.power_sankey_title"),
|
||||
type: "power-sankey",
|
||||
collection_key: collectionKey,
|
||||
@@ -76,6 +116,28 @@ export class PowerViewStrategy extends ReactiveElement {
|
||||
});
|
||||
}
|
||||
|
||||
if (hasWaterDevices) {
|
||||
const showFloorsAndAreas = shouldShowFloorsAndAreas(
|
||||
prefs.device_consumption_water,
|
||||
hass,
|
||||
(d) => d.stat_rate
|
||||
);
|
||||
chartsSection.cards!.push({
|
||||
title: hass.localize("ui.panel.energy.cards.water_flow_sankey_title"),
|
||||
type: "water-flow-sankey",
|
||||
collection_key: collectionKey,
|
||||
group_by_floor: showFloorsAndAreas,
|
||||
group_by_area: showFloorsAndAreas,
|
||||
grid_options: {
|
||||
columns: 36,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (badges.length) {
|
||||
view.badges = badges;
|
||||
}
|
||||
|
||||
return view;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +101,8 @@ class PanelHome extends LitElement {
|
||||
oldHass.entities !== this.hass.entities ||
|
||||
oldHass.devices !== this.hass.devices ||
|
||||
oldHass.areas !== this.hass.areas ||
|
||||
oldHass.floors !== this.hass.floors
|
||||
oldHass.floors !== this.hass.floors ||
|
||||
oldHass.panels !== this.hass.panels
|
||||
) {
|
||||
if (this.hass.config.state === "RUNNING") {
|
||||
this._debounceRegistriesChanged();
|
||||
|
||||
@@ -58,7 +58,8 @@ class PanelLight extends LitElement {
|
||||
oldHass.entities !== this.hass.entities ||
|
||||
oldHass.devices !== this.hass.devices ||
|
||||
oldHass.areas !== this.hass.areas ||
|
||||
oldHass.floors !== this.hass.floors
|
||||
oldHass.floors !== this.hass.floors ||
|
||||
oldHass.panels !== this.hass.panels
|
||||
) {
|
||||
if (this.hass.config.state === "RUNNING") {
|
||||
this._debounceRegistriesChanged();
|
||||
|
||||
@@ -64,10 +64,12 @@ const processAreasForLight = (
|
||||
heading_style: "subtitle",
|
||||
type: "heading",
|
||||
heading: area.name,
|
||||
tap_action: {
|
||||
action: "navigate",
|
||||
navigation_path: `/home/areas-${area.area_id}`,
|
||||
},
|
||||
tap_action: hass.panels.home
|
||||
? {
|
||||
action: "navigate",
|
||||
navigation_path: `/home/areas-${area.area_id}`,
|
||||
}
|
||||
: undefined,
|
||||
badges: [
|
||||
// Toggle buttons for mobile
|
||||
{
|
||||
@@ -107,18 +109,35 @@ const processAreasForLight = (
|
||||
// Toggle group card for desktop
|
||||
cards.push({
|
||||
type: "toggle-group",
|
||||
title: hass.localize("ui.panel.lovelace.strategy.light.all_lights"),
|
||||
color: "amber",
|
||||
entities: areaLights,
|
||||
visibility: [LARGE_SCREEN_CONDITION],
|
||||
grid_options: {
|
||||
columns: 6,
|
||||
rows: 1,
|
||||
min_columns: 6,
|
||||
},
|
||||
} as ToggleGroupCardConfig);
|
||||
|
||||
cards.push(...areaCards);
|
||||
areaCards.forEach((card) => {
|
||||
// Insert a blank card before every 3rd card to align the individual
|
||||
// cards with the toggle group card on desktop
|
||||
if (
|
||||
areaCards.indexOf(card) % 3 === 0 &&
|
||||
areaCards.indexOf(card) !== 0
|
||||
) {
|
||||
cards.push({
|
||||
type: "vertical-stack",
|
||||
cards: [],
|
||||
visibility: [LARGE_SCREEN_CONDITION],
|
||||
grid_options: {
|
||||
columns: 6,
|
||||
rows: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
cards.push(card);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
124
src/panels/lovelace/badges/energy/hui-gas-total-badge.ts
Normal file
124
src/panels/lovelace/badges/energy/hui-gas-total-badge.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { mdiFire } from "@mdi/js";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import "../../../../components/ha-badge";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type { EnergyData, EnergyPreferences } from "../../../../data/energy";
|
||||
import {
|
||||
formatFlowRateShort,
|
||||
getEnergyDataCollection,
|
||||
getFlowRateFromState,
|
||||
} from "../../../../data/energy";
|
||||
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { LovelaceBadge } from "../../types";
|
||||
import type { GasTotalBadgeConfig } from "../types";
|
||||
|
||||
@customElement("hui-gas-total-badge")
|
||||
export class HuiGasTotalBadge
|
||||
extends SubscribeMixin(LitElement)
|
||||
implements LovelaceBadge
|
||||
{
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _config?: GasTotalBadgeConfig;
|
||||
|
||||
@state() private _data?: EnergyData;
|
||||
|
||||
private _entities = new Set<string>();
|
||||
|
||||
protected hassSubscribeRequiredHostProps = ["_config"];
|
||||
|
||||
public setConfig(config: GasTotalBadgeConfig): void {
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
public hassSubscribe(): UnsubscribeFunc[] {
|
||||
return [
|
||||
getEnergyDataCollection(this.hass, {
|
||||
key: this._config?.collection_key,
|
||||
}).subscribe((data) => {
|
||||
this._data = data;
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
protected shouldUpdate(changedProps: PropertyValues): boolean {
|
||||
if (changedProps.has("_config") || changedProps.has("_data")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (changedProps.has("hass")) {
|
||||
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
|
||||
if (!oldHass || !this._entities.size) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const entityId of this._entities) {
|
||||
if (oldHass.states[entityId] !== this.hass?.states[entityId]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private _getCurrentFlowRate(entityId: string): number {
|
||||
this._entities.add(entityId);
|
||||
return getFlowRateFromState(this.hass.states[entityId]) ?? 0;
|
||||
}
|
||||
|
||||
private _computeTotalFlowRate(prefs: EnergyPreferences): number {
|
||||
this._entities.clear();
|
||||
|
||||
let totalFlow = 0;
|
||||
|
||||
prefs.energy_sources.forEach((source) => {
|
||||
if (source.type === "gas" && source.stat_rate) {
|
||||
const value = this._getCurrentFlowRate(source.stat_rate);
|
||||
if (value > 0) totalFlow += value;
|
||||
}
|
||||
});
|
||||
|
||||
return Math.max(0, totalFlow);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._config || !this._data) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const flowRate = this._computeTotalFlowRate(this._data.prefs);
|
||||
const displayValue = formatFlowRateShort(
|
||||
this.hass.locale,
|
||||
this.hass.config.unit_system.length,
|
||||
flowRate
|
||||
);
|
||||
|
||||
const name =
|
||||
this._config.title ||
|
||||
this.hass.localize("ui.panel.lovelace.cards.energy.gas_total_title");
|
||||
|
||||
return html`
|
||||
<ha-badge .label=${name}>
|
||||
<ha-svg-icon slot="icon" .path=${mdiFire}></ha-svg-icon>
|
||||
${displayValue}
|
||||
</ha-badge>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-badge {
|
||||
--badge-color: var(--energy-gas-color);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-gas-total-badge": HuiGasTotalBadge;
|
||||
}
|
||||
}
|
||||
143
src/panels/lovelace/badges/energy/hui-power-total-badge.ts
Normal file
143
src/panels/lovelace/badges/energy/hui-power-total-badge.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { mdiHomeLightningBolt } from "@mdi/js";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { formatNumber } from "../../../../common/number/format_number";
|
||||
import "../../../../components/ha-badge";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type { EnergyData, EnergyPreferences } from "../../../../data/energy";
|
||||
import {
|
||||
getEnergyDataCollection,
|
||||
getPowerFromState,
|
||||
} from "../../../../data/energy";
|
||||
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { LovelaceBadge } from "../../types";
|
||||
import type { PowerTotalBadgeConfig } from "../types";
|
||||
|
||||
@customElement("hui-power-total-badge")
|
||||
export class HuiPowerTotalBadge
|
||||
extends SubscribeMixin(LitElement)
|
||||
implements LovelaceBadge
|
||||
{
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _config?: PowerTotalBadgeConfig;
|
||||
|
||||
@state() private _data?: EnergyData;
|
||||
|
||||
private _entities = new Set<string>();
|
||||
|
||||
protected hassSubscribeRequiredHostProps = ["_config"];
|
||||
|
||||
public setConfig(config: PowerTotalBadgeConfig): void {
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
public hassSubscribe(): UnsubscribeFunc[] {
|
||||
return [
|
||||
getEnergyDataCollection(this.hass, {
|
||||
key: this._config?.collection_key,
|
||||
}).subscribe((data) => {
|
||||
this._data = data;
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
protected shouldUpdate(changedProps: PropertyValues): boolean {
|
||||
if (changedProps.has("_config") || changedProps.has("_data")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (changedProps.has("hass")) {
|
||||
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
|
||||
if (!oldHass || !this._entities.size) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const entityId of this._entities) {
|
||||
if (oldHass.states[entityId] !== this.hass?.states[entityId]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private _getCurrentPower(entityId: string): number {
|
||||
this._entities.add(entityId);
|
||||
return getPowerFromState(this.hass.states[entityId]) ?? 0;
|
||||
}
|
||||
|
||||
private _computeTotalPower(prefs: EnergyPreferences): number {
|
||||
this._entities.clear();
|
||||
|
||||
let solar = 0;
|
||||
let fromGrid = 0;
|
||||
let toGrid = 0;
|
||||
let fromBattery = 0;
|
||||
let toBattery = 0;
|
||||
|
||||
prefs.energy_sources.forEach((source) => {
|
||||
if (source.type === "solar" && source.stat_rate) {
|
||||
const value = this._getCurrentPower(source.stat_rate);
|
||||
if (value > 0) solar += value;
|
||||
} else if (source.type === "grid" && source.stat_rate) {
|
||||
const value = this._getCurrentPower(source.stat_rate);
|
||||
if (value > 0) fromGrid += value;
|
||||
else if (value < 0) toGrid += Math.abs(value);
|
||||
} else if (source.type === "battery" && source.stat_rate) {
|
||||
const value = this._getCurrentPower(source.stat_rate);
|
||||
if (value > 0) fromBattery += value;
|
||||
else if (value < 0) toBattery += Math.abs(value);
|
||||
}
|
||||
});
|
||||
|
||||
const usedTotal = fromGrid + solar + fromBattery - toGrid - toBattery;
|
||||
return Math.max(0, usedTotal);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._config || !this._data) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const power = this._computeTotalPower(this._data.prefs);
|
||||
|
||||
let displayValue = "";
|
||||
if (power >= 1000) {
|
||||
displayValue = `${formatNumber(power / 1000, this.hass.locale, {
|
||||
maximumFractionDigits: 2,
|
||||
})} kW`;
|
||||
} else {
|
||||
displayValue = `${formatNumber(power, this.hass.locale, {
|
||||
maximumFractionDigits: 0,
|
||||
})} W`;
|
||||
}
|
||||
|
||||
const name =
|
||||
this._config.title ||
|
||||
this.hass.localize("ui.panel.lovelace.cards.energy.power_total_title");
|
||||
|
||||
return html`
|
||||
<ha-badge .label=${name}>
|
||||
<ha-svg-icon slot="icon" .path=${mdiHomeLightningBolt}></ha-svg-icon>
|
||||
${displayValue}
|
||||
</ha-badge>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-badge {
|
||||
--badge-color: var(--primary-color);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-power-total-badge": HuiPowerTotalBadge;
|
||||
}
|
||||
}
|
||||
124
src/panels/lovelace/badges/energy/hui-water-total-badge.ts
Normal file
124
src/panels/lovelace/badges/energy/hui-water-total-badge.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { mdiWater } from "@mdi/js";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import "../../../../components/ha-badge";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type { EnergyData, EnergyPreferences } from "../../../../data/energy";
|
||||
import {
|
||||
formatFlowRateShort,
|
||||
getEnergyDataCollection,
|
||||
getFlowRateFromState,
|
||||
} from "../../../../data/energy";
|
||||
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { LovelaceBadge } from "../../types";
|
||||
import type { WaterTotalBadgeConfig } from "../types";
|
||||
|
||||
@customElement("hui-water-total-badge")
|
||||
export class HuiWaterTotalBadge
|
||||
extends SubscribeMixin(LitElement)
|
||||
implements LovelaceBadge
|
||||
{
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _config?: WaterTotalBadgeConfig;
|
||||
|
||||
@state() private _data?: EnergyData;
|
||||
|
||||
private _entities = new Set<string>();
|
||||
|
||||
protected hassSubscribeRequiredHostProps = ["_config"];
|
||||
|
||||
public setConfig(config: WaterTotalBadgeConfig): void {
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
public hassSubscribe(): UnsubscribeFunc[] {
|
||||
return [
|
||||
getEnergyDataCollection(this.hass, {
|
||||
key: this._config?.collection_key,
|
||||
}).subscribe((data) => {
|
||||
this._data = data;
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
protected shouldUpdate(changedProps: PropertyValues): boolean {
|
||||
if (changedProps.has("_config") || changedProps.has("_data")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (changedProps.has("hass")) {
|
||||
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
|
||||
if (!oldHass || !this._entities.size) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const entityId of this._entities) {
|
||||
if (oldHass.states[entityId] !== this.hass?.states[entityId]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private _getCurrentFlowRate(entityId: string): number {
|
||||
this._entities.add(entityId);
|
||||
return getFlowRateFromState(this.hass.states[entityId]) ?? 0;
|
||||
}
|
||||
|
||||
private _computeTotalFlowRate(prefs: EnergyPreferences): number {
|
||||
this._entities.clear();
|
||||
|
||||
let totalFlow = 0;
|
||||
|
||||
prefs.energy_sources.forEach((source) => {
|
||||
if (source.type === "water" && source.stat_rate) {
|
||||
const value = this._getCurrentFlowRate(source.stat_rate);
|
||||
if (value > 0) totalFlow += value;
|
||||
}
|
||||
});
|
||||
|
||||
return Math.max(0, totalFlow);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._config || !this._data) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const flowRate = this._computeTotalFlowRate(this._data.prefs);
|
||||
const displayValue = formatFlowRateShort(
|
||||
this.hass.locale,
|
||||
this.hass.config.unit_system.length,
|
||||
flowRate
|
||||
);
|
||||
|
||||
const name =
|
||||
this._config.title ||
|
||||
this.hass.localize("ui.panel.lovelace.cards.energy.water_total_title");
|
||||
|
||||
return html`
|
||||
<ha-badge .label=${name}>
|
||||
<ha-svg-icon slot="icon" .path=${mdiWater}></ha-svg-icon>
|
||||
${displayValue}
|
||||
</ha-badge>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-badge {
|
||||
--badge-color: var(--energy-water-color);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-water-total-badge": HuiWaterTotalBadge;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import "../../../components/ha-svg-icon";
|
||||
import { cameraUrlWithWidthHeight } from "../../../data/camera";
|
||||
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { addBrandsAuth } from "../../../util/brands-url";
|
||||
import { actionHandler } from "../common/directives/action-handler-directive";
|
||||
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
|
||||
import { findEntities } from "../common/find-entities";
|
||||
@@ -143,7 +144,7 @@ export class HuiEntityBadge extends LitElement implements LovelaceBadge {
|
||||
|
||||
if (!entityPicture) return undefined;
|
||||
|
||||
let imageUrl = this.hass!.hassUrl(entityPicture);
|
||||
let imageUrl = this.hass!.hassUrl(addBrandsAuth(entityPicture));
|
||||
if (computeStateDomain(stateObj) === "camera") {
|
||||
imageUrl = cameraUrlWithWidthHeight(imageUrl, 32, 32);
|
||||
}
|
||||
|
||||
@@ -48,3 +48,20 @@ export interface EntityBadgeConfig extends LovelaceBadgeConfig {
|
||||
*/
|
||||
display_type?: DisplayType;
|
||||
}
|
||||
|
||||
interface EnergyTotalBadgeConfig extends LovelaceBadgeConfig {
|
||||
title?: string;
|
||||
collection_key?: string;
|
||||
}
|
||||
|
||||
export interface PowerTotalBadgeConfig extends EnergyTotalBadgeConfig {
|
||||
type: "power-total";
|
||||
}
|
||||
|
||||
export interface WaterTotalBadgeConfig extends EnergyTotalBadgeConfig {
|
||||
type: "water-total";
|
||||
}
|
||||
|
||||
export interface GasTotalBadgeConfig extends EnergyTotalBadgeConfig {
|
||||
type: "gas-total";
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
formatDateMonthYear,
|
||||
formatDateShort,
|
||||
formatDateVeryShort,
|
||||
formatDateWeekdayShortDate,
|
||||
formatDateWeekdayVeryShortDate,
|
||||
} from "../../../../../common/datetime/format_date";
|
||||
import { formatTime } from "../../../../../common/datetime/format_time";
|
||||
import type { ECOption } from "../../../../../resources/echarts/echarts";
|
||||
@@ -222,7 +224,9 @@ function formatTooltip(
|
||||
if (suggestedPeriod === "month") {
|
||||
period = `${formatDateMonthYear(date, locale, config)}`;
|
||||
} else if (suggestedPeriod === "day") {
|
||||
period = `${(showCompareYear ? formatDateShort : formatDateVeryShort)(date, locale, config)}`;
|
||||
period = showCompareYear
|
||||
? formatDateWeekdayShortDate(date, locale, config)
|
||||
: formatDateWeekdayVeryShortDate(date, locale, config);
|
||||
} else {
|
||||
period = `${
|
||||
compare
|
||||
|
||||
@@ -796,7 +796,7 @@ class HuiEnergyDistrubutionCard
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
${this._config.link_dashboard
|
||||
${this._config.link_dashboard && this.hass.panels.energy
|
||||
? html`
|
||||
<div class="card-actions">
|
||||
<ha-button
|
||||
|
||||
@@ -302,9 +302,11 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard {
|
||||
--ha-card-border-radius,
|
||||
var(--ha-border-radius-lg)
|
||||
);
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.header:not(:has(> hui-buttons-header-footer)) {
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.footer {
|
||||
border-bottom-left-radius: var(
|
||||
|
||||
@@ -21,6 +21,7 @@ import { cameraUrlWithWidthHeight } from "../../../data/camera";
|
||||
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
|
||||
import "../../../state-display/state-display";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { addBrandsAuth } from "../../../util/brands-url";
|
||||
import "../card-features/hui-card-features";
|
||||
import type { LovelaceCardFeatureContext } from "../card-features/types";
|
||||
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
|
||||
@@ -158,7 +159,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
|
||||
|
||||
if (!entityPicture) return undefined;
|
||||
|
||||
let imageUrl = this.hass!.hassUrl(entityPicture);
|
||||
let imageUrl = this.hass!.hassUrl(addBrandsAuth(entityPicture));
|
||||
if (computeDomain(entity.entity_id) === "camera") {
|
||||
imageUrl = cameraUrlWithWidthHeight(imageUrl, 80, 80);
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ export class HuiToggleGroupCard extends LitElement implements LovelaceCard {
|
||||
return stateColorCss(onEntities[0]);
|
||||
}
|
||||
|
||||
private _computeSecondary(): string {
|
||||
private _computeLabel(): string {
|
||||
if (!this.hass || !this._config) return "";
|
||||
const onCount = this._getOnEntities().length;
|
||||
const total = this._config.entities.length;
|
||||
@@ -117,6 +117,10 @@ export class HuiToggleGroupCard extends LitElement implements LovelaceCard {
|
||||
const style = {
|
||||
"--tile-color": color,
|
||||
};
|
||||
|
||||
const label = this._computeLabel();
|
||||
const primary = this._config.title || label;
|
||||
const secondary = this._config.title ? label : undefined;
|
||||
return html`
|
||||
<ha-card style=${styleMap(style)}>
|
||||
<ha-tile-container .vertical=${Boolean(this._config.vertical)}>
|
||||
@@ -128,8 +132,8 @@ export class HuiToggleGroupCard extends LitElement implements LovelaceCard {
|
||||
></ha-tile-icon>
|
||||
<ha-tile-info
|
||||
slot="info"
|
||||
.primary=${this._config.title}
|
||||
.secondary=${this._computeSecondary()}
|
||||
.primary=${primary}
|
||||
.secondary=${secondary}
|
||||
></ha-tile-info>
|
||||
</ha-tile-container>
|
||||
</ha-card>
|
||||
|
||||
@@ -251,6 +251,14 @@ export interface WaterSankeyCardConfig extends EnergyCardBaseConfig {
|
||||
group_by_area?: boolean;
|
||||
}
|
||||
|
||||
export interface WaterFlowSankeyCardConfig extends EnergyCardBaseConfig {
|
||||
type: "water-flow-sankey";
|
||||
title?: string;
|
||||
layout?: "vertical" | "horizontal" | "auto";
|
||||
group_by_floor?: boolean;
|
||||
group_by_area?: boolean;
|
||||
}
|
||||
|
||||
export interface PowerSourcesGraphCardConfig extends EnergyCardBaseConfig {
|
||||
type: "power-sources-graph";
|
||||
title?: string;
|
||||
|
||||
628
src/panels/lovelace/cards/water/hui-water-flow-sankey-card.ts
Normal file
628
src/panels/lovelace/cards/water/hui-water-flow-sankey-card.ts
Normal file
@@ -0,0 +1,628 @@
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import "../../../../components/ha-card";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { EnergyData } from "../../../../data/energy";
|
||||
import {
|
||||
formatFlowRateShort,
|
||||
getEnergyDataCollection,
|
||||
getFlowRateFromState,
|
||||
} from "../../../../data/energy";
|
||||
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { LovelaceCard, LovelaceGridOptions } from "../../types";
|
||||
import type { WaterFlowSankeyCardConfig } from "../types";
|
||||
import "../../../../components/chart/ha-sankey-chart";
|
||||
import type { Link, Node } from "../../../../components/chart/ha-sankey-chart";
|
||||
import { getGraphColorByIndex } from "../../../../common/color/colors";
|
||||
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
|
||||
import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
|
||||
|
||||
const DEFAULT_CONFIG: Partial<WaterFlowSankeyCardConfig> = {
|
||||
group_by_floor: true,
|
||||
group_by_area: true,
|
||||
};
|
||||
|
||||
// Minimum flow threshold as a fraction of total inflow to display a device node.
|
||||
// Devices below this threshold will be grouped into an "Other" node.
|
||||
const MIN_FLOW_THRESHOLD_FACTOR = 0.001; // 0.1% of total inflow
|
||||
|
||||
interface SmallConsumer {
|
||||
statRate: string;
|
||||
name: string | undefined;
|
||||
value: number;
|
||||
effectiveParent: string | undefined;
|
||||
idx: number;
|
||||
}
|
||||
|
||||
@customElement("hui-water-flow-sankey-card")
|
||||
class HuiWaterFlowSankeyCard
|
||||
extends SubscribeMixin(MobileAwareMixin(LitElement))
|
||||
implements LovelaceCard
|
||||
{
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public layout?: string;
|
||||
|
||||
@state() private _config?: WaterFlowSankeyCardConfig;
|
||||
|
||||
@state() private _data?: EnergyData;
|
||||
|
||||
private _entities = new Set<string>();
|
||||
|
||||
protected hassSubscribeRequiredHostProps = ["_config"];
|
||||
|
||||
public setConfig(config: WaterFlowSankeyCardConfig): void {
|
||||
this._config = { ...DEFAULT_CONFIG, ...config };
|
||||
}
|
||||
|
||||
public hassSubscribe(): UnsubscribeFunc[] {
|
||||
return [
|
||||
getEnergyDataCollection(this.hass, {
|
||||
key: this._config?.collection_key,
|
||||
}).subscribe((data) => {
|
||||
this._data = data;
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
public getCardSize(): Promise<number> | number {
|
||||
return 5;
|
||||
}
|
||||
|
||||
getGridOptions(): LovelaceGridOptions {
|
||||
return {
|
||||
columns: 12,
|
||||
min_columns: 6,
|
||||
rows: 6,
|
||||
min_rows: 2,
|
||||
};
|
||||
}
|
||||
|
||||
protected shouldUpdate(changedProps: PropertyValues): boolean {
|
||||
if (
|
||||
changedProps.has("_config") ||
|
||||
changedProps.has("_data") ||
|
||||
changedProps.has("_isMobileSize")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (changedProps.has("hass")) {
|
||||
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
|
||||
if (!oldHass || !this._entities.size) {
|
||||
return true;
|
||||
}
|
||||
for (const entityId of this._entities) {
|
||||
if (oldHass.states[entityId] !== this.hass.states[entityId]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._config) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
if (!this._data) {
|
||||
return html`${this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.loading"
|
||||
)}`;
|
||||
}
|
||||
|
||||
const prefs = this._data.prefs;
|
||||
const computedStyle = getComputedStyle(this);
|
||||
|
||||
// Clear tracked entities and rebuild set
|
||||
this._entities.clear();
|
||||
|
||||
// Collect water sources with stat_rate
|
||||
const waterSources = prefs.energy_sources.filter(
|
||||
(source) => source.type === "water" && source.stat_rate
|
||||
);
|
||||
|
||||
let totalInflow = 0;
|
||||
waterSources.forEach((source) => {
|
||||
if (source.type === "water" && source.stat_rate) {
|
||||
const value = this._getCurrentFlowRate(source.stat_rate);
|
||||
if (value > 0) totalInflow += value;
|
||||
}
|
||||
});
|
||||
|
||||
// When there are no source meters, pre-compute total device flow so the
|
||||
// home node has the correct value (sum of all device consumption) rather
|
||||
// than 0. This avoids a broken sankey where the root node has value=0
|
||||
// while its children have positive values.
|
||||
let totalDeviceFlow = 0;
|
||||
if (waterSources.length === 0) {
|
||||
prefs.device_consumption_water.forEach((device) => {
|
||||
if (device.stat_rate) {
|
||||
totalDeviceFlow += this._getCurrentFlowRate(device.stat_rate);
|
||||
}
|
||||
});
|
||||
}
|
||||
const effectiveTotalInflow =
|
||||
waterSources.length === 0 ? totalDeviceFlow : totalInflow;
|
||||
|
||||
// Calculate dynamic threshold
|
||||
const minFlowThreshold = effectiveTotalInflow * MIN_FLOW_THRESHOLD_FACTOR;
|
||||
|
||||
const nodes: Node[] = [];
|
||||
const links: Link[] = [];
|
||||
const waterColor = computedStyle
|
||||
.getPropertyValue("--energy-water-color")
|
||||
.trim();
|
||||
const primaryColor = computedStyle
|
||||
.getPropertyValue("--primary-color")
|
||||
.trim();
|
||||
|
||||
// Determine the "root" node for device links.
|
||||
// - 0 sources: home node (value = sum of device values, computed later)
|
||||
// - 1 source: that source node is the root (no home node)
|
||||
// - >1 sources: home node aggregates all sources
|
||||
const showHomeNode = waterSources.length !== 1;
|
||||
let rootNodeId: string;
|
||||
|
||||
if (showHomeNode) {
|
||||
// Add source nodes and link to home
|
||||
waterSources.forEach((source) => {
|
||||
if (source.type !== "water" || !source.stat_rate) return;
|
||||
const value = this._getCurrentFlowRate(source.stat_rate);
|
||||
if (value <= 0) return;
|
||||
const sourceNodeId = `water_source_${source.stat_rate}`;
|
||||
nodes.push({
|
||||
id: sourceNodeId,
|
||||
label:
|
||||
this._getEntityLabel(source.stat_rate) ||
|
||||
this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_distribution.water"
|
||||
),
|
||||
value,
|
||||
color: waterColor,
|
||||
index: 0,
|
||||
entityId: source.stat_rate,
|
||||
});
|
||||
links.push({ source: sourceNodeId, target: "home" });
|
||||
});
|
||||
|
||||
const homeNode: Node = {
|
||||
id: "home",
|
||||
label: this.hass.config.location_name,
|
||||
value: Math.max(0, effectiveTotalInflow),
|
||||
color: primaryColor,
|
||||
index: 1,
|
||||
};
|
||||
nodes.push(homeNode);
|
||||
rootNodeId = "home";
|
||||
} else {
|
||||
// Single source: that source IS the root, no home node
|
||||
const source = waterSources[0];
|
||||
if (source.type === "water" && source.stat_rate) {
|
||||
const value = this._getCurrentFlowRate(source.stat_rate);
|
||||
nodes.push({
|
||||
id: source.stat_rate,
|
||||
label:
|
||||
this._getEntityLabel(source.stat_rate) ||
|
||||
this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_distribution.water"
|
||||
),
|
||||
value: Math.max(0, value),
|
||||
color: waterColor,
|
||||
index: 0,
|
||||
entityId: source.stat_rate,
|
||||
});
|
||||
rootNodeId = source.stat_rate;
|
||||
} else {
|
||||
// Fallback (shouldn't happen)
|
||||
rootNodeId = "home";
|
||||
}
|
||||
}
|
||||
|
||||
// Build a map of device relationships for hierarchy resolution
|
||||
const deviceMap = new Map<
|
||||
string,
|
||||
{ stat_rate?: string; included_in_stat?: string }
|
||||
>();
|
||||
prefs.device_consumption_water.forEach((device) => {
|
||||
deviceMap.set(device.stat_consumption, {
|
||||
stat_rate: device.stat_rate,
|
||||
included_in_stat: device.included_in_stat,
|
||||
});
|
||||
});
|
||||
|
||||
// Set of stat_rate entities that will be rendered as nodes
|
||||
const renderedStatRates = new Set<string>();
|
||||
prefs.device_consumption_water.forEach((device) => {
|
||||
if (device.stat_rate) {
|
||||
const value = this._getCurrentFlowRate(device.stat_rate);
|
||||
if (value >= minFlowThreshold) {
|
||||
renderedStatRates.add(device.stat_rate);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Find the effective parent for hierarchy
|
||||
const findEffectiveParent = (
|
||||
includedInStat: string | undefined
|
||||
): string | undefined => {
|
||||
let currentParent = includedInStat;
|
||||
while (currentParent) {
|
||||
const parentDevice = deviceMap.get(currentParent);
|
||||
if (!parentDevice) return undefined;
|
||||
if (
|
||||
parentDevice.stat_rate &&
|
||||
renderedStatRates.has(parentDevice.stat_rate)
|
||||
) {
|
||||
return parentDevice.stat_rate;
|
||||
}
|
||||
currentParent = parentDevice.included_in_stat;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
let untrackedConsumption = effectiveTotalInflow;
|
||||
const deviceNodes: Node[] = [];
|
||||
const parentLinks: Record<string, string> = {};
|
||||
const smallConsumersByParent = new Map<string, SmallConsumer[]>();
|
||||
|
||||
prefs.device_consumption_water.forEach((device, idx) => {
|
||||
if (!device.stat_rate) return;
|
||||
const value = this._getCurrentFlowRate(device.stat_rate);
|
||||
const effectiveParent = findEffectiveParent(device.included_in_stat);
|
||||
|
||||
if (value < minFlowThreshold) {
|
||||
const parentKey = effectiveParent ?? rootNodeId;
|
||||
if (!smallConsumersByParent.has(parentKey)) {
|
||||
smallConsumersByParent.set(parentKey, []);
|
||||
}
|
||||
smallConsumersByParent.get(parentKey)!.push({
|
||||
statRate: device.stat_rate,
|
||||
name: device.name,
|
||||
value,
|
||||
effectiveParent,
|
||||
idx,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const node = {
|
||||
id: device.stat_rate,
|
||||
label: device.name || this._getEntityLabel(device.stat_rate),
|
||||
value,
|
||||
color: getGraphColorByIndex(idx, computedStyle),
|
||||
index: 4,
|
||||
parent: effectiveParent,
|
||||
entityId: device.stat_rate,
|
||||
};
|
||||
|
||||
if (node.parent) {
|
||||
parentLinks[node.id] = node.parent;
|
||||
links.push({ source: node.parent, target: node.id });
|
||||
} else {
|
||||
untrackedConsumption -= value;
|
||||
}
|
||||
deviceNodes.push(node);
|
||||
});
|
||||
|
||||
// Process small consumers
|
||||
smallConsumersByParent.forEach((consumers, parentKey) => {
|
||||
const totalValue = consumers.reduce((sum, c) => sum + c.value, 0);
|
||||
if (totalValue <= 0) return;
|
||||
|
||||
if (consumers.length === 1) {
|
||||
const consumer = consumers[0];
|
||||
const node = {
|
||||
id: consumer.statRate,
|
||||
label: consumer.name || this._getEntityLabel(consumer.statRate),
|
||||
value: consumer.value,
|
||||
color: getGraphColorByIndex(consumer.idx, computedStyle),
|
||||
index: 4,
|
||||
parent: consumer.effectiveParent,
|
||||
entityId: consumer.statRate,
|
||||
};
|
||||
if (node.parent) {
|
||||
parentLinks[node.id] = node.parent;
|
||||
links.push({ source: node.parent, target: node.id });
|
||||
} else {
|
||||
untrackedConsumption -= consumer.value;
|
||||
}
|
||||
deviceNodes.push(node);
|
||||
} else {
|
||||
const otherNodeId = `other_${parentKey}`;
|
||||
const otherNode: Node = {
|
||||
id: otherNodeId,
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.other"
|
||||
),
|
||||
value: Math.ceil(totalValue),
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 4,
|
||||
};
|
||||
|
||||
if (parentKey !== rootNodeId) {
|
||||
parentLinks[otherNodeId] = parentKey;
|
||||
links.push({ source: parentKey, target: otherNodeId });
|
||||
} else {
|
||||
untrackedConsumption -= totalValue;
|
||||
}
|
||||
deviceNodes.push(otherNode);
|
||||
}
|
||||
});
|
||||
|
||||
const devicesWithoutParent = deviceNodes.filter(
|
||||
(node) => !parentLinks[node.id]
|
||||
);
|
||||
|
||||
const { group_by_area, group_by_floor, layout, title } = this._config;
|
||||
if (group_by_area || group_by_floor) {
|
||||
const { areas, floors } = this._groupByFloorAndArea(devicesWithoutParent);
|
||||
|
||||
Object.keys(floors)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(this.hass.floors[b]?.level ?? -Infinity) -
|
||||
(this.hass.floors[a]?.level ?? -Infinity)
|
||||
)
|
||||
.forEach((floorId) => {
|
||||
let floorNodeId = `floor_${floorId}`;
|
||||
if (floorId === "no_floor" || !group_by_floor) {
|
||||
floorNodeId = rootNodeId;
|
||||
} else {
|
||||
nodes.push({
|
||||
id: floorNodeId,
|
||||
label: this.hass.floors[floorId].name,
|
||||
value: floors[floorId].value,
|
||||
index: 2,
|
||||
color: primaryColor,
|
||||
});
|
||||
links.push({ source: rootNodeId, target: floorNodeId });
|
||||
}
|
||||
|
||||
floors[floorId].areas.forEach((areaId) => {
|
||||
let targetNodeId: string;
|
||||
|
||||
if (areaId === "no_area" || !group_by_area) {
|
||||
targetNodeId = floorNodeId;
|
||||
} else {
|
||||
const areaNodeId = `area_${areaId}`;
|
||||
nodes.push({
|
||||
id: areaNodeId,
|
||||
label: this.hass.areas[areaId]?.name || areaId,
|
||||
value: areas[areaId].value,
|
||||
index: 3,
|
||||
color: primaryColor,
|
||||
});
|
||||
links.push({
|
||||
source: floorNodeId,
|
||||
target: areaNodeId,
|
||||
value: areas[areaId].value,
|
||||
});
|
||||
targetNodeId = areaNodeId;
|
||||
}
|
||||
|
||||
areas[areaId].devices.forEach((device) => {
|
||||
links.push({
|
||||
source: targetNodeId,
|
||||
target: device.id,
|
||||
value: device.value,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
devicesWithoutParent.forEach((deviceNode) => {
|
||||
links.push({
|
||||
source: rootNodeId,
|
||||
target: deviceNode.id,
|
||||
value: deviceNode.value,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const deviceSections = this._getDeviceSections(parentLinks, deviceNodes);
|
||||
deviceSections.forEach((section, index) => {
|
||||
section.forEach((node: Node) => {
|
||||
nodes.push({ ...node, index: 4 + index });
|
||||
});
|
||||
});
|
||||
|
||||
// Untracked consumption (only show if > 1 L/min threshold)
|
||||
if (untrackedConsumption > 1) {
|
||||
nodes.push({
|
||||
id: "untracked",
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
|
||||
),
|
||||
value: untrackedConsumption,
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 3 + deviceSections.length,
|
||||
});
|
||||
links.push({
|
||||
source: rootNodeId,
|
||||
target: "untracked",
|
||||
value: untrackedConsumption,
|
||||
});
|
||||
}
|
||||
|
||||
const hasData = nodes.some((node) => node.value > 0);
|
||||
|
||||
const vertical =
|
||||
layout === "vertical" || (layout !== "horizontal" && this._isMobileSize);
|
||||
|
||||
return html`
|
||||
<ha-card
|
||||
.header=${title}
|
||||
class=${classMap({
|
||||
"is-grid": this.layout === "grid",
|
||||
"is-panel": this.layout === "panel",
|
||||
"is-vertical": vertical,
|
||||
})}
|
||||
>
|
||||
<div class="card-content">
|
||||
${hasData
|
||||
? html`<ha-sankey-chart
|
||||
.data=${{ nodes, links }}
|
||||
.vertical=${vertical}
|
||||
.valueFormatter=${this._valueFormatter}
|
||||
@node-click=${this._handleNodeClick}
|
||||
></ha-sankey-chart>`
|
||||
: html`${this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.no_data"
|
||||
)}`}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _valueFormatter = (value: number) =>
|
||||
`<div style="direction:ltr; display: inline;">
|
||||
${formatFlowRateShort(this.hass.locale, this.hass.config.unit_system.length, value)}
|
||||
</div>`;
|
||||
|
||||
private _handleNodeClick(ev: CustomEvent<{ node: Node }>) {
|
||||
const { node } = ev.detail;
|
||||
if (node.entityId) {
|
||||
fireEvent(this, "hass-more-info", { entityId: node.entityId });
|
||||
}
|
||||
}
|
||||
|
||||
private _getCurrentFlowRate(entityId: string): number {
|
||||
this._entities.add(entityId);
|
||||
return getFlowRateFromState(this.hass.states[entityId]) ?? 0;
|
||||
}
|
||||
|
||||
private _getEntityLabel(entityId: string): string {
|
||||
const stateObj = this.hass.states[entityId];
|
||||
if (!stateObj) return entityId;
|
||||
return stateObj.attributes.friendly_name || entityId;
|
||||
}
|
||||
|
||||
protected _groupByFloorAndArea(deviceNodes: Node[]) {
|
||||
const areas: Record<string, { value: number; devices: Node[] }> = {
|
||||
no_area: { value: 0, devices: [] },
|
||||
};
|
||||
const floors: Record<string, { value: number; areas: string[] }> = {
|
||||
no_floor: { value: 0, areas: ["no_area"] },
|
||||
};
|
||||
|
||||
deviceNodes.forEach((deviceNode) => {
|
||||
const entity = this.hass.states[deviceNode.id];
|
||||
const { area, floor } = entity
|
||||
? getEntityContext(
|
||||
entity,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
)
|
||||
: { area: null, floor: null };
|
||||
|
||||
if (area) {
|
||||
if (area.area_id in areas) {
|
||||
areas[area.area_id].value += deviceNode.value;
|
||||
areas[area.area_id].devices.push(deviceNode);
|
||||
} else {
|
||||
areas[area.area_id] = {
|
||||
value: deviceNode.value,
|
||||
devices: [deviceNode],
|
||||
};
|
||||
}
|
||||
if (floor) {
|
||||
if (floor.floor_id in floors) {
|
||||
floors[floor.floor_id].value += deviceNode.value;
|
||||
if (!floors[floor.floor_id].areas.includes(area.area_id)) {
|
||||
floors[floor.floor_id].areas.push(area.area_id);
|
||||
}
|
||||
} else {
|
||||
floors[floor.floor_id] = {
|
||||
value: deviceNode.value,
|
||||
areas: [area.area_id],
|
||||
};
|
||||
}
|
||||
} else {
|
||||
floors.no_floor.value += deviceNode.value;
|
||||
if (!floors.no_floor.areas.includes(area.area_id)) {
|
||||
floors.no_floor.areas.unshift(area.area_id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
areas.no_area.value += deviceNode.value;
|
||||
areas.no_area.devices.push(deviceNode);
|
||||
}
|
||||
});
|
||||
|
||||
return { areas, floors };
|
||||
}
|
||||
|
||||
protected _getDeviceSections(
|
||||
parentLinks: Record<string, string>,
|
||||
deviceNodes: Node[]
|
||||
): Node[][] {
|
||||
const parentSection: Node[] = [];
|
||||
const childSection: Node[] = [];
|
||||
const parentIds = Object.values(parentLinks);
|
||||
const remainingLinks: typeof parentLinks = {};
|
||||
|
||||
deviceNodes.forEach((deviceNode) => {
|
||||
const isChild = deviceNode.id in parentLinks;
|
||||
const isParent = parentIds.includes(deviceNode.id);
|
||||
if (isParent && !isChild) {
|
||||
parentSection.push(deviceNode);
|
||||
} else {
|
||||
childSection.push(deviceNode);
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(parentLinks).forEach(([child, parent]) => {
|
||||
if (!parentSection.some((node) => node.id === parent)) {
|
||||
remainingLinks[child] = parent;
|
||||
}
|
||||
});
|
||||
|
||||
if (parentSection.length > 0) {
|
||||
return [
|
||||
parentSection,
|
||||
...this._getDeviceSections(remainingLinks, childSection),
|
||||
];
|
||||
}
|
||||
|
||||
return [deviceNodes];
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-card {
|
||||
height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
--chart-max-height: none;
|
||||
}
|
||||
ha-card.is-vertical {
|
||||
height: 500px;
|
||||
}
|
||||
ha-card.is-grid,
|
||||
ha-card.is-panel {
|
||||
height: 100%;
|
||||
}
|
||||
.card-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-water-flow-sankey-card": HuiWaterFlowSankeyCard;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@ const ALWAYS_LOADED_TYPES = new Set(["error", "entity"]);
|
||||
const LAZY_LOAD_TYPES = {
|
||||
"entity-filter": () => import("../badges/hui-entity-filter-badge"),
|
||||
"state-label": () => import("../badges/hui-state-label-badge"),
|
||||
"power-total": () => import("../badges/energy/hui-power-total-badge"),
|
||||
"gas-total": () => import("../badges/energy/hui-gas-total-badge"),
|
||||
"water-total": () => import("../badges/energy/hui-water-total-badge"),
|
||||
};
|
||||
|
||||
// This will not return an error card but will throw the error
|
||||
|
||||
@@ -67,6 +67,8 @@ const LAZY_LOAD_TYPES = {
|
||||
import("../cards/energy/hui-energy-usage-graph-card"),
|
||||
"energy-sankey": () => import("../cards/energy/hui-energy-sankey-card"),
|
||||
"water-sankey": () => import("../cards/water/hui-water-sankey-card"),
|
||||
"water-flow-sankey": () =>
|
||||
import("../cards/water/hui-water-flow-sankey-card"),
|
||||
"power-sources-graph": () =>
|
||||
import("../cards/energy/hui-power-sources-graph-card"),
|
||||
"power-sankey": () => import("../cards/energy/hui-power-sankey-card"),
|
||||
|
||||
@@ -142,7 +142,6 @@ export class HuiCreateDialogBadge
|
||||
`
|
||||
: html`
|
||||
<hui-entity-picker-table
|
||||
no-label-float
|
||||
.hass=${this.hass}
|
||||
.narrow=${true}
|
||||
@selected-changed=${this._handleSelectedChanged}
|
||||
|
||||
@@ -127,26 +127,30 @@ export class HuiCreateDialogCard
|
||||
></ha-icon-button>
|
||||
<span slot="title">${title}</span>
|
||||
|
||||
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
|
||||
<ha-tab-group-tab
|
||||
slot="nav"
|
||||
.active=${this._currTab === "card"}
|
||||
panel="card"
|
||||
?autofocus=${this._narrow}
|
||||
>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.cardpicker.by_card"
|
||||
)}
|
||||
</ha-tab-group-tab>
|
||||
<ha-tab-group-tab
|
||||
slot="nav"
|
||||
.active=${this._currTab === "entity"}
|
||||
panel="entity"
|
||||
>${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.cardpicker.by_entity"
|
||||
)}</ha-tab-group-tab
|
||||
>
|
||||
</ha-tab-group>
|
||||
${!this._params.saveCard
|
||||
? html`
|
||||
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
|
||||
<ha-tab-group-tab
|
||||
slot="nav"
|
||||
.active=${this._currTab === "card"}
|
||||
panel="card"
|
||||
?autofocus=${this._narrow}
|
||||
>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.cardpicker.by_card"
|
||||
)}
|
||||
</ha-tab-group-tab>
|
||||
<ha-tab-group-tab
|
||||
slot="nav"
|
||||
.active=${this._currTab === "entity"}
|
||||
panel="entity"
|
||||
>${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.cardpicker.by_entity"
|
||||
)}</ha-tab-group-tab
|
||||
>
|
||||
</ha-tab-group>
|
||||
`
|
||||
: nothing}
|
||||
</ha-dialog-header>
|
||||
${cache(
|
||||
this._currTab === "card"
|
||||
@@ -161,7 +165,6 @@ export class HuiCreateDialogCard
|
||||
`
|
||||
: html`
|
||||
<hui-entity-picker-table
|
||||
no-label-float
|
||||
.hass=${this.hass}
|
||||
narrow
|
||||
@selected-changed=${this._handleSelectedChanged}
|
||||
@@ -255,6 +258,17 @@ export class HuiCreateDialogCard
|
||||
}
|
||||
}
|
||||
|
||||
if (this._params!.saveCard) {
|
||||
showEditCardDialog(this, {
|
||||
lovelaceConfig: this._params!.lovelaceConfig,
|
||||
saveCardConfig: this._params!.saveCard,
|
||||
cardConfig: config,
|
||||
isNew: true,
|
||||
});
|
||||
this.closeDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
const lovelaceConfig = this._params!.lovelaceConfig;
|
||||
const containerPath = this._params!.path;
|
||||
const saveConfig = this._params!.saveConfig;
|
||||
|
||||
@@ -43,9 +43,6 @@ export class HuiEntityPickerTable extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property({ type: Boolean, attribute: "no-label-float" })
|
||||
public noLabelFloat? = false;
|
||||
|
||||
@property({ type: Array }) public entities?: string[];
|
||||
|
||||
protected firstUpdated(_changedProperties: PropertyValues): void {
|
||||
@@ -115,7 +112,6 @@ export class HuiEntityPickerTable extends LitElement {
|
||||
.searchLabel=${this.hass.localize(
|
||||
"ui.panel.lovelace.unused_entities.search"
|
||||
)}
|
||||
.noLabelFloat=${this.noLabelFloat}
|
||||
.noDataText=${this.hass.localize(
|
||||
"ui.panel.lovelace.unused_entities.no_data"
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
|
||||
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
|
||||
import type { LovelaceContainerPath } from "../lovelace-path";
|
||||
|
||||
@@ -8,6 +9,7 @@ export interface CreateCardDialogParams {
|
||||
path: LovelaceContainerPath;
|
||||
suggestedCards?: string[];
|
||||
entities?: string[]; // We can pass entity id's that will be added to the config when a card is picked
|
||||
saveCard?: (cardConfig: LovelaceCardConfig) => void; // Optional: pick a single card and return it via callback, hides entity tab
|
||||
}
|
||||
|
||||
export const importCreateCardDialog = () => import("./hui-dialog-create-card");
|
||||
|
||||
@@ -128,52 +128,7 @@ export class HuiSaveConfig extends LitElement implements HassDialog {
|
||||
`
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this._params.mode === "storage"
|
||||
? html`
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
appearance="plain"
|
||||
@click=${this.closeDialog}
|
||||
>
|
||||
${this.hass!.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._saveConfig}
|
||||
.loading=${this._saving}
|
||||
>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.save_config.save"
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
`
|
||||
: html`
|
||||
<p>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.save_config.yaml_mode"
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.save_config.yaml_control"
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.save_config.yaml_config"
|
||||
)}
|
||||
</p>
|
||||
<ha-yaml-editor
|
||||
.hass=${this.hass}
|
||||
.defaultValue=${this._params!.lovelace.config}
|
||||
autofocus
|
||||
></ha-yaml-editor>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
|
||||
${
|
||||
this._params.mode === "storage"
|
||||
? html`
|
||||
|
||||
@@ -46,6 +46,8 @@ import "./hui-section-settings-editor";
|
||||
import "./hui-section-visibility-editor";
|
||||
import type { EditSectionDialogParams } from "./show-edit-section-dialog";
|
||||
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
|
||||
import { getViewType } from "../../views/get-view-type";
|
||||
import { SECTIONS_VIEW_LAYOUT } from "../../views/const";
|
||||
|
||||
const TABS = ["tab-settings", "tab-visibility"] as const;
|
||||
|
||||
@@ -290,13 +292,16 @@ export class HuiDialogEditSection
|
||||
|
||||
const toView = selectedDashConfig.views[viewIndex];
|
||||
|
||||
if (isStrategyView(toView)) {
|
||||
if (
|
||||
isStrategyView(toView) ||
|
||||
getViewType(toView) !== SECTIONS_VIEW_LAYOUT
|
||||
) {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.move_section.error_title"
|
||||
),
|
||||
text: this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.move_section.error_text_strategy"
|
||||
"ui.panel.lovelace.editor.move_section.error_text"
|
||||
),
|
||||
warning: true,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { mdiDotsVertical, mdiPlaylistEdit } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { deepEqual } from "../../../../common/util/deep-equal";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-dialog";
|
||||
import "../../../../components/ha-dialog-footer";
|
||||
import "../../../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
|
||||
import "../../../../components/ha-dropdown-item";
|
||||
import "../../../../components/ha-spinner";
|
||||
import "../../../../components/ha-yaml-editor";
|
||||
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
|
||||
import type { LovelaceViewFooterConfig } from "../../../../data/lovelace/config/view";
|
||||
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
|
||||
import {
|
||||
haStyleDialog,
|
||||
haStyleDialogFixedTop,
|
||||
} from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import "./hui-view-footer-settings-editor";
|
||||
import type { EditViewFooterDialogParams } from "./show-edit-view-footer-dialog";
|
||||
|
||||
@customElement("hui-dialog-edit-view-footer")
|
||||
export class HuiDialogEditViewFooter extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _params?: EditViewFooterDialogParams;
|
||||
|
||||
@state() private _config?: LovelaceViewFooterConfig;
|
||||
|
||||
@state() private _saving = false;
|
||||
|
||||
@state() private _dirty = false;
|
||||
|
||||
@state() private _yamlMode = false;
|
||||
|
||||
@query("ha-yaml-editor") private _editor?: HaYamlEditor;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
protected updated(changedProperties: PropertyValues) {
|
||||
if (this._yamlMode && changedProperties.has("_yamlMode")) {
|
||||
const config = {
|
||||
...this._config,
|
||||
};
|
||||
this._editor?.setValue(config);
|
||||
}
|
||||
}
|
||||
|
||||
public showDialog(params: EditViewFooterDialogParams): void {
|
||||
this._params = params;
|
||||
|
||||
this._dirty = false;
|
||||
this._config = this._params.config;
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
public closeDialog(): void {
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._params = undefined;
|
||||
this._config = undefined;
|
||||
this._yamlMode = false;
|
||||
this._dirty = false;
|
||||
this._saving = false;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params || !this.hass) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
let content: TemplateResult;
|
||||
|
||||
if (this._yamlMode) {
|
||||
content = html`
|
||||
<ha-yaml-editor
|
||||
.hass=${this.hass}
|
||||
autofocus
|
||||
@value-changed=${this._viewYamlChanged}
|
||||
></ha-yaml-editor>
|
||||
`;
|
||||
} else {
|
||||
content = html`
|
||||
<hui-view-footer-settings-editor
|
||||
.hass=${this.hass}
|
||||
.config=${this._config}
|
||||
.maxColumns=${this._params.maxColumns}
|
||||
@config-changed=${this._configChanged}
|
||||
></hui-view-footer-settings-editor>
|
||||
`;
|
||||
}
|
||||
|
||||
const title = this.hass.localize(
|
||||
"ui.panel.lovelace.editor.edit_view_footer.header"
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
.hass=${this.hass}
|
||||
.open=${this._open}
|
||||
header-title=${title}
|
||||
.width=${this._yamlMode ? "full" : "large"}
|
||||
@closed=${this._dialogClosed}
|
||||
class=${this._yamlMode ? "yaml-mode" : ""}
|
||||
>
|
||||
<ha-dropdown
|
||||
slot="headerActionItems"
|
||||
placement="bottom-end"
|
||||
@wa-select=${this._handleAction}
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
.label=${this.hass!.localize("ui.common.menu")}
|
||||
.path=${mdiDotsVertical}
|
||||
></ha-icon-button>
|
||||
<ha-dropdown-item value="toggle-mode">
|
||||
${this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.edit_view_footer.edit_${!this._yamlMode ? "yaml" : "ui"}`
|
||||
)}
|
||||
<ha-svg-icon slot="icon" .path=${mdiPlaylistEdit}></ha-svg-icon>
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
${content}
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
.disabled=${!this._config || !this._dirty}
|
||||
@click=${this._save}
|
||||
.loading=${this._saving}
|
||||
>
|
||||
${this.hass!.localize("ui.common.save")}</ha-button
|
||||
>
|
||||
</ha-dialog-footer>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _handleAction(ev: HaDropdownSelectEvent) {
|
||||
const action = ev.detail.item.value;
|
||||
|
||||
if (action === "toggle-mode") {
|
||||
this._yamlMode = !this._yamlMode;
|
||||
}
|
||||
}
|
||||
|
||||
private _configChanged(ev: CustomEvent): void {
|
||||
if (
|
||||
ev.detail &&
|
||||
ev.detail.config &&
|
||||
!deepEqual(this._config, ev.detail.config)
|
||||
) {
|
||||
this._config = ev.detail.config;
|
||||
this._dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
private _viewYamlChanged(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
if (!ev.detail.isValid) {
|
||||
return;
|
||||
}
|
||||
this._config = ev.detail.value;
|
||||
this._dirty = true;
|
||||
}
|
||||
|
||||
private async _save(): Promise<void> {
|
||||
if (!this._params || !this._config) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._saving = true;
|
||||
|
||||
try {
|
||||
await this._params.saveConfig(this._config);
|
||||
this.closeDialog();
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, {
|
||||
text: `${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.edit_view_footer.saving_failed"
|
||||
)}: ${err.message}`,
|
||||
});
|
||||
} finally {
|
||||
this._saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleDialog,
|
||||
haStyleDialogFixedTop,
|
||||
css`
|
||||
ha-dialog.yaml-mode {
|
||||
--dialog-content-padding: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-dialog-edit-view-footer": HuiDialogEditViewFooter;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
import type {
|
||||
HaFormSchema,
|
||||
SchemaUnion,
|
||||
} from "../../../../components/ha-form/types";
|
||||
import type { LovelaceViewFooterConfig } from "../../../../data/lovelace/config/view";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
|
||||
@customElement("hui-view-footer-settings-editor")
|
||||
export class HuiViewFooterSettingsEditor extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public config?: LovelaceViewFooterConfig;
|
||||
|
||||
@property({ attribute: false }) public maxColumns = 4;
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(maxColumns: number) =>
|
||||
[
|
||||
{
|
||||
name: "column_span",
|
||||
selector: {
|
||||
number: {
|
||||
min: 1,
|
||||
max: maxColumns,
|
||||
slider_ticks: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const satisfies HaFormSchema[]
|
||||
);
|
||||
|
||||
protected render() {
|
||||
const data = {
|
||||
column_span: this.config?.column_span || 1,
|
||||
};
|
||||
|
||||
const schema = this._schema(this.maxColumns);
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${data}
|
||||
.schema=${schema}
|
||||
.computeLabel=${this._computeLabel}
|
||||
.computeHelper=${this._computeHelper}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
`;
|
||||
}
|
||||
|
||||
private _valueChanged(ev: CustomEvent): void {
|
||||
ev.stopPropagation();
|
||||
const newData = ev.detail.value;
|
||||
|
||||
const config: LovelaceViewFooterConfig = {
|
||||
...this.config,
|
||||
...newData,
|
||||
};
|
||||
|
||||
fireEvent(this, "config-changed", { config });
|
||||
}
|
||||
|
||||
private _computeLabel = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
) =>
|
||||
this.hass.localize(
|
||||
`ui.panel.lovelace.editor.edit_view_footer.settings.${schema.name}`
|
||||
);
|
||||
|
||||
private _computeHelper = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
) =>
|
||||
this.hass.localize(
|
||||
`ui.panel.lovelace.editor.edit_view_footer.settings.${schema.name}_helper`
|
||||
) || "";
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-view-footer-settings-editor": HuiViewFooterSettingsEditor;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user