Compare commits

...

43 Commits

Author SHA1 Message Date
Wendelin
fd4675502a Enhance entity fetching by expanding memoization parameters in HaEntityPicker 2026-01-07 14:13:23 +01:00
Marcin Bauer
f5cd234c4b Refactor: Polish automation dialog UI and component styles (#28831)
* refactor: polish automation dialog UI and component styles

* Update ha-automation-row-targets.ts

- added borders to main automation list chips
2026-01-07 09:27:01 +00:00
karwosts
49bed5e6a6 Standardize all energy period calculations (#28827) 2026-01-07 08:46:54 +02:00
Bram Kragten
b84a51235d Prevent showing error during loading of statistics picker (#28823) 2026-01-06 17:40:53 +01:00
Bram Kragten
602d6a2337 Use target selector to filter references entities (#28822)
* Use target selector to filter references entities

* Update ha-selector-state.ts
2026-01-06 16:16:23 +01:00
Matthias Alphart
6e614cd3f2 Explicitly set ha-wa-dialog content color (#28821) 2026-01-06 16:00:15 +01:00
Paulus Schoutsen
6793edd68b Bluetooth panel to support multi adapter (#28763)
* Support multiple adapters in bluetooth panel

* Move connection allocations up

* Make it tabs

* Add icons

* Revert "Add icons"

This reverts commit e338b6e578.

* Revert "Make it tabs"

This reverts commit d1b19d5c3e.

* Fix scanner matching and no active connection slot support

* Update src/panels/config/integrations/integration-panels/bluetooth/bluetooth-config-dashboard.ts

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-01-06 16:46:43 +02:00
Bram Kragten
ad6e3267c3 Fix translation loading of choose selector (#28817) 2026-01-06 14:24:19 +01:00
Bram Kragten
f941117ca4 Remove iOS focus handling from dialogs (#28818) 2026-01-06 13:45:21 +01:00
Bram Kragten
aef0bf03e3 Use single path for thread icon, add KNX, simplify (#28819)
* Use single path for thread icon, simplify

* Add custom path for KNX
2026-01-06 12:43:05 +00:00
Simon Lamon
f22f6b74db Remove used from energy usage header (#28775)
* Remove used

* Update src/translations/en.json

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-01-06 11:37:27 +00:00
Aidan Timson
913c4ae24e Remove duplicate custom items, remove "no matching ..." when allow-custom-value set (#28801)
* Remove duplicate custom items, allow default from picker

* Memoize

* Memoize

* Memoize func

* Don't show no matching item when custom value is allowed

* Remove no items found label now unused

* Cleanup unused translations

* Restore used value

* Remove no items found label now unused

* Remove redundant comment

* Remove searchFn

* Ensure custom value isnt identical

* Fix duplicated value

* Fix duplicated value

* Use additional items for entity state content

* Fix duplicate values

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2026-01-06 11:16:54 +01:00
Matthias Alphart
4b7b5fa21a Replace unload event handler for custom panels with pagehide (#28781)
* Replace `unload` event handler for custom panels

* Handle restore from bfcache

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-01-06 09:07:52 +00:00
Norbert Rittel
bf6887541b Capitalize counter button labels (#28814)
Capitalized counter button labels
2026-01-06 08:47:51 +02:00
karwosts
26da9f3a37 Fix statistic-graph-card cutoff w/ energy date picker (#28810)
* Fix statistics-graph energy-date mode end-time with 5min statistics

* don't modify date/hour for 5minute graph

* suggestedMax use period instead of days

* go back to string types
2026-01-05 17:28:15 +02:00
Aidan Timson
d48520efdf Add option for any state and show translated label for entity state values (#28803)
* Add option for any state

* Use translated labels for value
2026-01-05 16:55:23 +02:00
Aidan Timson
d462356122 Reapply "Migrate dialog-device-registry-detail to ha-wa-dialog (#27668)" (#28804)
* Reapply "Migrate dialog-device-registry-detail to ha-wa-dialog (#27668)" (#27716)

This reverts commit 5f75fc5bcb.

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-01-05 11:51:12 +00:00
Copilot
9a5cdb0a99 Display template targets with neutral badge instead of "Unknown area" error (#28799)
* Initial plan

* Add template target display with neutral badge

- Import mdiCodeBraces icon and isTemplate function
- Check if target ID is a template before checking if it exists
- Display grey {} icon with "Template" text for templated targets
- Add "template" translation key to target_summary
- Prevents misleading red "Unknown area" badge for template targets

Co-authored-by: piitaya <5878303+piitaya@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: piitaya <5878303+piitaya@users.noreply.github.com>
2026-01-05 12:44:58 +01:00
Paul Bottein
eaf012d5ff Show close button when zwave firmware update is finished (#28805) 2026-01-05 13:42:55 +02:00
Paul Bottein
19934dad72 Remove custom value for unknown icon in icon picker (#28800) 2026-01-05 10:57:09 +00:00
Paul Bottein
6194f73442 Use regular item for bottom padding in combobox (#28798) 2026-01-05 11:40:54 +01:00
Paul Bottein
dbc880fe35 Add warning about running tsc with file arguments (#28797)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 10:08:21 +00:00
karwosts
be4e46a3c6 Fix statistic names w/ energy_date_selection (#28787) 2026-01-05 09:51:49 +02:00
renovate[bot]
2fce89a689 Update dependency globals to v17 (#28789)
* Update dependency globals to v17

* Add global definitions for audioWorklet in ESLint configuration

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-01-05 07:11:10 +00:00
renovate[bot]
81d21b0907 Update formatjs monorepo (#28793)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-05 09:04:20 +02:00
dependabot[bot]
65381b1dc5 Bump relative-ci/agent-action from 3.2.1 to 3.2.2 (#28792)
Bumps [relative-ci/agent-action](https://github.com/relative-ci/agent-action) from 3.2.1 to 3.2.2.
- [Release notes](https://github.com/relative-ci/agent-action/releases)
- [Commits](c45aaa919e...3c68192601)

---
updated-dependencies:
- dependency-name: relative-ci/agent-action
  dependency-version: 3.2.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-05 08:51:48 +02:00
Norbert Rittel
7cbede2f6e A few small spelling fixes in user-facing strings (#28786)
- use correct spelling for "Wi-Fi" trademark
- capitalize "PIN" as abbreviation
- fix spelling of "set up" as verb
- fix sentence-casing
2026-01-04 18:07:40 +01:00
renovate[bot]
0a13dddaea Update dependency @rspack/core to v1.7.0 (#28774)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-04 09:12:12 +00:00
renovate[bot]
662be980e8 Update dependency @rspack/dev-server to v1.1.5 (#28773)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-04 10:03:10 +01:00
renovate[bot]
209abf466d Update dependency @codemirror/view to v6.39.8 (#28759)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-02 19:29:57 +01:00
Simon Lamon
db9a3bd562 Fix matter translations (#28752) 2026-01-02 11:22:45 +01:00
Paulus Schoutsen
36ecaa6610 Add config entry picker for Z-Wave JS panel (#28741) 2026-01-02 11:20:42 +01:00
Simon Lamon
4f46d0f4a3 Make cancel a secondary action in blueprint import (#28754) 2026-01-02 11:18:37 +01:00
Paulus Schoutsen
42ad47649d Verify bluetooth config entries exist before showing entry (#28745) 2026-01-02 11:18:02 +01:00
dependabot[bot]
c62ee6e692 Bump qs from 6.14.0 to 6.14.1 (#28760)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-02 11:16:37 +01:00
Simon Lamon
b38c8d7d5f Revert lit update (#28751) 2026-01-02 11:13:09 +01:00
renovate[bot]
83bcc39d5f Update dependency typescript-eslint to v8.51.0 (#28756) 2026-01-02 09:54:41 +01:00
Paulus Schoutsen
8d317d1e2c Hide dashboard controls in kiosk mode (#28742) 2026-01-01 00:36:49 +01:00
Simon Lamon
9acad2e83c Provide kioskmode in demo (#28739) 2025-12-30 22:45:01 +01:00
ildar170975
9099c5a92c Map card editor: add a basic sub-element editor (#28687)
* add subelement editor

* explicit type convertion

* test

* test

* test

* test

* prettier
2025-12-30 20:18:57 +01:00
Paulus Schoutsen
60c4d60d66 Protocol link updates (#28736)
* Update icons Thread & Insteon

* Remove matter link

* Remove back path from ZHA

* Fix ZHA dashboard config entry
2025-12-30 19:54:48 +01:00
sebcaps
e8a4cde643 Add energy percentage usage on pie chart view. (#28733)
* showPercent

* unnecessary change
2025-12-30 19:54:35 +01:00
renovate[bot]
148eab31b6 Update dependency jsdom to v27.4.0 (#28726)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-29 19:18:29 +01:00
49 changed files with 1281 additions and 937 deletions

View File

@@ -22,11 +22,13 @@ You are an assistant helping with development of the Home Assistant frontend. Th
```bash
yarn lint # ESLint + Prettier + TypeScript + Lit
yarn format # Auto-fix ESLint + Prettier
yarn lint:types # TypeScript compiler
yarn lint:types # TypeScript compiler (run WITHOUT file arguments)
yarn test # Vitest
script/develop # Development server
```
> **WARNING:** Never run `tsc` or `yarn lint:types` with file arguments (e.g., `yarn lint:types src/file.ts`). When `tsc` receives file arguments, it ignores `tsconfig.json` and emits `.js` files into `src/`, polluting the codebase. Always run `yarn lint:types` without arguments. For individual file type checking, rely on IDE diagnostics. If `.js` files are accidentally generated, clean up with `git clean -fd src/`.
### Component Prefixes
- `ha-` - Home Assistant components

View File

@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Send bundle stats and build information to RelativeCI
uses: relative-ci/agent-action@c45aaa919ef85620af54242a241ac17a8fa35983 # v3.2.1
uses: relative-ci/agent-action@3c681926017930047fc03acaa35cd6a44efcbfc3 # v3.2.2
with:
key: ${{ secrets[format('RELATIVE_CI_KEY_{0}_{1}', matrix.bundle, matrix.build)] }}
token: ${{ github.token }}

View File

@@ -187,5 +187,11 @@ export default tseslint.config(
],
"no-use-before-define": "off",
},
},
{
files: ["src/util/recorder-worklet.js"],
languageOptions: {
globals: globals.audioWorklet,
},
}
);

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.7",
"@codemirror/view": "6.39.8",
"@date-fns/tz": "1.4.1",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.1.0",
"@formatjs/intl-displaynames": "7.1.0",
"@formatjs/intl-durationformat": "0.9.0",
"@formatjs/intl-getcanonicallocales": "3.1.0",
"@formatjs/intl-listformat": "8.1.0",
"@formatjs/intl-locale": "5.1.0",
"@formatjs/intl-numberformat": "9.1.0",
"@formatjs/intl-pluralrules": "6.1.0",
"@formatjs/intl-relativetimeformat": "12.1.0",
"@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.7",
"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",
@@ -156,8 +156,8 @@
"@octokit/plugin-retry": "8.0.3",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.4.0",
"@rspack/core": "1.6.8",
"@rspack/dev-server": "1.1.4",
"@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",
"@types/chromecast-caf-sender": "1.0.11",
@@ -199,7 +199,7 @@
"gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "27.3.0",
"jsdom": "27.4.0",
"jszip": "3.10.1",
"lint-staged": "16.2.7",
"lit-analyzer": "2.0.3",
@@ -215,7 +215,7 @@
"terser-webpack-plugin": "5.3.16",
"ts-lit-plugin": "2.0.2",
"typescript": "5.9.3",
"typescript-eslint": "8.50.1",
"typescript-eslint": "8.51.0",
"vite-tsconfig-paths": "6.0.3",
"vitest": "4.0.16",
"webpack-stats-plugin": "1.1.3",
@@ -224,12 +224,12 @@
},
"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",
"globals": "16.5.0",
"globals": "17.0.0",
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"glob@^10.2.2": "^10.5.0"

View File

@@ -18,10 +18,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 rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
@@ -184,18 +181,17 @@ 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
@@ -316,10 +312,7 @@ export class HaEntityNamePicker extends LitElement {
return undefined;
}
private _getFilteredItems = (
searchString?: string,
_section?: string
): PickerComboBoxItem[] => {
private _getFilteredItems = (): PickerComboBoxItem[] => {
const items = this._getItems(this.entityId);
const currentItem =
this._editIndex != null ? this._items[this._editIndex] : undefined;
@@ -336,49 +329,27 @@ export class HaEntityNamePicker extends LitElement {
);
// When editing an existing text item, include it in the base items
if (currentItem?.type === "text" && currentItem.text && !searchString) {
if (currentItem?.type === "text" && currentItem.text) {
filteredItems.push(this._customNameOption(currentItem.text));
}
return filteredItems;
};
private _getAdditionalItems = (
searchString?: string
private _searchFn = (
searchString: string,
filteredItems: PickerComboBoxItem[]
): 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;
// 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
);
// Remove custom name option if search string is present to avoid duplicates
if (searchString && currentId) {
return filteredItems.filter((item) => item.id !== currentId);
}
return filteredItems;
};

View File

@@ -251,11 +251,34 @@ export class HaEntityPicker extends LitElement {
}
);
private _getEntitiesMemoized = memoizeOne(getEntities);
private _getEntitiesMemoized = memoizeOne(
(
_entities: HomeAssistant["entities"],
includeDomains?: string[],
excludeDomains?: string[],
entityFilter?: HaEntityPickerEntityFilterFunc,
includeDeviceClasses?: string[],
includeUnitOfMeasurement?: string[],
includeEntities?: string[],
excludeEntities?: string[],
value?: string
) =>
getEntities(
this.hass,
includeDomains,
excludeDomains,
entityFilter,
includeDeviceClasses,
includeUnitOfMeasurement,
includeEntities,
excludeEntities,
value
)
);
private _getItems = () =>
this._getEntitiesMemoized(
this.hass,
this.hass.entities,
this.includeDomains,
this.excludeDomains,
this.entityFilter,

View File

@@ -19,7 +19,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 HIDDEN_ATTRIBUTES = [
@@ -199,11 +202,7 @@ export class HaStateContentPicker extends LitElement {
.value=${this._getPickerValue()}
.getItems=${this._getFilteredItems}
.getAdditionalItems=${this._getAdditionalItems}
.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"
)}
.searchFn=${this._searchFn}
@value-changed=${this._pickerValueChanged}
>
<div slot="field" class="container">
@@ -328,7 +327,7 @@ export class HaStateContentPicker extends LitElement {
(text: string): PickerComboBoxItem => ({
id: text,
primary: this.hass.localize(
"ui.components.entity.entity-state-content-picker.custom_state"
"ui.components.entity.entity-state-content-picker.custom_attribute"
),
secondary: `"${text}"`,
search_labels: {
@@ -340,10 +339,7 @@ export class HaStateContentPicker extends LitElement {
})
);
private _getFilteredItems = (
searchString?: string,
_section?: string
): PickerComboBoxItem[] => {
private _getFilteredItems = (): PickerComboBoxItem[] => {
const stateObj = this.entityId
? this.hass.states[this.entityId]
: undefined;
@@ -358,11 +354,7 @@ 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) &&
!searchString
) {
if (currentValue && !items.find((item) => item.id === currentValue)) {
filteredItems.push(this._customValueOption(currentValue));
}
@@ -372,33 +364,34 @@ 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);
const existingItem = items.find((item) => item.id === searchString);
// Only return custom value option if it doesn't match an existing item
if (!existingItem) {
// 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) {
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,6 +7,7 @@ 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 {
@@ -108,6 +109,12 @@ 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;
@@ -125,6 +132,7 @@ 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

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

View File

@@ -39,7 +39,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
public getItems!: (
searchString?: string,
section?: string
) => (PickerComboBoxItem | string)[];
) => (PickerComboBoxItem | string)[] | undefined;
@property({ attribute: false, type: Array })
public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];

View File

@@ -124,9 +124,6 @@ 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}
>
@@ -173,20 +170,6 @@ 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

@@ -44,6 +44,7 @@ class HaNavigationList extends LitElement {
>
<ha-svg-icon
.path=${page.iconPath}
.secondaryPath=${page.iconSecondaryPath}
.viewBox=${page.iconViewBox}
></ha-svg-icon>
</div>

View File

@@ -55,6 +55,7 @@ export interface PickerComboBoxItem {
}
export const NO_ITEMS_AVAILABLE_ID = "___no_items_available___";
const PADDING_ID = "___padding___";
const DEFAULT_ROW_RENDERER: RenderItemFunction<PickerComboBoxItem> = (
item
@@ -108,7 +109,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
public getItems!: (
searchString?: string,
section?: string
) => (PickerComboBoxItem | string)[];
) => PickerComboBoxItem[] | undefined;
@property({ attribute: false, type: Array })
public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];
@@ -150,7 +151,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@query("ha-textfield") private _searchFieldElement?: HaTextField;
@state() private _items: (PickerComboBoxItem | string)[] = [];
@state() private _items: PickerComboBoxItem[] = [];
protected get scrollableElement(): HTMLElement | null {
return this._virtualizerElement as HTMLElement | null;
@@ -160,7 +161,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@state() private _valuePinned = true;
private _allItems: (PickerComboBoxItem | string)[] = [];
private _allItems: PickerComboBoxItem[] = [];
private _selectedItemIndex = -1;
@@ -278,8 +279,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
this._sectionTitle = this.sectionTitleFunction({
firstIndex: ev.first,
lastIndex: ev.last,
firstItem: firstItem as PickerComboBoxItem | string,
secondItem: secondItem as PickerComboBoxItem | string,
firstItem: firstItem as PickerComboBoxItem,
secondItem: secondItem as PickerComboBoxItem,
itemsCount: this._virtualizerElement.items.length,
});
}
@@ -294,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) => {
@@ -323,28 +324,28 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
});
}
if (!items.length) {
items.push(NO_ITEMS_AVAILABLE_ID);
if (!items.length && !this.allowCustomValue) {
items.push({ id: NO_ITEMS_AVAILABLE_ID, primary: "" });
}
const additionalItems = this._getAdditionalItems();
items.push(...additionalItems);
if (this.mode === "dialog") {
items.push("padding"); // padding for safe area inset
items.push({ id: PADDING_ID, primary: "" }); // padding for safe area inset
}
return items;
};
private _renderItem = (item: PickerComboBoxItem | string, index: number) => {
private _renderItem = (item: PickerComboBoxItem, index: number) => {
if (!item) {
return nothing;
}
if (item === "padding") {
if (item.id === PADDING_ID) {
return html`<div class="bottom-padding"></div>`;
}
if (item === NO_ITEMS_AVAILABLE_ID) {
if (item.id === NO_ITEMS_AVAILABLE_ID) {
return html`
<div class="combo-box-row">
<ha-combo-box-item type="text" compact>
@@ -419,21 +420,18 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
return;
}
const index = this._fuseIndex(
this._allItems as PickerComboBoxItem[],
this.searchKeys
);
const index = this._fuseIndex(this._allItems, this.searchKeys);
let filteredItems = multiTermSortedSearch<PickerComboBoxItem>(
this._allItems as PickerComboBoxItem[],
this._allItems,
searchString,
this.searchKeys || DEFAULT_SEARCH_KEYS,
(item) => item.id,
index
) as (PickerComboBoxItem | string)[];
);
if (!filteredItems.length) {
filteredItems.push(NO_ITEMS_AVAILABLE_ID);
if (!filteredItems.length && !this.allowCustomValue) {
filteredItems.push({ id: NO_ITEMS_AVAILABLE_ID, primary: "" });
}
const additionalItems = this._getAdditionalItems(searchString);
@@ -442,8 +440,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
if (this.searchFn) {
filteredItems = this.searchFn(
searchString,
filteredItems as PickerComboBoxItem[],
this._allItems as PickerComboBoxItem[]
filteredItems,
this._allItems
);
}
@@ -459,7 +457,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
});
}
this._items = filteredItems as PickerComboBoxItem[];
this._items = filteredItems;
}
this._selectedItemIndex = -1;
@@ -789,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-1) var(--ha-space-2);
padding: var(--ha-space-2) var(--ha-space-3);
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-1) var(--ha-space-2);
padding: var(--ha-space-2) var(--ha-space-3);
font-weight: var(--ha-font-weight-bold);
color: var(--secondary-text-color);
min-height: var(--ha-space-6);

View File

@@ -54,7 +54,8 @@ export class HaChooseSelector extends LitElement {
size="small"
.buttons=${this._toggleButtons(
this.selector.choose.choices,
this.selector.choose.translation_key
this.selector.choose.translation_key,
this.hass.localize
)}
.active=${this._activeChoice}
@value-changed=${this._choiceChanged}
@@ -72,7 +73,11 @@ export class HaChooseSelector extends LitElement {
}
private _toggleButtons = memoizeOne(
(choices: ChooseSelector["choose"]["choices"], translationKey?: string) =>
(
choices: ChooseSelector["choose"]["choices"],
translationKey?: string,
_localize?: HomeAssistant["localize"]
) =>
Object.keys(choices).map((choice) => ({
label:
this.localizeValue && translationKey

View File

@@ -1,12 +1,17 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { StateSelector } from "../../data/selector";
import { extractFromTarget } from "../../data/target";
import memoizeOne from "memoize-one";
import {
resolveEntityIDs,
type StateSelector,
type TargetSelector,
} from "../../data/selector";
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) {
@@ -28,16 +33,33 @@ 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?.filter_target,
this.context?.target_selector
).then((entityIds) => {
this._entityIds = entityIds;
});
@@ -45,6 +67,9 @@ 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
@@ -52,7 +77,7 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
.entityId=${this._entityIds}
.attribute=${this.selector.state?.attribute ||
this.context?.filter_attribute}
.extraOptions=${this.selector.state?.extra_options}
.extraOptions=${extraOptions}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
@@ -69,7 +94,7 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
.entityId=${this._entityIds}
.attribute=${this.selector.state?.attribute ||
this.context?.filter_attribute}
.extraOptions=${this.selector.state?.extra_options}
.extraOptions=${extraOptions}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
@@ -84,7 +109,8 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
private async _resolveEntityIds(
selectorEntityId: string | string[] | undefined,
contextFilterEntity: string | string[] | undefined,
contextFilterTarget: HassServiceTarget | undefined
contextFilterTarget: HassServiceTarget | undefined,
contextTargetSelector: TargetSelector | undefined
): Promise<string | string[] | undefined> {
if (selectorEntityId !== undefined) {
return selectorEntityId;
@@ -93,8 +119,14 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
return contextFilterEntity;
}
if (contextFilterTarget !== undefined) {
const result = await extractFromTarget(this.hass, contextFilterTarget);
return result.referenced_entities;
return resolveEntityIDs(
this.hass,
contextFilterTarget,
this.hass.entities,
this.hass.devices,
this.hass.areas,
contextTargetSelector
);
}
return undefined;
}

View File

@@ -13,7 +13,6 @@ 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";
@@ -185,21 +184,22 @@ export class HaWaDialog extends ScrollableFadeMixin(LitElement) {
await this.updateComplete;
requestAnimationFrame(() => {
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;
}
// 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;
// }
(this.querySelector("[autofocus]") as HTMLElement | null)?.focus();
});
};
@@ -271,6 +271,7 @@ 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

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

@@ -16,6 +16,8 @@ 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 {
@@ -174,7 +176,7 @@ export const fetchStatistics = (
startTime: Date,
endTime?: Date,
statistic_ids?: string[],
period: "5minute" | "hour" | "day" | "week" | "month" = "hour",
period: StatisticPeriod = "hour",
units?: StatisticsUnitConfiguration,
types?: StatisticsTypes
) =>

View File

@@ -929,13 +929,13 @@ export const resolveEntityIDs = (
targetPickerValue: HassServiceTarget,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"]
areas: HomeAssistant["areas"],
targetSelector: TargetSelector = { target: {} }
): 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

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

View File

@@ -282,6 +282,7 @@ export const provideHass = (
dockedSidebar: "auto",
vibrate: true,
debugConnection: false,
kioskMode: false,
suspendWhenHidden: false,
moreInfoEntityId: null as any,
// @ts-ignore

View File

@@ -23,6 +23,7 @@ export interface PageNavigation {
core?: boolean;
advancedOnly?: boolean;
iconPath?: string;
iconSecondaryPath?: string;
iconViewBox?: string;
description?: string;
iconColor?: string;

View File

@@ -50,7 +50,6 @@ 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,6 +2062,7 @@ class DialogAddAutomationElement
.content.column {
flex-direction: column;
gap: var(--ha-space-3);
}
ha-md-list {

View File

@@ -285,6 +285,8 @@ 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

@@ -4,7 +4,6 @@ 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";
@@ -269,8 +268,11 @@ export class HaPlatformCondition extends LitElement {
return undefined;
}
const context = {};
const context: Record<string, any> = {};
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
@@ -378,7 +380,7 @@ export class HaPlatformCondition extends LitElement {
return "";
}
return this.hass.localize(
`component.${computeDomain(this.condition.condition)}.selector.${key}`
`component.${getConditionDomain(this.condition.condition)}.selector.${key}`
);
};

View File

@@ -82,7 +82,6 @@ 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

@@ -1,10 +1,16 @@
import { consume } from "@lit/context";
import { mdiAlert, mdiFormatListBulleted, mdiShape } from "@mdi/js";
import {
mdiAlert,
mdiCodeBraces,
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 {
@@ -167,6 +173,16 @@ 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(
@@ -207,6 +223,8 @@ 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,7 +4,6 @@ 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";
@@ -305,8 +304,11 @@ export class HaPlatformTrigger extends LitElement {
return undefined;
}
const context = {};
const context: Record<string, any> = {};
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
@@ -414,7 +416,7 @@ export class HaPlatformTrigger extends LitElement {
return "";
}
return this.hass.localize(
`component.${computeDomain(this.trigger.trigger)}.selector.${key}`
`component.${getTriggerDomain(this.trigger.trigger)}.selector.${key}`
);
};

View File

@@ -163,7 +163,7 @@ class DialogImportBlueprint extends LitElement {
</div>
<ha-button
appearance="plain"
slot="primaryAction"
slot="secondaryAction"
@click=${this.closeDialog}
.disabled=${this._saving}
>

View File

@@ -1,11 +1,12 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { canShowPage } from "../../../common/config/can_show_page";
import "../../../components/ha-card";
import "../../../components/ha-icon-next";
import "../../../components/ha-navigation-list";
import type { CloudStatus } from "../../../data/cloud";
import { getConfigEntries } from "../../../data/config_entries";
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
import type { HomeAssistant } from "../../../types";
@@ -17,13 +18,29 @@ class HaConfigNavigation extends LitElement {
@property({ attribute: false }) public pages!: PageNavigation[];
@state() private _hasBluetoothConfigEntries = false;
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
getConfigEntries(this.hass, {
domain: "bluetooth",
}).then((bluetoothEntries) => {
this._hasBluetoothConfigEntries = bluetoothEntries.length > 0;
});
}
protected render(): TemplateResult {
const pages = this.pages
.filter((page) =>
page.path === "#external-app-configuration"
? this.hass.auth.external?.config.hasSettingsScreen
: canShowPage(this.hass, page)
)
.filter((page) => {
if (page.path === "#external-app-configuration") {
return this.hass.auth.external?.config.hasSettingsScreen;
}
// Only show Bluetooth page if there are Bluetooth config entries
if (page.component === "bluetooth") {
return this._hasBluetoothConfigEntries;
}
return canShowPage(this.hass, page);
})
.map((page) => ({
...page,
name:

View File

@@ -5,8 +5,9 @@ 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";
@@ -19,6 +20,8 @@ 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;
@@ -42,10 +45,15 @@ 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 });
@@ -57,10 +65,12 @@ class DialogDeviceRegistryDetail extends LitElement {
}
const device = this._params.device;
return html`
<ha-dialog
open
@closed=${this.closeDialog}
.heading=${computeDeviceNameDisplay(device, this.hass)}
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${computeDeviceNameDisplay(device, this.hass)}
prevent-scrim-close
@closed=${this._dialogClosed}
>
<div>
${this._error
@@ -68,6 +78,7 @@ class DialogDeviceRegistryDetail extends LitElement {
: ""}
<div class="form">
<ha-textfield
autofocus
.value=${this._nameByUser}
@input=${this._nameChanged}
.label=${this.hass.localize(
@@ -75,7 +86,6 @@ class DialogDeviceRegistryDetail extends LitElement {
)}
.placeholder=${device.name || ""}
.disabled=${this._submitting}
dialogInitialFocus
></ha-textfield>
<ha-area-picker
.hass=${this.hass}
@@ -131,22 +141,25 @@ class DialogDeviceRegistryDetail extends LitElement {
</div>
</div>
</div>
<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>
<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>
`;
}

View File

@@ -26,7 +26,6 @@ import {
mdiScrewdriver,
mdiScriptText,
mdiShape,
mdiLan,
mdiSofa,
mdiTools,
mdiUpdate,
@@ -107,15 +106,6 @@ export const configSections: Record<string, PageNavigation[]> = {
},
],
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",
@@ -135,7 +125,8 @@ export const configSections: Record<string, PageNavigation[]> = {
{
path: "/knx",
name: "KNX",
iconPath: mdiLan,
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",
iconColor: "#4EAA66",
component: "knx",
translationKey: "knx",
@@ -144,8 +135,7 @@ export const configSections: Record<string, PageNavigation[]> = {
path: "/config/thread",
name: "Thread",
iconPath:
"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",
iconViewBox: "0 0 165 165",
"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",
iconColor: "#ED7744",
component: "thread",
translationKey: "thread",
@@ -161,7 +151,8 @@ export const configSections: Record<string, PageNavigation[]> = {
{
path: "/insteon",
name: "Insteon",
iconPath: mdiLan,
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",
iconColor: "#E4002C",
component: "insteon",
translationKey: "insteon",

View File

@@ -1,21 +1,14 @@
import { mdiCogOutline } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../../components/ha-card";
import "../../../../../components/ha-code-editor";
import "../../../../../components/ha-formfield";
import "../../../../../components/ha-switch";
import "../../../../../components/ha-alert";
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 "../../../../../components/ha-card";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-list";
import "../../../../../components/ha-list-item";
import type {
BluetoothAllocationsData,
BluetoothScannerState,
@@ -23,10 +16,17 @@ import type {
HaScannerType,
} from "../../../../../data/bluetooth";
import {
getValueInPercentage,
roundWithOneDecimal,
} from "../../../../../util/calculate";
import "../../../../../components/ha-metric";
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";
@customElement("bluetooth-config-dashboard")
export class BluetoothConfigDashboard extends LitElement {
@@ -34,18 +34,14 @@ export class BluetoothConfigDashboard extends LitElement {
@property({ type: Boolean }) public narrow = false;
@state() private _configEntries: ConfigEntry[] = [];
@state() private _connectionAllocationData: BluetoothAllocationsData[] = [];
@state() private _connectionAllocationsError?: string;
@state() private _scannerState?: BluetoothScannerState;
@state() private _scannerStates: Record<string, 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;
@@ -55,41 +51,44 @@ 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 || !this._configEntry) {
if (this._unsubConnectionAllocations) {
return;
}
try {
this._unsubConnectionAllocations =
await subscribeBluetoothConnectionAllocations(
this.hass.connection,
(data) => {
this._connectionAllocationData = data;
},
this._configEntry
);
} catch (err: any) {
this._unsubConnectionAllocations = undefined;
this._connectionAllocationsError = err.message;
}
this._unsubConnectionAllocations =
await subscribeBluetoothConnectionAllocations(
this.hass.connection,
(data) => {
this._connectionAllocationData = data;
}
);
}
private async _subscribeBluetoothScannerState(): Promise<void> {
if (this._unsubScannerState || !this._configEntry) {
if (this._unsubScannerState) {
return;
}
this._unsubScannerState = await subscribeBluetoothScannerState(
this.hass.connection,
(scannerState) => {
this._scannerState = scannerState;
},
this._configEntry
this._scannerStates = {
...this._scannerStates,
[scannerState.source]: scannerState,
};
}
);
}
@@ -122,31 +121,19 @@ 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 .narrow=${this.narrow} .hass=${this.hass}>
<hass-subpage
header=${this.hass.localize("ui.panel.config.bluetooth.title")}
.narrow=${this.narrow}
.hass=${this.hass}
>
<div class="content">
<ha-card
.header=${this.hass.localize(
"ui.panel.config.bluetooth.settings_title"
)}
>
<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-list>${this._renderAdaptersList()}</ha-list>
</ha-card>
<ha-card
.header=${this.hass.localize(
@@ -183,7 +170,11 @@ export class BluetoothConfigDashboard extends LitElement {
)}
>
<div class="card-content">
${this._renderConnectionAllocations()}
<p>
${this.hass.localize(
"ui.panel.config.bluetooth.connection_slot_allocations_monitor_description"
)}
</p>
</div>
<div class="card-actions">
<ha-button
@@ -201,13 +192,90 @@ export class BluetoothConfigDashboard extends LitElement {
`;
}
private _getUsedAllocations = (used: number, total: number) =>
roundWithOneDecimal(getValueInPercentage(used, 0, total));
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 _renderScannerMismatchWarning(
name: string,
scannerState: BluetoothScannerState,
scannerType: HaScannerType,
formatMode: (mode: string | null) => string
scannerType: HaScannerType
) {
const instructions: string[] = [];
@@ -238,8 +306,9 @@ export class BluetoothConfigDashboard extends LitElement {
${this.hass.localize(
"ui.panel.config.bluetooth.scanner_mode_mismatch",
{
requested: formatMode(scannerState.requested_mode),
current: formatMode(scannerState.current_mode),
name: name,
requested: this._formatMode(scannerState.requested_mode),
current: this._formatMode(scannerState.current_mode),
}
)}
</div>
@@ -249,127 +318,59 @@ export class BluetoothConfigDashboard extends LitElement {
</ha-alert>`;
}
private _renderScannerState() {
if (!this._configEntry || !this._scannerState) {
return html`<div>
${this.hass.localize(
"ui.panel.config.bluetooth.no_scanner_state_available"
)}
</div>`;
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;
}
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 _renderConnectionAllocations() {
if (this._connectionAllocationsError) {
return html`<ha-alert alert-type="error"
>${this._connectionAllocationsError}</ha-alert
>`;
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;
}
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;
private _formatScannerModeText(
scannerState: BluetoothScannerState | undefined
): string {
if (!scannerState) {
return this.hass.localize(
"ui.panel.config.bluetooth.scanner_state_unknown"
);
}
const configEntries = await getConfigEntries(this.hass, {
domain: "bluetooth",
});
const configEntry = configEntries.find(
(entry) => entry.entry_id === configEntryId
);
showOptionsFlowDialog(this, configEntry!);
return this._formatModeLabel(scannerState.current_mode);
}
private _openOptionFlow(ev: Event) {
const button = ev.currentTarget as HTMLElement & { entry: ConfigEntry };
showOptionsFlowDialog(this, button.entry);
}
static get styles(): CSSResultGroup {
@@ -394,17 +395,9 @@ export class BluetoothConfigDashboard extends LitElement {
display: flex;
justify-content: flex-end;
}
.scanner-state {
margin-bottom: 16px;
}
.state-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
}
.state-value {
font-weight: 500;
ha-list-item {
--mdc-list-item-meta-display: flex;
--mdc-list-item-meta-size: 48px;
}
`,
];

View File

@@ -46,44 +46,54 @@ export class MatterConfigDashboard extends LitElement {
href="/config/thread"
slot="toolbar-icon"
>
Visit Thread Panel</ha-button
${this.hass.localize(
"ui.panel.config.matter.panel.thread_panel"
)}</ha-button
>
`
: ""}
<div class="content">
<ha-card header="Matter">
<ha-alert alert-type="warning"
>Matter is still in the early phase of development, it is not
meant to be used in production. This panel is for development
only.</ha-alert
>${this.hass.localize(
"ui.panel.config.matter.panel.experimental_note"
)}</ha-alert
>
<div class="card-content">
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}
You can add Matter devices by commissing them if they are not
setup yet, or share them from another controller and enter the
share code.
${this.hass.localize("ui.panel.config.matter.panel.add_devices")}
</div>
<div class="card-actions">
${canCommissionMatterExternal(this.hass)
? html`<ha-button
appearance="plain"
@click=${this._startMobileCommissioning}
>Commission device with mobile app</ha-button
>${this.hass.localize(
"ui.panel.config.matter.panel.mobile_app_commisioning"
)}</ha-button
>`
: ""}
<ha-button appearance="plain" @click=${this._commission}
>Commission device</ha-button
>${this.hass.localize(
"ui.panel.config.matter.panel.commission_device"
)}</ha-button
>
<ha-button appearance="plain" @click=${this._acceptSharedDevice}
>Add shared device</ha-button
>${this.hass.localize(
"ui.panel.config.matter.panel.add_shared_device"
)}</ha-button
>
<ha-button appearance="plain" @click=${this._setWifi}
>Set WiFi Credentials</ha-button
>${this.hass.localize(
"ui.panel.config.matter.panel.set_wifi_credentials"
)}</ha-button
>
<ha-button appearance="plain" @click=${this._setThread}
>Set Thread Credentials</ha-button
>${this.hass.localize(
"ui.panel.config.matter.panel.set_thread_credentials"
)}</ha-button
>
</div>
</ha-card>
@@ -114,19 +124,31 @@ export class MatterConfigDashboard extends LitElement {
private async _setWifi(): Promise<void> {
this._error = undefined;
const networkName = await showPromptDialog(this, {
title: "Network name",
inputLabel: "Network name",
title: this.hass.localize(
"ui.panel.config.matter.panel.prompts.network_name.title"
),
inputLabel: this.hass.localize(
"ui.panel.config.matter.panel.prompts.network_name.input_label"
),
inputType: "string",
confirmText: "Continue",
confirmText: this.hass.localize(
"ui.panel.config.matter.panel.prompts.network_name.confirm"
),
});
if (!networkName) {
return;
}
const psk = await showPromptDialog(this, {
title: "Passcode",
inputLabel: "Code",
title: this.hass.localize(
"ui.panel.config.matter.panel.prompts.passcode.title"
),
inputLabel: this.hass.localize(
"ui.panel.config.matter.panel.prompts.passcode.input_label"
),
inputType: "password",
confirmText: "Set Wifi",
confirmText: this.hass.localize(
"ui.panel.config.matter.panel.prompts.passcode.confirm"
),
});
if (!psk) {
return;
@@ -140,10 +162,16 @@ export class MatterConfigDashboard extends LitElement {
private async _commission(): Promise<void> {
const code = await showPromptDialog(this, {
title: "Commission device",
inputLabel: "Code",
title: this.hass.localize(
"ui.panel.config.matter.panel.prompts.commission_device.title"
),
inputLabel: this.hass.localize(
"ui.panel.config.matter.panel.prompts.commission_device.input_label"
),
inputType: "string",
confirmText: "Commission",
confirmText: this.hass.localize(
"ui.panel.config.matter.panel.prompts.commission_device.confirm"
),
});
if (!code) {
return;
@@ -160,10 +188,16 @@ export class MatterConfigDashboard extends LitElement {
private async _acceptSharedDevice(): Promise<void> {
const code = await showPromptDialog(this, {
title: "Add shared device",
inputLabel: "Pin",
title: this.hass.localize(
"ui.panel.config.matter.panel.prompts.add_shared_device.title"
),
inputLabel: this.hass.localize(
"ui.panel.config.matter.panel.prompts.add_shared_device.input_label"
),
inputType: "number",
confirmText: "Accept device",
confirmText: this.hass.localize(
"ui.panel.config.matter.panel.prompts.add_shared_device.confirm"
),
});
if (!code) {
return;
@@ -180,10 +214,16 @@ export class MatterConfigDashboard extends LitElement {
private async _setThread(): Promise<void> {
const code = await showPromptDialog(this, {
title: "Set Thread operation",
inputLabel: "Dataset",
title: this.hass.localize(
"ui.panel.config.matter.panel.prompts.set_thread.title"
),
inputLabel: this.hass.localize(
"ui.panel.config.matter.panel.prompts.set_thread.input_label"
),
inputType: "string",
confirmText: "Set Thread",
confirmText: this.hass.localize(
"ui.panel.config.matter.panel.prompts.set_thread.confirm"
),
});
if (!code) {
return;

View File

@@ -1,5 +1,4 @@
import { customElement, property } from "lit/decorators";
import { navigate } from "../../../../../common/navigate";
import type { RouterOptions } from "../../../../../layouts/hass-router-page";
import { HassRouterPage } from "../../../../../layouts/hass-router-page";
import type { HomeAssistant } from "../../../../../types";
@@ -12,10 +11,6 @@ class ZHAConfigDashboardRouter extends HassRouterPage {
@property({ type: Boolean }) public narrow = false;
private _configEntry = new URLSearchParams(window.location.search).get(
"config_entry"
);
protected routerOptions: RouterOptions = {
defaultPage: "dashboard",
showLoading: true,
@@ -52,7 +47,6 @@ class ZHAConfigDashboardRouter extends HassRouterPage {
el.hass = this.hass;
el.isWide = this.isWide;
el.narrow = this.narrow;
el.configEntryId = this._configEntry;
if (this._currentPage === "group") {
el.groupId = this.routeTail.path.substr(1);
} else if (this._currentPage === "device") {
@@ -60,17 +54,6 @@ class ZHAConfigDashboardRouter extends HassRouterPage {
} else if (this._currentPage === "visualization") {
el.zoomedDeviceIdFromURL = this.routeTail.path.substr(1);
}
const searchParams = new URLSearchParams(window.location.search);
if (this._configEntry && !searchParams.has("config_entry")) {
searchParams.append("config_entry", this._configEntry);
navigate(
`${this.routeTail.prefix}${
this.routeTail.path
}?${searchParams.toString()}`,
{ replace: true }
);
}
}
}

View File

@@ -75,7 +75,7 @@ class ZHAConfigDashboard extends LitElement {
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ attribute: false }) public configEntryId?: string;
@state() private _configEntry?: ConfigEntry;
@state() private _configuration?: ZHAConfiguration;
@@ -95,6 +95,7 @@ class ZHAConfigDashboard extends LitElement {
super.firstUpdated(changedProperties);
if (this.hass) {
this.hass.loadBackendTranslation("config_panel", "zha", false);
this._fetchConfigEntry();
this._fetchConfiguration();
this._fetchSettings();
this._fetchDevicesAndUpdateStatus();
@@ -110,7 +111,6 @@ class ZHAConfigDashboard extends LitElement {
.narrow=${this.narrow}
.route=${this.route}
.tabs=${zhaTabs}
back-path="/config/integrations"
has-fab
>
<div class="container">
@@ -151,28 +151,26 @@ class ZHAConfigDashboard extends LitElement {
</div>
</div>
</div>
${this.configEntryId
? html`<div class="card-actions">
<ha-button
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this.configEntryId}`}
appearance="plain"
size="small"
>
${this.hass.localize(
"ui.panel.config.devices.caption"
)}</ha-button
>
<ha-button
appearance="plain"
size="small"
href=${`/config/entities/dashboard?historyBack=1&config_entry=${this.configEntryId}`}
>
${this.hass.localize(
"ui.panel.config.entities.caption"
)}</ha-button
>
</div>`
: ""}
<div class="card-actions">
<ha-button
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
appearance="plain"
size="small"
>
${this.hass.localize(
"ui.panel.config.devices.caption"
)}</ha-button
>
<ha-button
appearance="plain"
size="small"
href=${`/config/entities/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
>
${this.hass.localize(
"ui.panel.config.entities.caption"
)}</ha-button
>
</div>
</ha-card>
<ha-card
class="network-settings"
@@ -321,6 +319,15 @@ class ZHAConfigDashboard extends LitElement {
`;
}
private async _fetchConfigEntry(): Promise<void> {
const configEntries = await getConfigEntries(this.hass, {
domain: "zha",
});
if (configEntries.length) {
this._configEntry = configEntries[0];
}
}
private async _fetchConfiguration(): Promise<void> {
this._configuration = await fetchZHAConfiguration(this.hass!);
}
@@ -399,20 +406,11 @@ class ZHAConfigDashboard extends LitElement {
fileDownload(backupJSON, `${basename}.json`);
}
private async _openOptionFlow() {
if (!this.configEntryId) {
private _openOptionFlow() {
if (!this._configEntry) {
return;
}
const configEntries: ConfigEntry[] = await getConfigEntries(this.hass, {
domain: "zha",
});
const configEntry = configEntries.find(
(entry) => entry.entry_id === this.configEntryId
);
showOptionsFlowDialog(this, configEntry!);
showOptionsFlowDialog(this, this._configEntry);
}
private _dataChanged(ev) {

View File

@@ -302,10 +302,11 @@ 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>`
${this.hass.localize(
`ui.panel.config.zwave_js.update_firmware.finished_status.done${localizationKeySuffix}`
)}
</p>
${closeButton}`
: html`<p>
${this.hass.localize(
"ui.panel.config.zwave_js.update_firmware.finished_status.try_again"

View File

@@ -0,0 +1,136 @@
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-list";
import "../../../../../components/ha-list-item";
import "../../../../../layouts/hass-loading-screen";
import "../../../../../layouts/hass-subpage";
import type { ConfigEntry } from "../../../../../data/config_entries";
import { getConfigEntries } from "../../../../../data/config_entries";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import { navigate } from "../../../../../common/navigate";
import { caseInsensitiveStringCompare } from "../../../../../common/string/compare";
@customElement("zwave_js-config-entry-picker")
class ZWaveJSConfigEntryPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@state() private _configEntries?: ConfigEntry[];
protected async firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
await this._fetchConfigEntries();
}
protected render() {
if (!this._configEntries) {
return html`<hass-loading-screen></hass-loading-screen>`;
}
if (this._configEntries.length === 0) {
return html`
<hass-subpage header="Z-Wave" .narrow=${this.narrow} .hass=${this.hass}>
<div class="content">
<ha-card>
<div class="card-content">
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.picker.no_entries"
)}
</p>
</div>
</ha-card>
</div>
</hass-subpage>
`;
}
return html`
<hass-subpage header="Z-Wave" .narrow=${this.narrow} .hass=${this.hass}>
<div class="content">
<ha-card
.header=${this.hass.localize(
"ui.panel.config.zwave_js.picker.title"
)}
>
<ha-list>
${this._configEntries.map(
(entry) => html`
<a
href="/config/zwave_js/dashboard?config_entry=${entry.entry_id}"
>
<ha-list-item hasMeta>
<span>${entry.title}</span>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a>
`
)}
</ha-list>
</ha-card>
</div>
</hass-subpage>
`;
}
private async _fetchConfigEntries() {
const entries = await getConfigEntries(this.hass, {
domain: "zwave_js",
});
this._configEntries = entries.sort((a, b) =>
caseInsensitiveStringCompare(a.title, b.title)
);
if (this._configEntries.length === 1) {
navigate(
`/config/zwave_js/dashboard?config_entry=${this._configEntries[0].entry_id}`,
{ replace: true }
);
}
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
.content {
padding: 24px;
display: flex;
justify-content: center;
}
ha-card {
max-width: 600px;
width: 100%;
}
.card-header {
font-size: 20px;
font-weight: 500;
padding: 16px;
padding-bottom: 0;
}
a {
text-decoration: none;
color: inherit;
}
ha-list {
--md-list-item-leading-space: var(--ha-space-4);
--md-list-item-trailing-space: var(--ha-space-4);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"zwave_js-config-entry-picker": ZWaveJSConfigEntryPicker;
}
}

View File

@@ -3,9 +3,7 @@ import { customElement, property } from "lit/decorators";
import type { RouterOptions } from "../../../../../layouts/hass-router-page";
import { HassRouterPage } from "../../../../../layouts/hass-router-page";
import type { HomeAssistant } from "../../../../../types";
import { navigate } from "../../../../../common/navigate";
import type { PageNavigation } from "../../../../../layouts/hass-tabs-subpage";
import { getConfigEntries } from "../../../../../data/config_entries";
export const configTabs: PageNavigation[] = [
{
@@ -33,14 +31,36 @@ class ZWaveJSConfigRouter extends HassRouterPage {
@property({ type: Boolean }) public narrow = false;
private _configEntry = new URLSearchParams(window.location.search).get(
"config_entry"
);
private _configEntry: string | null = null;
protected routerOptions: RouterOptions = {
defaultPage: "dashboard",
defaultPage: "picker",
showLoading: true,
// Make sure that we have a config entry in the URL before rendering other pages
beforeRender: (page) => {
const searchParams = new URLSearchParams(window.location.search);
if (searchParams.has("config_entry")) {
this._configEntry = searchParams.get("config_entry");
} else if (page === "picker") {
this._configEntry = null;
return undefined;
}
if ((!page || page === "picker") && this._configEntry) {
return "dashboard";
}
if ((!page || page !== "picker") && !this._configEntry) {
return "picker";
}
return undefined;
},
routes: {
picker: {
tag: "zwave_js-config-entry-picker",
load: () => import("./zwave_js-config-entry-picker"),
},
dashboard: {
tag: "zwave_js-config-dashboard",
load: () => import("./zwave_js-config-dashboard"),
@@ -70,7 +90,6 @@ class ZWaveJSConfigRouter extends HassRouterPage {
load: () => import("./zwave_js-network-visualization"),
},
},
initialLoad: () => this._fetchConfigEntries(),
};
protected updatePageEl(el): void {
@@ -79,29 +98,6 @@ class ZWaveJSConfigRouter extends HassRouterPage {
el.isWide = this.isWide;
el.narrow = this.narrow;
el.configEntryId = this._configEntry;
const searchParams = new URLSearchParams(window.location.search);
if (this._configEntry && !searchParams.has("config_entry")) {
searchParams.append("config_entry", this._configEntry);
navigate(
`${this.routeTail.prefix}${
this.routeTail.path
}?${searchParams.toString()}`,
{ replace: true }
);
}
}
private async _fetchConfigEntries() {
if (this._configEntry) {
return;
}
const entries = await getConfigEntries(this.hass, {
domain: "zwave_js",
});
if (entries.length) {
this._configEntry = entries[0].entry_id;
}
}
}

View File

@@ -30,38 +30,33 @@ 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(
dayDifference: number,
end: Date,
detailedDailyData = false
): number {
export function getSuggestedMax(period: StatisticPeriod, end: Date): 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 (dayDifference > 2 && suggestedMax.getHours() === 0) {
if (suggestedMax.getHours() === 0) {
suggestedMax = subHours(suggestedMax, 1);
}
if (!detailedDailyData) {
suggestedMax.setMinutes(0, 0, 0);
}
if (dayDifference > 35) {
suggestedMax.setDate(1);
}
if (dayDifference > 2) {
suggestedMax.setHours(0);
suggestedMax.setHours(0);
if (period === "day" || period === "week") {
return suggestedMax.getTime();
}
// 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;
@@ -96,7 +91,10 @@ export function getCommonOptions(
xAxis: {
type: "time",
min: start,
max: getSuggestedMax(dayDifference, end, detailedDailyData),
max: getSuggestedMax(
getSuggestedPeriod(start, end, detailedDailyData),
end
),
},
yAxis: {
type: "value",

View File

@@ -186,7 +186,7 @@ export class HuiEnergyDevicesGraphCard
params.value[0] as number,
this.hass.locale,
params.value < 0.1 ? { maximumFractionDigits: 3 } : undefined
)} kWh`;
)} kWh ${params.percent ? `(${params.percent} %)` : ""}`;
return `${title}${params.marker} ${params.seriesName}: <div style="direction:ltr; display: inline;">${value}</div>`;
}

View File

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

View File

@@ -8,7 +8,10 @@ import { createSearchParam } from "../../../common/url/search-params";
import "../../../components/ha-card";
import "../../../components/ha-icon-next";
import "../../../components/ha-tooltip";
import { getEnergyDataCollection } from "../../../data/energy";
import {
getEnergyDataCollection,
getSuggestedPeriod,
} from "../../../data/energy";
import type {
Statistics,
StatisticsMetaData,
@@ -26,10 +29,7 @@ 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,
getSuggestedPeriod,
} from "./energy/common/energy-chart-options";
import { getSuggestedMax } from "./energy/common/energy-chart-options";
import type { StatisticsGraphCardConfig } from "./types";
export const DEFAULT_DAYS_TO_SHOW = 30;
@@ -196,6 +196,10 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (changedProps.has("hass") || changedProps.has("_config")) {
this._computeNames();
}
if (!this._config || !changedProps.has("_config")) {
return;
}
@@ -225,10 +229,6 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
}
}
if (changedProps.has("hass")) {
this._computeNames();
}
if (
changedProps.has("_config") &&
oldConfig?.entities !== this._config.entities
@@ -268,9 +268,7 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
return (
this._config?.period ??
(this._energyStart && this._energyEnd
? getSuggestedPeriod(
differenceInDays(this._energyEnd, this._energyStart)
)
? getSuggestedPeriod(this._energyStart, this._energyEnd)
: undefined)
);
}
@@ -334,10 +332,7 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
.maxYAxis=${this._config.max_y_axis}
.startTime=${this._energyStart}
.endTime=${this._energyEnd && this._energyStart
? getSuggestedMax(
differenceInDays(this._energyEnd, this._energyStart),
this._energyEnd
)
? getSuggestedMax(this._period!, this._energyEnd)
: undefined}
.fitYData=${this._config.fit_y_data || false}
.hideLegend=${this._config.hide_legend || false}

View File

@@ -28,11 +28,16 @@ import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import { DEFAULT_HOURS_TO_SHOW, DEFAULT_ZOOM } from "../../cards/hui-map-card";
import type { MapCardConfig, MapEntityConfig } from "../../cards/types";
import "../../components/hui-entity-editor";
import type { EntityConfig } from "../../entity-rows/types";
import "../hui-sub-element-editor";
import type {
EditDetailElementEvent,
SubElementEditorConfig,
EntitiesEditorEvent,
} from "../types";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import type { LovelaceCardEditor } from "../../types";
import { processEditorEntities } from "../process-editor-entities";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import type { EntitiesEditorEvent } from "../types";
import { configElementStyle } from "./config-elements-style";
export const mapEntitiesConfigStruct = union([
@@ -76,13 +81,20 @@ const cardConfigStruct = assign(
const themeModes = ["auto", "light", "dark"] as const;
const SUB_SCHEMA = [
{ name: "entity", selector: { entity: {} }, required: true },
{ name: "name", selector: { text: {} } },
] as const;
@customElement("hui-map-card-editor")
export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _config?: MapCardConfig;
@state() private _configEntities?: EntityConfig[];
@state() private _subElementEditorConfig?: SubElementEditorConfig;
@state() private _configEntities?: MapEntityConfig[];
@state() private _possibleGeoSources?: { value: string; label?: string }[];
@@ -150,7 +162,7 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
this._config = config;
this._configEntities = config.entities
? processEditorEntities(config.entities)
? (processEditorEntities(config.entities) as MapEntityConfig[])
: [];
}
@@ -167,6 +179,19 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
return nothing;
}
if (this._subElementEditorConfig) {
return html`
<hui-sub-element-editor
.hass=${this.hass}
.config=${this._subElementEditorConfig}
.schema=${SUB_SCHEMA}
@go-back=${this._goBack}
@config-changed=${this._handleSubEntityChanged}
>
</hui-sub-element-editor>
`;
}
return html`
<ha-form
.hass=${this.hass}
@@ -180,7 +205,9 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
.hass=${this.hass}
.entities=${this._configEntities}
.entityFilter=${hasLocation}
can-edit
@entities-changed=${this._entitiesValueChanged}
@edit-detail-element=${this._editDetailElement}
></hui-entity-editor>
<h3>
@@ -203,6 +230,36 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
`;
}
private _goBack(): void {
this._subElementEditorConfig = undefined;
}
private _editDetailElement(ev: HASSDomEvent<EditDetailElementEvent>): void {
this._subElementEditorConfig = ev.detail.subElementConfig;
}
private _handleSubEntityChanged(ev: CustomEvent): void {
ev.stopPropagation();
const index = this._subElementEditorConfig!.index!;
const newEntities = this._configEntities!.concat();
const newConfig = ev.detail.config as MapEntityConfig;
this._subElementEditorConfig = {
...this._subElementEditorConfig!,
elementConfig: newConfig,
};
newEntities[index] = newConfig;
let config = this._config!;
config = { ...config, entities: newEntities };
this._config = config;
this._configEntities = processEditorEntities(
config.entities as any[]
) as MapEntityConfig[];
fireEvent(this, "config-changed", { config });
}
private _selectSchema = memoizeOne(
(options, localize: LocalizeFunc): SelectSelector => ({
select: {
@@ -229,7 +286,9 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
if (ev.detail && ev.detail.entities) {
this._config = { ...this._config!, entities: ev.detail.entities };
this._configEntities = processEditorEntities(this._config.entities || []);
this._configEntities = processEditorEntities(
this._config.entities || []
) as MapEntityConfig[];
fireEvent(this, "config-changed", { config: this._config! });
}
}

View File

@@ -263,7 +263,8 @@ class HUIRoot extends LitElement {
{
icon: mdiPlus,
key: "ui.panel.lovelace.menu.add",
visible: !this._editMode && this.hass.user?.is_admin,
visible:
!this._editMode && this.hass.user?.is_admin && !this.hass.kioskMode,
overflow: this.narrow,
subItems: [
{
@@ -301,7 +302,7 @@ class HUIRoot extends LitElement {
key: "ui.panel.lovelace.menu.search_entities",
buttonAction: this._showQuickBar,
overflowAction: this._handleShowQuickBar,
visible: !this._editMode,
visible: !this._editMode && !this.hass.kioskMode,
overflow: this.narrow,
suffix:
this.hass.enableShortcuts && !isMobileClient ? "(E)" : undefined,
@@ -349,7 +350,8 @@ class HUIRoot extends LitElement {
visible:
!this._editMode &&
this.hass!.user?.is_admin &&
!this.hass!.config.recovery_mode,
!this.hass!.config.recovery_mode &&
!this.hass.kioskMode,
overflow: true,
overflow_can_promote: true,
},

View File

@@ -137,9 +137,9 @@
},
"counter": {
"actions": {
"increment": "increment",
"decrement": "decrement",
"reset": "reset"
"increment": "Increment",
"decrement": "Decrement",
"reset": "Reset"
}
},
"cover": {
@@ -672,8 +672,8 @@
"device_missing": "No related device"
},
"add": "Add",
"custom_name": "Custom name",
"no_match": "No entities found"
"search": "Search or enter custom name",
"custom_name": "Custom name"
},
"entity-attribute-picker": {
"attribute": "Attribute",
@@ -685,7 +685,7 @@
},
"entity-state-content-picker": {
"add": "Add",
"custom_state": "Custom state"
"custom_attribute": "Custom attribute"
}
},
"target-picker": {
@@ -772,9 +772,6 @@
"no_match": "No languages found for {term}",
"no_languages": "No languages available"
},
"icon-picker": {
"no_match": "No matching icons found"
},
"tts-picker": {
"tts": "Text-to-speech",
"none": "None"
@@ -4090,7 +4087,7 @@
"new_automation_setup_failed_title": "New {type} setup timed out",
"new_automation_setup_failed_text": "Your new {type} was saved, but waiting for it to set up has timed out. This could be due to errors parsing your configuration.yaml, please check the configuration in developer tools. Your {type} will not be visible until this is corrected and {types} are reloaded. Changes to area, category, or labels were not saved and must be reapplied.",
"new_automation_setup_keep_waiting": "You may continue to wait for a response from the server, in case it is just taking an unusually long time to process this {type}.",
"new_automation_setup_timedout_success": "The server has responded and this has now setup successfully. You may now close this dialog.",
"new_automation_setup_timedout_success": "The server has responded and this has now set up successfully. You may now close this dialog.",
"item_pasted": "{item} pasted",
"ctrl": "Ctrl",
"del": "Del",
@@ -4119,7 +4116,8 @@
"targets": "{count} {count, plural,\n one {target}\n other {targets}\n}",
"invalid": "Invalid target",
"all_entities": "All entities",
"none_entities": "No entities"
"none_entities": "No entities",
"template": "Template"
},
"triggers": {
"name": "Triggers",
@@ -6013,26 +6011,31 @@
},
"bluetooth": {
"title": "Bluetooth",
"settings_title": "Bluetooth settings",
"settings_title": "Bluetooth adapters",
"option_flow": "Configure Bluetooth options",
"advertisement_monitor": "Advertisement monitor",
"advertisement_monitor_details": "The advertisement monitor listens for Bluetooth advertisements and displays the data in a structured format.",
"connection_slot_allocations_monitor": "Connection slot allocations monitor",
"connection_slot_allocations_monitor_details": "The connection slot allocations monitor displays the (GATT) connection slot allocations for the adapter. This adapter supports up to {slots} simultaneous connections. Each remote Bluetooth device that requires an active connection will use one connection slot while the Bluetooth device is connecting or connected.",
"connection_slot_allocations_monitor_description": "The connection slot allocations monitor displays the (GATT) connection slot allocations for each adapter. Each remote Bluetooth device that requires an active connection will use one connection slot while the Bluetooth device is connecting or connected.",
"connection_monitor": "Connection monitor",
"visualization": "Visualization",
"used_connection_slot_allocations": "Used connection slot allocations",
"no_connections": "No active connections",
"active_connections": "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.",
"no_connection_slots": "No connection slots",
"no_scanner_state_available": "No scanner state available",
"scanner_state_unknown": "State unknown",
"current_scanning_mode": "Current scanning mode",
"requested_scanning_mode": "Requested scanning mode",
"scanning_mode_none": "none",
"scanning_mode_active": "active",
"scanning_mode_passive": "passive",
"scanner_mode_mismatch": "Scanner requested {requested} mode but is operating in {current} mode. The scanner is in a bad state and needs to be power cycled.",
"scanning_mode_active_label": "Active scanning",
"scanning_mode_passive_label": "Passive scanning",
"scanning_mode_none_label": "No scanning",
"scanner_mode_mismatch": "{name} requested {requested} mode but is operating in {current} mode. The scanner is in a bad state and needs to be power cycled.",
"scanner_mode_mismatch_remote": "For proxies: reboot the device",
"scanner_mode_mismatch_usb": "For USB adapters: unplug and plug back in",
"scanner_mode_mismatch_uart": "For UART/onboard adapters: power down the system completely and power it back up",
@@ -6253,7 +6256,7 @@
"new_channel": "New channel",
"change_channel": "Change channel",
"migration_warning": "Zigbee channel migration is an experimental feature and relies on devices on your network to support it. Device support for this feature varies and only a portion of your network may end up migrating! It may take up to an hour for changes to propagate to all devices.",
"description": "Change your Zigbee channel only after you have eliminated all other sources of 2.4GHz interference by using a USB extension cable and moving your coordinator away from USB 3.0 devices and ports, SSDs, 2.4GHz WiFi networks on the same channel, motherboards, and so on.",
"description": "Change your Zigbee channel only after you have eliminated all other sources of 2.4GHz interference by using a USB extension cable and moving your coordinator away from USB 3.0 devices and ports, SSDs, 2.4GHz Wi-Fi networks on the same channel, motherboards, and so on.",
"smart_explanation": "It is recommended to use the \"Smart\" option once your environment is optimized as opposed to manually choosing a channel, as it picks the best channel for you after scanning all Zigbee channels. This does not configure ZHA to automatically change channels in the future, it only changes the channel a single time.",
"channel_has_been_changed": "Network channel has been changed",
"devices_will_rejoin": "Devices will re-join the network over time. This may take a few minutes.",
@@ -6827,9 +6830,50 @@
}
}
}
},
"picker": {
"title": "Select Z-Wave network",
"no_entries": "No Z-Wave networks configured. Set up the Z-Wave JS integration first."
}
},
"matter": {
"panel": {
"thread_panel": "Visit Thread Panel",
"experimental_note": "Matter is still in the early phase of development, it is not meant to be used in production. This panel is for development only.",
"add_devices": "You can add Matter devices by commissioning them if they are not set up yet, or share them from another controller and enter the sharing code.",
"mobile_app_commisioning": "Commission device with mobile app",
"commission_device": "Commission device",
"add_shared_device": "Add shared device",
"set_wifi_credentials": "Set Wi-Fi credentials",
"set_thread_credentials": "Set Thread credentials",
"prompts": {
"network_name": {
"title": "Network name",
"input_label": "Network name",
"confirm": "Continue"
},
"passcode": {
"title": "Passcode",
"input_label": "Code",
"confirm": "Set Wi-Fi"
},
"commission_device": {
"title": "Commission device",
"input_label": "Code",
"confirm": "Commission"
},
"add_shared_device": {
"title": "Add shared device",
"input_label": "PIN",
"confirm": "Accept device"
},
"set_thread": {
"title": "Set Thread operation",
"input_label": "Dataset",
"confirm": "Set Thread"
}
}
},
"network_type": {
"thread": "Thread",
"wifi": "Wi-Fi",
@@ -7304,7 +7348,7 @@
"energy_usage_graph": {
"total_consumed": "Total consumed {num} kWh",
"total_returned": "Total returned {num} kWh",
"total_usage": "{num} kWh used",
"total_usage": "+{num} kWh",
"combined_from_grid": "Combined from grid",
"consumed_solar": "Consumed solar",
"consumed_battery": "Consumed battery"

631
yarn.lock

File diff suppressed because it is too large Load Diff