diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000000..d52ff00f5e --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,592 @@ +# GitHub Copilot & Claude Code Instructions + +You are an assistant helping with development of the Home Assistant frontend. The frontend is built using Lit-based Web Components and TypeScript, providing a responsive and performant interface for home automation control. + +## Table of Contents + +- [Quick Reference](#quick-reference) +- [Core Architecture](#core-architecture) +- [Development Standards](#development-standards) +- [Component Library](#component-library) +- [Common Patterns](#common-patterns) +- [Text and Copy Guidelines](#text-and-copy-guidelines) +- [Development Workflow](#development-workflow) +- [Review Guidelines](#review-guidelines) + +## Quick Reference + +### Essential Commands + +```bash +yarn lint # ESLint + Prettier + TypeScript + Lit +yarn format # Auto-fix ESLint + Prettier +yarn lint:types # TypeScript compiler +yarn test # Vitest +script/develop # Development server +``` + +### Component Prefixes + +- `ha-` - Home Assistant components +- `hui-` - Lovelace UI components +- `dialog-` - Dialog components + +### Import Patterns + +```typescript +import type { HomeAssistant } from "../types"; +import { fireEvent } from "../common/dom/fire_event"; +import { showAlertDialog } from "../dialogs/generic/show-alert-dialog"; +``` + +## Core Architecture + +The Home Assistant frontend is a modern web application that: + +- Uses Web Components (custom elements) built with Lit framework +- Is written entirely in TypeScript with strict type checking +- Communicates with the backend via WebSocket API +- Provides comprehensive theming and internationalization + +## Development Standards + +### Code Quality Requirements + +**Linting and Formatting (Enforced by Tools)** + +- ESLint config extends Airbnb, TypeScript strict, Lit, Web Components, Accessibility +- Prettier with ES5 trailing commas enforced +- No console statements (`no-console: "error"`) - use proper logging +- Import organization: No unused imports, consistent type imports + +**Naming Conventions** + +- PascalCase for types and classes +- camelCase for variables, methods +- Private methods require leading underscore +- Public methods forbid leading underscore + +### TypeScript Usage + +- **Always use strict TypeScript**: Enable all strict flags, avoid `any` types +- **Proper type imports**: Use `import type` for type-only imports +- **Define interfaces**: Create proper interfaces for data structures +- **Type component properties**: All Lit properties must be properly typed +- **No unused variables**: Prefix with `_` if intentionally unused +- **Consistent imports**: Use `@typescript-eslint/consistent-type-imports` + +```typescript +// Good +import type { HomeAssistant } from "../types"; + +interface EntityConfig { + entity: string; + name?: string; +} + +@property({ type: Object }) +hass!: HomeAssistant; + +// Bad +@property() +hass: any; +``` + +### Web Components with Lit + +- **Use Lit 3.x patterns**: Follow modern Lit practices +- **Extend appropriate base classes**: Use `LitElement`, `SubscribeMixin`, or other mixins as needed +- **Define custom element names**: Use `ha-` prefix for components + +```typescript +@customElement("ha-my-component") +export class HaMyComponent extends LitElement { + @property({ attribute: false }) + hass!: HomeAssistant; + + @state() + private _config?: MyComponentConfig; + + static get styles() { + return css` + :host { + display: block; + } + `; + } + + render() { + return html`
Content
`; + } +} +``` + +### Component Guidelines + +- **Use composition**: Prefer composition over inheritance +- **Lazy load panels**: Heavy panels should be dynamically imported +- **Optimize renders**: Use `@state()` for internal state, `@property()` for public API +- **Handle loading states**: Always show appropriate loading indicators +- **Support themes**: Use CSS custom properties from theme + +### Data Management + +- **Use WebSocket API**: All backend communication via home-assistant-js-websocket +- **Cache appropriately**: Use collections and caching for frequently accessed data +- **Handle errors gracefully**: All API calls should have error handling +- **Update real-time**: Subscribe to state changes for live updates + +```typescript +// Good +try { + const result = await fetchEntityRegistry(this.hass.connection); + this._processResult(result); +} catch (err) { + showAlertDialog(this, { + text: `Failed to load: ${err.message}`, + }); +} +``` + +### Styling Guidelines + +- **Use CSS custom properties**: Leverage the theme system +- **Mobile-first responsive**: Design for mobile, enhance for desktop +- **Follow Material Design**: Use Material Web Components where appropriate +- **Support RTL**: Ensure all layouts work in RTL languages + +```typescript +static get styles() { + return css` + :host { + --spacing: 16px; + padding: var(--spacing); + color: var(--primary-text-color); + background-color: var(--card-background-color); + } + + @media (max-width: 600px) { + :host { + --spacing: 8px; + } + } + `; +} +``` + +### Performance Best Practices + +- **Code split**: Split code at the panel/dialog level +- **Lazy load**: Use dynamic imports for heavy components +- **Optimize bundle**: Keep initial bundle size minimal +- **Use virtual scrolling**: For long lists, implement virtual scrolling +- **Memoize computations**: Cache expensive calculations + +### Testing Requirements + +- **Write tests**: Add tests for data processing and utilities +- **Test with Vitest**: Use the established test framework +- **Mock appropriately**: Mock WebSocket connections and API calls +- **Test accessibility**: Ensure components are accessible + +## Component Library + +### Dialog Components + +**Available Dialog Types:** + +- `ha-md-dialog` - Preferred for new code (Material Design 3) +- `ha-dialog` - Legacy component still widely used + +**Opening Dialogs (Fire Event Pattern - Recommended):** + +```typescript +fireEvent(this, "show-dialog", { + dialogTag: "dialog-example", + dialogImport: () => import("./dialog-example"), + dialogParams: { title: "Example", data: someData }, +}); +``` + +**Dialog Implementation Requirements:** + +- Implement `HassDialog` interface +- Use `createCloseHeading()` for standard headers +- Import `haStyleDialog` for consistent styling +- Return `nothing` when no params (loading state) +- Fire `dialog-closed` event when closing +- Add `dialogInitialFocus` for accessibility + +```` + +### Form Component (ha-form) +- Schema-driven using `HaFormSchema[]` +- Supports entity, device, area, target, number, boolean, time, action, text, object, select, icon, media, location selectors +- Built-in validation with error display +- Use `dialogInitialFocus` in dialogs +- Use `computeLabel`, `computeError`, `computeHelper` for translations + +```typescript + this.hass.localize(`ui.panel.${schema.name}`)} + @value-changed=${this._valueChanged} +> +```` + +### Alert Component (ha-alert) + +- Types: `error`, `warning`, `info`, `success` +- Properties: `title`, `alert-type`, `dismissable`, `icon`, `action`, `rtl` +- Content announced by screen readers when dynamically displayed + +```html +Error message +Description +Success message +``` + +## Common Patterns + +### Creating a Panel + +```typescript +@customElement("ha-panel-myfeature") +export class HaPanelMyFeature extends SubscribeMixin(LitElement) { + @property({ attribute: false }) + hass!: HomeAssistant; + + @property({ type: Boolean, reflect: true }) + narrow!: boolean; + + @property() + route!: Route; + + hassSubscribe() { + return [ + subscribeEntityRegistry(this.hass.connection, (entities) => { + this._entities = entities; + }), + ]; + } +} +``` + +### Creating a Dialog + +```typescript +@customElement("dialog-my-feature") +export class DialogMyFeature + extends LitElement + implements HassDialog +{ + @property({ attribute: false }) + hass!: HomeAssistant; + + @state() + private _params?: MyDialogParams; + + public async showDialog(params: MyDialogParams): Promise { + this._params = params; + } + + public closeDialog(): void { + this._params = undefined; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + protected render() { + if (!this._params) { + return nothing; + } + + return html` + + + + ${this.hass.localize("ui.common.cancel")} + + + ${this.hass.localize("ui.common.save")} + + + `; + } + + static styles = [haStyleDialog, css``]; +} +``` + +### Dialog Design Guidelines + +- Max width: 560px (Alert/confirmation: 320px fixed width) +- Close X-icon on top left (all screen sizes) +- Submit button grouped with cancel at bottom right +- Keep button labels short: "Save", "Delete", "Enable" +- Destructive actions use red warning button +- Always use a title (best practice) +- Strive for minimalism + +#### Creating a Lovelace Card + +**Purpose**: Cards allow users to tell different stories about their house (based on gallery) + +```typescript +@customElement("hui-my-card") +export class HuiMyCard extends LitElement implements LovelaceCard { + @property({ attribute: false }) + hass!: HomeAssistant; + + @state() + private _config?: MyCardConfig; + + public setConfig(config: MyCardConfig): void { + if (!config.entity) { + throw new Error("Entity required"); + } + this._config = config; + } + + public getCardSize(): number { + return 3; // Height in grid units + } + + // Optional: Editor for card configuration + public static getConfigElement(): LovelaceCardEditor { + return document.createElement("hui-my-card-editor"); + } + + // Optional: Stub config for card picker + public static getStubConfig(): object { + return { entity: "" }; + } +} +``` + +**Card Guidelines:** + +- Cards are highly customizable for different households +- Implement `LovelaceCard` interface with `setConfig()` and `getCardSize()` +- Use proper error handling in `setConfig()` +- Consider all possible states (loading, error, unavailable) +- Support different entity types and states +- Follow responsive design principles +- Add configuration editor when needed + +### Internationalization + +- **Use localize**: Always use the localization system +- **Add translation keys**: Add keys to src/translations/en.json +- **Support placeholders**: Use proper placeholder syntax + +```typescript +this.hass.localize("ui.panel.config.updates.update_available", { + count: 5, +}); +``` + +### Accessibility + +- **ARIA labels**: Add appropriate ARIA labels +- **Keyboard navigation**: Ensure all interactions work with keyboard +- **Screen reader support**: Test with screen readers +- **Color contrast**: Meet WCAG AA standards + +## Development Workflow + +### Setup and Commands + +1. **Setup**: `script/setup` - Install dependencies +2. **Develop**: `script/develop` - Development server +3. **Lint**: `yarn lint` - Run all linting before committing +4. **Test**: `yarn test` - Add and run tests +5. **Build**: `script/build_frontend` - Test production build + +### Common Pitfalls to Avoid + +- Don't use `querySelector` - Use refs or component properties +- Don't manipulate DOM directly - Let Lit handle rendering +- Don't use global styles - Scope styles to components +- Don't block the main thread - Use web workers for heavy computation +- Don't ignore TypeScript errors - Fix all type issues + +### Security Best Practices + +- Sanitize HTML - Never use `unsafeHTML` with user content +- Validate inputs - Always validate user inputs +- Use HTTPS - All external resources must use HTTPS +- CSP compliance - Ensure code works with Content Security Policy + +### Text and Copy Guidelines + +#### Terminology Standards + +**Delete vs Remove** (Based on gallery/src/pages/Text/remove-delete-add-create.markdown) + +- **Use "Remove"** for actions that can be restored or reapplied: + - Removing a user's permission + - Removing a user from a group + - Removing links between items + - Removing a widget from dashboard + - Removing an item from a cart +- **Use "Delete"** for permanent, non-recoverable actions: + - Deleting a field + - Deleting a value in a field + - Deleting a task + - Deleting a group + - Deleting a permission + - Deleting a calendar event + +**Create vs Add** (Create pairs with Delete, Add pairs with Remove) + +- **Use "Add"** for already-existing items: + - Adding a permission to a user + - Adding a user to a group + - Adding links between items + - Adding a widget to dashboard + - Adding an item to a cart +- **Use "Create"** for something made from scratch: + - Creating a new field + - Creating a new task + - Creating a new group + - Creating a new permission + - Creating a new calendar event + +#### Writing Style (Consistent with Home Assistant Documentation) + +- **Use American English**: Standard spelling and terminology +- **Friendly, informational tone**: Be inspiring, personal, comforting, engaging +- **Address users directly**: Use "you" and "your" +- **Be inclusive**: Objective, non-discriminatory language +- **Be concise**: Use clear, direct language +- **Be consistent**: Follow established terminology patterns +- **Use active voice**: "Delete the automation" not "The automation should be deleted" +- **Avoid jargon**: Use terms familiar to home automation users + +#### Language Standards + +- **Always use "Home Assistant"** in full, never "HA" or "HASS" +- **Avoid abbreviations**: Spell out terms when possible +- **Use sentence case everywhere**: Titles, headings, buttons, labels, UI elements + - ✅ "Create new automation" + - ❌ "Create New Automation" + - ✅ "Device settings" + - ❌ "Device Settings" +- **Oxford comma**: Use in lists (item 1, item 2, and item 3) +- **Replace Latin terms**: Use "like" instead of "e.g.", "for example" instead of "i.e." +- **Avoid CAPS for emphasis**: Use bold or italics instead +- **Write for all skill levels**: Both technical and non-technical users + +#### Key Terminology + +- **"add-on"** (hyphenated, not "addon") +- **"integration"** (preferred over "component") +- **Technical terms**: Use lowercase (automation, entity, device, service) + +#### Translation Considerations + +- **Add translation keys**: All user-facing text must be translatable +- **Use placeholders**: Support dynamic content in translations +- **Keep context**: Provide enough context for translators + +```typescript +// Good +this.hass.localize("ui.panel.config.automation.delete_confirm", { + name: automation.alias, +}); + +// Bad - hardcoded text +("Are you sure you want to delete this automation?"); +``` + +### Common Review Issues (From PR Analysis) + +#### User Experience and Accessibility + +- **Form validation**: Always provide proper field labels and validation feedback +- **Form accessibility**: Prevent password managers from incorrectly identifying fields +- **Loading states**: Show clear progress indicators during async operations +- **Error handling**: Display meaningful error messages when operations fail +- **Mobile responsiveness**: Ensure components work well on small screens +- **Hit targets**: Make clickable areas large enough for touch interaction +- **Visual feedback**: Provide clear indication of interactive states + +#### Dialog and Modal Patterns + +- **Dialog width constraints**: Respect minimum and maximum width requirements +- **Interview progress**: Show clear progress for multi-step operations +- **State persistence**: Handle dialog state properly during background operations +- **Cancel behavior**: Ensure cancel/close buttons work consistently +- **Form prefilling**: Use smart defaults but allow user override + +#### Component Design Patterns + +- **Terminology consistency**: Use "Join"/"Apply" instead of "Group" when appropriate +- **Visual hierarchy**: Ensure proper font sizes and spacing ratios +- **Grid alignment**: Components should align to the design grid system +- **Badge placement**: Position badges and indicators consistently +- **Color theming**: Respect theme variables and design system colors + +#### Code Quality Issues + +- **Null checking**: Always check if entities exist before accessing properties +- **TypeScript safety**: Handle potentially undefined array/object access +- **Import organization**: Remove unused imports and use proper type imports +- **Event handling**: Properly subscribe and unsubscribe from events +- **Memory leaks**: Clean up subscriptions and event listeners + +#### Configuration and Props + +- **Optional parameters**: Make configuration fields optional when sensible +- **Smart defaults**: Provide reasonable default values +- **Future extensibility**: Design APIs that can be extended later +- **Validation**: Validate configuration before applying changes + +## Review Guidelines + +### Core Requirements Checklist + +- [ ] TypeScript strict mode passes (`yarn lint:types`) +- [ ] No ESLint errors or warnings (`yarn lint:eslint`) +- [ ] Prettier formatting applied (`yarn lint:prettier`) +- [ ] Lit analyzer passes (`yarn lint:lit`) +- [ ] Component follows Lit best practices +- [ ] Proper error handling implemented +- [ ] Loading states handled +- [ ] Mobile responsive +- [ ] Theme variables used +- [ ] Translations added +- [ ] Accessible to screen readers +- [ ] Tests added (where applicable) +- [ ] No console statements (use proper logging) +- [ ] Unused imports removed +- [ ] Proper naming conventions + +### Text and Copy Checklist + +- [ ] Follows terminology guidelines (Delete vs Remove, Create vs Add) +- [ ] Localization keys added for all user-facing text +- [ ] Uses "Home Assistant" (never "HA" or "HASS") +- [ ] Sentence case for ALL text (titles, headings, buttons, labels) +- [ ] American English spelling +- [ ] Friendly, informational tone +- [ ] Avoids abbreviations and jargon +- [ ] Correct terminology (add-on not addon, integration not component) + +### Component-Specific Checks + +- [ ] Dialogs implement HassDialog interface +- [ ] Dialog styling uses haStyleDialog +- [ ] Dialog accessibility includes dialogInitialFocus +- [ ] ha-alert used correctly for messages +- [ ] ha-form uses proper schema structure +- [ ] Components handle all states (loading, error, unavailable) +- [ ] Entity existence checked before property access +- [ ] Event subscriptions properly cleaned up diff --git a/.gitignore b/.gitignore index 7de50364e7..a6fef79fcb 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,7 @@ src/cast/dev_const.ts # test coverage test/coverage/ + +# AI tooling +.claude + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000000..02dd134122 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +.github/copilot-instructions.md \ No newline at end of file diff --git a/package.json b/package.json index d9266af1d0..22174fcd05 100644 --- a/package.json +++ b/package.json @@ -30,11 +30,11 @@ "@braintree/sanitize-url": "7.1.1", "@codemirror/autocomplete": "6.18.6", "@codemirror/commands": "6.8.1", - "@codemirror/language": "6.11.1", + "@codemirror/language": "6.11.2", "@codemirror/legacy-modes": "6.5.1", "@codemirror/search": "6.5.11", "@codemirror/state": "6.5.2", - "@codemirror/view": "6.37.2", + "@codemirror/view": "6.38.0", "@egjs/hammerjs": "2.0.17", "@formatjs/intl-datetimeformat": "6.18.0", "@formatjs/intl-displaynames": "6.8.11", @@ -45,12 +45,12 @@ "@formatjs/intl-numberformat": "8.15.4", "@formatjs/intl-pluralrules": "5.4.4", "@formatjs/intl-relativetimeformat": "11.4.11", - "@fullcalendar/core": "6.1.17", - "@fullcalendar/daygrid": "6.1.17", - "@fullcalendar/interaction": "6.1.17", - "@fullcalendar/list": "6.1.17", - "@fullcalendar/luxon3": "6.1.17", - "@fullcalendar/timegrid": "6.1.17", + "@fullcalendar/core": "6.1.18", + "@fullcalendar/daygrid": "6.1.18", + "@fullcalendar/interaction": "6.1.18", + "@fullcalendar/list": "6.1.18", + "@fullcalendar/luxon3": "6.1.18", + "@fullcalendar/timegrid": "6.1.18", "@lezer/highlight": "1.2.1", "@lit-labs/motion": "1.0.8", "@lit-labs/observers": "2.0.5", @@ -89,14 +89,14 @@ "@thomasloven/round-slider": "0.6.0", "@tsparticles/engine": "3.8.1", "@tsparticles/preset-links": "3.2.0", - "@vaadin/combo-box": "24.8.0", - "@vaadin/vaadin-themable-mixin": "24.8.0", + "@vaadin/combo-box": "24.7.9", + "@vaadin/vaadin-themable-mixin": "24.7.9", "@vibrant/color": "4.0.0", "@vue/web-component-wrapper": "1.3.0", "@webcomponents/scoped-custom-element-registry": "0.0.10", "@webcomponents/webcomponentsjs": "2.8.0", "app-datepicker": "5.1.1", - "barcode-detector": "3.0.4", + "barcode-detector": "3.0.5", "color-name": "2.0.0", "comlink": "4.4.2", "core-js": "3.43.0", @@ -122,7 +122,7 @@ "lit": "3.3.0", "lit-html": "3.3.0", "luxon": "3.6.1", - "marked": "15.0.12", + "marked": "16.0.0", "memoize-one": "6.0.0", "node-vibrant": "4.0.3", "object-hash": "3.0.0", @@ -135,7 +135,7 @@ "stacktrace-js": "2.0.2", "superstruct": "2.0.2", "tinykeys": "3.0.0", - "ua-parser-js": "2.0.3", + "ua-parser-js": "2.0.4", "vis-data": "7.1.9", "vue": "2.7.16", "vue2-daterange-picker": "0.6.8", @@ -149,18 +149,18 @@ "xss": "1.0.15" }, "devDependencies": { - "@babel/core": "7.27.4", - "@babel/helper-define-polyfill-provider": "0.6.4", + "@babel/core": "7.27.7", + "@babel/helper-define-polyfill-provider": "0.6.5", "@babel/plugin-transform-runtime": "7.27.4", "@babel/preset-env": "7.27.2", - "@bundle-stats/plugin-webpack-filter": "4.20.2", - "@lokalise/node-api": "14.8.0", + "@bundle-stats/plugin-webpack-filter": "4.21.0", + "@lokalise/node-api": "14.9.0", "@octokit/auth-oauth-device": "8.0.1", "@octokit/plugin-retry": "8.0.1", "@octokit/rest": "22.0.0", - "@rsdoctor/rspack-plugin": "1.1.4", - "@rspack/cli": "1.3.12", - "@rspack/core": "1.3.12", + "@rsdoctor/rspack-plugin": "1.1.5", + "@rspack/cli": "1.4.2", + "@rspack/core": "1.4.2", "@types/babel__plugin-transform-runtime": "7.9.5", "@types/chromecast-caf-receiver": "6.0.22", "@types/chromecast-caf-sender": "1.0.11", @@ -168,7 +168,7 @@ "@types/glob": "8.1.0", "@types/html-minifier-terser": "7.0.2", "@types/js-yaml": "4.0.9", - "@types/leaflet": "1.9.18", + "@types/leaflet": "1.9.19", "@types/leaflet-draw": "1.0.12", "@types/leaflet.markercluster": "1.5.5", "@types/lodash.merge": "4.6.9", @@ -184,13 +184,13 @@ "babel-plugin-template-html-minifier": "4.1.0", "browserslist-useragent-regexp": "4.1.3", "del": "8.0.0", - "eslint": "9.29.0", + "eslint": "9.30.0", "eslint-config-airbnb-base": "15.0.0", "eslint-config-prettier": "10.1.5", "eslint-import-resolver-webpack": "0.13.10", "eslint-plugin-import": "2.32.0", "eslint-plugin-lit": "2.1.1", - "eslint-plugin-lit-a11y": "5.0.1", + "eslint-plugin-lit-a11y": "5.1.0", "eslint-plugin-unused-imports": "4.1.4", "eslint-plugin-wc": "3.0.1", "fancy-log": "2.0.0", @@ -199,7 +199,7 @@ "gulp": "5.0.1", "gulp-brotli": "3.0.0", "gulp-json-transform": "0.5.0", - "gulp-rename": "2.0.0", + "gulp-rename": "2.1.0", "html-minifier-terser": "7.2.0", "husky": "9.1.7", "jsdom": "26.1.0", @@ -210,7 +210,7 @@ "lodash.template": "4.5.0", "map-stream": "0.0.7", "pinst": "3.0.0", - "prettier": "3.5.3", + "prettier": "3.6.2", "rspack-manifest-plugin": "5.0.3", "serve": "14.2.4", "sinon": "21.0.0", @@ -218,7 +218,7 @@ "terser-webpack-plugin": "5.3.14", "ts-lit-plugin": "2.0.2", "typescript": "5.8.3", - "typescript-eslint": "8.34.1", + "typescript-eslint": "8.35.1", "vite-tsconfig-paths": "5.1.4", "vitest": "3.2.4", "webpack-stats-plugin": "1.1.3", @@ -231,8 +231,8 @@ "lit-html": "3.3.0", "clean-css": "5.3.3", "@lit/reactive-element": "2.1.0", - "@fullcalendar/daygrid": "6.1.17", - "globals": "16.2.0", + "@fullcalendar/daygrid": "6.1.18", + "globals": "16.3.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" }, diff --git a/pyproject.toml b/pyproject.toml index 169b86dbe8..d6597ff9bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "home-assistant-frontend" -version = "20250430.0" +version = "20250625.0" license = "Apache-2.0" license-files = ["LICENSE*"] description = "The Home Assistant frontend" diff --git a/src/common/decorators/storage.ts b/src/common/decorators/storage.ts index 03d4176548..80c258b83f 100644 --- a/src/common/decorators/storage.ts +++ b/src/common/decorators/storage.ts @@ -202,7 +202,6 @@ export function storage(options: { // Don't set the initial value if we have a value in localStorage if (this.__initialized || getValue() === undefined) { setValue(this, value); - this.requestUpdate(propertyKey, undefined); } }, configurable: true, @@ -212,11 +211,13 @@ export function storage(options: { const oldSetter = descriptor.set; newDescriptor = { ...descriptor, + get(this: ReactiveStorageElement) { + return getValue(); + }, set(this: ReactiveStorageElement, value) { // Don't set the initial value if we have a value in localStorage if (this.__initialized || getValue() === undefined) { setValue(this, value); - this.requestUpdate(propertyKey, undefined); } oldSetter?.call(this, value); }, diff --git a/src/common/entity/group_entities.ts b/src/common/entity/group_entities.ts new file mode 100644 index 0000000000..26237a699c --- /dev/null +++ b/src/common/entity/group_entities.ts @@ -0,0 +1,68 @@ +import type { HassEntity } from "home-assistant-js-websocket"; +import { computeStateDomain } from "./compute_state_domain"; +import { isUnavailableState, UNAVAILABLE } from "../../data/entity"; +import type { HomeAssistant } from "../../types"; + +export const computeGroupEntitiesState = (states: HassEntity[]): string => { + if (!states.length) { + return UNAVAILABLE; + } + + const validState = states.filter((stateObj) => isUnavailableState(stateObj)); + + if (!validState) { + return UNAVAILABLE; + } + + // Use the first state to determine the domain + // This assumes all states in the group have the same domain + const domain = computeStateDomain(states[0]); + + if (domain === "cover") { + for (const s of ["opening", "closing", "open"]) { + if (states.some((stateObj) => stateObj.state === s)) { + return s; + } + } + return "closed"; + } + + if (states.some((stateObj) => stateObj.state === "on")) { + return "on"; + } + return "off"; +}; + +export const toggleGroupEntities = ( + hass: HomeAssistant, + states: HassEntity[] +) => { + if (!states.length) { + return; + } + + // Use the first state to determine the domain + // This assumes all states in the group have the same domain + const domain = computeStateDomain(states[0]); + + const state = computeGroupEntitiesState(states); + + const isOn = state === "on" || state === "open"; + + let service = isOn ? "turn_off" : "turn_on"; + if (domain === "cover") { + if (state === "opening" || state === "closing") { + // If the cover is opening or closing, we toggle it to stop it + service = "stop_cover"; + } else { + // For covers, we use the open/close service + service = isOn ? "close_cover" : "open_cover"; + } + } + + const entitiesIds = states.map((stateObj) => stateObj.entity_id); + + hass.callService(domain, service, { + entity_id: entitiesIds, + }); +}; diff --git a/src/common/entity/state_color.ts b/src/common/entity/state_color.ts index 474fee0e59..976f56ab11 100644 --- a/src/common/entity/state_color.ts +++ b/src/common/entity/state_color.ts @@ -64,15 +64,27 @@ export const domainStateColorProperties = ( const compareState = state !== undefined ? state : stateObj.state; const active = stateActive(stateObj, state); + return domainColorProperties( + domain, + stateObj.attributes.device_class, + compareState, + active + ); +}; + +export const domainColorProperties = ( + domain: string, + deviceClass: string | undefined, + state: string, + active: boolean +) => { const properties: string[] = []; - const stateKey = slugify(compareState, "_"); + const stateKey = slugify(state, "_"); const activeKey = active ? "active" : "inactive"; - const dc = stateObj.attributes.device_class; - - if (dc) { - properties.push(`--state-${domain}-${dc}-${stateKey}-color`); + if (deviceClass) { + properties.push(`--state-${domain}-${deviceClass}-${stateKey}-color`); } properties.push( diff --git a/src/common/string/compare.ts b/src/common/string/compare.ts index 9d6aca76b8..8a8daa64df 100644 --- a/src/common/string/compare.ts +++ b/src/common/string/compare.ts @@ -2,12 +2,13 @@ import memoizeOne from "memoize-one"; import { isIPAddress } from "./is_ip_address"; const collator = memoizeOne( - (language: string | undefined) => new Intl.Collator(language) + (language: string | undefined) => + new Intl.Collator(language, { numeric: true }) ); const caseInsensitiveCollator = memoizeOne( (language: string | undefined) => - new Intl.Collator(language, { sensitivity: "accent" }) + new Intl.Collator(language, { sensitivity: "accent", numeric: true }) ); const fallbackStringCompare = (a: string, b: string) => { diff --git a/src/components/chart/ha-chart-base.ts b/src/components/chart/ha-chart-base.ts index fdaad85c83..7a7fa68567 100644 --- a/src/components/chart/ha-chart-base.ts +++ b/src/components/chart/ha-chart-base.ts @@ -390,6 +390,7 @@ export class HaChartBase extends LitElement { type: "inside", orient: "horizontal", filterMode: "none", + xAxisIndex: 0, moveOnMouseMove: !this._isTouchDevice || this._isZoomed, preventDefaultMouseMove: !this._isTouchDevice || this._isZoomed, zoomLock: !this._isTouchDevice && !this._modifierPressed, diff --git a/src/components/ha-area-picker.ts b/src/components/ha-area-picker.ts index 1f14568261..9b18057808 100644 --- a/src/components/ha-area-picker.ts +++ b/src/components/ha-area-picker.ts @@ -366,6 +366,7 @@ export class HaAreaPicker extends LitElement { .hass=${this.hass} .autofocus=${this.autofocus} .label=${this.label} + .helper=${this.helper} .notFoundLabel=${this.hass.localize( "ui.components.area-picker.no_match" )} diff --git a/src/components/ha-areas-floors-display-editor.ts b/src/components/ha-areas-floors-display-editor.ts index 414e2550c7..539df7ab09 100644 --- a/src/components/ha-areas-floors-display-editor.ts +++ b/src/components/ha-areas-floors-display-editor.ts @@ -1,14 +1,15 @@ -import { mdiTextureBox } from "@mdi/js"; +import { mdiDrag, mdiTextureBox } from "@mdi/js"; import type { TemplateResult } from "lit"; -import { LitElement, css, html } from "lit"; +import { LitElement, css, html, nothing } from "lit"; import { customElement, property } from "lit/decorators"; +import { repeat } from "lit/directives/repeat"; import memoizeOne from "memoize-one"; import { fireEvent } from "../common/dom/fire_event"; import { computeFloorName } from "../common/entity/compute_floor_name"; import { getAreaContext } from "../common/entity/context/get_area_context"; -import { stringCompare } from "../common/string/compare"; import { areaCompare } from "../data/area_registry"; import type { FloorRegistryEntry } from "../data/floor_registry"; +import { getFloors } from "../panels/lovelace/strategies/areas/helpers/areas-strategy-helper"; import type { HomeAssistant } from "../types"; import "./ha-expansion-panel"; import "./ha-floor-icon"; @@ -17,9 +18,14 @@ import type { DisplayItem, DisplayValue } from "./ha-items-display-editor"; import "./ha-svg-icon"; import "./ha-textfield"; -export interface AreasDisplayValue { - hidden?: string[]; - order?: string[]; +export interface AreasFloorsDisplayValue { + areas_display?: { + hidden?: string[]; + order?: string[]; + }; + floors_display?: { + order?: string[]; + }; } const UNASSIGNED_FLOOR = "__unassigned__"; @@ -30,12 +36,10 @@ export class HaAreasFloorsDisplayEditor extends LitElement { @property() public label?: string; - @property({ attribute: false }) public value?: AreasDisplayValue; + @property({ attribute: false }) public value?: AreasFloorsDisplayValue; @property() public helper?: string; - @property({ type: Boolean }) public expanded = false; - @property({ type: Boolean }) public disabled = false; @property({ type: Boolean }) public required = false; @@ -44,51 +48,79 @@ export class HaAreasFloorsDisplayEditor extends LitElement { public showNavigationButton = false; protected render(): TemplateResult { - const groupedItems = this._groupedItems(this.hass.areas, this.hass.floors); + const groupedAreasItems = this._groupedAreasItems( + this.hass.areas, + this.hass.floors + ); - const filteredFloors = this._sortedFloors(this.hass.floors).filter( + const filteredFloors = this._sortedFloors( + this.hass.floors, + this.value?.floors_display?.order + ).filter( (floor) => // Only include floors that have areas assigned to them - groupedItems[floor.floor_id]?.length > 0 + groupedAreasItems[floor.floor_id]?.length > 0 ); const value: DisplayValue = { - order: this.value?.order ?? [], - hidden: this.value?.hidden ?? [], + order: this.value?.areas_display?.order ?? [], + hidden: this.value?.areas_display?.hidden ?? [], }; + const canReorderFloors = + filteredFloors.filter((floor) => floor.floor_id !== UNASSIGNED_FLOOR) + .length > 1; + return html` - ${this.label}` : nothing} + - - ${filteredFloors.map( - (floor) => html` -
-
- -

