Compare commits

..
Author SHA1 Message Date
Petar Petrov 56cf89db5f Add type to mount candidates 2026-08-20 12:42:18 +03:00
Petar Petrov e4f54540c2 Add local disk mounts to storage settings
Support the new disk mount type from the Supervisor mount API:

- Model disk mounts in the mounts data layer. Only network mounts have a
  server and a port, so those move out of the shared base, and a disk
  mount carries the uuid and filesystem Supervisor resolves it to.
- Offer "Local disk" when adding storage, with the disk picked from the
  devices Supervisor reports as mountable. A device the host reports as
  write-protected forces the read-only option on, and is not offered for
  backups at all, as Supervisor refuses a read-only backup mount.
- Hide the option when Supervisor does not have the candidates endpoint,
  and explain it rather than error when no suitable disk is connected.
- Show the disk a mount already uses as fixed text when editing it,
  since Supervisor excludes a mounted device from the candidates.
- Describe disk mounts in the storage panel and the mount picker. Both
  built their secondary line from server, share and path, which a disk
  mount does not have, so it rendered as "undefined".
- Retitle the card to "Additional storage", as it is no longer limited
  to storage reached over the network.

Also fixes the mount picker dropping its mount type filter whenever a
usage was set, which reapplied the filter to the unfiltered list.
2026-08-14 16:12:13 +03:00
145 changed files with 3770 additions and 10736 deletions
+24 -40
View File
@@ -1,52 +1,36 @@
[modern]
# Modern builds target recent browsers supporting the latest features to minimize transpilation, polyfills, etc.
# It is served to browsers meeting the following requirements:
# - released in the last 2 years + current alpha/beta versions
# - released in the last year + current alpha/beta versions
# - Firefox extended support release (ESR)
# - with global utilization at or above 0.5%
# - exclude dead browsers (no security maintenance for 2+ years)
# - exclude QQ, and UC browsers due to lack of sufficient feature support data
# - exclude KaiOS, QQ, and UC browsers due to lack of sufficient feature support data
unreleased versions
last 2 years
last 1 year
Firefox ESR
>= 0.5%
not dead
not KaiOS > 0
not QQAndroid > 0
not UCAndroid > 0
[legacy]
# Legacy builds are served when modern requirements are not met.
# Floors are pinned explicitly (not usage-based) so the support policy is
# deliberate and does not drift with global usage statistics, which do not
# represent old tablets and wall displays used as Home Assistant dashboards:
# - iOS/Safari >= 12: iPad Air 1 / mini 2 / mini 3 (last supported iOS).
# Older iPads (iPad 2/3/mini 1 on iOS 9.3, iPad 4 on iOS 10.3) cannot run
# the app: their engines lack custom elements, shadow DOM, and/or CSS grid.
# - Chrome >= 59: Fire OS 5 tablets (Fire 7/HD 8/HD 10 through ~2017) have
# their system WebView pinned at Chromium 59 and commonly run Fully Kiosk;
# also covers old kiosk browsers and no-longer-updating webviews above it.
# - Edge >= 79: all Chromium-based Edge. Costs nothing (above the Chrome
# floor); mainly catches Edge 109 on Windows 7/8.1 and update-frozen
# enterprise installs, whose engines can run the legacy build fine.
# - Firefox >= 94: the zero-cost floor, not a chased population — pinning it
# adds no babel transforms, core-js modules, or Lightning CSS prefixes to
# the output. Mozilla-supported Firefox (incl. current ESR) always matches
# [modern]; Firefox on old OSes is not supported (use Chrome 109 instead).
# If Firefox ever becomes the pin forcing extra output, raise it first.
# - Samsung >= 9: Samsung Internet on old Galaxy tablets
Chrome >= 59
ChromeAndroid >= 59
Edge >= 79
Firefox >= 94
FirefoxAndroid >= 94
iOS >= 12
Safari >= 12
Samsung >= 9
# Legacy builds are served when modern requirements are not met and support browsers:
# - released in the last 7 years + current alpha/beta versionss
# - with global utilization at or above 0.05%
# - exclude dead browsers (no security maintenance for 2+ years)
# - exclude Opera Mini which does not support web sockets
unreleased versions
last 7 years
>= 0.05%
not dead
not op_mini all
[legacy-sw]
# Same as legacy, restricted to browsers that support service workers
# (currently resolves to the same set; guards the service worker build if the legacy floor ever drops below them)
Chrome >= 59 and supports serviceworkers
ChromeAndroid >= 59 and supports serviceworkers
Edge >= 79 and supports serviceworkers
Firefox >= 94 and supports serviceworkers
FirefoxAndroid >= 94 and supports serviceworkers
iOS >= 12 and supports serviceworkers
Safari >= 12 and supports serviceworkers
Samsung >= 9 and supports serviceworkers
# Same as legacy plus supports service workers
unreleased versions
last 7 years
>= 0.05% and supports serviceworkers
not dead
not op_mini all
+2 -2
View File
@@ -32,12 +32,12 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
with:
languages: javascript-typescript
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
with:
category: "/language:javascript-typescript"
+8 -8
View File
@@ -1,15 +1,15 @@
{
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
"frontend-modern": {
"app": 576583,
"core": 54790,
"authorize": 543547,
"onboarding": 655556
"app": 595204,
"core": 57741,
"authorize": 576928,
"onboarding": 685964
},
"frontend-legacy": {
"app": 717124,
"core": 181583,
"authorize": 699175,
"onboarding": 816133
"app": 861452,
"core": 258557,
"authorize": 834356,
"onboarding": 1001360
}
}
+2 -4
View File
@@ -63,10 +63,8 @@ module.exports.htmlMinifierOptions = {
};
module.exports.terserOptions = ({ latestBuild, isTestBuild }) => ({
// Highest syntax the minifier may emit; it never downlevels. Every browser
// in [modern] is well past ES2020 (universal since spring 2020); the
// [legacy] floors (Chrome 59 / Safari 12) top out at ES2017.
ecma: latestBuild ? 2020 : 2017,
safari10: !latestBuild,
ecma: latestBuild ? 2015 : 5,
module: latestBuild,
format: { comments: false },
sourceMap: !isTestBuild,
-5
View File
@@ -102,11 +102,6 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
cost_sensors: {},
solar_forecast_domains: [],
}));
hass.mockWS("energy/validate", () => ({
energy_sources: Array.from({ length: 6 }, () => []),
device_consumption: Array.from({ length: 6 }, () => []),
device_consumption_water: Array.from({ length: 2 }, () => []),
}));
hass.mockWS(
"energy/fossil_energy_consumption",
({ period }): FossilEnergyConsumption => ({
-6
View File
@@ -1,12 +1,6 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockSensor = (hass: MockHomeAssistant) => {
hass.mockWS(
"sensor/device_class_convertible_units",
({ device_class }: { device_class: string }) => ({
units: device_class === "energy" ? ["kWh"] : ["W"],
})
);
hass.mockWS("sensor/numeric_device_classes", () => ({
numeric_device_classes: [
"volume_storage",
@@ -25,9 +25,6 @@ title: Button
<ha-button appearance="filled">
filled button
</ha-button>
<ha-button appearance="outlined">
outlined button
</ha-button>
<ha-button size="s">
small
@@ -68,7 +65,7 @@ Check the [webawesome documentation](https://webawesome.com/docs/components/butt
| Name | Type | Default | Description |
| ---------- | ---------------------------------------------- | -------- | --------------------------------------------------------------------------------- |
| appearance | "accent"/"filled"/"outlined"/"plain" | "accent" | Sets the button appearance. |
| appearance | "accent"/"filled"/"plain" | "accent" | Sets the button appearance. |
| variants | "brand"/"danger"/"neutral"/"warning"/"success" | "brand" | Sets the button color variant. "brand" is default. |
| size | "xs"/"s"/"m"/"l"/"xl" | "m" | Sets the button size. |
| loading | Boolean | false | Shows a loading indicator instead of the buttons label and disable buttons click. |
+1 -1
View File
@@ -9,7 +9,7 @@ import "../../../../src/components/ha-svg-icon";
import { mdiHomeAssistant } from "../../../../src/resources/home-assistant-logo-svg";
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
const appearances = ["accent", "filled", "outlined", "plain"];
const appearances = ["accent", "filled", "plain"];
const variants = ["brand", "danger", "neutral", "warning", "success"];
@customElement("demo-components-ha-button")
-125
View File
@@ -29,50 +29,6 @@ const positions: Position[] = ["start", "end"];
const selectedStates = [false, true];
const disabledStates = [false, true];
interface TreeChild {
key: string;
label: string;
}
interface TreeGroup {
key: string;
label: string;
children: TreeChild[];
}
const treeGroups: TreeGroup[] = [
{
key: "binary_sensor",
label: "Binary sensor",
children: [
{ key: "door", label: "Door" },
{ key: "motion", label: "Motion" },
{ key: "window", label: "Window" },
],
},
{
key: "cover",
label: "Cover",
children: [
{ key: "garage", label: "Garage" },
{ key: "shutter", label: "Shutter" },
],
},
];
interface TreeRow {
group: TreeGroup;
child?: TreeChild;
}
const treeRows: TreeRow[] = treeGroups.flatMap((group) => [
{ group },
...group.children.map((child) => ({ group, child })),
]);
const treeKey = (group: TreeGroup, child: TreeChild) =>
`${group.key}/${child.key}`;
@customElement("demo-components-ha-list")
export class DemoHaList extends LitElement {
@state() private _buttonClicks = 0;
@@ -85,8 +41,6 @@ export class DemoHaList extends LitElement {
@state() private _multiCheckEnd: number | Set<number> = new Set();
@state() private _tree = new Set<string>();
private _options = ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"];
protected render(): TemplateResult {
@@ -320,45 +274,6 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckStart))}</pre>
selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre>
</ha-card>
<ha-card header="Controlled selection with indeterminate groups">
<ha-list-selectable
multi
controlled
aria-label="Controlled tree"
@ha-list-item-selected=${this._onTreeToggle}
@ha-list-item-deselected=${this._onTreeToggle}
>
${treeGroups.map((group) => {
const groupState = this._groupState(group);
return html`
<ha-list-item-option
appearance="checkbox"
selection-position="end"
.value=${group.key}
?selected=${groupState === "all"}
?indeterminate=${groupState === "some"}
>
<span slot="headline">${group.label}</span>
</ha-list-item-option>
${group.children.map(
(child) => html`
<ha-list-item-option
class="child"
appearance="checkbox"
selection-position="end"
.value=${treeKey(group, child)}
?selected=${this._tree.has(treeKey(group, child))}
>
<span slot="headline">${child.label}</span>
</ha-list-item-option>
`
)}
`;
})}
</ha-list-selectable>
<pre>selected: ${JSON.stringify([...this._tree])}</pre>
</ha-card>
<ha-card header="Option: all combinations">
<div class="grid">
${appearances.map((appearance) =>
@@ -446,43 +361,6 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre>
return next;
}
private _groupState(group: TreeGroup): "none" | "some" | "all" {
const selected = group.children.filter((child) =>
this._tree.has(treeKey(group, child))
).length;
if (selected === 0) {
return "none";
}
return selected === group.children.length ? "all" : "some";
}
private _onTreeToggle = (ev: CustomEvent<number>) => {
const row = treeRows[ev.detail];
if (!row) {
return;
}
const next = new Set(this._tree);
if (row.child) {
const key = treeKey(row.group, row.child);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
} else {
const select = this._groupState(row.group) !== "all";
row.group.children.forEach((child) => {
const key = treeKey(row.group, child);
if (select) {
next.add(key);
} else {
next.delete(key);
}
});
}
this._tree = next;
};
private _onSingle = (ev: CustomEvent<number>) => {
this._single = ev.detail;
};
@@ -565,9 +443,6 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre>
.drag-handle {
cursor: grab;
}
.child::part(base) {
padding-inline-start: var(--ha-space-12);
}
`;
}
@@ -1,3 +1,4 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
@@ -8,6 +9,7 @@ import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervis
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
import "../../../../src/components/ha-selector/ha-selector";
import "../../../../src/components/ha-settings-row";
import "../../../../src/components/ha-target-picker";
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { EntityRegistryDisplayEntry } from "../../../../src/data/entity/entity_registry";
@@ -153,6 +155,9 @@ interface Sample {
description: string;
selector: Selector;
value: unknown;
// Render ha-target-picker directly in compact (chip) mode instead of the
// ha-selector, which does not expose the compact option.
compact?: boolean;
}
const SAMPLES: Sample[] = [
@@ -163,6 +168,14 @@ const SAMPLES: Sample[] = [
selector: { target: {} },
value: { device_id: ["old_composite"] },
},
{
name: "Target (compact)",
description:
"In compact mode the replaced reference is shown as a warning chip.",
selector: { target: {} },
value: { device_id: ["old_composite"] },
compact: true,
},
{
name: "Device (unfiltered, multiple matches)",
description:
@@ -261,13 +274,23 @@ class DemoHaSelectorReplacedDevice
<ha-settings-row narrow slot=${slot}>
<span slot="heading">${sample.name}</span>
<span slot="description">${sample.description}</span>
<ha-selector
.hass=${this.hass}
.selector=${sample.selector}
.value=${this._values[idx]}
.sampleIdx=${idx}
@value-changed=${this._handleValueChanged}
></ha-selector>
${
sample.compact
? html`<ha-target-picker
compact
.hass=${this.hass}
.value=${this._values[idx] as HassServiceTarget}
.sampleIdx=${idx}
@value-changed=${this._handleValueChanged}
></ha-target-picker>`
: html`<ha-selector
.hass=${this.hass}
.selector=${sample.selector}
.value=${this._values[idx]}
.sampleIdx=${idx}
@value-changed=${this._handleValueChanged}
></ha-selector>`
}
</ha-settings-row>
`
)}
+11 -1
View File
@@ -15,6 +15,7 @@ const ALL_FEATURES =
VacuumEntityFeature.STOP +
VacuumEntityFeature.RETURN_HOME +
VacuumEntityFeature.FAN_SPEED +
VacuumEntityFeature.BATTERY +
VacuumEntityFeature.STATUS +
VacuumEntityFeature.LOCATE +
VacuumEntityFeature.CLEAN_SPOT +
@@ -27,6 +28,8 @@ const ENTITIES = [
attributes: {
friendly_name: "Full featured vacuum",
supported_features: ALL_FEATURES,
battery_level: 85,
battery_icon: "mdi:battery-80",
fan_speed: "balanced",
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
status: "Charged",
@@ -38,6 +41,8 @@ const ENTITIES = [
attributes: {
friendly_name: "Cleaning vacuum",
supported_features: ALL_FEATURES,
battery_level: 62,
battery_icon: "mdi:battery-60",
fan_speed: "turbo",
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
status: "Cleaning bedroom",
@@ -53,7 +58,10 @@ const ENTITIES = [
VacuumEntityFeature.START +
VacuumEntityFeature.PAUSE +
VacuumEntityFeature.STOP +
VacuumEntityFeature.RETURN_HOME,
VacuumEntityFeature.RETURN_HOME +
VacuumEntityFeature.BATTERY,
battery_level: 23,
battery_icon: "mdi:battery-20",
status: "Returning to dock",
},
},
@@ -88,6 +96,8 @@ const ENTITIES = [
attributes: {
friendly_name: "Paused vacuum",
supported_features: ALL_FEATURES,
battery_level: 45,
battery_icon: "mdi:battery-40",
fan_speed: "standard",
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
status: "Paused",
+10 -11
View File
@@ -42,14 +42,14 @@
"@babel/runtime": "8.0.0",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.3",
"@codemirror/commands": "6.11.0",
"@codemirror/commands": "6.10.4",
"@codemirror/lang-jinja": "6.0.1",
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.12.4",
"@codemirror/lint": "6.9.7",
"@codemirror/search": "6.7.1",
"@codemirror/state": "6.7.1",
"@codemirror/view": "6.43.9",
"@codemirror/view": "6.43.8",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.6.0",
@@ -89,7 +89,7 @@
"@vvo/tzdb": "6.198.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
"barcode-detector": "3.2.2",
"barcode-detector": "3.2.1",
"cally": "0.9.2",
"color-name": "2.1.1",
"comlink": "4.4.2",
@@ -103,11 +103,11 @@
"echarts": "6.1.0",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.5.0",
"hls.js": "1.7.0",
"hls.js": "1.6.17",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.13",
"js-yaml": "5.3.0",
"js-yaml": "5.2.3",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
@@ -145,14 +145,14 @@
"@bundle-stats/plugin-webpack-filter": "4.22.2",
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@html-eslint/eslint-plugin": "0.65.0",
"@html-eslint/eslint-plugin": "0.64.0",
"@lokalise/node-api": "16.3.0",
"@octokit/auth-oauth-device": "8.0.4",
"@octokit/plugin-retry": "8.1.1",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.62.1",
"@rsdoctor/rspack-plugin": "1.6.2",
"@rspack/core": "2.1.10",
"@rsdoctor/rspack-plugin": "1.6.1",
"@rspack/core": "2.1.8",
"@rspack/dev-server": "2.2.0",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -172,7 +172,6 @@
"@vitest/coverage-v8": "4.1.10",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist": "4.28.8",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.8.1",
@@ -187,7 +186,7 @@
"fs-extra": "11.4.0",
"generate-license-file": "4.2.1",
"glob": "13.0.6",
"globals": "17.11.0",
"globals": "17.9.0",
"gulp": "5.0.1",
"gulp-json-transform": "0.5.0",
"gulp-rename": "2.1.0",
@@ -196,7 +195,6 @@
"jsdom": "30.0.1",
"jszip": "3.10.1",
"license-checker-rseidelsohn": "5.0.1",
"lightningcss": "1.33.0",
"lint-staged": "17.3.0",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
@@ -225,6 +223,7 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.2",
"@fullcalendar/daygrid": "6.1.21",
"globals": "17.9.0",
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
},
@@ -1,217 +0,0 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from "@lit/reactive-element/reactive-controller";
import { css, type LitElement } from "lit";
import type { Ref } from "lit/directives/ref";
import { parseAnimationDuration } from "../util/parse-animation-duration";
type FilterPanelHost = ReactiveControllerHost &
LitElement & { expanded: boolean };
const EASING = "cubic-bezier(0.4, 0, 0.2, 1)";
/**
* Layout the controller relies on: the filter is a flex column made of its
* header (`ha-expansion-panel`) and a `.content` wrapper. Collapsed, it is as
* tall as its header; expanded, it fills what is left of the pane and hands
* that space down to the list through the wrapper.
*/
export const filterPanelStyles = css`
:host {
display: flex;
flex-direction: column;
box-sizing: border-box;
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
flex: none;
--ha-card-border-radius: var(--ha-border-radius-square);
}
ha-expansion-panel::part(summary) {
-webkit-user-select: none;
user-select: none;
}
.content {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
`;
const panels = new Set<FilterPanelController>();
let pending: Set<FilterPanelController> | undefined;
const flush = () => {
const batch = [...pending!].filter((panel) => panel.host.isConnected);
pending = undefined;
batch.forEach((panel) => panel.prepare());
batch.forEach((panel) => panel.measure());
batch.forEach((panel) => panel.play());
};
// Filters that change in the same frame (one closing while another opens) are
// animated as one batch: every filter is measured before any of them has
// changed the DOM, and the closing ones are parked at their final height so
// the opening one reads its own final height from the real layout.
const enqueue = (panel: FilterPanelController) => {
if (!pending) {
pending = new Set();
panels.forEach((other) => other.snapshot());
requestAnimationFrame(flush);
}
pending.add(panel);
};
const clearInlineStyles = (element?: HTMLElement) => {
element?.style.removeProperty("height");
element?.style.removeProperty("flex");
element?.style.removeProperty("overflow");
};
/**
* Animates a filter of the filter pane between its collapsed and expanded
* heights whenever `expanded` changes, and tells the host when to render its
* content: from the moment it expands until its collapse animation has ended.
*
* During the animation the content keeps its final size and the host clips
* it, so the list is revealed rather than resized.
*/
export class FilterPanelController implements ReactiveController {
public showContent = false;
public host: FilterPanelHost;
private _content: Ref<HTMLElement>;
private _expanded?: boolean;
private _first = 0;
private _last = 0;
private _contentHeight = 0;
private _animation?: Animation;
constructor(host: FilterPanelHost, content: Ref<HTMLElement>) {
this.host = host;
this._content = content;
host.addController(this);
}
public hostConnected() {
panels.add(this);
}
public hostDisconnected() {
panels.delete(this);
this._animation?.cancel();
this._animation = undefined;
clearInlineStyles(this.host);
clearInlineStyles(this._content.value);
}
public hostUpdate() {
const expanded = this.host.expanded;
if (this._expanded === undefined) {
this._expanded = expanded;
this.showContent = expanded;
return;
}
if (expanded === this._expanded) {
return;
}
this._expanded = expanded;
if (!this.host.isConnected) {
this.showContent = expanded;
return;
}
if (expanded) {
this.showContent = true;
}
enqueue(this);
}
public snapshot() {
this._first = this.host.getBoundingClientRect().height;
}
public prepare() {
this._animation?.cancel();
this._animation = undefined;
const host = this.host;
const content = this._content.value;
clearInlineStyles(host);
clearInlineStyles(content);
if (host.expanded) {
return;
}
this._last =
host.getBoundingClientRect().height -
(content?.getBoundingClientRect().height ?? 0);
this._contentHeight = this._first - this._last;
host.style.flex = "none";
host.style.height = `${this._last}px`;
}
public measure() {
if (!this.host.expanded) {
return;
}
this._last = this.host.getBoundingClientRect().height;
this._contentHeight =
this._content.value?.getBoundingClientRect().height ?? 0;
}
public play() {
const host = this.host;
if (this._first === this._last) {
this._finish();
return;
}
const content = this._content.value;
host.style.flex = "none";
host.style.overflow = "hidden";
if (content) {
content.style.flex = "none";
content.style.height = `${this._contentHeight}px`;
}
const animation = host.animate(
[{ height: `${this._first}px` }, { height: `${this._last}px` }],
{
duration:
parseAnimationDuration(
getComputedStyle(host).getPropertyValue(
"--ha-animation-duration-normal"
)
) || 250,
easing: EASING,
fill: "forwards",
}
);
animation.onfinish = () => this._finish(animation);
this._animation = animation;
}
private async _finish(animation?: Animation) {
if (!this.host.expanded) {
this.showContent = false;
this.host.requestUpdate();
await this.host.updateComplete;
}
if (this._animation !== animation) {
return;
}
clearInlineStyles(this.host);
clearInlineStyles(this._content.value);
this._animation?.cancel();
this._animation = undefined;
}
}
-19
View File
@@ -39,25 +39,6 @@ const formatTimeWithSecondsMem = memoizeOne(
})
);
// 9:15:24.123 PM || 21:15:24,123
export const formatTimeWithMilliseconds = (
dateObj: Date,
locale: FrontendLocaleData,
config: HassConfig
) => formatTimeWithMillisecondsMem(locale, config.time_zone).format(dateObj);
const formatTimeWithMillisecondsMem = memoizeOne(
(locale: FrontendLocaleData, serverTimeZone: string) =>
new Intl.DateTimeFormat(locale.language, {
hour: useAmPm(locale) ? "numeric" : "2-digit",
minute: "2-digit",
second: "2-digit",
fractionalSecondDigits: 3,
hourCycle: useAmPm(locale) ? "h12" : "h23",
timeZone: resolveTimeZone(locale.time_zone, serverTimeZone),
})
);
// Tuesday 7:00 PM || Tuesday 19:00
export const formatTimeWeekday = (
dateObj: Date,
-1
View File
@@ -25,7 +25,6 @@ export type LocalizeKeys =
| `ui.dialogs.unsupported.reasons.${string}`
| `ui.panel.config.${string}.${"caption" | "description"}`
| `ui.panel.config.dashboard.${string}`
| `ui.panel.config.mqtt.${string}`
| `ui.panel.config.storage.segments.${string}`
| `ui.panel.config.zha.${string}`
| `ui.panel.config.zwave_js.${string}`
@@ -1,5 +1,4 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { deepEqual } from "../util/deep-equal";
import {
createQueryString,
decodeQueryParams,
@@ -36,15 +35,6 @@ export const historyLogbookTargetFromQueryParams = (
): HassServiceTarget | undefined =>
serviceTargetFromQueryParams(params, historyLogbookTargetParamKeys);
export const historyLogbookTargetsEqual = (
a: HassServiceTarget,
b: HassServiceTarget
): boolean =>
deepEqual(
queryParamsFromServiceTarget(a, historyLogbookTargetParamKeys),
queryParamsFromServiceTarget(b, historyLogbookTargetParamKeys)
);
export const createHistoryLogbookUrl = (
path: string,
target: HassServiceTarget,
-64
View File
@@ -1,64 +0,0 @@
import {
isMoreInfoView,
type MoreInfoView,
} from "../../dialogs/more-info/more-info-view";
import type { SearchParamsSource } from "./query-params";
const ENTITY_ID_PARAM = "more-info-entity-id";
const VIEW_PARAM = "more-info-view";
export interface MoreInfoUrlData {
entityId?: string;
view?: MoreInfoView;
hash: URLSearchParams;
}
export interface CreateMoreInfoUrlData {
entityId: string;
view: MoreInfoView;
hash?: URLSearchParams;
}
export const decodeMoreInfoUrl = (
search: SearchParamsSource,
hash = ""
): MoreInfoUrlData => {
const params =
typeof search === "string"
? new URLSearchParams(search)
: search instanceof URLSearchParams
? search
: new URLSearchParams(search);
const entityId = params.get(ENTITY_ID_PARAM) || undefined;
const view = params.get(VIEW_PARAM) || undefined;
return {
entityId,
view: isMoreInfoView(view) ? view : undefined,
hash: new URLSearchParams(
__DEMO__ ? "" : hash.startsWith("#") ? hash.substring(1) : hash
),
};
};
export const createMoreInfoUrl = (
base: string,
data: CreateMoreInfoUrlData
): string => {
const url = new URL(base, window.location.origin);
url.searchParams.set(ENTITY_ID_PARAM, data.entityId);
url.searchParams.set(VIEW_PARAM, data.view);
if (!__DEMO__ && data.hash !== undefined) {
url.hash = data.hash.toString();
}
return `${url.pathname}${url.search}${url.hash}`;
};
export const removeMoreInfoUrl = (base: string): string => {
const url = new URL(base, window.location.origin);
url.searchParams.delete(ENTITY_ID_PARAM);
url.searchParams.delete(VIEW_PARAM);
return `${url.pathname}${url.search}${url.hash}`;
};
@@ -1,4 +1,3 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -23,10 +22,6 @@ import { hex2rgb } from "../../common/color/convert-color";
import { measureTextWidth } from "../../util/text";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
const ROW_HEIGHT = 30;
const ROW_HEIGHT_INSIDE_LABELS = 64;
const GRID_BOTTOM = 30;
@customElement("state-history-chart-timeline")
export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -43,10 +38,6 @@ export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
/** Draw each row's name above its bar instead of in a label column. */
@property({ attribute: "inside-labels", type: Boolean })
public insideLabels = false;
@property({ attribute: "click-for-more-info", type: Boolean })
public clickForMoreInfo = true;
@@ -69,13 +60,6 @@ export class StateHistoryChartTimeline extends LitElement {
@state() private _yWidth = 0;
private _width = 0;
private _resize = new ResizeController(this, {
skipInitial: true,
callback: (entries) => entries[0]?.contentRect.width,
});
private _chartTime: Date = new Date();
protected render() {
@@ -83,7 +67,7 @@ export class StateHistoryChartTimeline extends LitElement {
<ha-chart-base
.hass=${this.hass}
.options=${this._chartOptions}
.height=${`${this.data.length * (this.insideLabels ? ROW_HEIGHT_INSIDE_LABELS : ROW_HEIGHT) + GRID_BOTTOM}px`}
.height=${`${this.data.length * 30 + 30}px`}
.data=${this._chartData as HaECSeries}
small-controls
@chart-click=${this._handleChartClick}
@@ -193,19 +177,13 @@ export class StateHistoryChartTimeline extends LitElement {
this._generateData();
}
const width = this.insideLabels ? Math.round(this._resize.value ?? 0) : 0;
const widthChanged = width !== this._width;
this._width = width;
if (
!this.hasUpdated ||
changedProps.has("startTime") ||
changedProps.has("endTime") ||
changedProps.has("showNames") ||
changedProps.has("insideLabels") ||
changedProps.has("paddingYAxis") ||
changedProps.has("_yWidth") ||
widthChanged
changedProps.has("_yWidth")
) {
this._createOptions();
}
@@ -215,22 +193,14 @@ export class StateHistoryChartTimeline extends LitElement {
const narrow = this.narrow;
const showNames = this.chunked || this.showNames;
const maxInternalLabelWidth = narrow ? 105 : 185;
const insideLabels = this.insideLabels;
const labelWidth =
showNames && !insideLabels
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const labelWidth = showNames
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const labelMargin = 5;
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
// Keeps the plot aligned with the line charts sharing the y-axis padding.
const plotPadding = insideLabels ? this.paddingYAxis : labelWidth;
// A zero width hides the labels instead of truncating them.
const insideLabelWidth = this._width
? Math.max(0, this._width - plotPadding - labelMargin)
: undefined;
this._chartOptions = {
xAxis: {
type: "time",
@@ -254,52 +224,37 @@ export class StateHistoryChartTimeline extends LitElement {
axisLine: {
show: false,
},
axisLabel: insideLabels
? {
show: showNames,
inside: true,
margin: 0,
padding: [0, rtl ? 2 : 0, 14, rtl ? 0 : 2],
align: rtl ? "right" : "left",
verticalAlign: "bottom",
width: insideLabelWidth,
overflow: "truncate",
formatter: (id: string) =>
(this._chartData.find((d) => d.id === id)?.name as string) ??
"",
hideOverlap: true,
axisLabel: {
show: showNames,
width: labelWidth,
overflow: "truncate",
margin: labelMargin,
formatter: (id: string) => {
const label = this._chartData.find((d) => d.id === id)
?.name as string;
const width = label
? Math.min(
measureTextWidth(label, 12) + labelMargin,
maxInternalLabelWidth
)
: 0;
if (width > this._yWidth) {
this._yWidth = width;
fireEvent(this, "y-width-changed", {
value: this._yWidth,
chartIndex: this.chartIndex,
});
}
: {
show: showNames,
width: labelWidth,
overflow: "truncate",
margin: labelMargin,
formatter: (id: string) => {
const label = this._chartData.find((d) => d.id === id)
?.name as string;
const width = label
? Math.min(
measureTextWidth(label, 12) + labelMargin,
maxInternalLabelWidth
)
: 0;
if (width > this._yWidth) {
this._yWidth = width;
fireEvent(this, "y-width-changed", {
value: this._yWidth,
chartIndex: this.chartIndex,
});
}
return label;
},
hideOverlap: true,
},
return label;
},
hideOverlap: true,
},
},
grid: {
top: insideLabels ? 20 : 10,
bottom: GRID_BOTTOM,
left: rtl ? 1 : plotPadding,
right: rtl ? plotPadding : 1,
top: 10,
bottom: 30,
left: rtl ? 1 : labelWidth,
right: rtl ? labelWidth : 1,
},
tooltip: {
renderMode: "html",
@@ -443,10 +398,6 @@ export class StateHistoryChartTimeline extends LitElement {
}
static styles = css`
:host {
display: block;
}
ha-chart-base {
--chart-max-height: none;
}
@@ -79,10 +79,6 @@ export class StateHistoryCharts extends LitElement {
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
/** Draw timeline row names above their bar instead of in a label column. */
@property({ attribute: "inside-labels", type: Boolean, reflect: true })
public insideLabels = false;
@property({ attribute: "click-for-more-info", type: Boolean })
public clickForMoreInfo = true;
@@ -231,7 +227,6 @@ export class StateHistoryCharts extends LitElement {
.startTime=${this._computedStartTime}
.endTime=${this._computedEndTime}
.showNames=${this.showNames}
.insideLabels=${this.insideLabels}
.names=${this.names}
.narrow=${this.narrow}
.chunked=${this.virtualize}
@@ -429,12 +424,6 @@ export class StateHistoryCharts extends LitElement {
padding-top: 8px;
}
/* Names inside the plot sit close to the chart above them, so the groups
need more room between them to stay apart. */
:host([inside-labels]) .entry-container.timeline:not(:first-child) {
margin-top: var(--ha-space-8);
}
.entry-container:hover {
z-index: 1;
}
+15 -17
View File
@@ -117,7 +117,7 @@ export class HaDataTable extends LitElement {
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ type: Boolean }) public narrow = false;
@property({ type: Object }) public columns: DataTableColumnContainer = {};
@@ -1158,11 +1158,6 @@ export class HaDataTable extends LitElement {
/* default mdc styles, colors changed, without checkbox styles */
:host {
height: 100%;
--_cell-padding-inline: 16px;
}
:host([narrow]) {
--_cell-padding-inline: 8px;
}
.mdc-data-table__content {
font-family: var(--ha-font-family-body);
@@ -1243,7 +1238,8 @@ export class HaDataTable extends LitElement {
.mdc-data-table__cell,
.mdc-data-table__header-cell {
padding-inline: var(--_cell-padding-inline);
padding-right: 16px;
padding-left: 16px;
min-width: 150px;
align-self: center;
overflow: hidden;
@@ -1263,8 +1259,14 @@ export class HaDataTable extends LitElement {
.mdc-data-table__header-cell--checkbox,
.mdc-data-table__cell--checkbox {
padding-inline-start: var(--_cell-padding-inline);
padding-inline-end: 0;
/* @noflip */
padding-left: 16px;
/* @noflip */
padding-right: 0;
/* @noflip */
padding-inline-start: 16px;
/* @noflip */
padding-inline-end: initial;
width: 60px;
min-width: 60px;
}
@@ -1377,7 +1379,8 @@ export class HaDataTable extends LitElement {
.mdc-data-table__header-cell--overflow-menu:first-child,
.mdc-data-table__header-cell--icon-button:first-child,
.mdc-data-table__cell--icon-button:first-child {
padding-inline-start: var(--_cell-padding-inline);
padding-left: 16px;
padding-inline-start: 16px;
padding-inline-end: initial;
}
@@ -1385,7 +1388,8 @@ export class HaDataTable extends LitElement {
.mdc-data-table__header-cell--overflow-menu:last-child,
.mdc-data-table__header-cell--icon-button:last-child,
.mdc-data-table__cell--icon-button:last-child {
padding-inline-end: var(--_cell-padding-inline);
padding-right: 16px;
padding-inline-end: 16px;
padding-inline-start: initial;
}
.mdc-data-table__cell--overflow-menu,
@@ -1512,17 +1516,11 @@ export class HaDataTable extends LitElement {
.center {
text-align: center;
}
.primary {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.secondary {
color: var(--secondary-text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
.scroller {
height: calc(100% - 57px);
@@ -1,79 +0,0 @@
import { mdiCalendar } from "@mdi/js";
import { css, html } from "lit";
import { customElement } from "lit/decorators";
import "../chips/ha-assist-chip";
import "../ha-icon-button-next";
import "../ha-icon-button-prev";
import "../ha-svg-icon";
import {
haDateRangePickerStyles,
HaDateRangePicker,
} from "./ha-date-range-picker";
/**
* Date range picker as a single pill that also steps through ranges: a
* previous button, the selected range and a next button. Meant for a toolbar,
* next to other chips.
*/
@customElement("ha-date-range-nav")
export class HaDateRangeNav extends HaDateRangePicker {
protected override _renderField() {
return html`
<ha-icon-button-prev
class="step"
.label=${this._i18n.localize("ui.common.previous")}
.disabled=${this.disabled}
@click=${this._handlePrev}
></ha-icon-button-prev>
<ha-assist-chip
id="field"
class="range"
.label=${this._formatRange(" ")}
.disabled=${this.disabled}
@click=${this._openPicker}
>
<ha-svg-icon slot="icon" .path=${mdiCalendar}></ha-svg-icon>
</ha-assist-chip>
<ha-icon-button-next
class="step"
.label=${this._i18n.localize("ui.common.next")}
.disabled=${this.disabled}
@click=${this._handleNext}
></ha-icon-button-next>
`;
}
static override styles = [
haDateRangePickerStyles,
css`
/* The three controls read as one pill, with the range chip's borders as
the dividers between them. */
.date-range-inputs {
gap: 0;
border: 1px solid var(--outline-color);
border-radius: var(--ha-assist-chip-container-shape, 10px);
background: var(--ha-assist-chip-container-color, transparent);
overflow: hidden;
width: fit-content;
}
.step {
--ha-icon-button-size: 32px;
--mdc-icon-size: 20px;
}
.range {
--md-assist-chip-outline-color: transparent;
--ha-assist-chip-container-shape: 0;
--ha-assist-chip-container-color: transparent;
border-inline: 1px solid var(--divider-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-date-range-nav": HaDateRangeNav;
}
}
+128 -112
View File
@@ -2,6 +2,7 @@ import "@home-assistant/webawesome/dist/components/popover/popover";
import { consume, type ContextType } from "@lit/context";
import { mdiCalendar } from "@mdi/js";
import "cally";
import { isThisYear } from "date-fns";
import type { HassConfig } from "home-assistant-js-websocket/dist/types";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
@@ -10,7 +11,10 @@ import { tinykeys } from "tinykeys";
import { shiftDateRange } from "../../common/datetime/calc_date";
import type { DateRange } from "../../common/datetime/calc_date_range";
import { calcDateRange } from "../../common/datetime/calc_date_range";
import { formatShortDateTimeWithConditionalYear } from "../../common/datetime/format_date_time";
import {
formatShortDateTime,
formatShortDateTimeWithYear,
} from "../../common/datetime/format_date_time";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import { configContext, internationalizationContext } from "../../data/context";
@@ -38,67 +42,18 @@ const EXTENDED_RANGE_KEYS: DateRange[] = [
"now-30d",
];
export const haDateRangePickerStyles = css`
ha-icon-button {
direction: var(--direction);
}
.date-range-inputs {
display: flex;
align-items: center;
gap: var(--ha-space-2);
}
ha-textarea {
display: inline-block;
width: 340px;
}
@media only screen and (max-width: 460px) {
ha-textarea {
width: 100%;
}
}
wa-popover {
--wa-space-l: 0;
}
wa-popover::part(dialog)::backdrop {
opacity: 0;
transition: opacity var(--ha-animation-duration-normal) ease-out;
}
wa-popover.open::part(dialog)::backdrop {
opacity: 1;
}
:host(:not([backdrop])) wa-popover::part(dialog)::backdrop {
background: none;
}
wa-popover::part(body) {
min-width: max(var(--body-width), 250px);
max-width: calc(
100vw - var(--safe-area-inset-left) - var(--safe-area-inset-right) - var(
--ha-space-8
)
);
overflow: hidden;
}
`;
@customElement("ha-date-range-picker")
export class HaDateRangePicker extends LitElement {
@state()
@consume({ context: internationalizationContext, subscribe: true })
protected _i18n!: ContextType<typeof internationalizationContext>;
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: configContext, subscribe: true })
@transform<HomeAssistantConfig, HassConfig>({
transformer: ({ config }) => config,
})
protected _hassConfig!: HassConfig;
private _hassConfig!: HassConfig;
@property({ attribute: false }) public startDate!: Date;
@@ -188,7 +143,73 @@ export class HaDateRangePicker extends LitElement {
protected render(): TemplateResult {
return html`
<div class="container">
<div class="date-range-inputs">${this._renderField()}</div>
<div class="date-range-inputs">
${
!this.minimal
? html`<ha-textarea
id="field"
rows="1"
resize="auto"
@click=${this._openPicker}
@keydown=${this._handleKeydown}
.value=${
(isThisYear(this.startDate)
? formatShortDateTime(
this.startDate,
this._i18n.locale,
this._hassConfig
)
: formatShortDateTimeWithYear(
this.startDate,
this._i18n.locale,
this._hassConfig
)) +
(window.innerWidth >= 459 ? " - " : " - \n") +
(isThisYear(this.endDate)
? formatShortDateTime(
this.endDate,
this._i18n.locale,
this._hassConfig
)
: formatShortDateTimeWithYear(
this.endDate,
this._i18n.locale,
this._hassConfig
))
}
.label=${
this._i18n.localize(
"ui.components.date-range-picker.start_date"
) +
" - " +
this._i18n.localize(
"ui.components.date-range-picker.end_date"
)
}
.disabled=${this.disabled}
readonly
></ha-textarea>
<ha-icon-button-prev
.label=${this._i18n.localize("ui.common.previous")}
@click=${this._handlePrev}
>
</ha-icon-button-prev>
<ha-icon-button-next
.label=${this._i18n.localize("ui.common.next")}
@click=${this._handleNext}
>
</ha-icon-button-next>`
: html`<ha-icon-button
@click=${this._openPicker}
.disabled=${this.disabled}
id="field"
.label=${this._i18n.localize(
"ui.components.date-range-picker.select_date_range"
)}
.path=${mdiCalendar}
></ha-icon-button>`
}
</div>
${
this._pickerWrapperOpen || this._opened
? this._openedNarrow
@@ -227,60 +248,6 @@ export class HaDateRangePicker extends LitElement {
`;
}
/**
* The control that opens the picker. It has to carry `id="field"`, which the
* popover anchors to.
*/
protected _renderField() {
if (this.minimal) {
return html`<ha-icon-button
@click=${this._openPicker}
.disabled=${this.disabled}
id="field"
.label=${this._i18n.localize(
"ui.components.date-range-picker.select_date_range"
)}
.path=${mdiCalendar}
></ha-icon-button>`;
}
return html`<ha-textarea
id="field"
rows="1"
resize="auto"
@click=${this._openPicker}
@keydown=${this._handleKeydown}
.value=${this._formatRange(window.innerWidth >= 459 ? " - " : " - \n")}
.label=${
this._i18n.localize("ui.components.date-range-picker.start_date") +
" - " +
this._i18n.localize("ui.components.date-range-picker.end_date")
}
.disabled=${this.disabled}
readonly
></ha-textarea>
<ha-icon-button-prev
.label=${this._i18n.localize("ui.common.previous")}
@click=${this._handlePrev}
>
</ha-icon-button-prev>
<ha-icon-button-next
.label=${this._i18n.localize("ui.common.next")}
@click=${this._handleNext}
>
</ha-icon-button-next>`;
}
protected _formatRange(separator: string): string {
const format = (date: Date) =>
formatShortDateTimeWithConditionalYear(
date,
this._i18n.locale,
this._hassConfig
);
return format(this.startDate) + separator + format(this.endDate);
}
private _renderPicker() {
if (!this._opened) {
return nothing;
@@ -336,12 +303,12 @@ export class HaDateRangePicker extends LitElement {
this._opened = false;
};
protected _handleNext(ev: MouseEvent): void {
private _handleNext(ev: MouseEvent): void {
if (ev && ev.stopPropagation) ev.stopPropagation();
this._shift(true);
}
protected _handlePrev(ev: MouseEvent): void {
private _handlePrev(ev: MouseEvent): void {
if (ev && ev.stopPropagation) ev.stopPropagation();
this._shift(false);
}
@@ -369,7 +336,7 @@ export class HaDateRangePicker extends LitElement {
this._pickerWrapperOpen = false;
}
protected _openPicker(ev?: Event) {
private _openPicker(ev?: Event) {
if (this.disabled) {
return;
}
@@ -385,7 +352,7 @@ export class HaDateRangePicker extends LitElement {
});
}
protected _handleKeydown(ev: KeyboardEvent) {
private _handleKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.stopPropagation();
this._openPicker(ev);
@@ -402,7 +369,56 @@ export class HaDateRangePicker extends LitElement {
}
}
static styles = [haDateRangePickerStyles];
static styles = [
css`
ha-icon-button {
direction: var(--direction);
}
.date-range-inputs {
display: flex;
align-items: center;
gap: var(--ha-space-2);
}
ha-textarea {
display: inline-block;
width: 340px;
}
@media only screen and (max-width: 460px) {
ha-textarea {
width: 100%;
}
}
wa-popover {
--wa-space-l: 0;
}
wa-popover::part(dialog)::backdrop {
opacity: 0;
transition: opacity var(--ha-animation-duration-normal) ease-out;
}
wa-popover.open::part(dialog)::backdrop {
opacity: 1;
}
:host(:not([backdrop])) wa-popover::part(dialog)::backdrop {
background: none;
}
wa-popover::part(body) {
min-width: max(var(--body-width), 250px);
max-width: calc(
100vw - var(--safe-area-inset-left) - var(
--safe-area-inset-right
) - var(--ha-space-8)
);
overflow: hidden;
}
`,
];
}
declare global {
-1
View File
@@ -8,7 +8,6 @@ export const datePickerStyles = css`
}
calendar-date::part(button),
calendar-range::part(button) {
color: var(--primary-text-color);
border: none;
background-color: unset;
border-radius: var(--ha-border-radius-circle);
-3
View File
@@ -34,8 +34,6 @@ export class HaButtonToggleGroup extends LitElement {
@property({ type: Boolean, reflect: true, attribute: "full-width" })
public fullWidth = false;
@property({ type: Boolean }) public disabled = false;
@property() public variant:
"brand" | "neutral" | "success" | "warning" | "danger" = "brand";
@@ -59,7 +57,6 @@ export class HaButtonToggleGroup extends LitElement {
.value=${button.value}
@click=${this._handleClick}
.title=${button.label}
.disabled=${this.disabled}
.appearance=${this.active === button.value ? "accent" : "filled"}
>
${
+1 -2
View File
@@ -29,7 +29,7 @@ export type Appearance = "accent" | "filled" | "outlined" | "plain";
*
* @attr {("xs"|"s"|"m"|"l"|"xl")} size - Sets the button size.
* @attr {("brand"|"neutral"|"danger"|"warning"|"success")} variant - Sets the button color variant. "primary" is default.
* @attr {("accent"|"filled"|"outlined"|"plain")} appearance - Sets the button appearance.
* @attr {("accent"|"filled"|"plain")} appearance - Sets the button appearance.
* @attr {boolean} loading - shows a loading indicator instead of the buttons label and disable buttons click.
* @attr {boolean} disabled - Disables the button and prevents user interaction.
*/
@@ -199,7 +199,6 @@ export class HaButton extends Button {
:host([appearance~="outlined"]) .button.disabled {
background-color: transparent;
color: var(--ha-color-on-disabled-quiet);
border-color: var(--ha-color-on-disabled-quiet);
}
@media (hover: hover) {
+7 -29
View File
@@ -33,7 +33,6 @@ import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import { getEntityContext } from "../common/entity/context/get_entity_context";
import { computeDeviceName } from "../common/entity/compute_device_name";
import { computeEntityName } from "../common/entity/compute_entity_name";
import { computeAreaName } from "../common/entity/compute_area_name";
import { computeFloorName } from "../common/entity/compute_floor_name";
import { copyToClipboard } from "../common/util/copy-clipboard";
@@ -753,19 +752,6 @@ export class HaCodeEditor extends ReactiveElement {
this._states![key]
);
const entityName = computeEntityName(
this._states![key],
this._registries!.entities,
this._registries!.devices
);
const deviceName = context.device
? computeDeviceName(context.device)
: undefined;
const areaName = context.area ? computeAreaName(context.area) : undefined;
const floorName = context.floor
? computeFloorName(context.floor)
: undefined;
const completionItems: CompletionItem[] = [
{
label: this._i18n!.localize(
@@ -773,39 +759,31 @@ export class HaCodeEditor extends ReactiveElement {
),
value: formattedState,
subValue:
// If the state exactly matches the formatted state, don't show the raw state
this._states![key].state === formattedState
? undefined
: this._states![key].state,
},
];
if (entityName) {
completionItems.push({
label: this._i18n!.localize(
"ui.components.entity.entity-picker.entity"
),
value: entityName,
});
}
if (deviceName) {
if (context.device && context.device.name) {
completionItems.push({
label: this._i18n!.localize("ui.components.device-picker.device"),
value: deviceName,
value: context.device.name,
});
}
if (areaName) {
if (context.area && context.area.name) {
completionItems.push({
label: this._i18n!.localize("ui.components.area-picker.area"),
value: areaName,
value: context.area.name,
});
}
if (floorName) {
if (context.floor && context.floor.name) {
completionItems.push({
label: this._i18n!.localize("ui.components.floor-picker.floor"),
value: floorName,
value: context.floor.name,
});
}
+2 -3
View File
@@ -68,7 +68,6 @@ export class HaDurationInput extends LitElement {
{ label: "-", iconPath: mdiMinusThick, value: "-" },
]}
.active=${this._negative ? "-" : "+"}
.disabled=${this.disabled}
@value-changed=${this._negativeChanged}
></ha-button-toggle-group>
`
@@ -236,8 +235,8 @@ export class HaDurationInput extends LitElement {
ev.stopPropagation();
const negative = (ev.detail?.value || ev.target.value) === "-";
this._toggleNegative = negative;
if (this.data) {
const value = { ...this.data };
const value = this.data;
if (value) {
FIELDS.forEach((t) => {
if (value[t]) {
value[t] = negative ? -Math.abs(value[t]) : Math.abs(value[t]);
-79
View File
@@ -1,79 +0,0 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "./ha-svg-icon";
/**
* Centered placeholder for a surface that has nothing to show, with an icon, a
* heading, an optional description and optional actions.
*
* @slot - Actions that help the user fill the surface, e.g. a button.
*/
@customElement("ha-empty-state")
export class HaEmptyState extends LitElement {
/** SVG path of the icon shown above the heading. */
@property() public icon?: string;
@property() public heading?: string;
@property() public description?: string;
protected render() {
return html`
<div class="content">
${
this.icon
? html`<ha-svg-icon .path=${this.icon}></ha-svg-icon>`
: nothing
}
${this.heading ? html`<h2>${this.heading}</h2>` : nothing}
${this.description ? html`<p>${this.description}</p>` : nothing}
<slot></slot>
</div>
`;
}
static styles = css`
:host {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
height: 100%;
width: 100%;
}
.content {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--ha-space-4);
box-sizing: border-box;
max-width: 500px;
padding: var(--ha-space-8) var(--ha-space-4);
text-align: center;
}
ha-svg-icon {
--mdc-icon-size: var(--ha-empty-state-icon-size, 64px);
color: var(--secondary-text-color);
}
h2 {
margin: 0;
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
}
p {
margin: 0;
color: var(--secondary-text-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-empty-state": HaEmptyState;
}
}
+49 -43
View File
@@ -2,12 +2,7 @@ import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { customElement, property, query, state } from "lit/decorators";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import { fireEvent } from "../common/dom/fire_event";
@@ -39,11 +34,11 @@ export class HaFilterBlueprints extends LitElement {
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@state() private _blueprints?: Blueprints;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
@query("ha-list") private _list?: HTMLElement;
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
@@ -61,6 +56,7 @@ export class HaFilterBlueprints extends LitElement {
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -75,38 +71,29 @@ export class HaFilterBlueprints extends LitElement {
: nothing
}
</div>
</ha-expansion-panel>
${
this._panel.showContent
? html`
<div class="content" ${ref(this._content)}>
${
this._blueprints
? html`
<ha-list
@selected=${this._blueprintsSelected}
multi
class="ha-scrollbar"
${
this._blueprints && this._shouldRender
? html`
<ha-list
@selected=${this._blueprintsSelected}
multi
class="ha-scrollbar"
>
${Object.entries(this._blueprints).map(([id, blueprint]) =>
"error" in blueprint
? nothing
: html`<ha-check-list-item
.value=${id}
.selected=${(this.value || []).includes(id)}
>
${Object.entries(this._blueprints).map(
([id, blueprint]) =>
"error" in blueprint
? nothing
: html`<ha-check-list-item
.value=${id}
.selected=${(this.value || []).includes(id)}
>
${blueprint.metadata.name || id}
</ha-check-list-item>`
)}
</ha-list>
`
: nothing
}
</div>
`
: nothing
}
${blueprint.metadata.name || id}
</ha-check-list-item>`
)}
</ha-list>
`
: nothing
}
</ha-expansion-panel>
`;
}
@@ -117,6 +104,19 @@ export class HaFilterBlueprints extends LitElement {
this._blueprints = await fetchBlueprints(this.hass, this.type);
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (this.narrow || !this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -191,11 +191,17 @@ export class HaFilterBlueprints extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list {
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
min-height: 0;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
+53 -23
View File
@@ -8,14 +8,9 @@ import {
mdiTag,
} from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import type { CategoryRegistryEntry } from "../data/category_registry";
@@ -52,9 +47,9 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
@state() private _categories: CategoryRegistryEntry[] = [];
private _content = createRef<HTMLElement>();
@state() private _shouldRender = false;
private _panel = new FilterPanelController(this, this._content);
@query("ha-list") private _list?: HTMLElement;
protected hassSubscribeRequiredHostProps = ["scope"];
@@ -75,6 +70,7 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -89,11 +85,9 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
: nothing
}
</div>
</ha-expansion-panel>
${
this._panel.showContent
? html`
<div class="content" ${ref(this._content)}>
${
this._shouldRender
? html`
<ha-list
@selected=${this._categorySelected}
class="ha-scrollbar"
@@ -164,17 +158,34 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
</ha-list-item>`
)}
</ha-list>
<ha-list-item graphic="icon" @click=${this._addCategory}>
<ha-svg-icon slot="graphic" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.panel.config.category.editor.add")}
</ha-list-item>
</div>
`
`
: nothing
}
</ha-expansion-panel>
${
this.expanded
? html`<ha-list-item
graphic="icon"
@click=${this._addCategory}
class="add"
>
<ha-svg-icon slot="graphic" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.panel.config.category.editor.add")}
</ha-list-item>`
: nothing
}
`;
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - (49 + 48)}px`;
}, 300);
}
}
private _handleAction(ev: HaDropdownSelectEvent) {
const categoryId = (ev.currentTarget as any).categoryId;
const action = ev.detail.item.value;
@@ -233,6 +244,10 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
});
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -272,8 +287,19 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
:host {
border-bottom: 1px solid var(--divider-color);
position: relative;
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
@@ -299,8 +325,6 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
color: var(--text-primary-color);
}
ha-list {
flex: 1;
min-height: 0;
--mdc-list-item-meta-size: auto;
--mdc-list-side-padding-right: var(--ha-space-1);
--mdc-list-side-padding-left: var(--ha-space-4);
@@ -315,6 +339,12 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
.warning {
color: var(--error-color);
}
.add {
position: absolute;
bottom: 0;
right: 0;
left: 0;
}
`,
];
}
-287
View File
@@ -1,287 +0,0 @@
import { consume, type ContextType } from "@lit/context";
import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent, type HASSDomEvent } from "../common/dom/fire_event";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { stringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext, statesContext } from "../data/context";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-check-list-item";
import "./ha-domain-icon";
import "./ha-expansion-panel";
import "./ha-icon-button";
import "./ha-list";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
interface DeviceClassItem {
deviceClass: string;
domain: string;
name: string;
}
@customElement("ha-filter-device-classes")
export class HaFilterDeviceClasses extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@consume({ context: statesContext, subscribe: true })
@state()
private _states!: ContextType<typeof statesContext>;
@consume({ context: internationalizationContext, subscribe: true })
@state()
private _i18n!: ContextType<typeof internationalizationContext>;
@property({ attribute: false }) public value?: string[];
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@state() private _filter?: string;
@query("ha-list") private _list?: HTMLElement;
protected render() {
return html`
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this._localize("ui.components.filter-device-classes.caption")}
${
this.value?.length
? html`<div class="badge">${this.value?.length}</div>
<ha-icon-button
.path=${mdiFilterVariantRemove}
@click=${this._clearFilter}
></ha-icon-button>`
: nothing
}
</div>
${
this._shouldRender
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
class="ha-scrollbar"
@selected=${this._handleItemSelected}
multi
>
${repeat(
this._deviceClasses(
this._states,
this._localize,
this._i18n.locale.language,
this._filter
),
(item) => item.deviceClass,
(item) =>
html`<ha-check-list-item
.value=${item.deviceClass}
.selected=${(this.value || []).includes(item.deviceClass)}
graphic="icon"
>
<ha-domain-icon
slot="graphic"
.domain=${item.domain}
.deviceClass=${item.deviceClass}
.state=${item.domain === "binary_sensor" ? "on" : undefined}
></ha-domain-icon>
${item.name}
</ha-check-list-item>`
)}
</ha-list> `
: nothing
}
</ha-expansion-panel>
`;
}
private _deviceClasses = memoizeOne(
(
states: ContextType<typeof statesContext>,
localize: LocalizeFunc,
language: string | undefined,
filter: string | undefined
): DeviceClassItem[] =>
this._deviceClassItems(this._deviceClassDomains(states), localize)
.filter(
(item) =>
!filter ||
item.deviceClass.toLowerCase().includes(filter) ||
item.name.toLowerCase().includes(filter)
)
.sort((a, b) => stringCompare(a.name, b.name, language))
);
private _deviceClassDomains = memoizeOne(
(states: ContextType<typeof statesContext>): Map<string, string[]> => {
const domains = new Map<string, string[]>();
Object.values(states).forEach((stateObj) => {
const deviceClass = stateObj.attributes.device_class;
if (!deviceClass) {
return;
}
const domain = computeStateDomain(stateObj);
const known = domains.get(deviceClass);
if (!known) {
domains.set(deviceClass, [domain]);
} else if (!known.includes(domain)) {
known.push(domain);
}
});
return domains;
}
);
private _deviceClassItems = memoizeOne(
(
deviceClassDomains: Map<string, string[]>,
localize: LocalizeFunc
): DeviceClassItem[] =>
[...deviceClassDomains].map(([deviceClass, domains]) => {
for (const domain of domains) {
const name = localize(
`component.${domain}.entity_component.${deviceClass}.name`
);
if (name) {
return { deviceClass, domain, name };
}
}
return { deviceClass, domain: domains[0], name: deviceClass };
}),
([domainsA, localizeA], [domainsB, localizeB]) =>
localizeA === localizeB &&
domainsA.size === domainsB.size &&
[...domainsA].every(
([deviceClass, domains]) =>
domainsB.get(deviceClass)?.join() === domains.join()
)
);
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 32px - height of the search input
}, 300);
}
}
private _expandedWillChange(ev: HASSDomEvent<{ expanded: boolean }>) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev: HASSDomEvent<{ expanded: boolean }>) {
this.expanded = ev.detail.expanded;
}
private _handleItemSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
const deviceClasses = this._deviceClasses(
this._states,
this._localize,
this._i18n.locale.language,
this._filter
);
const visible = new Set(deviceClasses.map((item) => item.deviceClass));
const preserved = (this.value || []).filter((d) => !visible.has(d));
const selected = [...ev.detail.index]
.map((i) => deviceClasses[i]?.deviceClass)
.filter((d): d is string => !!d);
this.value = [...preserved, ...selected];
fireEvent(this, "data-table-filter-changed", {
value: this.value.length ? this.value : undefined,
items: undefined,
});
}
private _clearFilter(ev: Event) {
ev.preventDefault();
this.value = undefined;
fireEvent(this, "data-table-filter-changed", {
value: undefined,
items: undefined,
});
}
private _handleSearchChange(ev: InputEvent) {
const target = ev.target as HaInputSearch;
this._filter = (target.value ?? "").toLowerCase();
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-end: 8px;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: initial;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-device-classes": HaFilterDeviceClasses;
}
}
+97 -72
View File
@@ -3,12 +3,7 @@ import { mdiFilterVariantRemove } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../common/entity/compute_device_name";
@@ -64,15 +59,13 @@ export class HaFilterDevices extends LitElement {
@property({ type: Boolean }) public narrow = false;
@state() private _shouldRender = false;
@state() private _filter?: string;
@query("ha-list-selectable-virtualized")
private _listElement?: HaListSelectableVirtualized;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
@@ -84,11 +77,26 @@ export class HaFilterDevices extends LitElement {
}
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded || !this._listElement) {
return;
}
this._listElement.style.height = `${this.clientHeight - 49 - 4 - 38}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 38px - height of the search input
}, 300);
}
}
protected render() {
return html`
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -104,33 +112,31 @@ export class HaFilterDevices extends LitElement {
: nothing
}
</div>
${
this._shouldRender
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
@keydown=${this._handleSearchKeydown}
>
</ha-input-search>
<ha-list-selectable-virtualized
multi
.rows=${this._devices(
this._devicesReg,
this._filter || "",
this._localize,
this._states,
this._i18n.locale.language
)}
.rowRenderer=${this._renderItem}
@ha-list-item-selected=${this._handleAdded}
@ha-list-item-deselected=${this._handleRemoved}
></ha-list-selectable-virtualized>`
: nothing
}
</ha-expansion-panel>
${
this._panel.showContent
? html`<div class="content" ${ref(this._content)}>
<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
@keydown=${this._handleSearchKeydown}
>
</ha-input-search>
<ha-list-selectable-virtualized
multi
.rows=${this._devices(
this._devicesReg,
this._filter || "",
this._localize,
this._states,
this._i18n.locale.language
)}
.rowRenderer=${this._renderItem}
@ha-list-item-selected=${this._handleAdded}
@ha-list-item-deselected=${this._handleRemoved}
></ha-list-selectable-virtualized>
</div>`
: nothing
}
`;
}
@@ -171,6 +177,10 @@ export class HaFilterDevices extends LitElement {
this.value = (this.value ?? []).filter((deviceId) => deviceId !== id);
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -261,43 +271,58 @@ export class HaFilterDevices extends LitElement {
this._listElement?.clearSelection();
}
static styles = [
filterPanelStyles,
css`
ha-list-selectable-virtualized {
flex: 1;
min-height: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-end: 8px;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
`,
];
static styles = css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
display: flex;
flex-direction: column;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
:host([expanded]) ha-expansion-panel {
flex: 1;
min-height: 0;
}
ha-list-selectable-virtualized {
flex: 1;
min-height: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-end: 8px;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
`;
}
declare global {
+71 -55
View File
@@ -1,16 +1,11 @@
import { consume, type ContextType } from "@lit/context";
import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
@@ -46,17 +41,18 @@ export class HaFilterDomains extends LitElement {
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@state() private _filter?: string;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
@query("ha-list") private _list?: HTMLElement;
protected render() {
return html`
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -71,48 +67,46 @@ export class HaFilterDomains extends LitElement {
: nothing
}
</div>
${
this._shouldRender
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
class="ha-scrollbar"
@selected=${this._handleItemSelected}
multi
>
${repeat(
this._domains(
this._states,
this._localize,
this._i18n.locale.language,
this._filter,
this.value
),
(i) => i,
(domain) =>
html`<ha-check-list-item
.value=${domain}
.selected=${(this.value || []).includes(domain)}
graphic="icon"
>
<ha-domain-icon
slot="graphic"
.domain=${domain}
brand-fallback
></ha-domain-icon>
${domainToName(this._localize, domain)}
</ha-check-list-item>`
)}
</ha-list> `
: nothing
}
</ha-expansion-panel>
${
this._panel.showContent
? html`<div class="content" ${ref(this._content)}>
<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
class="ha-scrollbar"
@selected=${this._handleItemSelected}
multi
>
${repeat(
this._domains(
this._states,
this._localize,
this._i18n.locale.language,
this._filter,
this.value
),
(i) => i,
(domain) =>
html`<ha-check-list-item
.value=${domain}
.selected=${(this.value || []).includes(domain)}
graphic="icon"
>
<ha-domain-icon
slot="graphic"
.domain=${domain}
brand-fallback
></ha-domain-icon>
${domainToName(this._localize, domain)}
</ha-check-list-item>`
)}
</ha-list>
</div>`
: nothing
}
`;
}
@@ -145,6 +139,22 @@ export class HaFilterDomains extends LitElement {
}
);
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 32px - height of the search input
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -189,18 +199,24 @@ export class HaFilterDomains extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list {
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
min-height: 0;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-start: initial;
margin-inline-end: 8px;
}
ha-check-list-item {
+37 -21
View File
@@ -2,13 +2,8 @@ import { consume, type ContextType } from "@lit/context";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeStateDomain } from "../common/entity/compute_state_domain";
@@ -57,11 +52,11 @@ export class HaFilterEntities extends LitElement {
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@state() private _filter?: string;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
@query("ha-list") private _list?: HTMLElement;
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
@@ -83,6 +78,7 @@ export class HaFilterEntities extends LitElement {
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -97,11 +93,9 @@ export class HaFilterEntities extends LitElement {
: nothing
}
</div>
</ha-expansion-panel>
${
this._panel.showContent
? html`
<div class="content" ${ref(this._content)}>
${
this._shouldRender
? html`
<ha-input-search
appearance="outlined"
.value=${this._filter}
@@ -124,13 +118,25 @@ export class HaFilterEntities extends LitElement {
>
</lit-virtualizer>
</ha-list>
</div>
`
: nothing
}
`
: nothing
}
</ha-expansion-panel>
`;
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 32px - height of the search input
}, 300);
}
}
private _keyFunction = (entity) => entity?.entity_id;
private _renderItem = (entity) =>
@@ -167,6 +173,10 @@ export class HaFilterEntities extends LitElement {
listItem.selected = this.value?.includes(value);
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -245,11 +255,17 @@ export class HaFilterEntities extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list {
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
min-height: 0;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
+32 -21
View File
@@ -4,13 +4,8 @@ import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeRTL } from "../common/util/compute_rtl";
@@ -69,12 +64,10 @@ export class HaFilterFloorAreas extends LitElement {
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@query("ha-list-selectable") private _list?: HaListSelectable;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
@@ -93,6 +86,7 @@ export class HaFilterFloorAreas extends LitElement {
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -113,11 +107,9 @@ export class HaFilterFloorAreas extends LitElement {
: nothing
}
</div>
</ha-expansion-panel>
${
this._panel.showContent
? html`
<div class="content" ${ref(this._content)}>
${
this._shouldRender
? html`
<ha-list-selectable
class="ha-scrollbar"
multi
@@ -162,10 +154,10 @@ export class HaFilterFloorAreas extends LitElement {
(area) => this._renderArea(area)
)}
</ha-list-selectable>
</div>
`
: nothing
}
`
: nothing
}
</ha-expansion-panel>
`;
}
@@ -251,6 +243,19 @@ export class HaFilterFloorAreas extends LitElement {
};
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -342,11 +347,17 @@ export class HaFilterFloorAreas extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list-selectable {
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
min-height: 0;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
+72 -60
View File
@@ -1,16 +1,11 @@
import type { SelectedDetail } from "@material/mwc-list";
import { consume, type ContextType } from "@lit/context";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { stringCompare } from "../common/string/compare";
@@ -49,11 +44,11 @@ export class HaFilterIntegrations extends LitElement {
Object.values(manifests)
);
@state() private _shouldRender = false;
@state() private _filter?: string;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
@query("ha-list") private _list?: HTMLElement;
protected render() {
const manifests = this._manifests
@@ -64,6 +59,7 @@ export class HaFilterIntegrations extends LitElement {
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -78,57 +74,67 @@ export class HaFilterIntegrations extends LitElement {
: nothing
}
</div>
</ha-expansion-panel>
${
this._panel.showContent
? html`<div class="content" ${ref(this._content)}>
${
manifests
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
class="ha-scrollbar"
@selected=${this._itemSelected}
multi
>
${repeat(
this._integrations(
this._localize,
manifests,
this._filter,
this.value,
this._i18n.locale.language
),
(i) => i.domain,
(integration) =>
html`<ha-check-list-item
.value=${integration.domain}
.selected=${(this.value || []).includes(
integration.domain
)}
graphic="icon"
>
<ha-domain-icon
slot="graphic"
.domain=${integration.domain}
brand-fallback
></ha-domain-icon>
${integration.name}
</ha-check-list-item>`
${
manifests && this._shouldRender
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
class="ha-scrollbar"
@selected=${this._itemSelected}
multi
>
${repeat(
this._integrations(
this._localize,
manifests,
this._filter,
this.value,
this._i18n.locale.language
),
(i) => i.domain,
(integration) =>
html`<ha-check-list-item
.value=${integration.domain}
.selected=${(this.value || []).includes(
integration.domain
)}
</ha-list>`
: nothing
}
</div>`
: nothing
}
graphic="icon"
>
<ha-domain-icon
slot="graphic"
.domain=${integration.domain}
brand-fallback
></ha-domain-icon>
${integration.name}
</ha-check-list-item>`
)}
</ha-list> `
: nothing
}
</ha-expansion-panel>
`;
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 32px - height of the search input
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -207,11 +213,17 @@ export class HaFilterIntegrations extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list {
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
min-height: 0;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
+94 -62
View File
@@ -1,16 +1,11 @@
import { consume, type ContextType } from "@lit/context";
import type { SelectedDetail } from "@material/mwc-list";
import { mdiCog, mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { navigate } from "../common/navigate";
@@ -49,11 +44,11 @@ export class HaFilterLabels extends LitElement {
@state()
private _labels?: LabelRegistryEntry[];
@state() private _shouldRender = false;
@state() private _filter?: string;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
@query("ha-list") private _list?: HTMLElement;
private _filteredLabels = memoizeOne(
// `_value` used to recalculate the memoization when the selection changes
@@ -80,6 +75,7 @@ export class HaFilterLabels extends LitElement {
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -94,66 +90,89 @@ export class HaFilterLabels extends LitElement {
: nothing
}
</div>
${
this._shouldRender
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
@selected=${this._labelSelected}
class="ha-scrollbar"
multi
>
${repeat(
this._filteredLabels(
this._labels || [],
this._filter,
this._i18n.locale.language,
this.value
),
(label) => label.label_id,
(label) =>
html`<ha-check-list-item
.value=${label.label_id}
.selected=${(this.value || []).includes(label.label_id)}
hasMeta
>
<ha-label
.color=${label.color}
.description=${label.description}
>
${
label.icon
? html`<ha-icon
slot="icon"
.icon=${label.icon}
></ha-icon>`
: nothing
}
${label.name}
</ha-label>
</ha-check-list-item>`
)}
</ha-list> `
: nothing
}
</ha-expansion-panel>
${
this._panel.showContent
? html`<div class="content" ${ref(this._content)}>
<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
@selected=${this._labelSelected}
class="ha-scrollbar"
multi
>
${repeat(
this._filteredLabels(
this._labels || [],
this._filter,
this._i18n.locale.language,
this.value
),
(label) => label.label_id,
(label) =>
html`<ha-check-list-item
.value=${label.label_id}
.selected=${(this.value || []).includes(label.label_id)}
hasMeta
>
<ha-label
.color=${label.color}
.description=${label.description}
>
${
label.icon
? html`<ha-icon
slot="icon"
.icon=${label.icon}
></ha-icon>`
: nothing
}
${label.name}
</ha-label>
</ha-check-list-item>`
)}
</ha-list>
<ha-list-item graphic="icon" @click=${this._manageLabels}>
<ha-svg-icon slot="graphic" .path=${mdiCog}></ha-svg-icon>
${this._localize("ui.panel.config.labels.manage_labels")}
</ha-list-item>
</div>`
this.expanded
? html`<ha-list-item
graphic="icon"
@click=${this._manageLabels}
class="add"
>
<ha-svg-icon slot="graphic" .path=${mdiCog}></ha-svg-icon>
${this._localize("ui.panel.config.labels.manage_labels")}
</ha-list-item>`
: nothing
}
`;
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - (49 + 48 + 32 + 4)}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 32px - height of the search input
// 48px - height of ha-list-item
}, 300);
}
}
private _manageLabels() {
navigate("/config/labels");
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -208,11 +227,18 @@ export class HaFilterLabels extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list {
:host {
position: relative;
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
min-height: 0;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
@@ -241,6 +267,12 @@ export class HaFilterLabels extends LitElement {
.warning {
color: var(--error-color);
}
.add {
position: absolute;
bottom: 0;
right: 0;
left: 0;
}
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
-73
View File
@@ -1,73 +0,0 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "./chips/ha-assist-chip";
import "./ha-svg-icon";
/**
* Chip that opens a filter pane, with a badge showing how many filters are
* active.
*/
@customElement("ha-filter-pane-chip")
export class HaFilterPaneChip extends LitElement {
@property() public label = "";
/** SVG path of the leading icon. */
@property() public path?: string;
/** Number of active filters, shown as a badge when there is at least one. */
@property({ type: Number }) public count = 0;
@property({ type: Boolean }) public active = false;
@property({ type: Boolean }) public disabled = false;
protected render() {
return html`
<ha-assist-chip
.label=${this.label}
.active=${this.active}
.disabled=${this.disabled}
>
${
this.path
? html`<ha-svg-icon slot="icon" .path=${this.path}></ha-svg-icon>`
: nothing
}
</ha-assist-chip>
${this.count ? html`<div class="badge">${this.count}</div>` : nothing}
`;
}
static styles = css`
:host {
position: relative;
display: inline-block;
--ha-assist-chip-container-shape: 10px;
}
.badge {
position: absolute;
top: -4px;
right: -4px;
inset-inline-end: -4px;
inset-inline-start: initial;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0 2px;
color: var(--text-primary-color);
pointer-events: none;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-pane-chip": HaFilterPaneChip;
}
}
-182
View File
@@ -1,182 +0,0 @@
import { mdiFilterVariant, mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-adaptive-dialog";
import "./ha-button";
import "./ha-dialog-footer";
import "./ha-filter-pane-chip";
import "./ha-icon-button";
/**
* Filter pane for a filtered page: a column next to the content on wide
* screens, a bottom sheet on narrow ones. Mirrors the filter pane of
* `hass-tabs-subpage-data-table` for pages that are not a data table.
*
* The page keeps ownership of whether the pane is shown, so that it can also
* open it from elsewhere (e.g. an empty state) and hide its own toolbar chip
* while it is open.
*
* @slot - Filter panels, e.g. `ha-filter-domains`.
*/
@customElement("ha-filter-pane")
export class HaFilterPane extends LitElement {
@property({ type: Boolean, reflect: true }) public narrow = false;
/** Header label, defaults to "Filters". */
@property() public label?: string;
/** SVG path of the header chip icon. */
@property() public path = mdiFilterVariant;
/** Number of active filters, shows the clear button when above zero. */
@property({ type: Number }) public count = 0;
/**
* Number of results the current filters resolve to, shown on the narrow
* confirm button. Leave undefined when the page shows everything.
*/
@property({ attribute: false }) public resultCount?: number;
@property({ type: Boolean }) public disabled = false;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
protected render() {
const label =
this.label ?? this._localize("ui.components.subpage-data-table.filters");
if (this.narrow) {
return html`
<ha-adaptive-dialog
open
flexcontent
.headerTitle=${label}
@closed=${this._close}
>
${this._renderClearButton("headerActionItems")}
<div class="sheet-content">
<slot></slot>
</div>
<ha-dialog-footer slot="footer">
<ha-button slot="primaryAction" data-dialog="close">
${
this.resultCount === undefined
? this._localize("ui.common.close")
: this._localize(
"ui.components.subpage-data-table.show_results",
{ number: this.resultCount }
)
}
</ha-button>
</ha-dialog-footer>
</ha-adaptive-dialog>
`;
}
return html`
<div class="header">
<ha-filter-pane-chip
active
.label=${label}
.path=${this.path}
.disabled=${this.disabled}
@click=${this._close}
></ha-filter-pane-chip>
${this._renderClearButton()}
</div>
<div class="content ha-scrollbar">
<slot></slot>
</div>
`;
}
private _renderClearButton(slot?: string) {
if (!this.count) {
return nothing;
}
return html`
<ha-icon-button
slot=${ifDefined(slot)}
.path=${mdiFilterVariantRemove}
.disabled=${this.disabled}
.label=${this._localize("ui.components.subpage-data-table.clear_filter")}
@click=${this._clear}
></ha-icon-button>
`;
}
private _close() {
fireEvent(this, "close-filter-pane");
}
private _clear() {
fireEvent(this, "clear-filter");
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
display: flex;
flex-direction: column;
flex: 0 0 var(--ha-filter-pane-width, 320px);
width: var(--ha-filter-pane-width, 320px);
box-sizing: border-box;
overflow: hidden;
border-inline-end: 1px solid var(--divider-color);
}
/* The bottom sheet positions itself, so the pane takes no space. */
:host([narrow]) {
display: contents;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ha-space-4);
box-sizing: border-box;
height: 56px;
flex-shrink: 0;
padding: 0 16px;
background: var(--primary-background-color);
border-bottom: 1px solid var(--divider-color);
}
.content,
.sheet-content {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow-y: auto;
}
ha-adaptive-dialog {
--dialog-content-padding: 0;
/* Fixed height so the sheet does not resize while filtering. */
--ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12));
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-pane": HaFilterPane;
}
interface HASSDomEvents {
"close-filter-pane": undefined;
}
}
+45 -22
View File
@@ -1,13 +1,8 @@
import type { SelectedDetail } from "@material/mwc-list";
import type { List, SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-check-list-item";
@@ -32,9 +27,9 @@ export class HaFilterStates extends LitElement {
@property({ type: Boolean, reflect: true }) public expanded = false;
private _content = createRef<HTMLElement>();
@state() private _shouldRender = false;
private _panel = new FilterPanelController(this, this._content);
@query("ha-list") private _list!: List;
protected render() {
if (!this.states) {
@@ -45,6 +40,7 @@ export class HaFilterStates extends LitElement {
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -59,11 +55,9 @@ export class HaFilterStates extends LitElement {
: nothing
}
</div>
</ha-expansion-panel>
${
this._panel.showContent
? html`
<div class="content" ${ref(this._content)}>
${
this._shouldRender
? html`
<ha-list
@selected=${this._statesSelected}
multi
@@ -88,13 +82,36 @@ export class HaFilterStates extends LitElement {
</ha-check-list-item>`
)}
</ha-list>
</div>
`
: nothing
}
`
: nothing
}
</ha-expansion-panel>
`;
}
protected willUpdate(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
this._shouldRender = true;
}
}
protected updated(changed: PropertyValues<this>) {
if ((changed.has("expanded") || changed.has("states")) && this.expanded) {
setTimeout(async () => {
if (!this.expanded) return;
const list = this._list;
if (!list) {
return;
}
list.style.height = `${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -135,11 +152,17 @@ export class HaFilterStates extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list {
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
min-height: 0;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
+34 -20
View File
@@ -2,13 +2,8 @@ import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import { fireEvent } from "../common/dom/fire_event";
@@ -39,15 +34,16 @@ export class HaFilterVoiceAssistants extends LitElement {
@state() private _voiceAssistantOptions: string[] = [];
private _content = createRef<HTMLElement>();
@state() private _shouldRender = false;
private _panel = new FilterPanelController(this, this._content);
@query("ha-list") private _list?: HTMLElement;
protected render() {
return html`
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -62,11 +58,9 @@ export class HaFilterVoiceAssistants extends LitElement {
: nothing
}
</div>
</ha-expansion-panel>
${
this._panel.showContent
? html`<div class="content" ${ref(this._content)}>
<ha-list
${
this._shouldRender
? html`<ha-list
@selected=${this._assistantsSelected}
class="ha-scrollbar"
multi
@@ -89,10 +83,10 @@ export class HaFilterVoiceAssistants extends LitElement {
${voiceAssistants[voiceAssistantId].name}
</ha-check-list-item>`
)}
</ha-list>
</div>`
: nothing
}
</ha-list> `
: nothing
}
</ha-expansion-panel>
`;
}
@@ -101,6 +95,19 @@ export class HaFilterVoiceAssistants extends LitElement {
this._voiceAssistantOptions = Object.keys(voiceAssistants);
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -141,11 +148,18 @@ export class HaFilterVoiceAssistants extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list {
:host {
position: relative;
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
min-height: 0;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
-2
View File
@@ -43,8 +43,6 @@ const CUSTOM_ICONS: Record<string, () => Promise<string>> = {
import("../resources/esphome-logo-svg").then((mod) => mod.mdiEsphomeLogo),
matter: () =>
import("../resources/matter-logo-svg").then((mod) => mod.mdiMatterLogo),
mqtt: () =>
import("../resources/mqtt-logo-svg").then((mod) => mod.mdiMqttLogo),
};
@customElement("ha-icon")
+10 -5
View File
@@ -11,6 +11,7 @@ import {
fetchSupervisorMounts,
SupervisorMountType,
SupervisorMountUsage,
supervisorMountDescription,
} from "../data/supervisor/mounts";
import type { HomeAssistant } from "../types";
import "./ha-alert";
@@ -58,9 +59,7 @@ class HaMountPicker extends LitElement {
).map((mount) => ({
value: mount.name,
label: mount.name,
secondary: `${mount.server}${mount.port ? `:${mount.port}` : ""}${
mount.type === SupervisorMountType.NFS ? mount.path : `:${mount.share}`
}`,
secondary: supervisorMountDescription(mount),
iconPath:
mount.usage === SupervisorMountUsage.MEDIA
? mdiPlayBox
@@ -108,10 +107,16 @@ class HaMountPicker extends LitElement {
private _filterMounts = memoizeOne(
(mounts: SupervisorMounts, usage: this["usage"]) => {
let filteredMounts = mounts.mounts.filter((mount) =>
[SupervisorMountType.CIFS, SupervisorMountType.NFS].includes(mount.type)
[
SupervisorMountType.CIFS,
SupervisorMountType.DISK,
SupervisorMountType.NFS,
].includes(mount.type)
);
if (usage) {
filteredMounts = mounts.mounts.filter((mount) => mount.usage === usage);
filteredMounts = filteredMounts.filter(
(mount) => mount.usage === usage
);
}
return filteredMounts.sort((mountA, mountB) => {
if (mountA.name === mounts.default_backup_mount) {
@@ -81,7 +81,6 @@ export class HaChooseSelector extends LitElement {
.required=${this.required}
@value-changed=${this._handleValueChanged}
.helper=${this.helper}
.localizeValue=${this.localizeValue}
></ha-selector>`;
}
@@ -108,12 +107,7 @@ export class HaChooseSelector extends LitElement {
: {
[this._activeChoice!]: this.value,
};
const choice = ev.detail?.value || ev.target.value;
this._activeChoice = choice;
if (choice && "constant" in this.selector.choose.choices[choice].selector) {
value[choice] =
this.selector.choose.choices[choice].selector.constant?.value;
}
this._activeChoice = ev.detail?.value || ev.target.value;
fireEvent(this, "value-changed", {
value: {
...value,
-1
View File
@@ -664,7 +664,6 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
display: flex;
flex-direction: column;
overflow: hidden;
overscroll-behavior: contain;
-ms-user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-268
View File
@@ -1,268 +0,0 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ensureArray } from "../common/array/ensure-array";
import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
import type { DataTableFiltersValue } from "../data/data_table_filters";
import type { HaEntityPickerEntityFilterFunc } from "../data/entity/entity";
import type { EntitySources } from "../data/entity/entity_sources";
import type { HomeAssistant } from "../types";
import "./ha-filter-device-classes";
import "./ha-filter-domains";
import "./ha-filter-integrations";
import "./ha-target-picker";
/**
* Ways to narrow down the entities a target selection resolves to. Not to be
* confused with `EntitySources`, which maps an entity to its integration.
*/
export interface SourceFilters {
domains?: string[];
deviceClasses?: string[];
integrations?: string[];
}
const TARGET_KEYS = [
"floor_id",
"area_id",
"device_id",
"entity_id",
"label_id",
] as const;
/** Number of picked targets, no matter which type they are. */
export const countTargets = (target: HassServiceTarget): number =>
TARGET_KEYS.reduce(
(count, key) => count + (target[key] ? ensureArray(target[key]).length : 0),
0
);
/** Number of filters that have at least one option selected. */
export const countSourceFilters = (filters: SourceFilters): number =>
Object.values(filters).filter((value) => value?.length).length;
/**
* Narrows entity IDs down by the selected filters: an entity is kept when it
* matches every filter that has a selection.
*/
export const applySourceFilters = (
entityIds: string[],
filters: SourceFilters,
states: HomeAssistant["states"],
entities: HomeAssistant["entities"],
entitySources?: EntitySources
): string[] => {
const domains = filters.domains?.length ? filters.domains : undefined;
const deviceClasses = filters.deviceClasses?.length
? filters.deviceClasses
: undefined;
const integrations = filters.integrations?.length
? filters.integrations
: undefined;
if (!domains && !deviceClasses && !integrations) {
return entityIds;
}
return entityIds.filter((entityId) => {
if (domains && !domains.includes(computeDomain(entityId))) {
return false;
}
if (deviceClasses) {
const deviceClass = states[entityId]?.attributes.device_class;
if (!deviceClass || !deviceClasses.includes(deviceClass)) {
return false;
}
}
if (integrations) {
const integration =
entities[entityId]?.platform ?? entitySources?.[entityId]?.domain;
if (!integration || !integrations.includes(integration)) {
return false;
}
}
return true;
});
};
/**
* Picker for what a page shows: the targets to include, narrowed down by
* domain, device class and integration. Meant to be placed in an
* `ha-filter-pane`.
*
* The pages resolve every entity of a target, secondary ones included, so the
* target picker counts them too.
*/
@customElement("ha-sources-picker")
export class HaSourcesPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value: HassServiceTarget = {};
@property({ attribute: false }) public filters: SourceFilters = {};
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
/** Explains what the page shows while no target is picked. */
@property() public description?: string;
@property({ type: Boolean }) public disabled = false;
// Only one filter panel is expanded at a time, so that the expanded one can
// use the height that is left in the pane.
@state() private _expandedFilter?: keyof SourceFilters;
protected render() {
const noTargets = countTargets(this.value) === 0;
return html`
${
this.description && noTargets
? html`<div class="description">${this.description}</div>`
: nothing
}
<ha-target-picker
class=${classMap({ "no-padding-top": noTargets })}
.hass=${this.hass}
.value=${this.value}
.entityFilter=${this.entityFilter}
.primaryEntitiesOnly=${false}
.disabled=${this.disabled}
@value-changed=${this._targetsChanged}
></ha-target-picker>
<div
class=${classMap({ filters: true, expanded: !!this._expandedFilter })}
>
<ha-filter-domains
.value=${this.filters.domains}
.expanded=${this._expandedFilter === "domains"}
@data-table-filter-changed=${this._domainsChanged}
@expanded-changed=${this._domainsExpanded}
></ha-filter-domains>
<ha-filter-device-classes
.value=${this.filters.deviceClasses}
.expanded=${this._expandedFilter === "deviceClasses"}
@data-table-filter-changed=${this._deviceClassesChanged}
@expanded-changed=${this._deviceClassesExpanded}
></ha-filter-device-classes>
<ha-filter-integrations
.value=${this.filters.integrations}
.expanded=${this._expandedFilter === "integrations"}
@data-table-filter-changed=${this._integrationsChanged}
@expanded-changed=${this._integrationsExpanded}
></ha-filter-integrations>
</div>
`;
}
protected firstUpdated() {
// The filter panels label themselves with keys from the config panel.
this.hass.loadFragmentTranslation("config");
}
private _targetsChanged(ev: CustomEvent) {
ev.stopPropagation();
fireEvent(this, "value-changed", { value: ev.detail.value || {} });
}
private _domainsChanged(ev: CustomEvent) {
this._filterChanged("domains", ev);
}
private _deviceClassesChanged(ev: CustomEvent) {
this._filterChanged("deviceClasses", ev);
}
private _integrationsChanged(ev: CustomEvent) {
this._filterChanged("integrations", ev);
}
private _filterChanged(key: keyof SourceFilters, ev: CustomEvent) {
ev.stopPropagation();
const value = ev.detail.value as DataTableFiltersValue;
fireEvent(this, "source-filters-changed", {
value: {
...this.filters,
[key]: Array.isArray(value) && value.length ? value : undefined,
},
});
}
private _domainsExpanded(ev: CustomEvent) {
this._filterExpanded("domains", ev);
}
private _deviceClassesExpanded(ev: CustomEvent) {
this._filterExpanded("deviceClasses", ev);
}
private _integrationsExpanded(ev: CustomEvent) {
this._filterExpanded("integrations", ev);
}
private _filterExpanded(key: keyof SourceFilters, ev: CustomEvent) {
if (ev.detail.expanded) {
this._expandedFilter = key;
} else if (this._expandedFilter === key) {
this._expandedFilter = undefined;
}
}
static styles = css`
/* The sections are laid out by the pane, so that an expanded filter
panel can use the height that is left. */
:host {
display: contents;
}
.description {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
min-height: 92px;
margin: var(--ha-space-4) var(--ha-space-4) 0;
padding: 0 var(--ha-space-6);
border-radius: var(--ha-border-radius-lg);
background-color: var(--ha-color-fill-neutral-quiet-resting);
text-align: center;
color: var(--secondary-text-color);
}
ha-target-picker {
display: block;
flex: none;
padding: var(--ha-space-4);
}
/* The description already spaces the picker from the pane header. */
ha-target-picker.no-padding-top {
padding-top: 0;
}
.filters {
display: flex;
flex-direction: column;
flex: 1 0 auto;
border-top: 1px solid var(--divider-color);
}
/* An expanded panel sizes itself to the space that is left over. */
.filters.expanded {
flex: 1 1 auto;
min-height: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-sources-picker": HaSourcesPicker;
}
interface HASSDomEvents {
"source-filters-changed": { value: SourceFilters };
}
}
+297 -1
View File
@@ -28,6 +28,7 @@ import {
type DevicePickerItem,
} from "../data/device/device_picker";
import {
devicesInEffectiveArea,
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
} from "../data/device/device_registry";
@@ -41,6 +42,9 @@ import { domainToName } from "../data/integration";
import { getLabels, labelComboBoxKeys } from "../data/label/label_picker";
import type { LabelRegistryEntry } from "../data/label/label_registry";
import {
areaMeetsFilter,
deviceMeetsFilter,
entityRegMeetsFilter,
getTargetComboBoxItemType,
type TargetItem,
type TargetType,
@@ -63,6 +67,7 @@ import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import "./ha-svg-icon";
import "./ha-tree-indicator";
import "./target-picker/ha-target-picker-item-group";
import "./target-picker/ha-target-picker-value-chip";
const SEPARATOR = "________";
const CREATE_ID = "___create-new-entity___";
@@ -81,6 +86,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@property() public helper?: string;
@property({ type: Boolean, reflect: true }) public compact = false;
@property({ attribute: false }) public createDomains?: string[];
@property({ type: Boolean, attribute: "primary-entities-only" })
@@ -110,6 +117,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@property({ type: Boolean, reflect: true }) public disabled = false;
@property({ attribute: "add-on-top", type: Boolean }) public addOnTop = false;
@state() private _selectedSection?: TargetTypeFloorless;
@state() private _replaceTarget?: TargetItem;
@@ -245,9 +254,119 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
Fuse.createIndex(keys, states);
protected render() {
if (this.addOnTop) {
return html` ${this._renderPicker()} ${this._renderItems()} `;
}
return html` ${this._renderItems()} ${this._renderPicker()} `;
}
private _renderValueChips() {
const entityIds = this.value?.entity_id
? ensureArray(this.value.entity_id)
: [];
const deviceIds = this.value?.device_id
? ensureArray(this.value.device_id)
: [];
const areaIds = this.value?.area_id ? ensureArray(this.value.area_id) : [];
const floorIds = this.value?.floor_id
? ensureArray(this.value.floor_id)
: [];
const labelIds = this.value?.label_id
? ensureArray(this.value.label_id)
: [];
if (
!entityIds.length &&
!deviceIds.length &&
!areaIds.length &&
!floorIds.length &&
!labelIds.length
) {
return nothing;
}
return html`
<div class="items">
${
floorIds.length
? floorIds.map(
(floor_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="floor"
.itemId=${floor_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing
}
${
areaIds.length
? areaIds.map(
(area_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="area"
.itemId=${area_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing
}
${
deviceIds.length
? deviceIds.map(
(device_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="device"
.itemId=${device_id}
.compositeSplits=${this._compositeSplits}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing
}
${
entityIds.length
? entityIds.map(
(entity_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="entity"
.itemId=${entity_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing
}
${
labelIds.length
? labelIds.map(
(label_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="label"
.itemId=${label_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing
}
</div>
`;
}
private _renderValueGroups() {
const entityIds = this.value?.entity_id
? ensureArray(this.value.entity_id)
@@ -361,7 +480,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}
private _renderItems() {
return html` ${this._renderValueGroups()} `;
return html`
${this.compact ? this._renderValueChips() : this._renderValueGroups()}
`;
}
private _renderPicker() {
@@ -536,6 +657,162 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
});
}
private _handleExpand(ev: HASSDomEvent<HASSDomEvents["expand-target-item"]>) {
const type = ev.detail.type;
const itemId = ev.detail.id;
const newAreas: string[] = [];
const newDevices: string[] = [];
const newEntities: string[] = [];
if (type === "floor") {
Object.values(this.hass.areas).forEach((area) => {
if (
area.floor_id === itemId &&
!this.value!.area_id?.includes(area.area_id) &&
areaMeetsFilter(
area,
this.hass.devices,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
)
) {
newAreas.push(area.area_id);
}
});
} else if (type === "area") {
// Splitting an area yields its effective-area devices, so a child device
// that belongs to a different area is not pulled into this area.
devicesInEffectiveArea(this.hass.devices, itemId).forEach((device) => {
if (
!this.value!.device_id?.includes(device.id) &&
deviceMeetsFilter(
device,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
)
) {
newDevices.push(device.id);
}
});
Object.values(this.hass.entities).forEach((entity) => {
if (
entity.area_id === itemId &&
!this.value!.entity_id?.includes(entity.entity_id) &&
entityRegMeetsFilter(
entity,
false,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
)
) {
newEntities.push(entity.entity_id);
}
});
} else if (type === "device") {
// Splitting a device into entities includes its child devices' entities,
// since targeting the device would target its children too.
const deviceIds = new Set([
itemId,
...Object.values(this.hass.devices)
.filter((device) => device.parent_device_id === itemId)
.map((device) => device.id),
]);
Object.values(this.hass.entities).forEach((entity) => {
if (
entity.device_id &&
deviceIds.has(entity.device_id) &&
!this.value!.entity_id?.includes(entity.entity_id) &&
entityRegMeetsFilter(
entity,
false,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
)
) {
newEntities.push(entity.entity_id);
}
});
} else if (type === "label") {
Object.values(this.hass.areas).forEach((area) => {
if (
area.labels.includes(itemId) &&
!this.value!.area_id?.includes(area.area_id) &&
areaMeetsFilter(
area,
this.hass.devices,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
)
) {
newAreas.push(area.area_id);
}
});
Object.values(this.hass.devices).forEach((device) => {
if (
device.labels.includes(itemId) &&
!this.value!.device_id?.includes(device.id) &&
deviceMeetsFilter(
device,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
)
) {
newDevices.push(device.id);
}
});
Object.values(this.hass.entities).forEach((entity) => {
if (
entity.labels.includes(itemId) &&
!this.value!.entity_id?.includes(entity.entity_id) &&
entityRegMeetsFilter(
entity,
true,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
)
) {
newEntities.push(entity.entity_id);
}
});
} else {
return;
}
let value = this.value;
if (newEntities.length) {
value = this._addItems(value, "entity_id", newEntities);
}
if (newDevices.length) {
value = this._addItems(value, "device_id", newDevices);
}
if (newAreas.length) {
value = this._addItems(value, "area_id", newAreas);
}
value = this._removeItem(value, type, itemId);
fireEvent(this, "value-changed", { value });
}
private _handleReplace(
ev: HASSDomEvent<HASSDomEvents["replace-target-item"]>
) {
@@ -575,6 +852,17 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
this._replaceTargetAnchor = undefined;
}
private _addItems(
value: this["value"],
type: string,
ids: string[]
): this["value"] {
return {
...value,
[type]: value![type] ? ensureArray(value![type])!.concat(ids) : ids,
};
}
private _removeItem(
value: this["value"],
type: TargetType,
@@ -1150,6 +1438,13 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
width: 100%;
}
.items {
z-index: 2;
display: flex;
flex-wrap: wrap;
padding: var(--ha-space-2) 0;
gap: var(--ha-space-2);
}
.item-groups {
overflow: hidden;
border: var(--ha-border-width-sm) solid var(--divider-color);
@@ -1165,6 +1460,7 @@ declare global {
interface HASSDomEvents {
"remove-target-item": TargetItem;
"expand-target-item": TargetItem;
"replace-target-item": TargetItem;
"migrate-target-item": { id: string; replacements: string[] };
"remove-target-group": string;
@@ -24,7 +24,6 @@ export type HaListItemOptionSelectionPosition = "start" | "end";
* @cssprop --ha-list-item-selected-background - Background color when selected (`appearance="line"`).
*
* @attr {boolean} selected - Whether the option is selected. Set by the parent `ha-list-selectable`.
* @attr {boolean} indeterminate - Draws the checkbox in an indeterminate state, for a row that stands for a partially selected set.
* @attr {string} value - Value identifying the option.
* @attr {("line"|"checkbox")} appearance - Visual style. "line" highlights the row; "checkbox" renders an `ha-checkbox`.
* @attr {("start"|"end")} selection-position - Side the checkbox sits on when `appearance="checkbox"`.
@@ -33,8 +32,6 @@ export type HaListItemOptionSelectionPosition = "start" | "end";
export class HaListItemOption extends HaListItemBase {
@property({ type: Boolean, reflect: true }) public selected = false;
@property({ type: Boolean, reflect: true }) public indeterminate = false;
@property({ type: String }) public value?: string;
@property({ type: String, reflect: true })
@@ -83,7 +80,6 @@ export class HaListItemOption extends HaListItemBase {
return html`<div part="checkbox" class="checkbox" inert>
<ha-checkbox
.checked=${this.selected}
.indeterminate=${this.indeterminate}
.disabled=${this.disabled}
></ha-checkbox>
</div>`;
@@ -10,8 +10,6 @@ export const SelectableMixin = <T extends Constructor<HaListBase>>(
class SelectableClass extends superClass {
@property({ type: Boolean, reflect: true }) public multi = false;
@property({ type: Boolean, reflect: true }) public controlled = false;
protected override readonly hostRole = "listbox";
public connectedCallback(): void {
@@ -69,19 +67,15 @@ export const SelectableMixin = <T extends Constructor<HaListBase>>(
`ha-list-item-${el.selected ? "deselected" : "selected"}`,
index
);
if (!this.controlled) {
el.toggleAttribute("selected");
}
el.toggleAttribute("selected");
return;
}
if (!el.selected) {
fireEvent(this, "ha-list-item-selected", index);
if (!this.controlled) {
// deselect the other optional selected item
this.clearSelection();
el.toggleAttribute("selected", true);
}
// deselect the other optional selected item
this.clearSelection();
el.toggleAttribute("selected", true);
}
}
}
@@ -20,9 +20,6 @@ import { HaListVirtualized } from "./ha-list-virtualized";
*
* @attr {boolean} multi - Whether multiple options can be selected at once. In
* single-select mode, selecting a row clears any previous selection.
* @attr {boolean} controlled - Only notify on click, never toggle a row's `selected` state.
* Set it when the consumer derives `selected` from its own state, for example when a row stands
* for a group whose click also changes its children.
*
* @fires ha-list-item-selected - Fires when the user selects a row.
* `detail` is the row's index (number).
@@ -11,9 +11,6 @@ import { SelectableMixin } from "./ha-list-selectable-mixin";
* Toggle single vs multi selection via the `multi` attribute.
*
* @attr {boolean} multi - Whether multiple options can be selected at once.
* @attr {boolean} controlled - Only notify on click, never toggle an option's `selected` state.
* Set it when the consumer derives `selected` from its own state, for example when a row stands
* for a group whose click also changes its children.
*
* @fires ha-list-item-selected - An option was selected. `detail: number` (option index).
* @fires ha-list-item-deselected - An option was deselected (multi mode only). `detail: number` (option index).
@@ -0,0 +1,314 @@
import "@home-assistant/webawesome/dist/components/tag/tag";
import { consume } from "@lit/context";
import {
mdiAlertOutline,
mdiDevices,
mdiHome,
mdiLabel,
mdiTextureBox,
mdiUnfoldMoreVertical,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { computeCssColor } from "../../common/color/compute-color";
import { hex2rgb } from "../../common/color/convert-color";
import { fireEvent } from "../../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { slugify } from "../../common/string/slugify";
import { getConfigEntry } from "../../data/config_entries";
import { labelsContext } from "../../data/context";
import type { DeviceCompositeSplits } from "../../data/device/device_registry";
import { domainToName } from "../../data/integration";
import type { LabelRegistryEntry } from "../../data/label/label_registry";
import type { TargetType } from "../../data/target";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import "../ha-domain-icon";
import { floorDefaultIconPath } from "../ha-floor-icon";
import "../ha-icon";
import "../ha-icon-button";
import "../ha-state-icon";
import "../ha-tooltip";
@customElement("ha-target-picker-value-chip")
export class HaTargetPickerValueChip extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public type!: TargetType;
@property({ attribute: "item-id" }) public itemId!: string;
@property({ attribute: false })
public compositeSplits?: DeviceCompositeSplits;
@state() private _domainName?: string;
@state() private _iconImg?: string;
@state()
@consume({ context: labelsContext, subscribe: true })
_labelRegistry!: LabelRegistryEntry[];
protected render() {
const { name, iconPath, fallbackIconPath, stateObject, color } =
this._itemData(this.type, this.itemId);
const split =
this.type === "device" && !this.hass.devices?.[this.itemId]
? this.compositeSplits?.[this.itemId]
: undefined;
// Show the replaced reference using the primary replacement device's name,
// falling back to the first still-existing split device if the primary
// device itself was deleted. If no replacement device exists at all, fall
// back to the normal "not found" display.
const replacementDevice = split
? (split.primary_id && this.hass.devices?.[split.primary_id]) ||
split.split_ids
.map((id) => this.hass.devices?.[id])
.find((device) => device)
: undefined;
const replaced = !!replacementDevice;
const replacedName = replacementDevice
? computeDeviceNameDisplay(
replacementDevice,
this.hass.localize,
this.hass.states
)
: undefined;
return html`
<wa-tag
pill
with-remove
class=${classMap({ [this.type]: true, replaced })}
style=${color ? `--color: rgb(${color});` : ""}
@wa-remove=${this._removeItem}
>
<div class="icon">
${
replaced
? html`<ha-svg-icon .path=${mdiAlertOutline}></ha-svg-icon>`
: iconPath
? html`<ha-icon .icon=${iconPath}></ha-icon>`
: this._iconImg
? html`<img
alt=${this._domainName || ""}
width="24"
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: fallbackIconPath
? html`<ha-svg-icon
.path=${fallbackIconPath}
></ha-svg-icon>`
: stateObject
? html`<ha-state-icon
.stateObj=${stateObject}
></ha-state-icon>`
: nothing
}
</div>
<span class="name">
${
replaced
? replacedName ||
this.hass.localize(
"ui.components.target-picker.replaced_device"
)
: name
}
</span>
${
this.type === "entity" || replaced
? nothing
: html`<ha-tooltip .for="expand-${slugify(this.itemId)}"
>${this.hass.localize(
`ui.components.target-picker.expand_${this.type}_id`
)}
</ha-tooltip>
<ha-icon-button
class="expand-btn mdc-chip__icon mdc-chip__icon--trailing"
.label=${this.hass.localize(
"ui.components.target-picker.expand"
)}
.path=${mdiUnfoldMoreVertical}
hide-title
.id="expand-${slugify(this.itemId)}"
.type=${this.type}
@click=${this._handleExpand}
></ha-icon-button>`
}
</wa-tag>
`;
}
private _itemData = memoizeOne((type: TargetType, itemId: string) => {
if (type === "floor") {
const floor = this.hass.floors?.[itemId];
return {
name: floor?.name || itemId,
iconPath: floor?.icon,
fallbackIconPath: floor ? floorDefaultIconPath(floor) : mdiHome,
};
}
if (type === "area") {
const area = this.hass.areas?.[itemId];
return {
name: area?.name || itemId,
iconPath: area?.icon,
fallbackIconPath: mdiTextureBox,
};
}
if (type === "device") {
const device = this.hass.devices?.[itemId];
if (device?.primary_config_entry) {
this._getDeviceDomain(device.primary_config_entry);
}
return {
name: device
? computeDeviceNameDisplay(
device,
this.hass.localize,
this.hass.states
)
: itemId,
fallbackIconPath: mdiDevices,
};
}
if (type === "entity") {
this._setDomainName(computeDomain(itemId));
const stateObj = this.hass.states[itemId];
return {
name: computeStateName(stateObj) || itemId,
stateObject: stateObj,
};
}
// type label
const label = this._labelRegistry.find((lab) => lab.label_id === itemId);
let color = label?.color ? computeCssColor(label.color) : undefined;
if (color?.startsWith("var(")) {
const computedStyles = getComputedStyle(this);
color = computedStyles.getPropertyValue(
color.substring(4, color.length - 1)
);
}
if (color?.startsWith("#")) {
color = hex2rgb(color).join(",");
}
return {
name: label?.name || itemId,
iconPath: label?.icon,
fallbackIconPath: mdiLabel,
color,
};
});
private _setDomainName(domain: string) {
this._domainName = domainToName(this.hass.localize, domain);
}
private async _getDeviceDomain(configEntryId: string) {
try {
const data = await getConfigEntry(this.hass, configEntryId);
const domain = data.config_entry.domain;
this._iconImg = brandsUrl(
{
domain: domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
);
this._setDomainName(domain);
} catch {
// failed to load config entry -> ignore
}
}
private _removeItem(ev: MouseEvent) {
ev.stopPropagation();
fireEvent(this, "remove-target-item", {
type: this.type,
id: this.itemId,
});
}
private _handleExpand(ev: MouseEvent) {
ev.stopPropagation();
fireEvent(this, "expand-target-item", {
type: this.type,
id: this.itemId,
});
}
static styles = css`
:host {
display: inline-block;
max-width: 100%;
}
wa-tag {
background-color: var(--card-background-color);
border-width: var(--ha-border-width-md);
padding-inline-start: 0;
overflow: hidden;
max-width: 100%;
color: var(--primary-text-color);
}
wa-tag.entity {
border-color: var(--ha-color-green-80);
--background-color: var(--ha-color-green-80);
}
wa-tag.device {
border-color: var(--ha-color-primary-80);
--background-color: var(--ha-color-primary-80);
}
wa-tag.area {
border-color: var(--ha-color-orange-80);
--background-color: var(--ha-color-orange-80);
}
wa-tag.label {
border-color: var(--color);
--background-color: var(--color);
--icon-primary-color: var(--primary-text-color);
}
wa-tag.replaced {
border-color: var(--ha-color-border-warning-normal, var(--warning-color));
--background-color: var(--warning-color);
color: var(--ha-color-on-warning-normal, var(--warning-color));
}
.name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon {
background-color: var(--background-color);
border-radius: var(--ha-border-radius-circle);
padding: var(--ha-space-2) var(--ha-space-1);
display: flex;
}
.expand-btn {
--ha-icon-button-size: 16px;
--mdc-icon-size: 14px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-target-picker-value-chip": HaTargetPickerValueChip;
}
}
-1
View File
@@ -29,7 +29,6 @@ export class HaTraceLogbook extends LitElement {
.hass=${this.hass}
.entries=${this.logbookEntries}
.narrow=${this.narrow}
no-detail
></ha-logbook-renderer>
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
`
@@ -437,7 +437,6 @@ export class HaTracePathDetails extends LitElement {
.hass=${this.hass}
.entries=${entries}
.narrow=${this.narrow}
no-detail
></ha-logbook-renderer>
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
`
+3 -9
View File
@@ -1,7 +1,6 @@
import type { Connection } from "home-assistant-js-websocket";
import type { HaFormSchema } from "../components/ha-form/types";
import type { ConfigEntry } from "./config_entries";
import type { RepairsIssue } from "./repairs";
export type FlowType =
"config_flow" | "config_subentries_flow" | "options_flow" | "repair_flow";
@@ -57,31 +56,26 @@ export interface DataEntryFlowStepExternal {
translation_domain?: string;
}
export interface DataEntryFlowStepCreateEntry<
TResult extends ConfigEntry | RepairsIssue = ConfigEntry,
> {
export interface DataEntryFlowStepCreateEntry {
type: "create_entry";
version: number;
flow_id: string;
next_flow?: [FlowType, string]; // [flow_type, flow_id]
handler: string;
title: string;
result?: TResult;
result?: ConfigEntry;
description: string;
description_placeholders?: Record<string, string>;
translation_domain?: string;
}
export interface DataEntryFlowStepAbort<
TResult extends ConfigEntry | RepairsIssue = ConfigEntry,
> {
export interface DataEntryFlowStepAbort {
type: "abort";
flow_id: string;
handler: string;
reason: string;
description_placeholders?: Record<string, string>;
translation_domain?: string;
result?: TResult;
next_flow?: [FlowType, string]; // [flow_type, flow_id]
}
+50 -167
View File
@@ -21,6 +21,7 @@ import {
} from "../common/datetime/calc_date";
import type { DateRange } from "../common/datetime/calc_date_range";
import { calcDateRange } from "../common/datetime/calc_date_range";
import { formatTime24h } from "../common/datetime/format_time";
import { DEFAULT_ENTITY_NAME } from "../common/entity/compute_entity_name_display";
import { formatNumber } from "../common/number/format_number";
import { normalizeValueBySIPrefix } from "../common/number/normalize-by-si-prefix";
@@ -798,8 +799,8 @@ const clearEnergyCollectionPreferences = (hass: HomeAssistant) => {
};
const scheduleHourlyRefresh = (collection: EnergyCollection) => {
if (collection._refreshTimeout !== undefined) {
window.clearTimeout(collection._refreshTimeout);
if (collection._refreshTimeout) {
clearTimeout(collection._refreshTimeout);
}
if (collection._active && (!collection.end || collection.end > new Date())) {
@@ -858,92 +859,18 @@ export const getEnergyDefaultPeriodStorageKey = (
return `energy-default-period-${key}`;
};
// When today's first hourly statistic becomes available (01:00 in the
// configured timezone). Rolling the statistics view over at midnight would
// show an empty graph.
export const getEnergyFirstStatisticAt = (
now: Date,
locale: HomeAssistant["locale"],
config: HomeAssistant["config"]
): Date => addHours(calcDate(now, startOfDay, locale, config), 1);
// The statistics Energy view shows yesterday until 01:00 so the graph is not
// empty. The real-time "Now" view never does this — it has live data.
export const shouldFallbackEnergyPeriodToYesterday = (
midnightRollover: boolean,
now: Date,
locale: HomeAssistant["locale"],
config: HomeAssistant["config"]
): boolean =>
!midnightRollover &&
now.getTime() < getEnergyFirstStatisticAt(now, locale, config).getTime();
// Live day used while a rollover timer is scheduled (today, or the hour-0
// yesterday fallback). Custom dates do not use this. If the user already
// picked today during hour 0, keep today rather than snapping back.
export const getEnergyLiveDayPeriod = (
midnightRollover: boolean,
now: Date,
locale: HomeAssistant["locale"],
config: HomeAssistant["config"],
currentStart: Date
): { start: Date; end: Date } => {
const todayStart = calcDate(now, startOfDay, locale, config);
if (
shouldFallbackEnergyPeriodToYesterday(
midnightRollover,
now,
locale,
config
) &&
currentStart.getTime() !== todayStart.getTime()
) {
const yesterday = calcDate(now, addDays, locale, config, -1);
return {
start: calcDate(yesterday, startOfDay, locale, config),
end: calcDate(yesterday, endOfDay, locale, config),
};
}
return {
start: todayStart,
end: calcDate(now, endOfDay, locale, config),
};
};
// When does the collection's day period need to roll over to the next day?
// With `midnightRollover` (the real-time "Now" view) it rolls over right at
// midnight. Otherwise it waits an hour, until the new day's first hourly
// statistic exists — rolling over at midnight would show an empty graph.
// Pass `periodStart` when the collection is on a specific day: hour-0
// yesterday (and any older stale live day) must wake at today 01:00, not
// tomorrow 01:00. Keep tomorrow 01:00 only when the user already picked today.
export const getNextEnergyPeriodStart = (
midnightRollover: boolean,
now: Date,
locale: HomeAssistant["locale"],
config: HomeAssistant["config"],
periodStart?: Date
config: HomeAssistant["config"]
): Date => {
const todayStart = calcDate(now, startOfDay, locale, config);
if (
periodStart &&
shouldFallbackEnergyPeriodToYesterday(
midnightRollover,
now,
locale,
config
) &&
periodStart.getTime() !== todayStart.getTime()
) {
return getEnergyFirstStatisticAt(now, locale, config);
}
// Next midnight in the configured zone, not browser-local addDays, so a
// DST transition cannot skip a server-tz day.
const nextMidnight = addMilliseconds(
calcDate(now, endOfDay, locale, config),
1
);
return midnightRollover ? nextMidnight : addHours(nextMidnight, 1);
const dayEnd = calcDate(now, endOfDay, locale, config);
return midnightRollover ? addMilliseconds(dayEnd, 1) : addHours(dayEnd, 1);
};
export const getEnergyDataCollection = (
@@ -1002,80 +929,12 @@ export const getEnergyDataCollection = (
}
) as EnergyCollection;
collection._active = 0;
collection.prefs = options.prefs;
// True while the collection is tracking the rolling "today" (or hour-0
// yesterday) day. Cleared when the user picks a custom range.
let followLiveDay = false;
const applyLiveDayPeriod = (now: Date): boolean => {
const live = getEnergyLiveDayPeriod(
midnightRollover,
now,
hass.locale,
hass.config,
collection.start
);
const changed =
collection.start.getTime() !== live.start.getTime() ||
collection.end?.getTime() !== live.end.getTime();
collection.start = live.start;
collection.end = live.end;
return changed;
};
const clearUpdatePeriodTimeout = () => {
if (collection._updatePeriodTimeout !== undefined) {
window.clearTimeout(collection._updatePeriodTimeout);
collection._updatePeriodTimeout = undefined;
}
};
const scheduleUpdatePeriod = () => {
clearUpdatePeriodTimeout();
const scheduledAt = new Date();
collection._updatePeriodTimeout = window.setTimeout(
() => {
if (applyLiveDayPeriod(new Date())) {
collection.refresh();
}
scheduleUpdatePeriod();
},
Math.max(
0,
getNextEnergyPeriodStart(
midnightRollover,
scheduledAt,
hass.locale,
hass.config,
collection.start
).getTime() - scheduledAt.getTime()
)
);
};
const origSubscribe = collection.subscribe;
collection.subscribe = (subscriber: (data: EnergyData) => void) => {
// Catch up before origSubscribe so the first fetch uses the live day.
// Refresh only when state already exists: cold subscribe fetches via
// origSubscribe; a re-subscribe inside the 5s unsub grace does not.
const needsRefresh =
followLiveDay &&
applyLiveDayPeriod(new Date()) &&
collection.state !== undefined;
if (followLiveDay) {
scheduleUpdatePeriod();
}
const unsub = origSubscribe(subscriber);
collection._active++;
if (needsRefresh) {
collection.refresh();
}
if (collection._refreshTimeout === undefined) {
scheduleHourlyRefresh(collection);
}
@@ -1083,55 +942,79 @@ export const getEnergyDataCollection = (
return () => {
collection._active--;
if (collection._active < 1) {
if (collection._refreshTimeout !== undefined) {
window.clearTimeout(collection._refreshTimeout);
collection._refreshTimeout = undefined;
}
clearUpdatePeriodTimeout();
clearTimeout(collection._refreshTimeout);
collection._refreshTimeout = undefined;
}
unsub();
};
};
collection._active = 0;
collection.prefs = options.prefs;
const now = new Date();
const hour = formatTime24h(now, hass.locale, hass.config).split(":")[0];
// Set start to start of today if we have data for today, otherwise yesterday.
// The real-time "Now" view always tracks today; it shows live data even
// before today's first statistic exists, so it never falls back to yesterday.
const now = new Date();
const preferredPeriod =
(localStorage.getItem(
getEnergyDefaultPeriodStorageKey(hass, options.key)
) as DateRange) || "today";
const period =
preferredPeriod === "today" &&
shouldFallbackEnergyPeriodToYesterday(
midnightRollover,
now,
hass.locale,
hass.config
)
preferredPeriod === "today" && hour === "0" && !midnightRollover
? "yesterday"
: preferredPeriod;
const [start, end] = calcDateRange(hass.locale, hass.config, period);
collection.start = calcDate(start, startOfDay, hass.locale, hass.config);
collection.end = calcDate(end, endOfDay, hass.locale, hass.config);
followLiveDay = preferredPeriod === "today";
const scheduleUpdatePeriod = () => {
collection._updatePeriodTimeout = window.setTimeout(
() => {
collection.start = calcDate(
new Date(),
startOfDay,
hass.locale,
hass.config
);
collection.end = calcDate(
new Date(),
endOfDay,
hass.locale,
hass.config
);
collection.refresh();
scheduleUpdatePeriod();
},
getNextEnergyPeriodStart(
midnightRollover,
new Date(),
hass.locale,
hass.config
).getTime() - Date.now()
);
};
scheduleUpdatePeriod();
collection.isActive = () => !!collection._active;
collection.clearPrefs = () => {
collection.prefs = undefined;
};
collection.setPeriod = (newStart: Date, newEnd?: Date) => {
clearUpdatePeriodTimeout();
if (collection._updatePeriodTimeout) {
clearTimeout(collection._updatePeriodTimeout);
collection._updatePeriodTimeout = undefined;
}
collection.start = newStart;
collection.end = newEnd;
const periodNow = new Date();
followLiveDay =
if (
collection.start.getTime() ===
calcDate(periodNow, startOfDay, hass.locale, hass.config).getTime() &&
calcDate(new Date(), startOfDay, hass.locale, hass.config).getTime() &&
collection.end?.getTime() ===
calcDate(periodNow, endOfDay, hass.locale, hass.config).getTime();
if (followLiveDay) {
calcDate(new Date(), endOfDay, hass.locale, hass.config).getTime()
) {
scheduleUpdatePeriod();
}
};
+3 -10
View File
@@ -126,18 +126,11 @@ export const listDatadisks = async (
timeout: null,
});
// `disk` is "default" for the data disk, or a mount name. Omitting maxDepth
// leaves the depth to the Supervisor, which defaults per target — walking a
// mount costs a round trip per directory, so mounts want no depth at all.
export const fetchHostDisksUsage = async (
hass: HomeAssistant,
disk = "default",
maxDepth?: number
) =>
export const fetchHostDisksUsage = async (hass: HomeAssistant) =>
hass.callWS<HostDisksUsage>({
type: "supervisor/api",
endpoint: `/host/disks/${disk}/usage`,
endpoint: "/host/disks/default/usage",
method: "get",
timeout: 3600, // seconds. This can take a while
...(maxDepth === undefined ? {} : { params: { max_depth: maxDepth } }),
params: { max_depth: 3 },
});
+1 -1
View File
@@ -76,7 +76,7 @@ export const getLogbookDataForContext = async (
): Promise<LogbookEntry[]> =>
getLogbookDataFromServer(hass, startDate, undefined, undefined, contextId);
export const getLogbookDataFromServer = (
const getLogbookDataFromServer = (
hass: HomeAssistant,
startDate: string,
endDate?: string,
-67
View File
@@ -45,73 +45,6 @@ export interface MatterNodeDiagnostics {
export type MatterPingResult = Record<string, boolean>;
export type MatterTopologyNodeKind =
"matter" | "border_router" | "thread_unknown" | "wifi_ap";
export type MatterTopologyStrength =
"strong" | "medium" | "weak" | "none" | "unknown";
export interface MatterTopologyDirectionInfo {
strength: MatterTopologyStrength;
lqi?: number | null;
rssi?: number | null;
}
export interface MatterNetworkTopologyNode {
id: string;
kind: MatterTopologyNodeKind;
network_type: string;
node_id?: number | null;
ha_device_id?: string | null;
role?: string | null;
available?: boolean | null;
is_bridge?: boolean | null;
ext_address?: string | null;
rloc16?: number | null;
ext_pan_id?: string | null;
network_name?: string | null;
ssid?: string | null;
bssid?: string | null;
host_name?: string | null;
vendor_name?: string | null;
model_name?: string | null;
last_seen?: number | null;
}
export interface MatterNetworkTopologyConnection {
source: string;
target: string;
network: string;
strength: MatterTopologyStrength;
source_to_target?: MatterTopologyDirectionInfo | null;
target_to_source?: MatterTopologyDirectionInfo | null;
via_route_table?: boolean | null;
path_cost?: number | null;
}
export interface MatterNetworkTopology {
collected_at: number;
nodes: MatterNetworkTopologyNode[];
connections: MatterNetworkTopologyConnection[];
}
export const fetchMatterNetworkTopology = (
hass: HomeAssistant,
refresh = false
): Promise<MatterNetworkTopology> =>
hass.callWS({
type: "matter/network_topology",
refresh,
});
export const subscribeMatterNetworkTopology = (
hass: HomeAssistant,
callback: (topology: MatterNetworkTopology) => void
): Promise<UnsubscribeFunc> =>
hass.connection.subscribeMessage<MatterNetworkTopology>(callback, {
type: "matter/subscribe_network_topology",
});
export interface MatterCommissioningParameters {
setup_pin_code: number;
setup_manual_code: string;
+1 -10
View File
@@ -1135,10 +1135,6 @@ export const resolveEntityIDs = (
expanded.areas.forEach((id) => targetAreas.add(id));
});
// Devices only reached through an area do not pull in entities that are
// explicitly assigned to another area, matching core.
const devicesNotViaArea = new Set(targetDevices);
targetAreas.forEach((areaId) => {
const expanded = expandAreaTarget(
hass,
@@ -1157,7 +1153,6 @@ export const resolveEntityIDs = (
Object.values(devices).forEach((device) => {
if (device.parent_device_id && directDevices.has(device.parent_device_id)) {
targetDevices.add(device.id);
devicesNotViaArea.add(device.id);
}
});
@@ -1168,11 +1163,7 @@ export const resolveEntityIDs = (
entities,
targetSelector
);
expanded.entities.forEach((id) => {
if (devicesNotViaArea.has(deviceId) || !entities[id]?.area_id) {
targetEntities.add(id);
}
});
expanded.entities.forEach((id) => targetEntities.add(id));
});
return Array.from(targetEntities);
+87 -6
View File
@@ -3,6 +3,7 @@ import type { HomeAssistant } from "../../types";
export enum SupervisorMountType {
BIND = "bind",
CIFS = "cifs",
DISK = "disk",
NFS = "nfs",
}
@@ -28,26 +29,40 @@ interface SupervisorMountBase {
name: string;
usage: SupervisorMountUsage;
type: SupervisorMountType;
server: string;
port: number;
read_only?: boolean;
}
export interface SupervisorMountResponse extends SupervisorMountBase {
state: SupervisorMountState | null;
}
export interface SupervisorNFSMount extends SupervisorMountResponse {
// Supervisor omits port when the mount uses the protocol default.
interface SupervisorNetworkMount extends SupervisorMountResponse {
server: string;
port?: number;
}
export interface SupervisorNFSMount extends SupervisorNetworkMount {
type: SupervisorMountType.NFS;
path: string;
}
export interface SupervisorCIFSMount extends SupervisorMountResponse {
export interface SupervisorCIFSMount extends SupervisorNetworkMount {
type: SupervisorMountType.CIFS;
share: string;
version?: CIFSVersion;
}
export type SupervisorMount = SupervisorNFSMount | SupervisorCIFSMount;
// A disk mount is identified by device on the way in, but Supervisor resolves
// that to a UUID and only ever reports uuid and filesystem back.
export interface SupervisorDiskMount extends SupervisorMountResponse {
type: SupervisorMountType.DISK;
uuid: string;
filesystem?: string;
}
export type SupervisorMount =
SupervisorNFSMount | SupervisorCIFSMount | SupervisorDiskMount;
export type SupervisorNFSMountRequestParams = SupervisorNFSMount;
@@ -57,14 +72,68 @@ export interface SupervisorCIFSMountRequestParams extends SupervisorCIFSMount {
version?: CIFSVersion;
}
interface SupervisorDiskMountRequestParamsBase {
name: string;
usage: SupervisorMountUsage;
type: SupervisorMountType.DISK;
read_only?: boolean;
}
// Supervisor accepts exactly one identifier: device when creating from a
// candidate, uuid when round-tripping a mount it already resolved.
export type SupervisorDiskMountRequestParams =
| (SupervisorDiskMountRequestParamsBase & { device: string; uuid?: never })
| (SupervisorDiskMountRequestParamsBase & { uuid: string; device?: never });
export type SupervisorMountRequestParams =
SupervisorNFSMountRequestParams | SupervisorCIFSMountRequestParams;
| SupervisorNFSMountRequestParams
| SupervisorCIFSMountRequestParams
| SupervisorDiskMountRequestParams;
export interface SupervisorMounts {
default_backup_mount: string | null;
mounts: SupervisorMount[];
}
// Null when UDisks2 cannot attribute the device to a drive, which also happens
// when the drive is unplugged between enumeration and lookup.
export interface SupervisorMountCandidateDrive {
vendor: string;
model: string;
serial: string;
id: string;
size: number;
connection_bus: string;
removable: boolean;
ejectable: boolean;
}
export interface SupervisorMountCandidate {
type: SupervisorMountType.DISK;
device: string;
uuid: string;
label: string;
filesystem: string;
size: number;
read_only: boolean;
drive: SupervisorMountCandidateDrive | null;
}
export interface SupervisorMountCandidates {
candidates: SupervisorMountCandidate[];
}
// Identifies a mount in a list row. A disk mount has no server, share or path,
// so it is described by what Supervisor does report for it.
export const supervisorMountDescription = (mount: SupervisorMount): string => {
if (mount.type === SupervisorMountType.DISK) {
return [mount.filesystem, mount.uuid].filter(Boolean).join(" • ");
}
return `${mount.server}${mount.port ? `:${mount.port}` : ""}${
mount.type === SupervisorMountType.NFS ? mount.path : `:${mount.share}`
}`;
};
export const fetchSupervisorMounts = async (
hass: HomeAssistant
): Promise<SupervisorMounts> =>
@@ -75,6 +144,18 @@ export const fetchSupervisorMounts = async (
timeout: null,
});
// Returns an empty list on a host without UDisks2. A Supervisor predating disk
// mounts answers 404, which callers use to hide the feature.
export const fetchSupervisorMountCandidates = async (
hass: HomeAssistant
): Promise<SupervisorMountCandidates> =>
hass.callWS({
type: "supervisor/api",
endpoint: `/mounts/candidates`,
method: "get",
timeout: null,
});
export const createSupervisorMount = async (
hass: HomeAssistant,
data: SupervisorMountRequestParams
-3
View File
@@ -184,7 +184,6 @@ export const checkForEntityUpdates = async (
}
showToast(element, {
id: "check-updates",
message: hass.localize("ui.panel.config.updates.checking_updates"),
});
@@ -195,7 +194,6 @@ export const checkForEntityUpdates = async (
if (computeDomain(event.data.entity_id) === "update") {
updated++;
showToast(element, {
id: "check-updates",
message: hass.localize("ui.panel.config.updates.updates_refreshed", {
count: updated,
}),
@@ -218,7 +216,6 @@ export const checkForEntityUpdates = async (
if (updated === 0) {
showToast(element, {
id: "check-updates",
message: hass.localize("ui.panel.config.updates.no_new_updates"),
});
}
+2
View File
@@ -22,6 +22,7 @@ export enum VacuumEntityFeature {
STOP = 8,
RETURN_HOME = 16,
FAN_SPEED = 32,
BATTERY = 64,
STATUS = 128,
SEND_COMMAND = 256,
LOCATE = 512,
@@ -33,6 +34,7 @@ export enum VacuumEntityFeature {
}
interface VacuumEntityAttributes extends HassEntityAttributeBase {
battery_level?: number;
fan_speed?: string;
fan_speed_list?: string[];
[key: string]: any;
+2 -3
View File
@@ -566,10 +566,9 @@ export const getWeatherStateIcon = (
if (userDefinedIcon) {
return html`
<div
style=${styleMap({
"background-size": "cover",
style="background-size: cover;${styleMap({
"background-image": userDefinedIcon,
})}
})}"
></div>
`;
}
-22
View File
@@ -1,7 +1,6 @@
import type { Connection, UnsubscribeFunc } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../types";
import { callWS } from "../util/websocket";
import type { DeviceRegistryEntry } from "./device/device_registry";
export enum InclusionState {
/** The controller isn't doing anything regarding inclusion. */
@@ -466,27 +465,6 @@ export interface RequestedGrant {
clientSideAuth: boolean;
}
/**
* Get the Z-Wave node ID of a device from its registry identifiers, which have
* the form `<home id>-<node id>[-<manufacturer>:<product type>:<product id>]`.
* Returns undefined for devices without a node, e.g. provisioning entries.
*/
export const getNodeIdFromDevice = (
device: DeviceRegistryEntry
): number | undefined => {
for (const [domain, identifier] of device.identifiers) {
// a provisioning entry is identified by its DSK, whose blocks parse as numbers
if (domain !== "zwave_js" || identifier.startsWith("provision_")) {
continue;
}
const nodeId = parseInt(identifier.split("-")[1]);
if (!isNaN(nodeId)) {
return nodeId;
}
}
return undefined;
};
export const invokeZWaveCCApi = <T = unknown>(
hass: HomeAssistant,
device_id: string,
@@ -12,14 +12,12 @@ import "../../components/ha-button";
import "../../components/ha-dialog";
import "../../components/ha-dialog-footer";
import "../../components/ha-icon-button";
import type { ConfigEntry } from "../../data/config_entries";
import type { DataEntryFlowStep } from "../../data/data_entry_flow";
import {
subscribeDataEntryFlowProgress,
subscribeDataEntryFlowProgressed,
} from "../../data/data_entry_flow";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import type { RepairsIssue } from "../../data/repairs";
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
@@ -32,7 +30,6 @@ import type {
} from "./show-dialog-data-entry-flow";
import { showOptionsFlowDialog } from "./show-dialog-options-flow";
import { showSubConfigFlowDialog } from "./show-dialog-sub-config-flow";
import { showRepairsFlowDialog } from "../repairs-flow/show-dialog-repair-flow";
import "./step-flow-abort";
import "./step-flow-create-entry";
import "./step-flow-external";
@@ -221,9 +218,7 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
this._params.dialogClosedCallback({
flowFinished,
entryId:
"result" in this._step
? (this._step.result as ConfigEntry)?.entry_id
: undefined,
"result" in this._step ? this._step.result?.entry_id : undefined,
});
}
@@ -284,7 +279,7 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
const devicesLength = this._devices(
this._params.flowConfig.showDevices,
Object.values(this.hass.devices),
(this._step.result as ConfigEntry)?.entry_id,
this._step.result?.entry_id,
this._params.carryOverDevices
).length;
return this.hass.localize(
@@ -490,8 +485,7 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
.devices=${this._devices(
this._params.flowConfig.showDevices,
Object.values(this.hass.devices),
(this._step.result as ConfigEntry)
?.entry_id,
this._step.result?.entry_id,
this._params.carryOverDevices
)}
></step-flow-create-entry>
@@ -581,7 +575,7 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
const devices = this._devices(
this._params!.flowConfig.showDevices,
Object.values(this.hass.devices),
(this._step.result as ConfigEntry)?.entry_id,
this._step.result?.entry_id,
this._params!.carryOverDevices
);
@@ -687,23 +681,21 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
dialogClosedCallback: this._params!.dialogClosedCallback,
});
} else if (_step.next_flow[0] === "options_flow") {
showOptionsFlowDialog(this, _step.result!, {
continueFlowId: _step.next_flow[1],
navigateToResult: this._params!.navigateToResult,
dialogClosedCallback: this._params!.dialogClosedCallback,
});
if (_step.type === "create_entry") {
showOptionsFlowDialog(this, _step.result!, {
continueFlowId: _step.next_flow[1],
navigateToResult: this._params!.navigateToResult,
dialogClosedCallback: this._params!.dialogClosedCallback,
});
}
} else if (_step.next_flow[0] === "config_subentries_flow") {
showSubConfigFlowDialog(this, _step.result!, "", {
continueFlowId: _step.next_flow[1],
navigateToResult: this._params!.navigateToResult,
dialogClosedCallback: this._params!.dialogClosedCallback,
});
} else if (_step.next_flow[0] === "repair_flow") {
showRepairsFlowDialog(this, _step.result as unknown as RepairsIssue, {
continueFlowId: _step.next_flow[1],
navigateToResult: this._params!.navigateToResult,
dialogClosedCallback: this._params!.dialogClosedCallback,
});
if (_step.type === "create_entry") {
showSubConfigFlowDialog(this, _step.result!, "", {
continueFlowId: _step.next_flow[1],
navigateToResult: this._params!.navigateToResult,
dialogClosedCallback: this._params!.dialogClosedCallback,
});
}
} else {
this.closeDialog();
showAlertDialog(this, {
@@ -15,7 +15,6 @@ import type { HaInput } from "../../components/input/ha-input";
import { assistSatelliteSupportsSetupFlow } from "../../data/assist_satellite";
import { getConfigEntries } from "../../data/config_entries";
import type { DataEntryFlowStepCreateEntry } from "../../data/data_entry_flow";
import type { ConfigEntry } from "../../data/config_entries";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import { updateDeviceRegistryEntry } from "../../data/device/device_registry";
import {
@@ -41,8 +40,7 @@ class StepFlowCreateEntry extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false })
public step!: DataEntryFlowStepCreateEntry<ConfigEntry>;
@property({ attribute: false }) public step!: DataEntryFlowStepCreateEntry;
@property({ attribute: false }) public devices!: DeviceRegistryEntry[];
@@ -81,7 +79,7 @@ class StepFlowCreateEntry extends LitElement {
if (
this.devices.length !== 1 ||
this.devices[0].primary_config_entry !== this.step.result?.entry_id ||
this.step.result?.domain === "voip"
this.step.result.domain === "voip"
) {
return;
}
+15 -5
View File
@@ -7,11 +7,21 @@ import { isNumericEntity } from "../../data/history";
import { CONTINUOUS_DOMAINS } from "../../data/logbook";
import type { HomeAssistant } from "../../types";
export {
isMoreInfoView,
MORE_INFO_VIEWS,
type MoreInfoView,
} from "./more-info-view";
export const MORE_INFO_VIEWS = [
"info",
"history",
"settings",
"related",
"add_to",
"details",
] as const;
export type MoreInfoView = (typeof MORE_INFO_VIEWS)[number];
export const isMoreInfoView = (
value: string | undefined
): value is MoreInfoView =>
value !== undefined && (MORE_INFO_VIEWS as readonly string[]).includes(value);
export const DOMAINS_NO_INFO = ["camera", "configurator"];
/**
-9
View File
@@ -1,9 +0,0 @@
import { createContext } from "@lit/context";
export interface MoreInfoContext {
hash: URLSearchParams;
setHashParam: (key: string, value?: string) => void;
}
export const moreInfoContext =
createContext<MoreInfoContext>("more-info-context");
@@ -159,6 +159,23 @@ class MoreInfoVacuum extends LitElement {
`;
}
// Use deprecated battery_level and battery_icon attributes
if (
supportsFeature(this.stateObj, VacuumEntityFeature.BATTERY) &&
this.stateObj.attributes.battery_level
) {
return html`
<span class="battery" slot="after-time">
<span
>${Math.round(
this.stateObj.attributes.battery_level
)}${blankBeforePercent(this._i18n.locale)}%</span
>
<ha-icon .icon=${this.stateObj.attributes.battery_icon}></ha-icon>
</span>
`;
}
return nothing;
}
@@ -11,7 +11,6 @@ import { formatDateWeekdayShort } from "../../../common/datetime/format_date";
import { formatTime } from "../../../common/datetime/format_time";
import { transform } from "../../../common/decorators/transform";
import { formatNumber } from "../../../common/number/format_number";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-alert";
import "../../../components/ha-relative-time";
import "../../../components/ha-spinner";
@@ -49,16 +48,11 @@ import type {
HomeAssistantFormatters,
HomeAssistantInternationalization,
} from "../../../types";
import { moreInfoContext, type MoreInfoContext } from "../context";
@customElement("more-info-weather")
class MoreInfoWeather extends LitElement {
@property({ attribute: false }) public stateObj?: WeatherEntity;
@state()
@consume({ context: moreInfoContext, subscribe: true })
private _moreInfoContext?: MoreInfoContext;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: HomeAssistantInternationalization;
@@ -133,33 +127,15 @@ class MoreInfoWeather extends LitElement {
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (
(changedProps.has("stateObj") ||
changedProps.has("_moreInfoContext") ||
!this._subscribed) &&
this.stateObj
) {
if ((changedProps.has("stateObj") || !this._subscribed) && this.stateObj) {
const oldState = changedProps.get("stateObj") as
WeatherEntity | undefined;
if (
oldState?.entity_id !== this.stateObj?.entity_id ||
changedProps.has("_moreInfoContext") ||
!this._subscribed
) {
const supportedForecastTypes = getSupportedForecastTypes(this.stateObj);
const requestedForecastType =
this._moreInfoContext?.hash.get("forecast");
const selectedForecastType =
supportedForecastTypes.find(
(forecastType) => forecastType === requestedForecastType
) ?? getDefaultForecastType(this.stateObj);
if (selectedForecastType !== requestedForecastType) {
this._moreInfoContext?.setHashParam("forecast", selectedForecastType);
}
if (this._forecastType !== selectedForecastType || !this._subscribed) {
this._forecastType = selectedForecastType;
this._subscribeForecastEvents();
}
this._forecastType = getDefaultForecastType(this.stateObj);
this._subscribeForecastEvents();
}
} else if (changedProps.has("_forecastType")) {
this._subscribeForecastEvents();
@@ -533,17 +509,8 @@ class MoreInfoWeather extends LitElement {
`;
}
private _handleForecastTypeChanged(
ev: HASSDomEvent<{ name: ModernForecastType }>
): void {
if (
!this.stateObj ||
!getSupportedForecastTypes(this.stateObj).includes(ev.detail.name)
) {
return;
}
private _handleForecastTypeChanged(ev: CustomEvent): void {
this._forecastType = ev.detail.name;
this._moreInfoContext?.setHashParam("forecast", this._forecastType);
}
static get styles(): CSSResultGroup {
+2 -62
View File
@@ -17,7 +17,6 @@ import {
mdiTransitConnectionVariant,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { provide } from "@lit/context";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -44,10 +43,8 @@ import { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-reques
import {
getHistoryState,
navigate,
replaceCurrentUrl,
updateHistoryState,
} from "../../common/navigate";
import { createMoreInfoUrl } from "../../common/url/more-info-query-params";
import type { LocalizeKeys } from "../../common/translations/localize";
import { computeRTL } from "../../common/util/compute_rtl";
import { withViewTransition } from "../../common/util/view-transition";
@@ -88,7 +85,6 @@ import {
EDITABLE_DOMAINS_WITH_UNIQUE_ID,
type MoreInfoView,
} from "./const";
import { moreInfoContext, type MoreInfoContext } from "./context";
import "./controls/more-info-default";
import type { FavoritesDialogContext } from "./favorites";
import { getFavoritesDialogHandler } from "./favorites";
@@ -106,9 +102,6 @@ export interface MoreInfoDialogParams {
tab?: MoreInfoView;
large?: boolean;
data?: Record<string, any>;
hash?: URLSearchParams;
fromUrl?: boolean;
returnUrl?: string;
parentElement?: LitElement;
}
@@ -154,12 +147,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
@state() private _data?: Record<string, any>;
@provide({ context: moreInfoContext })
@state()
private _moreInfoContext: MoreInfoContext = this._createMoreInfoContext();
private _returnUrl?: string;
@state() private _currView: MoreInfoView = DEFAULT_VIEW;
@state() private _initialView: MoreInfoView = DEFAULT_VIEW;
@@ -194,8 +181,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
const view = params.view || params.tab || DEFAULT_VIEW;
this._data = params.data;
this._moreInfoContext = this._createMoreInfoContext(params.hash);
this._returnUrl = params.returnUrl;
this._currView = view;
this._initialView = view;
this._childViewStack = [];
@@ -231,9 +216,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
private _dialogClosed() {
if (this._returnUrl) {
replaceCurrentUrl(this._returnUrl);
}
this._entityId = undefined;
this._parentEntityIds = [];
this._entry = undefined;
@@ -242,8 +224,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
this._initialView = DEFAULT_VIEW;
this._currView = DEFAULT_VIEW;
this._childViewStack = [];
this._moreInfoContext = this._createMoreInfoContext();
this._returnUrl = undefined;
this._isEscapeEnabled = true;
window.removeEventListener("dialog-closed", this._enableEscapeKeyClose);
window.removeEventListener("show-dialog", this._disableEscapeKeyClose);
@@ -291,10 +271,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
return entity?.device_id ?? null;
}
private _setView(view: MoreInfoView, preserveHash = false) {
if (view !== this._currView && !preserveHash) {
this._moreInfoContext = this._createMoreInfoContext();
}
private _setView(view: MoreInfoView) {
updateHistoryState({
dialogParams: {
...getHistoryState()?.dialogParams,
@@ -302,38 +279,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
},
});
this._currView = view;
this._syncUrl();
}
private _syncUrl() {
if (!this._returnUrl || !this._entityId) {
return;
}
replaceCurrentUrl(
createMoreInfoUrl(this._returnUrl, {
entityId: this._entityId,
view: this._currView,
hash: this._moreInfoContext.hash,
})
);
}
private _createMoreInfoContext(hash?: URLSearchParams): MoreInfoContext {
return {
hash: new URLSearchParams(hash),
setHashParam: (key, value) => this._setHashParam(key, value),
};
}
private _setHashParam(key: string, value?: string) {
const hash = new URLSearchParams(this._moreInfoContext.hash);
if (value) {
hash.set(key, value);
} else {
hash.delete(key);
}
this._moreInfoContext = this._createMoreInfoContext(hash);
this._syncUrl();
}
private _goBack() {
@@ -361,9 +306,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
if (this._parentEntityIds.length > 0) {
this._entityId = this._parentEntityIds.pop();
this._currView = DEFAULT_VIEW;
this._moreInfoContext = this._createMoreInfoContext();
this._loadEntityRegistryEntry();
this._syncUrl();
}
}
@@ -1064,22 +1007,19 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
const view = ev.detail.view || ev.detail.tab || DEFAULT_VIEW;
if (entityId === this._entityId) {
this._moreInfoContext = this._createMoreInfoContext(ev.detail.hash);
this._infoEditMode = false;
this._detailsYamlMode = false;
this._setView(view, true);
this._setView(view);
return;
}
this._parentEntityIds = [...this._parentEntityIds, this._entityId!];
this._entityId = entityId;
this._moreInfoContext = this._createMoreInfoContext(ev.detail.hash);
this._currView = view === "details" ? view : DEFAULT_VIEW;
this._initialView = view;
this._infoEditMode = false;
this._detailsYamlMode = false;
this._childViewStack = [];
this._loadEntityRegistryEntry();
this._syncUrl();
}
private _enableEscapeKeyClose = () => {
-15
View File
@@ -1,15 +0,0 @@
export const MORE_INFO_VIEWS = [
"info",
"history",
"settings",
"related",
"add_to",
"details",
] as const;
export type MoreInfoView = (typeof MORE_INFO_VIEWS)[number];
export const isMoreInfoView = (
value: string | undefined
): value is MoreInfoView =>
value !== undefined && (MORE_INFO_VIEWS as readonly string[]).includes(value);
+5
View File
@@ -74,6 +74,11 @@ export class MockVacuumEntity extends MockBaseEntity {
stateAttrs.fan_speed = attrs.fan_speed ?? null;
}
if (supportsFeatureFromAttributes(attrs, VacuumEntityFeature.BATTERY)) {
stateAttrs.battery_level = attrs.battery_level ?? null;
stateAttrs.battery_icon = attrs.battery_icon ?? null;
}
if (supportsFeatureFromAttributes(attrs, VacuumEntityFeature.STATUS)) {
stateAttrs.status = attrs.status ?? null;
}
+58 -27
View File
@@ -28,13 +28,12 @@ import type {
SortingDirection,
} from "../components/data-table/ha-data-table";
import { showDataTableSettingsDialog } from "../components/data-table/show-dialog-data-table-settings";
import "../components/ha-adaptive-dialog";
import "../components/ha-button";
import "../components/ha-dialog";
import "../components/ha-dialog-footer";
import "../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../components/ha-dropdown";
import "../components/ha-dropdown-item";
import "../components/ha-filter-pane-chip";
import "../components/ha-icon-button";
import "../components/ha-svg-icon";
import "../components/input/ha-input-search";
@@ -238,13 +237,20 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
const localize = this.localizeFunc || this.hass.localize;
const showPane = this._showPaneController.value ?? !this.narrow;
const filterButton = this.hasFilters
? html`<ha-filter-pane-chip
.label=${localize("ui.components.subpage-data-table.filters")}
.path=${mdiFilterVariant}
.count=${this.filters}
.active=${!!this.filters}
@click=${this._toggleFilters}
></ha-filter-pane-chip>`
? html`<div class="relative">
<ha-assist-chip
.label=${localize("ui.components.subpage-data-table.filters")}
.active=${this.filters}
@click=${this._toggleFilters}
>
<ha-svg-icon slot="icon" .path=${mdiFilterVariant}></ha-svg-icon>
</ha-assist-chip>
${
this.filters
? html`<div class="badge">${this.filters}</div>`
: nothing
}
</div>`
: nothing;
const selectModeBtn =
@@ -465,14 +471,18 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
? nothing
: html`<div class="pane" slot="pane">
<div class="table-header">
<ha-filter-pane-chip
<ha-assist-chip
.label=${localize(
"ui.components.subpage-data-table.filters"
)}
.path=${mdiFilterVariant}
active
@click=${this._toggleFilters}
></ha-filter-pane-chip>
>
<ha-svg-icon
slot="icon"
.path=${mdiFilterVariant}
></ha-svg-icon>
</ha-assist-chip>
${
this.filters
? html`<ha-icon-button
@@ -566,17 +576,16 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
</hass-tabs-subpage>
${
this.showFilters && !showPane
? html`<ha-adaptive-dialog
open
flexcontent
? html`<ha-dialog
.open=${true}
width="full"
header-title=${localize("ui.components.subpage-data-table.filters")}
@closed=${this._closeFilters}
>
<ha-icon-button
slot="headerNavigationIcon"
data-dialog="close"
.path=${mdiClose}
@click=${this._closeFilters}
.label=${localize(
"ui.components.subpage-data-table.close_filter"
)}
@@ -597,13 +606,13 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
<slot name="filter-pane"></slot>
</div>
<ha-dialog-footer slot="footer">
<ha-button slot="primaryAction" data-dialog="close">
<ha-button slot="primaryAction" @click=${this._closeFilters}>
${localize("ui.components.subpage-data-table.show_results", {
number: this.data.length,
})}
</ha-button>
</ha-dialog-footer>
</ha-adaptive-dialog>`
</ha-dialog>`
: nothing
}
`;
@@ -876,6 +885,24 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
padding: 16px;
}
.badge {
position: absolute;
top: -4px;
right: -4px;
inset-inline-end: -4px;
inset-inline-start: initial;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
.narrow-header-row {
display: flex;
align-items: center;
@@ -924,8 +951,11 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
gap: var(--ha-space-2);
}
ha-assist-chip,
ha-filter-pane-chip {
.relative {
position: relative;
}
ha-assist-chip {
--ha-assist-chip-container-shape: 10px;
--ha-assist-chip-container-color: var(--card-background-color);
}
@@ -935,19 +965,20 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
--md-assist-chip-trailing-space: 8px;
}
ha-adaptive-dialog {
ha-dialog {
--dialog-content-padding: 0;
/* Fixed height so the sheet does not resize while filtering. */
--ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12));
--ha-dialog-min-height: calc(var(--safe-height) - var(--ha-space-20));
}
.filter-dialog-content {
height: calc(
100vh -
70px - var(--header-height, 0px) - var(
--safe-area-inset-top,
0px
) - var(--safe-area-inset-bottom, 0px)
);
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow-y: auto;
}
ha-dropdown ha-assist-chip {
-40
View File
@@ -6,16 +6,12 @@ import { storage } from "../common/decorators/storage";
import { isNavigationClick } from "../common/dom/is-navigation-click";
import { navigate } from "../common/navigate";
import type { LocalizeFunc } from "../common/translations/localize";
import { decodeMoreInfoUrl } from "../common/url/more-info-query-params";
import { extractSearchParamsObject } from "../common/url/search-params";
import { afterNextRender } from "../common/util/render-status";
import { fetchHttpConfig } from "../data/http";
import type { HttpConfigState } from "../data/http";
import type { WindowWithPreloads } from "../data/preloads";
import type { RecorderInfo } from "../data/recorder";
import { getRecorderInfo } from "../data/recorder";
import { showHttpPendingConfigDialog } from "../dialogs/http-pending-config/show-dialog-http-pending-config";
import { showMoreInfoDialog } from "../dialogs/more-info/show-ha-more-info-dialog";
import "../resources/custom-card-support";
import { HassElement } from "../state/hass-element";
import QuickBarMixin from "../state/quick-bar-mixin";
@@ -148,7 +144,6 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
// the shadow root. Appending the dialog before that render would let it
// tear the freshly-added dialog straight back out of the DOM.
this.checkHttpPendingConfig();
this._restoreMoreInfoFromUrl();
}
}
@@ -183,12 +178,6 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
// Handle history changes
window.addEventListener("popstate", () => updateRoute());
// Restore a more-info dialog deep-linked in the current URL, if any.
window.addEventListener("location-changed", () =>
this._restoreMoreInfoFromUrl()
);
window.addEventListener("popstate", () => this._restoreMoreInfoFromUrl());
// Handle clicking on links
window.addEventListener("click", (ev) => {
const href = isNavigationClick(ev);
@@ -308,35 +297,6 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
});
}
private _restoreMoreInfoFromUrl() {
// Only restore once the main UI is rendered so the dialog has access to
// the loaded entities.
if (this.render !== this.renderHass) {
return;
}
const searchParams = extractSearchParamsObject();
if (!searchParams["more-info-entity-id"]) {
return;
}
const { entityId, view, hash } = decodeMoreInfoUrl(
window.location.search,
window.location.hash
);
if (!entityId) {
return;
}
// Wait for the next render to ensure the view is fully loaded
// because the more info dialog is closed when the url changes
afterNextRender(() => {
showMoreInfoDialog(this, {
entityId,
view,
hash,
fromUrl: true,
});
});
}
protected async checkDataBaseMigration() {
if (__DEMO__) {
this._databaseMigration = false;
@@ -49,7 +49,6 @@ const SUPPORTED_UI_TYPES = [
const secretTag = defineScalarTag("!secret", {
resolve: (data) => `!secret ${data}`,
identify: () => false,
});
const ADDON_YAML_SCHEMA = YAML11_SCHEMA.withTags(secretTag);
@@ -545,7 +545,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing
}
<div class="description-text">${this._currentAddon.description}</div>
${this._currentAddon.description}.<br />
${this.i18n.localize(
"ui.panel.config.apps.dashboard.visit_app_page",
{
@@ -1658,15 +1658,6 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
color: var(--primary-color);
}
.description:dir(rtl) > .description-text {
text-align: right;
direction: ltr;
}
.long-description {
direction: ltr;
}
img.logo {
max-width: 100%;
max-height: 40px;
@@ -2,12 +2,10 @@ import "@home-assistant/webawesome/dist/components/divider/divider";
import { ResizeController } from "@lit-labs/observers/resize-controller";
import { consume } from "@lit/context";
import {
mdiCloseThick,
mdiCog,
mdiContentDuplicate,
mdiDelete,
mdiDotsVertical,
mdiExclamationThick,
mdiHelpCircleOutline,
mdiInformationOutline,
mdiMenuDown,
@@ -131,28 +129,6 @@ import {
import { getAvailableAssistants } from "../voice-assistants/expose/available-assistants";
import { showNewAutomationDialog } from "./show-dialog-new-automation";
const renderIconBadge = (path: string, color: string) => html`
<div
style=${styleMap({
position: "absolute",
top: "-5px",
insetInlineEnd: "-7px",
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "18px",
height: "18px",
borderRadius: "50%",
backgroundColor: color,
boxShadow: "0 0 0 2px var(--data-table-background-color)",
color: "var(--data-table-background-color)",
"--mdc-icon-size": "12px",
})}
>
<ha-svg-icon style="margin: 0;" .path=${path}></ha-svg-icon>
</div>
`;
type AutomationItem = AutomationEntity & {
name: string;
area: string | undefined;
@@ -327,11 +303,6 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
localize: LocalizeFunc,
entitiesToCheck?: any[]
): DataTableColumnContainer<AutomationItem> => {
const triggeredAtColumn = getTriggeredAtTableColumn<AutomationItem>(
localize,
this.hass
);
const columns: DataTableColumnContainer<AutomationItem> = {
icon: {
title: "",
@@ -339,34 +310,18 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
type: "icon",
moveable: false,
showNarrow: true,
template: (automation) => {
const unavailable = automation.state === UNAVAILABLE;
const disabled = automation.state === "off";
return html`<div
style="position: relative; display: inline-flex; width: 24px; height: 24px;"
>
<ha-state-icon
.stateObj=${automation}
.stateValue=${unavailable || disabled ? "on" : undefined}
style=${styleMap({
display: "flex",
margin: "0",
color: unavailable
template: (automation) =>
html`<ha-state-icon
.stateObj=${automation}
style=${styleMap({
color:
automation.state === UNAVAILABLE
? "var(--error-color)"
: disabled
? "var(--disabled-color)"
: automation.state === "on"
? "var(--state-active-color)"
: "unset",
})}
></ha-state-icon>
${
unavailable
? renderIconBadge(mdiExclamationThick, "var(--error-color)")
: disabled
? renderIconBadge(mdiCloseThick, "var(--disabled-color)")
: nothing
}
</div>`;
},
})}
></ha-state-icon>`,
},
entity_id: getEntityIdHiddenTableColumn(),
name: {
@@ -387,33 +342,23 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
area: getAreaTableColumn(localize),
category: getCategoryTableColumn(localize),
labels: getLabelsTableColumn(),
last_triggered: {
...triggeredAtColumn,
template: (automation) =>
narrow && automation.state === "off"
? nothing
: triggeredAtColumn.template!(automation),
},
last_triggered: getTriggeredAtTableColumn(localize, this.hass),
formatted_state: {
minWidth: "82px",
maxWidth: "82px",
sortable: true,
groupable: true,
hidden: narrow,
type: "overflow",
title: this.hass.localize("ui.panel.config.automation.picker.state"),
template: (automation) =>
narrow
? automation.state === "off"
? localize("ui.panel.config.automation.picker.disabled")
: nothing
: html`
<ha-switch
@click=${stopPropagation}
@change=${this._handleSwitchToggle}
.automation=${automation}
.checked=${automation.state === "on"}
></ha-switch>
`,
template: (automation) => html`
<ha-switch
@click=${stopPropagation}
@change=${this._handleSwitchToggle}
.automation=${automation}
.checked=${automation.state === "on"}
></ha-switch>
`,
},
actions: {
lastFixed: true,
@@ -124,7 +124,7 @@ class HaBackupConfigData extends LitElement {
private async _fetchStorageInfo() {
try {
this._storageInfo = await fetchHostDisksUsage(this.hass, "default", 3);
this._storageInfo = await fetchHostDisksUsage(this.hass);
} catch (_err: any) {
this._storageInfo = null;
}
@@ -33,31 +33,23 @@ interface ProgressSegment {
const HA_STAGES: CreateBackupStage[] = ["home_assistant"];
// Quick metadata writes emitted while the backup is initialized, before the
// Home Assistant stage (docker_config only by older Supervisors)
const SETUP_STAGES: CreateBackupStage[] = [
const ADDON_STAGES: CreateBackupStage[] = [
"addons",
"apps",
"addon_repositories",
"app_repositories",
"docker_config",
];
const ADDON_STAGES: CreateBackupStage[] = ["addons", "apps"];
const MEDIA_STAGES: CreateBackupStage[] = ["folders", "finishing_file"];
// Emitted after the backup file is finished, when the backend waits for
// cold-backup add-ons to come back up
const AWAIT_RESTART_STAGES: CreateBackupStage[] = [
"await_addon_restarts",
"await_app_restarts",
];
// Ordered groups matching actual backend execution order. The await restart
// stages share the last creation group to keep the progress monotonic.
const MEDIA_STAGES: CreateBackupStage[] = ["folders", "finishing_file"];
// Ordered groups matching actual backend execution order
const STAGE_ORDER: CreateBackupStage[][] = [
[...SETUP_STAGES, ...HA_STAGES],
ADDON_STAGES,
[...MEDIA_STAGES, ...AWAIT_RESTART_STAGES],
MEDIA_STAGES,
HA_STAGES,
["upload_to_agents"],
["cleaning_up"],
];
@@ -173,21 +165,21 @@ export class HaBackupOverviewProgress extends LitElement {
return [
{
label: this.hass.localize(
"ui.panel.config.backup.overview.progress.segments.home_assistant"
"ui.panel.config.backup.overview.progress.segments.apps"
),
state: this._getSegmentState(0, currentGroupIndex),
flex: 2,
},
{
label: this.hass.localize(
"ui.panel.config.backup.overview.progress.segments.apps"
"ui.panel.config.backup.overview.progress.segments.media"
),
state: this._getSegmentState(1, currentGroupIndex),
flex: 2,
},
{
label: this.hass.localize(
"ui.panel.config.backup.overview.progress.segments.media"
"ui.panel.config.backup.overview.progress.segments.home_assistant"
),
state: this._getSegmentState(2, currentGroupIndex),
flex: 2,
@@ -209,18 +201,18 @@ export class HaBackupOverviewProgress extends LitElement {
];
}
// Non-HAOS: No app segment, just HA, Media, Upload and Cleaning up
// Non-HAOS: No app segment, just Media, HA, Upload and Cleaning up
return [
{
label: this.hass.localize(
"ui.panel.config.backup.overview.progress.segments.home_assistant"
"ui.panel.config.backup.overview.progress.segments.media"
),
state: this._getSegmentState(0, currentGroupIndex),
state: this._getSegmentState(1, currentGroupIndex),
flex: 2,
},
{
label: this.hass.localize(
"ui.panel.config.backup.overview.progress.segments.media"
"ui.panel.config.backup.overview.progress.segments.home_assistant"
),
state: this._getSegmentState(2, currentGroupIndex),
flex: 2,
-9
View File
@@ -39,7 +39,6 @@ import {
import memoizeOne from "memoize-one";
import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
import type { HomeAssistant } from "../../types";
import { mdiMqttLogo } from "../../resources/mqtt-logo-svg";
const getHasDomainCheck = (domain: string) => {
const prefix = `${domain}.`;
@@ -143,14 +142,6 @@ export const configSections: Record<string, PageNavigation[]> = {
translationKey: "knx",
adminOnly: true,
},
{
path: "/config/mqtt",
iconPath: mdiMqttLogo,
iconColor: "#660066",
component: "mqtt",
translationKey: "mqtt",
adminOnly: true,
},
{
path: "/config/thread",
iconPath:
@@ -1393,7 +1393,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
invert: this._switchAsInvert,
target_domain: this._switchAsDomain,
}
)) as DataEntryFlowStepCreateEntry<ConfigEntry>;
)) as DataEntryFlowStepCreateEntry;
if (configFlowResult.result?.entry_id) {
try {
const entry = await this._waitForEntityRegistryUpdate(
@@ -1459,7 +1459,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
invert: this._switchAsInvert,
target_domain: this._switchAsDomain,
}
)) as DataEntryFlowStepCreateEntry<ConfigEntry>;
)) as DataEntryFlowStepCreateEntry;
if (configFlowResult.result?.entry_id) {
try {
const entry = await this._waitForEntityRegistryUpdate(
@@ -5,7 +5,6 @@ import {
mdiPlus,
mdiShape,
mdiTune,
mdiVectorPolyline,
} from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
@@ -145,10 +144,6 @@ export class MatterConfigDashboard extends LitElement {
<ha-card class="nav-card">
<div class="card-header">
${this.hass.localize("ui.panel.config.matter.panel.my_network_title")}
<ha-button appearance="filled" href="/config/matter/visualization">
<ha-svg-icon slot="start" .path=${mdiVectorPolyline}></ha-svg-icon>
${this.hass.localize("ui.panel.config.matter.panel.show_map")}
</ha-button>
</div>
<div class="card-content">
<ha-md-list>
@@ -257,9 +252,6 @@ export class MatterConfigDashboard extends LitElement {
}
.nav-card .card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: var(--ha-space-2);
}
@@ -27,10 +27,6 @@ class MatterConfigRouter extends HassRouterPage {
tag: "matter-options-page",
load: () => import("./matter-options-page"),
},
visualization: {
tag: "matter-network-visualization",
load: () => import("./matter-network-visualization"),
},
},
};
@@ -1,360 +0,0 @@
import { getDeviceArea } from "../../../../../common/entity/context/get_device_context";
import type {
NetworkData,
NetworkLink,
NetworkNode,
} from "../../../../../components/chart/ha-network-graph";
import type {
MatterNetworkTopology,
MatterNetworkTopologyNode,
MatterTopologyStrength,
} from "../../../../../data/matter";
import type { HomeAssistant } from "../../../../../types";
const CATEGORY_HOME_ASSISTANT = 0;
const CATEGORY_BORDER_ROUTER = 1;
const CATEGORY_ROUTER = 2;
const CATEGORY_END_DEVICE = 3;
const CATEGORY_WIFI_AP = 4;
const CATEGORY_OFFLINE = 5;
const CATEGORY_UNKNOWN = 6;
const ROUTER_ROLES = new Set(["leader", "router", "reed"]);
// HA is not a Matter node; the frontend synthesizes it as the graph root.
export const HOME_ASSISTANT_NODE_ID = "ha";
const HOME_ASSISTANT_LABEL = "Home Assistant";
// 0 is never returned: a falsy link value re-enables the direction arrow
// in ha-network-graph
export const strengthToScale = (
strength?: MatterTopologyStrength | null
): number => {
switch (strength) {
case "strong":
return 4;
case "medium":
return 3;
case "weak":
return 2;
// "unknown" (no measurement, link presumed up) sits above "none"/dead so it
// reads as a present link, not a degraded one
case "unknown":
return 2;
default:
return 1;
}
};
// links are colored by transport; signal level stays on the line width.
// Both hues clear 3:1 on the light and the dark card background -- named
// palette colors have no dark variant, so a darker pick would vanish.
export const networkToColorVar = (network?: string | null): string => {
switch (network) {
case "thread":
return "--purple-color";
case "wifi":
return "--orange-color";
// `network` is a plain string on the wire: "ethernet" and anything a newer
// server invents still draw, just neutrally
default:
return "--secondary-text-color";
}
};
const strengthToWidth = (strength?: MatterTopologyStrength | null): number =>
strength === "strong" ? 3 : strength === "medium" ? 2 : 1;
export const getTopologyNodeCategory = (
node: MatterNetworkTopologyNode
): number => {
if (node.kind === "border_router") {
return CATEGORY_BORDER_ROUTER;
}
if (node.kind === "wifi_ap") {
return CATEGORY_WIFI_AP;
}
if (node.kind === "thread_unknown") {
return CATEGORY_UNKNOWN;
}
if (node.available === false) {
return CATEGORY_OFFLINE;
}
return node.role && ROUTER_ROLES.has(node.role)
? CATEGORY_ROUTER
: CATEGORY_END_DEVICE;
};
export const getTopologyNodeName = (
node: MatterNetworkTopologyNode,
hass: HomeAssistant
): string => {
const device = node.ha_device_id
? hass.devices[node.ha_device_id]
: undefined;
if (device) {
return device.name_by_user || device.name || node.id;
}
if (node.kind === "border_router") {
return (
// many vendors report an identical vendor/model pair on every unit
node.host_name ||
[node.vendor_name, node.model_name].filter(Boolean).join(" ") ||
hass.localize("ui.panel.config.matter.visualization.border_router")
);
}
if (node.kind === "wifi_ap") {
return (
// the SSID names the network; network_name still holds the BSSID here
node.ssid ||
node.network_name ||
hass.localize("ui.panel.config.matter.visualization.wifi_ap")
);
}
if (node.kind === "thread_unknown") {
return hass.localize("ui.panel.config.matter.visualization.unknown_device");
}
if (node.node_id != null) {
return hass.localize("ui.panel.config.matter.visualization.node", {
node_id: node.node_id,
});
}
return node.id;
};
const isHub = (category: number): boolean =>
category === CATEGORY_BORDER_ROUTER || category === CATEGORY_WIFI_AP;
export function createMatterNetworkChartData(
topology: MatterNetworkTopology,
hass: HomeAssistant,
element: Element
): NetworkData {
const style = getComputedStyle(element);
// a hub wears its transport's colour, the same hue as the links behind it
const categoryColors = [
style.getPropertyValue("--primary-color"),
style.getPropertyValue(networkToColorVar("thread")),
style.getPropertyValue("--cyan-color"),
style.getPropertyValue("--teal-color"),
style.getPropertyValue(networkToColorVar("wifi")),
style.getPropertyValue("--error-color"),
style.getPropertyValue("--disabled-color"),
];
const categories = [
{
name: HOME_ASSISTANT_LABEL,
symbol: "roundRect",
itemStyle: { color: categoryColors[CATEGORY_HOME_ASSISTANT] },
},
{
name: hass.localize("ui.panel.config.matter.visualization.border_router"),
symbol: "roundRect",
itemStyle: { color: categoryColors[CATEGORY_BORDER_ROUTER] },
},
{
name: hass.localize("ui.panel.config.matter.visualization.router"),
symbol: "circle",
itemStyle: { color: categoryColors[CATEGORY_ROUTER] },
},
{
name: hass.localize("ui.panel.config.matter.visualization.end_device"),
symbol: "circle",
itemStyle: { color: categoryColors[CATEGORY_END_DEVICE] },
},
{
name: hass.localize("ui.panel.config.matter.visualization.wifi_ap"),
symbol: "roundRect",
itemStyle: { color: categoryColors[CATEGORY_WIFI_AP] },
},
{
name: hass.localize("ui.panel.config.matter.visualization.offline"),
symbol: "circle",
itemStyle: { color: categoryColors[CATEGORY_OFFLINE] },
},
{
name: hass.localize(
"ui.panel.config.matter.visualization.unknown_devices"
),
symbol: "circle",
itemStyle: { color: categoryColors[CATEGORY_UNKNOWN] },
},
];
const threadNetworks = new Set(
topology.nodes.map((node) => node.ext_pan_id).filter(Boolean)
);
const multiNetwork = threadNetworks.size > 1;
const nodes: NetworkNode[] = [
{
id: HOME_ASSISTANT_NODE_ID,
name: HOME_ASSISTANT_LABEL,
category: CATEGORY_HOME_ASSISTANT,
value: 4,
symbol: "roundRect",
symbolSize: 45,
polarDistance: 0,
fixed: true,
itemStyle: { color: categoryColors[CATEGORY_HOME_ASSISTANT] },
},
];
const nodeCategories = new Map<string, number>();
topology.nodes.forEach((node) => {
const category = getTopologyNodeCategory(node);
nodeCategories.set(node.id, category);
const device = node.ha_device_id
? hass.devices[node.ha_device_id]
: undefined;
const area = device
? getDeviceArea(device, hass.areas, hass.devices)
: undefined;
const name = getTopologyNodeName(node, hass);
// an AP is named by its SSID, so its own radio address is what tells two
// radios of one mesh apart; everything else is named by its network
const networkLabel =
node.kind === "wifi_ap"
? node.bssid || node.network_name
: node.ssid || node.network_name;
const contextParts: string[] = [];
if (area) {
contextParts.push(area.name);
}
// skip a label that just repeats the name, e.g. an AP with no SSID
if ((multiNetwork || !area) && networkLabel && networkLabel !== name) {
contextParts.push(networkLabel);
}
nodes.push({
id: node.id,
name,
context: contextParts.join(" • ") || undefined,
category,
value: isHub(category) ? 3 : category === CATEGORY_ROUTER ? 2 : 1,
symbol: isHub(category) ? "roundRect" : "circle",
symbolSize: isHub(category) ? 40 : category === CATEGORY_ROUTER ? 30 : 20,
itemStyle: {
color: categoryColors[category],
...(node.role === "leader"
? {
borderColor: style.getPropertyValue("--primary-color"),
borderWidth: 2,
}
: {}),
},
polarDistance: isHub(category)
? 0.1
: category === CATEGORY_ROUTER
? 0.4
: 0.8,
});
});
const links: NetworkLink[] = [];
topology.connections.forEach((conn) => {
if (!nodeCategories.has(conn.source) || !nodeCategories.has(conn.target)) {
return;
}
// the summary strength is the strongest observed direction, so "none" means
// every direction is dead -- a stale neighbour entry the dashboard also
// refuses to draw
if (conn.strength === "none") {
return;
}
let { source, target } = conn;
let forward = conn.source_to_target;
let reverse = conn.target_to_source;
if (!forward && reverse) {
// normalize so the arrow points in the observed direction
[source, target] = [target, source];
forward = reverse;
reverse = undefined;
}
const oneWay = Boolean(forward) && !reverse;
const asymmetric =
forward && reverse && forward.strength !== reverse.strength;
// an edge is lower confidence when an endpoint is inferred rather than
// commissioned, or is offline -- the dashboard dashes on the same two
const lowConfidence = [source, target].some((id) => {
const category = nodeCategories.get(id);
return category === CATEGORY_UNKNOWN || category === CATEGORY_OFFLINE;
});
const width = strengthToWidth(conn.strength);
links.push({
source,
target,
value: strengthToScale(forward?.strength ?? conn.strength),
// route-table edges without per-direction info are not directional
reverseValue: oneWay
? undefined
: strengthToScale(reverse?.strength ?? conn.strength),
symbolSize: oneWay ? width * 2 + 3 : undefined,
lineStyle: {
width,
color: style.getPropertyValue(networkToColorVar(conn.network)),
type:
oneWay || asymmetric || lowConfidence
? "dashed"
: !forward && conn.via_route_table
? "dotted"
: "solid",
},
ignoreForceLayout: !(
isHub(nodeCategories.get(source)!) || isHub(nodeCategories.get(target)!)
),
});
});
// Only a hub gets an edge to HA, and it is a real path. A node whose route we
// cannot see gets nothing: inventing an edge to HA reads as a physical link.
// It keeps the HA node's own color rather than a transport hue.
// `symbol: "none"` is what keeps the arrowhead off these edges -- ha-network-graph
// keys arrow suppression on `reverseValue`, not on `value` -- so it must stay.
const haLink = (target: string, network: string): NetworkLink => ({
source: HOME_ASSISTANT_NODE_ID,
target,
value: 0,
symbol: "none",
lineStyle: {
width: 3,
// the same hue as the radio links behind this hub, so one transport
// reads as one colour all the way back to Home Assistant
color: style.getPropertyValue(networkToColorVar(network)),
type: "solid",
},
});
// HA reaches the mesh through the border routers and Wi-Fi access points
topology.nodes
.filter((node) => node.kind === "border_router" || node.kind === "wifi_ap")
.forEach((node) =>
links.push(haLink(node.id, node.kind === "wifi_ap" ? "wifi" : "thread"))
);
// keep the strongest link of every node in the force layout so
// nodes hang near their best connection instead of floating free
nodes.forEach((node) => {
let bestLink: NetworkLink | undefined;
const hasActiveLink = links.some((link) => {
if (link.source !== node.id && link.target !== node.id) {
return false;
}
if (!link.ignoreForceLayout) {
return true;
}
const linkValue = Math.max(link.value ?? 0, link.reverseValue ?? 0);
if (
linkValue >
Math.max(bestLink?.value ?? -1, bestLink?.reverseValue ?? -1)
) {
bestLink = link;
}
return false;
});
if (!hasActiveLink && bestLink) {
bestLink.ignoreForceLayout = false;
}
});
return { nodes, links, categories };
}
@@ -1,508 +0,0 @@
import { mdiRefresh } from "@mdi/js";
import type {
CallbackDataParams,
TopLevelFormatterParams,
} from "echarts/types/dist/shared";
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 memoizeOne from "memoize-one";
import { relativeTime } from "../../../../../common/datetime/relative_time";
import { getDeviceArea } from "../../../../../common/entity/context/get_device_context";
import { navigate } from "../../../../../common/navigate";
import type { LocalizeKeys } from "../../../../../common/translations/localize";
import { throttle } from "../../../../../common/util/throttle";
import "../../../../../components/chart/ha-network-graph";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-spinner";
import "../../../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../../../components/input/ha-input-search";
import type {
MatterNetworkTopology,
MatterNetworkTopologyConnection,
MatterNetworkTopologyNode,
MatterTopologyDirectionInfo,
} from "../../../../../data/matter";
import {
fetchMatterNetworkTopology,
subscribeMatterNetworkTopology,
} from "../../../../../data/matter";
import "../../../../../layouts/hass-subpage";
import type { HomeAssistant, Route } from "../../../../../types";
import {
createMatterNetworkChartData,
getTopologyNodeName,
HOME_ASSISTANT_NODE_ID,
} from "./matter-network-data";
const UPDATE_THROTTLE_TIME = 5000;
@customElement("matter-network-visualization")
export class MatterNetworkVisualization extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ attribute: false }) public route!: Route;
@state() private _topology?: MatterNetworkTopology;
@state() private _notSupported = false;
@state() private _error?: string;
@state() private _refreshing = false;
@state() private _searchFilter = "";
private _unsub?: Promise<UnsubscribeFunc>;
private _throttledUpdateTopology = throttle(
(topology: MatterNetworkTopology) => {
this._topology = topology;
},
UPDATE_THROTTLE_TIME
);
public connectedCallback(): void {
super.connectedCallback();
if (this.hass && !this._unsub) {
this._subscribe();
}
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this._throttledUpdateTopology.cancel();
if (this._unsub) {
this._unsub.then((unsub) => unsub()).catch(() => undefined);
this._unsub = undefined;
}
}
private _subscribe(): void {
this._unsub = subscribeMatterNetworkTopology(this.hass, (topology) => {
if (!this._topology) {
this._topology = topology;
} else {
this._throttledUpdateTopology(topology);
}
});
this._unsub.catch((err: { code?: string; message?: string }) => {
this._unsub = undefined;
if (err?.code === "not_supported" || err?.code === "unknown_command") {
this._notSupported = true;
} else {
this._error = err?.message || String(err);
}
});
}
protected render() {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize(
"ui.panel.config.matter.visualization.header"
)}
back-path="/config/matter/dashboard"
>
${
this.narrow && this._topology?.nodes.length
? html`<div slot="header">${this._renderInputSearch()}</div>`
: nothing
}
${this._renderContent()}
</hass-subpage>
`;
}
private _renderContent() {
if (this._notSupported) {
return html`<div class="center">
<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.config.matter.visualization.not_supported"
)}
</ha-alert>
</div>`;
}
if (this._error) {
return html`<div class="center">
<ha-alert alert-type="error">
${this.hass.localize(
"ui.panel.config.matter.visualization.error_loading",
{ error: this._error }
)}
</ha-alert>
</div>`;
}
if (!this._topology) {
return html`<div class="center"><ha-spinner></ha-spinner></div>`;
}
if (!this._topology.nodes.length) {
return html`<div class="center empty">
${this.hass.localize("ui.panel.config.matter.visualization.empty")}
</div>`;
}
return html`
<ha-network-graph
.hass=${this.hass}
.searchFilter=${this._searchFilter}
.data=${this._formatNetworkData(
this._topology,
this.hass.devices,
this.hass.areas,
this.hass.themes,
this.hass.language
)}
.searchableAttributes=${this._getSearchableAttributes}
.tooltipFormatter=${this._tooltipFormatter}
@chart-click=${this._handleChartClick}
>
${!this.narrow ? this._renderInputSearch("search") : nothing}
<ha-icon-button
slot="button"
class="refresh-button"
.disabled=${this._refreshing}
.path=${mdiRefresh}
@click=${this._refreshTopology}
label=${this.hass.localize(
"ui.panel.config.matter.visualization.refresh_topology"
)}
></ha-icon-button>
</ha-network-graph>
`;
}
private _renderInputSearch(slot = "") {
return html`<ha-input-search
appearance="outlined"
slot=${slot}
.value=${this._searchFilter}
@input=${this._handleSearchChange}
></ha-input-search>`;
}
private _handleSearchChange(ev: InputEvent): void {
this._searchFilter = (ev.target as HaInputSearch).value ?? "";
}
private async _refreshTopology(): Promise<void> {
if (this._refreshing) {
return;
}
this._refreshing = true;
try {
this._topology = await fetchMatterNetworkTopology(this.hass, true);
} catch (err: unknown) {
this._error = (err as { message?: string })?.message || String(err);
} finally {
this._refreshing = false;
}
}
private _formatNetworkData = memoizeOne(
(
topology: MatterNetworkTopology,
_devices: HomeAssistant["devices"],
_areas: HomeAssistant["areas"],
// node/link colors and labels also depend on the theme and language,
// so both take part in the cache key even though they are read via hass
_themes: HomeAssistant["themes"],
_language: HomeAssistant["language"]
) => createMatterNetworkChartData(topology, this.hass, this)
);
private _getTopologyNode(id: string): MatterNetworkTopologyNode | undefined {
return this._topology?.nodes.find((node) => node.id === id);
}
private _getConnection(
source: string,
target: string
): MatterNetworkTopologyConnection | undefined {
return this._topology?.connections.find(
(conn) =>
(conn.source === source && conn.target === target) ||
(conn.source === target && conn.target === source)
);
}
private _getNodeName(id: string): string {
const node = this._getTopologyNode(id);
return node ? getTopologyNodeName(node, this.hass) : id;
}
private _getSearchableAttributes = (nodeId: string): string[] => {
const node = this._getTopologyNode(nodeId);
if (!node) {
return [];
}
const attributes: string[] = [];
if (node.node_id != null) {
attributes.push(String(node.node_id));
}
if (node.network_name) {
attributes.push(node.network_name);
}
if (node.ext_address) {
attributes.push(node.ext_address);
}
if (node.vendor_name) {
attributes.push(node.vendor_name);
}
if (node.model_name) {
attributes.push(node.model_name);
}
if (node.host_name) {
attributes.push(node.host_name);
}
const device = node.ha_device_id
? this.hass.devices[node.ha_device_id]
: undefined;
if (device?.manufacturer) {
attributes.push(device.manufacturer);
}
if (device?.model) {
attributes.push(device.model);
}
device?.connections.forEach((connection) => {
attributes.push(connection[1]);
});
return attributes;
};
private _localizeDynamic(prefix: string, value: string): string {
return (
this.hass.localize(
`ui.panel.config.matter.${prefix}.${value}` as LocalizeKeys
) || value
);
}
private _formatDirection(direction: MatterTopologyDirectionInfo): string {
const strength = this._localizeDynamic(
"visualization.strength",
direction.strength
);
if (direction.lqi != null) {
return `${strength} (LQI ${direction.lqi})`;
}
if (direction.rssi != null) {
return `${strength} (RSSI ${direction.rssi} dBm)`;
}
return strength;
}
private _tooltipFormatter = (params: TopLevelFormatterParams) => {
const { dataType, data } = params as CallbackDataParams;
if (dataType === "edge") {
const { source, target } = data as { source: string; target: string };
const conn = this._getConnection(source, target);
if (!conn) {
return nothing;
}
const lines: TemplateResult[] = [];
// the link color now encodes the transport, and the graph legend can
// only describe nodes, so name it here
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.network"
)}:</b
>
${this._localizeDynamic("network_type", conn.network)}`
);
if (conn.source_to_target) {
lines.push(
html`<br />${this._getNodeName(conn.source)}
${this._getNodeName(conn.target)}:
${this._formatDirection(conn.source_to_target)}`
);
}
if (conn.target_to_source) {
lines.push(
html`<br />${this._getNodeName(conn.target)}
${this._getNodeName(conn.source)}:
${this._formatDirection(conn.target_to_source)}`
);
}
if (!conn.source_to_target && !conn.target_to_source) {
// no per-direction reading: state the overall strength the width is
// drawn from, so this edge class is not left unexplained
const details = [
this._localizeDynamic("visualization.strength", conn.strength),
];
if (conn.via_route_table) {
details.push(
this.hass.localize(
"ui.panel.config.matter.visualization.via_route_table"
)
);
}
lines.push(html`<br />${details.join(" • ")}`);
}
return html`<b
>${this._getNodeName(conn.source)}
${this._getNodeName(conn.target)}</b
>${lines}`;
}
const { id } = data as { id: string };
if (id === HOME_ASSISTANT_NODE_ID) {
return html`<b>Home Assistant</b>`;
}
const node = this._getTopologyNode(id);
if (!node) {
return nothing;
}
const device = node.ha_device_id
? this.hass.devices[node.ha_device_id]
: undefined;
const area = device
? getDeviceArea(device, this.hass.areas, this.hass.devices)
: undefined;
const lines: TemplateResult[] = [];
if (node.node_id != null) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.node_id"
)}:</b
>
${node.node_id}`
);
}
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.network"
)}:</b
>
${this._localizeDynamic("network_type", node.network_type)}${
node.network_name ? html` (${node.network_name})` : nothing
}`
);
if (node.role) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.role"
)}:</b
>
${this._localizeDynamic("visualization.roles", node.role)}`
);
}
if (node.available != null) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.status"
)}:</b
>
${this.hass.localize(
node.available
? "ui.panel.config.matter.visualization.online"
: "ui.panel.config.matter.visualization.offline"
)}`
);
}
if (device?.manufacturer || node.vendor_name) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.manufacturer"
)}:</b
>
${device?.manufacturer || node.vendor_name}`
);
}
if (device?.model || node.model_name) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.model"
)}:</b
>
${device?.model || node.model_name}`
);
}
if (area) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.area"
)}:</b
>
${area.name}`
);
}
if (node.last_seen != null) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.last_seen"
)}:</b
>
${relativeTime(new Date(node.last_seen), this.hass.locale)}`
);
}
return html`<b>${this._getNodeName(id)}</b>${lines}`;
};
private _handleChartClick(e: CustomEvent): void {
if (
e.detail.dataType === "node" &&
e.detail.event.target.cursor === "pointer"
) {
const { id } = e.detail.data;
const node = this._getTopologyNode(id);
if (node?.ha_device_id) {
navigate(`/config/devices/device/${node.ha_device_id}`);
}
}
}
static get styles(): CSSResultGroup {
return [
css`
ha-network-graph {
height: 100%;
}
[slot="header"] {
display: flex;
align-items: center;
}
ha-input-search {
flex: 1;
}
.center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: var(--ha-space-4);
box-sizing: border-box;
}
ha-alert {
max-width: 500px;
}
.empty {
color: var(--secondary-text-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"matter-network-visualization": MatterNetworkVisualization;
}
}
@@ -1,27 +1,14 @@
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import {
mdiAlertCircleOutline,
mdiCheck,
mdiDevices,
mdiShape,
mdiTune,
} from "@mdi/js";
import { storage } from "../../../../../common/decorators/storage";
import "../../../../../components/ha-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-code-editor";
import "../../../../../components/ha-formfield";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-svg-icon";
import type { HaSelectSelectEvent } from "../../../../../components/ha-select";
import "../../../../../components/ha-switch";
import "../../../../../components/input/ha-input";
import type { ConfigEntry } from "../../../../../data/config_entries";
import { getConfigEntries } from "../../../../../data/config_entries";
import type { Action } from "../../../../../data/script";
import { callExecuteScript } from "../../../../../data/service";
@@ -31,10 +18,6 @@ import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import { showToast } from "../../../../../util/toast";
import "./mqtt-subscribe-card";
import { brandsUrl } from "../../../../../util/brands-url";
import { showConfigFlowDialog } from "../../../../../dialogs/config-flow/show-dialog-config-flow";
import { fetchIntegrationManifest } from "../../../../../data/integration";
import { mdiMqttLogo } from "../../../../../resources/mqtt-logo-svg";
const qosLevel = ["0", "1", "2"];
@@ -76,240 +59,78 @@ export class MQTTConfigPanel extends LitElement {
})
private _retain = false;
@state() private _configEntry?: ConfigEntry;
protected firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
if (this.hass) {
this._fetchConfigEntry();
}
}
private _MQTTDeviceIds = 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
);
protected render(): TemplateResult | typeof nothing {
if (!this._configEntry) {
return nothing;
}
const isOnline = this._configEntry.state === "loaded";
const deviceIds = this._MQTTDeviceIds(
this.hass.devices,
this._configEntry.entry_id
);
const entityCount = this._entityCount(this.hass.entities, deviceIds);
protected render(): TemplateResult {
return html`
<hass-subpage
.narrow=${this.narrow}
.hass=${this.hass}
header="MQTT"
back-path="/config/integrations/integration/mqtt"
has-fab
>
<div class="content">
<div class="container">
${this._renderNetworkStatus(isOnline, deviceIds.size)}
${this._renderMyNetworkCard(deviceIds.size, entityCount)}
${this._renderNavigationCard()} ${this._renderPublishCard()}
<mqtt-subscribe-card .hass=${this.hass}></mqtt-subscribe-card>
</div>
<ha-card
.header=${this.hass.localize("ui.panel.config.mqtt.settings_title")}
>
<div class="card-actions">
<ha-button appearance="plain" @click=${this._openOptionFlow}
>${this.hass.localize(
"ui.panel.config.mqtt.option_flow"
)}</ha-button
>
</div>
</ha-card>
<ha-card
.header=${this.hass.localize(
"ui.panel.config.mqtt.description_publish"
)}
>
<div class="card-content">
<div class="panel-dev-mqtt-fields">
<ha-input
.label=${this.hass.localize("ui.panel.config.mqtt.topic")}
.value=${this._topic}
@change=${this._handleTopic}
></ha-input>
<ha-select
.label=${this.hass.localize("ui.panel.config.mqtt.qos")}
.value=${this._qos}
@selected=${this._handleQos}
.options=${qosLevel}
>
</ha-select>
<ha-formfield
label=${this.hass!.localize("ui.panel.config.mqtt.retain")}
>
<ha-switch
@change=${this._handleRetain}
.checked=${this._retain}
></ha-switch>
</ha-formfield>
</div>
<p>${this.hass.localize("ui.panel.config.mqtt.payload")}</p>
<ha-code-editor
mode="jinja2"
autocomplete-entities
autocomplete-icons
.value=${this._payload}
@value-changed=${this._handlePayload}
dir="ltr"
></ha-code-editor>
</div>
<div class="card-actions">
<ha-button appearance="plain" @click=${this._publish}
>${this.hass.localize(
"ui.panel.config.mqtt.publish"
)}</ha-button
>
</div>
</ha-card>
<mqtt-subscribe-card .hass=${this.hass}></mqtt-subscribe-card>
</div>
</hass-subpage>
`;
}
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.mqtt.status_${isOnline ? "online" : "offline"}`
)}<br />
<small>
${this.hass.localize("ui.panel.config.mqtt.devices", {
count: deviceCount,
})}
</small>
</div>
<img
class="logo"
alt="MQTT"
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: "mqtt",
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>
</div>
</div>
</ha-card>
`;
}
private _renderMyNetworkCard(deviceCount: number, entityCount: number) {
return html`
<ha-card class="nav-card">
<div class="card-header">
${this.hass.localize("ui.panel.config.mqtt.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.mqtt.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.mqtt.entity_count", {
count: entityCount,
})}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
</ha-md-list>
</div>
</ha-card>
`;
}
private _renderNavigationCard() {
return html`
<ha-card class="nav-card">
<div class="card-content">
<ha-md-list>
<ha-md-list-item type="link" @click=${this._openOptionFlow}>
<ha-svg-icon slot="start" .path=${mdiTune}></ha-svg-icon>
<div slot="headline">
${this.hass.localize("ui.panel.config.mqtt.option_flow")}
</div>
<div slot="supporting-text">
${this.hass.localize(
"ui.panel.config.mqtt.option_flow_description"
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
<ha-md-list-item type="link" @click=${this._openConfigFlow}>
<ha-svg-icon slot="start" .path=${mdiMqttLogo}></ha-svg-icon>
<div slot="headline">
${this.hass.localize("ui.panel.config.mqtt.config_flow")}
</div>
<div slot="supporting-text">
${this.hass.localize(
"ui.panel.config.mqtt.config_flow_description"
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
</ha-md-list>
</div>
</ha-card>
`;
}
private _renderPublishCard() {
return html`
<ha-card
.header=${this.hass.localize(
"ui.panel.config.mqtt.description_publish"
)}
>
<div class="card-content">
<div class="panel-dev-mqtt-fields">
<ha-input
.label=${this.hass.localize("ui.panel.config.mqtt.topic")}
.value=${this._topic}
@change=${this._handleTopic}
></ha-input>
<ha-select
.label=${this.hass.localize("ui.panel.config.mqtt.qos")}
.value=${this._qos}
@selected=${this._handleQos}
.options=${qosLevel}
>
</ha-select>
<ha-formfield
label=${this.hass!.localize("ui.panel.config.mqtt.retain")}
>
<ha-switch
@change=${this._handleRetain}
.checked=${this._retain}
></ha-switch>
</ha-formfield>
</div>
<p>${this.hass.localize("ui.panel.config.mqtt.payload")}</p>
<ha-code-editor
mode="jinja2"
autocomplete-entities
autocomplete-icons
.value=${this._payload}
@value-changed=${this._handlePayload}
dir="ltr"
></ha-code-editor>
</div>
<div class="card-actions">
<ha-button appearance="plain" @click=${this._publish}
>${this.hass.localize("ui.panel.config.mqtt.publish")}</ha-button
>
</div>
</ha-card>
`;
}
private async _fetchConfigEntry(): Promise<void> {
const configEntries = await getConfigEntries(this.hass, {
domain: "mqtt",
});
this._configEntry = configEntries.find(
(entry) => entry.disabled_by === null && entry.source !== "ignore"
);
}
private _handleTopic(ev: InputEvent) {
this._topic = (ev.target as HTMLInputElement).value;
}
@@ -354,23 +175,19 @@ export class MQTTConfigPanel extends LitElement {
}
private async _openOptionFlow() {
showOptionsFlowDialog(this, this._configEntry!);
}
private _openConfigFlow = async () => {
if (!this._configEntry) {
const searchParams = new URLSearchParams(window.location.search);
if (!searchParams.has("config_entry")) {
return;
}
showConfigFlowDialog(this, {
startFlowHandler: this._configEntry.domain,
manifest: await fetchIntegrationManifest(
this.hass,
this._configEntry.domain
),
entryId: this._configEntry.entry_id,
navigateToResult: true,
const configEntryId = searchParams.get("config_entry") as string;
const configEntries = await getConfigEntries(this.hass, {
domain: "mqtt",
});
};
const configEntry = configEntries.find(
(entry) => entry.entry_id === configEntryId
);
showOptionsFlowDialog(this, configEntry!);
}
static get styles(): CSSResultGroup {
return [
@@ -382,37 +199,17 @@ export class MQTTConfigPanel extends LitElement {
-moz-user-select: initial;
}
.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);
padding: 24px 0 32px;
max-width: 600px;
margin: 0 auto;
direction: ltr;
}
.panel-dev-mqtt-fields {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
}
ha-card {
margin: 0 auto var(--ha-space-4);
max-width: 600px;
}
ha-md-list {
background: none;
padding: 0;
}
ha-md-list-item {
--md-item-overflow: visible;
}
ha-select {
width: 96px;
margin: 0 8px;
@@ -439,78 +236,6 @@ export class MQTTConfigPanel extends LitElement {
display: block;
margin: 16px auto;
}
.network-status div.heading {
display: flex;
align-items: center;
column-gap: var(--ha-space-4);
}
.network-status div.heading .logo {
height: 40px;
width: 40px;
margin-inline-start: auto;
object-fit: contain;
}
.network-status div.heading .icon {
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 .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);
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)
calc(var(--ha-space-16) + var(--safe-area-inset-bottom, 0px));
}
a[slot="fab"] {
text-decoration: none;
}
`,
];
}
@@ -1,20 +1,15 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { mdiContentCopy } from "@mdi/js";
import { formatTime } from "../../../../../common/datetime/format_time";
import { copyToClipboard } from "../../../../../common/util/copy-clipboard";
import "../../../../../components/ha-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-markdown";
import type { HaSelectSelectEvent } from "../../../../../components/ha-select";
import "../../../../../components/ha-select";
import "../../../../../components/input/ha-input";
import type { MQTTMessage } from "../../../../../data/mqtt";
import { subscribeMQTTTopic } from "../../../../../data/mqtt";
import type { HomeAssistant } from "../../../../../types";
import { showToast } from "../../../../../util/toast";
import { storage } from "../../../../../common/decorators/storage";
import "../../../../../components/ha-formfield";
@@ -73,56 +68,53 @@ class MqttSubscribeCard extends LitElement {
return html`
<ha-card
header=${this.hass.localize("ui.panel.config.mqtt.description_listen")}
class="content_subscribe_panel"
>
<div class="card-content">
<form>
<p>
<ha-formfield
label=${this.hass!.localize(
"ui.panel.config.mqtt.json_formatting"
)}
>
<ha-switch
@change=${this._handleJSONFormat}
.checked=${this._json_format}
></ha-switch>
</ha-formfield>
</p>
<div class="panel-dev-mqtt-subscribe-fields">
<ha-input
.label=${
this._subscribed
? this.hass.localize("ui.panel.config.mqtt.listening_to")
: this.hass.localize("ui.panel.config.mqtt.subscribe_to")
}
.disabled=${this._subscribed !== undefined}
.value=${this._topic}
@change=${this._handleTopic}
></ha-input>
<ha-select
.label=${this.hass.localize("ui.panel.config.mqtt.qos")}
.disabled=${this._subscribed !== undefined}
.value=${this._qos}
@selected=${this._handleQos}
.options=${qosLevel}
>
</ha-select>
<ha-button
appearance="plain"
size="s"
.disabled=${this._topic === ""}
@click=${this._handleSubmit}
>
${
this._subscribed
? this.hass.localize("ui.panel.config.mqtt.stop_listening")
: this.hass.localize("ui.panel.config.mqtt.start_listening")
}
</ha-button>
</div>
</form>
</div>
<form>
<p>
<ha-formfield
label=${this.hass!.localize(
"ui.panel.config.mqtt.json_formatting"
)}
>
<ha-switch
@change=${this._handleJSONFormat}
.checked=${this._json_format}
></ha-switch>
</ha-formfield>
</p>
<div class="panel-dev-mqtt-subscribe-fields">
<ha-input
.label=${
this._subscribed
? this.hass.localize("ui.panel.config.mqtt.listening_to")
: this.hass.localize("ui.panel.config.mqtt.subscribe_to")
}
.disabled=${this._subscribed !== undefined}
.value=${this._topic}
@change=${this._handleTopic}
></ha-input>
<ha-select
.label=${this.hass.localize("ui.panel.config.mqtt.qos")}
.disabled=${this._subscribed !== undefined}
.value=${this._qos}
@selected=${this._handleQos}
.options=${qosLevel}
>
</ha-select>
<ha-button
appearance="plain"
size="s"
.disabled=${this._topic === ""}
@click=${this._handleSubmit}
>
${
this._subscribed
? this.hass.localize("ui.panel.config.mqtt.stop_listening")
: this.hass.localize("ui.panel.config.mqtt.start_listening")
}
</ha-button>
</div>
</form>
<div class="events">
${this._messages.map(
(msg) => html`
@@ -136,17 +128,7 @@ class MqttSubscribeCard extends LitElement {
this.hass!.config
),
})}
<div class="code-block">
<ha-icon-button
class="copy-button"
.path=${mdiContentCopy}
@click=${this._handleCopyClick}
data-payload=${msg.payload}
></ha-icon-button>
<ha-markdown
.content=${`\`\`\`${this._json_format ? "json" : ""}\n${msg.payload}\n\`\`\``}
></ha-markdown>
</div>
<pre>${msg.payload}</pre>
<div class="bottom">
QoS: ${msg.message.qos} - Retain:
${Boolean(msg.message.retain)}
@@ -174,16 +156,6 @@ class MqttSubscribeCard extends LitElement {
this._json_format = (ev.target! as any).checked;
}
private async _handleCopyClick(ev: Event): Promise<void> {
const payload = (ev.target as HTMLElement).getAttribute("data-payload");
if (payload) {
await copyToClipboard(payload);
showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"),
});
}
}
private async _handleSubmit(): Promise<void> {
if (this._subscribed) {
this._subscribed();
@@ -227,12 +199,6 @@ class MqttSubscribeCard extends LitElement {
padding: var(--ha-space-4);
padding-bottom: var(--ha-space-8);
}
.content_subscribe_panel {
margin-top: var(--ha-space-6);
max-width: 600px;
margin: 0 auto;
direction: ltr;
}
.events {
margin: -16px 0;
padding: 0 16px;
@@ -264,20 +230,6 @@ class MqttSubscribeCard extends LitElement {
ha-input {
flex: 1;
}
.code-block {
position: relative;
margin-bottom: 16px;
}
.code-block ha-markdown {
padding-right: 40px;
}
.copy-button {
position: absolute;
top: 8px;
right: 8px;
z-index: 1;
color: var(--secondary-text-color);
}
@media screen and (max-width: 600px) {
ha-select {
display: block;
@@ -2,7 +2,6 @@ import type {
CallbackDataParams,
TopLevelFormatterParams,
} from "echarts/types/dist/shared";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
@@ -20,13 +19,11 @@ import "../../../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../../../components/input/ha-input-search";
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
import type {
RssiError,
ZWaveJSNodeStatisticsUpdatedMessage,
ZWaveJSNodeStatus,
} from "../../../../../data/zwave_js";
import {
fetchZwaveNetworkStatus,
getNodeIdFromDevice,
NodeStatus,
subscribeZwaveNodeStatistics,
} from "../../../../../data/zwave_js";
@@ -57,36 +54,19 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
@state() private _searchFilter = "";
// Route statistics reference repeaters by device registry ID
private _nodeIdsByDeviceId: Record<string, number> = {};
public hassSubscribe() {
const subscriptions: Promise<UnsubscribeFunc>[] = [];
const devices: Record<number, DeviceRegistryEntry> = {};
const nodeIdsByDeviceId: Record<string, number> = {};
const devices = Object.values(this.hass.devices).filter((device) =>
device.config_entries.some((entry) => entry === this.configEntryId)
);
Object.values(this.hass.devices).forEach((device) => {
if (!device.config_entries.includes(this.configEntryId)) {
return;
}
const nodeId = getNodeIdFromDevice(device);
if (nodeId === undefined) {
return;
}
devices[nodeId] = device;
nodeIdsByDeviceId[device.id] = nodeId;
subscriptions.push(
subscribeZwaveNodeStatistics(this.hass!, device.id, (message) => {
this._nodeStatistics[nodeId] = message;
this._handleUpdatedNodeStatistics();
})
);
});
this._nodeIdsByDeviceId = nodeIdsByDeviceId;
this._devices = devices;
return subscriptions;
return devices.map((device) =>
subscribeZwaveNodeStatistics(this.hass!, device.id, (message) => {
const nodeId = message.nodeId ?? message.node_id;
this._devices[nodeId!] = device;
this._nodeStatistics[nodeId!] = message;
this._handleUpdatedNodeStatistics();
})
);
}
public connectedCallback() {
@@ -184,10 +164,8 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
sourceDevice?.name_by_user ?? sourceDevice?.name ?? source;
const targetName =
targetDevice?.name_by_user ?? targetDevice?.name ?? target;
// links point away from the controller, so the route belongs to the target
const stats =
this._nodeStatistics[target] ?? this._nodeStatistics[source];
const route = stats?.lwr || stats?.nlwr;
const route =
this._nodeStatistics[source]?.lwr || this._nodeStatistics[source]?.nlwr;
return html`${sourceName}
${targetName}${
route?.protocol_data_rate
@@ -355,69 +333,55 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
});
});
if (controllerNode === undefined) {
return { nodes, links, categories };
}
const controllerId = String(controllerNode);
Object.entries(nodeStatistics).forEach(([nodeId, stats]) => {
const route = stats.lwr || stats.nlwr;
if (!route) {
return;
if (route) {
const hops = [
...route.repeaters.map((id, i) => [
Object.keys(this._devices).find(
(_nodeId) => this._devices[_nodeId]?.id === id
)?.[0],
route.repeater_rssi[i],
]),
[controllerNode!, route.rssi],
];
let sourceNode: string = nodeId;
hops.forEach(([repeater, rssi]) => {
const RSSI = typeof rssi === "number" && rssi <= 0 ? rssi : -100;
const existingLink = links.find(
(link) =>
link.source === sourceNode && link.target === String(repeater)
);
const width = this._getLineWidth(RSSI);
if (existingLink) {
existingLink.value = Math.max(existingLink.value!, RSSI);
existingLink.lineStyle = {
...existingLink.lineStyle,
width: Math.max(existingLink.lineStyle!.width!, width),
type:
route.protocol_data_rate > 1
? "solid"
: existingLink.lineStyle!.type,
};
} else {
links.push({
source: sourceNode,
target: String(repeater),
value: RSSI,
lineStyle: {
width,
color:
repeater === controllerNode
? style.getPropertyValue("--primary-color")
: style.getPropertyValue("--disabled-color"),
type: route.protocol_data_rate > 1 ? "solid" : "dotted",
},
symbolSize: width * 3,
});
}
sourceNode = String(repeater);
});
}
// Routes go from the controller to the node via the repeaters, in order.
// Each station measures the hop leaving it: the controller reports
// `rssi`, repeater i reports `repeater_rssi[i]`.
const hops: [string, RssiError | number | null][] = [];
let hopRssi = route.rssi;
route.repeaters.forEach((deviceId, i) => {
const repeaterNodeId = this._nodeIdsByDeviceId[deviceId];
// skip repeaters we can't resolve, so the chain stays connected
if (repeaterNodeId !== undefined) {
hops.push([String(repeaterNodeId), hopRssi]);
}
hopRssi = route.repeater_rssi[i];
});
hops.push([nodeId, hopRssi]);
let sourceNode = controllerId;
hops.forEach(([target, rssi]) => {
if (target === sourceNode) {
return;
}
const RSSI = typeof rssi === "number" && rssi <= 0 ? rssi : -100;
const existingLink = links.find(
(link) => link.source === sourceNode && link.target === target
);
const width = this._getLineWidth(RSSI);
if (existingLink) {
existingLink.value = Math.max(existingLink.value!, RSSI);
existingLink.lineStyle = {
...existingLink.lineStyle,
width: Math.max(existingLink.lineStyle!.width!, width),
type:
route.protocol_data_rate > 1
? "solid"
: existingLink.lineStyle!.type,
};
} else {
links.push({
source: sourceNode,
target,
value: RSSI,
lineStyle: {
width,
color:
sourceNode === controllerId
? style.getPropertyValue("--primary-color")
: style.getPropertyValue("--disabled-color"),
type: route.protocol_data_rate > 1 ? "solid" : "dotted",
},
symbolSize: width * 3,
});
}
sourceNode = target;
});
});
return { nodes, links, categories };
@@ -1,4 +1,4 @@
import { mdiAccount, mdiAccountPlus, mdiPencil } from "@mdi/js";
import { mdiPencil } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -19,7 +19,6 @@ import type { PersonMutableParams } from "../../../data/person";
import type { User } from "../../../data/user";
import {
deleteUser,
fetchUsers,
SYSTEM_GROUP_ID_ADMIN,
SYSTEM_GROUP_ID_USER,
updateUser,
@@ -37,8 +36,6 @@ import { documentationUrl } from "../../../util/documentation-url";
import { showAddUserDialog } from "../users/show-dialog-add-user";
import { showAdminChangePasswordDialog } from "../users/show-dialog-admin-change-password";
import type { PersonDetailDialogParams } from "./show-dialog-person-detail";
import { showListItemsDialog } from "../../../dialogs/dialog-list-items/show-list-items-dialog";
import { computeDomain } from "../../../common/entity/compute_domain";
const includeDomains = ["device_tracker"];
@@ -88,8 +85,6 @@ class DialogPersonDetail
@state() private _open = false;
private _linkedExistingUser = false;
private _deviceTrackersAvailable = memoizeOne((hass) =>
Object.keys(hass.states).some(
(entityId) =>
@@ -145,7 +140,7 @@ class DialogPersonDetail
private _dialogClosed() {
// If we do not have a person ID yet (= person creation dialog was just cancelled), but
// we already created a user ID for it, delete it now to not have it "free floating".
if (!this._personExists && this._userId && !this._linkedExistingUser) {
if (!this._personExists && this._userId) {
const callback = this._params?.refreshUsers;
deleteUser(this.hass, this._userId).then(() => {
callback?.();
@@ -431,71 +426,26 @@ class DialogPersonDetail
this._updateDirtyState(this._currentState());
}
private async _linkUser(user: User, newUser: boolean) {
this._linkedExistingUser = !newUser;
if (this._params!.entry && this._params!.updateEntry) {
await this._params!.updateEntry({ user_id: user.id });
}
if (newUser) {
this._params?.refreshUsers?.();
}
this._user = user;
this._userId = user.id;
this._isAdmin = user.group_ids.includes(SYSTEM_GROUP_ID_ADMIN);
this._localOnly = user.local_only;
this._updateDirtyState(this._currentState());
}
private async _allowLoginChanged(ev): Promise<void> {
const target = ev.target;
if (target.checked) {
target.checked = false;
const users = await fetchUsers(this.hass);
const currentLinkedUsers = new Set(
Object.values(this.hass.states)
.filter(
(s) =>
computeDomain(s.entity_id) === "person" && s.attributes.user_id
)
.map((s) => s.attributes.user_id)
);
const eligibleUsers = users.filter(
(u) =>
!currentLinkedUsers.has(u.id) && !u.system_generated && u.username
);
const addUserDialog = () =>
showAddUserDialog(this, {
userAddedCallback: async (user?: User) => {
if (user) {
target.checked = true;
this._linkUser(user, true);
showAddUserDialog(this, {
userAddedCallback: async (user?: User) => {
if (user) {
target.checked = true;
if (this._params!.entry && this._params!.updateEntry) {
await this._params!.updateEntry({ user_id: user.id });
}
},
name: this._name,
});
if (eligibleUsers.length === 0) {
addUserDialog();
return;
}
showListItemsDialog(this, {
title: this.hass.localize("ui.panel.config.person.detail.select_user"),
items: [
{
iconPath: mdiAccountPlus,
label: this.hass.localize(
"ui.panel.config.person.detail.create_new_user"
),
action: addUserDialog,
},
...eligibleUsers.map((user) => ({
iconPath: mdiAccount,
label: `${user.name} (${user.username})`,
action: () => this._linkUser(user, false),
})),
],
this._params?.refreshUsers?.();
this._user = user;
this._userId = user.id;
this._isAdmin = user.group_ids.includes(SYSTEM_GROUP_ID_ADMIN);
this._localOnly = user.local_only;
this._updateDirtyState(this._currentState());
}
},
name: this._name,
});
} else if (this._userId) {
if (
@@ -1,8 +1,8 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { HomeAssistant } from "../../types";
import { domainToName } from "../../data/integration";
import type { RepairsIssue } from "../../data/repairs";
import type { HomeAssistant } from "../../../types";
import { domainToName } from "../../../data/integration";
import type { RepairsIssue } from "../../../data/repairs";
@customElement("dialog-repairs-issue-subtitle")
class DialogRepairsIssueSubtitle extends LitElement {
@@ -13,7 +13,7 @@ import "../../../components/ha-dialog";
import "../../../components/ha-button";
import "../../../components/ha-svg-icon";
import "../../../components/ha-dialog-footer";
import "../../../dialogs/repairs-flow/dialog-repairs-issue-subtitle";
import "./dialog-repairs-issue-subtitle";
import "../../../components/ha-markdown";
import type { RepairsIssue } from "../../../data/repairs";
import { ignoreRepairsIssue } from "../../../data/repairs";
@@ -20,7 +20,7 @@ import type { HomeAssistant } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
import { fixStatisticsIssue } from "../tools/statistics/fix-statistics";
import { showVacuumSegmentMappingDialog } from "../entities/dialogs/show-dialog-vacuum-segment-mapping";
import { showRepairsFlowDialog } from "../../../dialogs/repairs-flow/show-dialog-repair-flow";
import { showRepairsFlowDialog } from "./show-dialog-repair-flow";
import { showRepairsIssueDialog } from "./show-repair-issue-dialog";
@customElement("ha-config-repairs")
@@ -1,19 +1,18 @@
import { html, nothing } from "lit";
import type { DataEntryFlowStep } from "../../data/data_entry_flow";
import { domainToName } from "../../data/integration";
import type { RepairsIssue } from "../../data/repairs";
import type { DataEntryFlowStep } from "../../../data/data_entry_flow";
import { domainToName } from "../../../data/integration";
import type { RepairsIssue } from "../../../data/repairs";
import {
createRepairsFlow,
deleteRepairsFlow,
fetchRepairsFlow,
handleRepairsFlowStep,
} from "../../data/repairs";
import type { DataEntryFlowDialogParams } from "../config-flow/show-dialog-data-entry-flow";
} from "../../../data/repairs";
import {
loadDataEntryFlowDialog,
showFlowDialog,
} from "../config-flow/show-dialog-data-entry-flow";
import type { HomeAssistant } from "../../types";
} from "../../../dialogs/config-flow/show-dialog-data-entry-flow";
import type { HomeAssistant } from "../../../types";
import "./dialog-repairs-issue-subtitle";
const mergePlaceholders = (issue: RepairsIssue, step: DataEntryFlowStep) =>
@@ -37,14 +36,14 @@ export const loadRepairFlowDialog = loadDataEntryFlowDialog;
export const showRepairsFlowDialog = (
element: HTMLElement,
issue: RepairsIssue,
dialogParams?: Omit<DataEntryFlowDialogParams, "flowConfig">
dialogClosedCallback?: (params: { flowFinished: boolean }) => void
): void =>
showFlowDialog(
element,
{
startFlowHandler: issue.domain,
domain: issue.domain,
...dialogParams,
dialogClosedCallback,
},
{
flowType: "repair_flow",
@@ -204,28 +203,9 @@ export const showRepairsFlowDialog = (
return "";
},
renderCreateEntryDescription(hass, step) {
const description = hass.localize(
`component.${issue.domain}.issues.${
issue.translation_key || issue.issue_id
}.fix_flow.create_entry.${step.description || "default"}`,
step.description_placeholders
);
renderCreateEntryDescription(hass, _step) {
return html`
${
description
? html`
<ha-markdown
allow-svg
breaks
.content=${description}
></ha-markdown>
`
: html`<p>
${hass.localize("ui.dialogs.repair_flow.success.description")}
</p>`
}
<p>${hass.localize("ui.dialogs.repair_flow.success.description")}</p>
`;
},
@@ -311,17 +291,11 @@ export const showRepairsFlowDialog = (
},
renderMenuOption(hass, step, option) {
return (
hass.localize(
`component.${issue.domain}.issues.${
issue.translation_key || issue.issue_id
}.fix_flow.step.${step.step_id}.menu_options.${option}`,
mergePlaceholders(issue, step)
) ||
// Newer backends can offer options this frontend has no
// translation for yet — show the raw option key instead of
// an empty menu entry
option
return hass.localize(
`component.${issue.domain}.issues.${
issue.translation_key || issue.issue_id
}.fix_flow.step.${step.step_id}.menu_options.${option}`,
mergePlaceholders(issue, step)
);
},
+203 -44
View File
@@ -14,28 +14,92 @@ import type { SchemaUnion } from "../../../components/ha-form/types";
import "../../../components/ha-icon-button";
import "../../../components/ha-dialog";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import type { SupervisorMountRequestParams } from "../../../data/supervisor/mounts";
import type {
SupervisorMountCandidate,
SupervisorMountRequestParams,
} from "../../../data/supervisor/mounts";
import {
createSupervisorMount,
fetchSupervisorMountCandidates,
removeSupervisorMount,
SupervisorMountType,
SupervisorMountUsage,
updateSupervisorMount,
} from "../../../data/supervisor/mounts";
import { bytesToString } from "../../../util/bytes-to-string";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import type { MountViewDialogParams } from "./show-dialog-view-mount";
// Describes a device by the drive it belongs to, falling back to what UDisks2
// did report: an unattributed device has no drive, and an unformatted-label
// partition has no label.
const mountCandidateLabel = (candidate: SupervisorMountCandidate): string => {
const drive = [candidate.drive?.vendor, candidate.drive?.model]
.filter(Boolean)
.join(" ");
const identity = candidate.label || candidate.device;
const size = bytesToString(candidate.size);
return drive ? `${drive}${identity}, ${size}` : `${identity}, ${size}`;
};
const mountSchema = memoizeOne(
(
localize: LocalizeFunc,
existing?: boolean,
mountType?: SupervisorMountType,
showCIFSVersion?: boolean
) =>
[
showCIFSVersion?: boolean,
showDisk?: boolean,
candidates?: SupervisorMountCandidate[],
diskIdentity?: string,
readOnlyForced?: boolean,
allowBackupUsage = true
) => {
// Supervisor rejects a read-only mount used for backups, so a device that
// can only be mounted read-only is not offered for one.
const usageOptions: [string, string][] = allowBackupUsage
? [
[
SupervisorMountUsage.BACKUP,
localize(
"ui.panel.config.storage.network_mounts.mount_usage.backup"
),
],
]
: [];
usageOptions.push(
[
SupervisorMountUsage.MEDIA,
localize("ui.panel.config.storage.network_mounts.mount_usage.media"),
],
[
SupervisorMountUsage.SHARE,
localize("ui.panel.config.storage.network_mounts.mount_usage.share"),
]
);
const typeOptions: [string, string][] = [
[
SupervisorMountType.CIFS,
localize("ui.panel.config.storage.network_mounts.mount_type.cifs"),
],
[
SupervisorMountType.NFS,
localize("ui.panel.config.storage.network_mounts.mount_type.nfs"),
],
];
// Hidden on a Supervisor that does not support disk mounts, but always
// offered when editing one that already exists.
if (showDisk || mountType === SupervisorMountType.DISK) {
typeOptions.push([
SupervisorMountType.DISK,
localize("ui.panel.config.storage.network_mounts.mount_type.disk"),
]);
}
return [
{
name: "name",
required: true,
@@ -46,57 +110,34 @@ const mountSchema = memoizeOne(
name: "usage",
required: true,
type: "select",
options: [
[
SupervisorMountUsage.BACKUP,
localize(
"ui.panel.config.storage.network_mounts.mount_usage.backup"
),
],
[
SupervisorMountUsage.MEDIA,
localize(
"ui.panel.config.storage.network_mounts.mount_usage.media"
),
],
[
SupervisorMountUsage.SHARE,
localize(
"ui.panel.config.storage.network_mounts.mount_usage.share"
),
],
] as const,
},
{
name: "server",
required: true,
selector: { text: {} },
options: usageOptions,
},
{
name: "type",
required: true,
type: "select",
options: [
[
SupervisorMountType.CIFS,
localize("ui.panel.config.storage.network_mounts.mount_type.cifs"),
],
[
SupervisorMountType.NFS,
localize("ui.panel.config.storage.network_mounts.mount_type.nfs"),
],
],
options: typeOptions,
},
...(mountType === "nfs"
...(mountType === SupervisorMountType.NFS
? ([
{
name: "server",
required: true,
selector: { text: {} },
},
{
name: "path",
required: true,
selector: { text: {} },
},
] as const)
: mountType === "cifs"
: mountType === SupervisorMountType.CIFS
? ([
{
name: "server",
required: true,
selector: { text: {} },
},
...(showCIFSVersion
? ([
{
@@ -148,8 +189,44 @@ const mountSchema = memoizeOne(
selector: { text: { type: "password" } },
},
] as const)
: ([] as const)),
] as const
: mountType === SupervisorMountType.DISK
? existing
? // Supervisor excludes a mounted device from the candidates, so
// an existing mount can only show what it resolved to.
([
{
name: "device_identity",
type: "constant",
value: diskIdentity,
},
{
name: "read_only",
selector: { boolean: {} },
},
] as const)
: ([
{
name: "device",
required: true,
selector: {
select: {
options: (candidates ?? []).map((candidate) => ({
value: candidate.device,
label: mountCandidateLabel(candidate),
})),
mode: "dropdown",
},
},
},
{
name: "read_only",
disabled: readOnlyForced,
selector: { boolean: {} },
},
] as const)
: ([] as const)),
] as const;
}
);
@customElement("dialog-mount-view")
@@ -172,6 +249,12 @@ class ViewMountDialog extends DirtyStateProviderMixin<
@state() private _showCIFSVersion?: boolean;
@state() private _candidates?: SupervisorMountCandidate[];
@state() private _diskSupported = false;
@state() private _diskIdentity?: string;
@state() private _reloadMounts?: () => void;
@state() private _open = false;
@@ -190,13 +273,36 @@ class ViewMountDialog extends DirtyStateProviderMixin<
) {
this._showCIFSVersion = true;
}
if (dialogParams.mount?.type === SupervisorMountType.DISK) {
this._diskIdentity = [
dialogParams.mount.filesystem,
dialogParams.mount.uuid,
]
.filter(Boolean)
.join(" • ");
}
this._initDirtyTracking({ type: "deep" }, this._data ?? {});
this._loadCandidates();
}
public closeDialog(): void {
this._open = false;
}
private async _loadCandidates(): Promise<void> {
try {
const { candidates } = await fetchSupervisorMountCandidates(this.hass);
this._candidates = candidates;
this._diskSupported = true;
} catch (_err: any) {
// A Supervisor predating disk mounts answers 404. Any other failure
// leaves us unable to offer a device either, so in both cases the option
// is hidden rather than shown as broken.
this._candidates = [];
this._diskSupported = false;
}
}
private _dialogClosed(): void {
this._data = undefined;
this._waiting = undefined;
@@ -205,6 +311,9 @@ class ViewMountDialog extends DirtyStateProviderMixin<
this._validationWarning = undefined;
this._existing = undefined;
this._showCIFSVersion = undefined;
this._candidates = undefined;
this._diskSupported = false;
this._diskIdentity = undefined;
this._reloadMounts = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -249,6 +358,15 @@ class ViewMountDialog extends DirtyStateProviderMixin<
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing
}
${
this._showNoCandidates
? html`<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.config.storage.network_mounts.no_disk_candidates"
)}
</ha-alert>`
: nothing
}
<ha-form
autofocus
.data=${this._data}
@@ -256,7 +374,12 @@ class ViewMountDialog extends DirtyStateProviderMixin<
this.hass.localize,
this._existing,
this._data?.type,
this._showCIFSVersion
this._showCIFSVersion,
this._diskSupported,
this._candidates,
this._diskIdentity,
this._readOnlyForced,
this._allowBackupUsage
)}
.error=${this._validationError}
.warning=${this._validationWarning}
@@ -308,6 +431,33 @@ class ViewMountDialog extends DirtyStateProviderMixin<
`;
}
// The device the mount already uses is excluded from candidates, so an empty
// list is only worth mentioning while creating one.
private get _showNoCandidates(): boolean {
return (
!this._existing &&
this._data?.type === SupervisorMountType.DISK &&
this._candidates?.length === 0
);
}
private get _readOnlyForced(): boolean {
if (this._existing || this._data?.type !== SupervisorMountType.DISK) {
return false;
}
const { device } = this._data;
return !!this._candidates?.find((candidate) => candidate.device === device)
?.read_only;
}
// Backup usage is impossible for a read-only mount, whether the device forced
// that or the user chose it.
private get _allowBackupUsage(): boolean {
return !(
this._data?.type === SupervisorMountType.DISK && this._data.read_only
);
}
private _computeLabelCallback = (
// @ts-ignore
schema: SchemaUnion<ReturnType<typeof mountSchema>>
@@ -353,6 +503,15 @@ class ViewMountDialog extends DirtyStateProviderMixin<
) {
this._validationWarning.version = "not_recomeded_cifs_version";
}
// A device the host reports as read-only cannot be mounted writable.
if (this._readOnlyForced) {
this._data!.read_only = true;
}
// Picking such a device while backup was selected leaves a combination
// Supervisor refuses, so drop the usage and make the user choose again.
if (!this._allowBackupUsage && this._data?.usage === "backup") {
delete (this._data as Partial<SupervisorMountRequestParams>).usage;
}
this._updateDirtyState(this._data ?? {});
}
@@ -9,12 +9,10 @@ import {
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { navigate } from "../../../common/navigate";
import { blankBeforePercent } from "../../../common/translations/blank_before_percent";
import "../../../components/ha-alert";
import "../../../components/ha-bar";
import "../../../components/ha-button";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-next";
@@ -22,7 +20,6 @@ import "../../../components/ha-list";
import "../../../components/ha-list-item";
import "../../../components/ha-segmented-bar";
import type { Segment } from "../../../components/ha-segmented-bar";
import "../../../components/ha-spinner";
import "../../../components/ha-svg-icon";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import type { HassioHostInfo, HostDisksUsage } from "../../../data/hassio/host";
@@ -40,11 +37,11 @@ import {
SupervisorMountUsage,
fetchSupervisorMounts,
reloadSupervisorMount,
supervisorMountDescription,
} from "../../../data/supervisor/mounts";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
import type { HomeAssistant, Route } from "../../../types";
import { bytesToString } from "../../../util/bytes-to-string";
import "../core/ha-config-analytics";
import { showMoveDatadiskDialog } from "./show-dialog-move-datadisk";
import { showMountViewDialog } from "./show-dialog-view-mount";
@@ -66,13 +63,6 @@ class HaConfigSectionStorage extends LitElement {
@state() private _mountsInfo?: SupervisorMounts | null;
// Keyed by mount name. A missing key means the request is still in flight;
// null means it failed, and that row simply shows no usage.
@state() private _mountUsage: Record<string, HostDisksUsage | null> = {};
// Guards against a slow response from a previous reload landing in a newer one.
private _mountUsageGeneration = 0;
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
if (isComponentLoaded(this.hass.config, "hassio")) {
@@ -85,7 +75,11 @@ class HaConfigSectionStorage extends LitElement {
return nothing;
}
const validMounts = this._mountsInfo?.mounts.filter((mount) =>
[SupervisorMountType.CIFS, SupervisorMountType.NFS].includes(mount.type)
[
SupervisorMountType.CIFS,
SupervisorMountType.DISK,
SupervisorMountType.NFS,
].includes(mount.type)
);
const isHAOS = this._hostInfo?.features.includes("haos");
return html`
@@ -184,7 +178,6 @@ class HaConfigSectionStorage extends LitElement {
graphic="avatar"
.mount=${mount}
twoline
multiline-secondary
hasMeta
@click=${this._changeMount}
>
@@ -205,16 +198,7 @@ class HaConfigSectionStorage extends LitElement {
${mount.name}
</span>
<span slot="secondary">
<span class="mount-address">
${mount.server}${
mount.port ? `:${mount.port}` : ""
}${
mount.type === SupervisorMountType.NFS
? mount.path
: `:${mount.share}`
}
</span>
${this._renderMountUsage(mount)}
${supervisorMountDescription(mount)}
</span>
${
mount.state !== SupervisorMountState.ACTIVE
@@ -304,39 +288,6 @@ class HaConfigSectionStorage extends LitElement {
`;
}
private _renderMountUsage(mount: SupervisorMount) {
if (mount.state !== SupervisorMountState.ACTIVE) {
return nothing;
}
if (!(mount.name in this._mountUsage)) {
return html`<div class="mount-usage">
<ha-spinner size="tiny"></ha-spinner>
</div>`;
}
const usage = this._mountUsage[mount.name];
// Without a total there is no ratio to show, so show nothing rather than a
// bar that means something else.
if (!usage?.total_bytes) {
return nothing;
}
const percent = (usage.used_bytes / usage.total_bytes) * 100;
return html`<div class="mount-usage">
<ha-bar
class=${classMap({
"target-warning": percent > 85,
"target-critical": percent > 95,
})}
.value=${percent}
></ha-bar>
<span>
${this.hass.localize("ui.panel.config.storage.detailed_description", {
used: bytesToString(usage.used_bytes),
total: bytesToString(usage.total_bytes),
})}
</span>
</div>`;
}
private async _load() {
this._loadStorageInfo();
try {
@@ -353,7 +304,7 @@ class HaConfigSectionStorage extends LitElement {
private async _loadStorageInfo() {
try {
this._storageInfo = await fetchHostDisksUsage(this.hass, "default", 3);
this._storageInfo = await fetchHostDisksUsage(this.hass);
} catch (err: any) {
this._error = err.message || err;
this._storageInfo = null;
@@ -409,34 +360,6 @@ class HaConfigSectionStorage extends LitElement {
this._error = err.message || err;
this._mountsInfo = null;
}
this._loadMountUsage();
}
// Deliberately not awaited: a mount on a slow or unreachable server can take
// ~30 s to answer, and the rows must paint before then. Only active mounts are
// asked, since the endpoint has nothing to report for the others.
private _loadMountUsage(): void {
const generation = ++this._mountUsageGeneration;
this._mountUsage = {};
this._mountsInfo?.mounts
.filter((mount) => mount.state === SupervisorMountState.ACTIVE)
.forEach((mount) => {
fetchHostDisksUsage(this.hass, mount.name).then(
(usage) => this._setMountUsage(generation, mount.name, usage),
() => this._setMountUsage(generation, mount.name, null)
);
});
}
private _setMountUsage(
generation: number,
name: string,
usage: HostDisksUsage | null
): void {
if (generation !== this._mountUsageGeneration) {
return;
}
this._mountUsage = { ...this._mountUsage, [name]: usage };
}
static styles = css`
@@ -485,43 +408,6 @@ class HaConfigSectionStorage extends LitElement {
color: var(--warning-color);
}
/* multiline-secondary lets the secondary slot wrap, so the address keeps its
own single ellipsized line and only the usage sits below it. */
.mount-address {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mount-usage {
display: flex;
align-items: center;
gap: var(--ha-space-2);
margin-top: var(--ha-space-1);
}
.mount-usage ha-bar {
flex: 0 0 72px;
display: flex;
align-self: center;
--ha-bar-primary-color: var(--metric-bar-ok-color, var(--success-color));
}
.mount-usage ha-bar.target-warning {
--ha-bar-primary-color: var(
--metric-bar-warning-color,
var(--warning-color)
);
}
.mount-usage ha-bar.target-critical {
--ha-bar-primary-color: var(
--metric-bar-critical-color,
var(--error-color)
);
}
.mounts-not-supported {
padding: 0 16px 16px;
}
@@ -20,10 +20,9 @@ type ReloadableDomain = Exclude<
"heading" | "introduction" | "reload"
>;
interface TranslatedReloadableItem {
domain: ReloadableDomain | "frontend";
interface TranslatedReloadableDomain {
domain: ReloadableDomain;
name: string;
service?: string;
}
@customElement("tools-yaml-config")
@@ -38,7 +37,7 @@ export class ToolsYamlConfig extends LitElement {
@state() private _validating = false;
@state() private _reloadableItems: TranslatedReloadableItem[] = [];
@state() private _reloadableDomains: TranslatedReloadableDomain[] = [];
@state() private _validateResult?: CheckConfigResult;
@@ -55,10 +54,10 @@ export class ToolsYamlConfig extends LitElement {
oldHass.config.components !== this.hass.config.components ||
oldHass.localize !== this.hass.localize)
) {
this._reloadableItems = (
this._reloadableDomains = (
componentsWithService(this.hass, "reload") as ReloadableDomain[]
)
.map<TranslatedReloadableItem>((domain) => ({
.map((domain) => ({
domain,
name:
this.hass.localize(
@@ -69,15 +68,6 @@ export class ToolsYamlConfig extends LitElement {
{ domain: domainToName(this.hass.localize, domain) }
),
}))
.concat([
{
domain: "frontend",
service: "reload_themes",
name: this.hass.localize(
`ui.panel.config.tools.tabs.yaml.section.reloading.themes`
),
},
])
.sort((a, b) =>
stringCompare(a.name, b.name, this.hass.locale.language)
);
@@ -200,12 +190,12 @@ export class ToolsYamlConfig extends LitElement {
)}
</ha-call-service-button>
</div>
${this._reloadableItems.map(
${this._reloadableDomains.map(
(reloadable) => html`
<div class="card-actions">
<ha-call-service-button
.domain=${reloadable.domain}
.service=${reloadable.service || "reload"}
service="reload"
>${reloadable.name}
</ha-call-service-button>
</div>
+108 -292
View File
@@ -1,9 +1,8 @@
import {
mdiChartBoxOutline,
mdiDotsVertical,
mdiDownload,
mdiFilterRemove,
mdiImagePlus,
mdiTuneVariant,
} from "@mdi/js";
import { differenceInHours } from "date-fns";
import type {
@@ -11,21 +10,17 @@ import type {
UnsubscribeFunc,
} from "home-assistant-js-websocket/dist/types";
import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { storage } from "../../common/decorators/storage";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { computeDomain } from "../../common/entity/compute_domain";
import { navigate } from "../../common/navigate";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
import { shallowEqual } from "../../common/util/shallow-equal";
import {
createHistoryLogbookUrl,
decodeHistoryLogbookQueryParams,
historyLogbookTargetFromQueryParams,
historyLogbookTargetsEqual,
} from "../../common/url/history-logbook-query-params";
import {
extractSearchParamsObject,
@@ -34,25 +29,14 @@ import {
import { MIN_TIME_BETWEEN_UPDATES } from "../../components/chart/ha-chart-base";
import "../../components/chart/state-history-charts";
import type { StateHistoryCharts } from "../../components/chart/state-history-charts";
import "../../components/date-picker/ha-date-range-nav";
import "../../components/ha-button";
import "../../components/date-picker/ha-date-range-picker";
import "../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../components/ha-dropdown";
import "../../components/ha-dropdown-item";
import "../../components/ha-empty-state";
import "../../components/ha-filter-pane-chip";
import "../../components/ha-filter-pane";
import "../../components/ha-icon-button";
import {
applySourceFilters,
countSourceFilters,
countTargets,
} from "../../components/ha-sources-picker";
import type { SourceFilters } from "../../components/ha-sources-picker";
import "../../components/ha-spinner";
import "../../components/ha-target-picker";
import "../../components/ha-top-app-bar-fixed";
import type { EntitySources } from "../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../data/entity/entity_sources";
import type { HistoryResult } from "../../data/history";
import {
computeHistory,
@@ -68,8 +52,6 @@ import type { HomeAssistant } from "../../types";
import { addEntitiesToLovelaceView } from "../lovelace/editor/add-entities-to-view";
import { csvSafeString, csvDownload } from "../../util/csv";
const EMPTY_STATES: HomeAssistant["states"] = {};
@customElement("ha-panel-history")
class HaPanelHistory extends LitElement {
@property({ attribute: false }) hass!: HomeAssistant;
@@ -96,19 +78,6 @@ class HaPanelHistory extends LitElement {
@state() private _isLoading = false;
@state() private _filters: SourceFilters = {};
@storage({
key: "historySourceFilters",
state: false,
subscribe: false,
})
private _storedFilters?: SourceFilters;
@state() private _showSources?: boolean;
@state() private _entitySources?: EntitySources;
@state() private _stateHistory?: HistoryResult;
private _mungedStateHistory?: HistoryResult;
@@ -123,10 +92,6 @@ class HaPanelHistory extends LitElement {
private _subscribed?: Promise<UnsubscribeFunc | undefined>;
private _fetchedEntityIds?: string[];
private _statsFetchId = 0;
private _interval?: number;
public constructor() {
@@ -154,24 +119,7 @@ class HaPanelHistory extends LitElement {
}
protected render() {
const entityIds = this._getEntityIds();
const targetCount = countTargets(this._targetPickerValue);
const filterCount = countSourceFilters(this._filters);
const sourceCount = targetCount + filterCount;
const hasTargets = targetCount > 0;
// A target whose entities are all filtered out fetches nothing.
const loading =
this._isLoading || (entityIds.length > 0 && !this._mungedStateHistory);
const hasResults =
!!this._mungedStateHistory &&
(this._mungedStateHistory.line.length > 0 ||
this._mungedStateHistory.timeline.length > 0);
const sourcesLabel = sourceCount
? this.hass.localize("ui.panel.history.sources_count", {
count: entityIds.length,
})
: this.hass.localize("ui.panel.history.sources");
const entitiesSelected = this._getEntityIds().length > 0;
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
@@ -180,6 +128,13 @@ class HaPanelHistory extends LitElement {
<h1 class="page-title" slot="title">
${this.hass.localize("panel.history")}
</h1>
<ha-icon-button
slot="actionItems"
@click=${this._removeAll}
.disabled=${this._isLoading || !entitiesSelected}
.path=${mdiFilterRemove}
.label=${this.hass.localize("ui.panel.history.remove_all")}
></ha-icon-button>
<ha-dropdown slot="actionItems" @wa-select=${this._handleMenuAction}>
<ha-icon-button
slot="trigger"
@@ -198,112 +153,51 @@ class HaPanelHistory extends LitElement {
</ha-dropdown-item>
</ha-dropdown>
<div class="content">
<div class="main">
${
this._sourcesShown()
? html`<ha-filter-pane
.narrow=${this.narrow}
.label=${sourcesLabel}
.path=${mdiTuneVariant}
.count=${sourceCount}
.resultCount=${hasTargets ? entityIds.length : undefined}
.disabled=${this._isLoading}
@close-filter-pane=${this._closeSources}
@clear-filter=${this._clearSources}
>
<ha-sources-picker
.hass=${this.hass}
.value=${this._targetPickerValue}
.filters=${this._filters}
.disabled=${this._isLoading}
.description=${this.hass.localize(
"ui.panel.history.no_targets"
)}
@value-changed=${this._targetsChanged}
@source-filters-changed=${this._filtersChanged}
></ha-sources-picker>
</ha-filter-pane>`
: nothing
}
<div class="content-column">
<div class="toolbar">
${
this._sourcesShown() && !this.narrow
? nothing
: html`<ha-filter-pane-chip
.label=${sourcesLabel}
.path=${mdiTuneVariant}
.count=${filterCount}
.active=${sourceCount > 0}
.disabled=${this._isLoading}
@click=${this._toggleSources}
></ha-filter-pane-chip>`
}
<ha-date-range-nav
.disabled=${this._isLoading}
.startDate=${this._startDate}
.endDate=${this._endDate}
extended-presets
time-picker
@value-changed=${this._dateRangeChanged}
></ha-date-range-nav>
</div>
<div class="results ha-scrollbar">
${
loading
? html`<div class="progress-wrapper">
<ha-spinner></ha-spinner>
</div>`
: !hasTargets || !hasResults
? this._renderEmptyState(hasTargets)
: html`
<state-history-charts
.hass=${this.hass}
.historyData=${this._mungedStateHistory}
.startTime=${this._startDate}
.endTime=${this._endDate}
.narrow=${this.narrow}
inside-labels
sync-charts
>
</state-history-charts>
`
}
</div>
</div>
<div class="flex content ha-scrollbar">
<div class="filters">
<ha-date-range-picker
?disabled=${this._isLoading}
.startDate=${this._startDate}
.endDate=${this._endDate}
extended-presets
time-picker
@value-changed=${this._dateRangeChanged}
></ha-date-range-picker>
<ha-target-picker
.hass=${this.hass}
.value=${this._targetPickerValue}
.disabled=${this._isLoading}
add-on-top
@value-changed=${this._targetsChanged}
compact
></ha-target-picker>
</div>
${
this._isLoading
? html`<div class="progress-wrapper">
<ha-spinner></ha-spinner>
</div>`
: !entitiesSelected
? html`<div class="start-search">
${this.hass.localize("ui.panel.history.start_search")}
</div>`
: html`
<state-history-charts
.hass=${this.hass}
.historyData=${this._mungedStateHistory}
.startTime=${this._startDate}
.endTime=${this._endDate}
.narrow=${this.narrow}
sync-charts
>
</state-history-charts>
`
}
</div>
</ha-top-app-bar-fixed>
`;
}
private _renderEmptyState(hasTargets: boolean) {
return html`
<ha-empty-state
.icon=${mdiChartBoxOutline}
.heading=${this.hass.localize(
hasTargets
? "ui.panel.history.no_results_title"
: "ui.panel.history.start_search_title"
)}
.description=${this.hass.localize(
hasTargets
? "ui.panel.history.no_results"
: "ui.panel.history.start_search"
)}
>
<ha-button appearance="plain" @click=${this._openSources}>
${this.hass.localize(
hasTargets
? "ui.panel.history.change_sources"
: "ui.panel.history.add_targets"
)}
</ha-button>
</ha-empty-state>
`;
}
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
@@ -332,22 +226,12 @@ class HaPanelHistory extends LitElement {
const queryParams = decodeHistoryLogbookQueryParams(
extractSearchParamsObject()
);
const urlTarget = historyLogbookTargetFromQueryParams(queryParams);
const initialValue = urlTarget ?? this._storedTargetPickerValue;
const initialValue =
historyLogbookTargetFromQueryParams(queryParams) ??
this._storedTargetPickerValue;
if (initialValue) {
this._targetPickerValue = initialValue;
}
// A target linked from another page must not be narrowed by stored filters.
if (
this._storedFilters &&
(!urlTarget ||
historyLogbookTargetsEqual(
urlTarget,
this._storedTargetPickerValue ?? {}
))
) {
this._filters = this._storedFilters;
}
if (queryParams.start_date) {
this._startDate = queryParams.start_date;
}
@@ -358,9 +242,6 @@ class HaPanelHistory extends LitElement {
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
fetchEntitySourcesWithCache(this.hass).then((sources) => {
this._entitySources = sources;
});
const searchParams = extractSearchParamsObject();
if (searchParams.back === "1" && history.length > 1) {
this._showBack = true;
@@ -374,39 +255,18 @@ class HaPanelHistory extends LitElement {
if (
changedProps.has("_startDate") ||
changedProps.has("_endDate") ||
!shallowEqual(this._getEntityIds(), this._fetchedEntityIds)
changedProps.has("_targetPickerValue") ||
(!this._stateHistory &&
(changedProps.has("_deviceEntityLookup") ||
changedProps.has("_areaEntityLookup") ||
changedProps.has("_areaDeviceLookup")))
) {
this._getHistory();
this._getStats();
}
}
private _sourcesShown(): boolean {
return this._showSources ?? !this.narrow;
}
private _toggleSources() {
this._showSources = !this._sourcesShown();
}
private _openSources() {
this._showSources = true;
}
private _closeSources() {
this._showSources = false;
}
private _filtersChanged(
ev: HASSDomEvent<HASSDomEvents["source-filters-changed"]>
) {
this._filters = ev.detail.value;
this._storedFilters = this._filters;
}
private _clearSources() {
this._filters = {};
this._storedFilters = this._filters;
private _removeAll() {
this._targetPickerValue = {};
this._storedTargetPickerValue = this._targetPickerValue;
this._updatePath();
@@ -414,8 +274,6 @@ class HaPanelHistory extends LitElement {
private async _getStats() {
const statisticIds = this._getEntityIds();
this._fetchedEntityIds = statisticIds;
const fetchId = ++this._statsFetchId;
if (statisticIds.length === 0) {
this._statisticsHistory = undefined;
@@ -442,10 +300,6 @@ class HaPanelHistory extends LitElement {
return;
}
if (fetchId !== this._statsFetchId) {
return;
}
this._statisticsHistory = convertStatisticsToHistory(
this.hass!,
statistics,
@@ -456,13 +310,9 @@ class HaPanelHistory extends LitElement {
private async _getHistory() {
const entityIds = this._getEntityIds();
this._fetchedEntityIds = entityIds;
if (entityIds.length === 0) {
// The running subscription would keep pushing the previous entities.
this._unsubscribeHistory();
this._stateHistory = undefined;
this._isLoading = false;
return;
}
@@ -492,7 +342,6 @@ class HaPanelHistory extends LitElement {
);
this._subscribed.catch(() => {
this._isLoading = false;
this._stateHistory = { line: [], timeline: [] };
this._unsubscribeHistory();
});
if (this._endDate > now) {
@@ -528,44 +377,24 @@ class HaPanelHistory extends LitElement {
}
private _getEntityIds(): string[] {
return this.__filterEntityIds(
this.__resolveTargetEntityIds(
this._targetPickerValue,
this.hass.entities,
this.hass.devices,
this.hass.areas
),
this._filters,
// Only the device class filter reads the states.
this._filters.deviceClasses?.length ? this.hass.states : EMPTY_STATES,
return this.__getEntityIds(
this._targetPickerValue,
this.hass.entities,
this._entitySources
this.hass.devices,
this.hass.areas
);
}
// Same rules as the target picker, so that the chip and the picker agree.
private __resolveTargetEntityIds = memoizeOne(
private __getEntityIds = memoizeOne(
(
targetPickerValue: HassServiceTarget,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"]
): string[] => {
const picked = new Set(ensureArray(targetPickerValue.entity_id));
return resolveEntityIDs(
this.hass,
targetPickerValue,
entities,
devices,
areas
).filter(
(entityId) => picked.has(entityId) || !entities[entityId]?.hidden
);
}
): string[] =>
resolveEntityIDs(this.hass, targetPickerValue, entities, devices, areas)
);
private __filterEntityIds = memoizeOne(applySourceFilters);
private _dateRangeChanged(ev) {
this._startDate = ev.detail.value.startDate;
this._endDate = ev.detail.value.endDate;
@@ -744,14 +573,7 @@ class HaPanelHistory extends LitElement {
line-height: inherit;
}
:host {
--ha-generic-picker-width: min(400px, calc(100vw - 32px));
--ha-generic-picker-max-width: 400px;
}
.content {
display: flex;
flex-direction: column;
height: calc(
100vh - var(--header-height, 0px) - var(
--safe-area-inset-top,
@@ -759,55 +581,13 @@ class HaPanelHistory extends LitElement {
) - var(--safe-area-inset-bottom, 0px)
);
box-sizing: border-box;
overflow: hidden;
overflow-x: hidden;
padding: 0 16px 16px;
}
.main {
display: flex;
flex: 1;
min-height: 0;
}
.content-column {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.toolbar {
display: flex;
align-items: center;
gap: var(--ha-space-4);
box-sizing: border-box;
height: 56px;
flex-shrink: 0;
padding: 0 16px;
background: var(--primary-background-color);
border-bottom: 1px solid var(--divider-color);
direction: var(--direction);
overflow-x: auto;
scrollbar-width: none;
}
.toolbar::-webkit-scrollbar {
display: none;
}
.toolbar > * {
flex-shrink: 0;
}
.results {
flex: 1;
min-width: 0;
overflow: hidden auto;
padding: 16px 8px;
}
/* Line the charts up with the toolbar when there are no axis labels. */
:host([narrow]) .results {
padding-inline: 16px;
:host([virtualize]) {
height: 100%;
--ha-generic-picker-max-width: 400px;
}
.progress-wrapper {
@@ -817,6 +597,42 @@ class HaPanelHistory extends LitElement {
flex-direction: column;
padding: 16px;
}
.filters {
display: flex;
align-items: flex-start;
margin-top: 16px;
}
ha-date-range-picker {
margin-right: 16px;
margin-inline-end: 16px;
margin-inline-start: initial;
max-width: 100%;
direction: var(--direction);
}
ha-target-picker {
flex: 1;
max-width: 100%;
min-width: 0;
}
@media all and (max-width: 1025px) {
.filters {
flex-direction: column;
}
ha-date-range-picker {
width: 100%;
margin-bottom: 8px;
}
}
.start-search {
padding-top: 16px;
text-align: center;
color: var(--secondary-text-color);
}
`,
];
}
-406
View File
@@ -1,406 +0,0 @@
import { mdiPuzzle } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup } 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 { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-adaptive-dialog";
import "../../components/ha-alert";
import "../../components/ha-icon";
import "../../components/ha-icon-next";
import "../../components/ha-relative-time";
import "../../components/ha-spinner";
import "../../components/ha-svg-icon";
import "../../components/item/ha-list-item-base";
import "../../components/item/ha-list-item-button";
import "../../components/item/ha-list-item-value";
import "../../components/list/ha-grouped-list";
import { fetchDateWS } from "../../data/history";
import type { LogbookEntry } from "../../data/logbook";
import type { HassDialog } from "../../dialogs/make-dialog-manager";
import { haStyle, haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import "./ha-logbook-chain";
import type { LogbookChain } from "./logbook-chain-resolver";
import { resolveLogbookChain } from "./logbook-chain-resolver";
import type { LogbookItem } from "./logbook-entry-model";
import { computeLogbookItem } from "./logbook-entry-model";
import { renderLogbookGlyph, transitionArrow } from "./logbook-entry-templates";
import type { LogbookDetailDialogParams } from "./show-dialog-logbook-detail";
@customElement("dialog-logbook-detail")
class DialogLogbookDetail
extends LitElement
implements HassDialog<LogbookDetailDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: LogbookDetailDialogParams;
@state() private _open = false;
@state() private _chain?: LogbookChain;
@state() private _previousState?: string;
@state() private _error = false;
public showDialog(params: LogbookDetailDialogParams): void {
this._params = params;
this._open = true;
this._chain = undefined;
this._previousState = undefined;
this._error = false;
if (
params.entry.context_event_type === "call_service" &&
params.entry.context_domain
) {
this.hass.loadBackendTranslation("services", params.entry.context_domain);
}
this._loadDetails();
}
public closeDialog(): boolean {
this._open = false;
return true;
}
private _dialogClosed(): void {
this._params = undefined;
this._chain = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
// Both fetches resolve into a single render so the dialog reflows once.
private async _loadDetails() {
const { entry } = this._params!;
const [{ chain, errored }, previousState] = await Promise.all([
this._fetchChain(entry),
this._fetchPreviousState(entry),
]);
if (this._params?.entry !== entry) {
return;
}
this._error = errored;
this._chain = chain;
this._previousState = previousState;
}
private async _fetchChain(
entry: LogbookEntry
): Promise<{ chain: LogbookChain; errored: boolean }> {
const { userIdToName, systemUserIds } = this._params!;
const options = { userIdToName, systemUserIds };
const resolveWithoutFetch = () =>
resolveLogbookChain(this.hass, entry, options, async () => []);
try {
const chain = isComponentLoaded(this.hass.config, "logbook")
? await resolveLogbookChain(this.hass, entry, options)
: await resolveWithoutFetch();
return { chain, errored: false };
} catch {
return { chain: await resolveWithoutFetch(), errored: true };
}
}
// The feed the row was clicked in can be filtered or partially loaded, so
// the state active just before the entry is resolved from history instead.
private async _fetchPreviousState(
entry: LogbookEntry
): Promise<string | undefined> {
if (
!entry.entity_id ||
entry.state === undefined ||
!isComponentLoaded(this.hass.config, "history")
) {
return undefined;
}
const end = new Date(entry.when * 1000);
const start = new Date(end.getTime() - 1);
try {
const states = await fetchDateWS(this.hass, start, end, [
entry.entity_id,
]);
return states[entry.entity_id]?.[0]?.s;
} catch {
// The row is still useful without an old state.
return undefined;
}
}
protected render() {
if (!this._params) {
return nothing;
}
const { entry } = this._params;
const item = computeLogbookItem(this.hass, entry);
return html`
<ha-adaptive-dialog
.open=${this._open}
header-title=${this.hass.localize("ui.dialogs.logbook_detail.title")}
@closed=${this._dialogClosed}
@hass-more-info=${this._moreInfoOpened}
>
<div class="content">
${this._renderFacts(item, entry)} ${this._renderWhatHappened(entry)}
</div>
</ha-adaptive-dialog>
`;
}
private _renderFacts(item: LogbookItem, entry: LogbookEntry) {
const stateObj = entry.entity_id
? this.hass.states[entry.entity_id]
: undefined;
const transition = this._transitionValues(item, entry, stateObj);
const when = this._entryDate(item.when);
return html`
<ha-grouped-list>
${this._renderSubjectRow(item, entry)}
${
transition
? html`
<ha-list-item-value
.label=${this.hass.localize(
"ui.dialogs.logbook_detail.state"
)}
>
${
transition.oldState
? html`<span class="old-state"
>${transition.oldState}</span
><span class="arrow"
>${transitionArrow(this.hass)}</span
>`
: nothing
}<span class="new-state">${transition.newState}</span>
</ha-list-item-value>
`
: item.value
? html`
<ha-list-item-value
.label=${this.hass.localize(
"ui.dialogs.logbook_detail.event"
)}
>
${item.value.text}
</ha-list-item-value>
`
: nothing
}
<ha-list-item-value
class="time-value"
.label=${this.hass.localize("ui.dialogs.logbook_detail.time")}
>
${formatDateTimeWithSeconds(when, this.hass.locale, this.hass.config)}
<span class="sub">
<ha-relative-time .datetime=${when} capitalize></ha-relative-time>
</span>
</ha-list-item-value>
</ha-grouped-list>
`;
}
// Mirrors the target picker: icon, name, area ▸ device.
private _renderSubjectRow(item: LogbookItem, entry: LogbookEntry) {
const icon = html`<span class="subject-icon" slot="start">
${this._renderSubjectIcon(item, entry)}
</span>`;
if (!entry.entity_id || !(entry.entity_id in this.hass.states)) {
return html`
<ha-list-item-base
.headline=${item.name}
.supportingText=${item.context}
>
${icon}
</ha-list-item-base>
`;
}
return html`
<ha-list-item-button
.headline=${item.name}
.supportingText=${item.context}
.entityId=${entry.entity_id}
@click=${this._entityClicked}
>
${icon}
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
`;
}
// The feed draws brand rows as a brands image, which stays blank for the
// integrations that have none. An integration domain resolves to no domain
// icon either, so fall back to the chain's own integration glyph.
private _renderSubjectIcon(item: LogbookItem, entry: LogbookEntry) {
if (item.glyph.type !== "brand") {
return renderLogbookGlyph(this.hass, entry, item.glyph);
}
return item.glyph.icon
? html`<ha-icon .icon=${item.glyph.icon}></ha-icon>`
: html`<ha-svg-icon .path=${mdiPuzzle}></ha-svg-icon>`;
}
private _entityClicked(ev: Event) {
const target = ev.currentTarget as HTMLElement & { entityId: string };
fireEvent(target, "hass-more-info", { entityId: target.entityId });
}
private _renderWhatHappened(entry: LogbookEntry) {
return html`
${
this._error
? html`<ha-alert alert-type="warning">
${this.hass.localize("ui.components.logbook.retrieval_error")}
</ha-alert>`
: nothing
}
<ha-grouped-list
.header=${this.hass.localize("ui.dialogs.logbook_detail.what_happened")}
>
<div class="chain-area">
${
this._chain === undefined
? html`<div class="loading"><ha-spinner></ha-spinner></div>`
: html`<ha-logbook-chain
.hass=${this.hass}
.chain=${this._chain}
.subject=${entry}
.traceContexts=${this._params?.traceContexts ?? {}}
></ha-logbook-chain>`
}
</div>
</ha-grouped-list>
`;
}
private _entryDate = memoizeOne((when: number) => new Date(when));
private _transitionValues(
item: LogbookItem,
entry: LogbookEntry,
stateObj?: HassEntity
): { oldState?: string; newState: string } | undefined {
if (item.category !== "entity" || entry.state === undefined) {
return undefined;
}
const newState = stateObj
? this.hass.formatEntityState(stateObj, entry.state)
: entry.state;
const previousState = this._previousState;
const oldState =
previousState !== undefined && previousState !== entry.state
? stateObj
? this.hass.formatEntityState(stateObj, previousState)
: previousState
: undefined;
return { oldState, newState };
}
private _moreInfoOpened() {
this.closeDialog();
}
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleDialog,
css`
.content {
display: flex;
flex-direction: column;
gap: var(--ha-space-4);
}
ha-list-item-button,
ha-list-item-base {
--ha-row-item-gap: var(--ha-space-3);
--ha-row-item-min-height: 56px;
}
/* Match the weight the chain gives its own row names. */
ha-list-item-button::part(headline),
ha-list-item-base::part(headline) {
font-weight: var(--ha-font-weight-medium);
}
.subject-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
color: var(--secondary-text-color);
--state-icon-color: var(--secondary-text-color);
}
.subject-icon state-badge {
width: 32px;
height: 32px;
margin: 0;
}
ha-icon-next {
color: var(--secondary-text-color);
}
.sub {
display: block;
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
}
.old-state {
color: var(--secondary-text-color);
}
.arrow {
color: var(--disabled-color);
padding: 0 4px;
}
.new-state {
font-weight: var(--ha-font-weight-medium);
}
.time-value {
font-variant-numeric: tabular-nums;
}
ha-relative-time {
display: contents;
}
/* minmax(0, 1fr) lets the chain shrink: a grid item defaults to its
min-content width, which a long automation name blows past. */
.chain-area {
display: grid;
grid-template-columns: minmax(0, 1fr);
}
/* Held at two chain rows, the typical chain height, so the swap from
spinner to content barely moves the dialog. The resolved content
sizes itself a lone "no cause" line must not sit in a tall box. */
.loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 114px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-logbook-detail": DialogLogbookDetail;
}
}

Some files were not shown because too many files have changed in this diff Show More