Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adcf554149 | ||
|
|
921b73ecbe | ||
|
|
6dbf56aae0 | ||
|
|
784c8f782c | ||
|
|
78d45ccf76 | ||
|
|
14952d74ac | ||
|
|
734bc63100 |
No files matched your search
@@ -0,0 +1,4 @@
|
||||
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 252 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 252 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 330 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 330 B |
@@ -0,0 +1,6 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 408 B |
@@ -0,0 +1,6 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 408 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 174 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 174 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 252 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 252 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 330 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 330 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="56" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 174 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="56" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 174 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="56" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 252 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="56" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 252 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="76" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 174 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="76" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 174 B |
@@ -15,6 +15,10 @@ import {
|
||||
|
||||
const DEBOUNCE_WAIT = 300;
|
||||
|
||||
// A page can be opened with or without a trailing slash
|
||||
const trimSlash = (path: string) =>
|
||||
path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
|
||||
|
||||
export interface QueryParamsControllerOptions {
|
||||
/** Whether the host keeps its state in the URL. */
|
||||
enabled: () => boolean;
|
||||
@@ -24,6 +28,12 @@ export interface QueryParamsControllerOptions {
|
||||
* the URL with `searchParams()` and apply it, then `write()` the state back.
|
||||
*/
|
||||
navigated: () => void;
|
||||
/**
|
||||
* The path of the host's page, for a page that can first be connected on
|
||||
* a sub-path it sends on to itself, like an add link. Defaults to the path
|
||||
* the host is first connected on.
|
||||
*/
|
||||
path?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,7 +67,7 @@ export class QueryParamsController<
|
||||
public hostConnected() {
|
||||
// A cached page is reconnected on the way to another page, before its
|
||||
// router has moved on, so it keeps the page it was first connected on
|
||||
this._path ??= currentPath();
|
||||
this._path ??= this._options.path ?? currentPath();
|
||||
this._connected = true;
|
||||
mainWindow.addEventListener("location-changed", this._navigated);
|
||||
mainWindow.addEventListener("popstate", this._navigated);
|
||||
@@ -127,7 +137,9 @@ export class QueryParamsController<
|
||||
|
||||
private _isActive(): boolean {
|
||||
return (
|
||||
this._connected && this._options.enabled() && currentPath() === this._path
|
||||
this._connected &&
|
||||
this._options.enabled() &&
|
||||
trimSlash(currentPath()) === trimSlash(this._path ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -158,7 +170,9 @@ export class QueryParamsController<
|
||||
const current = mainWindow.location.search;
|
||||
const queryString = updateQueryString(current, values, this._config);
|
||||
if (current !== (queryString ? `?${queryString}` : "")) {
|
||||
replaceCurrentUrl(constructUrlCurrentPath(queryString));
|
||||
// Keep a fragment like #config_entry=, the demo already routes with it
|
||||
const hash = __DEMO__ ? "" : mainWindow.location.hash;
|
||||
replaceCurrentUrl(`${constructUrlCurrentPath(queryString)}${hash}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,8 +114,7 @@ export const automationsLinkFromUrl = (
|
||||
"ha-filter-devices": filter(values("device")),
|
||||
"ha-filter-entities": filter(values("entity")),
|
||||
"ha-filter-labels": filter(values("label")),
|
||||
// The category filter picks one category
|
||||
"ha-filter-categories": filter(values("category").slice(0, 1)),
|
||||
"ha-filter-categories": filter(values("category")),
|
||||
"ha-filter-voice-assistants": filter(values("assistants")),
|
||||
"ha-filter-blueprints": filter(values("blueprint")),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
decodeQueryParams,
|
||||
hasQueryParams,
|
||||
type QueryParamConfig,
|
||||
type QueryParamValues,
|
||||
type SearchParamsSource,
|
||||
} from "./query-params";
|
||||
|
||||
// `status` adds the ignored and disabled integrations to what is shown.
|
||||
export const integrationsQueryParamConfig = {
|
||||
string: ["search"],
|
||||
list: ["status"],
|
||||
} as const satisfies QueryParamConfig;
|
||||
|
||||
export const integrationQueryParamConfig = {
|
||||
string: ["search"],
|
||||
} as const satisfies QueryParamConfig;
|
||||
|
||||
export type IntegrationsQueryParams = QueryParamValues<
|
||||
typeof integrationsQueryParamConfig
|
||||
>;
|
||||
|
||||
export interface IntegrationsState {
|
||||
search: string;
|
||||
showIgnored: boolean;
|
||||
showDisabled: boolean;
|
||||
}
|
||||
|
||||
export const integrationsQueryParams = (
|
||||
state: IntegrationsState
|
||||
): IntegrationsQueryParams => ({
|
||||
search: state.search,
|
||||
status: [
|
||||
...(state.showIgnored ? ["ignored"] : []),
|
||||
...(state.showDisabled ? ["disabled"] : []),
|
||||
],
|
||||
});
|
||||
|
||||
// What a link says to show, undefined when it has none of the page's params
|
||||
// so what was picked before stays. A link replaces the search and the toggles.
|
||||
export const integrationsLinkFromUrl = (
|
||||
searchParams: SearchParamsSource
|
||||
): IntegrationsState | undefined => {
|
||||
if (!hasQueryParams(searchParams, integrationsQueryParamConfig)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { search, status } = decodeQueryParams(
|
||||
searchParams,
|
||||
integrationsQueryParamConfig
|
||||
);
|
||||
|
||||
return {
|
||||
search: search ?? "",
|
||||
showIgnored: !!status?.includes("ignored"),
|
||||
showDisabled: !!status?.includes("disabled"),
|
||||
};
|
||||
};
|
||||
|
||||
export const integrationSearchFromUrl = (
|
||||
searchParams: SearchParamsSource
|
||||
): string | undefined => {
|
||||
if (!hasQueryParams(searchParams, integrationQueryParamConfig)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
decodeQueryParams(searchParams, integrationQueryParamConfig).search ?? ""
|
||||
);
|
||||
};
|
||||
@@ -107,8 +107,7 @@ export const scenesLinkFromUrl = (
|
||||
"ha-filter-devices": filter(values("device")),
|
||||
"ha-filter-entities": filter(values("entity")),
|
||||
"ha-filter-labels": filter(values("label")),
|
||||
// The category filter picks one category
|
||||
"ha-filter-categories": filter(values("category").slice(0, 1)),
|
||||
"ha-filter-categories": filter(values("category")),
|
||||
"ha-filter-voice-assistants": filter(values("assistants")),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -109,8 +109,7 @@ export const scriptsLinkFromUrl = (
|
||||
"ha-filter-devices": filter(values("device")),
|
||||
"ha-filter-entities": filter(values("entity")),
|
||||
"ha-filter-labels": filter(values("label")),
|
||||
// The category filter picks one category
|
||||
"ha-filter-categories": filter(values("category").slice(0, 1)),
|
||||
"ha-filter-categories": filter(values("category")),
|
||||
"ha-filter-voice-assistants": filter(values("assistants")),
|
||||
"ha-filter-blueprints": filter(values("blueprint")),
|
||||
},
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { SelectedDetail } from "@material/mwc-list";
|
||||
import {
|
||||
mdiDelete,
|
||||
mdiDotsVertical,
|
||||
@@ -12,6 +11,7 @@ import type { CSSResultGroup } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
@@ -35,8 +35,9 @@ import type { HaDropdownSelectEvent } from "./ha-dropdown";
|
||||
import "./ha-dropdown-item";
|
||||
import "./ha-expansion-panel";
|
||||
import "./ha-icon";
|
||||
import "./ha-list";
|
||||
import "./ha-list-item";
|
||||
import "./item/ha-list-item-option";
|
||||
import "./list/ha-list-selectable";
|
||||
|
||||
@customElement("ha-filter-categories")
|
||||
export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
@@ -94,47 +95,44 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
this._panel.showContent
|
||||
? html`
|
||||
<div class="content" ${ref(this._content)}>
|
||||
<ha-list
|
||||
@selected=${this._categorySelected}
|
||||
<ha-list-selectable
|
||||
class="ha-scrollbar"
|
||||
activatable
|
||||
multi
|
||||
aria-label=${this.hass.localize(
|
||||
"ui.panel.config.category.caption"
|
||||
)}
|
||||
@ha-list-item-selected=${this._categoryAdded}
|
||||
@ha-list-item-deselected=${this._categoryRemoved}
|
||||
>
|
||||
${
|
||||
this._categories.length > 0
|
||||
? html`<ha-list-item
|
||||
.selected=${!this.value?.length}
|
||||
.activated=${!this.value?.length}
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.category.filter.show_all"
|
||||
)}</ha-list-item
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
${this._categories.map(
|
||||
${repeat(
|
||||
this._categories,
|
||||
(category) => category.category_id,
|
||||
(category) =>
|
||||
html`<ha-list-item
|
||||
html`<ha-list-item-option
|
||||
appearance="checkbox"
|
||||
selection-position="end"
|
||||
.value=${category.category_id}
|
||||
.selected=${this.value?.includes(category.category_id)}
|
||||
.activated=${this.value?.includes(category.category_id)}
|
||||
graphic="icon"
|
||||
hasMeta
|
||||
.selected=${
|
||||
this.value?.includes(category.category_id) ?? false
|
||||
}
|
||||
>
|
||||
${
|
||||
category.icon
|
||||
? html`<ha-icon
|
||||
slot="graphic"
|
||||
slot="start"
|
||||
.icon=${category.icon}
|
||||
></ha-icon>`
|
||||
: html`<ha-svg-icon
|
||||
.path=${mdiTag}
|
||||
slot="graphic"
|
||||
slot="start"
|
||||
></ha-svg-icon>`
|
||||
}
|
||||
${category.name}
|
||||
<span slot="headline">${category.name}</span>
|
||||
<ha-dropdown
|
||||
@click=${stopPropagation}
|
||||
@keydown=${this._handleMenuKeydown}
|
||||
@wa-select=${this._handleAction}
|
||||
slot="meta"
|
||||
slot="end"
|
||||
.categoryId=${category.category_id}
|
||||
>
|
||||
<ha-icon-button
|
||||
@@ -161,9 +159,9 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
)}
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
</ha-list-item>`
|
||||
</ha-list-item-option>`
|
||||
)}
|
||||
</ha-list>
|
||||
</ha-list-selectable>
|
||||
<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")}
|
||||
@@ -213,10 +211,10 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
try {
|
||||
await deleteCategoryRegistryEntry(this.hass, this.scope!, id);
|
||||
fireEvent(this, "data-table-filter-changed", {
|
||||
value: [],
|
||||
items: undefined,
|
||||
});
|
||||
|
||||
if (this.value?.includes(id)) {
|
||||
this._setValue(this.value.filter((categoryId) => categoryId !== id));
|
||||
}
|
||||
} catch (err: any) {
|
||||
alert(`Failed to delete: ${err.message}`);
|
||||
}
|
||||
@@ -237,25 +235,39 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private async _categorySelected(ev: CustomEvent<SelectedDetail<number>>) {
|
||||
if (!ev.detail.index) {
|
||||
fireEvent(this, "data-table-filter-changed", {
|
||||
value: [],
|
||||
items: undefined,
|
||||
});
|
||||
this.value = [];
|
||||
return;
|
||||
// The list toggles the focused row on Enter and Space, which would pick the
|
||||
// category instead of opening its menu.
|
||||
private _handleMenuKeydown(ev: KeyboardEvent) {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.stopPropagation();
|
||||
}
|
||||
const index = ev.detail.index - 1;
|
||||
}
|
||||
|
||||
const val = this._categories![index]?.category_id;
|
||||
if (!val) {
|
||||
private _categoryAdded(ev: CustomEvent<number>) {
|
||||
const categoryId = this._categories[ev.detail]?.category_id;
|
||||
|
||||
if (!categoryId || this.value?.includes(categoryId)) {
|
||||
return;
|
||||
}
|
||||
this.value = [val];
|
||||
|
||||
this._setValue([...(this.value || []), categoryId]);
|
||||
}
|
||||
|
||||
private _categoryRemoved(ev: CustomEvent<number>) {
|
||||
const categoryId = this._categories[ev.detail]?.category_id;
|
||||
|
||||
if (!categoryId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._setValue((this.value || []).filter((id) => id !== categoryId));
|
||||
}
|
||||
|
||||
private _setValue(value: string[]) {
|
||||
this.value = value;
|
||||
|
||||
fireEvent(this, "data-table-filter-changed", {
|
||||
value: this.value,
|
||||
value: value.length ? value : undefined,
|
||||
items: undefined,
|
||||
});
|
||||
}
|
||||
@@ -298,13 +310,22 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
padding: 0px 2px;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
ha-list {
|
||||
/* The list scrolls through its own container, not through the host. */
|
||||
ha-list-selectable {
|
||||
display: flex;
|
||||
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);
|
||||
--ha-icon-button-size: 36px;
|
||||
}
|
||||
ha-list-selectable::part(base) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
/* Keeps a row with its menu button as tall as the other filters. */
|
||||
ha-list-item-option {
|
||||
--ha-row-item-padding-block: var(--ha-space-2);
|
||||
}
|
||||
ha-list-item-option ha-icon-button {
|
||||
--ha-icon-button-size: 32px;
|
||||
}
|
||||
ha-list-item {
|
||||
--mdc-list-item-graphic-margin: var(--ha-space-4);
|
||||
|
||||
@@ -4,7 +4,10 @@ import type {
|
||||
HassEntityBase,
|
||||
} from "home-assistant-js-websocket";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import { createDurationData } from "../common/datetime/create_duration_data";
|
||||
import { applyDurationSign } from "../common/datetime/normalize_duration";
|
||||
import type { WeekdayShort } from "../common/datetime/weekday";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import { navigate } from "../common/navigate";
|
||||
import type { LocalizeKeys } from "../common/translations/localize";
|
||||
import { createSearchParam } from "../common/url/search-params";
|
||||
@@ -524,6 +527,26 @@ export const pickRowConfig = <T extends object>(
|
||||
return config as Partial<T>;
|
||||
};
|
||||
|
||||
// Older sun and calendar triggers set a positive offset with an offset type,
|
||||
// which the backend folds into the sign of the offset.
|
||||
const OFFSET_TYPE_TRIGGER_DOMAINS = ["sun", "calendar"];
|
||||
|
||||
const foldOffsetType = (
|
||||
options: Record<string, unknown>
|
||||
): Record<string, unknown> => {
|
||||
const { offset_type: offsetType, ...folded } = options;
|
||||
const offset = createDurationData(
|
||||
folded.offset as string | number | ForDict | undefined
|
||||
);
|
||||
if (offset && !Object.values(offset).every(Number.isFinite)) {
|
||||
return options;
|
||||
}
|
||||
if (offsetType === "before" && offset) {
|
||||
folded.offset = applyDurationSign(offset, true);
|
||||
}
|
||||
return folded;
|
||||
};
|
||||
|
||||
export const getRowNote = (note: unknown): string | undefined =>
|
||||
typeof note === "string" ? note.trim() || undefined : undefined;
|
||||
|
||||
@@ -551,12 +574,12 @@ export const migrateAutomationTrigger = (
|
||||
delete trigger.platform;
|
||||
}
|
||||
|
||||
if ("options" in trigger) {
|
||||
if (
|
||||
typeof trigger.options === "object" &&
|
||||
trigger.options !== null &&
|
||||
"behavior" in trigger.options
|
||||
) {
|
||||
if (
|
||||
"options" in trigger &&
|
||||
typeof trigger.options === "object" &&
|
||||
trigger.options !== null
|
||||
) {
|
||||
if ("behavior" in trigger.options) {
|
||||
// Deprecated behavior values renamed in 2026; the backend raises a repair
|
||||
// when they are still used, so flag the migration to offer saving.
|
||||
if (trigger.options.behavior === "any") {
|
||||
@@ -571,6 +594,12 @@ export const migrateAutomationTrigger = (
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
"offset_type" in trigger.options &&
|
||||
OFFSET_TYPE_TRIGGER_DOMAINS.includes(computeDomain(trigger.trigger))
|
||||
) {
|
||||
trigger.options = foldOffsetType(trigger.options);
|
||||
}
|
||||
}
|
||||
|
||||
return trigger;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { TZDate } from "@date-fns/tz";
|
||||
import type { HassConfig, HassEntity } from "home-assistant-js-websocket";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import { createDurationData } from "../common/datetime/create_duration_data";
|
||||
import { durationDataToSeconds } from "../common/datetime/duration_to_seconds";
|
||||
import {
|
||||
formatDurationDigital,
|
||||
formatDurationLong,
|
||||
@@ -10,6 +12,7 @@ import {
|
||||
formatTime,
|
||||
formatTimeWithSeconds,
|
||||
} from "../common/datetime/format_time";
|
||||
import { normalizeDuration } from "../common/datetime/normalize_duration";
|
||||
import secondsToDuration from "../common/datetime/seconds_to_duration";
|
||||
import { sortWeekdays } from "../common/datetime/sort_weekdays";
|
||||
import { computeAttributeNameDisplay } from "../common/entity/compute_attribute_display";
|
||||
@@ -422,16 +425,7 @@ const describeLegacyTrigger = (
|
||||
|
||||
// Sun Trigger
|
||||
if (trigger.trigger === "sun" && trigger.event) {
|
||||
let duration = "";
|
||||
if (trigger.offset) {
|
||||
if (typeof trigger.offset === "number") {
|
||||
duration = secondsToDuration(trigger.offset)!;
|
||||
} else if (typeof trigger.offset === "string") {
|
||||
duration = trigger.offset;
|
||||
} else {
|
||||
duration = JSON.stringify(trigger.offset);
|
||||
}
|
||||
}
|
||||
const duration = formatSunOffset(hass, trigger.offset);
|
||||
|
||||
return hass.localize(
|
||||
trigger.event === "sunset"
|
||||
@@ -841,19 +835,16 @@ const describeLegacyTrigger = (
|
||||
|
||||
let offsetChoice = "other";
|
||||
let offset = "";
|
||||
if (typeof trigger.offset === "string" && trigger.offset) {
|
||||
offsetChoice = trigger.offset.startsWith("-") ? "before" : "after";
|
||||
const parts = trigger.offset.startsWith("-")
|
||||
? trigger.offset.substring(1).split(":")
|
||||
: trigger.offset.split(":");
|
||||
const duration = {
|
||||
hours: parts.length > 0 ? +parts[0] : 0,
|
||||
minutes: parts.length > 1 ? +parts[1] : 0,
|
||||
seconds: parts.length > 2 ? +parts[2] : 0,
|
||||
};
|
||||
offset = formatDurationLong(hass.locale, duration);
|
||||
if (offset === "") {
|
||||
offsetChoice = "other";
|
||||
const duration = createDurationData(trigger.offset);
|
||||
if (duration) {
|
||||
const normalized = normalizeDuration(duration, {
|
||||
enableDay: true,
|
||||
enableSecond: true,
|
||||
enableMillisecond: true,
|
||||
});
|
||||
offset = formatDurationLong(hass.locale, normalized.duration);
|
||||
if (offset) {
|
||||
offsetChoice = normalized.negative ? "before" : "after";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -884,9 +875,17 @@ const formatSunOffset = (
|
||||
if (typeof offset === "string") {
|
||||
return offset;
|
||||
}
|
||||
if (!durationDataToSeconds(offset)) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const formatted = formatDurationDigital(hass.locale, offset);
|
||||
return formatted.startsWith("-") ? formatted : `+${formatted}`;
|
||||
const normalized = normalizeDuration(offset, {
|
||||
enableDay: false,
|
||||
enableSecond: true,
|
||||
enableMillisecond: true,
|
||||
});
|
||||
const formatted = formatDurationDigital(hass.locale, normalized.duration);
|
||||
return `${normalized.negative ? "-" : "+"}${formatted}`;
|
||||
} catch (_e) {
|
||||
return JSON.stringify(offset);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import {
|
||||
mdiAccessPoint,
|
||||
mdiBluetooth,
|
||||
mdiMusic,
|
||||
mdiSwapHorizontal,
|
||||
} from "@mdi/js";
|
||||
import { mdiAccessPoint, mdiBluetooth, mdiSwapHorizontal } from "@mdi/js";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import type { LocalizeKeys } from "../common/translations/localize";
|
||||
import { mdiSendspin } from "../resources/sendspin-logo-svg";
|
||||
import type { ConfigEntry } from "./config_entries";
|
||||
import type { DataEntryFlowProgress } from "./data_entry_flow";
|
||||
import type {
|
||||
@@ -55,17 +51,19 @@ export const CAPABILITY_ORDER: ESPHomeCapabilityId[] = [
|
||||
"serial",
|
||||
];
|
||||
|
||||
/** Theme colors for each setup capability. */
|
||||
const SENDSPIN_RED = "#e25c4c";
|
||||
|
||||
/** Colors for each setup capability. */
|
||||
export const ESPHOME_CAPABILITY_ACCENTS: Record<ESPHomeCapabilityId, string> = {
|
||||
bluetooth: "var(--blue-color)",
|
||||
audio: "var(--light-green-color)",
|
||||
audio: SENDSPIN_RED,
|
||||
connectivity: "var(--cyan-color)",
|
||||
serial: "var(--deep-purple-color)",
|
||||
};
|
||||
|
||||
export const ESPHOME_CAPABILITY_ICONS: Record<ESPHomeCapabilityId, string> = {
|
||||
bluetooth: mdiBluetooth,
|
||||
audio: mdiMusic,
|
||||
audio: mdiSendspin,
|
||||
connectivity: mdiAccessPoint,
|
||||
serial: mdiSwapHorizontal,
|
||||
};
|
||||
|
||||
@@ -398,14 +398,16 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
if (status !== "completed") {
|
||||
return nothing;
|
||||
}
|
||||
return this._checkBadge(
|
||||
this._i18n!.localize(
|
||||
"ui.panel.config.devices.esphome.setup_status_completed"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private _checkBadge(label: string) {
|
||||
return html`
|
||||
<span
|
||||
class="status-badge completed"
|
||||
role="img"
|
||||
aria-label=${this._i18n!.localize(
|
||||
"ui.panel.config.devices.esphome.setup_status_completed"
|
||||
)}
|
||||
>
|
||||
<span class="status-badge completed" role="img" aria-label=${label}>
|
||||
<ha-svg-icon .path=${mdiCheck}></ha-svg-icon>
|
||||
</span>
|
||||
`;
|
||||
@@ -507,7 +509,7 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
return html`
|
||||
<div
|
||||
class="protect"
|
||||
style="--capability-accent: ${ESPHOME_CAPABILITY_ACCENTS.audio}"
|
||||
style="--capability-accent: var(--light-green-color)"
|
||||
>
|
||||
<span class="icon-chip">
|
||||
<ha-svg-icon .path=${mdiShieldLock}></ha-svg-icon>
|
||||
@@ -624,11 +626,9 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
<span class="port-text">
|
||||
<span class="port-name">${entry.title}</span>
|
||||
</span>
|
||||
<span class="chip active">
|
||||
${localize(
|
||||
"ui.panel.config.devices.esphome.setup_zwave_configured"
|
||||
)}
|
||||
</span>
|
||||
${this._checkBadge(
|
||||
localize("ui.panel.config.devices.esphome.setup_zwave_configured")
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
`;
|
||||
@@ -748,13 +748,11 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
</span>
|
||||
`
|
||||
: usage.consumers.length
|
||||
? html`
|
||||
<span class="chip active">
|
||||
${localize(
|
||||
"ui.panel.config.devices.esphome.setup_serial_configured"
|
||||
)}
|
||||
</span>
|
||||
`
|
||||
? this._checkBadge(
|
||||
localize(
|
||||
"ui.panel.config.devices.esphome.setup_serial_configured"
|
||||
)
|
||||
)
|
||||
: html`
|
||||
<ha-button size="s" @click=${this._setupSerialPort}>
|
||||
${localize(
|
||||
@@ -1404,10 +1402,6 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
background: var(--ha-color-fill-neutral-normal-resting);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.chip.active {
|
||||
background: var(--success-color);
|
||||
color: var(--ha-color-on-success-loud);
|
||||
}
|
||||
.ma-offer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -65,7 +65,7 @@ export class HaSunCondition extends LitElement implements ConditionElement {
|
||||
name: "after_offset",
|
||||
selector: {
|
||||
duration: {
|
||||
allow_negative: true,
|
||||
mode: "offset",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -96,7 +96,7 @@ export class HaSunCondition extends LitElement implements ConditionElement {
|
||||
name: "before_offset",
|
||||
selector: {
|
||||
duration: {
|
||||
allow_negative: true,
|
||||
mode: "offset",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -980,13 +980,17 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
// the filters below only expose the selected options (as filter.value);
|
||||
// applying the filter must be done here
|
||||
} else if (isFilterUsed(key, filter, "ha-filter-categories")) {
|
||||
// category filter only allows a single selected option
|
||||
filteredEntityIds = filteredEntityIds.filter(
|
||||
(entityId) =>
|
||||
filter.value![0] ===
|
||||
this._entityReg.find((reg) => reg.entity_id === entityId)
|
||||
?.categories.automation
|
||||
);
|
||||
filteredEntityIds = filteredEntityIds.filter((entityId) => {
|
||||
const category = this._entityReg.find(
|
||||
(reg) => reg.entity_id === entityId
|
||||
)?.categories.automation;
|
||||
|
||||
return (
|
||||
category !== undefined &&
|
||||
Array.isArray(filter.value) &&
|
||||
filter.value.includes(category)
|
||||
);
|
||||
});
|
||||
} else if (isFilterUsed(key, filter, "ha-filter-labels")) {
|
||||
filteredEntityIds = filteredEntityIds.filter((entityId) =>
|
||||
this._entityReg
|
||||
|
||||
@@ -5,16 +5,22 @@ import memoizeOne from "memoize-one";
|
||||
import { consume } from "../../../common/decorators/consume";
|
||||
import { createDurationData } from "../../../common/datetime/create_duration_data";
|
||||
import { formatDurationNarrow } from "../../../common/datetime/format_duration";
|
||||
import { normalizeDuration } from "../../../common/datetime/normalize_duration";
|
||||
import { hasTemplate } from "../../../common/string/has-template";
|
||||
import type { ForDict } from "../../../data/automation";
|
||||
import { internationalizationContext } from "../../../data/context";
|
||||
import { rowSummaryStyles } from "./styles";
|
||||
|
||||
const SUMMARY_UNITS = {
|
||||
enableDay: true,
|
||||
enableSecond: true,
|
||||
enableMillisecond: true,
|
||||
};
|
||||
|
||||
interface HaAutomationRowOptionsConfig {
|
||||
options?: {
|
||||
for?: string | number | ForDict;
|
||||
offset?: string | number | ForDict;
|
||||
offset_type?: "before" | "after";
|
||||
};
|
||||
timeout?: string | number | ForDict;
|
||||
}
|
||||
@@ -63,20 +69,18 @@ export class HaAutomationRowOptions extends LitElement {
|
||||
this._i18n.localize(
|
||||
"ui.panel.config.automation.editor.row_options.for",
|
||||
{
|
||||
duration: forDuration,
|
||||
duration: forDuration.text,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const offsetDuration = this._duration(options.offset);
|
||||
if (offsetDuration) {
|
||||
const offsetType =
|
||||
options.offset_type === "before" ? "before" : "after";
|
||||
const offset = this._duration(options.offset);
|
||||
if (offset) {
|
||||
parts.push(
|
||||
this._i18n.localize(
|
||||
`ui.panel.config.automation.editor.row_options.offset_${offsetType}`,
|
||||
{ offset: offsetDuration }
|
||||
`ui.panel.config.automation.editor.row_options.offset_${offset.negative ? "before" : "after"}`,
|
||||
{ offset: offset.text }
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -88,7 +92,7 @@ export class HaAutomationRowOptions extends LitElement {
|
||||
parts.push(
|
||||
this._i18n.localize(
|
||||
"ui.panel.config.automation.editor.row_options.timeout",
|
||||
{ duration: timeoutDuration }
|
||||
{ duration: timeoutDuration.text }
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -98,7 +102,9 @@ export class HaAutomationRowOptions extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
private _duration(value: unknown): string | undefined {
|
||||
private _duration(
|
||||
value: unknown
|
||||
): { negative: boolean; text: string } | undefined {
|
||||
if (value === undefined || hasTemplate(value)) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -113,7 +119,9 @@ export class HaAutomationRowOptions extends LitElement {
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return formatDurationNarrow(this._i18n.locale, duration);
|
||||
const normalized = normalizeDuration(duration, SUMMARY_UNITS);
|
||||
const text = formatDurationNarrow(this._i18n.locale, normalized.duration);
|
||||
return text ? { negative: normalized.negative, text } : undefined;
|
||||
}
|
||||
|
||||
static styles = rowSummaryStyles;
|
||||
|
||||
@@ -5,10 +5,7 @@ import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import type { CalendarTrigger } from "../../../../../data/automation";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import type { TriggerElement } from "../ha-automation-trigger-row";
|
||||
import type { HaDurationData } from "../../../../../components/ha-duration-input";
|
||||
import "../../../../../components/ha-form/ha-form";
|
||||
import { createDurationData } from "../../../../../common/datetime/create_duration_data";
|
||||
import { durationDataToSeconds } from "../../../../../common/datetime/duration_to_seconds";
|
||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||
import type { SchemaUnion } from "../../../../../components/ha-form/types";
|
||||
|
||||
@@ -47,25 +44,10 @@ export class HaCalendarTrigger extends LitElement implements TriggerElement {
|
||||
],
|
||||
],
|
||||
},
|
||||
{ name: "offset", required: true, selector: { duration: {} } },
|
||||
{
|
||||
name: "offset_type",
|
||||
type: "select",
|
||||
name: "offset",
|
||||
required: true,
|
||||
options: [
|
||||
[
|
||||
"before",
|
||||
localize(
|
||||
"ui.panel.config.automation.editor.triggers.type.calendar.before"
|
||||
),
|
||||
],
|
||||
[
|
||||
"after",
|
||||
localize(
|
||||
"ui.panel.config.automation.editor.triggers.type.calendar.after"
|
||||
),
|
||||
],
|
||||
],
|
||||
selector: { duration: { enable_day: true, mode: "offset" } },
|
||||
},
|
||||
] as const
|
||||
);
|
||||
@@ -81,34 +63,10 @@ export class HaCalendarTrigger extends LitElement implements TriggerElement {
|
||||
|
||||
protected render() {
|
||||
const schema = this._schema(this.hass.localize);
|
||||
// Convert from string representation to ha form duration representation
|
||||
const trigger_offset = this.trigger.offset;
|
||||
// Copy, `createDurationData` returns the input object as-is for dict values.
|
||||
const duration: HaDurationData = { ...createDurationData(trigger_offset)! };
|
||||
let offset_type = "after";
|
||||
if (durationDataToSeconds(duration) < 0) {
|
||||
// A negative offset negates the whole period, and the sign is shown by
|
||||
// the separate before/after select instead.
|
||||
if (duration.days) {
|
||||
duration.days = Math.abs(duration.days);
|
||||
}
|
||||
duration.hours = Math.abs(duration.hours ?? 0);
|
||||
duration.minutes = Math.abs(duration.minutes ?? 0);
|
||||
duration.seconds = Math.abs(duration.seconds ?? 0);
|
||||
if (duration.milliseconds) {
|
||||
duration.milliseconds = Math.abs(duration.milliseconds);
|
||||
}
|
||||
offset_type = "before";
|
||||
}
|
||||
const data = {
|
||||
...this.trigger,
|
||||
offset: duration,
|
||||
offset_type: offset_type,
|
||||
};
|
||||
return html`
|
||||
<ha-form
|
||||
.schema=${schema}
|
||||
.data=${data}
|
||||
.data=${this.trigger}
|
||||
.hass=${this.hass}
|
||||
.disabled=${this.disabled}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
@@ -119,18 +77,7 @@ export class HaCalendarTrigger extends LitElement implements TriggerElement {
|
||||
|
||||
private _valueChanged(ev: CustomEvent): void {
|
||||
ev.stopPropagation();
|
||||
// Convert back to duration string representation
|
||||
const duration = ev.detail.value.offset;
|
||||
const offsetType = ev.detail.value.offset_type === "before" ? "-" : "";
|
||||
const h = (duration.days ?? 0) * 24 + (duration.hours ?? 0);
|
||||
const m = duration.minutes ?? 0;
|
||||
const s = (duration.seconds ?? 0) + (duration.milliseconds ?? 0) / 1000;
|
||||
const newTrigger = {
|
||||
...ev.detail.value,
|
||||
offset: `${offsetType}${h}:${m}:${s}`,
|
||||
};
|
||||
delete newTrigger.offset_type;
|
||||
fireEvent(this, "value-changed", { value: newTrigger });
|
||||
fireEvent(this, "value-changed", { value: ev.detail.value });
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (
|
||||
@@ -143,6 +90,10 @@ export class HaCalendarTrigger extends LitElement implements TriggerElement {
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.automation.editor.triggers.type.calendar.event"
|
||||
);
|
||||
case "offset":
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.automation.editor.triggers.type.calendar.offset"
|
||||
);
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
@@ -39,7 +39,7 @@ export class HaSunTrigger extends LitElement implements TriggerElement {
|
||||
],
|
||||
],
|
||||
},
|
||||
{ name: "offset", selector: { text: {} } },
|
||||
{ name: "offset", selector: { duration: { mode: "offset" } } },
|
||||
] as const
|
||||
);
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { customElement, property, queryAll, state } from "lit/decorators";
|
||||
import { until } from "lit/directives/until";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { QueryParamsController } from "../../../common/controllers/query-params-controller";
|
||||
import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
|
||||
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
|
||||
import {
|
||||
@@ -26,6 +27,10 @@ import {
|
||||
} from "../../../common/integrations/protocolIntegrationPicked";
|
||||
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import {
|
||||
integrationQueryParamConfig,
|
||||
integrationSearchFromUrl,
|
||||
} from "../../../common/url/integrations-query-params";
|
||||
import { nextRender } from "../../../common/util/render-status";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-dropdown";
|
||||
@@ -176,6 +181,15 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
@queryAll("ha-config-entry-row")
|
||||
private _configEntryRows!: NodeListOf<HaConfigEntryRow>;
|
||||
|
||||
private _queryParams = new QueryParamsController(
|
||||
this,
|
||||
integrationQueryParamConfig,
|
||||
{
|
||||
enabled: () => true,
|
||||
navigated: () => this._applyUrl(),
|
||||
}
|
||||
);
|
||||
|
||||
private _handleHashChange = () => {
|
||||
this._searchParms = new URLSearchParams(
|
||||
__DEMO__ ? window.location.search : window.location.hash.substring(1)
|
||||
@@ -237,6 +251,10 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
this._fetchDiagnostics();
|
||||
this._fetchEntitySources();
|
||||
}
|
||||
|
||||
if (!this.hasUpdated) {
|
||||
this._applyUrl();
|
||||
}
|
||||
if (
|
||||
changedProperties.has("configEntries") ||
|
||||
changedProperties.has("_extraConfigEntries")
|
||||
@@ -720,11 +738,12 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
</div>
|
||||
|
||||
${
|
||||
normalData.length + attentionData.length > 0
|
||||
normalData.length + attentionData.length > 0 || this._filter
|
||||
? html`<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
@blur=${this._flushUrl}
|
||||
></ha-input-search>`
|
||||
: nothing
|
||||
}
|
||||
@@ -1256,6 +1275,32 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
|
||||
private _handleSearchChange(ev: HASSDomTargetEvent<HaInputSearch>) {
|
||||
this._filter = ev.target.value ?? "";
|
||||
|
||||
this._writeUrl(true);
|
||||
}
|
||||
|
||||
private _flushUrl() {
|
||||
this._queryParams.flush();
|
||||
}
|
||||
|
||||
private _writeUrl(debounced = false) {
|
||||
this._queryParams.write({ search: this._filter }, debounced);
|
||||
}
|
||||
|
||||
private _applyUrl() {
|
||||
const searchParams = this._queryParams.searchParams();
|
||||
|
||||
if (!searchParams) {
|
||||
return;
|
||||
}
|
||||
|
||||
const search = integrationSearchFromUrl(searchParams);
|
||||
|
||||
if (search !== undefined) {
|
||||
this._filter = search;
|
||||
}
|
||||
|
||||
this._writeUrl();
|
||||
}
|
||||
|
||||
private async _handleEnableDebugLogging() {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { classMap } from "lit/directives/class-map";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consume } from "../../../common/decorators/consume";
|
||||
import { QueryParamsController } from "../../../common/controllers/query-params-controller";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { deepActiveElement } from "../../../common/dom/deep-active-element";
|
||||
import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
|
||||
@@ -16,12 +17,13 @@ import {
|
||||
PROTOCOL_INTEGRATIONS,
|
||||
protocolIntegrationPicked,
|
||||
} from "../../../common/integrations/protocolIntegrationPicked";
|
||||
import {
|
||||
getHistoryState,
|
||||
navigate,
|
||||
updateHistoryState,
|
||||
} from "../../../common/navigate";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
|
||||
import {
|
||||
integrationsLinkFromUrl,
|
||||
integrationsQueryParamConfig,
|
||||
integrationsQueryParams,
|
||||
} from "../../../common/url/integrations-query-params";
|
||||
import { extractSearchParam } from "../../../common/url/search-params";
|
||||
import { nextRender } from "../../../common/util/render-status";
|
||||
import "../../../components/ha-button";
|
||||
@@ -169,7 +171,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
||||
window.location.hash.substring(1)
|
||||
);
|
||||
|
||||
@state() private _filter: string = getHistoryState()?.filter || "";
|
||||
@state() private _filter = "";
|
||||
|
||||
@state() private _logInfos?: Record<string, IntegrationLogInfo>;
|
||||
|
||||
@@ -185,6 +187,17 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
||||
|
||||
private _childReadyRegistered = false;
|
||||
|
||||
// The page can first be opened on /add, which sends itself on to the page
|
||||
private _queryParams = new QueryParamsController(
|
||||
this,
|
||||
integrationsQueryParamConfig,
|
||||
{
|
||||
enabled: () => true,
|
||||
navigated: () => this._applyUrl(),
|
||||
path: "/config/integrations/dashboard",
|
||||
}
|
||||
);
|
||||
|
||||
@consume({ context: childPanelReadyContext, subscribe: true })
|
||||
private _registerChildPanelReady?: RegisterChildPanelReady;
|
||||
|
||||
@@ -398,6 +411,14 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
||||
}
|
||||
);
|
||||
|
||||
protected willUpdate(changed: PropertyValues<this>) {
|
||||
super.willUpdate(changed);
|
||||
|
||||
if (!this.hasUpdated) {
|
||||
this._applyUrl();
|
||||
}
|
||||
}
|
||||
|
||||
protected firstUpdated(changed: PropertyValues<this>) {
|
||||
super.firstUpdated(changed);
|
||||
this._fetchManifests();
|
||||
@@ -556,6 +577,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
@blur=${this._flushUrl}
|
||||
.placeholder=${this.hass.localize(
|
||||
"ui.panel.config.integrations.search"
|
||||
)}
|
||||
@@ -577,6 +599,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
@blur=${this._flushUrl}
|
||||
.placeholder=${this.hass.localize(
|
||||
"ui.panel.config.integrations.search"
|
||||
)}
|
||||
@@ -886,6 +909,8 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
||||
}
|
||||
|
||||
private _createFlow() {
|
||||
// Keep the search typed so far on the entry the dialog's Back returns to
|
||||
this._queryParams.flush();
|
||||
showAddIntegrationDialog(this, {
|
||||
initialFilter: this._filter,
|
||||
navigateToResult: true,
|
||||
@@ -897,6 +922,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
||||
switch (action) {
|
||||
case "show-ignored":
|
||||
this._showIgnored = !this._showIgnored;
|
||||
this._writeUrl();
|
||||
break;
|
||||
case "show-disabled":
|
||||
this._toggleShowDisabled();
|
||||
@@ -906,11 +932,46 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
||||
|
||||
private _toggleShowDisabled() {
|
||||
this._showDisabled = !this._showDisabled;
|
||||
this._writeUrl();
|
||||
}
|
||||
|
||||
private _handleSearchChange(ev: HASSDomTargetEvent<HaInputSearch>) {
|
||||
this._filter = ev.target.value ?? "";
|
||||
updateHistoryState({ filter: this._filter });
|
||||
|
||||
this._writeUrl(true);
|
||||
}
|
||||
|
||||
private _flushUrl() {
|
||||
this._queryParams.flush();
|
||||
}
|
||||
|
||||
private _writeUrl(debounced = false) {
|
||||
this._queryParams.write(
|
||||
integrationsQueryParams({
|
||||
search: this._filter,
|
||||
showIgnored: this._showIgnored,
|
||||
showDisabled: this._showDisabled,
|
||||
}),
|
||||
debounced
|
||||
);
|
||||
}
|
||||
|
||||
private _applyUrl() {
|
||||
const searchParams = this._queryParams.searchParams();
|
||||
|
||||
if (!searchParams) {
|
||||
return;
|
||||
}
|
||||
|
||||
const link = integrationsLinkFromUrl(searchParams);
|
||||
|
||||
if (link) {
|
||||
this._filter = link.search;
|
||||
this._showIgnored = link.showIgnored;
|
||||
this._showDisabled = link.showDisabled;
|
||||
}
|
||||
|
||||
this._writeUrl();
|
||||
}
|
||||
|
||||
private async _highlightEntry() {
|
||||
@@ -946,7 +1007,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
||||
const brand = extractSearchParam("brand");
|
||||
const domain = extractSearchParam("domain");
|
||||
// Navigating closes open dialogs, so open them once it has finished
|
||||
await navigate("/config/integrations/dashboard/", { replace: true });
|
||||
await navigate("/config/integrations/dashboard", { replace: true });
|
||||
|
||||
if (brand) {
|
||||
showAddIntegrationDialog(this, {
|
||||
|
||||
@@ -60,6 +60,7 @@ class HaConfigIntegrations extends SubscribeMixin(HassRouterPage) {
|
||||
},
|
||||
integration: {
|
||||
tag: "ha-config-integration-page",
|
||||
itemId: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -201,7 +202,12 @@ class HaConfigIntegrations extends SubscribeMixin(HassRouterPage) {
|
||||
if (urlParams.has("domain")) {
|
||||
const domain = urlParams.get("domain");
|
||||
pageEl.domain = domain;
|
||||
navigate(`/config/integrations/integration/${domain}`);
|
||||
urlParams.delete("domain");
|
||||
const search = urlParams.toString();
|
||||
navigate(
|
||||
`/config/integrations/integration/${domain}${search ? `?${search}` : ""}`,
|
||||
{ replace: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { computeDeviceName } from "../../../../../common/entity/compute_device_name";
|
||||
import { getDeviceArea } from "../../../../../common/entity/context/get_device_context";
|
||||
import type {
|
||||
NetworkData,
|
||||
NetworkLink,
|
||||
NetworkNode,
|
||||
} from "../../../../../components/chart/ha-network-graph";
|
||||
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
|
||||
import type {
|
||||
MatterNetworkTopology,
|
||||
MatterNetworkTopologyNode,
|
||||
@@ -18,6 +20,8 @@ const CATEGORY_END_DEVICE = 3;
|
||||
const CATEGORY_WIFI_AP = 4;
|
||||
const CATEGORY_OFFLINE = 5;
|
||||
const CATEGORY_UNKNOWN = 6;
|
||||
const CATEGORY_BRIDGE = 7;
|
||||
const CATEGORY_BRIDGED = 8;
|
||||
|
||||
const ROUTER_ROLES = new Set(["leader", "router", "reed"]);
|
||||
|
||||
@@ -25,6 +29,41 @@ const ROUTER_ROLES = new Set(["leader", "router", "reed"]);
|
||||
export const HOME_ASSISTANT_NODE_ID = "ha";
|
||||
const HOME_ASSISTANT_LABEL = "Home Assistant";
|
||||
|
||||
// bridged devices are not Matter nodes, so they are synthesized from the
|
||||
// device registry under an id that cannot collide with a topology node
|
||||
const BRIDGED_DEVICE_NODE_PREFIX = "bridged_";
|
||||
|
||||
export const getBridgedDeviceId = (nodeId: string): string | undefined =>
|
||||
nodeId.startsWith(BRIDGED_DEVICE_NODE_PREFIX)
|
||||
? nodeId.slice(BRIDGED_DEVICE_NODE_PREFIX.length)
|
||||
: undefined;
|
||||
|
||||
// a bridge exposes the devices behind it as endpoints of its own node, so
|
||||
// the topology never lists them; core registers each as a device whose
|
||||
// via_device is the bridge
|
||||
export const getBridgedDevices = (
|
||||
bridgeDeviceId: string,
|
||||
devices: HomeAssistant["devices"]
|
||||
): DeviceRegistryEntry[] =>
|
||||
Object.values(devices).filter(
|
||||
(device) => device.via_device_id === bridgeDeviceId && !device.disabled_by
|
||||
);
|
||||
|
||||
// ha-network-graph labels a node beside its symbol only, so the bridged
|
||||
// device count is drawn into the symbol itself
|
||||
const bridgeSymbol = (
|
||||
color: string,
|
||||
textColor: string,
|
||||
count?: number
|
||||
): string => {
|
||||
const text =
|
||||
count === undefined
|
||||
? ""
|
||||
: `<text x="16" y="16" dy="0.35em" text-anchor="middle" font-family="sans-serif" font-weight="bold" font-size="${count > 99 ? 10 : 13}" fill="${textColor}">${count}</text>`;
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><circle cx="16" cy="16" r="16" fill="${color}" fill-opacity="0.35"/><circle cx="16" cy="16" r="12" fill="${color}"/>${text}</svg>`;
|
||||
return `image://data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
};
|
||||
|
||||
// 0 is never returned: a falsy link value re-enables the direction arrow
|
||||
// in ha-network-graph
|
||||
export const strengthToScale = (
|
||||
@@ -80,6 +119,9 @@ export const getTopologyNodeCategory = (
|
||||
if (node.available === false) {
|
||||
return CATEGORY_OFFLINE;
|
||||
}
|
||||
if (node.is_bridge) {
|
||||
return CATEGORY_BRIDGE;
|
||||
}
|
||||
return node.role && ROUTER_ROLES.has(node.role)
|
||||
? CATEGORY_ROUTER
|
||||
: CATEGORY_END_DEVICE;
|
||||
@@ -128,7 +170,10 @@ const isHub = (category: number): boolean =>
|
||||
export function createMatterNetworkChartData(
|
||||
topology: MatterNetworkTopology,
|
||||
hass: HomeAssistant,
|
||||
element: Element
|
||||
element: Element,
|
||||
// a bridge can expose many devices, so they stay behind their bridge until
|
||||
// it is expanded
|
||||
expandedBridges: ReadonlySet<string> = new Set()
|
||||
): NetworkData {
|
||||
const style = getComputedStyle(element);
|
||||
|
||||
@@ -141,7 +186,13 @@ export function createMatterNetworkChartData(
|
||||
style.getPropertyValue(networkToColorVar("wifi")),
|
||||
style.getPropertyValue("--error-color"),
|
||||
style.getPropertyValue("--disabled-color"),
|
||||
];
|
||||
// a bridge is an end device that aggregates others behind it
|
||||
style.getPropertyValue("--teal-color"),
|
||||
style.getPropertyValue("--blue-grey-color"),
|
||||
].map((color) => color.trim());
|
||||
const textPrimaryColor = style
|
||||
.getPropertyValue("--text-primary-color")
|
||||
.trim();
|
||||
const categories = [
|
||||
{
|
||||
name: HOME_ASSISTANT_LABEL,
|
||||
@@ -180,6 +231,18 @@ export function createMatterNetworkChartData(
|
||||
symbol: "circle",
|
||||
itemStyle: { color: categoryColors[CATEGORY_UNKNOWN] },
|
||||
},
|
||||
{
|
||||
name: hass.localize("ui.panel.config.matter.node_type.bridge"),
|
||||
symbol: bridgeSymbol(categoryColors[CATEGORY_BRIDGE], textPrimaryColor),
|
||||
itemStyle: { color: categoryColors[CATEGORY_BRIDGE] },
|
||||
},
|
||||
{
|
||||
name: hass.localize(
|
||||
"ui.panel.config.matter.visualization.bridged_device"
|
||||
),
|
||||
symbol: "circle",
|
||||
itemStyle: { color: categoryColors[CATEGORY_BRIDGED] },
|
||||
},
|
||||
];
|
||||
|
||||
const threadNetworks = new Set(
|
||||
@@ -201,6 +264,7 @@ export function createMatterNetworkChartData(
|
||||
},
|
||||
];
|
||||
const nodeCategories = new Map<string, number>();
|
||||
const bridgedDevices = new Map<string, DeviceRegistryEntry[]>();
|
||||
topology.nodes.forEach((node) => {
|
||||
const category = getTopologyNodeCategory(node);
|
||||
nodeCategories.set(node.id, category);
|
||||
@@ -225,6 +289,31 @@ export function createMatterNetworkChartData(
|
||||
if ((multiNetwork || !area) && networkLabel && networkLabel !== name) {
|
||||
contextParts.push(networkLabel);
|
||||
}
|
||||
if (node.is_bridge) {
|
||||
// without a device the bridged devices are unknown, not zero
|
||||
const bridged = node.ha_device_id
|
||||
? getBridgedDevices(node.ha_device_id, hass.devices)
|
||||
: undefined;
|
||||
if (bridged && expandedBridges.has(node.id)) {
|
||||
bridgedDevices.set(node.id, bridged);
|
||||
}
|
||||
nodes.push({
|
||||
id: node.id,
|
||||
name,
|
||||
context: contextParts.join(" • ") || undefined,
|
||||
category,
|
||||
value: 2,
|
||||
symbol: bridgeSymbol(
|
||||
categoryColors[category],
|
||||
textPrimaryColor,
|
||||
bridged?.length
|
||||
),
|
||||
symbolSize: 30,
|
||||
itemStyle: { color: categoryColors[category] },
|
||||
polarDistance: 0.8,
|
||||
});
|
||||
return;
|
||||
}
|
||||
nodes.push({
|
||||
id: node.id,
|
||||
name,
|
||||
@@ -366,5 +455,39 @@ export function createMatterNetworkChartData(
|
||||
}
|
||||
});
|
||||
|
||||
bridgedDevices.forEach((devices, bridgeId) => {
|
||||
devices.forEach((device) => {
|
||||
const id = `${BRIDGED_DEVICE_NODE_PREFIX}${device.id}`;
|
||||
const area = getDeviceArea(device, hass.areas, hass.devices);
|
||||
nodes.push({
|
||||
id,
|
||||
name: computeDeviceName(device) || id,
|
||||
context: area?.name,
|
||||
category: CATEGORY_BRIDGED,
|
||||
value: 1,
|
||||
symbol: "circle",
|
||||
symbolSize: 15,
|
||||
itemStyle: { color: categoryColors[CATEGORY_BRIDGED] },
|
||||
polarDistance: 0.9,
|
||||
});
|
||||
// not a radio link, so no strength and no arrow; it stays in the force
|
||||
// layout so the devices gather around their bridge
|
||||
links.push({
|
||||
source: bridgeId,
|
||||
target: id,
|
||||
value: 0,
|
||||
symbol: "none",
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: categoryColors[CATEGORY_BRIDGED],
|
||||
type:
|
||||
nodeCategories.get(bridgeId) === CATEGORY_OFFLINE
|
||||
? "dashed"
|
||||
: "solid",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return { nodes, links, categories };
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { relativeTime } from "../../../../../common/datetime/relative_time";
|
||||
import type { HASSDomTargetEvent } from "../../../../../common/dom/fire_event";
|
||||
import { computeDeviceName } from "../../../../../common/entity/compute_device_name";
|
||||
import { getDeviceArea } from "../../../../../common/entity/context/get_device_context";
|
||||
import { navigate } from "../../../../../common/navigate";
|
||||
import type { LocalizeKeys } from "../../../../../common/translations/localize";
|
||||
@@ -20,6 +21,7 @@ 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 { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
|
||||
import type {
|
||||
MatterNetworkTopology,
|
||||
MatterNetworkTopologyConnection,
|
||||
@@ -34,6 +36,8 @@ import "../../../../../layouts/hass-subpage";
|
||||
import type { HomeAssistant, Route } from "../../../../../types";
|
||||
import {
|
||||
createMatterNetworkChartData,
|
||||
getBridgedDeviceId,
|
||||
getBridgedDevices,
|
||||
getTopologyNodeName,
|
||||
HOME_ASSISTANT_NODE_ID,
|
||||
} from "./matter-network-data";
|
||||
@@ -60,6 +64,9 @@ export class MatterNetworkVisualization extends LitElement {
|
||||
|
||||
@state() private _searchFilter = "";
|
||||
|
||||
// topology node ids of the bridges whose bridged devices are shown
|
||||
@state() private _expandedBridges: ReadonlySet<string> = new Set();
|
||||
|
||||
private _unsub?: Promise<UnsubscribeFunc>;
|
||||
|
||||
private _throttledUpdateTopology = throttle(
|
||||
@@ -159,7 +166,8 @@ export class MatterNetworkVisualization extends LitElement {
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.themes,
|
||||
this.hass.language
|
||||
this.hass.language,
|
||||
this._expandedBridges
|
||||
)}
|
||||
.searchableAttributes=${this._getSearchableAttributes}
|
||||
.tooltipFormatter=${this._tooltipFormatter}
|
||||
@@ -215,14 +223,21 @@ export class MatterNetworkVisualization extends LitElement {
|
||||
// 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)
|
||||
_language: HomeAssistant["language"],
|
||||
expandedBridges: ReadonlySet<string>
|
||||
) =>
|
||||
createMatterNetworkChartData(topology, this.hass, this, expandedBridges)
|
||||
);
|
||||
|
||||
private _getTopologyNode(id: string): MatterNetworkTopologyNode | undefined {
|
||||
return this._topology?.nodes.find((node) => node.id === id);
|
||||
}
|
||||
|
||||
private _getBridgedDevice(id: string): DeviceRegistryEntry | undefined {
|
||||
const deviceId = getBridgedDeviceId(id);
|
||||
return deviceId ? this.hass.devices[deviceId] : undefined;
|
||||
}
|
||||
|
||||
private _getConnection(
|
||||
source: string,
|
||||
target: string
|
||||
@@ -235,11 +250,35 @@ export class MatterNetworkVisualization extends LitElement {
|
||||
}
|
||||
|
||||
private _getNodeName(id: string): string {
|
||||
const bridgedDevice = this._getBridgedDevice(id);
|
||||
if (bridgedDevice) {
|
||||
return computeDeviceName(bridgedDevice) || id;
|
||||
}
|
||||
const node = this._getTopologyNode(id);
|
||||
return node ? getTopologyNodeName(node, this.hass) : id;
|
||||
}
|
||||
|
||||
private _getDeviceSearchableAttributes(
|
||||
device: DeviceRegistryEntry | undefined
|
||||
): string[] {
|
||||
const attributes: string[] = [];
|
||||
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 _getSearchableAttributes = (nodeId: string): string[] => {
|
||||
const bridgedDevice = this._getBridgedDevice(nodeId);
|
||||
if (bridgedDevice) {
|
||||
return this._getDeviceSearchableAttributes(bridgedDevice);
|
||||
}
|
||||
const node = this._getTopologyNode(nodeId);
|
||||
if (!node) {
|
||||
return [];
|
||||
@@ -266,15 +305,17 @@ export class MatterNetworkVisualization extends LitElement {
|
||||
const device = node.ha_device_id
|
||||
? this.hass.devices[node.ha_device_id]
|
||||
: undefined;
|
||||
if (device?.manufacturer) {
|
||||
attributes.push(device.manufacturer);
|
||||
attributes.push(...this._getDeviceSearchableAttributes(device));
|
||||
if (node.is_bridge && node.ha_device_id) {
|
||||
getBridgedDevices(node.ha_device_id, this.hass.devices).forEach(
|
||||
(bridged) => {
|
||||
const name = computeDeviceName(bridged);
|
||||
if (name) {
|
||||
attributes.push(name);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
if (device?.model) {
|
||||
attributes.push(device.model);
|
||||
}
|
||||
device?.connections.forEach((connection) => {
|
||||
attributes.push(connection[1]);
|
||||
});
|
||||
return attributes;
|
||||
};
|
||||
|
||||
@@ -304,6 +345,18 @@ export class MatterNetworkVisualization extends LitElement {
|
||||
const { dataType, data } = params as CallbackDataParams;
|
||||
if (dataType === "edge") {
|
||||
const { source, target } = data as { source: string; target: string };
|
||||
if (this._getBridgedDevice(target)) {
|
||||
return html`<b
|
||||
>${this._getNodeName(source)} ↔ ${this._getNodeName(target)}</b
|
||||
><br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.network"
|
||||
)}:</b
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.bridged"
|
||||
)}`;
|
||||
}
|
||||
const conn = this._getConnection(source, target);
|
||||
if (!conn) {
|
||||
return nothing;
|
||||
@@ -357,6 +410,10 @@ export class MatterNetworkVisualization extends LitElement {
|
||||
if (id === HOME_ASSISTANT_NODE_ID) {
|
||||
return html`<b>Home Assistant</b>`;
|
||||
}
|
||||
const bridgedDevice = this._getBridgedDevice(id);
|
||||
if (bridgedDevice) {
|
||||
return this._bridgedDeviceTooltip(id, bridgedDevice);
|
||||
}
|
||||
const node = this._getTopologyNode(id);
|
||||
if (!node) {
|
||||
return nothing;
|
||||
@@ -388,6 +445,16 @@ export class MatterNetworkVisualization extends LitElement {
|
||||
node.network_name ? html` (${node.network_name})` : nothing
|
||||
}`
|
||||
);
|
||||
if (node.is_bridge && node.ha_device_id) {
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.bridged_devices"
|
||||
)}:</b
|
||||
>
|
||||
${getBridgedDevices(node.ha_device_id, this.hass.devices).length}`
|
||||
);
|
||||
}
|
||||
if (node.role) {
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
@@ -452,20 +519,101 @@ export class MatterNetworkVisualization extends LitElement {
|
||||
${relativeTime(new Date(node.last_seen), this.hass.locale)}`
|
||||
);
|
||||
}
|
||||
if (
|
||||
node.is_bridge &&
|
||||
node.ha_device_id &&
|
||||
getBridgedDevices(node.ha_device_id, this.hass.devices).length
|
||||
) {
|
||||
lines.push(
|
||||
html`<br /><i
|
||||
>${this.hass.localize(
|
||||
this._expandedBridges.has(id)
|
||||
? "ui.panel.config.matter.visualization.hide_bridged_devices"
|
||||
: "ui.panel.config.matter.visualization.show_bridged_devices"
|
||||
)}</i
|
||||
>`
|
||||
);
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
private _bridgedDeviceTooltip(
|
||||
id: string,
|
||||
device: DeviceRegistryEntry
|
||||
): TemplateResult {
|
||||
const bridge = device.via_device_id
|
||||
? this.hass.devices[device.via_device_id]
|
||||
: undefined;
|
||||
const area = getDeviceArea(device, this.hass.areas, this.hass.devices);
|
||||
const lines: TemplateResult[] = [];
|
||||
if (bridge) {
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.bridged_by"
|
||||
)}:</b
|
||||
>
|
||||
${computeDeviceName(bridge) || bridge.id}`
|
||||
);
|
||||
}
|
||||
if (device.manufacturer) {
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.manufacturer"
|
||||
)}:</b
|
||||
>
|
||||
${device.manufacturer}`
|
||||
);
|
||||
}
|
||||
if (device.model) {
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.model"
|
||||
)}:</b
|
||||
>
|
||||
${device.model}`
|
||||
);
|
||||
}
|
||||
if (area) {
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.area"
|
||||
)}:</b
|
||||
>
|
||||
${area.name}`
|
||||
);
|
||||
}
|
||||
return html`<b>${this._getNodeName(id)}</b>${lines}`;
|
||||
}
|
||||
|
||||
private _handleChartClick(e: CustomEvent): void {
|
||||
if (e.detail.dataType !== "node") {
|
||||
return;
|
||||
}
|
||||
const { id } = e.detail.data;
|
||||
const node = this._getTopologyNode(id);
|
||||
// the label has a pointer cursor, the draggable symbol a move cursor
|
||||
if (e.detail.event.target.cursor === "pointer") {
|
||||
const deviceId = getBridgedDeviceId(id) ?? node?.ha_device_id;
|
||||
if (deviceId) {
|
||||
navigate(`/config/devices/device/${deviceId}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (node?.is_bridge) {
|
||||
this._toggleBridge(id);
|
||||
}
|
||||
}
|
||||
|
||||
private _toggleBridge(id: string): void {
|
||||
const expanded = new Set(this._expandedBridges);
|
||||
if (!expanded.delete(id)) {
|
||||
expanded.add(id);
|
||||
}
|
||||
this._expandedBridges = expanded;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -830,13 +830,17 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
|
||||
// the filters below only expose the selected options (as filter.value);
|
||||
// applying the filter must be done here
|
||||
} else if (isFilterUsed(key, filter, "ha-filter-categories")) {
|
||||
// category filter only allows a single selected option
|
||||
filteredEntityIds = filteredEntityIds.filter(
|
||||
(entityId) =>
|
||||
filter.value![0] ===
|
||||
this._entityReg.find((reg) => reg.entity_id === entityId)
|
||||
?.categories.scene
|
||||
);
|
||||
filteredEntityIds = filteredEntityIds.filter((entityId) => {
|
||||
const category = this._entityReg.find(
|
||||
(reg) => reg.entity_id === entityId
|
||||
)?.categories.scene;
|
||||
|
||||
return (
|
||||
category !== undefined &&
|
||||
Array.isArray(filter.value) &&
|
||||
filter.value.includes(category)
|
||||
);
|
||||
});
|
||||
} else if (isFilterUsed(key, filter, "ha-filter-labels")) {
|
||||
filteredEntityIds = filteredEntityIds.filter((entityId) =>
|
||||
this._entityReg
|
||||
|
||||
@@ -783,13 +783,17 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
|
||||
// the filters below only expose the selected options (as filter.value);
|
||||
// applying the filter must be done here
|
||||
} else if (isFilterUsed(key, filter, "ha-filter-categories")) {
|
||||
// category filter only allows a single selected option
|
||||
filteredEntityIds = filteredEntityIds.filter(
|
||||
(entityId) =>
|
||||
filter.value![0] ===
|
||||
this._entityReg.find((reg) => reg.entity_id === entityId)
|
||||
?.categories.script
|
||||
);
|
||||
filteredEntityIds = filteredEntityIds.filter((entityId) => {
|
||||
const category = this._entityReg.find(
|
||||
(reg) => reg.entity_id === entityId
|
||||
)?.categories.script;
|
||||
|
||||
return (
|
||||
category !== undefined &&
|
||||
Array.isArray(filter.value) &&
|
||||
filter.value.includes(category)
|
||||
);
|
||||
});
|
||||
} else if (isFilterUsed(key, filter, "ha-filter-labels")) {
|
||||
filteredEntityIds = filteredEntityIds.filter((entityId) =>
|
||||
this._entityReg
|
||||
|
||||
@@ -1,26 +1,38 @@
|
||||
import { mdiClose, mdiDotsVertical, mdiFileMoveOutline } from "@mdi/js";
|
||||
import {
|
||||
mdiCellphone,
|
||||
mdiClose,
|
||||
mdiDotsVertical,
|
||||
mdiFileMoveOutline,
|
||||
mdiMonitor,
|
||||
} from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { cache } from "lit/directives/cache";
|
||||
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
|
||||
import { storage } from "../../../../common/decorators/storage";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { stopPropagation } from "../../../../common/dom/stop_propagation";
|
||||
import { withViewTransition } from "../../../../common/util/view-transition";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-dialog-header";
|
||||
import "../../../../components/ha-dialog-footer";
|
||||
import "../../../../components/ha-dialog";
|
||||
import "../../../../components/ha-dropdown";
|
||||
import "../../../../components/ha-dropdown-item";
|
||||
import "../../../../components/ha-icon-button";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/ha-tooltip";
|
||||
import "../../../../components/ha-tab-group";
|
||||
import "../../../../components/ha-tab-group-tab";
|
||||
import "../../../../components/ha-yaml-editor";
|
||||
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
|
||||
import type { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/section";
|
||||
import { isStrategySection } from "../../../../data/lovelace/config/section";
|
||||
import {
|
||||
DEFAULT_SECTION_COLUMN_SPAN,
|
||||
isStrategySection,
|
||||
} from "../../../../data/lovelace/config/section";
|
||||
import type { LovelaceStrategyConfig } from "../../../../data/lovelace/config/strategy";
|
||||
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
|
||||
import { saveConfig } from "../../../../data/lovelace/config/types";
|
||||
@@ -32,6 +44,7 @@ import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider
|
||||
import {
|
||||
haStyleDialog,
|
||||
haStyleDialogFixedTop,
|
||||
haStyleScrollbar,
|
||||
} from "../../../../resources/styles";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
|
||||
import { cleanLegacyStrategyConfig } from "../../strategies/legacy-strategy";
|
||||
@@ -45,6 +58,8 @@ import {
|
||||
setAtPath,
|
||||
} from "../lovelace-path";
|
||||
import { showSelectViewDialog } from "../select-view/show-select-view-dialog";
|
||||
import "../../sections/hui-section";
|
||||
import "../../sections/hui-section-background";
|
||||
import "./hui-section-settings-editor";
|
||||
import "./hui-section-visibility-editor";
|
||||
import "./hui-section-strategy-element-editor";
|
||||
@@ -53,11 +68,20 @@ import type { ConfigChangedEvent } from "../hui-element-editor";
|
||||
import type { EditSectionDialogParams } from "./show-edit-section-dialog";
|
||||
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
|
||||
import { getViewType } from "../../views/get-view-type";
|
||||
import { SECTIONS_VIEW_LAYOUT } from "../../views/const";
|
||||
import { DEFAULT_MAX_COLUMNS, SECTIONS_VIEW_LAYOUT } from "../../views/const";
|
||||
import { LOVELACE_EDIT_TOAST_ID } from "../../types";
|
||||
|
||||
const TABS = ["tab-strategy", "tab-appearance", "tab-visibility"] as const;
|
||||
|
||||
type PreviewMode = "desktop" | "mobile";
|
||||
|
||||
const PREVIEW_MODES: { mode: PreviewMode; iconPath: string }[] = [
|
||||
{ mode: "mobile", iconPath: mdiCellphone },
|
||||
{ mode: "desktop", iconPath: mdiMonitor },
|
||||
];
|
||||
|
||||
const TWO_COLUMNS_MIN_WIDTH = 2 * 320 + 3 * 32;
|
||||
|
||||
@customElement("hui-dialog-edit-section")
|
||||
export class HuiDialogEditSection
|
||||
extends DirtyStateProviderMixin<LovelaceSectionRawConfig>()(LitElement)
|
||||
@@ -67,6 +91,8 @@ export class HuiDialogEditSection
|
||||
|
||||
@property({ attribute: false }) public lovelace?: Lovelace;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public large = false;
|
||||
|
||||
@state() private _params?: EditSectionDialogParams;
|
||||
|
||||
@state() private _config?: LovelaceSectionRawConfig;
|
||||
@@ -83,6 +109,15 @@ export class HuiDialogEditSection
|
||||
|
||||
@state() private _yamlError = false;
|
||||
|
||||
@storage({
|
||||
key: "dashboardSectionPreviewMode",
|
||||
state: true,
|
||||
subscribe: false,
|
||||
})
|
||||
private _storedPreviewMode?: PreviewMode;
|
||||
|
||||
@state() private _defaultPreviewMode: PreviewMode = "mobile";
|
||||
|
||||
@query("ha-yaml-editor") private _editor?: HaYamlEditor;
|
||||
|
||||
@query("hui-section-strategy-element-editor")
|
||||
@@ -118,6 +153,12 @@ export class HuiDialogEditSection
|
||||
viewPath
|
||||
);
|
||||
this._initDirtyTracking({ type: "deep" }, this._config);
|
||||
this._defaultPreviewMode =
|
||||
window.innerWidth >= TWO_COLUMNS_MIN_WIDTH ? "desktop" : "mobile";
|
||||
}
|
||||
|
||||
private get _previewMode(): PreviewMode {
|
||||
return this._storedPreviewMode ?? this._defaultPreviewMode;
|
||||
}
|
||||
|
||||
public closeDialog() {
|
||||
@@ -127,6 +168,7 @@ export class HuiDialogEditSection
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._params = undefined;
|
||||
this.large = false;
|
||||
this._yamlMode = false;
|
||||
this._config = undefined;
|
||||
this._currTab = TABS[0];
|
||||
@@ -140,19 +182,23 @@ export class HuiDialogEditSection
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const heading = this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.edit_section.header"
|
||||
);
|
||||
|
||||
const strategy = isStrategySection(this._config)
|
||||
? cleanLegacyStrategyConfig(this._config.strategy)
|
||||
: undefined;
|
||||
const heading = this._computeHeading(strategy);
|
||||
const tabs = strategy ? TABS : TABS.filter((tab) => tab !== "tab-strategy");
|
||||
const currentTab =
|
||||
!strategy && this._currTab === "tab-strategy"
|
||||
? "tab-appearance"
|
||||
: this._currTab;
|
||||
|
||||
const columnSpan = Math.min(
|
||||
this._config.column_span ?? DEFAULT_SECTION_COLUMN_SPAN,
|
||||
this._viewConfig?.max_columns || DEFAULT_MAX_COLUMNS
|
||||
);
|
||||
const singleColumn = columnSpan === 1;
|
||||
const previewColumnSpan = this._previewMode === "desktop" ? columnSpan : 1;
|
||||
|
||||
let content: TemplateResult<1> | typeof nothing = nothing;
|
||||
|
||||
if (this._yamlMode) {
|
||||
@@ -202,65 +248,151 @@ export class HuiDialogEditSection
|
||||
return html`
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
.width=${this.large ? "full" : "large"}
|
||||
.preventScrimClose=${this.isDirtyState}
|
||||
@keydown=${this._ignoreKeydown}
|
||||
@closed=${this._dialogClosed}
|
||||
class=${classMap({
|
||||
"yaml-mode": this._yamlMode,
|
||||
})}
|
||||
>
|
||||
<ha-dialog-header show-border slot="header">
|
||||
<ha-icon-button
|
||||
slot="headerNavigationIcon"
|
||||
@click=${this._cancel}
|
||||
.label=${this.hass.localize("ui.common.close")}
|
||||
.path=${mdiClose}
|
||||
></ha-icon-button>
|
||||
<span
|
||||
slot="headerTitle"
|
||||
class="title-enlargeable"
|
||||
@click=${this._enlarge}
|
||||
>${heading}</span
|
||||
>
|
||||
<ha-dropdown
|
||||
slot="headerActionItems"
|
||||
placement="bottom-end"
|
||||
@closed=${stopPropagation}
|
||||
@wa-select=${this._handleAction}
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="navigationIcon"
|
||||
@click=${this._cancel}
|
||||
.label=${this.hass.localize("ui.common.close")}
|
||||
.path=${mdiClose}
|
||||
slot="trigger"
|
||||
.label=${this.hass!.localize("ui.common.menu")}
|
||||
.path=${mdiDotsVertical}
|
||||
></ha-icon-button>
|
||||
<span slot="title">${heading}</span>
|
||||
<ha-dropdown
|
||||
slot="actionItems"
|
||||
placement="bottom-end"
|
||||
@closed=${stopPropagation}
|
||||
@wa-select=${this._handleAction}
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
.label=${this.hass!.localize("ui.common.menu")}
|
||||
.path=${mdiDotsVertical}
|
||||
></ha-icon-button>
|
||||
<ha-dropdown-item value="move-to-view">
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiFileMoveOutline}
|
||||
></ha-svg-icon>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.edit_view.move_to_view"
|
||||
)}
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
${
|
||||
!this._yamlMode
|
||||
? html`
|
||||
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
|
||||
${tabs.map(
|
||||
(tab) => html`
|
||||
<ha-tab-group-tab
|
||||
slot="nav"
|
||||
.panel=${tab}
|
||||
.active=${currentTab === tab}
|
||||
>
|
||||
${this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.edit_section.${tab.replace("-", "_")}`
|
||||
)}
|
||||
</ha-tab-group-tab>
|
||||
`
|
||||
)}
|
||||
</ha-tab-group>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-dialog-header>
|
||||
${this._yamlMode ? content : cache(content)}
|
||||
<ha-dropdown-item value="move-to-view">
|
||||
<ha-svg-icon slot="icon" .path=${mdiFileMoveOutline}></ha-svg-icon>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.edit_view.move_to_view"
|
||||
)}
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
<div class="content">
|
||||
<div class="element-editor ha-scrollbar">
|
||||
${
|
||||
!this._yamlMode
|
||||
? html`
|
||||
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
|
||||
${tabs.map(
|
||||
(tab) => html`
|
||||
<ha-tab-group-tab
|
||||
slot="nav"
|
||||
.panel=${tab}
|
||||
.active=${currentTab === tab}
|
||||
>
|
||||
${this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.edit_section.${tab.replace("-", "_")}`
|
||||
)}
|
||||
</ha-tab-group-tab>
|
||||
`
|
||||
)}
|
||||
</ha-tab-group>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${this._yamlMode ? content : cache(content)}
|
||||
</div>
|
||||
<div class="element-preview ha-scrollbar">
|
||||
<div class="preview-header">
|
||||
<span id="preview-label">
|
||||
${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.edit_section.preview"
|
||||
)}
|
||||
</span>
|
||||
${
|
||||
singleColumn
|
||||
? nothing
|
||||
: html`
|
||||
<div
|
||||
class="preview-modes"
|
||||
role="group"
|
||||
aria-labelledby="preview-label"
|
||||
>
|
||||
${PREVIEW_MODES.map(
|
||||
({ mode, iconPath }) => html`
|
||||
<button
|
||||
id="preview-mode-${mode}"
|
||||
class=${classMap({
|
||||
"preview-mode": true,
|
||||
active: this._previewMode === mode,
|
||||
})}
|
||||
aria-pressed=${this._previewMode === mode}
|
||||
aria-label=${this.hass.localize(
|
||||
`ui.panel.lovelace.editor.edit_section.preview_${mode}`
|
||||
)}
|
||||
.mode=${mode}
|
||||
@click=${this._previewModeClicked}
|
||||
>
|
||||
<ha-svg-icon .path=${iconPath}></ha-svg-icon>
|
||||
</button>
|
||||
<ha-tooltip
|
||||
for="preview-mode-${mode}"
|
||||
placement="bottom"
|
||||
>
|
||||
${this.hass.localize(
|
||||
`ui.panel.lovelace.editor.edit_section.preview_${mode}`
|
||||
)}
|
||||
</ha-tooltip>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
<div class="preview-frame">
|
||||
<div
|
||||
class=${classMap({
|
||||
"section-preview": true,
|
||||
wide: previewColumnSpan > 1,
|
||||
})}
|
||||
style=${styleMap({
|
||||
"--column-span": previewColumnSpan,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
class=${classMap({
|
||||
"section-container": true,
|
||||
"has-background": this._config.background !== undefined,
|
||||
blur: this._yamlError || this._strategyError,
|
||||
})}
|
||||
>
|
||||
${
|
||||
this._config.background !== undefined
|
||||
? html`
|
||||
<hui-section-background
|
||||
.hass=${this.hass}
|
||||
.background=${this._config.background}
|
||||
.theme=${this._config.theme}
|
||||
></hui-section-background>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<hui-section
|
||||
.hass=${this.hass}
|
||||
.config=${this._config}
|
||||
preview
|
||||
></hui-section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
@@ -319,6 +451,37 @@ export class HuiDialogEditSection
|
||||
this._updateDirtyState(this._config);
|
||||
}
|
||||
|
||||
private _computeHeading(strategy?: LovelaceStrategyConfig) {
|
||||
const header = this.hass.localize(
|
||||
"ui.panel.lovelace.editor.edit_section.header"
|
||||
);
|
||||
if (!strategy) {
|
||||
return header;
|
||||
}
|
||||
const strategyName = this.hass.localize(
|
||||
`ui.panel.lovelace.editor.section_strategy.${strategy.type}.name`
|
||||
);
|
||||
if (!strategyName) {
|
||||
return header;
|
||||
}
|
||||
return this.hass.localize(
|
||||
"ui.panel.lovelace.editor.edit_section.typed_header",
|
||||
{ type: strategyName }
|
||||
);
|
||||
}
|
||||
|
||||
private _previewModeClicked(ev: Event) {
|
||||
this._storedPreviewMode = (
|
||||
ev.currentTarget as HTMLElement & { mode: PreviewMode }
|
||||
).mode;
|
||||
}
|
||||
|
||||
private _enlarge() {
|
||||
withViewTransition(() => {
|
||||
this.large = !this.large;
|
||||
});
|
||||
}
|
||||
|
||||
private _handleTabChanged(ev: CustomEvent): void {
|
||||
const newTab = ev.detail.name;
|
||||
if (newTab === this._currTab) {
|
||||
@@ -531,15 +694,153 @@ export class HuiDialogEditSection
|
||||
return [
|
||||
haStyleDialog,
|
||||
haStyleDialogFixedTop,
|
||||
haStyleScrollbar,
|
||||
css`
|
||||
:host {
|
||||
--code-mirror-max-height: calc(100vh - 209px);
|
||||
}
|
||||
|
||||
ha-dialog {
|
||||
--dialog-content-padding: var(--ha-space-6);
|
||||
--dialog-content-padding: var(--ha-space-2);
|
||||
}
|
||||
ha-dialog.yaml-mode {
|
||||
--dialog-content-padding: 0;
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
.gui-mode-button {
|
||||
margin-inline-end: auto;
|
||||
|
||||
@media all and (max-width: 450px), all and (max-height: 500px) {
|
||||
.content {
|
||||
gap: var(--ha-space-3);
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (min-width: 451px) and (min-height: 501px) {
|
||||
:host([large]) .content {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
.content .preview-frame {
|
||||
padding: 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.content .section-preview {
|
||||
--preview-column-width: 390px;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
max-width: var(--preview-column-width);
|
||||
}
|
||||
.content .section-preview.wide {
|
||||
max-width: none;
|
||||
width: calc(
|
||||
var(--column-span) * var(--preview-column-width) +
|
||||
(var(--column-span) - 1) *
|
||||
var(--ha-view-sections-column-gap, 32px)
|
||||
);
|
||||
}
|
||||
.preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--ha-space-2);
|
||||
min-height: 32px;
|
||||
padding: var(--ha-space-1) var(--ha-space-2);
|
||||
color: var(--secondary-text-color);
|
||||
font-size: var(--ha-font-size-s);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
.preview-modes {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
border-radius: var(--ha-border-radius-pill);
|
||||
background: var(--ha-color-fill-neutral-normal-resting);
|
||||
}
|
||||
.preview-mode {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--ha-border-radius-pill);
|
||||
background: none;
|
||||
color: var(--secondary-text-color);
|
||||
cursor: pointer;
|
||||
--mdc-icon-size: 18px;
|
||||
}
|
||||
.preview-mode:hover {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.preview-mode.active {
|
||||
color: var(--primary-text-color);
|
||||
background: var(--card-background-color);
|
||||
box-shadow: 0 1px 2px rgb(0 0 0 / 0.2);
|
||||
}
|
||||
.preview-mode:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.content .element-editor {
|
||||
padding-inline-start: var(--ha-space-1);
|
||||
padding-inline-end: var(--ha-space-2);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1000px) {
|
||||
.content {
|
||||
flex-direction: row;
|
||||
max-height: var(--code-mirror-max-height);
|
||||
}
|
||||
.content > .element-editor {
|
||||
padding-inline-end: var(--ha-space-4);
|
||||
max-width: 500px;
|
||||
}
|
||||
.content > .element-editor,
|
||||
.content > .element-preview {
|
||||
flex-basis: 0;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
height: auto;
|
||||
}
|
||||
.content .preview-frame {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
}
|
||||
.element-editor {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.blur {
|
||||
filter: blur(2px) grayscale(100%);
|
||||
}
|
||||
.element-preview {
|
||||
position: relative;
|
||||
height: max-content;
|
||||
background: var(--primary-background-color);
|
||||
padding: 4px;
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
}
|
||||
.section-container {
|
||||
position: relative;
|
||||
}
|
||||
.section-container.has-background {
|
||||
padding: var(--ha-space-2);
|
||||
border-radius: var(
|
||||
--ha-section-border-radius,
|
||||
var(--ha-border-radius-xl)
|
||||
);
|
||||
}
|
||||
.section-container hui-section {
|
||||
display: block;
|
||||
}
|
||||
ha-tab-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
ha-tab-group-tab {
|
||||
flex: 1;
|
||||
@@ -548,6 +849,14 @@ export class HuiDialogEditSection
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
.gui-mode-button {
|
||||
margin-right: auto;
|
||||
margin-inline-end: auto;
|
||||
margin-inline-start: initial;
|
||||
}
|
||||
.title-enlargeable {
|
||||
display: block;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -19,8 +19,71 @@ import {
|
||||
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
|
||||
import { DEFAULT_MAX_COLUMNS } from "../../views/const";
|
||||
|
||||
const COLUMN_SPAN_NAMES = ["single", "double", "triple"] as const;
|
||||
|
||||
const MAX_NAMED_COLUMN_SPAN = COLUMN_SPAN_NAMES.length + 1;
|
||||
|
||||
const computeColumnSpanLabel = (
|
||||
span: number,
|
||||
maxColumns: number,
|
||||
localize: LocalizeFunc
|
||||
) => {
|
||||
if (span >= maxColumns) {
|
||||
return localize(
|
||||
"ui.panel.lovelace.editor.edit_section.settings.column_span_options.full"
|
||||
);
|
||||
}
|
||||
return localize(
|
||||
`ui.panel.lovelace.editor.edit_section.settings.column_span_options.${COLUMN_SPAN_NAMES[span - 1]}`
|
||||
);
|
||||
};
|
||||
|
||||
const computeColumnSpanSchemas = (
|
||||
maxColumns: number,
|
||||
localize: LocalizeFunc
|
||||
) => {
|
||||
if (maxColumns <= 1) {
|
||||
return [];
|
||||
}
|
||||
if (maxColumns > MAX_NAMED_COLUMN_SPAN) {
|
||||
return [
|
||||
{
|
||||
name: "column_span",
|
||||
selector: {
|
||||
number: { min: 1, max: maxColumns, slider_ticks: true },
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
}
|
||||
const options = Array.from({ length: maxColumns }, (_, i) => {
|
||||
const span = i + 1;
|
||||
return {
|
||||
value: String(span),
|
||||
label: computeColumnSpanLabel(span, maxColumns, localize),
|
||||
image: {
|
||||
src: `/static/images/form/section_column_span_${span}_of_${maxColumns}.svg`,
|
||||
src_dark: `/static/images/form/section_column_span_${span}_of_${maxColumns}_dark.svg`,
|
||||
flip_rtl: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
return [
|
||||
{
|
||||
name: "column_span",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "box",
|
||||
box_max_columns:
|
||||
maxColumns === MAX_NAMED_COLUMN_SPAN ? 2 : maxColumns,
|
||||
options,
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
};
|
||||
|
||||
interface SettingsData {
|
||||
column_span?: number;
|
||||
column_span?: string | number;
|
||||
background_enabled?: boolean;
|
||||
background_color?: string;
|
||||
background_opacity?: number;
|
||||
@@ -36,20 +99,30 @@ export class HuiDialogEditSection extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public viewConfig!: LovelaceViewConfig;
|
||||
|
||||
private get _maxColumns() {
|
||||
return this.viewConfig.max_columns || DEFAULT_MAX_COLUMNS;
|
||||
}
|
||||
|
||||
private get _displayedColumnSpan() {
|
||||
return String(
|
||||
Math.min(
|
||||
this.config.column_span ?? DEFAULT_SECTION_COLUMN_SPAN,
|
||||
this._maxColumns
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private get _columnSpanFormValue() {
|
||||
if (this._maxColumns > MAX_NAMED_COLUMN_SPAN) {
|
||||
return Number(this._displayedColumnSpan);
|
||||
}
|
||||
return this._displayedColumnSpan;
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(maxColumns: number, localize: LocalizeFunc) =>
|
||||
(localize: LocalizeFunc, maxColumns: number) =>
|
||||
[
|
||||
{
|
||||
name: "column_span",
|
||||
default: DEFAULT_SECTION_COLUMN_SPAN,
|
||||
selector: {
|
||||
number: {
|
||||
min: 1,
|
||||
max: maxColumns,
|
||||
slider_ticks: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
...computeColumnSpanSchemas(maxColumns, localize),
|
||||
{
|
||||
name: "background_enabled",
|
||||
selector: { boolean: {} },
|
||||
@@ -109,17 +182,14 @@ export class HuiDialogEditSection extends LitElement {
|
||||
const backgroundEnabled = background !== undefined;
|
||||
|
||||
const data: SettingsData = {
|
||||
column_span: this.config.column_span,
|
||||
column_span: this._columnSpanFormValue,
|
||||
background_enabled: backgroundEnabled,
|
||||
background_color: background?.color,
|
||||
background_opacity: background?.opacity,
|
||||
theme: this.config.theme,
|
||||
};
|
||||
|
||||
const schema = this._schema(
|
||||
this.viewConfig.max_columns || DEFAULT_MAX_COLUMNS,
|
||||
this._localize
|
||||
);
|
||||
const schema = this._schema(this._localize, this._maxColumns);
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
@@ -154,10 +224,15 @@ export class HuiDialogEditSection extends LitElement {
|
||||
...this.config,
|
||||
};
|
||||
|
||||
if (newData.column_span) {
|
||||
newConfig.column_span = newData.column_span;
|
||||
} else {
|
||||
if (newData.column_span === undefined) {
|
||||
delete newConfig.column_span;
|
||||
} else if (String(newData.column_span) !== this._displayedColumnSpan) {
|
||||
const columnSpan = Number(newData.column_span);
|
||||
if (columnSpan === DEFAULT_SECTION_COLUMN_SPAN) {
|
||||
delete newConfig.column_span;
|
||||
} else {
|
||||
newConfig.column_span = columnSpan;
|
||||
}
|
||||
}
|
||||
|
||||
if (newData.background_enabled) {
|
||||
|
||||
@@ -279,7 +279,8 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
|
||||
}
|
||||
|
||||
private async _initializeConfig() {
|
||||
let sectionConfig = { ...this.config };
|
||||
const config = this.config;
|
||||
let sectionConfig = { ...config };
|
||||
let isStrategy = false;
|
||||
|
||||
if (isStrategySection(sectionConfig)) {
|
||||
@@ -288,6 +289,9 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
|
||||
sectionConfig,
|
||||
this.hass!
|
||||
);
|
||||
if (config !== this.config) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
sectionConfig = {
|
||||
|
||||
@@ -292,10 +292,15 @@ export const getMyRedirects = (): Redirects => ({
|
||||
redirect: "/config/integrations/integration",
|
||||
params: {
|
||||
domain: "string",
|
||||
search: "string?",
|
||||
},
|
||||
},
|
||||
integrations: {
|
||||
redirect: "/config/integrations",
|
||||
redirect: "/config/integrations/dashboard",
|
||||
params: {
|
||||
search: "string?",
|
||||
status: "string?",
|
||||
},
|
||||
},
|
||||
config_mqtt: {
|
||||
component: "mqtt",
|
||||
@@ -883,7 +888,13 @@ class HaPanelMy extends LitElement {
|
||||
this._redirect!.redirect === "/config/integrations/integration" &&
|
||||
params.domain === "hacs"
|
||||
) {
|
||||
return `${this._redirect!.redirect}?${createSearchParam({ domain: "marketplace" })}`;
|
||||
const marketplaceParams = new URLSearchParams(
|
||||
this._createRedirectParams()
|
||||
);
|
||||
|
||||
marketplaceParams.set("domain", "marketplace");
|
||||
|
||||
return `${this._redirect!.redirect}?${marketplaceParams}`;
|
||||
}
|
||||
|
||||
// Special case for supervisor_app/supervisor_addon: use path-based URL
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const mdiSendspin =
|
||||
"M9.44 13.751C9.323 13.868 9.133 13.86 9.022 13.737C8.038 12.647 7.5 11.251 7.5 9.772C7.5 8.192 8.115 6.704 9.234 5.587C11.469 3.352 15.061 3.282 17.382 5.376C17.505 5.487 17.513 5.678 17.396 5.795L16.855 6.336C16.748 6.443 16.576 6.45 16.466 6.35C15.2 5.221 13.408 4.862 11.668 5.514C11.659 5.517 11.648 5.522 11.639 5.526C9.863 6.336 8.835 7.976 8.835 9.772C8.835 10.907 9.246 11.98 9.995 12.819C10.095 12.932 10.088 13.102 9.981 13.208L9.44 13.749V13.751ZM10.911 11.836C10.185 10.956 9.96 9.748 10.388 8.572C10.393 8.561 10.398 8.549 10.402 8.538C11.363 6.386 13.899 5.962 15.482 7.266C15.613 7.374 15.621 7.572 15.502 7.69L14.958 8.234C14.856 8.336 14.695 8.344 14.58 8.256C13.832 7.683 12.753 7.739 12.069 8.423C11.709 8.783 11.51 9.263 11.51 9.774C11.51 10.198 11.648 10.602 11.902 10.933C11.99 11.049 11.984 11.21 11.881 11.313L11.338 11.856C11.218 11.976 11.021 11.967 10.913 11.836H10.911ZM3.939 19.252C3.822 19.369 3.631 19.361 3.52 19.238C2.506 18.115 2 16.694 2 15.275C2 13.855 2.506 12.435 3.52 11.312C3.631 11.189 3.822 11.181 3.939 11.298L4.203 11.562L4.48 11.839C4.586 11.945 4.594 12.116 4.494 12.228C3.722 13.094 3.336 14.185 3.336 15.276C3.336 16.367 3.722 17.459 4.494 18.325C4.594 18.437 4.585 18.607 4.48 18.714L4.203 18.99L3.939 19.255V19.252ZM5.834 17.357C5.716 17.475 5.518 17.469 5.412 17.34C4.931 16.76 4.67 16.038 4.67 15.275C4.67 14.512 4.931 13.79 5.412 13.21C5.518 13.08 5.716 13.074 5.834 13.193L6.378 13.737C6.481 13.84 6.488 14.001 6.399 14.117C6.146 14.448 6.007 14.852 6.007 15.276C6.007 15.7 6.146 16.105 6.399 16.436C6.488 16.551 6.481 16.712 6.378 16.816L5.834 17.36V17.357ZM20.48 12.69C20.369 12.813 20.178 12.821 20.061 12.704L19.797 12.439L19.52 12.163C19.414 12.056 19.406 11.886 19.506 11.774C20.278 10.907 20.664 9.816 20.664 8.725C20.664 7.634 20.278 6.543 19.506 5.677C19.406 5.564 19.415 5.394 19.52 5.288L19.797 5.011L20.061 4.747C20.178 4.63 20.369 4.637 20.48 4.76C21.494 5.883 22 7.303 22 8.724C22 10.145 21.494 11.564 20.48 12.687V12.69ZM18.168 10.809L17.623 10.265C17.52 10.161 17.514 10 17.602 9.885C17.856 9.553 17.994 9.149 17.994 8.725C17.994 8.301 17.856 7.897 17.602 7.566C17.514 7.45 17.52 7.289 17.623 7.186L18.168 6.642C18.286 6.523 18.484 6.529 18.59 6.658C19.07 7.239 19.332 7.961 19.332 8.724C19.332 9.487 19.07 10.208 18.59 10.789C18.484 10.918 18.286 10.924 18.168 10.806V10.809ZM10.583 20.144C9.162 20.144 7.743 19.638 6.62 18.624C6.497 18.513 6.489 18.322 6.606 18.205L7.147 17.664C7.253 17.557 7.424 17.55 7.536 17.65C9.334 19.252 12.101 19.192 13.824 17.468C15.548 15.744 15.608 12.977 14.007 11.179C13.906 11.067 13.916 10.897 14.02 10.79L14.561 10.249C14.678 10.132 14.87 10.14 14.981 10.263C17.075 12.584 17.005 16.177 14.77 18.413C13.616 19.566 12.1 20.144 10.585 20.144H10.583ZM10.583 17.472C9.849 17.472 9.117 17.226 8.519 16.734C8.39 16.627 8.383 16.43 8.501 16.311L9.045 15.767C9.147 15.665 9.308 15.658 9.423 15.746C10.171 16.319 11.25 16.264 11.934 15.579C12.618 14.895 12.674 13.816 12.101 13.068C12.013 12.953 12.021 12.792 12.122 12.69L12.666 12.146C12.785 12.027 12.983 12.033 13.089 12.164C14.14 13.438 14.071 15.333 12.879 16.524C12.247 17.156 11.414 17.474 10.583 17.474V17.472Z";
|
||||
@@ -3482,9 +3482,6 @@
|
||||
"unknown_error": "An unknown error happened when saving the category",
|
||||
"confirm_delete": "Delete category?",
|
||||
"confirm_delete_text": "This will delete the category and unassign everything that is currently assigned to it."
|
||||
},
|
||||
"filter": {
|
||||
"show_all": "Show all"
|
||||
}
|
||||
},
|
||||
"labels": {
|
||||
@@ -5759,9 +5756,7 @@
|
||||
"event": "[%key:ui::panel::config::automation::editor::triggers::type::homeassistant::event%]",
|
||||
"start": "Event start",
|
||||
"end": "Event end",
|
||||
"offset": "Offset (optional)",
|
||||
"before": "Before",
|
||||
"after": "After",
|
||||
"offset": "Offset",
|
||||
"description": {
|
||||
"picker": "Triggers when a calendar event starts or ends.",
|
||||
"full": "When{offsetChoice, select, \n before { it's {offset} before}\n after { it's {offset} after}\n other {}\n} a calendar event{eventChoice, select, \n start { starts}\n end { ends}\n other { starts or ends}\n}{hasCalendar, select, \n true { in {calendar}}\n other {}\n}"
|
||||
@@ -5890,7 +5885,7 @@
|
||||
"event": "[%key:ui::panel::config::automation::editor::triggers::type::homeassistant::event%]",
|
||||
"sunrise": "Sunrise",
|
||||
"sunset": "Sunset",
|
||||
"offset": "Offset in seconds or HH:MM:SS (optional)",
|
||||
"offset": "Offset",
|
||||
"description": {
|
||||
"picker": "Triggers when the sun sets or rises.",
|
||||
"sets": "When the sun sets{hasDuration, select, \n true { offset by {duration}} \n other {}\n }",
|
||||
@@ -7432,7 +7427,7 @@
|
||||
"setup_capability_audio_title": "Stream audio via Sendspin",
|
||||
"setup_capability_audio_short": "Synchronized multi-room audio",
|
||||
"setup_capability_audio_description": "Set up Sendspin and Music Assistant to turn this device into a synchronized music player. Music Assistant manages your audio library and uses the Sendspin streaming protocol to keep audio in sync across smart speakers in your home. Without this setup, Home Assistant can still play audio on this device, but not in sync with other speakers.",
|
||||
"setup_capability_connectivity_title": "Connect a Z-Wave adapter",
|
||||
"setup_capability_connectivity_title": "Connect a Z-Wave device",
|
||||
"setup_capability_connectivity_short": "Network connectivity for ZWA-2",
|
||||
"setup_capability_connectivity_description": "Plug a Connect ZWA-2 into the USB port to enable Z-Wave connectivity over Ethernet or Wi-Fi. Get your antenna in an optimal location for better device responsiveness.",
|
||||
"setup_capability_serial_title": "Control a device over serial",
|
||||
@@ -7465,7 +7460,7 @@
|
||||
"setup_status_completed": "Completed",
|
||||
"setup_zwave": "Set up Z-Wave",
|
||||
"setup_zwave_configured": "Configured",
|
||||
"setup_what_are_adapters": "What is this adapter?",
|
||||
"setup_what_are_adapters": "What is this device?",
|
||||
"setup_serial_configured": "Configured",
|
||||
"setup_serial_offline": "Offline",
|
||||
"setup_serial_port_ttl": "TTL",
|
||||
@@ -9121,6 +9116,12 @@
|
||||
"online": "Online",
|
||||
"unknown_device": "Unknown device",
|
||||
"unknown_devices": "Unknown devices",
|
||||
"bridged": "Bridged",
|
||||
"bridged_device": "Bridged device",
|
||||
"bridged_devices": "Bridged devices",
|
||||
"show_bridged_devices": "Select to show the bridged devices",
|
||||
"hide_bridged_devices": "Select to hide the bridged devices",
|
||||
"bridged_by": "[%key:ui::panel::config::integrations::config_entry::via%]",
|
||||
"node": "Node {node_id}",
|
||||
"node_id": "Node ID",
|
||||
"network": "Network",
|
||||
@@ -10429,8 +10430,17 @@
|
||||
"error_section": {
|
||||
"title": "Error"
|
||||
},
|
||||
"section_strategy": {
|
||||
"common-controls": {
|
||||
"name": "Common controls"
|
||||
}
|
||||
},
|
||||
"edit_section": {
|
||||
"header": "Edit section",
|
||||
"header": "Section configuration",
|
||||
"typed_header": "{type} section configuration",
|
||||
"preview": "Preview",
|
||||
"preview_desktop": "Desktop preview",
|
||||
"preview_mobile": "Mobile preview",
|
||||
"tab_strategy": "Configuration",
|
||||
"tab_visibility": "[%key:ui::panel::lovelace::editor::edit_view::tab_visibility%]",
|
||||
"tab_appearance": "Appearance",
|
||||
@@ -10438,7 +10448,13 @@
|
||||
"edit_yaml": "[%key:ui::panel::lovelace::editor::edit_view::edit_yaml%]",
|
||||
"settings": {
|
||||
"column_span": "Width",
|
||||
"column_span_helper": "Larger sections will be made smaller to fit the display. (for example, on mobile devices)",
|
||||
"column_span_helper": "Smaller screens may show it narrower, and phones stack sections one under the other.",
|
||||
"column_span_options": {
|
||||
"single": "Single",
|
||||
"double": "Double",
|
||||
"triple": "Triple",
|
||||
"full": "Full width"
|
||||
},
|
||||
"background": "Background options",
|
||||
"background_enabled": "Background",
|
||||
"background_enabled_helper": "Display a colored background behind the section",
|
||||
|
||||
@@ -65,9 +65,9 @@ describe("automations query params", () => {
|
||||
).toBe(automationsFilterQueryString({}));
|
||||
});
|
||||
|
||||
it("keeps one category, as the category filter picks one", () => {
|
||||
it("keeps every category", () => {
|
||||
expect(
|
||||
automationsLinkFromUrl("?category=a,b")?.filters["ha-filter-categories"]
|
||||
).toEqual(filter(["a"]));
|
||||
).toEqual(filter(["a", "b"]));
|
||||
});
|
||||
});
|
||||
@@ -61,9 +61,9 @@ describe("scenes query params", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps one category, as the category filter picks one", () => {
|
||||
it("keeps every category", () => {
|
||||
expect(
|
||||
scenesLinkFromUrl("?category=a,b")?.filters["ha-filter-categories"]
|
||||
).toEqual(filter(["a"]));
|
||||
).toEqual(filter(["a", "b"]));
|
||||
});
|
||||
});
|
||||
@@ -65,9 +65,9 @@ describe("scripts query params", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps one category, as the category filter picks one", () => {
|
||||
it("keeps every category", () => {
|
||||
expect(
|
||||
scriptsLinkFromUrl("?category=a,b")?.filters["ha-filter-categories"]
|
||||
).toEqual(filter(["a"]));
|
||||
).toEqual(filter(["a", "b"]));
|
||||
});
|
||||
});
|
||||
@@ -108,3 +108,83 @@ describe("normalizeAutomationConfig deprecated option reporting", () => {
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateAutomationConfig offset type", () => {
|
||||
const migrateTrigger = (trigger: Record<string, unknown>) =>
|
||||
(
|
||||
migrateAutomationConfig({ triggers: [trigger as any] }).triggers as any
|
||||
)[0];
|
||||
|
||||
it.each([
|
||||
["sun.sunrise", { hours: 1 }, { hours: -1 }],
|
||||
[
|
||||
"calendar.event_started",
|
||||
{ hours: 1, minutes: 30 },
|
||||
{ hours: -1, minutes: -30 },
|
||||
],
|
||||
["sun.sunset", { hours: -1 }, { hours: 1 }],
|
||||
[
|
||||
"sun.dawn",
|
||||
"00:30:00",
|
||||
{ hours: 0, minutes: -30, seconds: 0, milliseconds: 0 },
|
||||
],
|
||||
])(
|
||||
"folds a `before` offset type into the sign for %s",
|
||||
(trigger, offset, expected) => {
|
||||
expect(
|
||||
migrateTrigger({ trigger, options: { offset, offset_type: "before" } })
|
||||
).toEqual({ trigger, options: { offset: expected } });
|
||||
}
|
||||
);
|
||||
|
||||
it("drops an `after` offset type and keeps the offset", () => {
|
||||
expect(
|
||||
migrateTrigger({
|
||||
trigger: "calendar.event_ended",
|
||||
options: { offset: { hours: 1 }, offset_type: "after" },
|
||||
})
|
||||
).toEqual({
|
||||
trigger: "calendar.event_ended",
|
||||
options: { offset: { hours: 1 } },
|
||||
});
|
||||
});
|
||||
|
||||
it("drops the offset type when there is no offset", () => {
|
||||
expect(
|
||||
migrateTrigger({
|
||||
trigger: "sun.sunrise",
|
||||
options: { offset_type: "before" },
|
||||
})
|
||||
).toEqual({ trigger: "sun.sunrise", options: {} });
|
||||
});
|
||||
|
||||
it("leaves an offset it cannot read untouched", () => {
|
||||
const options = { offset: "{{ offset }}", offset_type: "before" };
|
||||
expect(
|
||||
migrateTrigger({ trigger: "sun.sunrise", options: { ...options } })
|
||||
).toEqual({ trigger: "sun.sunrise", options });
|
||||
});
|
||||
|
||||
it("leaves other integrations untouched", () => {
|
||||
const options = { offset: { hours: 1 }, offset_type: "before" };
|
||||
expect(
|
||||
migrateTrigger({ trigger: "custom.event", options: { ...options } })
|
||||
).toEqual({ trigger: "custom.event", options });
|
||||
});
|
||||
|
||||
it("does not flag the migration as deprecated", () => {
|
||||
const report: AutomationMigrationReport = { deprecated: false };
|
||||
migrateAutomationConfig(
|
||||
{
|
||||
triggers: [
|
||||
{
|
||||
trigger: "sun.sunrise",
|
||||
options: { offset: { hours: 1 }, offset_type: "before" },
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
report
|
||||
);
|
||||
expect(report.deprecated).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { IntlMessageFormat } from "intl-messageformat";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ForDict } from "../../src/data/automation";
|
||||
import { describeTrigger } from "../../src/data/automation_i18n";
|
||||
import {
|
||||
DateFormat,
|
||||
FirstWeekday,
|
||||
NumberFormat,
|
||||
TimeFormat,
|
||||
TimeZone,
|
||||
} from "../../src/data/translation";
|
||||
import en from "../../src/translations/en.json";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
|
||||
type TranslationNode = string | { [key: string]: TranslationNode };
|
||||
|
||||
const localize = (key: string, values?: Record<string, unknown>) => {
|
||||
const message = key
|
||||
.split(".")
|
||||
.reduce<TranslationNode | undefined>(
|
||||
(translations, part) =>
|
||||
typeof translations === "object" ? translations[part] : undefined,
|
||||
en as TranslationNode
|
||||
);
|
||||
return typeof message === "string"
|
||||
? (new IntlMessageFormat(message, "en").format(values) as string)
|
||||
: "";
|
||||
};
|
||||
|
||||
const hass = {
|
||||
localize,
|
||||
locale: {
|
||||
language: "en",
|
||||
number_format: NumberFormat.language,
|
||||
time_format: TimeFormat.twenty_four,
|
||||
date_format: DateFormat.language,
|
||||
first_weekday: FirstWeekday.language,
|
||||
time_zone: TimeZone.local,
|
||||
},
|
||||
config: { time_zone: "Etc/UTC" },
|
||||
states: {},
|
||||
} as unknown as HomeAssistant;
|
||||
|
||||
describe("legacy calendar trigger offset description", () => {
|
||||
it.each([
|
||||
[
|
||||
"-01:30:00",
|
||||
"When it's 1 hour, 30 minutes before a calendar event starts",
|
||||
],
|
||||
[
|
||||
{ hours: 1, minutes: -30 },
|
||||
"When it's 30 minutes after a calendar event starts",
|
||||
],
|
||||
[
|
||||
{ seconds: 1.5 },
|
||||
"When it's 1 second, 500 milliseconds after a calendar event starts",
|
||||
],
|
||||
[{ hours: 0, minutes: 0, seconds: 0 }, "When a calendar event starts"],
|
||||
])("describes %j", (offset, expected) => {
|
||||
expect(
|
||||
describeTrigger(
|
||||
{
|
||||
trigger: "calendar",
|
||||
event: "start",
|
||||
entity_id: "",
|
||||
offset: offset as string | ForDict,
|
||||
},
|
||||
hass,
|
||||
[]
|
||||
)
|
||||
).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy sun trigger offset description", () => {
|
||||
it.each([
|
||||
[{ minutes: -30 }, "When the sun sets offset by -30:00"],
|
||||
[{ hours: 1, minutes: -30 }, "When the sun sets offset by +30:00"],
|
||||
[{ hours: 0, minutes: 0, seconds: 0 }, "When the sun sets"],
|
||||
])("describes %j", (offset, expected) => {
|
||||
expect(
|
||||
describeTrigger({ trigger: "sun", event: "sunset", offset }, hass, [])
|
||||
).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
} from "../../../../../src/data/matter";
|
||||
import {
|
||||
createMatterNetworkChartData,
|
||||
getBridgedDeviceId,
|
||||
getTopologyNodeCategory,
|
||||
getTopologyNodeName,
|
||||
networkToColorVar,
|
||||
@@ -129,6 +130,17 @@ describe("getTopologyNodeCategory", () => {
|
||||
expect(
|
||||
getTopologyNodeCategory(node({ id: "unknown_1", kind: "thread_unknown" }))
|
||||
).toBe(6);
|
||||
expect(
|
||||
getTopologyNodeCategory(
|
||||
node({ id: "8", network_type: "ethernet", is_bridge: true })
|
||||
)
|
||||
).toBe(7);
|
||||
// an offline bridge reads as offline like any other node
|
||||
expect(
|
||||
getTopologyNodeCategory(
|
||||
node({ id: "9", is_bridge: true, available: false })
|
||||
)
|
||||
).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -283,7 +295,7 @@ describe("createMatterNetworkChartData", () => {
|
||||
element
|
||||
);
|
||||
|
||||
expect(data.categories).toHaveLength(7);
|
||||
expect(data.categories).toHaveLength(9);
|
||||
// Home Assistant root + the 3 topology nodes
|
||||
expect(data.nodes).toHaveLength(4);
|
||||
|
||||
@@ -799,4 +811,150 @@ describe("createMatterNetworkChartData", () => {
|
||||
expect(data.nodes.find((n) => n.id === "1")!.context).toBe("NetA");
|
||||
expect(data.nodes.find((n) => n.id === "2")!.context).toBe("NetB");
|
||||
});
|
||||
|
||||
it("hangs the devices behind a bridge off the bridge node", () => {
|
||||
const hass = mockHass(
|
||||
{
|
||||
bridge: { id: "bridge", name: "Gateway" },
|
||||
couch: {
|
||||
id: "couch",
|
||||
name: "Couch",
|
||||
via_device_id: "bridge",
|
||||
area_id: "living_room",
|
||||
},
|
||||
window: {
|
||||
id: "window",
|
||||
name: "Window",
|
||||
name_by_user: "Bedroom window",
|
||||
via_device_id: "bridge",
|
||||
},
|
||||
disabled: {
|
||||
id: "disabled",
|
||||
name: "Disabled",
|
||||
via_device_id: "bridge",
|
||||
disabled_by: "user",
|
||||
},
|
||||
other: { id: "other", name: "Other", via_device_id: "plug" },
|
||||
plug: { id: "plug", name: "Plug" },
|
||||
},
|
||||
{ living_room: { area_id: "living_room", name: "Living room" } }
|
||||
);
|
||||
const data = createMatterNetworkChartData(
|
||||
topology([
|
||||
node({
|
||||
id: "3",
|
||||
node_id: 3,
|
||||
network_type: "ethernet",
|
||||
is_bridge: true,
|
||||
ha_device_id: "bridge",
|
||||
}),
|
||||
// only a node flagged as a bridge exposes bridged devices
|
||||
node({
|
||||
id: "4",
|
||||
node_id: 4,
|
||||
network_type: "wifi",
|
||||
ha_device_id: "plug",
|
||||
}),
|
||||
]),
|
||||
hass,
|
||||
element,
|
||||
new Set(["3"])
|
||||
);
|
||||
|
||||
// the bridge is drawn as an aggregator carrying its bridged device count
|
||||
const bridge = data.nodes.find((n) => n.id === "3")!;
|
||||
expect(bridge.category).toBe(7);
|
||||
expect(bridge.symbol).toMatch(/^image:\/\/data:image\/svg\+xml/);
|
||||
expect(decodeURIComponent(bridge.symbol!)).toContain(">2</text>");
|
||||
expect(data.nodes.find((n) => n.id === "4")!.symbol).toBe("circle");
|
||||
|
||||
const bridged = data.nodes.filter((n) => n.category === 8);
|
||||
expect(bridged.map((n) => [n.id, n.name, n.context])).toEqual([
|
||||
["bridged_couch", "Couch", "Living room"],
|
||||
["bridged_window", "Bedroom window", undefined],
|
||||
]);
|
||||
expect(getBridgedDeviceId("bridged_couch")).toBe("couch");
|
||||
expect(getBridgedDeviceId("3")).toBeUndefined();
|
||||
expect(data.categories).toHaveLength(9);
|
||||
|
||||
const bridgedLinks = data.links.filter((l) => l.source === "3");
|
||||
expect(bridgedLinks.map((l) => l.target)).toEqual([
|
||||
"bridged_couch",
|
||||
"bridged_window",
|
||||
]);
|
||||
// not a directional radio link, and it pulls the device to its bridge
|
||||
expect(bridgedLinks[0].symbol).toBe("none");
|
||||
expect(bridgedLinks[0].lineStyle?.type).toBe("solid");
|
||||
expect(bridgedLinks[0].ignoreForceLayout).toBeFalsy();
|
||||
});
|
||||
|
||||
it("dashes the links to the devices behind an offline bridge", () => {
|
||||
const data = createMatterNetworkChartData(
|
||||
topology([
|
||||
node({
|
||||
id: "3",
|
||||
node_id: 3,
|
||||
network_type: "ethernet",
|
||||
is_bridge: true,
|
||||
available: false,
|
||||
ha_device_id: "bridge",
|
||||
}),
|
||||
]),
|
||||
mockHass({
|
||||
bridge: { id: "bridge", name: "Gateway" },
|
||||
couch: { id: "couch", name: "Couch", via_device_id: "bridge" },
|
||||
}),
|
||||
element,
|
||||
new Set(["3"])
|
||||
);
|
||||
|
||||
const link = data.links.find((l) => l.target === "bridged_couch")!;
|
||||
expect(link.source).toBe("3");
|
||||
expect(link.lineStyle?.type).toBe("dashed");
|
||||
});
|
||||
|
||||
it("keeps the devices behind a collapsed bridge out of the graph", () => {
|
||||
const data = createMatterNetworkChartData(
|
||||
topology([
|
||||
node({
|
||||
id: "3",
|
||||
node_id: 3,
|
||||
network_type: "ethernet",
|
||||
is_bridge: true,
|
||||
ha_device_id: "bridge",
|
||||
}),
|
||||
]),
|
||||
mockHass({
|
||||
bridge: { id: "bridge", name: "Gateway" },
|
||||
couch: { id: "couch", name: "Couch", via_device_id: "bridge" },
|
||||
}),
|
||||
element
|
||||
);
|
||||
|
||||
expect(data.nodes.filter((n) => n.category === 8)).toHaveLength(0);
|
||||
expect(data.links.filter((l) => l.source === "3")).toHaveLength(0);
|
||||
// the count still tells what the bridge would expand to
|
||||
const bridge = data.nodes.find((n) => n.id === "3")!;
|
||||
expect(decodeURIComponent(bridge.symbol!)).toContain(">1</text>");
|
||||
});
|
||||
|
||||
it("draws a bridge without a device without a count", () => {
|
||||
const data = createMatterNetworkChartData(
|
||||
topology([
|
||||
node({
|
||||
id: "3",
|
||||
node_id: 3,
|
||||
network_type: "ethernet",
|
||||
is_bridge: true,
|
||||
}),
|
||||
]),
|
||||
mockHass(),
|
||||
element
|
||||
);
|
||||
|
||||
const bridge = data.nodes.find((n) => n.id === "3")!;
|
||||
expect(bridge.category).toBe(7);
|
||||
expect(decodeURIComponent(bridge.symbol!)).not.toContain("<text");
|
||||
expect(data.nodes.filter((n) => n.category === 8)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||