${computeFloorName(floor)}

-
-
+
+ ${repeat( + filteredFloors, + (floor) => floor.floor_id, + (floor: FloorRegistryEntry) => html` + + + ${floor.floor_id === UNASSIGNED_FLOOR || !canReorderFloors + ? nothing + : html` + + `} -
-
- ` - )} - + + ` + )} +
+
`; } - private _groupedItems = memoizeOne( + private _groupedAreasItems = memoizeOne( ( hassAreas: HomeAssistant["areas"], // update items if floors change @@ -112,7 +144,6 @@ export class HaAreasFloorsDisplayEditor extends LitElement { label: area.name, icon: area.icon ?? undefined, iconPath: mdiTextureBox, - description: floor?.name, }); return acc; @@ -124,20 +155,19 @@ export class HaAreasFloorsDisplayEditor extends LitElement { ); private _sortedFloors = memoizeOne( - (hassFloors: HomeAssistant["floors"]): FloorRegistryEntry[] => { - const floors = Object.values(hassFloors).sort((floorA, floorB) => { - if (floorA.level !== floorB.level) { - return (floorA.level ?? 0) - (floorB.level ?? 0); - } - return stringCompare(floorA.name, floorB.name); - }); + ( + hassFloors: HomeAssistant["floors"], + order: string[] | undefined + ): FloorRegistryEntry[] => { + const floors = getFloors(hassFloors, order); + const noFloors = floors.length === 0; floors.push({ floor_id: UNASSIGNED_FLOOR, - name: this.hass.localize( - "ui.panel.lovelace.strategy.areas.unassigned_areas" - ), + name: noFloors + ? this.hass.localize("ui.panel.lovelace.strategy.areas.areas") + : this.hass.localize("ui.panel.lovelace.strategy.areas.other_areas"), icon: null, - level: 999999, + level: null, aliases: [], created_at: 0, modified_at: 0, @@ -146,68 +176,101 @@ export class HaAreasFloorsDisplayEditor extends LitElement { } ); - private async _areaDisplayChanged(ev) { + private _floorMoved(ev: CustomEvent) { ev.stopPropagation(); - const value = ev.detail.value as DisplayValue; - const currentFloorId = ev.currentTarget.floorId; + const newIndex = ev.detail.newIndex; + const oldIndex = ev.detail.oldIndex; + const floorIds = this._sortedFloors( + this.hass.floors, + this.value?.floors_display?.order + ).map((floor) => floor.floor_id); + const newOrder = [...floorIds]; + const movedFloorId = newOrder.splice(oldIndex, 1)[0]; + newOrder.splice(newIndex, 0, movedFloorId); + const newValue: AreasFloorsDisplayValue = { + areas_display: this.value?.areas_display, + floors_display: { + order: newOrder, + }, + }; + if (newValue.floors_display?.order?.length === 0) { + delete newValue.floors_display.order; + } + fireEvent(this, "value-changed", { value: newValue }); + } - const floorIds = this._sortedFloors(this.hass.floors).map( - (floor) => floor.floor_id - ); + private async _areaDisplayChanged(ev: CustomEvent<{ value: DisplayValue }>) { + ev.stopPropagation(); + const value = ev.detail.value; + const currentFloorId = (ev.currentTarget as any).floorId; + + const floorIds = this._sortedFloors( + this.hass.floors, + this.value?.floors_display?.order + ).map((floor) => floor.floor_id); + + const oldAreaDisplay = this.value?.areas_display ?? {}; + + const oldHidden = oldAreaDisplay?.hidden ?? []; + const oldOrder = oldAreaDisplay?.order ?? []; const newHidden: string[] = []; const newOrder: string[] = []; for (const floorId of floorIds) { - if (currentFloorId === floorId) { + if ((currentFloorId ?? UNASSIGNED_FLOOR) === floorId) { newHidden.push(...(value.hidden ?? [])); newOrder.push(...(value.order ?? [])); continue; } - const hidden = this.value?.hidden?.filter( - (areaId) => this.hass.areas[areaId]?.floor_id === floorId - ); - if (hidden) { + const hidden = oldHidden.filter((areaId) => { + const id = this.hass.areas[areaId]?.floor_id ?? UNASSIGNED_FLOOR; + return id === floorId; + }); + if (hidden?.length) { newHidden.push(...hidden); } - const order = this.value?.order?.filter( - (areaId) => this.hass.areas[areaId]?.floor_id === floorId - ); - if (order) { + const order = oldOrder.filter((areaId) => { + const id = this.hass.areas[areaId]?.floor_id ?? UNASSIGNED_FLOOR; + return id === floorId; + }); + if (order?.length) { newOrder.push(...order); } } - const newValue: AreasDisplayValue = { - hidden: newHidden, - order: newOrder, + const newValue: AreasFloorsDisplayValue = { + areas_display: { + hidden: newHidden, + order: newOrder, + }, + floors_display: this.value?.floors_display, }; - if (newValue.hidden?.length === 0) { - delete newValue.hidden; + if (newValue.areas_display?.hidden?.length === 0) { + delete newValue.areas_display.hidden; } - if (newValue.order?.length === 0) { - delete newValue.order; + if (newValue.areas_display?.order?.length === 0) { + delete newValue.areas_display.order; + } + if (newValue.floors_display?.order?.length === 0) { + delete newValue.floors_display.order; } fireEvent(this, "value-changed", { value: newValue }); } static styles = css` - .floor .header p { - margin: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - flex: 1: + ha-expansion-panel { + margin-bottom: 8px; + --expansion-panel-summary-padding: 0 16px; } - .floor .header { - margin: 16px 0 8px 0; - padding: 0 8px; - display: flex; - flex-direction: row; - align-items: center; - gap: 8px; + ha-expansion-panel [slot="leading-icon"] { + margin-inline-end: 16px; + } + label { + display: block; + font-weight: var(--ha-font-weight-bold); + margin-bottom: 8px; } `; } diff --git a/src/components/ha-code-editor.ts b/src/components/ha-code-editor.ts index 190b456fbd..69387c89a3 100644 --- a/src/components/ha-code-editor.ts +++ b/src/components/ha-code-editor.ts @@ -6,6 +6,7 @@ import type { } from "@codemirror/autocomplete"; import type { Extension, TransactionSpec } from "@codemirror/state"; import type { EditorView, KeyBinding, ViewUpdate } from "@codemirror/view"; +import { mdiArrowExpand, mdiArrowCollapse } from "@mdi/js"; import type { HassEntities } from "home-assistant-js-websocket"; import type { PropertyValues } from "lit"; import { css, ReactiveElement } from "lit"; @@ -15,6 +16,7 @@ import { fireEvent } from "../common/dom/fire_event"; import { stopPropagation } from "../common/dom/stop_propagation"; import type { HomeAssistant } from "../types"; import "./ha-icon"; +import "./ha-icon-button"; declare global { interface HASSDomEvents { @@ -59,8 +61,13 @@ export class HaCodeEditor extends ReactiveElement { @property({ type: Boolean }) public error = false; + @property({ type: Boolean, attribute: "disable-fullscreen" }) + public disableFullscreen = false; + @state() private _value = ""; + @state() private _isFullscreen = false; + // eslint-disable-next-line @typescript-eslint/consistent-type-imports private _loadedCodeMirror?: typeof import("../resources/codemirror"); @@ -92,6 +99,7 @@ export class HaCodeEditor extends ReactiveElement { this.requestUpdate(); } this.addEventListener("keydown", stopPropagation); + this.addEventListener("keydown", this._handleKeyDown); // This is unreachable as editor will not exist yet, // but focus should not behave like this for good a11y. // (@steverep to fix in autofocus PR) @@ -106,6 +114,10 @@ export class HaCodeEditor extends ReactiveElement { public disconnectedCallback() { super.disconnectedCallback(); this.removeEventListener("keydown", stopPropagation); + this.removeEventListener("keydown", this._handleKeyDown); + if (this._isFullscreen) { + this._toggleFullscreen(); + } this.updateComplete.then(() => { this.codemirror!.destroy(); delete this.codemirror; @@ -164,6 +176,12 @@ export class HaCodeEditor extends ReactiveElement { if (changedProps.has("error")) { this.classList.toggle("error-state", this.error); } + if (changedProps.has("_isFullscreen")) { + this.classList.toggle("fullscreen", this._isFullscreen); + } + if (changedProps.has("disableFullscreen")) { + this._updateFullscreenButton(); + } } private get _mode() { @@ -238,8 +256,74 @@ export class HaCodeEditor extends ReactiveElement { }), parent: this.renderRoot, }); + + this._updateFullscreenButton(); } + private _updateFullscreenButton() { + const existingButton = this.renderRoot.querySelector(".fullscreen-button"); + + if (this.disableFullscreen) { + // Remove button if it exists and fullscreen is disabled + if (existingButton) { + existingButton.remove(); + } + // Exit fullscreen if currently in fullscreen mode + if (this._isFullscreen) { + this._isFullscreen = false; + } + return; + } + + // Create button if it doesn't exist + if (!existingButton) { + const button = document.createElement("ha-icon-button"); + (button as any).path = this._isFullscreen + ? mdiArrowCollapse + : mdiArrowExpand; + button.setAttribute( + "label", + this._isFullscreen ? "Exit fullscreen" : "Enter fullscreen" + ); + button.classList.add("fullscreen-button"); + // Use bound method to ensure proper this context + button.addEventListener("click", this._handleFullscreenClick); + this.renderRoot.appendChild(button); + } else { + // Update existing button + (existingButton as any).path = this._isFullscreen + ? mdiArrowCollapse + : mdiArrowExpand; + existingButton.setAttribute( + "label", + this._isFullscreen ? "Exit fullscreen" : "Enter fullscreen" + ); + } + } + + private _handleFullscreenClick = (e: Event) => { + e.preventDefault(); + e.stopPropagation(); + this._toggleFullscreen(); + }; + + private _toggleFullscreen() { + this._isFullscreen = !this._isFullscreen; + this._updateFullscreenButton(); + } + + private _handleKeyDown = (e: KeyboardEvent) => { + if (this._isFullscreen && e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + this._toggleFullscreen(); + } else if (e.key === "F11" && !this.disableFullscreen) { + e.preventDefault(); + e.stopPropagation(); + this._toggleFullscreen(); + } + }; + private _getStates = memoizeOne((states: HassEntities): Completion[] => { if (!states) { return []; @@ -460,9 +544,77 @@ export class HaCodeEditor extends ReactiveElement { }; static styles = css` + :host { + position: relative; + display: block; + } + :host(.error-state) .cm-gutters { border-color: var(--error-state-color, red); } + + .fullscreen-button { + position: absolute; + top: 8px; + right: 8px; + z-index: 1; + color: var(--secondary-text-color); + background-color: var(--secondary-background-color); + border-radius: 50%; + opacity: 0.9; + transition: opacity 0.2s; + --mdc-icon-button-size: 32px; + --mdc-icon-size: 18px; + /* Ensure button is clickable on iOS */ + cursor: pointer; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; + } + + .fullscreen-button:hover, + .fullscreen-button:active { + opacity: 1; + } + + @media (hover: none) { + .fullscreen-button { + opacity: 0.8; + } + } + + :host(.fullscreen) { + position: fixed !important; + top: calc(var(--header-height, 56px) + 8px) !important; + left: 8px !important; + right: 8px !important; + bottom: 8px !important; + z-index: 9999 !important; + border-radius: 12px !important; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3) !important; + overflow: hidden !important; + background-color: var( + --code-editor-background-color, + var(--card-background-color) + ) !important; + margin: 0 !important; + padding-top: var(--safe-area-inset-top) !important; + padding-left: var(--safe-area-inset-left) !important; + padding-right: var(--safe-area-inset-right) !important; + padding-bottom: var(--safe-area-inset-bottom) !important; + box-sizing: border-box !important; + display: block !important; + } + + :host(.fullscreen) .cm-editor { + height: 100% !important; + max-height: 100% !important; + border-radius: 0 !important; + } + + :host(.fullscreen) .fullscreen-button { + top: calc(var(--safe-area-inset-top, 0px) + 8px); + right: calc(var(--safe-area-inset-right, 0px) + 8px); + } `; } diff --git a/src/components/ha-combo-box.ts b/src/components/ha-combo-box.ts index bcdc9077e7..44e55c55d9 100644 --- a/src/components/ha-combo-box.ts +++ b/src/components/ha-combo-box.ts @@ -369,7 +369,6 @@ export class HaComboBox extends LitElement { } vaadin-combo-box-light { position: relative; - --vaadin-combo-box-overlay-max-height: calc(45vh - 56px); } ha-combo-box-textfield { width: 100%; diff --git a/src/components/ha-domain-icon.ts b/src/components/ha-domain-icon.ts index 6994c32c99..7d30d2f4a9 100644 --- a/src/components/ha-domain-icon.ts +++ b/src/components/ha-domain-icon.ts @@ -18,6 +18,8 @@ export class HaDomainIcon extends LitElement { @property({ attribute: false }) public deviceClass?: string; + @property({ attribute: false }) public state?: string; + @property() public icon?: string; @property({ attribute: "brand-fallback", type: Boolean }) @@ -36,14 +38,17 @@ export class HaDomainIcon extends LitElement { return this._renderFallback(); } - const icon = domainIcon(this.hass, this.domain, this.deviceClass).then( - (icn) => { - if (icn) { - return html``; - } - return this._renderFallback(); + const icon = domainIcon( + this.hass, + this.domain, + this.deviceClass, + this.state + ).then((icn) => { + if (icn) { + return html``; } - ); + return this._renderFallback(); + }); return html`${until(icon)}`; } diff --git a/src/components/ha-filter-blueprints.ts b/src/components/ha-filter-blueprints.ts index bdb2816ff3..c32d79659a 100644 --- a/src/components/ha-filter-blueprints.ts +++ b/src/components/ha-filter-blueprints.ts @@ -14,6 +14,7 @@ import "./ha-check-list-item"; import "./ha-expansion-panel"; import "./ha-icon-button"; import "./ha-list"; +import { deepEqual } from "../common/util/deep-equal"; @customElement("ha-filter-blueprints") export class HaFilterBlueprints extends LitElement { @@ -34,10 +35,11 @@ export class HaFilterBlueprints extends LitElement { public willUpdate(properties: PropertyValues) { super.willUpdate(properties); - if (!this.hasUpdated) { - if (this.value?.length) { - this._findRelated(); - } + if ( + properties.has("value") && + !deepEqual(this.value, properties.get("value")) + ) { + this._findRelated(); } } @@ -130,17 +132,15 @@ export class HaFilterBlueprints extends LitElement { } this.value = value; - - this._findRelated(); } private async _findRelated() { if (!this.value?.length) { + this.value = []; fireEvent(this, "data-table-filter-changed", { value: [], items: undefined, }); - this.value = []; return; } diff --git a/src/components/ha-filter-devices.ts b/src/components/ha-filter-devices.ts index 7beb3c05ea..17bf421295 100644 --- a/src/components/ha-filter-devices.ts +++ b/src/components/ha-filter-devices.ts @@ -6,6 +6,7 @@ import memoizeOne from "memoize-one"; import { fireEvent } from "../common/dom/fire_event"; import { computeDeviceNameDisplay } from "../common/entity/compute_device_name"; import { stringCompare } from "../common/string/compare"; +import { deepEqual } from "../common/util/deep-equal"; import type { RelatedResult } from "../data/search"; import { findRelated } from "../data/search"; import { haStyleScrollbar } from "../resources/styles"; @@ -37,9 +38,13 @@ export class HaFilterDevices extends LitElement { if (!this.hasUpdated) { loadVirtualizer(); - if (this.value?.length) { - this._findRelated(); - } + } + + if ( + properties.has("value") && + !deepEqual(this.value, properties.get("value")) + ) { + this._findRelated(); } } @@ -110,7 +115,6 @@ export class HaFilterDevices extends LitElement { this.value = [...(this.value || []), value]; } listItem.selected = this.value?.includes(value); - this._findRelated(); } protected updated(changed) { @@ -160,11 +164,11 @@ export class HaFilterDevices extends LitElement { const relatedPromises: Promise[] = []; if (!this.value?.length) { + this.value = []; fireEvent(this, "data-table-filter-changed", { value: [], items: undefined, }); - this.value = []; return; } @@ -176,7 +180,6 @@ export class HaFilterDevices extends LitElement { relatedPromises.push(findRelated(this.hass, "device", deviceId)); } } - this.value = value; const results = await Promise.all(relatedPromises); const items = new Set(); for (const result of results) { diff --git a/src/components/ha-filter-entities.ts b/src/components/ha-filter-entities.ts index 3f078d813c..35da537f86 100644 --- a/src/components/ha-filter-entities.ts +++ b/src/components/ha-filter-entities.ts @@ -17,6 +17,7 @@ import "./ha-expansion-panel"; import "./ha-list"; import "./ha-state-icon"; import "./search-input-outlined"; +import { deepEqual } from "../common/util/deep-equal"; @customElement("ha-filter-entities") export class HaFilterEntities extends LitElement { @@ -39,9 +40,13 @@ export class HaFilterEntities extends LitElement { if (!this.hasUpdated) { loadVirtualizer(); - if (this.value?.length) { - this._findRelated(); - } + } + + if ( + properties.has("value") && + !deepEqual(this.value, properties.get("value")) + ) { + this._findRelated(); } } @@ -131,7 +136,6 @@ export class HaFilterEntities extends LitElement { this.value = [...(this.value || []), value]; } listItem.selected = this.value?.includes(value); - this._findRelated(); } private _expandedWillChange(ev) { @@ -178,11 +182,11 @@ export class HaFilterEntities extends LitElement { const relatedPromises: Promise[] = []; if (!this.value?.length) { + this.value = []; fireEvent(this, "data-table-filter-changed", { value: [], items: undefined, }); - this.value = []; return; } diff --git a/src/components/ha-filter-floor-areas.ts b/src/components/ha-filter-floor-areas.ts index 5f4e811b4f..38ee414683 100644 --- a/src/components/ha-filter-floor-areas.ts +++ b/src/components/ha-filter-floor-areas.ts @@ -20,6 +20,7 @@ import "./ha-icon-button"; import "./ha-list"; import "./ha-svg-icon"; import "./ha-tree-indicator"; +import { deepEqual } from "../common/util/deep-equal"; @customElement("ha-filter-floor-areas") export class HaFilterFloorAreas extends LitElement { @@ -41,10 +42,11 @@ export class HaFilterFloorAreas extends LitElement { public willUpdate(properties: PropertyValues) { super.willUpdate(properties); - if (!this.hasUpdated) { - if (this.value?.floors?.length || this.value?.areas?.length) { - this._findRelated(); - } + if ( + properties.has("value") && + !deepEqual(this.value, properties.get("value")) + ) { + this._findRelated(); } } @@ -174,8 +176,6 @@ export class HaFilterFloorAreas extends LitElement { } listItem.selected = this.value[type]?.includes(value); - - this._findRelated(); } protected updated(changed) { @@ -188,10 +188,6 @@ export class HaFilterFloorAreas extends LitElement { } } - protected firstUpdated() { - this._findRelated(); - } - private _expandedWillChange(ev) { this._shouldRender = ev.detail.expanded; } @@ -226,6 +222,7 @@ export class HaFilterFloorAreas extends LitElement { !this.value || (!this.value.areas?.length && !this.value.floors?.length) ) { + this.value = {}; fireEvent(this, "data-table-filter-changed", { value: {}, items: undefined, diff --git a/src/components/ha-form/ha-form-select.ts b/src/components/ha-form/ha-form-select.ts index 8451b21a6c..5ccb28ba84 100644 --- a/src/components/ha-form/ha-form-select.ts +++ b/src/components/ha-form/ha-form-select.ts @@ -24,12 +24,16 @@ export class HaFormSelect extends LitElement implements HaFormElement { @property() public helper?: string; + @property({ attribute: false }) + public localizeValue?: (key: string) => string; + @property({ type: Boolean }) public disabled = false; private _selectSchema = memoizeOne( - (options): SelectSelector => ({ + (schema: HaFormSelectSchema): SelectSelector => ({ select: { - options: options.map((option) => ({ + translation_key: schema.name, + options: schema.options.map((option) => ({ value: option[0], label: option[1], })), @@ -41,13 +45,13 @@ export class HaFormSelect extends LitElement implements HaFormElement { return html` `; diff --git a/src/components/ha-items-display-editor.ts b/src/components/ha-items-display-editor.ts index e87ecfaef0..91d55820bf 100644 --- a/src/components/ha-items-display-editor.ts +++ b/src/components/ha-items-display-editor.ts @@ -122,22 +122,6 @@ export class HaItemDisplayEditor extends LitElement { ${description ? html`${description}` : nothing} - ${isVisible && !disableSorting - ? html` - - ` - : html``} ${!showIcon ? nothing : icon @@ -162,6 +146,9 @@ export class HaItemDisplayEditor extends LitElement { ${this.actionsRenderer(item)} ` : nothing} + ${this.showNavigationButton + ? html`` + : nothing} - ${this.showNavigationButton - ? html` ` - : nothing} + ${isVisible && !disableSorting + ? html` + + ` + : html``} `; } diff --git a/src/components/ha-navigation-list.ts b/src/components/ha-navigation-list.ts index a0bc4ef64e..f309dd1a4e 100644 --- a/src/components/ha-navigation-list.ts +++ b/src/components/ha-navigation-list.ts @@ -44,7 +44,7 @@ class HaNavigationList extends LitElement { > - ${page.name} + ${page.name} ${this.hasSecondary ? html`${page.description}` : ""} diff --git a/src/components/ha-selector/ha-selector-area.ts b/src/components/ha-selector/ha-selector-area.ts index 1966214ab3..d2abf64449 100644 --- a/src/components/ha-selector/ha-selector-area.ts +++ b/src/components/ha-selector/ha-selector-area.ts @@ -54,7 +54,7 @@ export class HaAreaSelector extends LitElement { } protected willUpdate(changedProperties: PropertyValues): void { - if (changedProperties.has("selector") && this.value !== undefined) { + if (changedProperties.get("selector") && this.value !== undefined) { if (this.selector.area?.multiple && !Array.isArray(this.value)) { this.value = [this.value]; fireEvent(this, "value-changed", { value: this.value }); diff --git a/src/components/ha-selector/ha-selector-device.ts b/src/components/ha-selector/ha-selector-device.ts index 0ae3ca0fc6..8ce9b26860 100644 --- a/src/components/ha-selector/ha-selector-device.ts +++ b/src/components/ha-selector/ha-selector-device.ts @@ -56,7 +56,7 @@ export class HaDeviceSelector extends LitElement { } protected willUpdate(changedProperties: PropertyValues): void { - if (changedProperties.has("selector") && this.value !== undefined) { + if (changedProperties.get("selector") && this.value !== undefined) { if (this.selector.device?.multiple && !Array.isArray(this.value)) { this.value = [this.value]; fireEvent(this, "value-changed", { value: this.value }); diff --git a/src/components/ha-selector/ha-selector-entity.ts b/src/components/ha-selector/ha-selector-entity.ts index df3a7eab76..fb9a2eeae1 100644 --- a/src/components/ha-selector/ha-selector-entity.ts +++ b/src/components/ha-selector/ha-selector-entity.ts @@ -43,7 +43,7 @@ export class HaEntitySelector extends LitElement { } protected willUpdate(changedProperties: PropertyValues): void { - if (changedProperties.has("selector") && this.value !== undefined) { + if (changedProperties.get("selector") && this.value !== undefined) { if (this.selector.entity?.multiple && !Array.isArray(this.value)) { this.value = [this.value]; fireEvent(this, "value-changed", { value: this.value }); diff --git a/src/components/ha-selector/ha-selector-floor.ts b/src/components/ha-selector/ha-selector-floor.ts index f756a77426..f7135c2158 100644 --- a/src/components/ha-selector/ha-selector-floor.ts +++ b/src/components/ha-selector/ha-selector-floor.ts @@ -54,7 +54,7 @@ export class HaFloorSelector extends LitElement { } protected willUpdate(changedProperties: PropertyValues): void { - if (changedProperties.has("selector") && this.value !== undefined) { + if (changedProperties.get("selector") && this.value !== undefined) { if (this.selector.floor?.multiple && !Array.isArray(this.value)) { this.value = [this.value]; fireEvent(this, "value-changed", { value: this.value }); diff --git a/src/components/ha-selector/ha-selector-media.ts b/src/components/ha-selector/ha-selector-media.ts index def2f3d9bc..c4b3a25f11 100644 --- a/src/components/ha-selector/ha-selector-media.ts +++ b/src/components/ha-selector/ha-selector-media.ts @@ -24,6 +24,10 @@ const MANUAL_SCHEMA = [ { name: "media_content_type", required: false, selector: { text: {} } }, ] as const; +const INCLUDE_DOMAINS = ["media_player"]; + +const EMPTY_FORM = {}; + @customElement("ha-selector-media") export class HaMediaSelector extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -84,7 +88,7 @@ export class HaMediaSelector extends LitElement { (stateObj && supportsFeature(stateObj, MediaPlayerEntityFeature.BROWSE_MEDIA)); - const hasAccept = this.selector.media?.accept?.length; + const hasAccept = this.selector?.media?.accept?.length; return html` ${hasAccept @@ -100,7 +104,7 @@ export class HaMediaSelector extends LitElement { .disabled=${this.disabled} .helper=${this.helper} .required=${this.required} - include-domains='["media_player"]' + .includeDomains=${INCLUDE_DOMAINS} allow-custom-entity @value-changed=${this._entityChanged} > @@ -114,7 +118,7 @@ export class HaMediaSelector extends LitElement { @@ -122,63 +126,62 @@ export class HaMediaSelector extends LitElement { : html` -
- ${this.value?.metadata?.thumbnail - ? html` -
- ` - : html` -
- -
- `} -
-
- ${!this.value?.media_content_id - ? this.hass.localize( - "ui.components.selectors.media.pick_media" - ) - : this.value.metadata?.title || this.value.media_content_id} +
+
+ ${this.value?.metadata?.thumbnail + ? html` +
+ ` + : html` +
+ +
+ `} +
+
+ ${!this.value?.media_content_id + ? this.hass.localize( + "ui.components.selectors.media.pick_media" + ) + : this.value.metadata?.title || this.value.media_content_id} +
`} @@ -229,6 +232,13 @@ export class HaMediaSelector extends LitElement { }); } + private _handleKeyDown(ev: KeyboardEvent) { + if (ev.key === "Enter" || ev.key === " ") { + ev.preventDefault(); + this._pickMedia(); + } + } + static styles = css` ha-entity-picker { display: block; @@ -243,41 +253,52 @@ export class HaMediaSelector extends LitElement { } ha-card { position: relative; - width: 200px; + width: 100%; box-sizing: border-box; cursor: pointer; + transition: background-color 180ms ease-in-out; + min-height: 56px; + } + ha-card:hover:not(.disabled), + ha-card:focus:not(.disabled) { + background-color: var(--state-icon-hover-color, rgba(0, 0, 0, 0.04)); + } + ha-card:focus { + outline: none; } ha-card.disabled { pointer-events: none; color: var(--disabled-text-color); } + .content-container { + display: flex; + align-items: center; + padding: 8px; + gap: 12px; + } ha-card .thumbnail { - width: 100%; + width: 40px; + height: 40px; + flex-shrink: 0; position: relative; box-sizing: border-box; - transition: padding-bottom 0.1s ease-out; - padding-bottom: 100%; - } - ha-card .thumbnail.portrait { - padding-bottom: 150%; + border-radius: 8px; + overflow: hidden; } ha-card .image { - border-radius: 3px 3px 0 0; + border-radius: 8px; } .folder { - --mdc-icon-size: calc(var(--media-browse-item-size, 175px) * 0.4); + --mdc-icon-size: 24px; } .title { - font-size: var(--ha-font-size-l); - padding-top: 16px; + font-size: var(--ha-font-size-m); overflow: hidden; text-overflow: ellipsis; - margin-bottom: 16px; - padding-left: 16px; - padding-right: 4px; - padding-inline-start: 16px; - padding-inline-end: 4px; white-space: nowrap; + line-height: 1.4; + flex: 1; + min-width: 0; } .image { position: absolute; @@ -290,13 +311,15 @@ export class HaMediaSelector extends LitElement { background-position: center; } .centered-image { - margin: 0 8px; + margin: 4px; background-size: contain; } .icon-holder { display: flex; justify-content: center; align-items: center; + width: 100%; + height: 100%; } `; } diff --git a/src/components/ha-selector/ha-selector-number.ts b/src/components/ha-selector/ha-selector-number.ts index 66fed152d9..f4aff5a7d5 100644 --- a/src/components/ha-selector/ha-selector-number.ts +++ b/src/components/ha-selector/ha-selector-number.ts @@ -23,6 +23,9 @@ export class HaNumberSelector extends LitElement { @property() public helper?: string; + @property({ attribute: false }) + public localizeValue?: (key: string) => string; + @property({ type: Boolean }) public required = true; @property({ type: Boolean }) public disabled = false; @@ -60,6 +63,14 @@ export class HaNumberSelector extends LitElement { } } + const translationKey = this.selector.number?.translation_key; + let unit = this.selector.number?.unit_of_measurement; + if (isBox && unit && this.localizeValue && translationKey) { + unit = + this.localizeValue(`${translationKey}.unit_of_measurement.${unit}`) || + unit; + } + return html` ${this.label && !isBox ? html`${this.label}${this.required ? "*" : ""}` @@ -97,7 +108,7 @@ export class HaNumberSelector extends LitElement { .helper=${isBox ? this.helper : undefined} .disabled=${this.disabled} .required=${this.required} - .suffix=${this.selector.number?.unit_of_measurement} + .suffix=${unit} type="number" autoValidate ?no-spinner=${!isBox} diff --git a/src/components/ha-selector/ha-selector-object.ts b/src/components/ha-selector/ha-selector-object.ts index bca45148b3..626699c4fe 100644 --- a/src/components/ha-selector/ha-selector-object.ts +++ b/src/components/ha-selector/ha-selector-object.ts @@ -122,11 +122,7 @@ export class HaObjectSelector extends LitElement { } protected render() { - if (!this.selector.object) { - return nothing; - } - - if (this.selector.object.fields) { + if (this.selector.object?.fields) { if (this.selector.object.multiple) { const items = ensureArray(this.value ?? []); return html` diff --git a/src/components/ha-service-control.ts b/src/components/ha-service-control.ts index a31382ac2f..898959277d 100644 --- a/src/components/ha-service-control.ts +++ b/src/components/ha-service-control.ts @@ -276,6 +276,16 @@ export class HaServiceControl extends LitElement { private _getTargetedEntities = memoizeOne((target, value) => { const targetSelector = target ? { target } : { target: {} }; + if ( + hasTemplate(value?.target) || + hasTemplate(value?.data?.entity_id) || + hasTemplate(value?.data?.device_id) || + hasTemplate(value?.data?.area_id) || + hasTemplate(value?.data?.floor_id) || + hasTemplate(value?.data?.label_id) + ) { + return null; + } const targetEntities = ensureArray( value?.target?.entity_id || value?.data?.entity_id @@ -349,8 +359,11 @@ export class HaServiceControl extends LitElement { private _filterField( filter: ExtHassService["fields"][number]["filter"], - targetEntities: string[] + targetEntities: string[] | null ) { + if (targetEntities === null) { + return true; // Target is a template, show all fields + } if (!targetEntities.length) { return false; } @@ -386,8 +399,21 @@ export class HaServiceControl extends LitElement { } private _targetSelector = memoizeOne( - (targetSelector: TargetSelector | null | undefined) => - targetSelector ? { target: { ...targetSelector } } : { target: {} } + (targetSelector: TargetSelector | null | undefined, value) => { + if (!value || (typeof value === "object" && !Object.keys(value).length)) { + delete this._stickySelector.target; + } else if (hasTemplate(value)) { + if (typeof value === "string") { + this._stickySelector.target = { template: null }; + } else { + this._stickySelector.target = { object: null }; + } + } + return ( + this._stickySelector.target ?? + (targetSelector ? { target: { ...targetSelector } } : { target: {} }) + ); + } ); protected render() { @@ -482,7 +508,8 @@ export class HaServiceControl extends LitElement { > @@ -588,7 +615,7 @@ export class HaServiceControl extends LitElement { hasOptional: boolean, domain: string | undefined, serviceName: string | undefined, - targetEntities: string[] + targetEntities: string[] | null ) => { if ( dataField.filter && @@ -822,6 +849,10 @@ export class HaServiceControl extends LitElement { private _targetChanged(ev: CustomEvent) { ev.stopPropagation(); + if (ev.detail.isValid === false) { + // Don't clear an object selector that returns invalid YAML + return; + } const newValue = ev.detail.value; if (this._value?.target === newValue) { return; diff --git a/src/components/ha-settings-row.ts b/src/components/ha-settings-row.ts index c7994e8d57..fa946d81f8 100644 --- a/src/components/ha-settings-row.ts +++ b/src/components/ha-settings-row.ts @@ -89,6 +89,7 @@ export class HaSettingsRow extends LitElement { display: var(--settings-row-content-display, flex); justify-content: flex-end; flex: 1; + min-width: 0; padding: 16px 0; } .content ::slotted(*) { diff --git a/src/components/ha-yaml-editor.ts b/src/components/ha-yaml-editor.ts index c2a95a4022..53a7e579c7 100644 --- a/src/components/ha-yaml-editor.ts +++ b/src/components/ha-yaml-editor.ts @@ -44,6 +44,9 @@ export class HaYamlEditor extends LitElement { @property({ attribute: "read-only", type: Boolean }) public readOnly = false; + @property({ type: Boolean, attribute: "disable-fullscreen" }) + public disableFullscreen = false; + @property({ type: Boolean }) public required = false; @property({ attribute: "copy-clipboard", type: Boolean }) @@ -110,6 +113,7 @@ export class HaYamlEditor extends LitElement { .hass=${this.hass} .value=${this._yaml} .readOnly=${this.readOnly} + .disableFullscreen=${this.disableFullscreen} mode="yaml" autocomplete-entities autocomplete-icons diff --git a/src/data/energy.ts b/src/data/energy.ts index 0ee60fde90..3510c92125 100644 --- a/src/data/energy.ts +++ b/src/data/energy.ts @@ -1114,12 +1114,16 @@ export const formatConsumptionShort = ( if (!consumption) { return `0 ${unit}`; } - const units = ["kWh", "MWh", "GWh", "TWh"]; + const units = ["Wh", "kWh", "MWh", "GWh", "TWh"]; let pickedUnit = unit; let val = consumption; let unitIndex = units.findIndex((u) => u === unit); if (unitIndex >= 0) { - while (val >= 1000 && unitIndex < units.length - 1) { + while (Math.abs(val) < 1 && unitIndex > 0) { + val *= 1000; + unitIndex--; + } + while (Math.abs(val) >= 1000 && unitIndex < units.length - 1) { val /= 1000; unitIndex++; } @@ -1127,7 +1131,8 @@ export const formatConsumptionShort = ( } return ( formatNumber(val, hass.locale, { - maximumFractionDigits: val < 10 ? 2 : val < 100 ? 1 : 0, + maximumFractionDigits: + Math.abs(val) < 10 ? 2 : Math.abs(val) < 100 ? 1 : 0, }) + " " + pickedUnit diff --git a/src/data/icons.ts b/src/data/icons.ts index 5d870aac2e..4c6699c038 100644 --- a/src/data/icons.ts +++ b/src/data/icons.ts @@ -504,14 +504,25 @@ export const serviceSectionIcon = async ( export const domainIcon = async ( hass: HomeAssistant, domain: string, - deviceClass?: string + deviceClass?: string, + state?: string ): Promise => { const entityComponentIcons = await getComponentIcons(hass, domain); if (entityComponentIcons) { const translations = (deviceClass && entityComponentIcons[deviceClass]) || entityComponentIcons._; - return translations?.default; + // First check for exact state match + if (state && translations.state?.[state]) { + return translations.state[state]; + } + // Then check for range-based icons if we have a numeric state + if (state !== undefined && translations.range && !isNaN(Number(state))) { + return getIconFromRange(Number(state), translations.range); + } + // Fallback to default icon + return translations.default; } + return undefined; }; diff --git a/src/data/integration_quality_scale.ts b/src/data/integration_quality_scale.ts index e49bbc835d..d4c06a0162 100644 --- a/src/data/integration_quality_scale.ts +++ b/src/data/integration_quality_scale.ts @@ -1,5 +1,4 @@ import { mdiContentSave, mdiMedal, mdiTrophy } from "@mdi/js"; -import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg"; import type { LocalizeKeys } from "../common/translations/localize"; /** @@ -26,11 +25,6 @@ export const QUALITY_SCALE_MAP: Record< translationKey: "ui.panel.config.integrations.config_entry.platinum_quality", }, - internal: { - icon: mdiHomeAssistant, - translationKey: - "ui.panel.config.integrations.config_entry.internal_integration", - }, legacy: { icon: mdiContentSave, translationKey: diff --git a/src/data/logbook.ts b/src/data/logbook.ts index d57e27fd26..21822d0916 100644 --- a/src/data/logbook.ts +++ b/src/data/logbook.ts @@ -114,9 +114,13 @@ const getLogbookDataFromServer = ( export const subscribeLogbook = ( hass: HomeAssistant, - callbackFunction: (message: LogbookStreamMessage) => void, + callbackFunction: ( + message: LogbookStreamMessage, + subscriptionId: number + ) => void, startDate: string, endDate: string, + subscriptionId: number, entityIds?: string[], deviceIds?: string[] ): Promise => { @@ -140,7 +144,7 @@ export const subscribeLogbook = ( params.device_ids = deviceIds; } return hass.connection.subscribeMessage( - (message) => callbackFunction(message), + (message) => callbackFunction(message, subscriptionId), params ); }; diff --git a/src/data/script.ts b/src/data/script.ts index 6128014dfc..c7ba8d6c79 100644 --- a/src/data/script.ts +++ b/src/data/script.ts @@ -14,6 +14,7 @@ import { literal, is, boolean, + refine, } from "superstruct"; import { arrayLiteralIncludes } from "../common/array/literal-includes"; import { navigate } from "../common/navigate"; @@ -49,13 +50,18 @@ export const targetStruct = object({ label_id: optional(union([string(), array(string())])), }); -export const serviceActionStruct: Describe = assign( +export const serviceActionStruct: Describe = assign( baseActionStruct, object({ action: optional(string()), service_template: optional(string()), entity_id: optional(string()), - target: optional(targetStruct), + target: optional( + union([ + targetStruct, + refine(string(), "has_template", (val) => hasTemplate(val)), + ]) + ), data: optional(object()), response_variable: optional(string()), metadata: optional(object()), @@ -132,6 +138,12 @@ export interface ServiceAction extends BaseAction { metadata?: Record; } +type ServiceActionWithTemplate = ServiceAction & { + target?: HassServiceTarget | string; +}; + +export type { ServiceActionWithTemplate }; + export interface DeviceAction extends BaseAction { type: string; device_id: string; diff --git a/src/data/selector.ts b/src/data/selector.ts index abac290475..dacd2c0c56 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -333,6 +333,7 @@ export interface NumberSelector { mode?: "box" | "slider"; unit_of_measurement?: string; slider_ticks?: boolean; + translation_key?: string; } | null; } diff --git a/src/dialogs/config-flow/dialog-data-entry-flow.ts b/src/dialogs/config-flow/dialog-data-entry-flow.ts index 298f5742e2..2a03334b9b 100644 --- a/src/dialogs/config-flow/dialog-data-entry-flow.ts +++ b/src/dialogs/config-flow/dialog-data-entry-flow.ts @@ -286,7 +286,7 @@ class DataEntryFlowDialog extends LitElement { scrimClickAction escapeKeyAction hideActions - .heading=${dialogTitle} + .heading=${dialogTitle || true} > { - const step = await fetchConfigFlow(hass, flowId); - await hass.loadFragmentTranslation("config"); - await hass.loadBackendTranslation("config", step.handler); - await hass.loadBackendTranslation("selector", step.handler); + const [step] = await Promise.all([ + fetchConfigFlow(hass, flowId), + hass.loadFragmentTranslation("config"), + ]); + await Promise.all([ + hass.loadBackendTranslation("config", step.handler), + hass.loadBackendTranslation("selector", step.handler), + // Used as fallback if no header defined for step + hass.loadBackendTranslation("title", step.handler), + ]); return step; }, handleFlowStep: handleConfigFlowStep, diff --git a/src/panels/config/automation/action/types/ha-automation-action-play_media.ts b/src/panels/config/automation/action/types/ha-automation-action-play_media.ts index 9259d4ef62..0b77eba251 100644 --- a/src/panels/config/automation/action/types/ha-automation-action-play_media.ts +++ b/src/panels/config/automation/action/types/ha-automation-action-play_media.ts @@ -2,12 +2,19 @@ import { html, LitElement } from "lit"; import { customElement, property } from "lit/decorators"; import memoizeOne from "memoize-one"; import { fireEvent } from "../../../../../common/dom/fire_event"; -import "../../../../../components/ha-selector/ha-selector-media"; +import "../../../../../components/ha-selector/ha-selector"; import type { PlayMediaAction } from "../../../../../data/script"; -import type { MediaSelectorValue } from "../../../../../data/selector"; +import type { + MediaSelectorValue, + Selector, +} from "../../../../../data/selector"; import type { HomeAssistant } from "../../../../../types"; import type { ActionElement } from "../ha-automation-action-row"; +const MEDIA_SELECTOR_SCHEMA: Selector = { + media: {}, +}; + @customElement("ha-automation-action-play_media") export class HaPlayMediaAction extends LitElement implements ActionElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -38,12 +45,13 @@ export class HaPlayMediaAction extends LitElement implements ActionElement { protected render() { return html` - + > `; } diff --git a/src/panels/config/automation/action/types/ha-automation-action-service.ts b/src/panels/config/automation/action/types/ha-automation-action-service.ts index fa431c2f50..20f2da1c5a 100644 --- a/src/panels/config/automation/action/types/ha-automation-action-service.ts +++ b/src/panels/config/automation/action/types/ha-automation-action-service.ts @@ -42,7 +42,7 @@ export class HaServiceAction extends LitElement implements ActionElement { if ( this.action && Object.entries(this.action).some( - ([key, val]) => key !== "data" && hasTemplate(val) + ([key, val]) => !["data", "target"].includes(key) && hasTemplate(val) ) ) { fireEvent( diff --git a/src/panels/config/automation/ha-automation-editor.ts b/src/panels/config/automation/ha-automation-editor.ts index 09913aec3c..691fbb4e82 100644 --- a/src/panels/config/automation/ha-automation-editor.ts +++ b/src/panels/config/automation/ha-automation-editor.ts @@ -502,6 +502,7 @@ export class HaAutomationEditor extends PreventUnsavedMixin( .readOnly=${this._readOnly} @value-changed=${this._yamlChanged} .showErrors=${false} + disable-fullscreen >` : nothing}
diff --git a/src/panels/config/automation/trigger/ha-automation-trigger-row.ts b/src/panels/config/automation/trigger/ha-automation-trigger-row.ts index f9f4c28ec2..5cabb41266 100644 --- a/src/panels/config/automation/trigger/ha-automation-trigger-row.ts +++ b/src/panels/config/automation/trigger/ha-automation-trigger-row.ts @@ -566,6 +566,7 @@ export default class HaAutomationTriggerRow extends LitElement { text: html` diff --git a/src/panels/config/blueprint/blueprint-generic-editor.ts b/src/panels/config/blueprint/blueprint-generic-editor.ts index 97d6ad7442..844b6f5abb 100644 --- a/src/panels/config/blueprint/blueprint-generic-editor.ts +++ b/src/panels/config/blueprint/blueprint-generic-editor.ts @@ -173,6 +173,7 @@ export abstract class HaBlueprintGenericEditor extends LitElement { .content=${value?.description} > ${html` + ${this.hass.localize("panel.config")} + +
${this.hass.localize("ui.panel.config.updates.title", { count: this.total || this.updateEntities.length, })} @@ -115,7 +115,7 @@ class HaConfigUpdates extends SubscribeMixin(LitElement) { >` : nothing}
- ${deviceEntry ? computeDeviceNameDisplay(deviceEntry, this.hass) : entity.attributes.friendly_name}) { super.willUpdate(changedProps); - if (changedProps.has("deviceId") || changedProps.has("entries")) { + if (changedProps.has("deviceId")) { this._deviceActions = []; this._deviceAlerts = []; this._deleteButtons = []; this._diagnosticDownloadLinks = []; + } + + if (changedProps.has("deviceId") || changedProps.has("entries")) { this._fetchData(); } } - protected firstUpdated(changedProps) { + protected firstUpdated(changedProps: PropertyValues) { super.firstUpdated(changedProps); loadDeviceRegistryDetailDialog(); - this._fetchData(); } protected updated(changedProps) { @@ -989,6 +991,7 @@ export class HaConfigDevicePage extends LitElement { } private _getDeleteActions() { + const deviceId = this.deviceId; const device = this.hass.devices[this.deviceId]; if (!device) { @@ -1058,12 +1061,18 @@ export class HaConfigDevicePage extends LitElement { } ); + if (this.deviceId !== deviceId) { + // abort if the device has changed + return; + } + if (buttons.length > 0) { this._deleteButtons = buttons; } } private async _getDeviceActions() { + const deviceId = this.deviceId; const device = this.hass.devices[this.deviceId]; if (!device) { @@ -1157,14 +1166,25 @@ export class HaConfigDevicePage extends LitElement { // load matter device actions async to avoid an UI with 0 actions when the matter integration needs very long to get node diagnostics matter.getMatterDeviceActions(this, this.hass, device).then((actions) => { + if (this.deviceId !== deviceId) { + // abort if the device has changed + return; + } this._deviceActions = [...actions, ...(this._deviceActions || [])]; }); } + if (this.deviceId !== deviceId) { + // abort if the device has changed + return; + } + this._deviceActions = deviceActions; } private async _getDeviceAlerts() { + const deviceId = this.deviceId; + const device = this.hass.devices[this.deviceId]; if (!device) { @@ -1188,6 +1208,11 @@ export class HaConfigDevicePage extends LitElement { deviceAlerts.push(...alerts); } + if (this.deviceId !== deviceId) { + // abort if the device has changed + return; + } + this._deviceAlerts = deviceAlerts; if (deviceAlerts.length) { this._deviceAlertsActionsTimeout = window.setTimeout(() => { @@ -1317,9 +1342,13 @@ export class HaConfigDevicePage extends LitElement { // eslint-disable-next-line no-await-in-loop (await showConfirmationDialog(this, { title: this.hass.localize( - "ui.panel.config.devices.confirm_disable_config_entry", - { entry_name: config_entry.title } + "ui.panel.config.devices.confirm_disable_config_entry_title" ), + text: this.hass.localize( + "ui.panel.config.devices.confirm_disable_config_entry_message", + { name: config_entry.title } + ), + destructive: true, confirmText: this.hass.localize("ui.common.yes"), dismissText: this.hass.localize("ui.common.no"), })) diff --git a/src/panels/config/entities/ha-config-entities.ts b/src/panels/config/entities/ha-config-entities.ts index e1607485cb..106a6fb8f3 100644 --- a/src/panels/config/entities/ha-config-entities.ts +++ b/src/panels/config/entities/ha-config-entities.ts @@ -1099,10 +1099,10 @@ ${ } protected firstUpdated() { + this._setFiltersFromUrl(); fetchEntitySourcesWithCache(this.hass).then((sources) => { this._entitySources = sources; }); - this._setFiltersFromUrl(); if (Object.keys(this._filters).length) { return; } @@ -1116,7 +1116,7 @@ ${ const configEntry = this._searchParms.get("config_entry"); const subEntry = this._searchParms.get("sub_entry"); const device = this._searchParms.get("device"); - const label = this._searchParms.has("label"); + const label = this._searchParms.get("label"); if (!domain && !configEntry && !label && !device) { return; @@ -1128,21 +1128,10 @@ ${ "ha-filter-states": [], "ha-filter-integrations": domain ? [domain] : [], "ha-filter-devices": device ? [device] : [], + "ha-filter-labels": label ? [label] : [], config_entry: configEntry ? [configEntry] : [], sub_entry: subEntry ? [subEntry] : [], }; - this._filterLabel(); - } - - private _filterLabel() { - const label = this._searchParms.get("label"); - if (!label) { - return; - } - this._filters = { - ...this._filters, - "ha-filter-labels": [label], - }; } private _clearFilter() { @@ -1152,6 +1141,11 @@ ${ public willUpdate(changedProps: PropertyValues): void { super.willUpdate(changedProps); + + if (!this.hasUpdated) { + this._setFiltersFromUrl(); + } + const oldHass = changedProps.get("hass"); let changed = false; if (!this.hass || !this._entities) { diff --git a/src/panels/config/integrations/ha-config-entry-device-row.ts b/src/panels/config/integrations/ha-config-entry-device-row.ts index d122f3c67b..6783240f46 100644 --- a/src/panels/config/integrations/ha-config-entry-device-row.ts +++ b/src/panels/config/integrations/ha-config-entry-device-row.ts @@ -1,15 +1,24 @@ import { - mdiCogOutline, mdiDelete, + mdiDevices, mdiDotsVertical, + mdiPencil, + mdiShapeOutline, mdiStopCircleOutline, + mdiTransitConnectionVariant, } from "@mdi/js"; import { css, html, LitElement, nothing } from "lit"; import { customElement, property } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import { stopPropagation } from "../../../common/dom/stop_propagation"; import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name"; import { getDeviceContext } from "../../../common/entity/context/get_device_context"; import { navigate } from "../../../common/navigate"; -import type { ConfigEntry } from "../../../data/config_entries"; +import { + disableConfigEntry, + type ConfigEntry, + type DisableConfigEntryResult, +} from "../../../data/config_entries"; import { removeConfigEntryFromDevice, updateDeviceRegistryEntry, @@ -49,99 +58,118 @@ class HaConfigEntryDeviceRow extends LitElement { area ? area.name : undefined, ].filter(Boolean); - return html` + return html` +
${computeDeviceNameDisplay(device, this.hass)}
${supportingText.join(" • ")} ${supportingText.length && entities.length ? " • " : nothing} - ${ - entities.length - ? html`${this.hass.localize( - "ui.panel.config.integrations.config_entry.entities", - { count: entities.length } - )}` - : nothing - } - ` + : nothing} +
+ ${!this.narrow + ? html`` + : nothing} + + -
- -
- ${ - !this.narrow - ? html`` - : nothing - } - - - ${ - this.narrow - ? html` - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.device.configure" - )} - ` - : nothing - } - - - - ${ - device.disabled_by && device.disabled_by !== "user" - ? this.hass.localize( - "ui.dialogs.device-registry-detail.enabled_cause", - { - type: this.hass.localize( - `ui.dialogs.device-registry-detail.type.${ - device.entry_type || "device" - }` - ), - cause: this.hass.localize( - `config_entry.disabled_by.${device.disabled_by}` - ), - } - ) - : device.disabled_by - ? this.hass.localize( - "ui.panel.config.integrations.config_entry.device.enable" - ) - : this.hass.localize( - "ui.panel.config.integrations.config_entry.device.disable" - ) - } - - - - ${ - this.entry.supports_remove_device - ? html` + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.device.edit" + )} + ` + : nothing} + ${entities.length + ? html` + - + ${this.hass.localize( - "ui.panel.config.integrations.config_entry.device.delete" + `ui.panel.config.integrations.config_entry.entities`, + { count: entities.length } )} - ` - : nothing - } + + + ` + : nothing} + + + + ${device.disabled_by && device.disabled_by !== "user" + ? this.hass.localize( + "ui.dialogs.device-registry-detail.enabled_cause", + { + type: this.hass.localize( + `ui.dialogs.device-registry-detail.type.${ + device.entry_type || "device" + }` + ), + cause: this.hass.localize( + `config_entry.disabled_by.${device.disabled_by}` + ), + } + ) + : device.disabled_by + ? this.hass.localize( + "ui.panel.config.integrations.config_entry.device.enable" + ) + : this.hass.localize( + "ui.panel.config.integrations.config_entry.device.disable" + )} + + ${this.entry.supports_remove_device + ? html` + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.device.delete" + )} + ` + : nothing}
`; } @@ -149,7 +177,8 @@ class HaConfigEntryDeviceRow extends LitElement { private _getEntities = (): EntityRegistryEntry[] => this.entities?.filter((entity) => entity.device_id === this.device.id); - private _handleConfigureDevice() { + private _handleEditDevice(ev: MouseEvent) { + ev.stopPropagation(); // Prevent triggering the click handler on the list item showDeviceRegistryDetailDialog(this, { device: this.device, updateEntry: async (updates) => { @@ -159,8 +188,78 @@ class HaConfigEntryDeviceRow extends LitElement { } private async _handleDisableDevice() { + const disable = this.device.disabled_by === null; + + if (disable) { + if ( + !Object.values(this.hass.devices).some( + (dvc) => + dvc.id !== this.device.id && + dvc.config_entries.includes(this.entry.entry_id) + ) + ) { + const config_entry = this.entry; + if ( + config_entry && + !config_entry.disabled_by && + (await showConfirmationDialog(this, { + title: this.hass.localize( + "ui.panel.config.devices.confirm_disable_config_entry_title" + ), + text: this.hass.localize( + "ui.panel.config.devices.confirm_disable_config_entry_message", + { name: config_entry.title } + ), + destructive: true, + confirmText: this.hass.localize("ui.common.yes"), + dismissText: this.hass.localize("ui.common.no"), + })) + ) { + let result: DisableConfigEntryResult; + try { + result = await disableConfigEntry(this.hass, this.entry.entry_id); + } catch (err: any) { + showAlertDialog(this, { + title: this.hass.localize( + "ui.panel.config.integrations.config_entry.disable_error" + ), + text: err.message, + }); + return; + } + if (result.require_restart) { + showAlertDialog(this, { + text: this.hass.localize( + "ui.panel.config.integrations.config_entry.disable_restart_confirm" + ), + }); + } + return; + } + } + } + + if (disable) { + const confirm = await showConfirmationDialog(this, { + title: this.hass.localize( + "ui.panel.config.integrations.config_entry.device.confirm_disable_title" + ), + text: this.hass.localize( + "ui.panel.config.integrations.config_entry.device.confirm_disable_message", + { name: computeDeviceNameDisplay(this.device, this.hass) } + ), + destructive: true, + confirmText: this.hass.localize("ui.common.yes"), + dismissText: this.hass.localize("ui.common.no"), + }); + + if (!confirm) { + return; + } + } + await updateDeviceRegistryEntry(this.hass, this.device.id, { - disabled_by: this.device.disabled_by === "user" ? null : "user", + disabled_by: disable ? "user" : null, }); } @@ -203,6 +302,14 @@ class HaConfigEntryDeviceRow extends LitElement { } ha-md-list-item { --md-list-item-leading-space: 56px; + --md-ripple-hover-color: transparent; + --md-ripple-pressed-color: transparent; + } + .disabled { + opacity: 0.5; + } + :host([narrow]) ha-md-list-item { + --md-list-item-leading-space: 16px; } .vertical-divider { height: 100%; diff --git a/src/panels/config/integrations/ha-config-entry-row.ts b/src/panels/config/integrations/ha-config-entry-row.ts index 2d1c80a3fe..37b1020096 100644 --- a/src/panels/config/integrations/ha-config-entry-row.ts +++ b/src/panels/config/integrations/ha-config-entry-row.ts @@ -1,7 +1,6 @@ import { mdiAlertCircle, mdiChevronDown, - mdiChevronUp, mdiCogOutline, mdiDelete, mdiDevices, @@ -58,6 +57,7 @@ import { showConfigEntrySystemOptionsDialog } from "../../../dialogs/config-entr import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow"; import { showOptionsFlowDialog } from "../../../dialogs/config-flow/show-dialog-options-flow"; import { showSubConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-sub-config-flow"; +import { haStyle } from "../../../resources/styles"; import type { HomeAssistant } from "../../../types"; import { documentationUrl } from "../../../util/documentation-url"; import { fileDownload } from "../../../util/file_download"; @@ -69,7 +69,6 @@ import { import "./ha-config-entry-device-row"; import { renderConfigEntryError } from "./ha-config-integration-page"; import "./ha-config-sub-entry-row"; -import { haStyle } from "../../../resources/styles"; @customElement("ha-config-entry-row") class HaConfigEntryRow extends LitElement { @@ -155,7 +154,10 @@ class HaConfigEntryRow extends LitElement { statusLine.push( html`${entities.length} entities${this.hass.localize( + "ui.panel.config.integrations.config_entry.entities", + { count: entities.length } + )}` ); } @@ -178,8 +180,8 @@ class HaConfigEntryRow extends LitElement { > ${subEntries.length || ownDevices.length ? html`` @@ -405,47 +407,55 @@ class HaConfigEntryRow extends LitElement {
${this._expanded ? subEntries.length - ? html` - - + - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.devices_without_subentry" - )} - - ${this._devicesExpanded - ? ownDevices.map( - (device) => - html`` - ) - : nothing} - - ${subEntries.map( - (subEntry) => html` - - ` - )}` + + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.devices_without_subentry" + )} + + ${this._devicesExpanded + ? ownDevices.map( + (device) => + html`` + ) + : nothing} + ` + : nothing} + ${subEntries.map( + (subEntry) => html` + + ` + )}` : html` ${ownDevices.map( (device) => @@ -734,12 +744,20 @@ class HaConfigEntryRow extends LitElement { css` .expand-button { margin: 0 -12px; + transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1); + } + .expand-button.expanded { + transform: rotate(180deg); } ha-md-list { border: 1px solid var(--divider-color); border-radius: var(--ha-card-border-radius, 12px); padding: 0; } + :host([narrow]) { + margin-left: -12px; + margin-right: -12px; + } ha-md-list.devices { margin: 16px; margin-top: 0; @@ -750,6 +768,14 @@ class HaConfigEntryRow extends LitElement { var(--md-sys-color-on-surface-variant, #49454f) ); } + .toggle-devices-row { + overflow: hidden; + border-radius: var(--ha-card-border-radius, 12px); + } + .toggle-devices-row.expanded { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } `, ]; } diff --git a/src/panels/config/integrations/ha-config-integration-page.ts b/src/panels/config/integrations/ha-config-integration-page.ts index 148739aaaa..744b3fd181 100644 --- a/src/panels/config/integrations/ha-config-integration-page.ts +++ b/src/panels/config/integrations/ha-config-integration-page.ts @@ -380,6 +380,14 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {

${domainToName(this.hass.localize, this.domain)}

+ ${this._manifest?.version != null + ? html`${this.hass.localize( + "ui.panel.config.integrations.config_entry.version", + { version: this._manifest.version } + )}` + : nothing} ${this._manifest?.is_built_in === false ? html`
` : nothing} - ${this._logInfo?.level === LogSeverity.DEBUG - ? html`
- - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.debug_logging_enabled" - )} -
` - : nothing} ${this._manifest?.iot_class?.startsWith("cloud_") ? html`
@@ -432,7 +432,8 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { )}
` : nothing} - ${this._manifest?.quality_scale && + ${this._manifest?.is_built_in && + this._manifest.quality_scale && Object.keys(QUALITY_SCALE_MAP).includes( this._manifest.quality_scale ) @@ -539,6 +540,22 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
+ ${this._logInfo?.level === LogSeverity.DEBUG + ? html`
+ + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.debug_logging_enabled" + )} + + ${this.hass.localize("ui.common.disable")} + + +
` + : nothing} ${discoveryFlows.length ? html`
@@ -885,7 +902,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { } .title { display: flex; - gap: 8px; + gap: 4px; flex-direction: column; justify-content: space-between; } @@ -903,6 +920,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { display: flex; flex-wrap: wrap; gap: 8px 16px; + align-items: center; } .card-content { padding: 16px 0 8px; @@ -926,9 +944,6 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { width: 80px; } .version { - padding-top: 8px; - display: flex; - justify-content: center; color: var(--secondary-text-color); } .overview .card-actions { @@ -946,6 +961,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { } .actions { display: flex; + flex-wrap: wrap; gap: 8px; } .section { @@ -1000,9 +1016,6 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { ha-svg-icon.platinum-quality { color: #727272; } - ha-svg-icon.internal-quality { - color: var(--primary-color); - } ha-svg-icon.legacy-quality { color: var(--mdc-theme-text-icon-on-background, rgba(0, 0, 0, 0.38)); animation: unset; diff --git a/src/panels/config/integrations/ha-config-integrations-dashboard.ts b/src/panels/config/integrations/ha-config-integrations-dashboard.ts index 3cb99481cd..f3a8e1eb4a 100644 --- a/src/panels/config/integrations/ha-config-integrations-dashboard.ts +++ b/src/panels/config/integrations/ha-config-integrations-dashboard.ts @@ -406,11 +406,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin( ${!this._showDisabled && this.narrow && disabledConfigEntries.length ? html`${disabledConfigEntries.length}` : ""} - + ${devices.length || services.length ? html`` @@ -239,6 +239,10 @@ class HaConfigSubEntryRow extends LitElement { static styles = css` .expand-button { margin: 0 -12px; + transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1); + } + .expand-button.expanded { + transform: rotate(180deg); } ha-md-list { border: 1px solid var(--divider-color); diff --git a/src/panels/config/repairs/ha-config-repairs.ts b/src/panels/config/repairs/ha-config-repairs.ts index 46c3a50816..156d559ce9 100644 --- a/src/panels/config/repairs/ha-config-repairs.ts +++ b/src/panels/config/repairs/ha-config-repairs.ts @@ -41,7 +41,7 @@ class HaConfigRepairs extends LitElement { const issues = this.repairsIssues; return html` -
+
${this.hass.localize("ui.panel.config.repairs.title", { count: this.total || this.repairsIssues.length, })} diff --git a/src/panels/config/scene/ha-scene-editor.ts b/src/panels/config/scene/ha-scene-editor.ts index 66494fbf3e..f0d66cef4f 100644 --- a/src/panels/config/scene/ha-scene-editor.ts +++ b/src/panels/config/scene/ha-scene-editor.ts @@ -321,6 +321,7 @@ export class HaSceneEditor extends PreventUnsavedMixin( .defaultValue=${this._config} @value-changed=${this._yamlChanged} .showErrors=${false} + disable-fullscreen >`; } diff --git a/src/panels/config/script/ha-script-editor.ts b/src/panels/config/script/ha-script-editor.ts index 4eaffe3d45..af68900fde 100644 --- a/src/panels/config/script/ha-script-editor.ts +++ b/src/panels/config/script/ha-script-editor.ts @@ -440,6 +440,7 @@ export class HaScriptEditor extends SubscribeMixin( .hass=${this.hass} .defaultValue=${this._preprocessYaml()} .readOnly=${this._readOnly} + disable-fullscreen @value-changed=${this._yamlChanged} .showErrors=${false} >` diff --git a/src/panels/developer-tools/action/developer-tools-action.ts b/src/panels/developer-tools/action/developer-tools-action.ts index d964555e27..d28f89c714 100644 --- a/src/panels/developer-tools/action/developer-tools-action.ts +++ b/src/panels/developer-tools/action/developer-tools-action.ts @@ -535,7 +535,7 @@ class HaPanelDevAction extends LitElement { if ( this._serviceData && Object.entries(this._serviceData).some( - ([key, val]) => key !== "data" && hasTemplate(val) + ([key, val]) => !["data", "target"].includes(key) && hasTemplate(val) ) ) { this._yamlMode = true; diff --git a/src/panels/logbook/ha-logbook.ts b/src/panels/logbook/ha-logbook.ts index 2f404ed0aa..230e8fd905 100644 --- a/src/panels/logbook/ha-logbook.ts +++ b/src/panels/logbook/ha-logbook.ts @@ -88,6 +88,8 @@ export class HaLogbook extends LitElement { 1000 ); + private _logbookSubscriptionId = 0; + protected render() { if (!isComponentLoaded(this.hass, "logbook")) { return nothing; @@ -278,13 +280,20 @@ export class HaLogbook extends LitElement { } try { + this._logbookSubscriptionId++; + this._unsubLogbook = subscribeLogbook( this.hass, - (streamMessage) => { + (streamMessage, subscriptionId) => { + if (subscriptionId !== this._logbookSubscriptionId) { + // Ignore messages from previous subscriptions + return; + } this._processOrQueueStreamMessage(streamMessage); }, logbookPeriod.startTime.toISOString(), logbookPeriod.endTime.toISOString(), + this._logbookSubscriptionId, this.entityIds, this.deviceIds ); diff --git a/src/panels/lovelace/card-features/hui-area-controls-card-feature.ts b/src/panels/lovelace/card-features/hui-area-controls-card-feature.ts index 353be677b9..77fbbd50d0 100644 --- a/src/panels/lovelace/card-features/hui-area-controls-card-feature.ts +++ b/src/panels/lovelace/card-features/hui-area-controls-card-feature.ts @@ -1,59 +1,81 @@ -import { mdiFan, mdiLightbulb, mdiToggleSwitch } from "@mdi/js"; -import { callService, type HassEntity } from "home-assistant-js-websocket"; -import { LitElement, css, html, nothing } from "lit"; +import type { HassEntity } from "home-assistant-js-websocket"; +import { css, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import { styleMap } from "lit/directives/style-map"; import memoizeOne from "memoize-one"; +import { ensureArray } from "../../../common/array/ensure-array"; +import { generateEntityFilter } from "../../../common/entity/entity_filter"; import { - generateEntityFilter, - type EntityFilter, -} from "../../../common/entity/entity_filter"; + computeGroupEntitiesState, + toggleGroupEntities, +} from "../../../common/entity/group_entities"; import { stateActive } from "../../../common/entity/state_active"; +import { domainColorProperties } from "../../../common/entity/state_color"; import "../../../components/ha-control-button"; import "../../../components/ha-control-button-group"; +import "../../../components/ha-domain-icon"; import "../../../components/ha-svg-icon"; import type { AreaRegistryEntry } from "../../../data/area_registry"; +import { forwardHaptic } from "../../../data/haptics"; +import { computeCssVariable } from "../../../resources/css-variables"; import type { HomeAssistant } from "../../../types"; +import type { AreaCardFeatureContext } from "../cards/hui-area-card"; import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types"; import { cardFeatureStyles } from "./common/card-feature-styles"; import type { AreaControl, AreaControlsCardFeatureConfig, LovelaceCardFeatureContext, + LovelaceCardFeaturePosition, } from "./types"; import { AREA_CONTROLS } from "./types"; interface AreaControlsButton { - iconPath: string; - onService: string; - offService: string; - filter: EntityFilter; + offIcon?: string; + onIcon?: string; + filter: { + domain: string; + device_class?: string; + }; } +const coverButton = (deviceClass: string) => ({ + filter: { + domain: "cover", + device_class: deviceClass, + }, +}); + export const AREA_CONTROLS_BUTTONS: Record = { light: { - iconPath: mdiLightbulb, + // Overrides the icons for lights + offIcon: "mdi:lightbulb-off", + onIcon: "mdi:lightbulb", filter: { domain: "light", }, - onService: "light.turn_on", - offService: "light.turn_off", }, fan: { - iconPath: mdiFan, filter: { domain: "fan", }, - onService: "fan.turn_on", - offService: "fan.turn_off", }, switch: { - iconPath: mdiToggleSwitch, filter: { domain: "switch", }, - onService: "switch.turn_on", - offService: "switch.turn_off", }, + "cover-blind": coverButton("blind"), + "cover-curtain": coverButton("curtain"), + "cover-damper": coverButton("damper"), + "cover-awning": coverButton("awning"), + "cover-door": coverButton("door"), + "cover-garage": coverButton("garage"), + "cover-gate": coverButton("gate"), + "cover-shade": coverButton("shade"), + "cover-shutter": coverButton("shutter"), + "cover-window": coverButton("window"), }; export const supportsAreaControlsCardFeature = ( @@ -67,8 +89,8 @@ export const supportsAreaControlsCardFeature = ( export const getAreaControlEntities = ( controls: AreaControl[], areaId: string, - hass: HomeAssistant, - excludeEntities: string[] = [] + excludeEntities: string[] | undefined, + hass: HomeAssistant ): Record => controls.reduce( (acc, control) => { @@ -80,13 +102,15 @@ export const getAreaControlEntities = ( }); acc[control] = Object.keys(hass.entities).filter( - (entityId) => filter(entityId) && !excludeEntities.includes(entityId) + (entityId) => filter(entityId) && !excludeEntities?.includes(entityId) ); return acc; }, {} as Record ); +export const MAX_DEFAULT_AREA_CONTROLS = 4; + @customElement("hui-area-controls-card-feature") class HuiAreaControlsCardFeature extends LitElement @@ -94,7 +118,10 @@ class HuiAreaControlsCardFeature { @property({ attribute: false }) public hass?: HomeAssistant; - @property({ attribute: false }) public context?: LovelaceCardFeatureContext; + @property({ attribute: false }) public context?: AreaCardFeatureContext; + + @property({ attribute: false }) + public position?: LovelaceCardFeaturePosition; @state() private _config?: AreaControlsCardFeatureConfig; @@ -144,24 +171,19 @@ class HuiAreaControlsCardFeature const controlEntities = this._controlEntities( this._controls, this.context.area_id, - this._config.exclude_entities, + this.context.exclude_entities, this.hass!.entities, this.hass!.devices, this.hass!.areas ); const entitiesIds = controlEntities[control]; - const { onService, offService } = AREA_CONTROLS_BUTTONS[control]; + const entities = entitiesIds + .map((entityId) => this.hass!.states[entityId] as HassEntity | undefined) + .filter((v): v is HassEntity => Boolean(v)); - const isOn = entitiesIds.some((entityId) => - stateActive(this.hass!.states[entityId] as HassEntity) - ); - - const [domain, service] = (isOn ? offService : onService).split("."); - - callService(this.hass!.connection, domain, service, { - entity_id: entitiesIds, - }); + forwardHaptic("light"); + toggleGroupEntities(this.hass, entities); } private _controlEntities = memoizeOne( @@ -173,7 +195,7 @@ class HuiAreaControlsCardFeature _entities: HomeAssistant["entities"], _devices: HomeAssistant["devices"], _areas: HomeAssistant["areas"] - ) => getAreaControlEntities(controls, areaId, this.hass!, excludeEntities) + ) => getAreaControlEntities(controls, areaId, excludeEntities, this.hass!) ); protected render() { @@ -190,7 +212,7 @@ class HuiAreaControlsCardFeature const controlEntities = this._controlEntities( this._controls, this.context.area_id!, - this._config.exclude_entities, + this.context.exclude_entities, this.hass!.entities, this.hass!.devices, this.hass!.areas @@ -200,33 +222,71 @@ class HuiAreaControlsCardFeature (control) => controlEntities[control].length > 0 ); - if (!supportedControls.length) { + const displayControls = this._config.controls + ? supportedControls + : supportedControls.slice(0, MAX_DEFAULT_AREA_CONTROLS); // Limit to max if using default controls + + if (!displayControls.length) { return nothing; } return html` - - ${supportedControls.map((control) => { + + ${displayControls.map((control) => { const button = AREA_CONTROLS_BUTTONS[control]; - const entities = controlEntities[control]; - const active = entities.some((entityId) => { - const stateObj = this.hass!.states[entityId] as - | HassEntity - | undefined; - if (!stateObj) { - return false; - } - return stateActive(stateObj); - }); + const entityIds = controlEntities[control]; + + const entities = entityIds + .map( + (entityId) => + this.hass!.states[entityId] as HassEntity | undefined + ) + .filter((v): v is HassEntity => Boolean(v)); + + const groupState = computeGroupEntitiesState(entities); + + const active = entities[0] + ? stateActive(entities[0], groupState) + : false; + + const label = this.hass!.localize( + `ui.card_features.area_controls.${control}.${active ? "off" : "on"}` + ); + + const icon = active ? button.onIcon : button.offIcon; + + const domain = button.filter.domain; + const deviceClass = button.filter.device_class + ? ensureArray(button.filter.device_class)[0] + : undefined; + + const activeColor = computeCssVariable( + domainColorProperties(domain, deviceClass, groupState, true) + ); return html` - + `; })} @@ -238,6 +298,23 @@ class HuiAreaControlsCardFeature return [ cardFeatureStyles, css` + :host { + pointer-events: none !important; + display: flex; + flex-direction: row; + justify-content: flex-end; + } + ha-control-button-group { + pointer-events: auto; + width: 100%; + } + ha-control-button-group.no-stretch { + width: auto; + max-width: 100%; + } + ha-control-button-group.no-stretch > ha-control-button { + width: 48px; + } ha-control-button { --active-color: var(--state-active-color); --control-button-focus-color: var(--state-active-color); diff --git a/src/panels/lovelace/card-features/hui-card-feature.ts b/src/panels/lovelace/card-features/hui-card-feature.ts index 12592b4fba..9dae99634c 100644 --- a/src/panels/lovelace/card-features/hui-card-feature.ts +++ b/src/panels/lovelace/card-features/hui-card-feature.ts @@ -1,4 +1,4 @@ -import { LitElement, html, nothing } from "lit"; +import { LitElement, css, html, nothing } from "lit"; import { customElement, property } from "lit/decorators"; import type { HomeAssistant } from "../../../types"; import type { HuiErrorCard } from "../cards/hui-error-card"; @@ -7,6 +7,7 @@ import type { LovelaceCardFeature } from "../types"; import type { LovelaceCardFeatureConfig, LovelaceCardFeatureContext, + LovelaceCardFeaturePosition, } from "./types"; @customElement("hui-card-feature") @@ -19,6 +20,9 @@ export class HuiCardFeature extends LitElement { @property({ attribute: false }) public color?: string; + @property({ attribute: false }) + public position?: LovelaceCardFeaturePosition; + private _element?: LovelaceCardFeature | HuiErrorCard; private _getFeatureElement(feature: LovelaceCardFeatureConfig) { @@ -41,6 +45,7 @@ export class HuiCardFeature extends LitElement { element.hass = this.hass; element.context = this.context; element.color = this.color; + element.position = this.position; // Backwards compatibility from custom card features if (this.context.entity_id) { const stateObj = this.hass.states[this.context.entity_id]; @@ -51,6 +56,12 @@ export class HuiCardFeature extends LitElement { } return html`${element}`; } + + static styles = css` + :host > * { + pointer-events: auto; + } + `; } declare global { diff --git a/src/panels/lovelace/card-features/hui-card-features.ts b/src/panels/lovelace/card-features/hui-card-features.ts index b723c3f2a0..21227fe0fa 100644 --- a/src/panels/lovelace/card-features/hui-card-features.ts +++ b/src/panels/lovelace/card-features/hui-card-features.ts @@ -5,6 +5,7 @@ import "./hui-card-feature"; import type { LovelaceCardFeatureConfig, LovelaceCardFeatureContext, + LovelaceCardFeaturePosition, } from "./types"; @customElement("hui-card-features") @@ -17,6 +18,9 @@ export class HuiCardFeatures extends LitElement { @property({ attribute: false }) public color?: string; + @property({ attribute: false }) + public position?: LovelaceCardFeaturePosition; + protected render() { if (!this.features) { return nothing; @@ -29,6 +33,7 @@ export class HuiCardFeatures extends LitElement { .context=${this.context} .color=${this.color} .feature=${feature} + .position=${this.position} > ` )} @@ -41,6 +46,7 @@ export class HuiCardFeatures extends LitElement { --feature-height: 42px; --feature-border-radius: 12px; --feature-button-spacing: 12px; + pointer-events: none; position: relative; width: 100%; display: flex; diff --git a/src/panels/lovelace/card-features/types.ts b/src/panels/lovelace/card-features/types.ts index b486fa1c4d..9ded542fe3 100644 --- a/src/panels/lovelace/card-features/types.ts +++ b/src/panels/lovelace/card-features/types.ts @@ -158,16 +158,31 @@ export interface UpdateActionsCardFeatureConfig { backup?: "yes" | "no" | "ask"; } -export const AREA_CONTROLS = ["light", "fan", "switch"] as const; +export const AREA_CONTROLS = [ + "light", + "fan", + "cover-shutter", + "cover-blind", + "cover-curtain", + "cover-shade", + "cover-awning", + "cover-garage", + "cover-gate", + "cover-door", + "cover-window", + "cover-damper", + "switch", +] as const; export type AreaControl = (typeof AREA_CONTROLS)[number]; export interface AreaControlsCardFeatureConfig { type: "area-controls"; controls?: AreaControl[]; - exclude_entities?: string[]; } +export type LovelaceCardFeaturePosition = "bottom" | "inline"; + export type LovelaceCardFeatureConfig = | AlarmModesCardFeatureConfig | ClimateFanModesCardFeatureConfig diff --git a/src/panels/lovelace/cards/hui-area-card.ts b/src/panels/lovelace/cards/hui-area-card.ts index 3f87f13f65..68dce2164e 100644 --- a/src/panels/lovelace/cards/hui-area-card.ts +++ b/src/panels/lovelace/cards/hui-area-card.ts @@ -53,6 +53,10 @@ export const DEVICE_CLASSES = { binary_sensor: ["motion", "moisture"], }; +export interface AreaCardFeatureContext extends LovelaceCardFeatureContext { + exclude_entities?: string[]; +} + @customElement("hui-area-card") export class HuiAreaCard extends LitElement implements LovelaceCard { @property({ attribute: false }) public hass!: HomeAssistant; @@ -61,7 +65,7 @@ export class HuiAreaCard extends LitElement implements LovelaceCard { @state() private _config?: AreaCardConfig; - @state() private _featureContext: LovelaceCardFeatureContext = {}; + @state() private _featureContext: AreaCardFeatureContext = {}; private _ratio: { w: number; @@ -87,6 +91,7 @@ export class HuiAreaCard extends LitElement implements LovelaceCard { this._featureContext = { area_id: config.area, + exclude_entities: config.exclude_entities, }; } @@ -166,7 +171,8 @@ export class HuiAreaCard extends LitElement implements LovelaceCard { ( entities: HomeAssistant["entities"], areaId: string, - sensorClasses: string[] + sensorClasses: string[], + excludeEntities?: string[] ): Map => { const sensorFilter = generateEntityFilter(this.hass, { area: areaId, @@ -174,7 +180,10 @@ export class HuiAreaCard extends LitElement implements LovelaceCard { domain: "sensor", device_class: sensorClasses, }); - const entityIds = Object.keys(entities).filter(sensorFilter); + const entityIds = Object.keys(entities).filter( + (id) => sensorFilter(id) && !excludeEntities?.includes(id) + ); + return this._groupEntitiesByDeviceClass(entityIds); } ); @@ -183,7 +192,8 @@ export class HuiAreaCard extends LitElement implements LovelaceCard { ( entities: HomeAssistant["entities"], areaId: string, - binarySensorClasses: string[] + binarySensorClasses: string[], + excludeEntities?: string[] ): Map => { const binarySensorFilter = generateEntityFilter(this.hass, { area: areaId, @@ -191,7 +201,11 @@ export class HuiAreaCard extends LitElement implements LovelaceCard { domain: "binary_sensor", device_class: binarySensorClasses, }); - const entityIds = Object.keys(entities).filter(binarySensorFilter); + + const entityIds = Object.keys(entities).filter( + (id) => binarySensorFilter(id) && !excludeEntities?.includes(id) + ); + return this._groupEntitiesByDeviceClass(entityIds); } ); @@ -215,13 +229,15 @@ export class HuiAreaCard extends LitElement implements LovelaceCard { const areaId = this._config?.area; const area = areaId ? this.hass.areas[areaId] : undefined; const alertClasses = this._config?.alert_classes; + const excludeEntities = this._config?.exclude_entities; if (!area || !alertClasses) { return []; } const groupedEntities = this._groupedBinarySensorEntityIds( this.hass.entities, area.area_id, - alertClasses + alertClasses, + excludeEntities ); return ( @@ -286,6 +302,7 @@ export class HuiAreaCard extends LitElement implements LovelaceCard { const areaId = this._config?.area; const area = areaId ? this.hass.areas[areaId] : undefined; const sensorClasses = this._config?.sensor_classes; + const excludeEntities = this._config?.exclude_entities; if (!area || !sensorClasses) { return undefined; } @@ -293,7 +310,8 @@ export class HuiAreaCard extends LitElement implements LovelaceCard { const groupedEntities = this._groupedSensorEntityIds( this.hass.entities, area.area_id, - sensorClasses + sensorClasses, + excludeEntities ); const sensorStates = sensorClasses @@ -330,6 +348,14 @@ export class HuiAreaCard extends LitElement implements LovelaceCard { return undefined; } + // If only one entity, return its formatted state + if (entities.length === 1) { + const stateObj = entities[0]; + return isUnavailableState(stateObj.state) + ? "" + : this.hass.formatEntityState(stateObj); + } + // Use the first entity's unit_of_measurement for formatting const uom = entities.find( (entity) => entity.attributes.unit_of_measurement @@ -514,6 +540,7 @@ export class HuiAreaCard extends LitElement implements LovelaceCard { .context=${this._featureContext} .color=${this._config.color} .features=${features} + .position=${featurePosition} > ` : nothing} diff --git a/src/panels/lovelace/cards/hui-weather-forecast-card.ts b/src/panels/lovelace/cards/hui-weather-forecast-card.ts index 2140038978..7c2759102d 100644 --- a/src/panels/lovelace/cards/hui-weather-forecast-card.ts +++ b/src/panels/lovelace/cards/hui-weather-forecast-card.ts @@ -243,11 +243,11 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard { ); let itemsToShow = this._config?.forecast_slots ?? 5; - if (this._sizeController.value.width === "very-very-narrow") { + if (this._sizeController.value?.width === "very-very-narrow") { itemsToShow = Math.min(3, itemsToShow); - } else if (this._sizeController.value.width === "very-narrow") { + } else if (this._sizeController.value?.width === "very-narrow") { itemsToShow = Math.min(5, itemsToShow); - } else if (this._sizeController.value.width === "narrow") { + } else if (this._sizeController.value?.width === "narrow") { itemsToShow = Math.min(7, itemsToShow); } @@ -266,8 +266,12 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard { return html` { this._params = params; this._GUImode = true; @@ -93,9 +91,6 @@ export class HuiDialogEditCard } public closeDialog(): boolean { - this._isEscapeEnabled = true; - window.removeEventListener("dialog-closed", this._enableEscapeKeyClose); - window.removeEventListener("hass-more-info", this._disableEscapeKeyClose); if (this._dirty) { this._confirmCancel(); return false; @@ -124,16 +119,6 @@ export class HuiDialogEditCard } } - private _enableEscapeKeyClose = (ev: any) => { - if (ev.detail.dialog === "ha-more-info-dialog") { - this._isEscapeEnabled = true; - } - }; - - private _disableEscapeKeyClose = () => { - this._isEscapeEnabled = false; - }; - protected render() { if (!this._params || !this._cardConfig) { return nothing; @@ -170,7 +155,7 @@ export class HuiDialogEditCard ({ - area_id: areaId, - }) - ); + @state() private _featureContext: AreaCardFeatureContext = {}; private _schema = memoizeOne( ( @@ -174,7 +174,10 @@ export class HuiAreaCardEditor ); private _binaryClassesForArea = memoizeOne( - (area: string | undefined): string[] => { + ( + area: string | undefined, + excludeEntities: string[] | undefined + ): string[] => { if (!area) { return []; } @@ -186,7 +189,9 @@ export class HuiAreaCardEditor }); const classes = Object.keys(this.hass!.entities) - .filter(binarySensorFilter) + .filter( + (id) => binarySensorFilter(id) && !excludeEntities?.includes(id) + ) .map((id) => this.hass!.states[id]?.attributes.device_class) .filter((c): c is string => Boolean(c)); @@ -195,7 +200,11 @@ export class HuiAreaCardEditor ); private _sensorClassesForArea = memoizeOne( - (area: string | undefined, numericDeviceClasses?: string[]): string[] => { + ( + area: string | undefined, + excludeEntities: string[] | undefined, + numericDeviceClasses: string[] | undefined + ): string[] => { if (!area) { return []; } @@ -208,7 +217,7 @@ export class HuiAreaCardEditor }); const classes = Object.keys(this.hass!.entities) - .filter(sensorFilter) + .filter((id) => sensorFilter(id) && !excludeEntities?.includes(id)) .map((id) => this.hass!.states[id]?.attributes.device_class) .filter((c): c is string => Boolean(c)); @@ -257,6 +266,11 @@ export class HuiAreaCardEditor display_type: displayType, }; delete this._config.show_camera; + + this._featureContext = { + area_id: config.area, + exclude_entities: config.exclude_entities, + }; } protected async updated() { @@ -306,11 +320,13 @@ export class HuiAreaCardEditor return nothing; } - const areaId = this._config!.area; - - const possibleBinaryClasses = this._binaryClassesForArea(this._config.area); + const possibleBinaryClasses = this._binaryClassesForArea( + this._config.area, + this._config.exclude_entities + ); const possibleSensorClasses = this._sensorClassesForArea( this._config.area, + this._config.exclude_entities, this._numericDeviceClasses ); const binarySelectOptions = this._buildBinaryOptions( @@ -347,8 +363,9 @@ export class HuiAreaCardEditor ...this._config, }; - const featureContext = this._featureContext(areaId); - const hasCompatibleFeatures = this._hasCompatibleFeatures(featureContext); + const hasCompatibleFeatures = this._hasCompatibleFeatures( + this._featureContext + ); return html` ): void { const index = ev.detail.subElementConfig.index; const config = this._config!.features![index!]; - const featureContext = this._featureContext(this._config!.area); fireEvent(this, "edit-sub-element", { config: config, saveConfig: (newConfig) => this._updateFeature(index!, newConfig), - context: featureContext, + context: this._featureContext, type: "feature", } as EditSubElementEvent< LovelaceCardFeatureConfig, diff --git a/src/panels/lovelace/editor/config-elements/hui-area-controls-card-feature-editor.ts b/src/panels/lovelace/editor/config-elements/hui-area-controls-card-feature-editor.ts index ac79793497..18089a26b7 100644 --- a/src/panels/lovelace/editor/config-elements/hui-area-controls-card-feature-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-area-controls-card-feature-editor.ts @@ -9,13 +9,16 @@ import type { SchemaUnion, } from "../../../../components/ha-form/types"; import type { HomeAssistant } from "../../../../types"; -import { getAreaControlEntities } from "../../card-features/hui-area-controls-card-feature"; +import { + getAreaControlEntities, + MAX_DEFAULT_AREA_CONTROLS, +} from "../../card-features/hui-area-controls-card-feature"; import { AREA_CONTROLS, type AreaControl, type AreaControlsCardFeatureConfig, - type LovelaceCardFeatureContext, } from "../../card-features/types"; +import type { AreaCardFeatureContext } from "../../cards/hui-area-card"; import type { LovelaceCardFeatureEditor } from "../../types"; type AreaControlsCardFeatureData = AreaControlsCardFeatureConfig & { @@ -29,7 +32,7 @@ export class HuiAreaControlsCardFeatureEditor { @property({ attribute: false }) public hass?: HomeAssistant; - @property({ attribute: false }) public context?: LovelaceCardFeatureContext; + @property({ attribute: false }) public context?: AreaCardFeatureContext; @state() private _config?: AreaControlsCardFeatureConfig; @@ -72,9 +75,10 @@ export class HuiAreaControlsCardFeatureEditor ] as const satisfies readonly HaFormSchema[] ); - private _compatibleControls = memoizeOne( + private _supportedControls = memoizeOne( ( areaId: string, + excludeEntities: string[] | undefined, // needed to update memoized function when entities, devices or areas change _entities: HomeAssistant["entities"], _devices: HomeAssistant["devices"], @@ -86,6 +90,7 @@ export class HuiAreaControlsCardFeatureEditor const controlEntities = getAreaControlEntities( AREA_CONTROLS as unknown as AreaControl[], areaId, + excludeEntities, this.hass! ); return ( @@ -99,14 +104,15 @@ export class HuiAreaControlsCardFeatureEditor return nothing; } - const compatibleControls = this._compatibleControls( + const supportedControls = this._supportedControls( this.context.area_id, + this.context.exclude_entities, this.hass.entities, this.hass.devices, this.hass.areas ); - if (compatibleControls.length === 0) { + if (supportedControls.length === 0) { return html` ${this.hass.localize( @@ -124,7 +130,7 @@ export class HuiAreaControlsCardFeatureEditor const schema = this._schema( this.hass.localize, data.customize_controls, - compatibleControls + supportedControls ); return html` @@ -143,12 +149,13 @@ export class HuiAreaControlsCardFeatureEditor .value as AreaControlsCardFeatureData; if (customize_controls && !config.controls) { - config.controls = this._compatibleControls( + config.controls = this._supportedControls( this.context!.area_id!, + this.context!.exclude_entities, this.hass!.entities, this.hass!.devices, this.hass!.areas - ).concat(); + ).slice(0, MAX_DEFAULT_AREA_CONTROLS); // Limit to max default controls } if (!customize_controls && config.controls) { diff --git a/src/panels/lovelace/editor/dashboard-strategy-editor/dialogs/dialog-dashboard-strategy-editor.ts b/src/panels/lovelace/editor/dashboard-strategy-editor/dialogs/dialog-dashboard-strategy-editor.ts index b9d5a40aed..c57efcc38e 100644 --- a/src/panels/lovelace/editor/dashboard-strategy-editor/dialogs/dialog-dashboard-strategy-editor.ts +++ b/src/panels/lovelace/editor/dashboard-strategy-editor/dialogs/dialog-dashboard-strategy-editor.ts @@ -144,6 +144,9 @@ class DialogDashboardStrategyEditor extends LitElement { .path=${mdiClose} > ${title} + ${this._params.title + ? html`${this._params.title}` + : nothing} void; takeControl: () => void; deleteDashboard: () => Promise; diff --git a/src/panels/lovelace/hui-editor.ts b/src/panels/lovelace/hui-editor.ts index a7fd0c0216..1a08e2c8a4 100644 --- a/src/panels/lovelace/hui-editor.ts +++ b/src/panels/lovelace/hui-editor.ts @@ -89,6 +89,7 @@ class LovelaceFullConfigEditor extends LitElement { .hass=${this.hass} @value-changed=${this._yamlChanged} @editor-save=${this._handleSave} + disable-fullscreen dir="ltr" > diff --git a/src/panels/lovelace/hui-root.ts b/src/panels/lovelace/hui-root.ts index 6f3d727d4d..1a6f09f7a7 100644 --- a/src/panels/lovelace/hui-root.ts +++ b/src/panels/lovelace/hui-root.ts @@ -782,6 +782,7 @@ class HUIRoot extends LitElement { showDashboardStrategyEditorDialog(this, { config: this.lovelace!.rawConfig, + title: this.panel ? getPanelTitle(this.hass, this.panel) : undefined, saveConfig: this.lovelace!.saveConfig, takeControl: () => { showSaveDialog(this, { diff --git a/src/panels/lovelace/strategies/areas/areas-dashboard-strategy.ts b/src/panels/lovelace/strategies/areas/areas-dashboard-strategy.ts index 3b882837bf..6154719ec6 100644 --- a/src/panels/lovelace/strategies/areas/areas-dashboard-strategy.ts +++ b/src/panels/lovelace/strategies/areas/areas-dashboard-strategy.ts @@ -22,6 +22,9 @@ export interface AreasDashboardStrategyConfig { hidden?: string[]; order?: string[]; }; + floors_display?: { + order?: string[]; + }; areas_options?: Record; } @@ -78,13 +81,13 @@ export class AreasDashboardStrategy extends ReactiveElement { return { views: [ { - title: "Home", icon: "mdi:home", path: "home", strategy: { type: "areas-overview", areas_display: config.areas_display, areas_options: config.areas_options, + floors_display: config.floors_display, } satisfies AreasViewStrategyConfig, }, ...areaViews, diff --git a/src/panels/lovelace/strategies/areas/areas-overview-view-strategy.ts b/src/panels/lovelace/strategies/areas/areas-overview-view-strategy.ts index 4fdd45c075..9d2d75e2d2 100644 --- a/src/panels/lovelace/strategies/areas/areas-overview-view-strategy.ts +++ b/src/panels/lovelace/strategies/areas/areas-overview-view-strategy.ts @@ -1,15 +1,18 @@ import { ReactiveElement } from "lit"; import { customElement } from "lit/decorators"; -import { stringCompare } from "../../../../common/string/compare"; import { floorDefaultIcon } from "../../../../components/ha-floor-icon"; import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section"; import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view"; import type { HomeAssistant } from "../../../../types"; import { getAreaControlEntities } from "../../card-features/hui-area-controls-card-feature"; -import type { AreaControl } from "../../card-features/types"; +import { AREA_CONTROLS, type AreaControl } from "../../card-features/types"; import type { AreaCardConfig, HeadingCardConfig } from "../../cards/types"; import type { EntitiesDisplay } from "./area-view-strategy"; -import { computeAreaPath, getAreas } from "./helpers/areas-strategy-helper"; +import { + computeAreaPath, + getAreas, + getFloors, +} from "./helpers/areas-strategy-helper"; const UNASSIGNED_FLOOR = "__unassigned__"; @@ -23,6 +26,9 @@ export interface AreasViewStrategyConfig { hidden?: string[]; order?: string[]; }; + floors_display?: { + order?: string[]; + }; areas_options?: Record; } @@ -32,43 +38,35 @@ export class AreasOverviewViewStrategy extends ReactiveElement { config: AreasViewStrategyConfig, hass: HomeAssistant ): Promise { - const areas = getAreas( + const displayedAreas = getAreas( hass.areas, config.areas_display?.hidden, config.areas_display?.order ); - const floors = Object.values(hass.floors); - floors.sort((floorA, floorB) => { - if (floorA.level !== floorB.level) { - return (floorA.level ?? 0) - (floorB.level ?? 0); - } - return stringCompare(floorA.name, floorB.name); - }); + const floors = getFloors(hass.floors, config.floors_display?.order); const floorSections = [ ...floors, { floor_id: UNASSIGNED_FLOOR, - name: hass.localize( - "ui.panel.lovelace.strategy.areas.unassigned_areas" - ), + name: hass.localize("ui.panel.lovelace.strategy.areas.other_areas"), level: null, icon: null, }, ] - .map((floor) => { - const areasInFloors = areas.filter( + .map((floor) => { + const areasInFloors = displayedAreas.filter( (area) => area.floor_id === floor.floor_id || (!area.floor_id && floor.floor_id === UNASSIGNED_FLOOR) ); - if (areasInFloors.length === 0) { - return undefined; - } - - const areasCards = areasInFloors.map((area) => { + return [floor, areasInFloors] as const; + }) + .filter(([_, areas]) => areas.length) + .map(([floor, areas], _, array) => { + const areasCards = areas.map((area) => { const path = computeAreaPath(area.area_id); const areaOptions = config.areas_options?.[area.area_id] || {}; @@ -77,49 +75,62 @@ export class AreasOverviewViewStrategy extends ReactiveElement { .map((display) => display.hidden || []) .flat(); - const controls: AreaControl[] = ["light", "fan"]; + const controls: AreaControl[] = AREA_CONTROLS.filter( + (a) => a !== "switch" // Exclude switches control for areas as we don't know what the switches control + ); const controlEntities = getAreaControlEntities( controls, area.area_id, - hass, - hiddenEntities + hiddenEntities, + hass ); const filteredControls = controls.filter( (control) => controlEntities[control].length > 0 ); + const sensorClasses: string[] = []; + if (area.temperature_entity_id) { + sensorClasses.push("temperature"); + } + if (area.humidity_entity_id) { + sensorClasses.push("humidity"); + } + return { type: "area", area: area.area_id, display_type: "compact", - sensor_classes: ["temperature", "humidity"], - alert_classes: [ - "water_leak", - "smoke", - "gas", - "co", - "motion", - "occupancy", - "presence", - ], + sensor_classes: sensorClasses, + exclude_entities: hiddenEntities, features: filteredControls.length ? [ { type: "area-controls", controls: filteredControls, - exclude_entities: hiddenEntities, }, ] : [], + grid_options: { + rows: 1, + columns: 12, + }, + features_position: "inline", navigation_path: path, }; }); + const noFloors = + array.length === 1 && floor.floor_id === UNASSIGNED_FLOOR; + + const headingTitle = noFloors + ? hass.localize("ui.panel.lovelace.strategy.areas.areas") + : floor.name; + const headingCard: HeadingCardConfig = { type: "heading", heading_style: "title", - heading: floor.name, + heading: headingTitle, icon: floor.icon || floorDefaultIcon(floor), }; diff --git a/src/panels/lovelace/strategies/areas/editor/hui-areas-dashboard-strategy-editor.ts b/src/panels/lovelace/strategies/areas/editor/hui-areas-dashboard-strategy-editor.ts index 311641caa2..96448afc83 100644 --- a/src/panels/lovelace/strategies/areas/editor/hui-areas-dashboard-strategy-editor.ts +++ b/src/panels/lovelace/strategies/areas/editor/hui-areas-dashboard-strategy-editor.ts @@ -1,28 +1,32 @@ +import { mdiThermometerWater } from "@mdi/js"; 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 "../../../../../components/ha-areas-display-editor"; import type { AreasDisplayValue } from "../../../../../components/ha-areas-display-editor"; +import "../../../../../components/ha-areas-floors-display-editor"; +import type { AreasFloorsDisplayValue } from "../../../../../components/ha-areas-floors-display-editor"; import "../../../../../components/ha-entities-display-editor"; +import "../../../../../components/ha-icon"; import "../../../../../components/ha-icon-button"; import "../../../../../components/ha-icon-button-prev"; -import "../../../../../components/ha-icon"; +import "../../../../../components/ha-svg-icon"; +import { + updateAreaRegistryEntry, + type AreaRegistryEntry, +} from "../../../../../data/area_registry"; +import { buttonLinkStyle } from "../../../../../resources/styles"; import type { HomeAssistant } from "../../../../../types"; +import { showAreaRegistryDetailDialog } from "../../../../config/areas/show-dialog-area-registry-detail"; +import type { LovelaceStrategyEditor } from "../../types"; +import type { AreasDashboardStrategyConfig } from "../areas-dashboard-strategy"; import type { AreaStrategyGroup } from "../helpers/areas-strategy-helper"; import { AREA_STRATEGY_GROUP_ICONS, AREA_STRATEGY_GROUPS, getAreaGroupedEntities, } from "../helpers/areas-strategy-helper"; -import type { LovelaceStrategyEditor } from "../../types"; -import type { AreasDashboardStrategyConfig } from "../areas-dashboard-strategy"; -import { showAreaRegistryDetailDialog } from "../../../../config/areas/show-dialog-area-registry-detail"; -import { - updateAreaRegistryEntry, - type AreaRegistryEntry, -} from "../../../../../data/area_registry"; -import { buttonLinkStyle } from "../../../../../resources/styles"; -import "../../../../../components/ha-areas-floors-display-editor"; @customElement("hui-areas-dashboard-strategy-editor") export class HuiAreasDashboardStrategyEditor @@ -58,14 +62,18 @@ export class HuiAreasDashboardStrategyEditor
+

${this.hass!.localize( - `ui.panel.lovelace.strategy.areas.header_description`, + `ui.panel.lovelace.strategy.areas.sensors_description`, { edit_the_area: html`