Compare commits

..

2 Commits

Author SHA1 Message Date
Paul Bottein
22d29ef4f5 Use regular item for bottom padding in combobox 2026-01-05 11:24:57 +01:00
Paul Bottein
f65d41fea6 Add warning about running tsc with file arguments
When tsc receives file arguments, it ignores tsconfig.json and emits
.js files into src/, polluting the codebase. This documents the issue
and provides cleanup instructions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 10:51:00 +01:00
108 changed files with 1327 additions and 3141 deletions

View File

@@ -213,9 +213,7 @@ const createRspackConfig = ({
"lit/directives/join$": "lit/directives/join.js",
"lit/directives/repeat$": "lit/directives/repeat.js",
"lit/directives/live$": "lit/directives/live.js",
"lit/directives/keyed$": latestBuild
? "lit/directives/keyed.js"
: path.resolve(__dirname, "../src/common/lit/keyed-es5.ts"),
"lit/directives/keyed$": "lit/directives/keyed.js",
"lit/polyfill-support$": "lit/polyfill-support.js",
"@lit-labs/virtualizer/layouts/grid":
"@lit-labs/virtualizer/layouts/grid.js",

View File

@@ -34,18 +34,18 @@
"@codemirror/legacy-modes": "6.5.2",
"@codemirror/search": "6.5.11",
"@codemirror/state": "6.5.3",
"@codemirror/view": "6.39.9",
"@codemirror/view": "6.39.8",
"@date-fns/tz": "1.4.1",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.1.2",
"@formatjs/intl-displaynames": "7.1.2",
"@formatjs/intl-durationformat": "0.9.2",
"@formatjs/intl-getcanonicallocales": "3.1.2",
"@formatjs/intl-listformat": "8.1.2",
"@formatjs/intl-locale": "5.1.2",
"@formatjs/intl-numberformat": "9.1.2",
"@formatjs/intl-pluralrules": "6.1.2",
"@formatjs/intl-relativetimeformat": "12.1.2",
"@formatjs/intl-datetimeformat": "7.1.1",
"@formatjs/intl-displaynames": "7.1.1",
"@formatjs/intl-durationformat": "0.9.1",
"@formatjs/intl-getcanonicallocales": "3.1.1",
"@formatjs/intl-listformat": "8.1.1",
"@formatjs/intl-locale": "5.1.1",
"@formatjs/intl-numberformat": "9.1.1",
"@formatjs/intl-pluralrules": "6.1.1",
"@formatjs/intl-relativetimeformat": "12.1.1",
"@fullcalendar/core": "6.1.20",
"@fullcalendar/daygrid": "6.1.20",
"@fullcalendar/interaction": "6.1.20",
@@ -112,13 +112,13 @@
"hls.js": "1.6.15",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.2.2",
"intl-messageformat": "11.0.9",
"intl-messageformat": "11.0.8",
"js-yaml": "4.1.1",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
"lit": "3.3.2",
"lit-html": "3.3.2",
"lit": "3.3.1",
"lit-html": "3.3.1",
"luxon": "3.7.2",
"marked": "17.0.1",
"memoize-one": "6.0.0",
@@ -150,13 +150,13 @@
"@babel/helper-define-polyfill-provider": "0.6.5",
"@babel/plugin-transform-runtime": "7.28.5",
"@babel/preset-env": "7.28.5",
"@bundle-stats/plugin-webpack-filter": "4.21.8",
"@bundle-stats/plugin-webpack-filter": "4.21.7",
"@lokalise/node-api": "15.6.0",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.0.3",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.4.0",
"@rspack/core": "1.7.1",
"@rspack/core": "1.7.0",
"@rspack/dev-server": "1.1.5",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.25",
@@ -215,8 +215,8 @@
"terser-webpack-plugin": "5.3.16",
"ts-lit-plugin": "2.0.2",
"typescript": "5.9.3",
"typescript-eslint": "8.52.0",
"vite-tsconfig-paths": "6.0.4",
"typescript-eslint": "8.51.0",
"vite-tsconfig-paths": "6.0.3",
"vitest": "4.0.16",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
@@ -224,8 +224,8 @@
},
"resolutions": {
"@material/mwc-button@^0.25.3": "^0.27.0",
"lit": "3.3.2",
"lit-html": "3.3.2",
"lit": "3.3.1",
"lit-html": "3.3.1",
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.2",
"@fullcalendar/daygrid": "6.1.20",

View File

@@ -38,11 +38,13 @@ export class HaAuthFormString extends HaFormString {
}
</style>
<ha-auth-textfield
.type=${!this.isPassword
.type=${
!this.isPassword
? this.stringType
: this.unmaskedPassword
? "text"
: "password"}
: "password"
}
.label=${this.label}
.value=${this.data || ""}
.helper=${this.helper}
@@ -53,17 +55,18 @@ export class HaAuthFormString extends HaFormString {
.name=${this.schema.name}
.autocomplete=${this.schema.autocomplete}
?autofocus=${this.schema.autofocus}
.suffix=${this.isPassword
? // reserve some space for the icon.
html`<div style="width: 24px"></div>`
: this.schema.description?.suffix}
.validationMessage=${this.schema.required
? this.localize?.("ui.panel.page-authorize.form.error_required")
: undefined}
.suffix=${
this.isPassword
? // reserve some space for the icon.
html`<div style="width: 24px"></div>`
: this.schema.description?.suffix
}
.validationMessage=${this.schema.required ? this.localize?.("ui.panel.page-authorize.form.error_required") : undefined}
@input=${this._valueChanged}
@change=${this._valueChanged}
></ha-auth-textfield>
${this.renderIcon()}
></ha-auth-textfield>
${this.renderIcon()}
</ha-auth-textfield>
`;
}
}

View File

@@ -79,7 +79,7 @@ export const generateColorPalette = (
}
return steps.map((step) => {
const name = `ha-color-${label}-${step}`;
const name = `color-${label}-${step}`;
// Base color at 50%
if (step === 50) {

View File

@@ -93,8 +93,8 @@ export const calcDateRange = (
];
case "now-12m":
return [
calcDate(today, subMonths, hass.locale, hass.config, 12),
calcDate(today, subMonths, hass.locale, hass.config, 0),
calcDate(subMonths(today, 12), startOfMonth, hass.locale, hass.config),
calcDate(subMonths(today, 1), endOfMonth, hass.locale, hass.config),
];
case "now-1h":
return [

View File

@@ -1,53 +0,0 @@
/**
* ES5-compatible implementation of the keyed directive.
* Based on lit-html's keyed directive but written to avoid ES5 minification issues.
*
* This implementation avoids parameter destructuring in the update() method,
* which causes Terser with ecma: 5 to generate invalid references like `_k`.
*
* Used only for ES5 builds (legacy browsers). Modern builds use the original
* lit-html keyed directive.
*
* @see https://github.com/home-assistant/frontend/issues/28732
*/
// eslint-disable-next-line import/extensions
import { directive, Directive } from "lit-html/directive.js";
// eslint-disable-next-line import/extensions
import { setCommittedValue } from "lit-html/directive-helpers.js";
// eslint-disable-next-line lit/no-legacy-imports
import { nothing } from "lit-html";
// eslint-disable-next-line import/extensions
import type { Part } from "lit-html/directive.js";
class KeyedES5 extends Directive {
private _key: unknown = nothing;
render(k: unknown, v: unknown) {
this._key = k;
return v;
}
update(part: unknown, args: [unknown, unknown]) {
const k = args[0];
const v = args[1];
if (k !== this._key) {
// Clear the part before returning a value. The one-arg form of
// setCommittedValue sets the value to a sentinel which forces a
// commit the next render.
setCommittedValue(part as Part);
this._key = k;
}
return v;
}
}
/**
* Associates a renderable value with a unique key. When the key changes, the
* previous DOM is removed and disposed before rendering the next value, even
* if the value - such as a template - is the same.
*
* This is useful for forcing re-renders of stateful components, or working
* with code that expects new data to generate new HTML elements, such as some
* animation techniques.
*/
export const keyed = directive(KeyedES5);

View File

@@ -1,16 +1,6 @@
// From https://github.com/epoberezkin/fast-deep-equal
// MIT License - Copyright (c) 2017 Evgeny Poberezkin
interface DeepEqualOptions {
/** Compare Symbol properties in addition to string keys */
compareSymbols?: boolean;
}
export const deepEqual = (
a: any,
b: any,
options?: DeepEqualOptions
): boolean => {
export const deepEqual = (a: any, b: any): boolean => {
if (a === b) {
return true;
}
@@ -28,7 +18,7 @@ export const deepEqual = (
return false;
}
for (i = length; i-- !== 0; ) {
if (!deepEqual(a[i], b[i], options)) {
if (!deepEqual(a[i], b[i])) {
return false;
}
}
@@ -45,7 +35,7 @@ export const deepEqual = (
}
}
for (i of a.entries()) {
if (!deepEqual(i[1], b.get(i[0]), options)) {
if (!deepEqual(i[1], b.get(i[0]))) {
return false;
}
}
@@ -103,28 +93,11 @@ export const deepEqual = (
for (i = length; i-- !== 0; ) {
const key = keys[i];
if (!deepEqual(a[key], b[key], options)) {
if (!deepEqual(a[key], b[key])) {
return false;
}
}
// Compare Symbol properties if requested
if (options?.compareSymbols) {
const symbolsA = Object.getOwnPropertySymbols(a);
const symbolsB = Object.getOwnPropertySymbols(b);
if (symbolsA.length !== symbolsB.length) {
return false;
}
for (const sym of symbolsA) {
if (!Object.prototype.hasOwnProperty.call(b, sym)) {
return false;
}
if (!deepEqual(a[sym], b[sym], options)) {
return false;
}
}
}
return true;
}

View File

@@ -21,7 +21,6 @@ import { measureTextWidth } from "../../util/text";
import { fireEvent } from "../../common/dom/fire_event";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import { filterXSS } from "../../common/util/xss";
const safeParseFloat = (value) => {
const parsed = parseFloat(value);
@@ -185,7 +184,7 @@ export class StateHistoryChartLine extends LitElement {
}
if (param.seriesName) {
return `${param.marker} ${filterXSS(param.seriesName)}: ${value}`;
return `${param.marker} ${param.seriesName}: ${value}`;
}
return `${param.marker} ${value}`;
})

View File

@@ -1364,9 +1364,6 @@ export class HaDataTable extends LitElement {
.mdc-data-table__header-cell > * {
transition: var(--float-start) 0.2s ease;
}
.mdc-data-table__header-cell--numeric > span {
transition: none;
}
.mdc-data-table__header-cell ha-svg-icon {
top: -3px;
position: absolute;

View File

@@ -18,7 +18,6 @@ import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
export type HaDevicePickerDeviceFilterFunc = (
device: DeviceRegistryEntry
@@ -95,30 +94,7 @@ export class HaDevicePicker extends LitElement {
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
private _getDevicesMemoized = memoizeOne(
(
_devices: HomeAssistant["devices"],
configEntryLookup: Record<string, ConfigEntry>,
includeDomains?: string[],
excludeDomains?: string[],
includeDeviceClasses?: string[],
deviceFilter?: HaDevicePickerDeviceFilterFunc,
entityFilter?: HaEntityPickerEntityFilterFunc,
excludeDevices?: string[],
value?: string
) =>
getDevices(
this.hass,
configEntryLookup,
includeDomains,
excludeDomains,
includeDeviceClasses,
deviceFilter,
entityFilter,
excludeDevices,
value
)
);
private _getDevicesMemoized = memoizeOne(getDevices);
protected firstUpdated(_changedProperties: PropertyValues): void {
super.firstUpdated(_changedProperties);
@@ -134,7 +110,7 @@ export class HaDevicePicker extends LitElement {
private _getItems = () =>
this._getDevicesMemoized(
this.hass.devices,
this.hass,
this._configEntryLookup,
this.includeDomains,
this.excludeDomains,

View File

@@ -18,7 +18,10 @@ import "../ha-combo-box-item";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import "../ha-input-helper-text";
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
import {
NO_ITEMS_AVAILABLE_ID,
type PickerComboBoxItem,
} from "../ha-picker-combo-box";
import "../ha-sortable";
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
@@ -181,17 +184,18 @@ export class HaEntityNamePicker extends LitElement {
.disabled=${this.disabled}
.required=${this.required && !value.length}
.getItems=${this._getFilteredItems}
.getAdditionalItems=${this._getAdditionalItems}
.rowRenderer=${rowRenderer}
.searchFn=${this._searchFn}
.notFoundLabel=${this.hass.localize(
"ui.components.entity.entity-name-picker.no_match"
)}
.value=${this._getPickerValue()}
allow-custom-value
.customValueLabel=${this.hass.localize(
"ui.components.entity.entity-name-picker.custom_name"
)}
@value-changed=${this._pickerValueChanged}
.searchFn=${this._searchFn}
.searchLabel=${this.hass.localize(
"ui.components.entity.entity-name-picker.search"
)}
>
<div slot="field" class="container">
<ha-sortable
@@ -275,11 +279,6 @@ export class HaEntityNamePicker extends LitElement {
this._editIndex = idx;
await this.updateComplete;
await this._picker?.open();
const value = this._items[idx];
// Pre-fill the field value when editing a text item
if (value.type === "text" && value.text) {
this._picker?.setFieldValue(value.text);
}
}
private get _items(): EntityNameItem[] {
@@ -317,7 +316,10 @@ export class HaEntityNamePicker extends LitElement {
return undefined;
}
private _getFilteredItems = (): PickerComboBoxItem[] => {
private _getFilteredItems = (
searchString?: string,
_section?: string
): PickerComboBoxItem[] => {
const items = this._getItems(this.entityId);
const currentItem =
this._editIndex != null ? this._items[this._editIndex] : undefined;
@@ -334,27 +336,49 @@ export class HaEntityNamePicker extends LitElement {
);
// When editing an existing text item, include it in the base items
if (currentItem?.type === "text" && currentItem.text) {
if (currentItem?.type === "text" && currentItem.text && !searchString) {
filteredItems.push(this._customNameOption(currentItem.text));
}
return filteredItems;
};
private _searchFn = (
searchString: string,
filteredItems: PickerComboBoxItem[]
private _getAdditionalItems = (
searchString?: string
): PickerComboBoxItem[] => {
if (!searchString) {
return [];
}
const currentItem =
this._editIndex != null ? this._items[this._editIndex] : undefined;
const currentId =
currentItem?.type === "text" && currentItem.text
? this._customNameOption(currentItem.text).id
: undefined;
// Remove custom name option if search string is present to avoid duplicates
if (searchString && currentId) {
return filteredItems.filter((item) => item.id !== currentId);
// Don't add if it's the same as the current item being edited
if (
currentItem?.type === "text" &&
currentItem.text &&
currentItem.text === searchString
) {
return [];
}
// Always return custom name option when there's a search string
// This prevents "No matching items found" from showing
return [this._customNameOption(searchString)];
};
private _searchFn = (
search: string,
filteredItems: PickerComboBoxItem[],
_allItems: PickerComboBoxItem[]
): PickerComboBoxItem[] => {
// Remove NO_ITEMS_AVAILABLE_ID if we have additional items (custom name option)
// This prevents "No matching items found" from showing when custom values are allowed
const hasAdditionalItems = this._getAdditionalItems(search).length > 0;
if (hasAdditionalItems) {
return filteredItems.filter(
(item) => typeof item !== "string" || item !== NO_ITEMS_AVAILABLE_ID
);
}
return filteredItems;
};

View File

@@ -19,10 +19,7 @@ import "../ha-combo-box-item";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import "../ha-input-helper-text";
import {
NO_ITEMS_AVAILABLE_ID,
type PickerComboBoxItem,
} from "../ha-picker-combo-box";
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
import "../ha-sortable";
const HIDDEN_ATTRIBUTES = [
@@ -202,7 +199,11 @@ export class HaStateContentPicker extends LitElement {
.value=${this._getPickerValue()}
.getItems=${this._getFilteredItems}
.getAdditionalItems=${this._getAdditionalItems}
.searchFn=${this._searchFn}
.notFoundLabel=${this.hass.localize("ui.components.combo-box.no_match")}
allow-custom-value
.customValueLabel=${this.hass.localize(
"ui.components.entity.entity-state-content-picker.custom_state"
)}
@value-changed=${this._pickerValueChanged}
>
<div slot="field" class="container">
@@ -327,7 +328,7 @@ export class HaStateContentPicker extends LitElement {
(text: string): PickerComboBoxItem => ({
id: text,
primary: this.hass.localize(
"ui.components.entity.entity-state-content-picker.custom_attribute"
"ui.components.entity.entity-state-content-picker.custom_state"
),
secondary: `"${text}"`,
search_labels: {
@@ -339,7 +340,10 @@ export class HaStateContentPicker extends LitElement {
})
);
private _getFilteredItems = (): PickerComboBoxItem[] => {
private _getFilteredItems = (
searchString?: string,
_section?: string
): PickerComboBoxItem[] => {
const stateObj = this.entityId
? this.hass.states[this.entityId]
: undefined;
@@ -354,7 +358,11 @@ export class HaStateContentPicker extends LitElement {
);
// When editing an existing custom value, include it in the base items
if (currentValue && !items.find((item) => item.id === currentValue)) {
if (
currentValue &&
!items.find((item) => item.id === currentValue) &&
!searchString
) {
filteredItems.push(this._customValueOption(currentValue));
}
@@ -364,34 +372,33 @@ export class HaStateContentPicker extends LitElement {
private _getAdditionalItems = (
searchString?: string
): PickerComboBoxItem[] => {
if (!searchString) {
return [];
}
const currentValue =
this._editIndex != null ? this._value[this._editIndex] : undefined;
// Don't add if it's the same as the current item being edited
if (currentValue && currentValue === searchString) {
return [];
}
// Check if the search string matches an existing item
const stateObj = this.entityId
? this.hass.states[this.entityId]
: undefined;
const items = this._getItems(this.entityId, stateObj, this.allowName);
// If the search string does not match with the id of any of the items,
// offer to add it as a custom attribute
const existingItem = items.find((item) => item.id === searchString);
if (searchString && !existingItem) {
// Only return custom value option if it doesn't match an existing item
if (!existingItem) {
return [this._customValueOption(searchString)];
}
return [];
};
private _searchFn = (
search: string,
filteredItems: PickerComboBoxItem[],
_allItems: PickerComboBoxItem[]
): PickerComboBoxItem[] => {
if (!search) {
return filteredItems;
}
// Always exclude NO_ITEMS_AVAILABLE_ID (since custom values are allowed) and currentValue (the custom value being edited)
return filteredItems.filter((item) => item.id !== NO_ITEMS_AVAILABLE_ID);
};
private async _moveItem(ev: CustomEvent) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;

View File

@@ -7,7 +7,6 @@ import { getStates } from "../../common/entity/get_states";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-generic-picker";
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
import type { PickerValueRenderer } from "../ha-picker-field";
@customElement("ha-entity-state-picker")
export class HaEntityStatePicker extends LitElement {
@@ -109,12 +108,6 @@ export class HaEntityStatePicker extends LitElement {
this.extraOptions
);
private _valueRenderer: PickerValueRenderer = (value: string) => {
const items = this._getFilteredItems();
const item = items.find((option) => option.id === value);
return html`<span slot="headline">${item?.primary ?? value}</span>`;
};
protected render() {
if (!this.hass) {
return nothing;
@@ -132,7 +125,6 @@ export class HaEntityStatePicker extends LitElement {
.helper=${this.helper}
.value=${this.value}
.getItems=${this._getFilteredItems}
.valueRenderer=${this._valueRenderer}
.notFoundLabel=${this.hass.localize("ui.components.combo-box.no_match")}
.customValueLabel=${this.hass.localize(
"ui.components.entity.entity-state-picker.add_custom_state"

View File

@@ -143,19 +143,17 @@ export class HaEntityToggle extends LitElement {
// Optimistic update.
this._isOn = turnOn;
try {
await this.hass.callService(serviceDomain, service, {
entity_id: this.stateObj.entity_id,
});
} finally {
setTimeout(async () => {
// If after 2 seconds we have not received a state update
// reset the switch to it's original state.
if (this.stateObj === currentState) {
this._isOn = isOn(this.stateObj);
}
}, 2000);
}
await this.hass.callService(serviceDomain, service, {
entity_id: this.stateObj.entity_id,
});
setTimeout(async () => {
// If after 2 seconds we have not received a state update
// reset the switch to it's original state.
if (this.stateObj === currentState) {
this._isOn = isOn(this.stateObj);
}
}, 2000);
}
static styles = css`

View File

@@ -141,7 +141,6 @@ export class HaStatisticPicker extends LitElement {
private async _getStatisticIds() {
this.statisticIds = await getStatisticIds(this.hass, this.statisticTypes);
this._picker?.requestUpdate();
}
private _getItems = () =>
@@ -178,9 +177,9 @@ export class HaStatisticPicker extends LitElement {
entitiesOnly?: boolean,
excludeStatistics?: string[],
value?: string
): StatisticComboBoxItem[] | undefined => {
): StatisticComboBoxItem[] => {
if (!statisticIds) {
return undefined;
return [];
}
if (includeStatisticsUnitOfMeasurement) {

View File

@@ -174,14 +174,12 @@ export class HaAutomationRow extends LitElement {
}
::slotted([slot="header"]) {
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
margin: 0 var(--ha-space-3);
}
.icons {
display: flex;
align-items: center;
flex-shrink: 0;
}
:host([sort-selected]) .row {
outline: solid;

View File

@@ -51,10 +51,7 @@ export class HaCard extends LitElement {
font-weight: var(--ha-font-weight-normal);
}
:host
::slotted(
.card-content:not(:nth-child(1 of .card-content, .card-header))
),
:host ::slotted(.card-content:not(:first-child)),
slot:not(:first-child)::slotted(.card-content) {
padding-top: 0;
margin-top: calc(var(--ha-space-2) * -1);

View File

@@ -0,0 +1,24 @@
import type { PropertyValues } from "lit";
import { customElement, property } from "lit/decorators";
import { HaTextField } from "./ha-textfield";
@customElement("ha-combo-box-textfield")
export class HaComboBoxTextField extends HaTextField {
@property({ type: Boolean, attribute: "force-blank-value" })
public forceBlankValue = false;
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("value") || changedProps.has("forceBlankValue")) {
if (this.forceBlankValue && this.value) {
this.value = "";
}
}
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-combo-box-textfield": HaComboBoxTextField;
}
}

View File

@@ -1,11 +1,9 @@
import { mdiMinusThick, mdiPlusThick } from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import "./ha-base-time-input";
import type { TimeChangedEvent } from "./ha-base-time-input";
import "./ha-button-toggle-group";
export interface HaDurationData {
days?: number;
@@ -15,8 +13,6 @@ export interface HaDurationData {
milliseconds?: number;
}
const FIELDS = ["milliseconds", "seconds", "minutes", "hours", "days"];
@customElement("ha-duration-input")
class HaDurationInput extends LitElement {
@property({ attribute: false }) public data?: HaDurationData;
@@ -33,80 +29,41 @@ class HaDurationInput extends LitElement {
@property({ attribute: "enable-day", type: Boolean })
public enableDay = false;
@property({ attribute: "allow-negative", type: Boolean })
public allowNegative = false;
@property({ type: Boolean }) public disabled = false;
private _toggleNegative = false;
protected render(): TemplateResult {
return html`
<div class="row">
${this.allowNegative
? html`
<ha-button-toggle-group
size="small"
.buttons=${[
{ label: "+", iconPath: mdiPlusThick, value: "+" },
{ label: "-", iconPath: mdiMinusThick, value: "-" },
]}
.active=${this._negative ? "-" : "+"}
@value-changed=${this._negativeChanged}
></ha-button-toggle-group>
`
: nothing}
<ha-base-time-input
.label=${this.label}
.helper=${this.helper}
.required=${this.required}
.clearable=${!this.required && this.data !== undefined}
.autoValidate=${this.required}
.disabled=${this.disabled}
errorMessage="Required"
enable-second
.enableMillisecond=${this.enableMillisecond}
.enableDay=${this.enableDay}
format="24"
.days=${this._days}
.hours=${this._hours}
.minutes=${this._minutes}
.seconds=${this._seconds}
.milliseconds=${this._milliseconds}
@value-changed=${this._durationChanged}
no-hours-limit
day-label="dd"
hour-label="hh"
min-label="mm"
sec-label="ss"
ms-label="ms"
></ha-base-time-input>
</div>
<ha-base-time-input
.label=${this.label}
.helper=${this.helper}
.required=${this.required}
.clearable=${!this.required && this.data !== undefined}
.autoValidate=${this.required}
.disabled=${this.disabled}
errorMessage="Required"
enable-second
.enableMillisecond=${this.enableMillisecond}
.enableDay=${this.enableDay}
format="24"
.days=${this._days}
.hours=${this._hours}
.minutes=${this._minutes}
.seconds=${this._seconds}
.milliseconds=${this._milliseconds}
@value-changed=${this._durationChanged}
no-hours-limit
day-label="dd"
hour-label="hh"
min-label="mm"
sec-label="ss"
ms-label="ms"
></ha-base-time-input>
`;
}
private get _negative() {
return (
this._toggleNegative ||
(this.data?.days
? this.data.days < 0
: this.data?.hours
? this.data.hours < 0
: this.data?.minutes
? this.data.minutes < 0
: this.data?.seconds
? this.data.seconds < 0
: this.data?.milliseconds
? this.data.milliseconds < 0
: false)
);
}
private get _days() {
return this.data?.days
? this.allowNegative
? Math.abs(Number(this.data.days))
: Number(this.data.days)
? Number(this.data.days)
: this.required || this.data
? 0
: NaN;
@@ -114,9 +71,7 @@ class HaDurationInput extends LitElement {
private get _hours() {
return this.data?.hours
? this.allowNegative
? Math.abs(Number(this.data.hours))
: Number(this.data.hours)
? Number(this.data.hours)
: this.required || this.data
? 0
: NaN;
@@ -124,9 +79,7 @@ class HaDurationInput extends LitElement {
private get _minutes() {
return this.data?.minutes
? this.allowNegative
? Math.abs(Number(this.data.minutes))
: Number(this.data.minutes)
? Number(this.data.minutes)
: this.required || this.data
? 0
: NaN;
@@ -134,9 +87,7 @@ class HaDurationInput extends LitElement {
private get _seconds() {
return this.data?.seconds
? this.allowNegative
? Math.abs(Number(this.data.seconds))
: Number(this.data.seconds)
? Number(this.data.seconds)
: this.required || this.data
? 0
: NaN;
@@ -144,9 +95,7 @@ class HaDurationInput extends LitElement {
private get _milliseconds() {
return this.data?.milliseconds
? this.allowNegative
? Math.abs(Number(this.data.milliseconds))
: Number(this.data.milliseconds)
? Number(this.data.milliseconds)
: this.required || this.data
? 0
: NaN;
@@ -164,14 +113,6 @@ class HaDurationInput extends LitElement {
if ("days" in value) value.days ||= 0;
if ("milliseconds" in value) value.milliseconds ||= 0;
if (this.allowNegative) {
FIELDS.forEach((t) => {
if (value[t]) {
value[t] = Math.abs(value[t]);
}
});
}
if (!this.enableMillisecond && !value.milliseconds) {
// @ts-ignore
delete value.milliseconds;
@@ -194,47 +135,12 @@ class HaDurationInput extends LitElement {
value.days = (value.days ?? 0) + Math.floor(value.hours / 24);
value.hours %= 24;
}
if (this._negative) {
FIELDS.forEach((t) => {
if (value[t]) {
value[t] = -Math.abs(value[t]);
}
});
}
}
fireEvent(this, "value-changed", {
value,
});
}
private _negativeChanged(ev) {
ev.stopPropagation();
const negative = (ev.detail?.value || ev.target.value) === "-";
this._toggleNegative = negative;
const value = this.data;
if (value) {
FIELDS.forEach((t) => {
if (value[t]) {
value[t] = negative ? -Math.abs(value[t]) : Math.abs(value[t]);
}
});
fireEvent(this, "value-changed", {
value,
});
}
}
static styles = css`
.row {
display: flex;
align-items: center;
}
ha-button-toggle-group {
margin: var(--ha-space-2);
}
`;
}
declare global {

View File

@@ -1,198 +0,0 @@
import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { fireEvent } from "../common/dom/fire_event";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-icon";
import "./ha-icon-button";
import "./ha-label";
import "./ha-list";
import "./ha-list-item";
import "./voice-assistant-brand-icon";
import { voiceAssistants } from "../data/expose";
import "../panels/config/voice-assistants/expose/expose-assistant-icon";
@customElement("ha-filter-voice-assistants")
export class HaFilterVoiceAssistants extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
// the list of selected voiceAssistantIds
@property({ attribute: false }) public value: string[] = [];
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _voiceAssistantOptions: string[] = [];
@state() private _shouldRender = false;
protected render() {
return html`
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize(
"ui.panel.config.dashboard.voice_assistants.main"
)}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>
<ha-icon-button
.path=${mdiFilterVariantRemove}
@click=${this._clearFilter}
></ha-icon-button>`
: nothing}
</div>
${this._shouldRender
? html`<ha-list
@selected=${this._assistantsSelected}
class="ha-scrollbar"
multi
>
${repeat(
this._voiceAssistantOptions,
(voiceAssistantId) => voiceAssistantId,
(voiceAssistantId) =>
html`<ha-check-list-item
.value=${voiceAssistantId}
.selected=${(this.value || []).includes(voiceAssistantId)}
hasMeta
graphic="icon"
>
<voice-assistant-brand-icon
slot="graphic"
.voiceAssistantId=${voiceAssistantId}
.hass=${this.hass}
>
</voice-assistant-brand-icon>
${voiceAssistants[voiceAssistantId].name}
</ha-check-list-item>`
)}
</ha-list> `
: nothing}
</ha-expansion-panel>
`;
}
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
this._voiceAssistantOptions = Object.keys(voiceAssistants);
}
protected updated(changed) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("ha-list")!.style.height =
`${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
private async _assistantsSelected(
ev: CustomEvent<SelectedDetail<Set<number>>>
) {
if (!ev.detail.index) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}
const newvalue: string[] = [];
for (const index of ev.detail.index) {
newvalue.push(this._voiceAssistantOptions![index]);
}
this.value = newvalue;
fireEvent(this, "data-table-filter-changed", {
value: this.value,
items: undefined,
});
}
private _clearFilter(ev) {
ev.preventDefault();
this.value = [];
fireEvent(this, "data-table-filter-changed", {
value: undefined,
items: undefined,
});
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
position: relative;
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-end: 8px;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: initial;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
.add {
position: absolute;
bottom: 0;
right: 0;
left: 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-voice-assistants": HaFilterVoiceAssistants;
}
}

View File

@@ -1,21 +1,5 @@
import type { Selector } from "../../data/selector";
import type { HaFormData, HaFormSchema } from "./types";
const setDefaultValue = (
field: HaFormSchema,
value: HaFormData | undefined
) => {
if ("selector" in field && "choose" in field.selector) {
const firstChoice = Object.keys(field.selector.choose.choices)[0];
if (firstChoice) {
return {
active_choice: firstChoice,
[firstChoice]: value,
};
}
}
return value;
};
import type { HaFormSchema } from "./types";
export const computeInitialHaFormData = (
schema: HaFormSchema[] | readonly HaFormSchema[]
@@ -26,12 +10,9 @@ export const computeInitialHaFormData = (
field.description?.suggested_value !== undefined &&
field.description?.suggested_value !== null
) {
data[field.name] = setDefaultValue(
field,
field.description.suggested_value
);
data[field.name] = field.description.suggested_value;
} else if ("default" in field) {
data[field.name] = setDefaultValue(field, field.default);
data[field.name] = field.default;
} else if (field.type === "expandable") {
const expandableData = computeInitialHaFormData(field.schema);
if (field.required || Object.keys(expandableData).length) {
@@ -127,21 +108,6 @@ export const computeInitialHaFormData = (
data[field.name] = {};
} else if ("state" in selector) {
data[field.name] = selector.state?.multiple ? [] : "";
} else if ("choose" in selector) {
const firstChoice = Object.keys(selector.choose.choices)[0];
if (!firstChoice) {
data[field.name] = {};
} else {
data[field.name] = {
active_choice: firstChoice,
[firstChoice]: computeInitialHaFormData([
{
name: firstChoice,
selector: selector.choose.choices[firstChoice].selector,
},
])[firstChoice],
};
}
} else {
throw new Error(
`Selector ${Object.keys(selector)[0]} not supported in initial form data`

View File

@@ -1,19 +1,12 @@
import "@home-assistant/webawesome/dist/components/popover/popover";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiPlaylistPlus } from "@mdi/js";
import {
css,
html,
LitElement,
nothing,
type CSSResultGroup,
type PropertyValues,
} from "lit";
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one";
import { tinykeys } from "tinykeys";
import { fireEvent } from "../common/dom/fire_event";
import { throttle } from "../common/util/throttle";
import { PickerMixin } from "../mixins/picker-mixin";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import type { HomeAssistant } from "../types";
@@ -46,7 +39,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
public getItems!: (
searchString?: string,
section?: string
) => (PickerComboBoxItem | string)[] | undefined;
) => (PickerComboBoxItem | string)[];
@property({ attribute: false, type: Array })
public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];
@@ -121,8 +114,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@state() private _openedNarrow = false;
@state() private _unknownValue = false;
static shadowRootOptions = {
...LitElement.shadowRootOptions,
delegatesFocus: true,
@@ -139,25 +130,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
private _unsubscribeTinyKeys?: () => void;
protected willUpdate(changedProperties: PropertyValues) {
if (changedProperties.has("value")) {
this._setUnknownValue();
return;
}
if (changedProperties.has("hass")) {
this._throttleUnknownValue();
}
}
public setFieldValue(value: string) {
if (this._comboBox) {
this._comboBox.setFieldValue(value);
return;
}
// Store initial value to set when opened
this._initialFieldValue = value;
}
protected render() {
// Only show label if it's not a top label and there is a value.
const label = this.useTopLabel && this.value ? undefined : this.label;
@@ -185,7 +157,11 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
type="button"
class=${this._opened ? "opened" : ""}
compact
.unknown=${this._unknownValue}
.unknown=${this._unknownValue(
this.allowCustomValue,
this.value,
this.getItems()
)}
.unknownItemText=${this.unknownItemText}
aria-label=${ifDefined(this.label)}
@click=${this.open}
@@ -206,42 +182,40 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
</ha-picker-field>`}
</slot>
</div>
${this._pickerWrapperOpen || this._opened
? this._openedNarrow
? html`
<ha-bottom-sheet
flexcontent
.open=${this._pickerWrapperOpen}
@wa-after-show=${this._dialogOpened}
@closed=${this._hidePicker}
role="dialog"
aria-modal="true"
aria-label=${this.label || "Select option"}
>
${this._renderComboBox(true)}
</ha-bottom-sheet>
`
: html`
<wa-popover
.open=${this._pickerWrapperOpen}
style="--body-width: ${this._popoverWidth}px;"
without-arrow
distance="-4"
.placement=${this.popoverPlacement}
for="picker"
auto-size="vertical"
auto-size-padding="16"
@wa-after-show=${this._dialogOpened}
@wa-after-hide=${this._hidePicker}
trap-focus
role="dialog"
aria-modal="true"
aria-label=${this.label || "Select option"}
>
${this._renderComboBox()}
</wa-popover>
`
: nothing}
${!this._openedNarrow && (this._pickerWrapperOpen || this._opened)
? html`
<wa-popover
.open=${this._pickerWrapperOpen}
style="--body-width: ${this._popoverWidth}px;"
without-arrow
distance="-4"
.placement=${this.popoverPlacement}
for="picker"
auto-size="vertical"
auto-size-padding="16"
@wa-after-show=${this._dialogOpened}
@wa-after-hide=${this._hidePicker}
trap-focus
role="dialog"
aria-modal="true"
aria-label=${this.label || "Select option"}
>
${this._renderComboBox()}
</wa-popover>
`
: this._pickerWrapperOpen || this._opened
? html`<ha-bottom-sheet
flexcontent
.open=${this._pickerWrapperOpen}
@wa-after-show=${this._dialogOpened}
@closed=${this._hidePicker}
role="dialog"
aria-modal="true"
aria-label=${this.label || "Select option"}
>
${this._renderComboBox(true)}
</ha-bottom-sheet>`
: nothing}
</div>
${this._renderHelper()}`;
}
@@ -274,29 +248,26 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
`;
}
private _setUnknownValue = () => {
const items = this.getItems();
if (
this.allowCustomValue ||
this.value === undefined ||
this.value === null ||
this.value === "" ||
!items
) {
this._unknownValue = false;
return;
private _unknownValue = memoizeOne(
(
allowCustomValue: boolean,
value?: string,
items?: (PickerComboBoxItem | string)[]
) => {
if (
allowCustomValue ||
value === undefined ||
value === null ||
value === "" ||
!items
) {
return false;
}
return !items.some(
(item) => typeof item !== "string" && item.id === value
);
}
this._unknownValue = !items.some(
(item) => typeof item !== "string" && item.id === this.value
);
};
private _throttleUnknownValue = throttle(
this._setUnknownValue,
1000,
true,
false
);
private _renderHelper() {
@@ -312,16 +283,9 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
</ha-input-helper-text>`;
}
private _initialFieldValue?: string;
private _dialogOpened = () => {
this._opened = true;
requestAnimationFrame(() => {
// Set initial field value if needed
if (this._initialFieldValue) {
this._comboBox?.setFieldValue(this._initialFieldValue);
this._initialFieldValue = undefined;
}
if (this.hass && isIosApp(this.hass)) {
this.hass.auth.external!.fireMessage({
type: "focus_element",
@@ -331,7 +295,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
});
return;
}
this._comboBox?.focus();
});
};
@@ -413,7 +376,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
.container {
position: relative;
display: block;
max-width: 100%;
}
label[disabled] {
color: var(--mdc-text-field-disabled-ink-color, rgba(0, 0, 0, 0.6));

View File

@@ -124,6 +124,9 @@ export class HaIconPicker extends LitElement {
.label=${this.label}
.value=${this._value}
.searchFn=${this._filterIcons}
.notFoundLabel=${this.hass?.localize(
"ui.components.icon-picker.no_match"
)}
popover-placement="bottom-start"
@value-changed=${this._valueChanged}
>
@@ -170,6 +173,20 @@ export class HaIconPicker extends LitElement {
}
}
// Allow preview for custom icon not in list
if (rankedItems.length === 0) {
rankedItems.push({
item: {
id: filter,
primary: filter,
icon: filter,
search_labels: { keyword: filter },
sorting_label: filter,
},
rank: 0,
});
}
return rankedItems
.sort((itemA, itemB) => itemA.rank - itemB.rank)
.map((item) => item.item);

View File

@@ -109,7 +109,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
public getItems!: (
searchString?: string,
section?: string
) => PickerComboBoxItem[] | undefined;
) => PickerComboBoxItem[];
@property({ attribute: false, type: Array })
public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];
@@ -153,12 +153,6 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@state() private _items: PickerComboBoxItem[] = [];
public setFieldValue(value: string) {
if (this._searchFieldElement) {
this._searchFieldElement.value = value;
}
}
protected get scrollableElement(): HTMLElement | null {
return this._virtualizerElement as HTMLElement | null;
}
@@ -301,7 +295,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
this.getAdditionalItems?.(searchString) || [];
private _getItems = () => {
let items = [...(this.getItems(this._search, this.selectedSection) || [])];
let items = [...this.getItems(this._search, this.selectedSection)];
if (!this.sections?.length) {
items = items.sort((entityA, entityB) => {
@@ -330,7 +324,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
});
}
if (!items.length && !this.allowCustomValue) {
if (!items.length) {
items.push({ id: NO_ITEMS_AVAILABLE_ID, primary: "" });
}
@@ -436,7 +430,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
index
);
if (!filteredItems.length && !this.allowCustomValue) {
if (!filteredItems.length) {
filteredItems.push({ id: NO_ITEMS_AVAILABLE_ID, primary: "" });
}
@@ -793,7 +787,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
.section-title,
.title {
background-color: var(--ha-color-fill-neutral-quiet-resting);
padding: var(--ha-space-2) var(--ha-space-3);
padding: var(--ha-space-1) var(--ha-space-2);
font-weight: var(--ha-font-weight-bold);
color: var(--secondary-text-color);
min-height: var(--ha-space-6);

View File

@@ -10,7 +10,7 @@ class HaSectionTitle extends LitElement {
static styles = css`
:host {
background-color: var(--ha-color-fill-neutral-quiet-resting);
padding: var(--ha-space-2) var(--ha-space-3);
padding: var(--ha-space-1) var(--ha-space-2);
font-weight: var(--ha-font-weight-bold);
color: var(--secondary-text-color);
min-height: var(--ha-space-6);

View File

@@ -38,13 +38,6 @@ export class HaChooseSelector extends LitElement {
) {
this._setActiveChoice();
}
if (
changedProperties.has("value") &&
changedProperties.get("value")?.active_choice &&
changedProperties.get("value")?.active_choice !== this._activeChoice
) {
this._setActiveChoice();
}
}
protected render() {
@@ -61,8 +54,7 @@ export class HaChooseSelector extends LitElement {
size="small"
.buttons=${this._toggleButtons(
this.selector.choose.choices,
this.selector.choose.translation_key,
this.hass.localize
this.selector.choose.translation_key
)}
.active=${this._activeChoice}
@value-changed=${this._choiceChanged}
@@ -80,11 +72,7 @@ export class HaChooseSelector extends LitElement {
}
private _toggleButtons = memoizeOne(
(
choices: ChooseSelector["choose"]["choices"],
translationKey?: string,
_localize?: HomeAssistant["localize"]
) =>
(choices: ChooseSelector["choose"]["choices"], translationKey?: string) =>
Object.keys(choices).map((choice) => ({
label:
this.localizeValue && translationKey

View File

@@ -1,4 +1,3 @@
import memoizeOne from "memoize-one";
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { DurationSelector } from "../../data/selector";
@@ -12,10 +11,7 @@ export class HaTimeDuration extends LitElement {
@property({ attribute: false }) public selector!: DurationSelector;
@property({ attribute: false }) public value?:
| HaDurationData
| string
| number;
@property({ attribute: false }) public value?: HaDurationData;
@property() public label?: string;
@@ -25,47 +21,16 @@ export class HaTimeDuration extends LitElement {
@property({ type: Boolean }) public required = true;
private _data = memoizeOne(
(value?: HaDurationData | string | number): HaDurationData | undefined => {
if (typeof value === "number") {
return { seconds: value };
}
if (typeof value === "string") {
const negative = value.trim()[0] === "-";
const parts = value
.split(":")
.map((p) => (negative && p ? -Math.abs(Number(p)) : Number(p)));
if (parts.length === 1) {
return { seconds: parts[0] };
}
if (parts.length === 2) {
return { hours: parts[0], minutes: parts[1] };
}
if (parts.length === 3) {
return {
hours: parts[0],
minutes: parts[1],
seconds: parts[2],
};
}
return undefined;
}
return value;
}
);
protected render() {
return html`
<ha-duration-input
.label=${this.label}
.helper=${this.helper}
.data=${this._data(this.value)}
.data=${this.value}
.disabled=${this.disabled}
.required=${this.required}
.enableDay=${this.selector.duration?.enable_day}
.enableMillisecond=${this.selector.duration?.enable_millisecond}
.allowNegative=${this.selector.duration?.allow_negative}
></ha-duration-input>
`;
}

View File

@@ -1,17 +1,12 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import {
resolveEntityIDs,
type StateSelector,
type TargetSelector,
} from "../../data/selector";
import type { StateSelector } from "../../data/selector";
import { extractFromTarget } from "../../data/target";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../types";
import "../entity/ha-entity-state-picker";
import "../entity/ha-entity-states-picker";
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
@customElement("ha-selector-state")
export class HaSelectorState extends SubscribeMixin(LitElement) {
@@ -33,33 +28,16 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
filter_attribute?: string;
filter_entity?: string | string[];
filter_target?: HassServiceTarget;
target_selector?: TargetSelector;
};
@state() private _entityIds?: string | string[];
private _convertExtraOptions = memoizeOne(
(
extraOptions?: { label: string; value: any }[]
): PickerComboBoxItem[] | undefined => {
if (!extraOptions) {
return undefined;
}
return extraOptions.map((option) => ({
id: option.value,
primary: option.label,
sorting_label: option.label,
}));
}
);
willUpdate(changedProps) {
if (changedProps.has("selector") || changedProps.has("context")) {
this._resolveEntityIds(
this.selector.state?.entity_id,
this.context?.filter_entity,
this.context?.filter_target,
this.context?.target_selector
this.context?.filter_target
).then((entityIds) => {
this._entityIds = entityIds;
});
@@ -67,9 +45,6 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
}
protected render() {
const extraOptions = this._convertExtraOptions(
this.selector.state?.extra_options
);
if (this.selector.state?.multiple) {
return html`
<ha-entity-states-picker
@@ -77,7 +52,7 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
.entityId=${this._entityIds}
.attribute=${this.selector.state?.attribute ||
this.context?.filter_attribute}
.extraOptions=${extraOptions}
.extraOptions=${this.selector.state?.extra_options}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
@@ -94,7 +69,7 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
.entityId=${this._entityIds}
.attribute=${this.selector.state?.attribute ||
this.context?.filter_attribute}
.extraOptions=${extraOptions}
.extraOptions=${this.selector.state?.extra_options}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
@@ -109,8 +84,7 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
private async _resolveEntityIds(
selectorEntityId: string | string[] | undefined,
contextFilterEntity: string | string[] | undefined,
contextFilterTarget: HassServiceTarget | undefined,
contextTargetSelector: TargetSelector | undefined
contextFilterTarget: HassServiceTarget | undefined
): Promise<string | string[] | undefined> {
if (selectorEntityId !== undefined) {
return selectorEntityId;
@@ -119,14 +93,8 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
return contextFilterEntity;
}
if (contextFilterTarget !== undefined) {
return resolveEntityIDs(
this.hass,
contextFilterTarget,
this.hass.entities,
this.hass.devices,
this.hass.areas,
contextTargetSelector
);
const result = await extractFromTarget(this.hass, contextFilterTarget);
return result.referenced_entities;
}
return undefined;
}

View File

@@ -52,6 +52,8 @@ import "./ha-spinner";
import "./ha-svg-icon";
import "./user/ha-user-badge";
const SUPPORT_SCROLL_IF_NEEDED = "scrollIntoViewIfNeeded" in document.body;
const SORT_VALUE_URL_PATHS = {
energy: 1,
map: 2,
@@ -342,6 +344,17 @@ class HaSidebar extends SubscribeMixin(LitElement) {
}
this._calculateCounts();
if (!SUPPORT_SCROLL_IF_NEEDED) {
return;
}
if (oldHass?.panelUrl !== this.hass.panelUrl) {
const selectedEl = this.shadowRoot!.querySelector(".selected");
if (selectedEl) {
// @ts-ignore
selectedEl.scrollIntoViewIfNeeded();
}
}
}
private _calculateCounts = throttle(() => {

View File

@@ -57,7 +57,6 @@ export class HaSlider extends Slider {
#thumb {
border: none;
background-color: var(--ha-slider-thumb-color, var(--primary-color));
overflow: hidden;
}
#thumb:after {

View File

@@ -1,5 +1,4 @@
import "@home-assistant/webawesome/dist/components/dialog/dialog";
import type WaDialog from "@home-assistant/webawesome/dist/components/dialog/dialog";
import { mdiClose } from "@mdi/js";
import { css, html, LitElement } from "lit";
import {
@@ -14,6 +13,7 @@ import { fireEvent } from "../common/dom/fire_event";
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import { isIosApp } from "../util/is_ios";
import "./ha-dialog-header";
import "./ha-icon-button";
@@ -50,6 +50,7 @@ export type DialogWidth = "small" | "medium" | "large" | "full";
* @cssprop --ha-dialog-hide-duration - Hide animation duration.
* @cssprop --ha-dialog-surface-background - Dialog background color.
* @cssprop --ha-dialog-border-radius - Border radius of the dialog surface.
* @cssprop --dialog-z-index - Z-index for the dialog.
* @cssprop --dialog-surface-margin-top - Top margin for the dialog surface.
*
* @attr {boolean} open - Controls the dialog open state.
@@ -114,8 +115,6 @@ export class HaWaDialog extends ScrollableFadeMixin(LitElement) {
@state()
private _bodyScrolled = false;
private _escapePressed = false;
protected get scrollableElement(): HTMLElement | null {
return this.bodyContainer;
}
@@ -141,8 +140,6 @@ export class HaWaDialog extends ScrollableFadeMixin(LitElement) {
(this.headerTitle !== undefined ? "ha-wa-dialog-title" : undefined)
)}
aria-describedby=${ifDefined(this.ariaDescribedBy)}
@keydown=${this._handleKeyDown}
@wa-hide=${this._handleHide}
@wa-show=${this._handleShow}
@wa-after-show=${this._handleAfterShow}
@wa-after-hide=${this._handleAfterHide}
@@ -188,22 +185,21 @@ export class HaWaDialog extends ScrollableFadeMixin(LitElement) {
await this.updateComplete;
requestAnimationFrame(() => {
// temporary disabled because of issues with focus in iOS app, can be reenabled in 2026.2.0
// if (isIosApp(this.hass)) {
// const element = this.querySelector("[autofocus]");
// if (element !== null) {
// if (!element.id) {
// element.id = "ha-wa-dialog-autofocus";
// }
// this.hass.auth.external!.fireMessage({
// type: "focus_element",
// payload: {
// element_id: element.id,
// },
// });
// }
// return;
// }
if (isIosApp(this.hass)) {
const element = this.querySelector("[autofocus]");
if (element !== null) {
if (!element.id) {
element.id = "ha-wa-dialog-autofocus";
}
this.hass.auth.external!.fireMessage({
type: "focus_element",
payload: {
element_id: element.id,
},
});
}
return;
}
(this.querySelector("[autofocus]") as HTMLElement | null)?.focus();
});
};
@@ -212,11 +208,9 @@ export class HaWaDialog extends ScrollableFadeMixin(LitElement) {
fireEvent(this, "after-show");
};
private _handleAfterHide = (ev: CustomEvent<{ source: Element }>) => {
if (ev.eventPhase === Event.AT_TARGET) {
this._open = false;
fireEvent(this, "closed");
}
private _handleAfterHide = () => {
this._open = false;
fireEvent(this, "closed");
};
public disconnectedCallback(): void {
@@ -229,23 +223,6 @@ export class HaWaDialog extends ScrollableFadeMixin(LitElement) {
this._bodyScrolled = (ev.target as HTMLDivElement).scrollTop > 0;
}
private _handleKeyDown(ev: KeyboardEvent) {
if (ev.key === "Escape") {
this._escapePressed = true;
}
}
private _handleHide(ev: CustomEvent<{ source: Element }>) {
if (
this.preventScrimClose &&
this._escapePressed &&
ev.detail.source === (ev.target as WaDialog).dialog
) {
ev.preventDefault();
}
this._escapePressed = false;
}
static get styles() {
return [
...super.styles,
@@ -294,7 +271,6 @@ export class HaWaDialog extends ScrollableFadeMixin(LitElement) {
}
wa-dialog::part(dialog) {
color: var(--primary-text-color);
min-width: var(--width, var(--full-width));
max-width: var(--width, var(--full-width));
max-height: var(

View File

@@ -24,7 +24,6 @@ import { setupLeafletMap } from "../../common/dom/setup-leaflet-map";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { DecoratedMarker } from "../../common/map/decorated_marker";
import { filterXSS } from "../../common/util/xss";
import type { HomeAssistant, ThemeMode } from "../../types";
import { isTouch } from "../../util/is_touch";
import "../ha-icon-button";
@@ -382,7 +381,7 @@ export class HaMap extends ReactiveElement {
this.hass.config
);
}
return `${filterXSS(path.name ?? "")}<br>${formattedTime}`;
return `${path.name}<br>${formattedTime}`;
}
private _drawPaths(): void {
@@ -550,7 +549,7 @@ export class HaMap extends ReactiveElement {
iconHTML = el.outerHTML;
} else {
const el = document.createElement("span");
el.textContent = title;
el.innerHTML = title;
iconHTML = el.outerHTML;
}

View File

@@ -1,6 +1,7 @@
import type { ActionDetail } from "@material/mwc-list";
import {
mdiAlphaABoxOutline,
mdiArrowLeft,
mdiClose,
mdiDotsVertical,
mdiGrid,
@@ -20,10 +21,9 @@ import type {
} from "../../data/media-player";
import { haStyleDialog, haStyleDialogFixedTop } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import "../ha-wa-dialog";
import "../ha-dialog";
import "../ha-dialog-header";
import "../ha-list-item";
import "../ha-icon-button-arrow-prev";
import "./ha-media-manage-button";
import "./ha-media-player-browse";
import type {
@@ -44,8 +44,6 @@ class DialogMediaPlayerBrowse extends LitElement {
@state() _preferredLayout: MediaPlayerLayoutType = "auto";
@state() private _open = false;
@query("ha-media-player-browse") private _browser!: HaMediaPlayerBrowse;
public showDialog(params: MediaPlayerBrowseDialogParams): void {
@@ -56,11 +54,9 @@ class DialogMediaPlayerBrowse extends LitElement {
media_content_type: undefined,
},
];
this._open = true;
}
public closeDialog() {
this._open = false;
this._params = undefined;
this._navigateIds = undefined;
this._currentItem = undefined;
@@ -75,20 +71,28 @@ class DialogMediaPlayerBrowse extends LitElement {
}
return html`
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
flexcontent
<ha-dialog
open
scrimClickAction
escapeKeyAction
hideActions
flexContent
.heading=${!this._currentItem
? this.hass.localize(
"ui.components.media-browser.media-player-browser"
)
: this._currentItem.title}
@closed=${this.closeDialog}
@opened=${this._dialogOpened}
>
<ha-dialog-header show-border slot="header">
<ha-dialog-header show-border slot="heading">
${this._navigateIds.length > (this._params.minimumNavigateLevel ?? 1)
? html`
<ha-icon-button-arrow-prev
<ha-icon-button
slot="navigationIcon"
.path=${mdiArrowLeft}
@click=${this._goBack}
></ha-icon-button-arrow-prev>
></ha-icon-button>
`
: nothing}
<span slot="title">
@@ -149,7 +153,7 @@ class DialogMediaPlayerBrowse extends LitElement {
<ha-icon-button
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
data-dialog="close"
dialogAction="close"
slot="actionItems"
></ha-icon-button>
</ha-dialog-header>
@@ -169,7 +173,7 @@ class DialogMediaPlayerBrowse extends LitElement {
@media-picked=${this._mediaPicked}
@media-browsed=${this._mediaBrowsed}
></ha-media-player-browse>
</ha-wa-dialog>
</ha-dialog>
`;
}
@@ -221,7 +225,8 @@ class DialogMediaPlayerBrowse extends LitElement {
haStyleDialog,
haStyleDialogFixedTop,
css`
ha-wa-dialog {
ha-dialog {
--dialog-z-index: 9;
--dialog-content-padding: 0;
}
@@ -236,9 +241,9 @@ class DialogMediaPlayerBrowse extends LitElement {
}
@media (min-width: 800px) {
ha-wa-dialog {
--ha-dialog-max-width: 800px;
--ha-dialog-max-height: calc(
ha-dialog {
--mdc-dialog-max-width: 800px;
--mdc-dialog-max-height: calc(
100vh - var(--ha-space-18) - var(--safe-area-inset-y)
);
}

View File

@@ -1,15 +1,14 @@
import { type CSSResultGroup, LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { mdiSpeaker, mdiSpeakerPause, mdiSpeakerPlay } from "@mdi/js";
import memoizeOne from "memoize-one";
import { mdiSpeaker } from "@mdi/js";
import type { HomeAssistant } from "../../types";
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
import { computeRTL } from "../../common/util/compute_rtl";
import { computeStateName } from "../../common/entity/compute_state_name";
import { fireEvent } from "../../common/dom/fire_event";
import "../ha-switch";
import "../ha-svg-icon";
import type { MediaPlayerEntity } from "../../data/media-player";
@customElement("ha-media-player-toggle")
class HaMediaPlayerToggle extends LitElement {
@@ -21,61 +20,15 @@ class HaMediaPlayerToggle extends LitElement {
@property({ type: Boolean }) public disabled = false;
private _computeDisplayData = memoizeOne(
(
entityId: string,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"],
isRTL: boolean,
stateObj: HomeAssistant["states"][string]
) => {
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
entities,
devices,
areas,
floors
);
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
return { primary, secondary };
}
);
protected render() {
const stateObj = this.hass.states[this.entityId];
let icon = mdiSpeaker;
if (stateObj.state === "playing") {
icon = mdiSpeakerPlay;
} else if (stateObj.state === "paused") {
icon = mdiSpeakerPause;
}
const isRTL = computeRTL(this.hass);
const { primary, secondary } = this._computeDisplayData(
this.entityId,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors,
isRTL,
stateObj
);
return html`<div class="list-item">
<ha-svg-icon .path=${icon}></ha-svg-icon>
<ha-svg-icon .path=${mdiSpeaker}></ha-svg-icon>
<div class="info">
<div class="main-text">${primary}</div>
<div class="secondary-text">${secondary}</div>
<div class="main-text">${computeStateName(stateObj)}</div>
<div class="secondary-text">
${this._formatSecondaryText(stateObj as MediaPlayerEntity)}
</div>
</div>
<ha-switch
.disabled=${this.disabled}
@@ -85,6 +38,16 @@ class HaMediaPlayerToggle extends LitElement {
</div>`;
}
private _formatSecondaryText(stateObj: MediaPlayerEntity): string {
if (stateObj.state !== "playing") {
return this.hass.localize("ui.card.media_player.idle");
}
return [stateObj.attributes.media_title, stateObj.attributes.media_artist]
.filter((segment) => segment)
.join(" · ");
}
static get styles(): CSSResultGroup {
return [
css`

View File

@@ -1,51 +0,0 @@
import { customElement, property } from "lit/decorators";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html } from "lit";
import { haStyle } from "../resources/styles";
import type { HomeAssistant } from "../types";
import { voiceAssistants } from "../data/expose";
import { brandsUrl } from "../util/brands-url";
@customElement("voice-assistant-brand-icon")
export class VoiceAssistantBrandicon extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public voiceAssistantId!: string;
protected render() {
return html`
<img
class="logo"
alt=${voiceAssistants[this.voiceAssistantId].name}
src=${brandsUrl({
domain: voiceAssistants[this.voiceAssistantId].domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
`;
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
.logo {
position: relative;
height: 24px;
margin-right: 16px;
margin-inline-end: 16px;
margin-inline-start: initial;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"voice-assistant-brand-icon": VoiceAssistantBrandicon;
}
}

View File

@@ -449,9 +449,16 @@ const getEnergyData = async (
const allStatIDs = [...energyStatIds, ...waterStatIds, ...powerStatIds];
const dayDifference = differenceInDays(end || new Date(), start);
const period = getSuggestedPeriod(start, end);
const finePeriod = getSuggestedPeriod(start, end, true);
const period =
isFirstDayOfMonth(start) &&
(!end || isLastDayOfMonth(end)) &&
dayDifference > 35
? "month"
: dayDifference > 2
? "day"
: "hour";
const finePeriod =
dayDifference > 64 ? "day" : dayDifference > 8 ? "hour" : "5minute";
const statsMetadata: Record<string, StatisticsMetaData> = {};
const statsMetadataArray = allStatIDs.length
@@ -582,7 +589,7 @@ const getEnergyData = async (
consumptionStatIDs,
co2SignalEntity,
end,
period
dayDifference > 35 ? "month" : dayDifference > 2 ? "day" : "hour"
);
if (compare) {
_fossilEnergyConsumptionCompare = getFossilEnergyConsumption(
@@ -591,7 +598,7 @@ const getEnergyData = async (
consumptionStatIDs,
co2SignalEntity,
endCompare,
period
dayDifference > 35 ? "month" : dayDifference > 2 ? "day" : "hour"
);
}
}
@@ -1420,22 +1427,3 @@ export const formatPowerShort = (
units[unitIndex]
);
};
export function getSuggestedPeriod(
start: Date,
end?: Date,
fine = false
): "5minute" | "hour" | "day" | "month" {
const dayDifference = differenceInDays(end || new Date(), start);
if (fine) {
return dayDifference > 64 ? "day" : dayDifference > 8 ? "hour" : "5minute";
}
return isFirstDayOfMonth(start) &&
(!end || isLastDayOfMonth(end)) &&
dayDifference > 35
? "month"
: dayDifference > 2
? "day"
: "hour";
}

View File

@@ -69,7 +69,6 @@ export const DOMAIN_ATTRIBUTES_UNITS = {
current_humidity: "%",
min_humidity: "%",
max_humidity: "%",
target_humidity_step: "%",
},
light: {
color_temp: "mired",

View File

@@ -1,6 +1,4 @@
import type { HomeAssistant } from "../types";
import type { EntityRegistryEntry } from "./entity/entity_registry";
import { entityRegistryByEntityId } from "./entity/entity_registry";
export const voiceAssistants = {
conversation: { domain: "assist_pipeline", name: "Assist" },
@@ -54,13 +52,3 @@ export const listExposedEntities = (hass: HomeAssistant) =>
hass.callWS<{ exposed_entities: Record<string, ExposeEntitySettings> }>({
type: "homeassistant/expose_entity/list",
});
export const getEntityVoiceAssistantsIds = (
entityRegistry: EntityRegistryEntry[],
entityId: string
) => {
const entity = entityRegistryByEntityId(entityRegistry)[entityId];
return Object.keys(voiceAssistants).filter(
(vaKey) => entity?.options?.[vaKey]?.should_expose
);
};

View File

@@ -16,7 +16,6 @@ export type HumidifierEntity = HassEntityBase & {
mode?: string;
action?: HumidifierAction;
available_modes?: string[];
target_humidity_step?: number;
};
};

View File

@@ -52,9 +52,6 @@ export interface BaseActionConfig {
export interface ConfirmationRestrictionConfig {
text?: string;
title?: string;
confirm_text?: string;
dismiss_text?: string;
exemptions?: RestrictionConfig[];
}

View File

@@ -49,7 +49,6 @@ export interface LovelaceBaseViewConfig {
title?: string;
path?: string;
icon?: string;
show_icon_and_title?: boolean;
theme?: string;
panel?: boolean;
background?: string | LovelaceViewBackgroundConfig;

View File

@@ -16,8 +16,6 @@ export interface RecorderInfo {
export type StatisticType = "change" | "state" | "sum" | "min" | "max" | "mean";
export type StatisticPeriod = "5minute" | "hour" | "day" | "week" | "month";
export type Statistics = Record<string, StatisticValue[]>;
export interface StatisticValue {
@@ -176,7 +174,7 @@ export const fetchStatistics = (
startTime: Date,
endTime?: Date,
statistic_ids?: string[],
period: StatisticPeriod = "hour",
period: "5minute" | "hour" | "day" | "week" | "month" = "hour",
units?: StatisticsUnitConfiguration,
types?: StatisticsTypes
) =>

View File

@@ -221,7 +221,6 @@ export interface DurationSelector {
duration: {
enable_day?: boolean;
enable_millisecond?: boolean;
allow_negative?: boolean;
} | null;
}
@@ -377,7 +376,7 @@ interface SelectBoxOptionImage {
}
export interface SelectOption {
value: string;
value: any;
label: string;
description?: string;
image?: string | SelectBoxOptionImage;
@@ -930,13 +929,13 @@ export const resolveEntityIDs = (
targetPickerValue: HassServiceTarget,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
targetSelector: TargetSelector = { target: {} }
areas: HomeAssistant["areas"]
): string[] => {
if (!targetPickerValue) {
return [];
}
const targetSelector = { target: {} };
const targetEntities = new Set(ensureArray(targetPickerValue.entity_id));
const targetDevices = new Set(ensureArray(targetPickerValue.device_id));
const targetAreas = new Set(ensureArray(targetPickerValue.area_id));

View File

@@ -44,27 +44,14 @@ export const updateUsesProgress = (entity: UpdateEntity): boolean =>
supportsFeature(entity, UpdateEntityFeature.PROGRESS) &&
entity.attributes.update_percentage !== null;
export const updateAvailable = (
entity: UpdateEntity,
showSkipped = false
): boolean =>
entity.state === BINARY_STATE_ON ||
(showSkipped && Boolean(entity.attributes.skipped_version));
export const updateCanInstall = (
entity: UpdateEntity,
showSkipped = false
): boolean =>
updateAvailable(entity, showSkipped) &&
(entity.state === BINARY_STATE_ON ||
(showSkipped && Boolean(entity.attributes.skipped_version))) &&
supportsFeature(entity, UpdateEntityFeature.INSTALL);
export const updateCanNotInstall = (
entity: UpdateEntity,
showSkipped = false
): boolean =>
updateAvailable(entity, showSkipped) &&
!supportsFeature(entity, UpdateEntityFeature.INSTALL);
export const latestVersionIsSkipped = (entity: UpdateEntity): boolean =>
!!(
entity.attributes.latest_version &&
@@ -121,17 +108,13 @@ export const filterUpdateEntities = (
);
});
export const filterUpdateEntitiesParameterized = (
export const filterUpdateEntitiesWithInstall = (
entities: HassEntities,
showSkipped = false,
showNotInstallable = false
showSkipped = false
) =>
filterUpdateEntities(entities).filter((entity) => {
if (showNotInstallable) {
return updateCanNotInstall(entity, showSkipped);
}
return updateCanInstall(entity, showSkipped);
});
filterUpdateEntities(entities).filter((entity) =>
updateCanInstall(entity, showSkipped)
);
export const checkForEntityUpdates = async (
element: HTMLElement,

View File

@@ -766,10 +766,7 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
}
.content-wrapper.settings-view .fade-bottom {
bottom: calc(
var(--ha-space-14) +
max(var(--safe-area-inset-bottom), var(--ha-space-4))
);
bottom: var(--ha-space-18);
}
.child-view {

View File

@@ -1,3 +1,4 @@
import { mdiAppleKeyboardCommand } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
@@ -153,10 +154,6 @@ const _SHORTCUTS: Section[] = [
shortcut: ["M"],
descriptionTranslationKey: "ui.dialogs.shortcuts.other.my_link",
},
{
shortcut: ["Shift", "/"],
descriptionTranslationKey: "ui.dialogs.shortcuts.other.show_shortcuts",
},
],
},
];
@@ -187,7 +184,9 @@ class DialogShortcuts extends LitElement {
html`<span
>${shortcutKey === CTRL_CMD
? isMac
? "⌘"
? html`<ha-svg-icon
.path=${mdiAppleKeyboardCommand}
></ha-svg-icon>`
: this.hass.localize("ui.panel.config.automation.editor.ctrl")
: typeof shortcutKey === "string"
? shortcutKey

View File

@@ -28,7 +28,6 @@ window.loadES5Adapter = () => {
};
let panelEl: HTMLElement | undefined;
let initialized = false;
function setProperties(properties) {
if (!panelEl) {
@@ -129,23 +128,13 @@ function initialize(
});
}
function handleReady() {
if (initialized) return;
initialized = true;
window.parent.customPanel!.registerIframe(initialize, setProperties);
}
document.addEventListener(
"DOMContentLoaded",
() => window.parent.customPanel!.registerIframe(initialize, setProperties),
{ once: true }
);
// Initial load
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", handleReady, { once: true });
} else {
handleReady();
}
window.addEventListener("pageshow", handleReady);
window.addEventListener("pagehide", () => {
initialized = false;
window.addEventListener("unload", () => {
// allow disconnected callback to fire
while (document.body.lastChild) {
document.body.removeChild(document.body.lastChild);

View File

@@ -152,7 +152,7 @@ export const provideHass = (
for (const ent of ensureArray(newEntities)) {
hass().entities[ent.entityId] = {
entity_id: ent.entityId,
name: ent.attributes.friendly_name || null,
name: ent.name,
icon: ent.icon,
platform: "demo",
labels: [],

View File

@@ -50,7 +50,7 @@ export const ScrollableFadeMixin = <T extends Constructor<LitElement>>(
/**
* Safe area padding in pixels for the scrollable element.
*/
protected scrollFadeSafeAreaPadding = 4;
protected scrollFadeSafeAreaPadding = 16;
/**
* Scroll threshold in pixels for showing the fades.
@@ -73,9 +73,6 @@ export const ScrollableFadeMixin = <T extends Constructor<LitElement>>(
protected firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated?.(changedProperties);
if (this.scrollableElement) {
this._updateScrollableState(this.scrollableElement);
}
this._attachScrollableElement();
}
@@ -86,8 +83,6 @@ export const ScrollableFadeMixin = <T extends Constructor<LitElement>>(
disconnectedCallback() {
this._detachScrollableElement();
this._contentScrolled = false;
this._contentScrollable = false;
super.disconnectedCallback();
}
@@ -130,16 +125,16 @@ export const ScrollableFadeMixin = <T extends Constructor<LitElement>>(
position: absolute;
left: 0;
right: 0;
height: var(--ha-space-2);
height: var(--ha-space-4);
pointer-events: none;
transition: opacity 180ms ease-in-out;
border-radius: var(--ha-border-radius-square);
opacity: 0;
background: linear-gradient(
to bottom,
var(--ha-color-shadow-scrollable-fade),
var(--shadow-color),
transparent
);
border-radius: var(--ha-border-radius-square);
opacity: 0;
}
.fade-top {
top: 0;

View File

@@ -50,6 +50,7 @@ import {
import "../../../layouts/hass-tabs-subpage";
import type { HomeAssistant, Route } from "../../../types";
import { showToast } from "../../../util/toast";
import "../ha-config-section";
import { configSections } from "../ha-panel-config";
import {
loadAreaRegistryDetailDialog,

View File

@@ -2062,7 +2062,6 @@ class DialogAddAutomationElement
.content.column {
flex-direction: column;
gap: var(--ha-space-3);
}
ha-md-list {

View File

@@ -285,8 +285,6 @@ export class HaAutomationAddItems extends LitElement {
border-radius: var(--ha-border-radius-md);
background: var(--ha-color-fill-neutral-normal-resting);
padding: 0 var(--ha-space-2) 0 var(--ha-space-1);
border: var(--ha-border-width-sm) solid
var(--ha-color-border-neutral-quiet);
color: var(--ha-color-on-neutral-normal);
overflow: hidden;
}

View File

@@ -157,7 +157,7 @@ class DialogAutomationSave extends LitElement implements HassDialog {
`
: nothing}
${this._visibleOptionals.includes("description")
? html`<ha-textarea
? html` <ha-textarea
.label=${this.hass.localize(
"ui.panel.config.automation.editor.description.label"
)}
@@ -168,7 +168,6 @@ class DialogAutomationSave extends LitElement implements HassDialog {
autogrow
.value=${this._newDescription}
.helper=${supportsMarkdownHelper(this.hass.localize)}
helperPersistent
@input=${this._valueChanged}
></ha-textarea>`
: nothing}
@@ -571,7 +570,7 @@ ${dump(this._params.config)}
ha-category-picker,
ha-labels-picker,
ha-area-picker,
ha-chip-set:has(> ha-assist-chip) {
ha-chip-set {
margin-top: 16px;
}
ha-alert {

View File

@@ -4,6 +4,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { computeDomain } from "../../../../../common/entity/compute_domain";
import "../../../../../components/ha-checkbox";
import "../../../../../components/ha-selector/ha-selector";
import "../../../../../components/ha-settings-row";
@@ -268,11 +269,8 @@ export class HaPlatformCondition extends LitElement {
return undefined;
}
const context: Record<string, any> = {};
const context = {};
for (const [context_key, data_key] of Object.entries(field.context)) {
if (data_key === "target" && this.description?.target) {
context.target_selector = this._targetSelector(this.description.target);
}
context[context_key] =
data_key === "target"
? this.condition.target
@@ -380,7 +378,7 @@ export class HaPlatformCondition extends LitElement {
return "";
}
return this.hass.localize(
`component.${getConditionDomain(this.condition.condition)}.selector.${key}`
`component.${computeDomain(this.condition.condition)}.selector.${key}`
);
};

View File

@@ -82,6 +82,7 @@ import type { Entries, HomeAssistant, Route } from "../../../types";
import { isMac } from "../../../util/is_mac";
import { showToast } from "../../../util/toast";
import { showAssignCategoryDialog } from "../category/show-dialog-assign-category";
import "../ha-config-section";
import { showAutomationModeDialog } from "./automation-mode-dialog/show-dialog-automation-mode";
import {
type EntityRegistryUpdate,

View File

@@ -57,7 +57,6 @@ import "../../../components/ha-filter-devices";
import "../../../components/ha-filter-entities";
import "../../../components/ha-filter-floor-areas";
import "../../../components/ha-filter-labels";
import "../../../components/ha-filter-voice-assistants";
import "../../../components/ha-icon-button";
import "../../../components/ha-md-divider";
import "../../../components/ha-md-menu";
@@ -116,8 +115,6 @@ import { showCategoryRegistryDetailDialog } from "../category/show-dialog-catego
import { configSections } from "../ha-panel-config";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
import { showNewAutomationDialog } from "./show-dialog-new-automation";
import { getEntityVoiceAssistantsIds } from "../../../data/expose";
import "../voice-assistants/expose/expose-assistant-icon";
type AutomationItem = AutomationEntity & {
name: string;
@@ -379,31 +376,6 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
></ha-icon-button>
`,
},
voice_assistants: {
title: localize(
"ui.panel.config.automation.picker.headers.voice_assistants"
),
type: "icon",
defaultHidden: true,
minWidth: "100px",
maxWidth: "100px",
template: (automation) => {
const exposedToVoiceAssistantIds = getEntityVoiceAssistantsIds(
this._entityReg,
automation.entity_id
);
return html` ${exposedToVoiceAssistantIds.length !== 0
? exposedToVoiceAssistantIds.map(
(vaId) =>
html` <voice-assistants-expose-assistant-icon
.assistant=${vaId}
.hass=${this.hass}
>
</voice-assistants-expose-assistant-icon>`
)
: "—"}`;
},
},
};
return columns;
}
@@ -661,15 +633,6 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-categories>
<ha-filter-voice-assistants
.hass=${this.hass}
.value=${this._filters["ha-filter-voice-assistants"]?.value}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-voice-assistants"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-voice-assistants>
<ha-filter-blueprints
.hass=${this.hass}
.type=${"automation"}
@@ -1040,7 +1003,8 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
? // @ts-ignore
items.intersection(categoryItems)
: new Set([...items].filter((x) => categoryItems!.has(x)));
} else if (
}
if (
key === "ha-filter-labels" &&
Array.isArray(filter.value) &&
filter.value.length
@@ -1062,29 +1026,6 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
? // @ts-ignore
items.intersection(labelItems)
: new Set([...items].filter((x) => labelItems!.has(x)));
} else if (
key === "ha-filter-voice-assistants" &&
Array.isArray(filter.value) &&
filter.value.length
) {
const assistItems = new Set<string>();
this.automations
.filter((automation) =>
getEntityVoiceAssistantsIds(
this._entityReg,
automation.entity_id
).some((va) => (filter.value as string[]).includes(va))
)
.forEach((automation) => assistItems.add(automation.entity_id));
if (!items) {
items = assistItems;
continue;
}
items =
"intersection" in items
? // @ts-ignore
items.intersection(assistItems)
: new Set([...items].filter((x) => assistItems!.has(x)));
}
}
this._filteredAutomations = items ? [...items] : undefined;

View File

@@ -1,16 +1,10 @@
import { consume } from "@lit/context";
import {
mdiAlert,
mdiCodeBraces,
mdiFormatListBulleted,
mdiShape,
} from "@mdi/js";
import { mdiAlert, mdiFormatListBulleted, mdiShape } from "@mdi/js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { css, html, LitElement, type nothing, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ensureArray } from "../../../../common/array/ensure-array";
import { transform } from "../../../../common/decorators/transform";
import { isTemplate } from "../../../../common/string/has-template";
import "../../../../components/ha-svg-icon";
import type { ConfigEntry } from "../../../../data/config_entries";
import {
@@ -173,16 +167,6 @@ export class HaAutomationRowTargets extends LitElement {
);
}
// Check if the target is a template
if (isTemplate(targetId)) {
return this._renderTargetBadge(
html`<ha-svg-icon .path=${mdiCodeBraces}></ha-svg-icon>`,
this.localize(
"ui.panel.config.automation.editor.target_summary.template"
)
);
}
const exists = this._checkTargetExists(targetType, targetId);
if (!exists) {
return this._renderTargetBadge(
@@ -223,8 +207,6 @@ export class HaAutomationRowTargets extends LitElement {
background: var(--ha-color-fill-neutral-normal-resting);
padding: 0 var(--ha-space-2) 0 var(--ha-space-1);
color: var(--ha-color-on-neutral-normal);
border: var(--ha-border-width-sm) solid
var(--ha-color-border-neutral-quiet);
overflow: hidden;
height: 32px;
}

View File

@@ -4,6 +4,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { computeDomain } from "../../../../../common/entity/compute_domain";
import "../../../../../components/ha-checkbox";
import "../../../../../components/ha-selector/ha-selector";
import "../../../../../components/ha-settings-row";
@@ -304,11 +305,8 @@ export class HaPlatformTrigger extends LitElement {
return undefined;
}
const context: Record<string, any> = {};
const context = {};
for (const [context_key, data_key] of Object.entries(field.context)) {
if (data_key === "target" && this.description?.target) {
context.target_selector = this._targetSelector(this.description.target);
}
context[context_key] =
data_key === "target"
? this.trigger.target
@@ -416,7 +414,7 @@ export class HaPlatformTrigger extends LitElement {
return "";
}
return this.hass.localize(
`component.${getTriggerDomain(this.trigger.trigger)}.selector.${key}`
`component.${computeDomain(this.trigger.trigger)}.selector.${key}`
);
};

View File

@@ -206,8 +206,8 @@ class HaBlueprintOverview extends LitElement {
sortable: true,
valueColumn: "usageCount",
type: "numeric",
minWidth: "90px",
maxWidth: "90px",
minWidth: "100px",
maxWidth: "120px",
template: (blueprint) => {
const count = blueprint.usageCount ?? 0;
return html`

View File

@@ -101,13 +101,7 @@ export class HaCategoryPicker extends SubscribeMixin(LitElement) {
);
private _getCategories = memoizeOne(
(
categories: CategoryRegistryEntry[] | undefined
): PickerComboBoxItem[] | undefined => {
if (!categories) {
return undefined;
}
(categories: CategoryRegistryEntry[] | undefined): PickerComboBoxItem[] => {
if (!categories || categories.length === 0) {
return [
{

View File

@@ -1,16 +1,19 @@
import {
mdiDotsVertical,
mdiLocationEnter,
mdiLocationExit,
mdiRefresh,
} from "@mdi/js";
import type { RequestSelectedDetail } from "@material/mwc-list/mwc-list-item";
import { mdiDotsVertical, mdiRefresh } from "@mdi/js";
import type { HassEntities } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { shouldHandleRequestSelectedEvent } from "../../../common/mwc/handle-request-selected-event";
import "../../../components/ha-alert";
import "../../../components/ha-bar";
import "../../../components/ha-button-menu";
import "../../../components/ha-card";
import "../../../components/ha-check-list-item";
import "../../../components/ha-list-item";
import "../../../components/ha-metric";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import type {
HassioSupervisorInfo,
@@ -23,16 +26,13 @@ import {
} from "../../../data/hassio/supervisor";
import {
checkForEntityUpdates,
filterUpdateEntitiesParameterized,
filterUpdateEntitiesWithInstall,
} from "../../../data/update";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../types";
import "../dashboard/ha-config-updates";
import { showJoinBetaDialog } from "./updates/show-dialog-join-beta";
import "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "@home-assistant/webawesome/dist/components/divider/divider";
@customElement("ha-config-section-updates")
class HaConfigSectionUpdates extends LitElement {
@@ -53,11 +53,7 @@ class HaConfigSectionUpdates extends LitElement {
}
protected render(): TemplateResult {
const canInstallUpdates = this._filterInstallableUpdateEntities(
this.hass.states,
this._showSkipped
);
const notInstallableUpdates = this._filterNotInstallableUpdateEntities(
const canInstallUpdates = this._filterUpdateEntitiesWithInstall(
this.hass.states,
this._showSkipped
);
@@ -77,86 +73,57 @@ class HaConfigSectionUpdates extends LitElement {
.path=${mdiRefresh}
@click=${this._checkUpdates}
></ha-icon-button>
<ha-dropdown @wa-select=${this._handleOverflowAction}>
<ha-button-menu multi>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-dropdown-item
type="checkbox"
.checked=${this._showSkipped}
value="show_skipped"
<ha-check-list-item
left
@request-selected=${this._toggleSkipped}
.selected=${this._showSkipped}
>
${this.hass.localize("ui.panel.config.updates.show_skipped")}
</ha-dropdown-item>
</ha-check-list-item>
${this._supervisorInfo
? html`
<wa-divider></wa-divider>
<ha-dropdown-item
value="toggle_beta"
<li divider role="separator"></li>
<ha-list-item
@request-selected=${this._toggleBeta}
.disabled=${this._supervisorInfo.channel === "dev"}
>
<ha-svg-icon
.path=${this._supervisorInfo.channel === "stable"
? mdiLocationEnter
: mdiLocationExit}
slot="icon"
></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.updates.${this._supervisorInfo.channel === "stable" ? "join" : "leave"}_beta`
)}
${this._supervisorInfo.channel === "stable"
? this.hass.localize("ui.panel.config.updates.join_beta")
: this.hass.localize(
"ui.panel.config.updates.leave_beta"
)}
</ha-dropdown-item>
</ha-list-item>
`
: nothing}
</ha-dropdown>
: ""}
</ha-button-menu>
</div>
<div class="content">
${canInstallUpdates.length
? html`
<ha-card outlined>
<div class="card-content">
<ha-card outlined>
<div class="card-content">
${canInstallUpdates.length
? html`
<ha-config-updates
.hass=${this.hass}
.narrow=${this.narrow}
.updateEntities=${canInstallUpdates}
.isInstallable=${true}
showAll
></ha-config-updates>
</div>
</ha-card>
`
: nothing}
${notInstallableUpdates.length
? html`
<ha-card outlined>
<div class="card-content">
<ha-config-updates
.hass=${this.hass}
.narrow=${this.narrow}
.updateEntities=${notInstallableUpdates}
.isInstallable=${false}
showAll
></ha-config-updates>
</div>
</ha-card>
`
: nothing}
${canInstallUpdates.length + notInstallableUpdates.length
? nothing
: html`
<ha-card outlined>
<div class="no-updates">
${this.hass.localize("ui.panel.config.updates.no_updates")}
</div>
</ha-card>
`}
`
: html`
<div class="no-updates">
${this.hass.localize(
"ui.panel.config.updates.no_updates"
)}
</div>
`}
</div>
</ha-card>
</div>
</hass-subpage>
`;
@@ -166,19 +133,27 @@ class HaConfigSectionUpdates extends LitElement {
this._supervisorInfo = await fetchHassioSupervisorInfo(this.hass);
}
private _handleOverflowAction(
ev: CustomEvent<{ item: { value: string } }>
): void {
if (ev.detail.item.value === "toggle_beta") {
if (this._supervisorInfo!.channel === "stable") {
showJoinBetaDialog(this, {
join: () => this._setChannel("beta"),
});
} else {
this._setChannel("stable");
}
} else if (ev.detail.item.value === "show_skipped") {
this._showSkipped = !this._showSkipped;
private _toggleSkipped(ev: CustomEvent<RequestSelectedDetail>): void {
if (ev.detail.source !== "property") {
return;
}
this._showSkipped = !this._showSkipped;
}
private async _toggleBeta(
ev: CustomEvent<RequestSelectedDetail>
): Promise<void> {
if (!shouldHandleRequestSelectedEvent(ev)) {
return;
}
if (this._supervisorInfo!.channel === "stable") {
showJoinBetaDialog(this, {
join: async () => this._setChannel("beta"),
});
} else {
this._setChannel("stable");
}
}
@@ -202,14 +177,9 @@ class HaConfigSectionUpdates extends LitElement {
checkForEntityUpdates(this, this.hass);
}
private _filterInstallableUpdateEntities = memoizeOne(
private _filterUpdateEntitiesWithInstall = memoizeOne(
(entities: HassEntities, showSkipped: boolean) =>
filterUpdateEntitiesParameterized(entities, showSkipped, false)
);
private _filterNotInstallableUpdateEntities = memoizeOne(
(entities: HassEntities, showSkipped: boolean) =>
filterUpdateEntitiesParameterized(entities, showSkipped, true)
filterUpdateEntitiesWithInstall(entities, showSkipped)
);
static styles = css`

View File

@@ -31,7 +31,7 @@ import {
import type { UpdateEntity } from "../../../data/update";
import {
checkForEntityUpdates,
filterUpdateEntitiesParameterized,
filterUpdateEntitiesWithInstall,
} from "../../../data/update";
import {
QuickBarMode,
@@ -161,27 +161,24 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
total: 0,
};
private _pages = memoizeOne(
(cloudStatus, isCloudLoaded, hasExternalSettings) => [
isCloudLoaded
? [
{
component: "cloud",
path: "/config/cloud",
name: "Home Assistant Cloud",
info: cloudStatus,
iconPath: mdiCloudLock,
iconColor: "#3B808E",
translationKey: "cloud",
},
...configSections.dashboard,
]
: configSections.dashboard,
hasExternalSettings ? configSections.dashboard_external_settings : [],
configSections.dashboard_2,
configSections.dashboard_3,
]
);
private _pages = memoizeOne((cloudStatus, isCloudLoaded) => [
isCloudLoaded
? [
{
component: "cloud",
path: "/config/cloud",
name: "Home Assistant Cloud",
info: cloudStatus,
iconPath: mdiCloudLock,
iconColor: "#3B808E",
translationKey: "cloud",
},
...configSections.dashboard,
]
: configSections.dashboard,
configSections.dashboard_2,
configSections.dashboard_3,
]);
public hassSubscribe(): UnsubscribeFunc[] {
return [
@@ -206,7 +203,7 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
protected render(): TemplateResult {
const { updates: canInstallUpdates, total: totalUpdates } =
this._filterUpdateEntitiesParameterized(
this._filterUpdateEntitiesWithInstall(
this.hass.states,
this.hass.entities
);
@@ -291,7 +288,6 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
.narrow=${this.narrow}
.total=${totalUpdates}
.updateEntities=${canInstallUpdates}
.isInstallable=${true}
></ha-config-updates>
${totalUpdates > canInstallUpdates.length
? html`
@@ -314,8 +310,7 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
: ""}
${this._pages(
this.cloudStatus,
isComponentLoaded(this.hass, "cloud"),
this.hass.auth.external?.config.hasSettingsScreen
isComponentLoaded(this.hass, "cloud")
).map((categoryPages) =>
categoryPages.length === 0
? nothing
@@ -349,16 +344,14 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
showShortcutsDialog(this);
}
private _filterUpdateEntitiesParameterized = memoizeOne(
private _filterUpdateEntitiesWithInstall = memoizeOne(
(
entities: HomeAssistant["states"],
entityRegistry: HomeAssistant["entities"]
): { updates: UpdateEntity[]; total: number } => {
const updates = filterUpdateEntitiesParameterized(
entities,
false,
false
).filter((entity) => !entityRegistry[entity.entity_id]?.hidden);
const updates = filterUpdateEntitiesWithInstall(entities).filter(
(entity) => !entityRegistry[entity.entity_id]?.hidden
);
return {
updates: updates.slice(0, updates.length === 3 ? updates.length : 2),

View File

@@ -32,8 +32,6 @@ class HaConfigUpdates extends SubscribeMixin(LitElement) {
@property({ type: Number }) public total?: number;
@property({ attribute: false }) public isInstallable = true;
@state() private _devices?: DeviceRegistryEntry[];
@state() private _entities?: EntityRegistryEntry[];
@@ -91,16 +89,9 @@ class HaConfigUpdates extends SubscribeMixin(LitElement) {
return html`
<div class="title" role="heading" aria-level="2">
${this.isInstallable
? this.hass.localize("ui.panel.config.updates.title", {
count: this.total || this.updateEntities.length,
})
: this.hass.localize(
"ui.panel.config.updates.title_not_installable",
{
count: this.total || this.updateEntities.length,
}
)}
${this.hass.localize("ui.panel.config.updates.title", {
count: this.total || this.updateEntities.length,
})}
</div>
<ha-md-list>
${updates.map((entity) => {

View File

@@ -5,9 +5,8 @@ import { fireEvent } from "../../../../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../../../../common/entity/compute_device_name";
import "../../../../components/ha-alert";
import "../../../../components/ha-area-picker";
import "../../../../components/ha-wa-dialog";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog";
import "../../../../components/ha-labels-picker";
import type { HaSwitch } from "../../../../components/ha-switch";
import "../../../../components/ha-textfield";
@@ -20,8 +19,6 @@ import type { DeviceRegistryDetailDialogParams } from "./show-dialog-device-regi
class DialogDeviceRegistryDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
@state() private _nameByUser!: string;
@state() private _error?: string;
@@ -45,15 +42,10 @@ class DialogDeviceRegistryDetail extends LitElement {
this._areaId = this._params.device.area_id || "";
this._labels = this._params.device.labels || [];
this._disabledBy = this._params.device.disabled_by;
this._open = true;
await this.updateComplete;
}
public closeDialog(): void {
this._open = false;
}
private _dialogClosed(): void {
this._error = "";
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
@@ -65,12 +57,10 @@ class DialogDeviceRegistryDetail extends LitElement {
}
const device = this._params.device;
return html`
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${computeDeviceNameDisplay(device, this.hass)}
prevent-scrim-close
@closed=${this._dialogClosed}
<ha-dialog
open
@closed=${this.closeDialog}
.heading=${computeDeviceNameDisplay(device, this.hass)}
>
<div>
${this._error
@@ -78,7 +68,6 @@ class DialogDeviceRegistryDetail extends LitElement {
: ""}
<div class="form">
<ha-textfield
autofocus
.value=${this._nameByUser}
@input=${this._nameChanged}
.label=${this.hass.localize(
@@ -86,6 +75,7 @@ class DialogDeviceRegistryDetail extends LitElement {
)}
.placeholder=${device.name || ""}
.disabled=${this._submitting}
dialogInitialFocus
></ha-textfield>
<ha-area-picker
.hass=${this.hass}
@@ -141,25 +131,22 @@ class DialogDeviceRegistryDetail extends LitElement {
</div>
</div>
</div>
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
@click=${this.closeDialog}
.disabled=${this._submitting}
appearance="plain"
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${this._submitting}
>
${this.hass.localize("ui.dialogs.device-registry-detail.update")}
</ha-button>
</ha-dialog-footer>
</ha-wa-dialog>
<ha-button
slot="secondaryAction"
@click=${this.closeDialog}
.disabled=${this._submitting}
appearance="plain"
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${this._submitting}
>
${this.hass.localize("ui.dialogs.device-registry-detail.update")}
</ha-button>
</ha-dialog>
`;
}

View File

@@ -64,7 +64,6 @@ import "../../../components/ha-filter-floor-areas";
import "../../../components/ha-filter-integrations";
import "../../../components/ha-filter-labels";
import "../../../components/ha-filter-states";
import "../../../components/ha-filter-voice-assistants";
import "../../../components/ha-icon";
import "../../../components/ha-icon-button";
import "../../../components/ha-md-divider";
@@ -116,8 +115,6 @@ import { isHelperDomain } from "../helpers/const";
import "../integrations/ha-integration-overflow-menu";
import { showAddIntegrationDialog } from "../integrations/show-add-integration-dialog";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
import { getEntityVoiceAssistantsIds } from "../../../data/expose";
import "../voice-assistants/expose/expose-assistant-icon";
export interface StateEntity extends Omit<
EntityRegistryEntry,
@@ -496,31 +493,6 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
template: (entry) =>
entry.label_entries.map((lbl) => lbl.name).join(" "),
},
voice_assistants: {
title: localize(
"ui.panel.config.entities.picker.headers.voice_assistants"
),
type: "icon",
defaultHidden: true,
minWidth: "100px",
maxWidth: "100px",
template: (entry) => {
const exposedToVoiceAssistantIds = getEntityVoiceAssistantsIds(
this._entities,
entry.entity_id
);
return html` ${exposedToVoiceAssistantIds.length !== 0
? exposedToVoiceAssistantIds.map(
(vaId) =>
html` <voice-assistants-expose-assistant-icon
.assistant=${vaId}
.hass=${this.hass}
>
</voice-assistants-expose-assistant-icon>`
)
: "—"}`;
},
},
})
);
@@ -665,16 +637,6 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
filteredEntities = filteredEntities.filter((entity) =>
entity.labels.some((lbl) => (filter as string[]).includes(lbl))
);
} else if (
key === "ha-filter-voice-assistants" &&
Array.isArray(filter) &&
filter.length
) {
filteredEntities = filteredEntities.filter((entity) =>
getEntityVoiceAssistantsIds(this._entities, entity.entity_id).some(
(va) => (filter as string[]).includes(va)
)
);
}
});
@@ -1114,15 +1076,6 @@ ${
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-labels>
<ha-filter-voice-assistants
.hass=${this.hass}
.value=${this._filters["ha-filter-voice-assistants"]}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-voice-assistants"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-voice-assistants>
${
includeAddDeviceFab
? html`<ha-fab
@@ -1175,7 +1128,6 @@ ${
const subEntry = this._searchParms.get("sub_entry");
const device = this._searchParms.get("device");
const label = this._searchParms.get("label");
const voiceAssistant = this._searchParms.get("voice_assistant");
if (!domain && !configEntry && !label && !device) {
return;
@@ -1188,7 +1140,6 @@ ${
"ha-filter-integrations": domain ? [domain] : [],
"ha-filter-devices": device ? [device] : [],
"ha-filter-labels": label ? [label] : [],
"ha-filter-voice-assistants": voiceAssistant ? [voiceAssistant] : [],
config_entry: configEntry ? [configEntry] : [],
sub_entry: subEntry ? [subEntry] : [],
};

View File

@@ -26,6 +26,7 @@ import {
mdiScrewdriver,
mdiScriptText,
mdiShape,
mdiLan,
mdiSofa,
mdiTools,
mdiUpdate,
@@ -105,24 +106,7 @@ export const configSections: Record<string, PageNavigation[]> = {
iconColor: "#3263C3",
},
],
dashboard_external_settings: [
{
path: "#external-app-configuration",
translationKey: "companion",
iconPath: mdiCellphoneCog,
iconColor: "#8E24AA",
},
],
dashboard_2: [
{
path: "/config/matter",
name: "Matter",
iconPath:
"M7.228375 6.41685c0.98855 0.80195 2.16365 1.3412 3.416275 1.56765V1.30093l1.3612 -0.7854275 1.360125 0.7854275V7.9845c1.252875 -0.226675 2.4283 -0.765875 3.41735 -1.56765l2.471225 1.4293c-4.019075 3.976275 -10.490025 3.976275 -14.5091 0l2.482925 -1.4293Zm3.00335 17.067575c1.43325 -5.47035 -1.8052 -11.074775 -7.2604 -12.564675v2.859675c1.189125 0.455 2.244125 1.202875 3.0672 2.174275L0.25 19.2955v1.5719l1.3611925 0.781175L7.39865 18.3068c0.430175 1.19825 0.550625 2.48575 0.35015 3.743l2.482925 1.434625ZM21.034 10.91975c-5.452225 1.4932 -8.6871 7.09635 -7.254025 12.564675l2.47655 -1.43035c-0.200025 -1.257275 -0.079575 -2.544675 0.35015 -3.743025l5.7832 3.337525L23.75 20.86315V19.2955L17.961475 15.9537c0.8233 -0.97115 1.878225 -1.718975 3.0672 -2.174275l0.005325 -2.859675Z",
iconColor: "#2458B3",
component: "matter",
translationKey: "matter",
},
{
path: "/config/zha",
name: "Zigbee",
@@ -142,8 +126,7 @@ export const configSections: Record<string, PageNavigation[]> = {
{
path: "/knx",
name: "KNX",
iconPath:
"M 3.9861338,14.261456 3.7267552,13.934877 6.3179131,11.306266 H 4.466374 l -2.6385205,2.68258 V 11.312882 H 0.00440574 L 0,17.679803 l 1.8278535,5.43e-4 v -1.818482 l 0.7225444,-0.732459 2.1373588,2.543782 2.1869324,-5.44e-4 M 24,17.680369 21.809238,17.669359 19.885559,15.375598 17.640262,17.68037 h -1.828407 l 3.236048,-3.302138 -2.574075,-3.067547 2.135161,0.0016 1.610309,1.87687 1.866403,-1.87687 h 1.828429 l -2.857742,2.87478 m -10.589867,-2.924898 2.829625,3.990552 -0.01489,-3.977887 1.811889,-0.0044 0.0011,6.357564 -2.093314,-5.44e-4 -2.922133,-3.947594 -0.0314,3.947594 H 8.2581097 V 11.261677 M 11.971203,6.3517488 c 0,0 2.800714,-0.093203 6.172001,1.0812045 3.462393,1.0898845 5.770926,3.4695627 5.770926,3.4695627 l -1.823898,-5.43e-4 C 22.088532,10.900273 20.577938,9.4271528 17.660223,8.5024618 15.139256,7.703366 12.723057,7.645835 12.111178,7.6449876 l -9.71e-4,0.0011 c 0,0 -0.0259,-6.4e-4 -0.07527,-9.714e-4 -0.04726,3.33e-4 -0.07201,9.714e-4 -0.07201,9.714e-4 v -0.00113 C 11.337007,7.6453728 8.8132091,7.7001736 6.2821829,8.5024618 3.3627914,9.4276738 1.8521646,10.901973 1.8521646,10.901973 l -1.82398708,5.43e-4 C 0.03128403,10.899322 2.339143,8.5221038 5.799224,7.4329533 9.170444,6.2585642 11.971203,6.3517488 11.971203,6.3517488 Z",
iconPath: mdiLan,
iconColor: "#4EAA66",
component: "knx",
translationKey: "knx",
@@ -152,7 +135,10 @@ export const configSections: Record<string, PageNavigation[]> = {
path: "/config/thread",
name: "Thread",
iconPath:
"m 17.126982,8.0730792 c 0,-0.7297242 -0.593746,-1.32357 -1.323637,-1.32357 -0.729454,0 -1.323199,0.5938458 -1.323199,1.32357 v 1.3234242 l 1.323199,1.458e-4 c 0.729891,0 1.323637,-0.5937006 1.323637,-1.32357 z M 11.999709,0 C 5.3829818,0 0,5.3838955 0,12.001455 0,18.574352 5.3105455,23.927406 11.865164,24 V 12.012075 l -3.9275642,-2.91e-4 c -1.1669814,0 -2.1169453,0.949979 -2.1169453,2.118323 0,1.16718 0.9499639,2.116868 2.1169453,2.116868 v 2.615717 c -2.6093089,0 -4.732218,-2.12327 -4.732218,-4.732585 0,-2.61048 2.1229091,-4.7343308 4.732218,-4.7343308 l 3.9275642,5.82e-4 v -1.323279 c 0,-2.172296 1.766691,-3.9395777 3.938181,-3.9395777 2.171928,0 3.9392,1.7672817 3.9392,3.9395777 0,2.1721498 -1.767272,3.9395768 -3.9392,3.9395768 l -1.323199,-1.45e-4 V 23.744102 C 19.911127,22.597726 24,17.768833 24,12.001455 24,5.3838955 18.616727,0 11.999709,0 Z",
"M82.498,0C37.008,0,0,37.008,0,82.496c0,45.181,36.51,81.977,81.573,82.476V82.569l-27.002-0.002 c-8.023,0-14.554,6.53-14.554,14.561c0,8.023,6.531,14.551,14.554,14.551v17.98c-17.939,0-32.534-14.595-32.534-32.531 c0-17.944,14.595-32.543,32.534-32.543l27.002,0.004v-9.096c0-14.932,12.146-27.08,27.075-27.08 c14.932,0,27.082,12.148,27.082,27.08c0,14.931-12.15,27.08-27.082,27.08l-9.097-0.001v80.641 C136.889,155.333,165,122.14,165,82.496C165,37.008,127.99,0,82.498,0z",
iconSecondaryPath:
"M117.748 55.493C117.748 50.477 113.666 46.395 108.648 46.395C103.633 46.395 99.551 50.477 99.551 55.493V64.59L108.648 64.591C113.666 64.591 117.748 60.51 117.748 55.493Z",
iconViewBox: "0 0 165 165",
iconColor: "#ED7744",
component: "thread",
translationKey: "thread",
@@ -169,7 +155,8 @@ export const configSections: Record<string, PageNavigation[]> = {
path: "/insteon",
name: "Insteon",
iconPath:
"m 12.001571,6.3842473 h 0.02973 c 3.652189,0 6.767389,-2.29456 7.987462,-5.5177193 L 15.389382,0 Z m 0,0 h -0.02972 c -3.6522186,0 -6.7673314,-2.2918546 -7.9874477,-5.5177193 h -0.00271 L 8.6111273,0 Z M 6.3840436,11.999287 v -0.02972 c 0,-3.6524074 -2.2944727,-6.7675928 -5.51754469,-7.9877383 L 0,8.6114473 Z m 0,0 v 0.02964 c 0,3.652378 -2.2917818,6.767578 -5.51754469,7.987796 v 0.0026 L 0,15.389818 Z M 24,8.6114473 23.133527,3.9818327 v 0.00269 C 19.907636,5.2046836 17.616,8.3198691 17.616,11.972276 v 0.02966 0.02972 0.0027 c 0,3.65232 2.2944,6.76752 5.517527,7.987738 L 24,15.392436 17.616,12.001935 Z M 20.018618,23.133527 15.389091,24 11.99872,17.615709 h 0.02964 c 3.652218,0 6.767418,2.291927 7.987491,5.517818 z M 11.99872,17.615709 8.6082618,24 3.9788364,23.133527 C 5.1989527,19.9104 8.3140655,17.615709 11.966284,17.615709 h 0.0027 z",
"M82.5108 43.8917H82.7152C107.824 43.8917 129.241 28.1166 137.629 5.95738L105.802 0L82.5108 43.8917ZM82.5108 43.8917H82.3065C57.1975 43.8917 35.7811 28.1352 27.3928 5.95738H27.3742L59.2015 0L82.5108 43.8917ZM43.8903 82.4951V82.2908C43.8903 57.1805 28.1158 35.7636 5.95718 27.3751L0 59.2037L43.8903 82.4951ZM43.8903 82.4951V82.6989C43.8903 107.809 28.1343 129.226 5.95718 137.615V137.633L0 105.805L43.8903 82.4951ZM165 59.2037L159.043 27.3751V27.3936C136.865 35.7822 121.11 57.1991 121.11 82.3094V82.5133V82.7176V82.7363C121.11 107.846 136.884 129.263 159.043 137.652L165 105.823L121.11 82.5133L165 59.2037ZM137.628 159.043L105.8 165L82.4912 121.108H82.695C107.804 121.108 129.221 136.865 137.609 159.043H137.628ZM82.4912 121.108L59.1818 165L27.3545 159.043C35.7428 136.884 57.1592 121.108 82.2682 121.108H82.2868H82.4912Z",
iconViewBox: "0 0 165 165",
iconColor: "#E4002C",
component: "insteon",
translationKey: "insteon",
@@ -190,6 +177,12 @@ export const configSections: Record<string, PageNavigation[]> = {
iconColor: "#5A87FA",
component: ["person", "users"],
},
{
path: "#external-app-configuration",
translationKey: "companion",
iconPath: mdiCellphoneCog,
iconColor: "#8E24AA",
},
{
path: "/config/system",
translationKey: "system",

View File

@@ -260,6 +260,8 @@ export class DialogHelperDetail extends LitElement {
open
@closed=${this.closeDialog}
class=${classMap({ "button-left": !this._domain })}
scrimClickAction
escapeKeyAction
.hideActions=${!this._domain}
.heading=${createCloseHeading(
this.hass,

View File

@@ -105,7 +105,7 @@ class HaTimerForm extends LitElement {
<ha-checkbox
.configValue=${"restore"}
.checked=${this._restore}
@change=${this._toggleRestore}
@click=${this._toggleRestore}
.disabled=${this.disabled}
>
</ha-checkbox>
@@ -135,8 +135,11 @@ class HaTimerForm extends LitElement {
});
}
private _toggleRestore(ev) {
this._restore = ev.target.checked;
private _toggleRestore() {
if (this.disabled) {
return;
}
this._restore = !this._restore;
fireEvent(this, "value-changed", {
value: { ...this._item, restore: this._restore },
});

View File

@@ -51,7 +51,6 @@ import "../../../components/ha-filter-devices";
import "../../../components/ha-filter-entities";
import "../../../components/ha-filter-floor-areas";
import "../../../components/ha-filter-labels";
import "../../../components/ha-filter-voice-assistants";
import "../../../components/ha-icon";
import "../../../components/ha-icon-overflow-menu";
import "../../../components/ha-md-divider";
@@ -123,8 +122,6 @@ import "../integrations/ha-integration-overflow-menu";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
import { isHelperDomain, type HelperDomain } from "./const";
import { showHelperDetailDialog } from "./show-dialog-helper-detail";
import { getEntityVoiceAssistantsIds } from "../../../data/expose";
import "../voice-assistants/expose/expose-assistant-icon";
interface HelperItem {
id: string;
@@ -208,7 +205,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
})
private _activeHiddenColumns?: string[];
@state() private _helperEntities: HassEntity[] = [];
@state() private _stateItems: HassEntity[] = [];
@state() private _disabledEntityEntries?: EntityRegistryEntry[];
@@ -226,8 +223,6 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
@state() private _diagnosticHandlers?: Record<string, boolean>;
@state() private _searchParms = new URLSearchParams(window.location.search);
@storage({
storage: "sessionStorage",
key: "helpers-table-filters",
@@ -250,7 +245,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg!: EntityRegistryEntry[];
@state() private _filteredHelperEntityIds?: string[] | null;
@state() private _filteredStateItems?: string[] | null;
private _sizeController = new ResizeController(this, {
callback: (entries) => entries[0]?.contentRect.width,
@@ -485,32 +480,6 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
</ha-icon-overflow-menu>
`,
},
voice_assistants: {
title: localize(
"ui.panel.config.helpers.picker.headers.voice_assistants"
),
type: "icon",
defaultHidden: true,
minWidth: "100px",
maxWidth: "100px",
template: (helper) => {
const exposedToVoiceAssistantIds = getEntityVoiceAssistantsIds(
this._entityReg,
helper.entity_id
);
return html` ${exposedToVoiceAssistantIds.length !== 0
? exposedToVoiceAssistantIds.map(
(vaId) => html`
<voice-assistants-expose-assistant-icon
.assistant=${vaId}
.hass=${this.hass}
>
</voice-assistants-expose-assistant-icon>
`
)
: "—"}`;
},
},
})
);
@@ -641,7 +610,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
protected render(): TemplateResult {
if (
!this.hass ||
this._helperEntities === undefined ||
this._stateItems === undefined ||
this._entityEntries === undefined ||
this._configEntries === undefined
) {
@@ -716,14 +685,14 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
(!this._sizeController.value && this.hass.dockedSidebar === "docked");
const helpers = this._getItems(
this.hass.localize,
this._helperEntities,
this._stateItems,
this._disabledEntityEntries || [],
this._entityEntries,
this._configEntries,
this._entityReg,
this._categories,
this._labels,
this._filteredHelperEntityIds
this._filteredStateItems
);
return html`
<hass-tabs-subpage-data-table
@@ -810,15 +779,6 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-categories>
<ha-filter-voice-assistants
.hass=${this.hass}
.value=${this._filters["ha-filter-voice-assistants"]}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-voice-assistants"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-voice-assistants>
${!this.narrow
? html`<ha-md-button-menu slot="selection-bar">
@@ -981,7 +941,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
filter.length
) {
const labelItems = new Set<string>();
this._helperEntities
this._stateItems
.filter((stateItem) =>
entityRegistryByEntityId(this._entityReg)[
stateItem.entity_id
@@ -1000,13 +960,14 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
? // @ts-ignore
items.intersection(labelItems)
: new Set([...items].filter((x) => labelItems!.has(x)));
} else if (
}
if (
key === "ha-filter-categories" &&
Array.isArray(filter) &&
filter.length
) {
const categoryItems = new Set<string>();
this._helperEntities
this._stateItems
.filter(
(stateItem) =>
filter[0] ===
@@ -1026,85 +987,10 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
? // @ts-ignore
items.intersection(categoryItems)
: new Set([...items].filter((x) => categoryItems!.has(x)));
} else if (
key === "ha-filter-voice-assistants" &&
Array.isArray(filter) &&
filter.length
) {
const assistItems = new Set<string>();
this._helperEntities
.filter((stateItem) =>
getEntityVoiceAssistantsIds(
this._entityReg,
stateItem.entity_id
).some((va) => (filter as string[]).includes(va))
)
.forEach((stateItem) => assistItems.add(stateItem.entity_id));
(this._disabledEntityEntries || [])
.filter((entry) =>
getEntityVoiceAssistantsIds(this._entityReg, entry.entity_id).some(
(va) => (filter as string[]).includes(va)
)
)
.forEach((entry) => assistItems.add(entry.entity_id));
if (!items) {
items = assistItems;
continue;
}
items =
"intersection" in items
? // @ts-ignore
items.intersection(assistItems)
: new Set([...items].filter((x) => assistItems!.has(x)));
}
}
this._filteredHelperEntityIds = items ? [...items] : undefined;
}
public connectedCallback() {
super.connectedCallback();
window.addEventListener("location-changed", this._locationChanged);
window.addEventListener("popstate", this._popState);
}
disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener("location-changed", this._locationChanged);
window.removeEventListener("popstate", this._popState);
}
private _locationChanged = () => {
if (window.location.search.substring(1) !== this._searchParms.toString()) {
this._searchParms = new URLSearchParams(window.location.search);
this._setFiltersFromUrl();
}
};
private _popState = () => {
if (window.location.search.substring(1) !== this._searchParms.toString()) {
this._searchParms = new URLSearchParams(window.location.search);
this._setFiltersFromUrl();
}
};
private _setFiltersFromUrl() {
const device = this._searchParms.get("device");
const label = this._searchParms.get("label");
const category = this._searchParms.get("category");
const voiceAssistant = this._searchParms.get("voice_assistant");
if (!category && !label && !device) {
return;
}
this._filter = history.state?.filter || "";
this._filters = {
"ha-filter-devices": device ? [device] : [],
"ha-filter-labels": label ? [label] : [],
"ha-filter-categories": category ? [category] : [],
"ha-filter-voice-assistants": voiceAssistant ? [voiceAssistant] : [],
};
this._filteredStateItems = items ? [...items] : undefined;
}
private _clearFilter() {
@@ -1207,7 +1093,7 @@ ${rejected
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
this._setFiltersFromUrl();
this._fetchEntitySources();
if (isComponentLoaded(this.hass, "diagnostics")) {
@@ -1320,10 +1206,6 @@ ${rejected
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._setFiltersFromUrl();
}
if (!this._entityEntries || !this._configEntries) {
return;
}
@@ -1343,7 +1225,7 @@ ${rejected
}
let changed =
!this._helperEntities ||
!this._stateItems ||
changedProps.has("_entityEntries") ||
changedProps.has("_configEntries") ||
changedProps.has("_entitySource");
@@ -1358,17 +1240,17 @@ ${rejected
const entityIds = Object.keys(this._entitySource);
const newHelpers = Object.values(this.hass!.states).filter(
const newStates = Object.values(this.hass!.states).filter(
(entity) =>
entityIds.includes(entity.entity_id) ||
isHelperDomain(computeStateDomain(entity))
);
if (
this._helperEntities.length !== newHelpers.length ||
!this._helperEntities.every((val, idx) => newHelpers[idx] === val)
this._stateItems.length !== newStates.length ||
!this._stateItems.every((val, idx) => newStates[idx] === val)
) {
this._helperEntities = newHelpers;
this._stateItems = newStates;
}
}

View File

@@ -327,6 +327,7 @@ class AddIntegrationDialog extends LitElement {
return html`<ha-dialog
open
@closed=${this.closeDialog}
scrimClickAction
hideActions
.heading=${createCloseHeading(
this.hass,

View File

@@ -23,6 +23,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { isDevVersion } from "../../../common/config/version";
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import { copyToClipboard } from "../../../common/util/copy-clipboard";
@@ -212,7 +213,10 @@ class HaConfigEntryRow extends LitElement {
? html`<ha-button slot="end" @click=${this._handleEnable}>
${this.hass.localize("ui.common.enable")}
</ha-button>`
: configPanel && !stateText
: configPanel &&
(item.domain !== "matter" ||
isDevVersion(this.hass.config.version)) &&
!stateText
? html`<a
slot="end"
href=${`/${configPanel}?config_entry=${item.entry_id}`}

View File

@@ -1,14 +1,21 @@
import { mdiCogOutline } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-list";
import "../../../../../components/ha-list-item";
import "../../../../../components/ha-code-editor";
import "../../../../../components/ha-formfield";
import "../../../../../components/ha-switch";
import "../../../../../components/ha-button";
import { getConfigEntries } from "../../../../../data/config_entries";
import { showOptionsFlowDialog } from "../../../../../dialogs/config-flow/show-dialog-options-flow";
import "../../../../../layouts/hass-subpage";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import {
subscribeBluetoothConnectionAllocations,
subscribeBluetoothScannerState,
subscribeBluetoothScannersDetails,
} from "../../../../../data/bluetooth";
import type {
BluetoothAllocationsData,
BluetoothScannerState,
@@ -16,17 +23,10 @@ import type {
HaScannerType,
} from "../../../../../data/bluetooth";
import {
subscribeBluetoothConnectionAllocations,
subscribeBluetoothScannerState,
subscribeBluetoothScannersDetails,
} from "../../../../../data/bluetooth";
import type { ConfigEntry } from "../../../../../data/config_entries";
import { getConfigEntries } from "../../../../../data/config_entries";
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
import { showOptionsFlowDialog } from "../../../../../dialogs/config-flow/show-dialog-options-flow";
import "../../../../../layouts/hass-subpage";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
getValueInPercentage,
roundWithOneDecimal,
} from "../../../../../util/calculate";
import "../../../../../components/ha-metric";
@customElement("bluetooth-config-dashboard")
export class BluetoothConfigDashboard extends LitElement {
@@ -34,14 +34,18 @@ export class BluetoothConfigDashboard extends LitElement {
@property({ type: Boolean }) public narrow = false;
@state() private _configEntries: ConfigEntry[] = [];
@state() private _connectionAllocationData: BluetoothAllocationsData[] = [];
@state() private _scannerStates: Record<string, BluetoothScannerState> = {};
@state() private _connectionAllocationsError?: string;
@state() private _scannerState?: BluetoothScannerState;
@state() private _scannerDetails?: BluetoothScannersDetails;
private _configEntry = new URLSearchParams(window.location.search).get(
"config_entry"
);
private _unsubConnectionAllocations?: (() => Promise<void>) | undefined;
private _unsubScannerState?: (() => Promise<void>) | undefined;
@@ -51,44 +55,41 @@ export class BluetoothConfigDashboard extends LitElement {
public connectedCallback(): void {
super.connectedCallback();
if (this.hass) {
this._loadConfigEntries();
this._subscribeBluetoothConnectionAllocations();
this._subscribeBluetoothScannerState();
this._subscribeScannerDetails();
}
}
private async _loadConfigEntries(): Promise<void> {
this._configEntries = await getConfigEntries(this.hass, {
domain: "bluetooth",
});
}
private async _subscribeBluetoothConnectionAllocations(): Promise<void> {
if (this._unsubConnectionAllocations) {
if (this._unsubConnectionAllocations || !this._configEntry) {
return;
}
this._unsubConnectionAllocations =
await subscribeBluetoothConnectionAllocations(
this.hass.connection,
(data) => {
this._connectionAllocationData = data;
}
);
try {
this._unsubConnectionAllocations =
await subscribeBluetoothConnectionAllocations(
this.hass.connection,
(data) => {
this._connectionAllocationData = data;
},
this._configEntry
);
} catch (err: any) {
this._unsubConnectionAllocations = undefined;
this._connectionAllocationsError = err.message;
}
}
private async _subscribeBluetoothScannerState(): Promise<void> {
if (this._unsubScannerState) {
if (this._unsubScannerState || !this._configEntry) {
return;
}
this._unsubScannerState = await subscribeBluetoothScannerState(
this.hass.connection,
(scannerState) => {
this._scannerStates = {
...this._scannerStates,
[scannerState.source]: scannerState,
};
}
this._scannerState = scannerState;
},
this._configEntry
);
}
@@ -121,19 +122,31 @@ export class BluetoothConfigDashboard extends LitElement {
}
protected render(): TemplateResult {
// Get scanner type to determine if options button should be shown
const scannerDetails =
this._scannerState && this._scannerDetails?.[this._scannerState.source];
const scannerType: HaScannerType =
scannerDetails?.scanner_type ?? "unknown";
const isRemoteScanner = scannerType === "remote";
return html`
<hass-subpage
header=${this.hass.localize("ui.panel.config.bluetooth.title")}
.narrow=${this.narrow}
.hass=${this.hass}
>
<hass-subpage .narrow=${this.narrow} .hass=${this.hass}>
<div class="content">
<ha-card
.header=${this.hass.localize(
"ui.panel.config.bluetooth.settings_title"
)}
>
<ha-list>${this._renderAdaptersList()}</ha-list>
<div class="card-content">${this._renderScannerState()}</div>
${!isRemoteScanner
? html`<div class="card-actions">
<ha-button @click=${this._openOptionFlow}
>${this.hass.localize(
"ui.panel.config.bluetooth.option_flow"
)}</ha-button
>
</div>`
: nothing}
</ha-card>
<ha-card
.header=${this.hass.localize(
@@ -170,11 +183,7 @@ export class BluetoothConfigDashboard extends LitElement {
)}
>
<div class="card-content">
<p>
${this.hass.localize(
"ui.panel.config.bluetooth.connection_slot_allocations_monitor_description"
)}
</p>
${this._renderConnectionAllocations()}
</div>
<div class="card-actions">
<ha-button
@@ -192,90 +201,13 @@ export class BluetoothConfigDashboard extends LitElement {
`;
}
private _renderAdaptersList() {
if (this._configEntries.length === 0) {
return html`<ha-list-item noninteractive>
${this.hass.localize(
"ui.panel.config.bluetooth.no_scanner_state_available"
)}
</ha-list-item>`;
}
// Build source to device mapping (same as visualization)
const sourceDevices: Record<string, DeviceRegistryEntry> = {};
Object.values(this.hass.devices).forEach((device) => {
const btConnection = device.connections.find(
(connection) => connection[0] === "bluetooth"
);
if (btConnection) {
sourceDevices[btConnection[1]] = device;
}
});
return this._configEntries.map((entry) => {
// Find scanner by matching device's config_entries to this entry
const scannerDetails = this._scannerDetails
? Object.values(this._scannerDetails).find((d) => {
const device = sourceDevices[d.source];
return device?.config_entries.includes(entry.entry_id);
})
: undefined;
const scannerState = scannerDetails
? this._scannerStates[scannerDetails.source]
: undefined;
const scannerType: HaScannerType =
scannerDetails?.scanner_type ?? "unknown";
const isRemoteScanner = scannerType === "remote";
const hasMismatch =
scannerState &&
scannerState.current_mode !== scannerState.requested_mode;
// Find allocation data for this scanner
const allocations = scannerDetails
? this._connectionAllocationData.find(
(a) => a.source === scannerDetails.source
)
: undefined;
const secondaryText = this._formatScannerModeText(scannerState);
return html`
<ha-list-item twoline hasMeta noninteractive>
<span>${entry.title}</span>
<span slot="secondary">
${secondaryText}${allocations
? allocations.slots > 0
? ` · ${allocations.slots - allocations.free}/${allocations.slots} ${this.hass.localize("ui.panel.config.bluetooth.active_connections")}`
: ` · ${this.hass.localize("ui.panel.config.bluetooth.no_connection_slots")}`
: nothing}
</span>
${!isRemoteScanner
? html`<ha-icon-button
slot="meta"
.path=${mdiCogOutline}
.entry=${entry}
@click=${this._openOptionFlow}
.label=${this.hass.localize(
"ui.panel.config.bluetooth.option_flow"
)}
></ha-icon-button>`
: nothing}
</ha-list-item>
${hasMismatch && scannerDetails
? this._renderScannerMismatchWarning(
entry.title,
scannerState,
scannerType
)
: nothing}
`;
});
}
private _getUsedAllocations = (used: number, total: number) =>
roundWithOneDecimal(getValueInPercentage(used, 0, total));
private _renderScannerMismatchWarning(
name: string,
scannerState: BluetoothScannerState,
scannerType: HaScannerType
scannerType: HaScannerType,
formatMode: (mode: string | null) => string
) {
const instructions: string[] = [];
@@ -306,9 +238,8 @@ export class BluetoothConfigDashboard extends LitElement {
${this.hass.localize(
"ui.panel.config.bluetooth.scanner_mode_mismatch",
{
name: name,
requested: this._formatMode(scannerState.requested_mode),
current: this._formatMode(scannerState.current_mode),
requested: formatMode(scannerState.requested_mode),
current: formatMode(scannerState.current_mode),
}
)}
</div>
@@ -318,59 +249,127 @@ export class BluetoothConfigDashboard extends LitElement {
</ha-alert>`;
}
private _formatMode(mode: string | null): string {
switch (mode) {
case null:
return this.hass.localize(
"ui.panel.config.bluetooth.scanning_mode_none"
);
case "active":
return this.hass.localize(
"ui.panel.config.bluetooth.scanning_mode_active"
);
case "passive":
return this.hass.localize(
"ui.panel.config.bluetooth.scanning_mode_passive"
);
default:
return mode;
}
}
private _formatModeLabel(mode: string | null): string {
switch (mode) {
case null:
return this.hass.localize(
"ui.panel.config.bluetooth.scanning_mode_none_label"
);
case "active":
return this.hass.localize(
"ui.panel.config.bluetooth.scanning_mode_active_label"
);
case "passive":
return this.hass.localize(
"ui.panel.config.bluetooth.scanning_mode_passive_label"
);
default:
return mode;
}
}
private _formatScannerModeText(
scannerState: BluetoothScannerState | undefined
): string {
if (!scannerState) {
return this.hass.localize(
"ui.panel.config.bluetooth.scanner_state_unknown"
);
private _renderScannerState() {
if (!this._configEntry || !this._scannerState) {
return html`<div>
${this.hass.localize(
"ui.panel.config.bluetooth.no_scanner_state_available"
)}
</div>`;
}
return this._formatModeLabel(scannerState.current_mode);
const scannerState = this._scannerState;
// Find the scanner details for this source
const scannerDetails = this._scannerDetails?.[scannerState.source];
const scannerType: HaScannerType =
scannerDetails?.scanner_type ?? "unknown";
const formatMode = (mode: string | null) => {
switch (mode) {
case null:
return this.hass.localize(
"ui.panel.config.bluetooth.scanning_mode_none"
);
case "active":
return this.hass.localize(
"ui.panel.config.bluetooth.scanning_mode_active"
);
case "passive":
return this.hass.localize(
"ui.panel.config.bluetooth.scanning_mode_passive"
);
default:
return mode; // Fallback for unknown modes
}
};
return html`
<div class="scanner-state">
<div class="state-row">
<span
>${this.hass.localize(
"ui.panel.config.bluetooth.current_scanning_mode"
)}:</span
>
<span class="state-value"
>${formatMode(scannerState.current_mode)}</span
>
</div>
<div class="state-row">
<span
>${this.hass.localize(
"ui.panel.config.bluetooth.requested_scanning_mode"
)}:</span
>
<span class="state-value"
>${formatMode(scannerState.requested_mode)}</span
>
</div>
${scannerState.current_mode !== scannerState.requested_mode
? this._renderScannerMismatchWarning(
scannerState,
scannerType,
formatMode
)
: nothing}
</div>
`;
}
private _openOptionFlow(ev: Event) {
const button = ev.currentTarget as HTMLElement & { entry: ConfigEntry };
showOptionsFlowDialog(this, button.entry);
private _renderConnectionAllocations() {
if (this._connectionAllocationsError) {
return html`<ha-alert alert-type="error"
>${this._connectionAllocationsError}</ha-alert
>`;
}
if (this._connectionAllocationData.length === 0) {
return html`<div>
${this.hass.localize(
"ui.panel.config.bluetooth.no_connection_slot_allocations"
)}
</div>`;
}
const allocations = this._connectionAllocationData[0];
const allocationsUsed = allocations.slots - allocations.free;
const allocationsTotal = allocations.slots;
if (allocationsTotal === 0) {
return html`<div>
${this.hass.localize(
"ui.panel.config.bluetooth.no_active_connection_support"
)}
</div>`;
}
return html`
<p>
${this.hass.localize(
"ui.panel.config.bluetooth.connection_slot_allocations_monitor_details",
{ slots: allocationsTotal }
)}
</p>
<ha-metric
.heading=${this.hass.localize(
"ui.panel.config.bluetooth.used_connection_slot_allocations"
)}
.value=${this._getUsedAllocations(allocationsUsed, allocationsTotal)}
.tooltip=${allocations.allocated.length > 0
? `${allocationsUsed}/${allocationsTotal} (${allocations.allocated.join(", ")})`
: `${allocationsUsed}/${allocationsTotal}`}
></ha-metric>
`;
}
private async _openOptionFlow() {
const configEntryId = this._configEntry;
if (!configEntryId) {
return;
}
const configEntries = await getConfigEntries(this.hass, {
domain: "bluetooth",
});
const configEntry = configEntries.find(
(entry) => entry.entry_id === configEntryId
);
showOptionsFlowDialog(this, configEntry!);
}
static get styles(): CSSResultGroup {
@@ -395,9 +394,17 @@ export class BluetoothConfigDashboard extends LitElement {
display: flex;
justify-content: flex-end;
}
ha-list-item {
--mdc-list-item-meta-display: flex;
--mdc-list-item-meta-size: 48px;
.scanner-state {
margin-bottom: 16px;
}
.state-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
}
.state-value {
font-weight: 500;
}
`,
];

View File

@@ -1,19 +1,11 @@
import { mdiAlertCircle, mdiCheckCircle, mdiPlus } from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../../../common/config/is_component_loaded";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-expansion-panel";
import "../../../../../components/ha-fab";
import "../../../../../components/ha-svg-icon";
import type { ConfigEntry } from "../../../../../data/config_entries";
import { getConfigEntries } from "../../../../../data/config_entries";
import type { HomeAssistant } from "../../../../../types";
import "../../../../../components/ha-button";
import {
acceptSharedMatterDevice,
canCommissionMatterExternal,
@@ -26,6 +18,7 @@ import {
import { showPromptDialog } from "../../../../../dialogs/generic/show-dialog-box";
import "../../../../../layouts/hass-subpage";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
@customElement("matter-config-dashboard")
export class MatterConfigDashboard extends LitElement {
@@ -33,8 +26,6 @@ export class MatterConfigDashboard extends LitElement {
@property({ type: Boolean }) public narrow = false;
@state() private _configEntry?: ConfigEntry;
@state() private _error?: string;
private _unsub?: UnsubscribeFunc;
@@ -44,33 +35,10 @@ export class MatterConfigDashboard extends LitElement {
this._stopRedirect();
}
protected firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
if (this.hass) {
this._fetchConfigEntry();
}
}
private _matterDeviceCount = memoizeOne(
(devices: HomeAssistant["devices"]): number =>
Object.values(devices).filter((device) =>
device.identifiers.some((identifier) => identifier[0] === "matter")
).length
);
protected render(): TemplateResult | typeof nothing {
if (!this._configEntry) {
return nothing;
}
const isOnline = this._configEntry.state === "loaded";
protected render(): TemplateResult {
return html`
<hass-subpage
.narrow=${this.narrow}
.hass=${this.hass}
header="Matter"
has-fab
>
${isComponentLoaded(this.hass, "thread")
<hass-subpage .narrow=${this.narrow} .hass=${this.hass} header="Matter">
${isComponentLoaded(this.hass, "otbr")
? html`
<ha-button
appearance="plain"
@@ -83,114 +51,53 @@ export class MatterConfigDashboard extends LitElement {
)}</ha-button
>
`
: nothing}
<div class="container">
<ha-card class="network-status">
: ""}
<div class="content">
<ha-card header="Matter">
<ha-alert alert-type="warning"
>${this.hass.localize(
"ui.panel.config.matter.panel.experimental_note"
)}</ha-alert
>
<div class="card-content">
<div class="heading">
<div class="icon">
<ha-svg-icon
.path=${isOnline ? mdiCheckCircle : mdiAlertCircle}
class=${isOnline ? "online" : "offline"}
></ha-svg-icon>
</div>
<div class="details">
Matter
${this.hass.localize(
"ui.panel.config.matter.panel.status_title"
)}:
${this.hass.localize(
`ui.panel.config.matter.panel.status_${isOnline ? "online" : "offline"}`
)}<br />
<small>
${this.hass.localize(
"ui.panel.config.matter.panel.devices",
{ count: this._matterDeviceCount(this.hass.devices) }
)}
</small>
</div>
</div>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}
${this.hass.localize("ui.panel.config.matter.panel.add_devices")}
</div>
<div class="card-actions">
<ha-button
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
appearance="plain"
size="small"
${canCommissionMatterExternal(this.hass)
? html`<ha-button
appearance="plain"
@click=${this._startMobileCommissioning}
>${this.hass.localize(
"ui.panel.config.matter.panel.mobile_app_commisioning"
)}</ha-button
>`
: ""}
<ha-button appearance="plain" @click=${this._commission}
>${this.hass.localize(
"ui.panel.config.matter.panel.commission_device"
)}</ha-button
>
${this.hass.localize("ui.panel.config.devices.caption")}
</ha-button>
<ha-button
appearance="plain"
size="small"
href=${`/config/entities/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
<ha-button appearance="plain" @click=${this._acceptSharedDevice}
>${this.hass.localize(
"ui.panel.config.matter.panel.add_shared_device"
)}</ha-button
>
<ha-button appearance="plain" @click=${this._setWifi}
>${this.hass.localize(
"ui.panel.config.matter.panel.set_wifi_credentials"
)}</ha-button
>
<ha-button appearance="plain" @click=${this._setThread}
>${this.hass.localize(
"ui.panel.config.matter.panel.set_thread_credentials"
)}</ha-button
>
${this.hass.localize("ui.panel.config.entities.caption")}
</ha-button>
</div>
</ha-card>
<ha-expansion-panel
outlined
.header=${this.hass.localize(
"ui.panel.config.matter.panel.developer_tools_title"
)}
.secondary=${this.hass.localize(
"ui.panel.config.matter.panel.developer_tools_description"
)}
>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<div class="dev-tools-content">
<p>
${this.hass.localize(
"ui.panel.config.matter.panel.developer_tools_info"
)}
</p>
<div class="dev-tools-actions">
${canCommissionMatterExternal(this.hass)
? html`<ha-button
appearance="plain"
@click=${this._startMobileCommissioning}
>${this.hass.localize(
"ui.panel.config.matter.panel.mobile_app_commisioning"
)}</ha-button
>`
: nothing}
<ha-button appearance="plain" @click=${this._commission}
>${this.hass.localize(
"ui.panel.config.matter.panel.commission_device"
)}</ha-button
>
<ha-button appearance="plain" @click=${this._acceptSharedDevice}
>${this.hass.localize(
"ui.panel.config.matter.panel.add_shared_device"
)}</ha-button
>
<ha-button appearance="plain" @click=${this._setWifi}
>${this.hass.localize(
"ui.panel.config.matter.panel.set_wifi_credentials"
)}</ha-button
>
<ha-button appearance="plain" @click=${this._setThread}
>${this.hass.localize(
"ui.panel.config.matter.panel.set_thread_credentials"
)}</ha-button
>
</div>
</div>
</ha-expansion-panel>
</div>
<a href="/config/matter/add" slot="fab">
<ha-fab
.label=${this.hass.localize(
"ui.panel.config.matter.panel.add_device"
)}
extended
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-fab>
</a>
</hass-subpage>
`;
}
@@ -329,101 +236,27 @@ export class MatterConfigDashboard extends LitElement {
}
}
private async _fetchConfigEntry(): Promise<void> {
const configEntries = await getConfigEntries(this.hass, {
domain: "matter",
});
if (configEntries.length) {
this._configEntry = configEntries[0];
}
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
ha-card {
margin: auto;
margin-top: var(--ha-space-4);
max-width: 500px;
}
ha-card .card-actions {
display: flex;
justify-content: flex-end;
}
.network-status div.heading {
display: flex;
align-items: center;
}
.network-status div.heading .icon {
margin-inline-end: var(--ha-space-4);
}
.network-status div.heading ha-svg-icon {
--mdc-icon-size: 48px;
}
.network-status div.heading .details {
font-size: var(--ha-font-size-xl);
}
.network-status small {
font-size: var(--ha-font-size-m);
}
.network-status .online {
color: var(--state-on-color, var(--success-color));
}
.network-status .offline {
color: var(--error-color, var(--error-color));
}
.container {
padding: var(--ha-space-2) var(--ha-space-4) var(--ha-space-4);
}
ha-expansion-panel {
margin: auto;
margin-top: var(--ha-space-4);
max-width: 500px;
background: var(--card-background-color);
border-radius: var(
--ha-card-border-radius,
var(--ha-border-radius-lg)
);
--expansion-panel-summary-padding: var(--ha-space-2) var(--ha-space-4);
--expansion-panel-content-padding: 0 var(--ha-space-4);
}
.dev-tools-content {
padding: 0 0 var(--ha-space-4);
}
.dev-tools-content p {
margin: 0 0 var(--ha-space-4);
color: var(--primary-text-color);
}
.dev-tools-actions {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--ha-space-2);
}
a[slot="toolbar-icon"] {
text-decoration: none;
}
a[slot="fab"] {
text-decoration: none;
}
`,
];
}
static styles = [
haStyle,
css`
ha-alert[alert-type="warning"] {
position: relative;
top: -16px;
}
.content {
padding: 24px 0 32px;
max-width: 600px;
margin: 0 auto;
direction: ltr;
}
ha-card:first-child {
margin-bottom: 16px;
}
a[slot="toolbar-icon"] {
text-decoration: none;
}
`,
];
}
declare global {

View File

@@ -45,9 +45,9 @@ class DialogThreadDataset extends LitElement implements HassDialog {
<div>
Network name: ${dataset.network_name}<br />
Channel: ${dataset.channel}<br />
Dataset ID: ${dataset.dataset_id}<br />
PAN ID: ${dataset.pan_id}<br />
Extended PAN ID: ${dataset.extended_pan_id}<br />
Dataset id: ${dataset.dataset_id}<br />
Pan id: ${dataset.pan_id}<br />
Extended Pan id: ${dataset.extended_pan_id}<br />
${hasOTBR
? html`OTBR URL: ${otbrInfo.url}<br />

View File

@@ -302,11 +302,10 @@ class DialogZWaveJSUpdateFirmwareNode extends LitElement {
</div>
${this._updateFinishedMessage!.success
? html`<p>
${this.hass.localize(
`ui.panel.config.zwave_js.update_firmware.finished_status.done${localizationKeySuffix}`
)}
</p>
${closeButton}`
${this.hass.localize(
`ui.panel.config.zwave_js.update_firmware.finished_status.done${localizationKeySuffix}`
)}
</p>`
: html`<p>
${this.hass.localize(
"ui.panel.config.zwave_js.update_firmware.finished_status.try_again"

View File

@@ -372,7 +372,7 @@ export class HaConfigLogs extends LitElement {
@media all and (max-width: 870px) {
ha-generic-picker {
max-width: max(30%, 160px);
max-width: 50%;
}
ha-button {
max-width: 100%;

View File

@@ -51,7 +51,6 @@ import "../../../components/ha-filter-devices";
import "../../../components/ha-filter-entities";
import "../../../components/ha-filter-floor-areas";
import "../../../components/ha-filter-labels";
import "../../../components/ha-filter-voice-assistants";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-overflow-menu";
import "../../../components/ha-md-divider";
@@ -108,8 +107,6 @@ import { showAssignCategoryDialog } from "../category/show-dialog-assign-categor
import { showCategoryRegistryDetailDialog } from "../category/show-dialog-category-registry-detail";
import { configSections } from "../ha-panel-config";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
import { getEntityVoiceAssistantsIds } from "../../../data/expose";
import "../voice-assistants/expose/expose-assistant-icon";
type SceneItem = SceneEntity & {
name: string;
@@ -413,31 +410,6 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
</ha-icon-overflow-menu>
`,
},
voice_assistants: {
title: localize(
"ui.panel.config.scene.picker.headers.voice_assistants"
),
type: "icon",
defaultHidden: true,
minWidth: "100px",
maxWidth: "100px",
template: (scene) => {
const exposedToVoiceAssistantIds = getEntityVoiceAssistantsIds(
this._entityReg,
scene.entity_id
);
return html` ${exposedToVoiceAssistantIds.length !== 0
? exposedToVoiceAssistantIds.map(
(vaId) =>
html` <voice-assistants-expose-assistant-icon
.assistant=${vaId}
.hass=${this.hass}
>
</voice-assistants-expose-assistant-icon>`
)
: "—"}`;
},
},
};
return columns;
@@ -680,15 +652,6 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-categories>
<ha-filter-voice-assistants
.hass=${this.hass}
.value=${this._filters["ha-filter-voice-assistants"]?.value}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-voice-assistants"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-voice-assistants>
${!this.narrow
? html`<ha-md-button-menu slot="selection-bar">
@@ -924,7 +887,8 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
? // @ts-ignore
items.intersection(categoryItems)
: new Set([...items].filter((x) => categoryItems!.has(x)));
} else if (
}
if (
key === "ha-filter-labels" &&
Array.isArray(filter.value) &&
filter.value.length
@@ -946,28 +910,6 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
? // @ts-ignore
items.intersection(labelItems)
: new Set([...items].filter((x) => labelItems!.has(x)));
} else if (
key === "ha-filter-voice-assistants" &&
Array.isArray(filter.value) &&
filter.value.length
) {
const assistItems = new Set<string>();
this.scenes
.filter((scene) =>
getEntityVoiceAssistantsIds(this._entityReg, scene.entity_id).some(
(va) => (filter.value as string[]).includes(va)
)
)
.forEach((scene) => assistItems.add(scene.entity_id));
if (!items) {
items = assistItems;
continue;
}
items =
"intersection" in items
? // @ts-ignore
items.intersection(assistItems)
: new Set([...items].filter((x) => assistItems!.has(x)));
}
}
this._filteredScenes = items ? [...items] : undefined;

View File

@@ -53,7 +53,6 @@ import "../../../components/ha-filter-devices";
import "../../../components/ha-filter-entities";
import "../../../components/ha-filter-floor-areas";
import "../../../components/ha-filter-labels";
import "../../../components/ha-filter-voice-assistants";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-overflow-menu";
import "../../../components/ha-md-divider";
@@ -112,8 +111,6 @@ import { showAssignCategoryDialog } from "../category/show-dialog-assign-categor
import { showCategoryRegistryDetailDialog } from "../category/show-dialog-category-registry-detail";
import { configSections } from "../ha-panel-config";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
import { getEntityVoiceAssistantsIds } from "../../../data/expose";
import "../voice-assistants/expose/expose-assistant-icon";
type ScriptItem = ScriptEntity & {
name: string;
@@ -401,32 +398,8 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
</ha-icon-overflow-menu>
`,
},
voice_assistants: {
title: localize(
"ui.panel.config.script.picker.headers.voice_assistants"
),
type: "icon",
defaultHidden: true,
minWidth: "100px",
maxWidth: "100px",
template: (script) => {
const exposedToVoiceAssistantIds = getEntityVoiceAssistantsIds(
this._entityReg,
script.entity_id
);
return html` ${exposedToVoiceAssistantIds.length !== 0
? exposedToVoiceAssistantIds.map(
(vaId) =>
html` <voice-assistants-expose-assistant-icon
.assistant=${vaId}
.hass=${this.hass}
>
</voice-assistants-expose-assistant-icon>`
)
: "—"}`;
},
},
};
return columns;
}
);
@@ -662,15 +635,6 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-categories>
<ha-filter-voice-assistants
.hass=${this.hass}
.value=${this._filters["ha-filter-voice-assistants"]?.value}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-voice-assistants"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-voice-assistants>
<ha-filter-blueprints
.hass=${this.hass}
.type=${"script"}
@@ -929,7 +893,8 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
? // @ts-ignore
items.intersection(categoryItems)
: new Set([...items].filter((x) => categoryItems!.has(x)));
} else if (
}
if (
key === "ha-filter-labels" &&
Array.isArray(filter.value) &&
filter.value.length
@@ -951,28 +916,6 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
? // @ts-ignore
items.intersection(labelItems)
: new Set([...items].filter((x) => labelItems!.has(x)));
} else if (
key === "ha-filter-voice-assistants" &&
Array.isArray(filter.value) &&
filter.value.length
) {
const assistItems = new Set<string>();
this.scripts
.filter((script) =>
getEntityVoiceAssistantsIds(this._entityReg, script.entity_id).some(
(va) => (filter.value as string[]).includes(va)
)
)
.forEach((script) => assistItems.add(script.entity_id));
if (!items) {
items = assistItems;
continue;
}
items =
"intersection" in items
? // @ts-ignore
items.intersection(assistItems)
: new Set([...items].filter((x) => assistItems!.has(x)));
}
}
this._filteredScripts = items ? [...items] : undefined;

View File

@@ -23,6 +23,7 @@ export class VoiceAssistantExposeAssistantIcon extends LitElement {
render() {
if (!this.assistant || !voiceAssistants[this.assistant]) return nothing;
return html`
<div class="container" id="container">
<img

View File

@@ -21,8 +21,6 @@ class EventSubscribeCard extends LitElement {
@state() private _subscribed?: () => void;
@state() private _eventFilter = "";
@state() private _events: {
id: number;
event: HassEvent;
@@ -32,8 +30,6 @@ class EventSubscribeCard extends LitElement {
private _eventCount = 0;
@state() _ignoredEventsCount = 0;
public disconnectedCallback() {
super.disconnectedCallback();
if (this._subscribed) {
@@ -74,16 +70,6 @@ class EventSubscribeCard extends LitElement {
.value=${this._eventType}
@input=${this._valueChanged}
></ha-textfield>
<ha-textfield
.label=${this.hass!.localize(
"ui.panel.developer-tools.tabs.events.filter_events"
)}
.value=${this._eventFilter}
.disabled=${this._subscribed !== undefined}
helperPersistent
.helper=${`${this.hass!.localize("ui.panel.developer-tools.tabs.events.filter_helper")}${this._ignoredEventsCount ? ` ${this.hass!.localize("ui.panel.developer-tools.tabs.events.filter_ignored", { count: this._ignoredEventsCount })}` : ""}`}
@input=${this._filterChanged}
></ha-textfield>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}
@@ -149,46 +135,6 @@ class EventSubscribeCard extends LitElement {
this._error = undefined;
}
private _filterChanged(ev): void {
this._eventFilter = ev.target.value;
}
private _testEventFilter(event: HassEvent): boolean {
if (!this._eventFilter) {
return true;
}
const searchStr = this._eventFilter;
function visit(node) {
// Handle primitives directly
if (node === null || typeof node !== "object") {
return String(node).includes(searchStr);
}
// Handle arrays and plain objects
for (const key in node) {
if (!Object.prototype.hasOwnProperty.call(node, key)) continue;
// Check key
if (key.includes(searchStr)) return true;
const value = node[key];
// Check primitive value
if (value === null || typeof value !== "object") {
if (String(value).includes(searchStr)) return true;
} else if (visit(value)) {
// Recurse into nested object/array
return true;
}
}
return false;
}
return visit(event);
}
private async _startOrStopListening(): Promise<void> {
if (this._subscribed) {
this._subscribed();
@@ -198,10 +144,6 @@ class EventSubscribeCard extends LitElement {
try {
this._subscribed =
await this.hass!.connection.subscribeEvents<HassEvent>((event) => {
if (!this._testEventFilter(event)) {
this._ignoredEventsCount++;
return;
}
const tail =
this._events.length > 30
? this._events.slice(0, 29)
@@ -226,7 +168,6 @@ class EventSubscribeCard extends LitElement {
private _clearEvents(): void {
this._events = [];
this._eventCount = 0;
this._ignoredEventsCount = 0;
this._error = undefined;
}

View File

@@ -201,7 +201,6 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
label: this.hass.localize(
"ui.panel.developer-tools.tabs.statistics.fix_issue.fix"
),
type: "icon",
template: (statistic) =>
html`${statistic.issues
? html`<ha-button

View File

@@ -283,18 +283,13 @@ class PanelEnergy extends LitElement {
["grid", "solar", "battery"].includes(source.type)
);
const hasPowerSource = this._prefs.energy_sources.some(
(source) =>
(source.type === "solar" && source.stat_rate) ||
(source.type === "battery" && source.stat_rate) ||
(source.type === "grid" && source.power?.length)
);
const hasDevicePower = this._prefs.device_consumption.some(
(device) => device.stat_rate
);
const hasPower = hasPowerSource || hasDevicePower;
const hasPower =
this._prefs.energy_sources.some(
(source) =>
(source.type === "solar" && source.stat_rate) ||
(source.type === "battery" && source.stat_rate) ||
(source.type === "grid" && source.power?.length)
) || this._prefs.device_consumption.some((device) => device.stat_rate);
const hasWater =
this._prefs.energy_sources.some((source) => source.type === "water") ||
@@ -319,10 +314,7 @@ class PanelEnergy extends LitElement {
if (hasPower) {
views.push(POWER_VIEW);
}
if (
hasPowerSource ||
[hasEnergy, hasGas, hasWater].filter(Boolean).length > 1
) {
if (views.length > 1) {
views.unshift(OVERVIEW_VIEW);
}
return {

View File

@@ -30,33 +30,38 @@ import {
import { formatTime } from "../../../../../common/datetime/format_time";
import type { ECOption } from "../../../../../resources/echarts/echarts";
import { filterXSS } from "../../../../../common/util/xss";
import type { StatisticPeriod } from "../../../../../data/recorder";
import { getSuggestedPeriod } from "../../../../../data/energy";
export function getSuggestedMax(period: StatisticPeriod, end: Date): number {
export function getSuggestedMax(
dayDifference: number,
end: Date,
detailedDailyData = false
): number {
let suggestedMax = new Date(end);
if (period === "5minute") {
return suggestedMax.getTime();
}
suggestedMax.setMinutes(0, 0, 0);
if (period === "hour") {
return suggestedMax.getTime();
}
// Sometimes around DST we get a time of 0:59 instead of 23:59 as expected.
// Correct for this when showing days/months so we don't get an extra day.
if (suggestedMax.getHours() === 0) {
if (dayDifference > 2 && suggestedMax.getHours() === 0) {
suggestedMax = subHours(suggestedMax, 1);
}
suggestedMax.setHours(0);
if (period === "day" || period === "week") {
return suggestedMax.getTime();
if (!detailedDailyData) {
suggestedMax.setMinutes(0, 0, 0);
}
if (dayDifference > 35) {
suggestedMax.setDate(1);
}
if (dayDifference > 2) {
suggestedMax.setHours(0);
}
// period === month
suggestedMax.setDate(1);
return suggestedMax.getTime();
}
export function getSuggestedPeriod(
dayDifference: number
): "month" | "day" | "hour" {
return dayDifference > 35 ? "month" : dayDifference > 2 ? "day" : "hour";
}
function createYAxisLabelFormatter(locale: FrontendLocaleData) {
let previousValue: number | undefined;
@@ -91,10 +96,7 @@ export function getCommonOptions(
xAxis: {
type: "time",
min: start,
max: getSuggestedMax(
getSuggestedPeriod(start, end, detailedDailyData),
end
),
max: getSuggestedMax(dayDifference, end, detailedDailyData),
},
yAxis: {
type: "value",

View File

@@ -1,4 +1,4 @@
import { endOfToday, isToday, startOfToday } from "date-fns";
import { differenceInDays, endOfToday, isToday, startOfToday } from "date-fns";
import type { HassConfig, UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
@@ -18,7 +18,6 @@ import type {
import {
getEnergyDataCollection,
getEnergySolarForecasts,
getSuggestedPeriod,
} from "../../../../data/energy";
import type { Statistics, StatisticsMetaData } from "../../../../data/recorder";
import { getStatisticLabel } from "../../../../data/recorder";
@@ -355,7 +354,7 @@ export class HuiEnergySolarGraphCard
) {
const data: LineSeriesOption[] = [];
const period = getSuggestedPeriod(start, end);
const dayDifference = differenceInDays(end || new Date(), start);
// Process solar forecast data.
solarSources.forEach((source) => {
@@ -371,10 +370,10 @@ export class HuiEnergySolarGraphCard
if (dateObj < start || (end && dateObj > end)) {
return;
}
if (period === "month") {
if (dayDifference > 35) {
dateObj.setDate(1);
}
if (period === "month" || period === "day") {
if (dayDifference > 2) {
dateObj.setHours(0, 0, 0, 0);
} else {
dateObj.setMinutes(0, 0, 0);

View File

@@ -67,19 +67,6 @@ export const SUM_DEVICE_CLASSES = [
"water",
];
// Additional sources for sensor device classes from entity attributes
// Maps device_class -> array of { domain, attribute } to include in aggregation
export const SENSOR_ATTRIBUTE_SOURCES: Record<
string,
{ domain: string; attribute: string }[]
> = {
temperature: [{ domain: "climate", attribute: "current_temperature" }],
humidity: [
{ domain: "climate", attribute: "current_humidity" },
{ domain: "humidifier", attribute: "current_humidity" },
],
};
export interface AreaCardFeatureContext extends LovelaceCardFeatureContext {
exclude_entities?: string[];
}
@@ -264,24 +251,6 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
}
);
private _domainEntityIds = memoizeOne(
(
entities: HomeAssistant["entities"],
areaId: string,
domains: string[],
excludeEntities?: string[]
): string[] => {
const filter = generateEntityFilter(this.hass, {
area: areaId,
entity_category: "none",
domain: domains,
});
return Object.keys(entities).filter(
(id) => filter(id) && !excludeEntities?.includes(id)
);
}
);
private _computeActiveAlertStates(): HassEntity[] {
const areaId = this._config?.area;
const area = areaId ? this.hass.areas[areaId] : undefined;
@@ -390,91 +359,58 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
: this.hass.formatEntityState(stateObj);
}
const sensorEntityIds = groupedEntities.get(sensorClass) || [];
const values: number[] = [];
let uom: string | undefined;
const entityIds = groupedEntities.get(sensorClass);
// Track devices that have sensor entities contributing values
// to avoid duplicate readings from climate/humidifier attributes
const devicesWithSensorValues = new Set<string>();
for (const entityId of sensorEntityIds) {
const stateObj = this.hass.states[entityId];
if (
stateObj &&
!isUnavailableState(stateObj.state) &&
isNumericState(stateObj) &&
!isNaN(Number(stateObj.state))
) {
if (!uom) {
uom = stateObj.attributes.unit_of_measurement;
}
if (stateObj.attributes.unit_of_measurement === uom) {
values.push(Number(stateObj.state));
// Track the device this sensor belongs to
const entityEntry = this.hass.entities[entityId];
if (entityEntry?.device_id) {
devicesWithSensorValues.add(entityEntry.device_id);
}
}
}
if (!entityIds) {
return undefined;
}
// Collect values from additional attribute sources
const attrSources = SENSOR_ATTRIBUTE_SOURCES[sensorClass];
if (attrSources) {
const domains = [...new Set(attrSources.map((s) => s.domain))];
const attrEntityIds = this._domainEntityIds(
this.hass.entities,
area.area_id,
domains,
excludeEntities
);
// Ensure all entities have state
const entities = entityIds
.map((entityId) => this.hass.states[entityId])
.filter(Boolean);
for (const entityId of attrEntityIds) {
const stateObj = this.hass.states[entityId];
if (!stateObj) continue;
// Skip if this entity's device already has a sensor contributing values
const entityEntry = this.hass.entities[entityId];
if (
entityEntry?.device_id &&
devicesWithSensorValues.has(entityEntry.device_id)
) {
continue;
}
const domain = entityId.split(".")[0];
const source = attrSources.find((s) => s.domain === domain);
if (!source) continue;
const attrValue = stateObj.attributes[source.attribute];
if (attrValue == null || isNaN(Number(attrValue))) continue;
if (!uom) {
// Determine unit from attribute
uom = this._getAttributeUnit(sensorClass, domain);
}
values.push(Number(attrValue));
}
if (entities.length === 0) {
return undefined;
}
if (values.length === 0) {
// If only one entity, return its formatted state
if (entities.length === 1) {
const stateObj = entities[0];
return isUnavailableState(stateObj.state)
? ""
: this.hass.formatEntityState(stateObj);
}
// Use the first entity's unit_of_measurement for formatting
const uom = entities.find(
(entity) => entity.attributes.unit_of_measurement
)?.attributes.unit_of_measurement;
// Ensure all entities have the same unit_of_measurement
const validEntities = entities.filter(
(entity) =>
entity.attributes.unit_of_measurement === uom &&
isNumericState(entity) &&
!isNaN(Number(entity.state))
);
if (validEntities.length === 0) {
return undefined;
}
const value = SUM_DEVICE_CLASSES.includes(sensorClass)
? values.reduce((acc, v) => acc + v, 0)
: this._computeMedianValue(values);
? this._computeSumState(validEntities)
: this._computeMedianState(validEntities);
const formattedValue = formatNumber(value, this.hass.locale, {
const formattedAverage = formatNumber(value, this.hass!.locale, {
maximumFractionDigits: 1,
});
const formattedUnit = uom
? `${blankBeforeUnit(uom, this.hass.locale)}${uom}`
? `${blankBeforeUnit(uom, this.hass!.locale)}${uom}`
: "";
return `${formattedValue}${formattedUnit}`;
return `${formattedAverage}${formattedUnit}`;
})
.filter(Boolean)
.join(" · ");
@@ -482,25 +418,20 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
return sensorStates;
}
private _getAttributeUnit(sensorClass: string, domain: string): string {
// Return the expected unit for attributes from specific domains
if (sensorClass === "temperature" && domain === "climate") {
return this.hass.config.unit_system.temperature;
}
if (sensorClass === "humidity") {
return "%";
}
return "";
private _computeSumState(entities: HassEntity[]): number {
return entities.reduce((acc, entity) => acc + Number(entity.state), 0);
}
private _computeMedianValue(values: number[]): number {
const sortedValues = [...values].sort((a, b) => a - b);
if (sortedValues.length % 2 === 0) {
const medianIndex = sortedValues.length / 2;
return (sortedValues[medianIndex] + sortedValues[medianIndex - 1]) / 2;
private _computeMedianState(entities: HassEntity[]): number {
const sortedStates = entities
.map((entity) => Number(entity.state))
.sort((a, b) => a - b);
if (sortedStates.length % 2 === 0) {
const medianIndex = sortedStates.length / 2;
return (sortedStates[medianIndex] + sortedStates[medianIndex - 1]) / 2;
}
const medianIndex = Math.floor(sortedValues.length / 2);
return sortedValues[medianIndex];
const medianIndex = Math.floor(sortedStates.length / 2);
return sortedStates[medianIndex];
}
private _featurePosition = memoizeOne((config: AreaCardConfig) => {

View File

@@ -94,12 +94,10 @@ export class HuiCalendarCard extends LitElement implements LovelaceCard {
(changedProps.has("_config") && this._config?.entities)
) {
const computedStyles = getComputedStyle(this);
if (this._config?.entities) {
this._calendars = this._config.entities.map((entity, idx) => ({
entity_id: entity,
backgroundColor: getColorByIndex(idx, computedStyles),
}));
}
this._calendars = this._config!.entities.map((entity, idx) => ({
entity_id: entity,
backgroundColor: getColorByIndex(idx, computedStyles),
}));
}
}

View File

@@ -1,116 +1,63 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-card";
import "../../../components/ha-button";
import "../../../components/ha-icon";
import type { HomeAssistant } from "../../../types";
import { handleAction } from "../common/handle-action";
import type { LovelaceCard, LovelaceCardEditor } from "../types";
import type { LovelaceCard } from "../types";
import type { EmptyStateCardConfig } from "./types";
@customElement("hui-empty-state-card")
export class HuiEmptyStateCard extends LitElement implements LovelaceCard {
public static async getConfigElement(): Promise<LovelaceCardEditor> {
await import("../editor/config-elements/hui-empty-state-card-editor");
return document.createElement("hui-empty-state-card-editor");
}
public static getStubConfig(): EmptyStateCardConfig {
return {
type: "empty-state",
title: "Welcome Home",
content: "This is an empty state card.",
};
}
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _config?: EmptyStateCardConfig;
public getCardSize(): number {
return 2;
}
public setConfig(config: EmptyStateCardConfig): void {
this._config = config;
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
public setConfig(_config: EmptyStateCardConfig): void {}
protected render() {
if (!this.hass || !this._config) {
if (!this.hass) {
return nothing;
}
return html`
<ha-card
class=${classMap({
"content-only": this._config.content_only ?? false,
})}
.header=${this.hass.localize(
"ui.panel.lovelace.cards.empty_state.title"
)}
>
<div class="container">
${this._config.icon
? html`<ha-icon .icon=${this._config.icon}></ha-icon>`
: nothing}
${this._config.title ? html`<h1>${this._config.title}</h1>` : nothing}
${this._config.content
? html`<p>${this._config.content}</p>`
: nothing}
${this._config.tap_action && this._config.action_button_text
? html`
<ha-button @click=${this._handleAction}>
${this._config.action_button_text}
</ha-button>
`
: nothing}
<div class="card-content">
${this.hass.localize(
"ui.panel.lovelace.cards.empty_state.no_devices"
)}
</div>
<div class="card-actions">
<ha-button appearance="plain" href="/config/integrations/dashboard">
${this.hass.localize(
"ui.panel.lovelace.cards.empty_state.go_to_integrations_page"
)}
</ha-button>
</div>
</ha-card>
`;
}
private _handleAction(): void {
if (this._config?.tap_action && this.hass) {
handleAction(this, this.hass, this._config, "tap");
}
}
static styles = css`
:host {
display: block;
height: 100%;
.content {
margin-top: -1em;
padding: 16px;
}
ha-card {
height: 100%;
.card-actions a {
text-decoration: none;
}
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
height: 100%;
padding: var(--ha-space-8) var(--ha-space-4);
box-sizing: border-box;
gap: var(--ha-space-4);
max-width: 640px;
margin: 0 auto;
}
ha-icon {
--mdc-icon-size: var(--ha-space-12);
color: var(--secondary-text-color);
}
h1 {
margin: 0;
font-size: var(--ha-font-size-xl);
font-weight: 500;
}
p {
margin: 0;
color: var(--secondary-text-color);
}
.content-only {
background: none;
box-shadow: none;
border: none;
ha-button {
margin-left: -8px;
margin-inline-start: -8px;
margin-inline-end: initial;
}
`;
}

View File

@@ -11,10 +11,7 @@ import { findEntities } from "../common/find-entities";
import type { LovelaceElement, LovelaceElementConfig } from "../elements/types";
import type { LovelaceCard, LovelaceCardEditor } from "../types";
import { createStyledHuiElement } from "./picture-elements/create-styled-hui-element";
import {
PREVIEW_CLICK_CALLBACK,
type PictureElementsCardConfig,
} from "./types";
import type { PictureElementsCardConfig } from "./types";
import type { PersonEntity } from "../../../data/person";
@customElement("hui-picture-elements-card")
@@ -169,7 +166,6 @@ class HuiPictureElementsCard extends LitElement implements LovelaceCard {
.aspectRatio=${this._config.aspect_ratio}
.darkModeFilter=${this._config.dark_mode_filter}
.darkModeImage=${darkModeImage}
@click=${this._handleImageClick}
></hui-image>
${this._elements}
</div>
@@ -225,19 +221,6 @@ class HuiPictureElementsCard extends LitElement implements LovelaceCard {
curCardEl === elToReplace ? newCardEl : curCardEl
);
}
private _handleImageClick(ev: MouseEvent): void {
if (!this.preview || !this._config?.[PREVIEW_CLICK_CALLBACK]) {
return;
}
const rect = (ev.currentTarget as HTMLElement).getBoundingClientRect();
const x = ((ev.clientX - rect.left) / rect.width) * 100;
const y = ((ev.clientY - rect.top) / rect.height) * 100;
// only the edited card has this callback
this._config[PREVIEW_CLICK_CALLBACK](x, y);
}
}
declare global {

View File

@@ -8,10 +8,7 @@ import { createSearchParam } from "../../../common/url/search-params";
import "../../../components/ha-card";
import "../../../components/ha-icon-next";
import "../../../components/ha-tooltip";
import {
getEnergyDataCollection,
getSuggestedPeriod,
} from "../../../data/energy";
import { getEnergyDataCollection } from "../../../data/energy";
import type {
Statistics,
StatisticsMetaData,
@@ -29,7 +26,10 @@ import { hasConfigOrEntitiesChanged } from "../common/has-changed";
import { processConfigEntities } from "../common/process-config-entities";
import type { EntityConfig } from "../entity-rows/types";
import type { LovelaceCard, LovelaceGridOptions } from "../types";
import { getSuggestedMax } from "./energy/common/energy-chart-options";
import {
getSuggestedMax,
getSuggestedPeriod,
} from "./energy/common/energy-chart-options";
import type { StatisticsGraphCardConfig } from "./types";
export const DEFAULT_DAYS_TO_SHOW = 30;
@@ -268,7 +268,9 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
return (
this._config?.period ??
(this._energyStart && this._energyEnd
? getSuggestedPeriod(this._energyStart, this._energyEnd)
? getSuggestedPeriod(
differenceInDays(this._energyEnd, this._energyStart)
)
: undefined)
);
}
@@ -332,7 +334,10 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
.maxYAxis=${this._config.max_y_axis}
.startTime=${this._energyStart}
.endTime=${this._energyEnd && this._energyStart
? getSuggestedMax(this._period!, this._energyEnd)
? getSuggestedMax(
differenceInDays(this._energyEnd, this._energyStart),
this._energyEnd
)
: undefined}
.fitYData=${this._config.fit_y_data || false}
.hideLegend=${this._config.hide_legend || false}

View File

@@ -58,12 +58,8 @@ export interface ConditionalCardConfig extends LovelaceCardConfig {
}
export interface EmptyStateCardConfig extends LovelaceCardConfig {
content_only?: boolean;
icon?: string;
content: string;
title?: string;
content?: string;
action_button_text?: string;
tap_action?: ActionConfig;
}
export interface EntityCardConfig extends LovelaceCardConfig {
@@ -487,10 +483,6 @@ export interface PictureCardConfig extends LovelaceCardConfig {
alt_text?: string;
}
// Symbol for preview click callback - preserved through spreads, not serialized
// This allows the editor to attach a callback that only exists on the edited card's config
export const PREVIEW_CLICK_CALLBACK = Symbol("previewClickCallback");
export interface PictureElementsCardConfig extends LovelaceCardConfig {
title?: string;
image?: string | MediaSelectorValue;
@@ -505,7 +497,6 @@ export interface PictureElementsCardConfig extends LovelaceCardConfig {
theme?: string;
dark_mode_image?: string | MediaSelectorValue;
dark_mode_filter?: string;
[PREVIEW_CLICK_CALLBACK]?: (x: number, y: number) => void;
}
export interface PictureEntityCardConfig extends LovelaceCardConfig {

View File

@@ -21,8 +21,5 @@ export const confirmAction = async (
hass.localize("ui.panel.lovelace.cards.actions.action_confirmation", {
action,
}),
title: config.title,
dismissText: config.dismiss_text,
confirmText: config.confirm_text,
});
};

View File

@@ -368,7 +368,6 @@ export const generateViewConfig = (
path: string,
title: string | undefined,
icon: string | undefined,
show_icon_and_title: boolean | undefined,
entities: HassEntities
): LovelaceViewConfig => {
const ungroupedEntitites: Record<string, string[]> = {};
@@ -498,9 +497,6 @@ export const generateViewConfig = (
if (icon) {
view.icon = icon;
}
if (show_icon_and_title) {
view.show_icon_and_title = show_icon_and_title;
}
return view;
};
@@ -521,7 +517,6 @@ export const generateDefaultViewConfig = (
const path = "default_view";
const title = "Home";
const icon = undefined;
const show_icon_and_title = undefined;
// In the case of a default view, we want to use the group order attribute
const groupOrders = {};
@@ -571,7 +566,6 @@ export const generateDefaultViewConfig = (
path,
title,
icon,
show_icon_and_title,
splittedByGroups.ungrouped
);

View File

@@ -7,11 +7,6 @@ const calcPoints = (
height: number,
limits?: { minX?: number; maxX?: number; minY?: number; maxY?: number }
) => {
// handling empty history (for example unavailable for long time)
if (history.length === 0) {
return { points: [], yAxisOrigin: height };
}
let yAxisOrigin = height;
let minY = limits?.minY ?? history[0][1];
let maxY = limits?.maxY ?? history[0][1];

View File

@@ -89,9 +89,6 @@ export const handleAction = async (
) ||
actionConfig.action,
}),
title: actionConfig.confirmation.title,
dismissText: actionConfig.confirmation.dismiss_text,
confirmText: actionConfig.confirmation.confirm_text,
}))
) {
return;

View File

@@ -1,153 +0,0 @@
import { mdiGestureTap } from "@mdi/js";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { assert, assign, boolean, object, optional, string } from "superstruct";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/ha-form/ha-form";
import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types";
import type { EmptyStateCardConfig } from "../../cards/types";
import type { LovelaceCardEditor } from "../../types";
import { actionConfigStruct } from "../structs/action-struct";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
const cardConfigStruct = assign(
baseLovelaceCardConfig,
object({
content_only: optional(boolean()),
icon: optional(string()),
title: optional(string()),
content: optional(string()),
action_button_text: optional(string()),
tap_action: optional(actionConfigStruct),
})
);
@customElement("hui-empty-state-card-editor")
export class HuiEmptyStateCardEditor
extends LitElement
implements LovelaceCardEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _config?: EmptyStateCardConfig;
public setConfig(config: EmptyStateCardConfig): void {
assert(config, cardConfigStruct);
this._config = config;
}
private _schema = memoizeOne(
(localize: LocalizeFunc) =>
[
{
name: "style",
selector: {
select: {
mode: "box",
options: (
[
{ value: "card", image: "card" },
{ value: "content-only", image: "text_only" },
] as const
).map((style) => ({
label: localize(
`ui.panel.lovelace.editor.card.empty_state.style_options.${style.value}`
),
image: {
src: `/static/images/form/markdown_${style.image}.svg`,
src_dark: `/static/images/form/markdown_${style.image}_dark.svg`,
flip_rtl: true,
},
value: style.value,
})),
},
},
},
{ name: "icon", selector: { icon: {} } },
{ name: "title", selector: { text: {} } },
{ name: "content", selector: { text: { multiline: true } } },
{
name: "interactions",
type: "expandable",
flatten: true,
iconPath: mdiGestureTap,
schema: [
{ name: "action_button_text", selector: { text: {} } },
{
name: "tap_action",
selector: {
ui_action: {
default_action: "none",
},
},
},
],
},
] as const satisfies readonly HaFormSchema[]
);
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
const data = {
...this._config,
style: this._config.content_only ? "content-only" : "card",
};
const schema = this._schema(this.hass.localize);
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _valueChanged(ev: CustomEvent): void {
const config = { ...ev.detail.value };
if (config.style === "content-only") {
config.content_only = true;
} else {
delete config.content_only;
}
delete config.style;
fireEvent(this, "config-changed", { config });
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
case "style":
case "content":
case "action_button_text":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.empty_state.${schema.name}`
);
default:
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
);
}
};
}
declare global {
interface HTMLElementTagNameMap {
"hui-empty-state-card-editor": HuiEmptyStateCardEditor;
}
}

View File

@@ -15,16 +15,12 @@ import {
} from "superstruct";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-alert";
import "../../../../components/ha-card";
import "../../../../components/ha-form/ha-form";
import "../../../../components/ha-icon";
import "../../../../components/ha-switch";
import type { HomeAssistant } from "../../../../types";
import {
PREVIEW_CLICK_CALLBACK,
type PictureElementsCardConfig,
} from "../../cards/types";
import type { PictureElementsCardConfig } from "../../cards/types";
import type { LovelaceCardEditor } from "../../types";
import "../hui-sub-element-editor";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
@@ -32,6 +28,7 @@ import type { EditDetailElementEvent, SubElementEditorConfig } from "../types";
import { configElementStyle } from "./config-elements-style";
import "../hui-picture-elements-card-row-editor";
import type { LovelaceElementConfig } from "../../elements/types";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type { LocalizeFunc } from "../../../../common/translations/localize";
const genericElementConfigStruct = type({
@@ -69,44 +66,6 @@ export class HuiPictureElementsCardEditor
this._config = config;
}
private _onPreviewClick = (x: number, y: number): void => {
if (this._subElementEditorConfig?.type === "element") {
this._handlePositionClick(x, y);
}
};
private _handlePositionClick(x: number, y: number): void {
if (
!this._subElementEditorConfig?.elementConfig ||
this._subElementEditorConfig.type !== "element" ||
this._subElementEditorConfig.elementConfig.type === "conditional"
) {
return;
}
const elementConfig = this._subElementEditorConfig
.elementConfig as LovelaceElementConfig;
const currentPosition = (elementConfig.style as Record<string, string>)
?.position;
if (currentPosition && currentPosition !== "absolute") {
return;
}
const newElement = {
...elementConfig,
style: {
...((elementConfig.style as Record<string, string>) || {}),
left: `${Math.round(x)}%`,
top: `${Math.round(y)}%`,
},
};
const updateEvent = new CustomEvent("config-changed", {
detail: { config: newElement },
});
this._handleSubElementChanged(updateEvent);
}
private _schema = memoizeOne(
(localize: LocalizeFunc) =>
[
@@ -179,16 +138,6 @@ export class HuiPictureElementsCardEditor
if (this._subElementEditorConfig) {
return html`
${this._subElementEditorConfig.type === "element" &&
this._subElementEditorConfig.elementConfig?.type !== "conditional"
? html`
<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.lovelace.editor.card.picture-elements.position_hint"
)}
</ha-alert>
`
: nothing}
<hui-sub-element-editor
.hass=${this.hass}
.config=${this._subElementEditorConfig}
@@ -232,7 +181,6 @@ export class HuiPictureElementsCardEditor
return;
}
// no need to attach the preview click callback here, no element is being edited
fireEvent(this, "config-changed", { config: ev.detail.value });
}
@@ -243,8 +191,7 @@ export class HuiPictureElementsCardEditor
const config = {
...this._config,
elements: ev.detail.elements as LovelaceElementConfig[],
[PREVIEW_CLICK_CALLBACK]: this._onPreviewClick,
} as PictureElementsCardConfig;
} as LovelaceCardConfig;
fireEvent(this, "config-changed", { config });
@@ -285,12 +232,7 @@ export class HuiPictureElementsCardEditor
elementConfig: value,
};
fireEvent(this, "config-changed", {
config: {
...this._config,
[PREVIEW_CLICK_CALLBACK]: this._onPreviewClick,
},
});
fireEvent(this, "config-changed", { config: this._config });
}
private _editDetailElement(ev: HASSDomEvent<EditDetailElementEvent>): void {

View File

@@ -10,9 +10,7 @@ export const getElementStubConfig = async (
): Promise<LovelaceElementConfig> => {
let elementConfig: LovelaceElementConfig = { type };
if (type === "conditional") {
elementConfig = { type, conditions: [], elements: [] };
} else {
if (type !== "conditional") {
elementConfig.style = { left: "50%", top: "50%" };
}

View File

@@ -89,11 +89,7 @@ export abstract class HuiElementEditor<
}
public set value(config: T | undefined) {
// Compare symbols to detect callback changes (e.g., preview click handlers)
if (
this._config &&
deepEqual(config, this._config, { compareSymbols: true })
) {
if (this._config && deepEqual(config, this._config)) {
return;
}
this._config = config;

View File

@@ -73,12 +73,6 @@ export class HuiViewEditor extends LitElement {
icon: {},
},
},
{
name: "show_icon_and_title",
selector: {
boolean: {},
},
},
{ name: "path", selector: { text: {} } },
{ name: "theme", selector: { theme: {} } },
{
@@ -213,7 +207,6 @@ export class HuiViewEditor extends LitElement {
case "path":
return this.hass!.localize("ui.panel.lovelace.editor.card.generic.url");
case "type":
case "show_icon_and_title":
case "subview":
case "max_columns":
case "dense_section_placement":
@@ -234,7 +227,6 @@ export class HuiViewEditor extends LitElement {
) => {
switch (schema.name) {
case "path":
case "show_icon_and_title":
case "subview":
case "dense_section_placement":
case "top_margin":

View File

@@ -112,20 +112,7 @@ class HuiSelectEntityRow extends LitElement implements LovelaceRow {
forwardHaptic(this, "light");
setSelectOption(this.hass!, stateObj.entity_id, option)
.catch((_err) => {
// silently swallow exception
})
.finally(() =>
setTimeout(() => {
const newStateObj = this.hass!.states[this._config!.entity];
if (newStateObj === stateObj) {
const select = this.shadowRoot?.querySelector("ha-select");
const index = select?.options.indexOf(stateObj.state) ?? -1;
select?.select(index);
}
}, 2000)
);
setSelectOption(this.hass!, stateObj.entity_id, option);
}
}

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