diff --git a/public/static/images/z-wave-add-node/long-range.svg b/public/static/images/z-wave-add-node/long-range.svg index 48deddc513..32fa115cc8 100644 --- a/public/static/images/z-wave-add-node/long-range.svg +++ b/public/static/images/z-wave-add-node/long-range.svg @@ -1,13 +1,13 @@ - + - + - - + + - + diff --git a/public/static/images/z-wave-add-node/mesh.svg b/public/static/images/z-wave-add-node/mesh.svg index 92a03c444a..48fba567f4 100644 --- a/public/static/images/z-wave-add-node/mesh.svg +++ b/public/static/images/z-wave-add-node/mesh.svg @@ -1,19 +1,19 @@ - - - - - - - - + + + + + + + + - - + + - - - - - + + + + + diff --git a/public/static/images/z-wave-add-node/mesh_dark.svg b/public/static/images/z-wave-add-node/mesh_dark.svg index 1824489e47..22cda5e4f1 100644 --- a/public/static/images/z-wave-add-node/mesh_dark.svg +++ b/public/static/images/z-wave-add-node/mesh_dark.svg @@ -1,19 +1,18 @@ - - - - - - - - + + + + + + + - - + + - - - - - + + + + + diff --git a/pyproject.toml b/pyproject.toml index cb3fd4db2f..8d2173dca9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "home-assistant-frontend" -version = "20250430.2" +version = "20250502.1" license = "Apache-2.0" license-files = ["LICENSE*"] description = "The Home Assistant frontend" diff --git a/src/common/decorators/storage.ts b/src/common/decorators/storage.ts index 82e67cd32f..03d4176548 100644 --- a/src/common/decorators/storage.ts +++ b/src/common/decorators/storage.ts @@ -1,6 +1,5 @@ import type { UnsubscribeFunc } from "home-assistant-js-websocket"; -import { ReactiveElement } from "lit"; -import type { InternalPropertyDeclaration } from "lit/decorators"; +import type { ReactiveElement } from "lit"; type Callback = (oldValue: any, newValue: any) => void; @@ -108,7 +107,6 @@ export function storage(options: { storage?: "localStorage" | "sessionStorage"; subscribe?: boolean; state?: boolean; - stateOptions?: InternalPropertyDeclaration; serializer?: (value: any) => any; deserializer?: (value: any) => any; }) { @@ -174,7 +172,7 @@ export function storage(options: { performUpdate.call(this); }; - if (options.state && options.subscribe) { + if (options.subscribe) { const connectedCallback = proto.connectedCallback; const disconnectedCallback = proto.disconnectedCallback; @@ -192,12 +190,6 @@ export function storage(options: { el.__unbsubLocalStorage = undefined; }; } - if (options.state) { - ReactiveElement.createProperty(propertyKey, { - noAccessor: true, - ...options.stateOptions, - }); - } const descriptor = Object.getOwnPropertyDescriptor(proto, propertyKey); let newDescriptor: PropertyDescriptor; diff --git a/src/common/decorators/transform.ts b/src/common/decorators/transform.ts index ee02be719b..b6ac2717d4 100644 --- a/src/common/decorators/transform.ts +++ b/src/common/decorators/transform.ts @@ -1,10 +1,4 @@ -import { - ReactiveElement, - type PropertyDeclaration, - type PropertyValues, -} from "lit"; -import { shallowEqual } from "../util/shallow-equal"; - +import type { ReactiveElement, PropertyValues } from "lit"; /** * Transform function type. */ @@ -23,7 +17,6 @@ type ReactiveTransformElement = ReactiveElement & { export function transform(config: { transformer: Transformer; watch?: PropertyKey[]; - propertyOptions?: PropertyDeclaration; }) { return ( proto: ElemClass, @@ -84,11 +77,6 @@ export function transform(config: { curWatch.add(propertyKey); }); } - ReactiveElement.createProperty(propertyKey, { - noAccessor: true, - hasChanged: (v: any, o: any) => !shallowEqual(v, o), - ...config.propertyOptions, - }); const descriptor = Object.getOwnPropertyDescriptor(proto, propertyKey); let newDescriptor: PropertyDescriptor; diff --git a/src/common/dom/can-override-input.ts b/src/common/dom/can-override-input.ts index d0896ba0a6..06aa28a8b0 100644 --- a/src/common/dom/can-override-input.ts +++ b/src/common/dom/can-override-input.ts @@ -1,5 +1,11 @@ export const canOverrideAlphanumericInput = (composedPath: EventTarget[]) => { - if (composedPath.some((el) => "tagName" in el && el.tagName === "HA-MENU")) { + if ( + composedPath.some( + (el) => + "tagName" in el && + (el.tagName === "HA-MENU" || el.tagName === "HA-CODE-EDITOR") + ) + ) { return false; } diff --git a/src/components/entity/ha-entity-attribute-picker.ts b/src/components/entity/ha-entity-attribute-picker.ts index 2468f6fe71..7e08060e33 100644 --- a/src/components/entity/ha-entity-attribute-picker.ts +++ b/src/components/entity/ha-entity-attribute-picker.ts @@ -73,16 +73,20 @@ class HaEntityAttributePicker extends LitElement { return nothing; } + const stateObj = this.hass.states[this.entityId!] as HassEntity | undefined; + return html` ${primary} - - ${secondary || - this.hass.localize("ui.components.device-picker.no_area")} - + ${secondary} ${showClearIcon ? html`${this._getTitle()} + ${title ? html`
${title}
` : nothing}
${this.icon ? html`` : nothing} @@ -73,17 +74,20 @@ class HaLabeledSlider extends LitElement { .slider-container { display: flex; + align-items: center; } ha-icon { - margin-top: 8px; color: var(--secondary-text-color); } ha-slider { + display: flex; flex-grow: 1; + align-items: center; background-image: var(--ha-slider-background); border-radius: 4px; + height: 32px; } `; } diff --git a/src/components/ha-language-picker.ts b/src/components/ha-language-picker.ts index f771871756..9940b75184 100644 --- a/src/components/ha-language-picker.ts +++ b/src/components/ha-language-picker.ts @@ -102,7 +102,7 @@ export class HaLanguagePicker extends LitElement { localeChanged ) { this._select.layoutOptions(); - if (this._select.value !== this.value) { + if (!this.disabled && this._select.value !== this.value) { fireEvent(this, "value-changed", { value: this._select.value }); } if (!this.value) { @@ -141,7 +141,10 @@ export class HaLanguagePicker extends LitElement { ); const value = - this.value ?? (this.required ? languageOptions[0]?.value : this.value); + this.value ?? + (this.required && !this.disabled + ? languageOptions[0]?.value + : this.value); return html` @@ -988,12 +1003,14 @@ const tryDescribeCondition = ( ); const attribute = condition.attribute - ? computeAttributeNameDisplay( - hass.localize, - stateObj, - hass.entities, - condition.attribute - ) + ? stateObj + ? computeAttributeNameDisplay( + hass.localize, + stateObj, + hass.entities, + condition.attribute + ) + : condition.attribute : undefined; if (condition.above !== undefined && condition.below !== undefined) { @@ -1187,7 +1204,9 @@ const tryDescribeCondition = ( if (localized) { return localized; } - const stateObj = hass.states[config.entity_id as string]; + const stateObj = hass.states[config.entity_id as string] as + | HassEntity + | undefined; return `${stateObj ? computeStateName(stateObj) : config.entity_id} ${ config.type }`; diff --git a/src/data/energy.ts b/src/data/energy.ts index 70aece7feb..06498ace9b 100644 --- a/src/data/energy.ts +++ b/src/data/energy.ts @@ -959,21 +959,13 @@ const computeConsumptionDataPartial = ( }; data.timestamps.forEach((t) => { - const used_total = - (data.from_grid?.[t] || 0) + - (data.solar?.[t] || 0) + - (data.from_battery?.[t] || 0) - - (data.to_grid?.[t] || 0) - - (data.to_battery?.[t] || 0); - - outData.used_total[t] = used_total; - outData.total.used_total += used_total; const { grid_to_battery, battery_to_grid, used_solar, used_grid, used_battery, + used_total, solar_to_battery, solar_to_grid, } = computeConsumptionSingle({ @@ -984,6 +976,8 @@ const computeConsumptionDataPartial = ( from_battery: data.from_battery && (data.from_battery[t] ?? 0), }); + outData.used_total[t] = used_total; + outData.total.used_total += used_total; outData.grid_to_battery[t] = grid_to_battery; outData.total.grid_to_battery += grid_to_battery; outData.battery_to_grid![t] = battery_to_grid; @@ -1017,12 +1011,20 @@ export const computeConsumptionSingle = (data: { used_solar: number; used_grid: number; used_battery: number; + used_total: number; } => { - const to_grid = data.to_grid; - const to_battery = data.to_battery; - const solar = data.solar; - const from_grid = data.from_grid; - const from_battery = data.from_battery; + let to_grid = Math.max(data.to_grid || 0, 0); + let to_battery = Math.max(data.to_battery || 0, 0); + let solar = Math.max(data.solar || 0, 0); + let from_grid = Math.max(data.from_grid || 0, 0); + let from_battery = Math.max(data.from_battery || 0, 0); + + const used_total = + (from_grid || 0) + + (solar || 0) + + (from_battery || 0) - + (to_grid || 0) - + (to_battery || 0); let used_solar = 0; let grid_to_battery = 0; @@ -1031,41 +1033,57 @@ export const computeConsumptionSingle = (data: { let solar_to_grid = 0; let used_battery = 0; let used_grid = 0; - if ((to_grid != null || to_battery != null) && solar != null) { - used_solar = (solar || 0) - (to_grid || 0) - (to_battery || 0); - if (used_solar < 0) { - if (to_battery != null) { - grid_to_battery = used_solar * -1; - if (grid_to_battery > (from_grid || 0)) { - battery_to_grid = grid_to_battery - (from_grid || 0); - grid_to_battery = from_grid || 0; - } - } - used_solar = 0; - } - } - if (from_battery != null) { - used_battery = (from_battery || 0) - battery_to_grid; - } + let used_total_remaining = Math.max(used_total, 0); + // Consumption Priority + // Battery_Out -> Grid_Out + // Solar -> Grid_Out + // Solar -> Battery_In + // Grid_In -> Battery_In + // Solar -> Consumption + // Battery_Out -> Consumption + // Grid_In -> Consumption - if (from_grid != null) { - used_grid = from_grid - grid_to_battery; - } + // Battery_Out -> Grid_Out + battery_to_grid = Math.min(from_battery, to_grid); + from_battery -= battery_to_grid; + to_grid -= battery_to_grid; - if (solar != null) { - if (to_battery != null) { - solar_to_battery = Math.max(0, (to_battery || 0) - grid_to_battery); - } - if (to_grid != null) { - solar_to_grid = Math.max(0, (to_grid || 0) - battery_to_grid); - } - } + // Solar -> Grid_Out + solar_to_grid = Math.min(solar, to_grid); + to_grid -= solar_to_grid; + solar -= solar_to_grid; + + // Solar -> Battery_In + solar_to_battery = Math.min(solar, to_battery); + to_battery -= solar_to_battery; + solar -= solar_to_battery; + + // Grid_In -> Battery_In + grid_to_battery = Math.min(from_grid, to_battery); + from_grid -= grid_to_battery; + to_battery -= grid_to_battery; + + // Solar -> Consumption + used_solar = Math.min(used_total_remaining, solar); + used_total_remaining -= used_solar; + solar -= used_solar; + + // Battery_Out -> Consumption + used_battery = Math.min(from_battery, used_total_remaining); + from_battery -= used_battery; + used_total_remaining -= used_battery; + + // Grid_In -> Consumption + used_grid = Math.min(used_total_remaining, from_grid); + from_grid -= used_grid; + used_total_remaining -= from_grid; return { used_solar, used_grid, used_battery, + used_total, grid_to_battery, battery_to_grid, solar_to_battery, diff --git a/src/dialogs/config-flow/step-flow-create-entry.ts b/src/dialogs/config-flow/step-flow-create-entry.ts index da5ab1d183..6f59bf593d 100644 --- a/src/dialogs/config-flow/step-flow-create-entry.ts +++ b/src/dialogs/config-flow/step-flow-create-entry.ts @@ -129,70 +129,73 @@ class StepFlowCreateEntry extends LitElement { )}` : nothing} - ${devices.length === 0 - ? html`

- ${localize( - "ui.panel.config.integrations.config_flow.created_config", - { name: this.step.title } - )} -

` - : html` -
- ${devices.map( - (device) => html` -
-
- ${this.step.result?.domain - ? html`${domainToName(` - : nothing} -
- ${device.model || device.manufacturer} - ${device.model - ? html` - ${device.manufacturer} - ` - : nothing} -
-
- - -
- ` + ${devices.length === 0 && + ["options_flow", "repair_flow"].includes(this.flowConfig.flowType) + ? nothing + : devices.length === 0 + ? html`

+ ${localize( + "ui.panel.config.integrations.config_flow.created_config", + { name: this.step.title } )} -

- `} +

` + : html` +
+ ${devices.map( + (device) => html` +
+
+ ${this.step.result?.domain + ? html`${domainToName(` + : nothing} +
+ ${device.model || device.manufacturer} + ${device.model + ? html` + ${device.manufacturer} + ` + : nothing} +
+
+ + +
+ ` + )} +
+ `}
; + +const DOMAIN_STYLE = styleMap({ + fontSize: "var(--ha-font-size-s)", + fontWeight: "var(--ha-font-weight-normal)", + lineHeight: "var(--ha-line-height-normal)", + alignSelf: "flex-end", + maxWidth: "30%", + textOverflow: "ellipsis", + overflow: "hidden", + whiteSpace: "nowrap", +}); + +const ENTITY_ID_STYLE = styleMap({ + fontFamily: "var(--ha-font-family-code)", + fontSize: "var(--ha-font-size-xs)", +}); + @customElement("ha-quick-bar") export class QuickBar extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -139,6 +168,11 @@ export class QuickBar extends LitElement { } } + protected firstUpdated(changedProps) { + super.firstUpdated(changedProps); + this.hass.loadBackendTranslation("title"); + } + private _getItems = memoizeOne( ( mode: QuickBarMode, @@ -323,61 +357,67 @@ export class QuickBar extends LitElement { private _renderDeviceItem(item: DeviceItem, index?: number) { return html` - - ${item.primaryText} + ${item.primaryText} ${item.area - ? html` - ${item.area} - ` + ? html` ${item.area} ` : nothing} - + `; } private _renderEntityItem(item: EntityItem, index?: number) { + const showEntityId = this.hass.userData?.showEntityIdPicker; + return html` - ${item.iconPath ? html` ` - : html`${item.icon}`} - ${item.primaryText} + : html`${item.icon}`} + ${item.primaryText} ${item.altText - ? html` - ${item.altText} - ` + ? html` ${item.altText} ` : nothing} - + ${item.entityId && showEntityId + ? html`${item.entityId}` + : nothing} + ${item.translatedDomain && !showEntityId + ? html`
+ ${item.translatedDomain} +
` + : nothing} + `; } private _renderCommandItem(item: CommandItem, index?: number) { return html` - ${item.iconPath ? html` - + ` : nothing} ${item.categoryText} @@ -394,7 +437,7 @@ export class QuickBar extends LitElement { ${item.primaryText} - + `; } @@ -421,7 +464,7 @@ export class QuickBar extends LitElement { } private _getItemAtIndex(index: number): ListItem | null { - return this.renderRoot.querySelector(`ha-list-item[index="${index}"]`); + return this.renderRoot.querySelector(`ha-md-list-item[index="${index}"]`); } private _addSpinnerToCommandItem(index: number): void { @@ -519,7 +562,7 @@ export class QuickBar extends LitElement { } private _handleItemClick(ev) { - const listItem = ev.target.closest("ha-list-item"); + const listItem = ev.target.closest("ha-md-list-item"); this._processItemAndCloseDialog( listItem.item, Number(listItem.getAttribute("index")) @@ -555,18 +598,41 @@ export class QuickBar extends LitElement { } private _generateEntityItems(): EntityItem[] { + const isRTL = computeRTL(this.hass); + return Object.keys(this.hass.states) .map((entityId) => { - const entityState = this.hass.states[entityId]; + const stateObj = this.hass.states[entityId]; + + const { area, device } = getEntityContext(stateObj, this.hass); + + const friendlyName = computeStateName(stateObj); // Keep this for search + const entityName = computeEntityName(stateObj, this.hass); + const deviceName = device ? computeDeviceName(device) : undefined; + const areaName = area ? computeAreaName(area) : undefined; + + const primary = entityName || deviceName || entityId; + const secondary = [areaName, entityName ? deviceName : undefined] + .filter(Boolean) + .join(isRTL ? " ◂ " : " ▸ "); + + const translatedDomain = domainToName( + this.hass.localize, + computeDomain(entityId) + ); + const entityItem = { - primaryText: computeStateName(entityState), - altText: entityId, + primaryText: primary, + altText: secondary, icon: html` `, + translatedDomain: translatedDomain, + entityId: entityId, + friendlyName: friendlyName, action: () => fireEvent(this, "hass-more-info", { entityId }), }; @@ -846,9 +912,30 @@ export class QuickBar extends LitElement { }); } + private _fuseIndex = memoizeOne((items: QuickBarItem[]) => + Fuse.createIndex( + [ + "primaryText", + "altText", + "friendlyName", + "translatedDomain", + "entityId", // for technical search + ], + items + ) + ); + private _filterItems = memoizeOne( - (items: QuickBarItem[], filter: string): QuickBarItem[] => - fuzzyFilterSort(filter.trimLeft(), items) + (items: QuickBarItem[], filter: string): QuickBarItem[] => { + const index = this._fuseIndex(items); + const fuse = new HaFuse(items, {}, index); + + const results = fuse.multiTermsSearch(filter.trim()); + if (!results || !results.length) { + return items; + } + return results.map((result) => result.item); + } ); static get styles() { @@ -930,9 +1017,25 @@ export class QuickBar extends LitElement { direction: var(--direction); } - ha-list-item { + ha-md-list-item { width: 100%; - --mdc-list-item-graphic-margin: 20px; + } + + /* Fixed height for items because we are use virtualizer */ + ha-md-list-item.two-line { + --md-list-item-one-line-container-height: 64px; + --md-list-item-two-line-container-height: 64px; + --md-list-item-top-space: 8px; + --md-list-item-bottom-space: 8px; + } + + ha-md-list-item.three-line { + width: 100%; + --md-list-item-one-line-container-height: 72px; + --md-list-item-two-line-container-height: 72px; + --md-list-item-three-line-container-height: 72px; + --md-list-item-top-space: 8px; + --md-list-item-bottom-space: 8px; } ha-tip { diff --git a/src/dialogs/shortcuts/dialog-shortcuts.ts b/src/dialogs/shortcuts/dialog-shortcuts.ts index 99a3710ba6..dbd85839ae 100644 --- a/src/dialogs/shortcuts/dialog-shortcuts.ts +++ b/src/dialogs/shortcuts/dialog-shortcuts.ts @@ -69,12 +69,17 @@ const _SHORTCUTS: Section[] = [ ], }, { - key: "ui.dialogs.shortcuts.automations.title", + key: "ui.dialogs.shortcuts.automation_script.title", items: [ { type: "shortcut", shortcut: [{ key: "ui.dialogs.shortcuts.shortcuts.ctrl_cmd" }, "V"], - key: "ui.dialogs.shortcuts.automations.paste", + key: "ui.dialogs.shortcuts.automation_script.paste", + }, + { + type: "shortcut", + shortcut: [{ key: "ui.dialogs.shortcuts.shortcuts.ctrl_cmd" }, "S"], + key: "ui.dialogs.shortcuts.automation_script.save", }, ], }, diff --git a/src/dialogs/voice-assistant-setup/voice-assistant-setup-dialog.ts b/src/dialogs/voice-assistant-setup/voice-assistant-setup-dialog.ts index 7b292fe9ed..699d9099dd 100644 --- a/src/dialogs/voice-assistant-setup/voice-assistant-setup-dialog.ts +++ b/src/dialogs/voice-assistant-setup/voice-assistant-setup-dialog.ts @@ -407,6 +407,7 @@ export class HaVoiceAssistantSetupDialog extends LitElement { align-items: center; margin-right: 12px; margin-inline-end: 12px; + margin-inline-start: initial; } `, ]; diff --git a/src/dialogs/voice-command-dialog/ha-voice-command-dialog.ts b/src/dialogs/voice-command-dialog/ha-voice-command-dialog.ts index 60aa04fb11..1b2a0e88a0 100644 --- a/src/dialogs/voice-command-dialog/ha-voice-command-dialog.ts +++ b/src/dialogs/voice-command-dialog/ha-voice-command-dialog.ts @@ -36,6 +36,7 @@ export class HaVoiceCommandDialog extends LitElement { @state() private _opened = false; + @state() @storage({ key: "AssistPipelineId", state: true, diff --git a/src/layouts/ha-init-page.ts b/src/layouts/ha-init-page.ts index 84cd396569..af33535a32 100644 --- a/src/layouts/ha-init-page.ts +++ b/src/layouts/ha-init-page.ts @@ -1,6 +1,8 @@ import type { PropertyValues } from "lit"; import { css, html, LitElement } from "lit"; import { property, state } from "lit/decorators"; +import "@material/mwc-button"; +import "../components/ha-spinner"; class HaInitPage extends LitElement { @property({ type: Boolean }) public error = false; diff --git a/src/panels/calendar/ha-panel-calendar.ts b/src/panels/calendar/ha-panel-calendar.ts index b27b52874a..73cd194e4e 100644 --- a/src/panels/calendar/ha-panel-calendar.ts +++ b/src/panels/calendar/ha-panel-calendar.ts @@ -42,6 +42,7 @@ class PanelCalendar extends LitElement { @state() private _error?: string = undefined; + @state() @storage({ key: "deSelectedCalendars", state: true, diff --git a/src/panels/config/application_credentials/ha-config-application-credentials.ts b/src/panels/config/application_credentials/ha-config-application-credentials.ts index cb2fa6c5db..0ddb6f89da 100644 --- a/src/panels/config/application_credentials/ha-config-application-credentials.ts +++ b/src/panels/config/application_credentials/ha-config-application-credentials.ts @@ -69,6 +69,7 @@ export class HaConfigApplicationCredentials extends LitElement { }) private _activeHiddenColumns?: string[]; + @state() @storage({ storage: "sessionStorage", key: "application-credentials-table-search", diff --git a/src/panels/config/automation/action/ha-automation-action.ts b/src/panels/config/automation/action/ha-automation-action.ts index a70e45bac4..e41f9e500f 100644 --- a/src/panels/config/automation/action/ha-automation-action.ts +++ b/src/panels/config/automation/action/ha-automation-action.ts @@ -36,6 +36,7 @@ export default class HaAutomationAction extends LitElement { @state() private _showReorder = false; + @state() @storage({ key: "automationClipboard", state: true, diff --git a/src/panels/config/automation/condition/ha-automation-condition.ts b/src/panels/config/automation/condition/ha-automation-condition.ts index 5c3ed0f910..f1557a7956 100644 --- a/src/panels/config/automation/condition/ha-automation-condition.ts +++ b/src/panels/config/automation/condition/ha-automation-condition.ts @@ -36,6 +36,7 @@ export default class HaAutomationCondition extends LitElement { @state() private _showReorder = false; + @state() @storage({ key: "automationClipboard", state: true, diff --git a/src/panels/config/automation/ha-automation-editor.ts b/src/panels/config/automation/ha-automation-editor.ts index ebf232fc29..b6e2da8732 100644 --- a/src/panels/config/automation/ha-automation-editor.ts +++ b/src/panels/config/automation/ha-automation-editor.ts @@ -135,6 +135,7 @@ export class HaAutomationEditor extends PreventUnsavedMixin( @state() private _blueprintConfig?: BlueprintAutomationConfig; + @state() @consume({ context: fullEntitiesContext, subscribe: true }) @transform({ transformer: function (this: HaAutomationEditor, value) { diff --git a/src/panels/config/automation/ha-automation-picker.ts b/src/panels/config/automation/ha-automation-picker.ts index 034fc12695..8f77c41275 100644 --- a/src/panels/config/automation/ha-automation-picker.ts +++ b/src/panels/config/automation/ha-automation-picker.ts @@ -138,6 +138,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) { @state() private _filteredAutomations?: string[] | null; + @state() @storage({ storage: "sessionStorage", key: "automation-table-search", @@ -146,6 +147,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) { }) private _filter = ""; + @state() @storage({ storage: "sessionStorage", key: "automation-table-filters-full", diff --git a/src/panels/config/automation/option/ha-automation-option.ts b/src/panels/config/automation/option/ha-automation-option.ts index 0bbe6e919d..8a3184a724 100644 --- a/src/panels/config/automation/option/ha-automation-option.ts +++ b/src/panels/config/automation/option/ha-automation-option.ts @@ -29,6 +29,7 @@ export default class HaAutomationOption extends LitElement { @state() private _showReorder = false; + @state() @storage({ key: "automationClipboard", state: true, diff --git a/src/panels/config/automation/trigger/ha-automation-trigger.ts b/src/panels/config/automation/trigger/ha-automation-trigger.ts index fabc23eef6..cc6219d0cc 100644 --- a/src/panels/config/automation/trigger/ha-automation-trigger.ts +++ b/src/panels/config/automation/trigger/ha-automation-trigger.ts @@ -38,6 +38,7 @@ export default class HaAutomationTrigger extends LitElement { @state() private _showReorder = false; + @state() @storage({ key: "automationClipboard", state: true, diff --git a/src/panels/config/backup/ha-config-backup-backups.ts b/src/panels/config/backup/ha-config-backup-backups.ts index 30af43c705..969c99b275 100644 --- a/src/panels/config/backup/ha-config-backup-backups.ts +++ b/src/panels/config/backup/ha-config-backup-backups.ts @@ -98,6 +98,7 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) { @state() private _selected: string[] = []; + @state() @storage({ storage: "sessionStorage", key: "backups-table-filters", diff --git a/src/panels/config/backup/ha-config-backup-location.ts b/src/panels/config/backup/ha-config-backup-location.ts index 6e5d5b585f..323ca5097c 100644 --- a/src/panels/config/backup/ha-config-backup-location.ts +++ b/src/panels/config/backup/ha-config-backup-location.ts @@ -118,19 +118,17 @@ class HaConfigBackupDetails extends LitElement {

` - : this.config?.agents[this.agentId] - ? html`` - : nothing} + : html``}
diff --git a/src/panels/config/blueprint/ha-blueprint-overview.ts b/src/panels/config/blueprint/ha-blueprint-overview.ts index 6b329ff8e6..6ac304a956 100644 --- a/src/panels/config/blueprint/ha-blueprint-overview.ts +++ b/src/panels/config/blueprint/ha-blueprint-overview.ts @@ -9,7 +9,7 @@ import { } from "@mdi/js"; import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit"; import { LitElement, html } from "lit"; -import { customElement, property } from "lit/decorators"; +import { customElement, property, state } from "lit/decorators"; import memoizeOne from "memoize-one"; import type { HASSDomEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event"; @@ -118,6 +118,7 @@ class HaBlueprintOverview extends LitElement { }) private _activeHiddenColumns?: string[]; + @state() @storage({ storage: "sessionStorage", key: "blueprint-table-search", @@ -499,9 +500,11 @@ class HaBlueprintOverview extends LitElement { list: html`
`; diff --git a/src/panels/lovelace/editor/badge-editor/hui-badge-picker.ts b/src/panels/lovelace/editor/badge-editor/hui-badge-picker.ts index 1e720a4632..af0030c52c 100644 --- a/src/panels/lovelace/editor/badge-editor/hui-badge-picker.ts +++ b/src/panels/lovelace/editor/badge-editor/hui-badge-picker.ts @@ -43,6 +43,7 @@ export class HuiBadgePicker extends LitElement { @property({ attribute: false }) public suggestedBadges?: string[]; + @state() @storage({ key: "dashboardBadgeClipboard", state: true, diff --git a/src/panels/lovelace/editor/badge-editor/hui-dialog-create-badge.ts b/src/panels/lovelace/editor/badge-editor/hui-dialog-create-badge.ts index d14959e4fb..9388295802 100644 --- a/src/panels/lovelace/editor/badge-editor/hui-dialog-create-badge.ts +++ b/src/panels/lovelace/editor/badge-editor/hui-dialog-create-badge.ts @@ -4,11 +4,7 @@ import { css, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { cache } from "lit/directives/cache"; import { classMap } from "lit/directives/class-map"; -import memoize from "memoize-one"; import { fireEvent } from "../../../../common/dom/fire_event"; -import { computeDomain } from "../../../../common/entity/compute_domain"; -import { computeStateName } from "../../../../common/entity/compute_state_name"; -import type { DataTableRowData } from "../../../../components/data-table/ha-data-table"; import "../../../../components/ha-dialog"; import "../../../../components/ha-dialog-header"; import "../../../../components/sl-tab-group"; @@ -137,7 +133,6 @@ export class HuiCreateDialogBadge no-label-float .hass=${this.hass} .narrow=${true} - .entities=${this._allEntities(this.hass.states)} @selected-changed=${this._handleSelectedChanged} > ` @@ -276,20 +271,6 @@ export class HuiCreateDialogBadge this.closeDialog(); } - - private _allEntities = memoize((entities) => - Object.keys(entities).map((entity) => { - const stateObj = this.hass.states[entity]; - return { - icon: "", - entity_id: entity, - stateObj, - name: computeStateName(stateObj), - domain: computeDomain(entity), - last_changed: stateObj!.last_changed, - } as DataTableRowData; - }) - ); } declare global { diff --git a/src/panels/lovelace/editor/card-editor/hui-card-picker.ts b/src/panels/lovelace/editor/card-editor/hui-card-picker.ts index edb1c704fd..f663f36021 100644 --- a/src/panels/lovelace/editor/card-editor/hui-card-picker.ts +++ b/src/panels/lovelace/editor/card-editor/hui-card-picker.ts @@ -42,6 +42,7 @@ export class HuiCardPicker extends LitElement { @property({ attribute: false }) public suggestedCards?: string[]; + @state() @storage({ key: "dashboardCardClipboard", state: true, diff --git a/src/panels/lovelace/editor/card-editor/hui-dialog-create-card.ts b/src/panels/lovelace/editor/card-editor/hui-dialog-create-card.ts index 314029fa0d..46927da9c8 100644 --- a/src/panels/lovelace/editor/card-editor/hui-dialog-create-card.ts +++ b/src/panels/lovelace/editor/card-editor/hui-dialog-create-card.ts @@ -5,11 +5,7 @@ import { customElement, property, state } from "lit/decorators"; import { cache } from "lit/directives/cache"; import { classMap } from "lit/directives/class-map"; import { ifDefined } from "lit/directives/if-defined"; -import memoize from "memoize-one"; import { fireEvent } from "../../../../common/dom/fire_event"; -import { computeDomain } from "../../../../common/entity/compute_domain"; -import { computeStateName } from "../../../../common/entity/compute_state_name"; -import type { DataTableRowData } from "../../../../components/data-table/ha-data-table"; import "../../../../components/ha-dialog"; import "../../../../components/ha-dialog-header"; import "../../../../components/sl-tab-group"; @@ -157,7 +153,6 @@ export class HuiCreateDialogCard no-label-float .hass=${this.hass} narrow - .entities=${this._allEntities(this.hass.states)} @selected-changed=${this._handleSelectedChanged} > ` @@ -340,20 +335,6 @@ export class HuiCreateDialogCard this.closeDialog(); } - - private _allEntities = memoize((entities) => - Object.keys(entities).map((entity) => { - const stateObj = this.hass.states[entity]; - return { - icon: "", - entity_id: entity, - stateObj, - name: computeStateName(stateObj), - domain: computeDomain(entity), - last_changed: stateObj!.last_changed, - } as DataTableRowData; - }) - ); } declare global { diff --git a/src/panels/lovelace/editor/card-editor/hui-entity-picker-table.ts b/src/panels/lovelace/editor/card-editor/hui-entity-picker-table.ts index 5918aafef6..dd0c3a549c 100644 --- a/src/panels/lovelace/editor/card-editor/hui-entity-picker-table.ts +++ b/src/panels/lovelace/editor/card-editor/hui-entity-picker-table.ts @@ -1,9 +1,17 @@ -import type { TemplateResult } from "lit"; -import { css, html, LitElement } from "lit"; +import type { PropertyValues, TemplateResult } from "lit"; +import { css, html, LitElement, nothing } from "lit"; import { customElement, property } from "lit/decorators"; +import { styleMap } from "lit/directives/style-map"; import memoizeOne from "memoize-one"; import type { HASSDomEvent } from "../../../../common/dom/fire_event"; import { fireEvent } from "../../../../common/dom/fire_event"; +import { computeAreaName } from "../../../../common/entity/compute_area_name"; +import { computeDeviceName } from "../../../../common/entity/compute_device_name"; +import { computeDomain } from "../../../../common/entity/compute_domain"; +import { computeEntityName } from "../../../../common/entity/compute_entity_name"; +import { getEntityContext } from "../../../../common/entity/context/get_entity_context"; +import type { LocalizeFunc } from "../../../../common/translations/localize"; +import { computeRTL } from "../../../../common/util/compute_rtl"; import "../../../../components/data-table/ha-data-table"; import type { DataTableColumnContainer, @@ -12,8 +20,26 @@ import type { } from "../../../../components/data-table/ha-data-table"; import "../../../../components/entity/state-badge"; import "../../../../components/ha-relative-time"; +import { domainToName } from "../../../../data/integration"; import type { HomeAssistant } from "../../../../types"; +const ENTITY_ID_STYLE = styleMap({ + fontFamily: "var(--ha-font-family-code)", + fontSize: "var(--ha-font-size-xs)", +}); + +interface EntityPickerTableRowData extends DataTableRowData { + icon: string; + entity_id: string; + stateObj: any; + name: string; + entity_name?: string; + device_name?: string; + area_name?: string; + domain_name: string; + last_changed: string; +} + @customElement("hui-entity-picker-table") export class HuiEntityPickerTable extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -23,16 +49,69 @@ export class HuiEntityPickerTable extends LitElement { @property({ type: Boolean, attribute: "no-label-float" }) public noLabelFloat? = false; - @property({ type: Array }) public entities!: DataTableRowData[]; + @property({ type: Array }) public entities?: string[]; + + protected firstUpdated(_changedProperties: PropertyValues): void { + super.firstUpdated(_changedProperties); + this.hass.loadBackendTranslation("title"); + } + + private _data = memoizeOne( + ( + states: HomeAssistant["states"], + localize: LocalizeFunc, + entities?: string[] + ): EntityPickerTableRowData[] => + (entities || Object.keys(states)).map( + (entity) => { + const stateObj = this.hass.states[entity]; + + const { area, device } = getEntityContext(stateObj, this.hass); + + const entityName = computeEntityName(stateObj, this.hass); + const deviceName = device ? computeDeviceName(device) : undefined; + const areaName = area ? computeAreaName(area) : undefined; + const name = [deviceName, entityName].filter(Boolean).join(" "); + const domain = computeDomain(entity); + + return { + icon: "", + entity_id: entity, + stateObj, + name: name, + entity_name: entityName, + device_name: deviceName, + area_name: areaName, + domain_name: domainToName(localize, domain), + last_changed: stateObj!.last_changed, + } satisfies EntityPickerTableRowData; + } + ) + ); protected render(): TemplateResult { + const data = this._data( + this.hass.states, + this.hass.localize, + this.entities + ); + + const showEntityId = Boolean(this.hass.userData?.showEntityIdPicker); + + const columns = this._columns( + this.narrow, + computeRTL(this.hass), + showEntityId + ); + return html` { - const columns: DataTableColumnContainer = { - icon: { - title: "", - label: this.hass!.localize( - "ui.panel.lovelace.unused_entities.state_icon" + private _columns = memoizeOne( + (narrow: boolean, isRTL: boolean, showEntityId: boolean) => { + const columns: DataTableColumnContainer = { + icon: { + title: "", + label: this.hass!.localize( + "ui.panel.lovelace.unused_entities.state_icon" + ), + type: "icon", + template: (entity) => html` + + `, + }, + name: { + title: this.hass!.localize( + "ui.panel.lovelace.unused_entities.entity" + ), + sortable: true, + filterable: true, + flex: 2, + main: true, + direction: "asc", + template: (entity: any) => { + const primary = + entity.entity_name || entity.device_name || entity.entity_id; + const secondary = [ + entity.area_name, + entity.entity_name ? entity.device_name : undefined, + ] + .filter(Boolean) + .join(isRTL ? " ◂ " : " ▸ "); + return html` +
+ ${primary} + ${secondary + ? html`
${secondary}
` + : nothing} + ${narrow && showEntityId + ? html` +
+ ${entity.entity_id} +
+ ` + : nothing} +
+ `; + }, + }, + }; + + columns.entity_name = { + title: "entity_name", + filterable: true, + hidden: true, + }; + + columns.device_name = { + title: "device_name", + filterable: true, + hidden: true, + }; + + columns.area_name = { + title: "area_name", + filterable: true, + hidden: true, + }; + + columns.entity_id = { + title: this.hass!.localize( + "ui.panel.lovelace.unused_entities.entity_id" ), - type: "icon", - template: (entity) => html` - - `, - }, - name: { - title: this.hass!.localize("ui.panel.lovelace.unused_entities.entity"), sortable: true, filterable: true, - flex: 2, - main: true, - direction: "asc", - template: (entity: any) => html` -
- ${entity.name} - ${narrow - ? html`
${entity.entity_id}
` - : ""} -
+ hidden: narrow || !showEntityId, + }; + + columns.domain_name = { + title: this.hass!.localize("ui.panel.lovelace.unused_entities.domain"), + sortable: true, + filterable: true, + hidden: narrow || showEntityId, + }; + + columns.last_changed = { + title: this.hass!.localize( + "ui.panel.lovelace.unused_entities.last_changed" + ), + type: "numeric", + sortable: true, + hidden: narrow, + template: (entity) => html` + `, - }, - }; + }; - columns.entity_id = { - title: this.hass!.localize("ui.panel.lovelace.unused_entities.entity_id"), - sortable: true, - filterable: true, - hidden: narrow, - }; - - columns.domain = { - title: this.hass!.localize("ui.panel.lovelace.unused_entities.domain"), - sortable: true, - filterable: true, - hidden: narrow, - }; - - columns.last_changed = { - title: this.hass!.localize( - "ui.panel.lovelace.unused_entities.last_changed" - ), - type: "numeric", - sortable: true, - hidden: narrow, - template: (entity) => html` - - `, - }; - - return columns; - }); + return columns; + } + ); private _handleSelectionChanged( ev: HASSDomEvent @@ -134,6 +254,9 @@ export class HuiEntityPickerTable extends LitElement { --data-table-border-width: 0; height: 100%; } + ha-data-table.show-entity-id { + --data-table-row-height: 64px; + } `; } diff --git a/src/panels/lovelace/editor/hui-entities-card-row-editor.ts b/src/panels/lovelace/editor/hui-entities-card-row-editor.ts index 06d9ab0f35..ab574af88d 100644 --- a/src/panels/lovelace/editor/hui-entities-card-row-editor.ts +++ b/src/panels/lovelace/editor/hui-entities-card-row-editor.ts @@ -210,6 +210,7 @@ export class HuiEntitiesCardRowEditor extends LitElement { .entity ha-entity-picker { flex-grow: 1; + min-width: 0; } .special-row { diff --git a/src/panels/lovelace/editor/unused-entities/hui-unused-entities.ts b/src/panels/lovelace/editor/unused-entities/hui-unused-entities.ts index a63d3e3a50..392f439f73 100644 --- a/src/panels/lovelace/editor/unused-entities/hui-unused-entities.ts +++ b/src/panels/lovelace/editor/unused-entities/hui-unused-entities.ts @@ -3,22 +3,19 @@ import type { PropertyValues } from "lit"; import { css, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; -import { computeDomain } from "../../../../common/entity/compute_domain"; -import { computeStateName } from "../../../../common/entity/compute_state_name"; -import type { DataTableRowData } from "../../../../components/data-table/ha-data-table"; import "../../../../components/ha-fab"; import "../../../../components/ha-svg-icon"; +import type { LovelaceConfig } from "../../../../data/lovelace/config/types"; import type { HomeAssistant } from "../../../../types"; import { computeUnusedEntities } from "../../common/compute-unused-entities"; -import type { Lovelace } from "../../types"; -import "../card-editor/hui-entity-picker-table"; -import { showSuggestCardDialog } from "../card-editor/show-suggest-card-dialog"; -import { showSelectViewDialog } from "../select-view/show-select-view-dialog"; -import type { LovelaceConfig } from "../../../../data/lovelace/config/types"; import { computeCards, computeSection, } from "../../common/generate-lovelace-config"; +import type { Lovelace } from "../../types"; +import "../card-editor/hui-entity-picker-table"; +import { showSuggestCardDialog } from "../card-editor/show-suggest-card-dialog"; +import { showSelectViewDialog } from "../select-view/show-select-view-dialog"; @customElement("hui-unused-entities") export class HuiUnusedEntities extends LitElement { @@ -80,17 +77,7 @@ export class HuiUnusedEntities extends LitElement { { - const stateObj = this.hass!.states[entity]; - return { - icon: "", - entity_id: entity, - stateObj, - name: stateObj ? computeStateName(stateObj) : "Unavailable", - domain: computeDomain(entity), - last_changed: stateObj?.last_changed, - }; - }) as DataTableRowData[]} + .entities=${this._unusedEntities} @selected-changed=${this._handleSelectedChanged} > diff --git a/src/panels/lovelace/hui-root.ts b/src/panels/lovelace/hui-root.ts index 883fab5089..8083dd947c 100644 --- a/src/panels/lovelace/hui-root.ts +++ b/src/panels/lovelace/hui-root.ts @@ -300,6 +300,12 @@ class HUIRoot extends LitElement { const background = curViewConfig?.background || this.config.background; + const _isTabHiddenForUser = (view: LovelaceViewConfig) => + view.visible !== undefined && + ((Array.isArray(view.visible) && + !view.visible.some((e) => e.user === this.hass!.user?.id)) || + view.visible === false); + const tabs = html` ${views.map( (view, index) => html` @@ -311,13 +317,7 @@ class HUIRoot extends LitElement { class=${classMap({ icon: Boolean(view.icon), "hide-tab": Boolean( - !this._editMode && - view.visible !== undefined && - ((Array.isArray(view.visible) && - !view.visible.some( - (e) => e.user === this.hass!.user?.id - )) || - view.visible === false) + !this._editMode && (view.subview || _isTabHiddenForUser(view)) ), })} > diff --git a/src/panels/media-browser/ha-panel-media-browser.ts b/src/panels/media-browser/ha-panel-media-browser.ts index 05b2799ae5..d84f09a703 100644 --- a/src/panels/media-browser/ha-panel-media-browser.ts +++ b/src/panels/media-browser/ha-panel-media-browser.ts @@ -64,6 +64,7 @@ class PanelMediaBrowser extends LitElement { @state() _currentItem?: MediaPlayerItem; + @state() @storage({ key: "mediaBrowserPreferredLayout", state: true, @@ -78,6 +79,7 @@ class PanelMediaBrowser extends LitElement { }, ]; + @state() @storage({ key: "mediaBrowseEntityId", state: true, diff --git a/src/panels/my/ha-panel-my.ts b/src/panels/my/ha-panel-my.ts index 110e540fbb..d41ef36863 100644 --- a/src/panels/my/ha-panel-my.ts +++ b/src/panels/my/ha-panel-my.ts @@ -16,6 +16,11 @@ import "../../layouts/hass-error-screen"; import type { HomeAssistant, Route } from "../../types"; import { documentationUrl } from "../../util/documentation-url"; +// When a user presses "m", the user is redirected to the first redirect +// for which holds true currentPath.startsWith(redirect.redirect) +// That's why redirects should be sorted with more specific ones first +// Or else pressing "M" will link to the higher level page. + export const getMyRedirects = (): Redirects => ({ application_credentials: { redirect: "/config/application_credentials", @@ -73,15 +78,15 @@ export const getMyRedirects = (): Redirects => ({ brand: "string", }, }, - integrations: { - redirect: "/config/integrations", - }, integration: { redirect: "/config/integrations/integration", params: { domain: "string", }, }, + integrations: { + redirect: "/config/integrations", + }, config_mqtt: { component: "mqtt", redirect: "/config/mqtt", @@ -106,6 +111,14 @@ export const getMyRedirects = (): Redirects => ({ component: "matter", redirect: "/config/matter/add", }, + bluetooth_advertisement_monitor: { + component: "bluetooth", + redirect: "/config/bluetooth/advertisement-monitor", + }, + bluetooth_connection_monitor: { + component: "bluetooth", + redirect: "/config/bluetooth/connection-monitor", + }, config_bluetooth: { component: "bluetooth", redirect: "/config/bluetooth", @@ -244,6 +257,9 @@ export const getMyRedirects = (): Redirects => ({ // customize was removed in 2021.12, fallback to dashboard redirect: "/config/dashboard", }, + profile_security: { + redirect: "/profile/security", + }, profile: { redirect: "/profile", }, @@ -259,10 +275,6 @@ export const getMyRedirects = (): Redirects => ({ component: "media_source", redirect: "/media-browser", }, - backup: { - component: "backup", - redirect: "/config/backup", - }, backup_list: { component: "backup", redirect: "/config/backup/backups", @@ -271,6 +283,10 @@ export const getMyRedirects = (): Redirects => ({ component: "backup", redirect: "/config/backup/settings", }, + backup: { + component: "backup", + redirect: "/config/backup", + }, supervisor_snapshots: { component: "backup", redirect: "/config/backup", diff --git a/src/panels/todo/ha-panel-todo.ts b/src/panels/todo/ha-panel-todo.ts index d6c2369822..9dcb961478 100644 --- a/src/panels/todo/ha-panel-todo.ts +++ b/src/panels/todo/ha-panel-todo.ts @@ -9,7 +9,7 @@ import { } from "@mdi/js"; import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit"; import { LitElement, css, html, nothing } from "lit"; -import { customElement, property } from "lit/decorators"; +import { customElement, property, state } from "lit/decorators"; import memoizeOne from "memoize-one"; import { isComponentLoaded } from "../../common/config/is_component_loaded"; import { storage } from "../../common/decorators/storage"; @@ -55,6 +55,7 @@ class PanelTodo extends LitElement { @property({ type: Boolean, reflect: true }) public mobile = false; + @state() @storage({ key: "selectedTodoEntity", state: true, diff --git a/src/state/quick-bar-mixin.ts b/src/state/quick-bar-mixin.ts index 598d064597..f0effc7f03 100644 --- a/src/state/quick-bar-mixin.ts +++ b/src/state/quick-bar-mixin.ts @@ -15,6 +15,8 @@ import type { HassElement } from "./hass-element"; import { extractSearchParamsObject } from "../common/url/search-params"; import { showVoiceCommandDialog } from "../dialogs/voice-command-dialog/show-ha-voice-command-dialog"; import { canOverrideAlphanumericInput } from "../common/dom/can-override-input"; +import { showShortcutsDialog } from "../dialogs/shortcuts/show-shortcuts-dialog"; +import type { Redirects } from "../panels/my/ha-panel-my"; declare global { interface HASSDomEvents { @@ -51,6 +53,8 @@ export default >(superClass: T) => case "a": this._showVoiceCommandDialog(ev.detail); break; + case "?": + this._showShortcutDialog(ev.detail); } }); @@ -65,6 +69,8 @@ export default >(superClass: T) => m: (ev) => this._createMyLink(ev), a: (ev) => this._showVoiceCommandDialog(ev), d: (ev) => this._showQuickBar(ev, QuickBarMode.Device), + // Workaround see https://github.com/jamiebuilds/tinykeys/issues/130 + "Shift+?": (ev) => this._showShortcutDialog(ev), // Those are fallbacks for non-latin keyboards that don't have e, c, m keys (qwerty-based shortcuts) KeyE: (ev) => this._showQuickBar(ev), KeyC: (ev) => this._showQuickBar(ev, QuickBarMode.Command), @@ -111,6 +117,19 @@ export default >(superClass: T) => showQuickBar(this, { mode }); } + private _showShortcutDialog(e: KeyboardEvent) { + if (!this._canShowQuickBar(e)) { + return; + } + + if (e.defaultPrevented) { + return; + } + e.preventDefault(); + + showShortcutsDialog(this); + } + private async _createMyLink(e: KeyboardEvent) { if ( !this.hass?.enableShortcuts || @@ -125,49 +144,44 @@ export default >(superClass: T) => e.preventDefault(); const targetPath = mainWindow.location.pathname; - const isHassio = isComponentLoaded(this.hass, "hassio"); const myParams = new URLSearchParams(); - if (isHassio && targetPath.startsWith("/hassio")) { + let redirects: Redirects; + + if (targetPath.startsWith("/hassio")) { const myPanelSupervisor = await import( "../../hassio/src/hassio-my-redirect" ); - for (const [slug, redirect] of Object.entries( - myPanelSupervisor.REDIRECTS - )) { - if (targetPath.startsWith(redirect.redirect)) { - myParams.append("redirect", slug); - if (redirect.redirect === "/hassio/addon") { - myParams.append("addon", targetPath.split("/")[3]); - } - window.open( - `https://my.home-assistant.io/create-link/?${myParams.toString()}`, - "_blank" - ); - return; - } - } + redirects = myPanelSupervisor.REDIRECTS; + } else { + const myPanel = await import("../panels/my/ha-panel-my"); + redirects = myPanel.getMyRedirects(); } - const myPanel = await import("../panels/my/ha-panel-my"); + for (const [slug, redirect] of Object.entries(redirects)) { + if (!targetPath.startsWith(redirect.redirect)) { + continue; + } + myParams.append("redirect", slug); - for (const [slug, redirect] of Object.entries(myPanel.getMyRedirects())) { - if (targetPath.startsWith(redirect.redirect)) { - myParams.append("redirect", slug); - if (redirect.params) { - const params = extractSearchParamsObject(); - for (const key of Object.keys(redirect.params)) { - if (key in params) { - myParams.append(key, params[key]); - } + if (redirect.params) { + const params = extractSearchParamsObject(); + for (const key of Object.keys(redirect.params)) { + if (key in params) { + myParams.append(key, params[key]); } } - window.open( - `https://my.home-assistant.io/create-link/?${myParams.toString()}`, - "_blank" - ); - return; } + if (redirect.redirect === "/config/integrations/integration") { + myParams.append("domain", targetPath.split("/")[4]); + } else if (redirect.redirect === "/hassio/addon") { + myParams.append("addon", targetPath.split("/")[3]); + } + window.open( + `https://my.home-assistant.io/create-link/?${myParams.toString()}`, + "_blank" + ); + return; } showToast(this, { message: this.hass.localize( diff --git a/src/translations/en.json b/src/translations/en.json index 1fb0d53531..4ceef74c9b 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1938,9 +1938,10 @@ "title": "Assist", "open_assist": "open Assist dialog" }, - "automations": { - "title": "Automations", - "paste": "to paste automation YAML from clipboard to automation editor" + "automation_script": { + "title": "Automations / Scripts", + "paste": "to paste automation/script YAML from clipboard to editor", + "save": "to save automation/script" }, "charts": { "title": "Charts", @@ -5508,6 +5509,7 @@ "connection_monitor": "Connection monitor", "used_connection_slot_allocations": "Used connection slot allocations", "no_connections": "No active connections", + "no_advertisements_found": "No matching Bluetooth advertisements found", "no_connection_slot_allocations": "No connection slot allocations information available", "no_active_connection_support": "This adapter does not support making active (GATT) connections.", "address": "Address", @@ -5528,7 +5530,8 @@ "title": "DHCP discovery", "mac_address": "MAC Address", "hostname": "Hostname", - "ip_address": "IP Address" + "ip_address": "IP Address", + "no_devices_found": "No recent DHCP requests found; no matching discoveries detected" }, "thread": { "other_networks": "Other networks", @@ -5577,7 +5580,8 @@ "ssdp_headers": "SSDP Headers", "upnp": "Universal Plug and Play (UPnP)", "discovery_information": "Discovery information", - "copy_to_clipboard": "Copy to clipboard" + "copy_to_clipboard": "Copy to clipboard", + "no_devices_found": "No matching SSDP/UPnP discoveries found" }, "zeroconf": { "name": "Name", @@ -5586,7 +5590,8 @@ "ip_addresses": "IP Addresses", "properties": "Properties", "discovery_information": "Discovery information", - "copy_to_clipboard": "Copy to clipboard" + "copy_to_clipboard": "Copy to clipboard", + "no_devices_found": "No matching Zeroconf discoveries found" }, "zha": { "common": { @@ -6394,15 +6399,14 @@ } }, "discovery": { + "title": "Network discovery", + "description": "Explore what data Home Assistant can see on the network.", "dhcp": "DHCP browser", - "dhcp_info": "The DHCP browser displays devices discovered by Home Assistant via DHCP, ARP+PTR lookups, and router-based device trackers. All detected devices by these methods will appear here.", - "dhcp_browser": "View DHCP browser", + "dhcp_info": "Show devices detected by Home Assistant using methods like DHCP, ARP+PTR lookups, and router-based device trackers. DHCP (Dynamic Host Configuration Protocol) data is received when devices join the network and request an IP address.", "ssdp": "SSDP browser", - "ssdp_info": "The SSDP browser shows devices discovered by Home Assistant using SSDP/UPnP. Devices that Home Assistant has discovered will appear here.", - "ssdp_browser": "View SSDP browser", + "ssdp_info": "Show devices discovered by Home Assistant using SSDP/UPnP. Devices that Home Assistant has discovered will appear here.", "zeroconf": "Zeroconf browser", - "zeroconf_info": "The Zeroconf browser shows devices discovered by Home Assistant using mDNS. Only devices that Home Assistant is actively searching for will appear here.", - "zeroconf_browser": "View Zeroconf browser" + "zeroconf_info": "Show devices discovered by Home Assistant using mDNS. Only devices that Home Assistant is actively searching for will appear here." }, "network_adapter": "Network adapter", "network_adapter_info": "Configure which network adapters integrations will use. Currently this setting only affects multicast traffic. A restart is required for these settings to apply.", diff --git a/test/data/energy.test.ts b/test/data/energy.test.ts index e5fcbe9dc6..ba8d159e08 100644 --- a/test/data/energy.test.ts +++ b/test/data/energy.test.ts @@ -14,6 +14,47 @@ import { } from "../../src/data/energy"; import type { HomeAssistant } from "../../src/types"; +const checkConsumptionResult = ( + input: { + from_grid: number | undefined; + to_grid: number | undefined; + solar: number | undefined; + to_battery: number | undefined; + from_battery: number | undefined; + }, + exact = true +): { + grid_to_battery: number; + battery_to_grid: number; + solar_to_battery: number; + solar_to_grid: number; + used_solar: number; + used_grid: number; + used_battery: number; + used_total: number; +} => { + const result = computeConsumptionSingle(input); + if (exact) { + assert.equal( + result.used_total, + result.used_solar + result.used_battery + result.used_grid + ); + assert.equal( + input.to_grid || 0, + result.solar_to_grid + result.battery_to_grid + ); + assert.equal( + input.to_battery || 0, + result.grid_to_battery + result.solar_to_battery + ); + assert.equal( + input.solar || 0, + result.solar_to_battery + result.solar_to_grid + result.used_solar + ); + } + return result; +}; + describe("Energy Short Format Test", () => { // Create default to not have to specify a not relevant TimeFormat over and over again. const defaultLocale: FrontendLocaleData = { @@ -88,7 +129,7 @@ describe("Energy Usage Calculation Tests", () => { it("Consuming Energy From the Grid", () => { [0, 5, 1000].forEach((x) => { assert.deepEqual( - computeConsumptionSingle({ + checkConsumptionResult({ from_grid: x, to_grid: undefined, solar: undefined, @@ -101,6 +142,7 @@ describe("Energy Usage Calculation Tests", () => { used_solar: 0, used_grid: x, used_battery: 0, + used_total: x, solar_to_battery: 0, solar_to_grid: 0, } @@ -108,61 +150,78 @@ describe("Energy Usage Calculation Tests", () => { }); }); it("Solar production, consuming some and returning the remainder to grid.", () => { - [2.99, 3, 10, 100].forEach((s) => { + ( + [ + [2.99, false], // unsolveable : solar < to_grid + [3, true], + [10, true], + [100, true], + ] as any + ).forEach(([s, exact]) => { assert.deepEqual( - computeConsumptionSingle({ - from_grid: 0, - to_grid: 3, - solar: s, - to_battery: undefined, - from_battery: undefined, - }), + checkConsumptionResult( + { + from_grid: 0, + to_grid: 3, + solar: s, + to_battery: undefined, + from_battery: undefined, + }, + exact + ), { grid_to_battery: 0, battery_to_grid: 0, - used_solar: Math.max(0, s - 3), + used_solar: Math.min(s, Math.max(0, s - 3)), used_grid: 0, used_battery: 0, + used_total: s - 3, solar_to_battery: 0, - solar_to_grid: 3, + solar_to_grid: Math.min(3, s), } ); }); }); it("Solar production with simultaneous grid consumption. Excess solar returned to the grid.", () => { - [ - [0, 0], - [3, 0], - [0, 3], - [5, 4], - [4, 5], - [10, 3], - [3, 7], - [3, 7.1], - ].forEach(([from_grid, to_grid]) => { + ( + [ + [0, 0, true], + [3, 0, true], + [0, 3, true], + [5, 4, true], + [4, 5, true], + [10, 3, true], + [3, 7, true], + [3, 7.1, false], // unsolveable: to_grid > solar + ] as any + ).forEach(([from_grid, to_grid, exact]) => { assert.deepEqual( - computeConsumptionSingle({ - from_grid, - to_grid, - solar: 7, - to_battery: undefined, - from_battery: undefined, - }), + checkConsumptionResult( + { + from_grid, + to_grid, + solar: 7, + to_battery: undefined, + from_battery: undefined, + }, + exact + ), { grid_to_battery: 0, battery_to_grid: 0, used_solar: Math.max(0, 7 - to_grid), - used_grid: from_grid, + used_grid: from_grid - Math.max(0, to_grid - 7), + used_total: from_grid - to_grid + 7, used_battery: 0, solar_to_battery: 0, - solar_to_grid: to_grid, + solar_to_grid: Math.min(7, to_grid), } ); }); }); it("Charging the battery from the grid", () => { assert.deepEqual( - computeConsumptionSingle({ + checkConsumptionResult({ from_grid: 5, to_grid: 0, solar: 0, @@ -175,6 +234,7 @@ describe("Energy Usage Calculation Tests", () => { used_solar: 0, used_grid: 2, used_battery: 0, + used_total: 2, solar_to_battery: 0, solar_to_grid: 0, } @@ -182,7 +242,7 @@ describe("Energy Usage Calculation Tests", () => { }); it("Consuming from the grid and battery simultaneously", () => { assert.deepEqual( - computeConsumptionSingle({ + checkConsumptionResult({ from_grid: 5, to_grid: 0, solar: 0, @@ -195,6 +255,7 @@ describe("Energy Usage Calculation Tests", () => { used_solar: 0, used_grid: 5, used_battery: 5, + used_total: 10, solar_to_battery: 0, solar_to_grid: 0, } @@ -202,7 +263,7 @@ describe("Energy Usage Calculation Tests", () => { }); it("Consuming some battery and returning some battery to the grid", () => { assert.deepEqual( - computeConsumptionSingle({ + checkConsumptionResult({ from_grid: 0, to_grid: 4, solar: 0, @@ -215,15 +276,15 @@ describe("Energy Usage Calculation Tests", () => { used_solar: 0, used_grid: 0, used_battery: 1, + used_total: 1, solar_to_battery: 0, solar_to_grid: 0, } ); }); - /* Fails it("Charging and discharging the battery to/from the grid in the same interval.", () => { assert.deepEqual( - computeConsumptionSingle({ + checkConsumptionResult({ from_grid: 5, to_grid: 1, solar: 0, @@ -234,15 +295,18 @@ describe("Energy Usage Calculation Tests", () => { grid_to_battery: 3, battery_to_grid: 1, used_solar: 0, - used_grid: 1, + used_grid: 2, used_battery: 0, + used_total: 2, + solar_to_battery: 0, + solar_to_grid: 0, } ); - }); */ - /* Test does not pass, battery is not really correct when solar is not present + }); + it("Charging the battery with no solar sensor.", () => { assert.deepEqual( - computeConsumptionSingle({ + checkConsumptionResult({ from_grid: 5, to_grid: 0, solar: undefined, @@ -255,13 +319,15 @@ describe("Energy Usage Calculation Tests", () => { used_solar: 0, used_grid: 2, used_battery: 0, + used_total: 2, + solar_to_battery: 0, + solar_to_grid: 0, } ); - }); */ - /* Test does not pass + }); it("Discharging battery to grid while also consuming from grid.", () => { assert.deepEqual( - computeConsumptionSingle({ + checkConsumptionResult({ from_grid: 5, to_grid: 4, solar: 0, @@ -274,14 +340,16 @@ describe("Energy Usage Calculation Tests", () => { used_solar: 0, used_grid: 5, used_battery: 0, + used_total: 5, + solar_to_grid: 0, + solar_to_battery: 0, } ); }); - */ it("Grid, solar, and battery", () => { assert.deepEqual( - computeConsumptionSingle({ + checkConsumptionResult({ from_grid: 5, to_grid: 3, solar: 7, @@ -294,12 +362,13 @@ describe("Energy Usage Calculation Tests", () => { used_solar: 1, used_grid: 5, used_battery: 0, + used_total: 6, solar_to_battery: 3, solar_to_grid: 3, } ); assert.deepEqual( - computeConsumptionSingle({ + checkConsumptionResult({ from_grid: 5, to_grid: 3, solar: 7, @@ -308,16 +377,17 @@ describe("Energy Usage Calculation Tests", () => { }), { grid_to_battery: 0, - battery_to_grid: 0, - used_solar: 1, + battery_to_grid: 3, + used_solar: 4, used_grid: 5, - used_battery: 10, + used_battery: 7, + used_total: 16, solar_to_battery: 3, - solar_to_grid: 3, + solar_to_grid: 0, } ); assert.deepEqual( - computeConsumptionSingle({ + checkConsumptionResult({ from_grid: 2, to_grid: 7, solar: 7, @@ -325,17 +395,18 @@ describe("Energy Usage Calculation Tests", () => { from_battery: 1, }), { - grid_to_battery: 1, - battery_to_grid: 0, + grid_to_battery: 0, + battery_to_grid: 1, used_solar: 0, - used_grid: 1, - used_battery: 1, - solar_to_battery: 0, - solar_to_grid: 7, + used_grid: 2, + used_battery: 0, + used_total: 2, + solar_to_battery: 1, + solar_to_grid: 6, } ); assert.deepEqual( - computeConsumptionSingle({ + checkConsumptionResult({ from_grid: 2, to_grid: 7, solar: 9, @@ -344,17 +415,17 @@ describe("Energy Usage Calculation Tests", () => { }), { grid_to_battery: 0, - battery_to_grid: 0, - used_solar: 1, + battery_to_grid: 1, + used_solar: 2, used_grid: 2, - used_battery: 1, + used_battery: 0, + used_total: 4, solar_to_battery: 1, - solar_to_grid: 7, + solar_to_grid: 6, } ); - /* Test does not pass assert.deepEqual( - computeConsumptionSingle({ + checkConsumptionResult({ from_grid: 5, to_grid: 3, solar: 1, @@ -367,8 +438,71 @@ describe("Energy Usage Calculation Tests", () => { used_solar: 0, used_grid: 5, used_battery: 0, + used_total: 5, + solar_to_battery: 0, + solar_to_grid: 1, + } + ); + assert.deepEqual( + checkConsumptionResult({ + from_grid: 6, + to_grid: 0, + solar: 3, + to_battery: 6, + from_battery: 6, + }), + { + grid_to_battery: 3, + battery_to_grid: 0, + used_solar: 0, + used_grid: 3, + used_battery: 6, + solar_to_battery: 3, + solar_to_grid: 0, + used_total: 9, + } + ); + }); + it("Solar -> Battery -> Grid", () => { + assert.deepEqual( + checkConsumptionResult({ + from_grid: 0, + to_grid: 1, + solar: 1, + to_battery: 1, + from_battery: 1, + }), + { + grid_to_battery: 0, + battery_to_grid: 1, + used_solar: 0, + used_grid: 0, + used_battery: 0, + solar_to_battery: 1, + solar_to_grid: 0, + used_total: 0, + } + ); + }); + it("Solar -> Grid && Grid -> Battery", () => { + assert.deepEqual( + checkConsumptionResult({ + from_grid: 1, + to_grid: 1, + solar: 1, + to_battery: 1, + from_battery: 0, + }), + { + grid_to_battery: 1, + battery_to_grid: 0, + used_solar: 0, + used_grid: 0, + used_battery: 0, + solar_to_battery: 0, + solar_to_grid: 1, + used_total: 0, } ); - */ }); });