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/gallery/src/pages/components/ha-selector.ts b/gallery/src/pages/components/ha-selector.ts index 0f50f6421f..4420b483ed 100644 --- a/gallery/src/pages/components/ha-selector.ts +++ b/gallery/src/pages/components/ha-selector.ts @@ -416,6 +416,34 @@ const SCHEMAS: { }, }, }, + items: { + name: "Items", + selector: { + object: { + label_field: "name", + description_field: "value", + multiple: true, + fields: { + name: { + label: "Name", + selector: { text: {} }, + required: true, + }, + value: { + label: "Value", + selector: { + number: { + mode: "slider", + min: 0, + max: 100, + unit_of_measurement: "%", + }, + }, + }, + }, + }, + }, + }, }, }, ]; diff --git a/package.json b/package.json index 0734945705..8e96f0682e 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.7.8", - "@vaadin/vaadin-themable-mixin": "24.7.8", + "@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", @@ -111,7 +111,7 @@ "fuse.js": "7.1.0", "google-timezones-json": "1.2.0", "gulp-zopfli-green": "6.0.2", - "hls.js": "1.6.5", + "hls.js": "1.6.6", "home-assistant-js-websocket": "9.5.0", "idb-keyval": "6.2.2", "intl-messageformat": "10.7.16", @@ -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,26 +149,25 @@ "xss": "1.0.15" }, "devDependencies": { - "@babel/core": "7.27.4", - "@babel/helper-define-polyfill-provider": "0.6.4", - "@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", + "@babel/core": "7.28.0", + "@babel/helper-define-polyfill-provider": "0.6.5", + "@babel/plugin-transform-runtime": "7.28.0", + "@babel/preset-env": "7.28.0", + "@bundle-stats/plugin-webpack-filter": "4.21.0", + "@lokalise/node-api": "14.9.1", "@octokit/auth-oauth-device": "8.0.1", "@octokit/plugin-retry": "8.0.1", "@octokit/rest": "22.0.0", - "@rsdoctor/rspack-plugin": "1.1.3", - "@rspack/cli": "1.3.12", - "@rspack/core": "1.3.12", + "@rsdoctor/rspack-plugin": "1.1.7", + "@rspack/cli": "1.4.3", + "@rspack/core": "1.4.3", "@types/babel__plugin-transform-runtime": "7.9.5", "@types/chromecast-caf-receiver": "6.0.22", "@types/chromecast-caf-sender": "1.0.11", "@types/color-name": "2.0.0", - "@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", @@ -179,18 +178,18 @@ "@types/tar": "6.1.13", "@types/ua-parser-js": "0.7.39", "@types/webspeechapi": "0.0.29", - "@vitest/coverage-v8": "3.2.3", + "@vitest/coverage-v8": "3.2.4", "babel-loader": "10.0.0", "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.1", "eslint-config-airbnb-base": "15.0.0", "eslint-config-prettier": "10.1.5", "eslint-import-resolver-webpack": "0.13.10", - "eslint-plugin-import": "2.31.0", + "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,18 +198,18 @@ "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", "jszip": "3.10.1", - "lint-staged": "16.1.0", + "lint-staged": "16.1.2", "lit-analyzer": "2.0.3", "lodash.merge": "4.6.2", "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,9 +217,9 @@ "terser-webpack-plugin": "5.3.14", "ts-lit-plugin": "2.0.2", "typescript": "5.8.3", - "typescript-eslint": "8.34.0", + "typescript-eslint": "8.35.1", "vite-tsconfig-paths": "5.1.4", - "vitest": "3.2.3", + "vitest": "3.2.4", "webpack-stats-plugin": "1.1.3", "webpackbar": "7.0.0", "workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch" @@ -231,8 +230,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/color/colors.ts b/src/common/color/colors.ts index aace900bbd..72aba65602 100644 --- a/src/common/color/colors.ts +++ b/src/common/color/colors.ts @@ -11,7 +11,6 @@ export const COLORS = [ "#9c6b4e", "#97bbf5", "#01ab63", - "#9498a0", "#094bad", "#c99000", "#d84f3e", @@ -21,7 +20,6 @@ export const COLORS = [ "#8043ce", "#7599d1", "#7a4c31", - "#74787f", "#6989f4", "#ffd444", "#ff957c", @@ -31,7 +29,6 @@ export const COLORS = [ "#c884ff", "#badeff", "#bf8b6d", - "#b6bac2", "#927acc", "#97ee3f", "#bf3947", @@ -44,7 +41,6 @@ export const COLORS = [ "#d9b100", "#9d7a00", "#698cff", - "#d9d9d9", "#00d27e", "#d06800", "#009f82", diff --git a/src/common/datetime/format_date.ts b/src/common/datetime/format_date.ts index 53b30f1deb..9b4b5bd510 100644 --- a/src/common/datetime/format_date.ts +++ b/src/common/datetime/format_date.ts @@ -77,7 +77,7 @@ export const formatDateNumeric = ( const month = parts.find((value) => value.type === "month")?.value; const year = parts.find((value) => value.type === "year")?.value; - const lastPart = parts.at(parts.length - 1); + const lastPart = parts[parts.length - 1]; let lastLiteral = lastPart?.type === "literal" ? lastPart?.value : ""; if (locale.language === "bg" && locale.date_format === DateFormat.YMD) { 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 65952c2922..8a8daa64df 100644 --- a/src/common/string/compare.ts +++ b/src/common/string/compare.ts @@ -1,12 +1,14 @@ 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) => { @@ -33,6 +35,19 @@ export const stringCompare = ( return fallbackStringCompare(a, b); }; +export const ipCompare = (a: string, b: string) => { + const aIsIpV4 = isIPAddress(a); + const bIsIpV4 = isIPAddress(b); + + if (aIsIpV4 && bIsIpV4) { + return ipv4Compare(a, b); + } + if (!aIsIpV4 && !bIsIpV4) { + return ipV6Compare(a, b); + } + return aIsIpV4 ? -1 : 1; +}; + export const caseInsensitiveStringCompare = ( a: string, b: string, @@ -64,3 +79,42 @@ export const orderCompare = (order: string[]) => (a: string, b: string) => { return idxA - idxB; }; + +function ipv4Compare(a: string, b: string) { + const num1 = Number( + a + .split(".") + .map((num) => num.padStart(3, "0")) + .join("") + ); + const num2 = Number( + b + .split(".") + .map((num) => num.padStart(3, "0")) + .join("") + ); + return num1 - num2; +} + +function ipV6Compare(a: string, b: string) { + const ipv6a = normalizeIPv6(a) + .split(":") + .map((part) => part.padStart(4, "0")) + .join(""); + const ipv6b = normalizeIPv6(b) + .split(":") + .map((part) => part.padStart(4, "0")) + .join(""); + + return ipv6a.localeCompare(ipv6b); +} + +function normalizeIPv6(ip) { + const parts = ip.split("::"); + const head = parts[0].split(":"); + const tail = parts[1] ? parts[1].split(":") : []; + const totalParts = 8; + const missing = totalParts - (head.length + tail.length); + const zeros = new Array(missing).fill("0"); + return [...head, ...zeros, ...tail].join(":"); +} diff --git a/src/common/style/derived-css-vars.ts b/src/common/style/derived-css-vars.ts index 65e6f21d06..3b2add7e1b 100644 --- a/src/common/style/derived-css-vars.ts +++ b/src/common/style/derived-css-vars.ts @@ -9,7 +9,9 @@ const _extractCssVars = ( cssString.split(";").forEach((rawLine) => { const line = rawLine.substring(rawLine.indexOf("--")).trim(); if (line.startsWith("--") && condition(line)) { - const [name, value] = line.split(":").map((part) => part.trim()); + const [name, value] = line + .split(":") + .map((part) => part.replaceAll("}", "").trim()); variables[name.substring(2, name.length)] = value; } }); @@ -25,7 +27,10 @@ export const extractVar = (css: CSSResult, varName: string) => { } const endIndex = cssString.indexOf(";", startIndex + search.length); - return cssString.substring(startIndex + search.length, endIndex).trim(); + return cssString + .substring(startIndex + search.length, endIndex) + .replaceAll("}", "") + .trim(); }; export const extractVars = (css: CSSResult) => { diff --git a/src/components/chart/ha-chart-base.ts b/src/components/chart/ha-chart-base.ts index 37a12f07e6..7a7fa68567 100644 --- a/src/components/chart/ha-chart-base.ts +++ b/src/components/chart/ha-chart-base.ts @@ -9,6 +9,7 @@ import type { LegendComponentOption, XAXisOption, YAXisOption, + LineSeriesOption, } from "echarts/types/dist/shared"; import type { PropertyValues } from "lit"; import { css, html, LitElement, nothing } from "lit"; @@ -49,6 +50,9 @@ export class HaChartBase extends LitElement { @property({ attribute: "expand-legend", type: Boolean }) public expandLegend?: boolean; + @property({ attribute: "small-controls", type: Boolean }) + public smallControls?: boolean; + // extraComponents is not reactive and should not trigger updates public extraComponents?: any[]; @@ -194,7 +198,7 @@ export class HaChartBase extends LitElement {
${this._renderLegend()} -
+
${this._isZoomed ? html` !this._hiddenDatasets.has(String(d.name ?? d.id))) - .map((s) => { - if (s.type === "line") { - if (yAxis?.type === "log") { - // set <=0 values to null so they render as gaps on a log graph - return { - ...s, - data: s.data?.map((v) => - Array.isArray(v) - ? [ - v[0], - typeof v[1] !== "number" || v[1] > 0 ? v[1] : null, - ...v.slice(2), - ] - : v - ), - }; - } - if (s.sampling === "minmax") { - const minX = - xAxis?.min && typeof xAxis.min === "number" - ? xAxis.min - : undefined; - const maxX = - xAxis?.max && typeof xAxis.max === "number" - ? xAxis.max - : undefined; - return { - ...s, - sampling: undefined, - data: downSampleLineData(s.data, this.clientWidth, minX, maxX), - }; - } + const series = ensureArray(this.data).map((s) => { + const data = this._hiddenDatasets.has(String(s.name ?? s.id)) + ? undefined + : s.data; + if (data && s.type === "line") { + if (yAxis?.type === "log") { + // set <=0 values to null so they render as gaps on a log graph + return { + ...s, + data: (data as LineSeriesOption["data"])!.map((v) => + Array.isArray(v) + ? [ + v[0], + typeof v[1] !== "number" || v[1] > 0 ? v[1] : null, + ...v.slice(2), + ] + : v + ), + }; } - return s; - }); - return series; + if (s.sampling === "minmax") { + const minX = + xAxis?.min && typeof xAxis.min === "number" ? xAxis.min : undefined; + const maxX = + xAxis?.max && typeof xAxis.max === "number" ? xAxis.max : undefined; + return { + ...s, + sampling: undefined, + data: downSampleLineData( + data as LineSeriesOption["data"], + this.clientWidth, + minX, + maxX + ), + }; + } + } + return { ...s, data }; + }); + return series as ECOption["series"]; } private _getDefaultHeight() { @@ -784,6 +791,10 @@ export class HaChartBase extends LitElement { flex-direction: column; gap: 4px; } + .chart-controls.small { + top: 0; + flex-direction: row; + } .chart-controls ha-icon-button, .chart-controls ::slotted(ha-icon-button) { background: var(--card-background-color); @@ -792,6 +803,11 @@ export class HaChartBase extends LitElement { color: var(--primary-color); border: 1px solid var(--divider-color); } + .chart-controls.small ha-icon-button, + .chart-controls.small ::slotted(ha-icon-button) { + --mdc-icon-button-size: 22px; + --mdc-icon-size: 16px; + } .chart-controls ha-icon-button.inactive, .chart-controls ::slotted(ha-icon-button.inactive) { color: var(--state-inactive-color); diff --git a/src/components/chart/state-history-chart-line.ts b/src/components/chart/state-history-chart-line.ts index f8f7689539..362d7ab579 100644 --- a/src/components/chart/state-history-chart-line.ts +++ b/src/components/chart/state-history-chart-line.ts @@ -226,22 +226,24 @@ export class StateHistoryChartLine extends LitElement { this.maxYAxis; if (typeof minYAxis === "number") { if (this.fitYData) { - minYAxis = ({ min }) => Math.min(min, this.minYAxis!); + minYAxis = ({ min }) => + Math.min(this._roundYAxis(min, Math.floor), this.minYAxis!); } } else if (this.logarithmicScale) { minYAxis = ({ min }) => { const value = min > 0 ? min * 0.95 : min * 1.05; - return Math.abs(value) < 1 ? value : Math.floor(value); + return this._roundYAxis(value, Math.floor); }; } if (typeof maxYAxis === "number") { if (this.fitYData) { - maxYAxis = ({ max }) => Math.max(max, this.maxYAxis!); + maxYAxis = ({ max }) => + Math.max(this._roundYAxis(max, Math.ceil), this.maxYAxis!); } } else if (this.logarithmicScale) { maxYAxis = ({ max }) => { const value = max > 0 ? max * 1.05 : max * 0.95; - return Math.abs(value) < 1 ? value : Math.ceil(value); + return this._roundYAxis(value, Math.ceil); }; } this._chartOptions = { @@ -729,20 +731,17 @@ export class StateHistoryChartLine extends LitElement { } private _formatYAxisLabel = (value: number) => { - const formatOptions = - value >= 1 || value <= -1 - ? undefined - : { - // show the first significant digit for tiny values - maximumFractionDigits: Math.max( - 2, - // use the difference to the previous value to determine the number of significant digits #25526 - -Math.floor( - Math.log10(Math.abs(value - this._previousYAxisLabelValue || 1)) - ) - ), - }; - const label = formatNumber(value, this.hass.locale, formatOptions); + // show the first significant digit for tiny values + const maximumFractionDigits = Math.max( + 1, + // use the difference to the previous value to determine the number of significant digits #25526 + -Math.floor( + Math.log10(Math.abs(value - this._previousYAxisLabelValue || 1)) + ) + ); + const label = formatNumber(value, this.hass.locale, { + maximumFractionDigits, + }); const width = measureTextWidth(label, 12) + 5; if (width > this._yWidth) { this._yWidth = width; @@ -767,6 +766,10 @@ export class StateHistoryChartLine extends LitElement { } return value; } + + private _roundYAxis(value: number, roundingFn: (value: number) => number) { + return Math.abs(value) < 1 ? value : roundingFn(value); + } } customElements.define("state-history-chart-line", StateHistoryChartLine); diff --git a/src/components/chart/state-history-chart-timeline.ts b/src/components/chart/state-history-chart-timeline.ts index 34e5e1deb6..93c3b0fccf 100644 --- a/src/components/chart/state-history-chart-timeline.ts +++ b/src/components/chart/state-history-chart-timeline.ts @@ -66,6 +66,7 @@ export class StateHistoryChartTimeline extends LitElement { .options=${this._chartOptions} .height=${`${this.data.length * 30 + 30}px`} .data=${this._chartData as ECOption["series"]} + small-controls @chart-click=${this._handleChartClick} > `; diff --git a/src/components/chart/statistics-chart.ts b/src/components/chart/statistics-chart.ts index a1b7e30dc9..dd60599bc2 100644 --- a/src/components/chart/statistics-chart.ts +++ b/src/components/chart/statistics-chart.ts @@ -238,22 +238,24 @@ export class StatisticsChart extends LitElement { this.maxYAxis; if (typeof minYAxis === "number") { if (this.fitYData) { - minYAxis = ({ min }) => Math.min(min, this.minYAxis!); + minYAxis = ({ min }) => + Math.min(this._roundYAxis(min, Math.floor), this.minYAxis!); } } else if (this.logarithmicScale) { minYAxis = ({ min }) => { const value = min > 0 ? min * 0.95 : min * 1.05; - return Math.abs(value) < 1 ? value : Math.floor(value); + return this._roundYAxis(value, Math.floor); }; } if (typeof maxYAxis === "number") { if (this.fitYData) { - maxYAxis = ({ max }) => Math.max(max, this.maxYAxis!); + maxYAxis = ({ max }) => + Math.max(this._roundYAxis(max, Math.ceil), this.maxYAxis!); } } else if (this.logarithmicScale) { maxYAxis = ({ max }) => { const value = max > 0 ? max * 1.05 : max * 0.95; - return Math.abs(value) < 1 ? value : Math.ceil(value); + return this._roundYAxis(value, Math.ceil); }; } const endTime = this.endTime ?? new Date(); @@ -634,6 +636,10 @@ export class StatisticsChart extends LitElement { return value; } + private _roundYAxis(value: number, roundingFn: (value: number) => number) { + return Math.abs(value) < 1 ? value : roundingFn(value); + } + static styles = css` :host { display: block; diff --git a/src/components/data-table/ha-data-table.ts b/src/components/data-table/ha-data-table.ts index b0cf4bab88..c625e9782d 100644 --- a/src/components/data-table/ha-data-table.ts +++ b/src/components/data-table/ha-data-table.ts @@ -72,6 +72,7 @@ export interface DataTableColumnData extends DataTableSortColumnData { label?: TemplateResult | string; type?: | "numeric" + | "ip" | "icon" | "icon-button" | "overflow" @@ -506,7 +507,9 @@ export class HaDataTable extends LitElement { this.hasFab, this.groupColumn, this.groupOrder, - this._collapsedGroups + this._collapsedGroups, + this.sortColumn, + this.sortDirection )} .keyFunction=${this._keyFunction} .renderItem=${renderRow} @@ -701,22 +704,37 @@ export class HaDataTable extends LitElement { hasFab: boolean, groupColumn: string | undefined, groupOrder: string[] | undefined, - collapsedGroups: string[] + collapsedGroups: string[], + sortColumn: string | undefined, + sortDirection: SortingDirection ) => { if (appendRow || hasFab || groupColumn) { let items = [...data]; if (groupColumn) { + const isGroupSortColumn = sortColumn === groupColumn; const grouped = groupBy(items, (item) => item[groupColumn]); if (grouped.undefined) { // make sure ungrouped items are at the bottom grouped[UNDEFINED_GROUP_KEY] = grouped.undefined; delete grouped.undefined; } - const sorted: Record = Object.keys( + const sortedEntries: [string, DataTableRowData[]][] = Object.keys( grouped ) .sort((a, b) => { + if (!groupOrder && isGroupSortColumn) { + const comparison = stringCompare( + a, + b, + this.hass.locale.language + ); + if (sortDirection === "asc") { + return comparison; + } + return comparison * -1; + } + const orderA = groupOrder?.indexOf(a) ?? -1; const orderB = groupOrder?.indexOf(b) ?? -1; if (orderA !== orderB) { @@ -734,12 +752,18 @@ export class HaDataTable extends LitElement { this.hass.locale.language ); }) - .reduce((obj, key) => { - obj[key] = grouped[key]; - return obj; - }, {}); + .reduce( + (entries, key) => { + const entry: [string, DataTableRowData[]] = [key, grouped[key]]; + + entries.push(entry); + return entries; + }, + [] as [string, DataTableRowData[]][] + ); + const groupedItems: DataTableRowData[] = []; - Object.entries(sorted).forEach(([groupName, rows]) => { + sortedEntries.forEach(([groupName, rows]) => { const collapsed = collapsedGroups.includes(groupName); groupedItems.push({ append: true, @@ -835,7 +859,9 @@ export class HaDataTable extends LitElement { this.hasFab, this.groupColumn, this.groupOrder, - this._collapsedGroups + this._collapsedGroups, + this.sortColumn, + this.sortDirection ); if ( diff --git a/src/components/data-table/sort-filter-worker.ts b/src/components/data-table/sort-filter-worker.ts index b34ec899ff..a4da225cbd 100644 --- a/src/components/data-table/sort-filter-worker.ts +++ b/src/components/data-table/sort-filter-worker.ts @@ -1,5 +1,5 @@ import { expose } from "comlink"; -import { stringCompare } from "../../common/string/compare"; +import { stringCompare, ipCompare } from "../../common/string/compare"; import { stripDiacritics } from "../../common/string/strip-diacritics"; import type { ClonedDataTableColumnData, @@ -57,6 +57,8 @@ const sortData = ( if (column.type === "numeric") { valA = isNaN(valA) ? undefined : Number(valA); valB = isNaN(valB) ? undefined : Number(valB); + } else if (column.type === "ip") { + return sort * ipCompare(valA, valB); } else if (typeof valA === "string" && typeof valB === "string") { return sort * stringCompare(valA, valB, language); } diff --git a/src/components/entity/ha-statistic-picker.ts b/src/components/entity/ha-statistic-picker.ts index 3d20ab7a43..24f96b0eb2 100644 --- a/src/components/entity/ha-statistic-picker.ts +++ b/src/components/entity/ha-statistic-picker.ts @@ -438,10 +438,8 @@ export class HaStatisticPicker extends LitElement { ` : nothing} ${item.primary} - ${item.secondary || item.type - ? html`${item.secondary} - ${item.type}` + ${item.secondary + ? html`${item.secondary}` : nothing} ${item.statistic_id && showEntityId ? html` diff --git a/src/components/entity/ha-statistics-picker.ts b/src/components/entity/ha-statistics-picker.ts index e259a222e9..96eab269ee 100644 --- a/src/components/entity/ha-statistics-picker.ts +++ b/src/components/entity/ha-statistics-picker.ts @@ -173,7 +173,6 @@ class HaStatisticsPicker extends LitElement { static styles = css` :host { - width: 200px; display: block; } ha-statistic-picker { 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 new file mode 100644 index 0000000000..539df7ab09 --- /dev/null +++ b/src/components/ha-areas-floors-display-editor.ts @@ -0,0 +1,282 @@ +import { mdiDrag, mdiTextureBox } from "@mdi/js"; +import type { TemplateResult } 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 { 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"; +import "./ha-items-display-editor"; +import type { DisplayItem, DisplayValue } from "./ha-items-display-editor"; +import "./ha-svg-icon"; +import "./ha-textfield"; + +export interface AreasFloorsDisplayValue { + areas_display?: { + hidden?: string[]; + order?: string[]; + }; + floors_display?: { + order?: string[]; + }; +} + +const UNASSIGNED_FLOOR = "__unassigned__"; + +@customElement("ha-areas-floors-display-editor") +export class HaAreasFloorsDisplayEditor extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public label?: string; + + @property({ attribute: false }) public value?: AreasFloorsDisplayValue; + + @property() public helper?: string; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean }) public required = false; + + @property({ type: Boolean, attribute: "show-navigation-button" }) + public showNavigationButton = false; + + protected render(): TemplateResult { + const groupedAreasItems = this._groupedAreasItems( + this.hass.areas, + this.hass.floors + ); + + const filteredFloors = this._sortedFloors( + this.hass.floors, + this.value?.floors_display?.order + ).filter( + (floor) => + // Only include floors that have areas assigned to them + groupedAreasItems[floor.floor_id]?.length > 0 + ); + + const value: DisplayValue = { + 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 ? html`` : nothing} + +
+ ${repeat( + filteredFloors, + (floor) => floor.floor_id, + (floor: FloorRegistryEntry) => html` + + + ${floor.floor_id === UNASSIGNED_FLOOR || !canReorderFloors + ? nothing + : html` + + `} + + + ` + )} +
+
+ `; + } + + private _groupedAreasItems = memoizeOne( + ( + hassAreas: HomeAssistant["areas"], + // update items if floors change + _hassFloors: HomeAssistant["floors"] + ): Record => { + const compare = areaCompare(hassAreas); + + const areas = Object.values(hassAreas).sort((areaA, areaB) => + compare(areaA.area_id, areaB.area_id) + ); + const groupedItems: Record = areas.reduce( + (acc, area) => { + const { floor } = getAreaContext(area, this.hass!); + const floorId = floor?.floor_id ?? UNASSIGNED_FLOOR; + + if (!acc[floorId]) { + acc[floorId] = []; + } + acc[floorId].push({ + value: area.area_id, + label: area.name, + icon: area.icon ?? undefined, + iconPath: mdiTextureBox, + }); + + return acc; + }, + {} as Record + ); + return groupedItems; + } + ); + + private _sortedFloors = memoizeOne( + ( + hassFloors: HomeAssistant["floors"], + order: string[] | undefined + ): FloorRegistryEntry[] => { + const floors = getFloors(hassFloors, order); + const noFloors = floors.length === 0; + floors.push({ + floor_id: UNASSIGNED_FLOOR, + name: noFloors + ? this.hass.localize("ui.panel.lovelace.strategy.areas.areas") + : this.hass.localize("ui.panel.lovelace.strategy.areas.other_areas"), + icon: null, + level: null, + aliases: [], + created_at: 0, + modified_at: 0, + }); + return floors; + } + ); + + private _floorMoved(ev: CustomEvent) { + ev.stopPropagation(); + 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 }); + } + + 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 ?? UNASSIGNED_FLOOR) === floorId) { + newHidden.push(...(value.hidden ?? [])); + newOrder.push(...(value.order ?? [])); + continue; + } + 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 = oldOrder.filter((areaId) => { + const id = this.hass.areas[areaId]?.floor_id ?? UNASSIGNED_FLOOR; + return id === floorId; + }); + if (order?.length) { + newOrder.push(...order); + } + } + + const newValue: AreasFloorsDisplayValue = { + areas_display: { + hidden: newHidden, + order: newOrder, + }, + floors_display: this.value?.floors_display, + }; + if (newValue.areas_display?.hidden?.length === 0) { + delete newValue.areas_display.hidden; + } + 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` + ha-expansion-panel { + margin-bottom: 8px; + --expansion-panel-summary-padding: 0 16px; + } + ha-expansion-panel [slot="leading-icon"] { + margin-inline-end: 16px; + } + label { + display: block; + font-weight: var(--ha-font-weight-bold); + margin-bottom: 8px; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-areas-floors-display-editor": HaAreasFloorsDisplayEditor; + } +} diff --git a/src/components/ha-aspect-ratio.ts b/src/components/ha-aspect-ratio.ts new file mode 100644 index 0000000000..67bc73c1e5 --- /dev/null +++ b/src/components/ha-aspect-ratio.ts @@ -0,0 +1,61 @@ +import { css, html, LitElement, type PropertyValues } from "lit"; +import { customElement, property } from "lit/decorators"; +import { styleMap } from "lit/directives/style-map"; +import parseAspectRatio from "../common/util/parse-aspect-ratio"; + +const DEFAULT_ASPECT_RATIO = "16:9"; + +@customElement("ha-aspect-ratio") +export class HaAspectRatio extends LitElement { + @property({ type: String, attribute: "aspect-ratio" }) + public aspectRatio?: string; + + private _ratio: { + w: number; + h: number; + } | null = null; + + public willUpdate(changedProps: PropertyValues) { + if (changedProps.has("aspect_ratio") || this._ratio === null) { + this._ratio = this.aspectRatio + ? parseAspectRatio(this.aspectRatio) + : null; + + if (this._ratio === null || this._ratio.w <= 0 || this._ratio.h <= 0) { + this._ratio = parseAspectRatio(DEFAULT_ASPECT_RATIO); + } + } + } + + protected render(): unknown { + if (!this.aspectRatio) { + return html``; + } + return html` +
+ +
+ `; + } + + static styles = css` + .ratio ::slotted(*) { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-aspect-ratio": HaAspectRatio; + } +} diff --git a/src/components/ha-base-time-input.ts b/src/components/ha-base-time-input.ts index eae4ccc306..674d36ca49 100644 --- a/src/components/ha-base-time-input.ts +++ b/src/components/ha-base-time-input.ts @@ -271,7 +271,9 @@ export class HaBaseTimeInput extends LitElement { `}
${this.helper - ? html`${this.helper}` + ? html`${this.helper}` : nothing} `; } diff --git a/src/components/ha-code-editor.ts b/src/components/ha-code-editor.ts index 17003c8e0b..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 []; @@ -257,6 +341,126 @@ export class HaCodeEditor extends ReactiveElement { private _entityCompletions( context: CompletionContext ): CompletionResult | null | Promise { + // Check for YAML mode and entity-related fields + if (this.mode === "yaml") { + const currentLine = context.state.doc.lineAt(context.pos); + const lineText = currentLine.text; + + // Properties that commonly contain entity IDs + const entityProperties = [ + "entity_id", + "entity", + "entities", + "badges", + "devices", + "lights", + "light", + "group_members", + "scene", + "zone", + "zones", + ]; + + // Create regex pattern for all entity properties + const propertyPattern = entityProperties.join("|"); + const entityFieldRegex = new RegExp( + `^\\s*(-\\s+)?(${propertyPattern}):\\s*` + ); + + // Check if we're in an entity field (single entity or list item) + const entityFieldMatch = lineText.match(entityFieldRegex); + const listItemMatch = lineText.match(/^\s*-\s+/); + + if (entityFieldMatch) { + // Calculate the position after the entity field + const afterField = currentLine.from + entityFieldMatch[0].length; + + // If cursor is after the entity field, show all entities + if (context.pos >= afterField) { + const states = this._getStates(this.hass!.states); + + if (!states || !states.length) { + return null; + } + + // Find what's already typed after the field + const typedText = context.state.sliceDoc(afterField, context.pos); + + // Filter states based on what's typed + const filteredStates = typedText + ? states.filter((entityState) => + entityState.label + .toLowerCase() + .startsWith(typedText.toLowerCase()) + ) + : states; + + return { + from: afterField, + options: filteredStates, + validFor: /^[a-z_]*\.?\w*$/, + }; + } + } else if (listItemMatch) { + // Check if this is a list item under an entity_id field + const lineNumber = currentLine.number; + + // Look at previous lines to check if we're under an entity_id field + for (let i = lineNumber - 1; i > 0 && i >= lineNumber - 10; i--) { + const prevLine = context.state.doc.line(i); + const prevText = prevLine.text; + + // Stop if we hit a non-indented line (new field) + if ( + prevText.trim() && + !prevText.startsWith(" ") && + !prevText.startsWith("\t") + ) { + break; + } + + // Check if we found an entity property field + const entityListFieldRegex = new RegExp( + `^\\s*(${propertyPattern}):\\s*$` + ); + if (prevText.match(entityListFieldRegex)) { + // We're in a list under an entity field + const afterListMarker = currentLine.from + listItemMatch[0].length; + + if (context.pos >= afterListMarker) { + const states = this._getStates(this.hass!.states); + + if (!states || !states.length) { + return null; + } + + // Find what's already typed after the list marker + const typedText = context.state.sliceDoc( + afterListMarker, + context.pos + ); + + // Filter states based on what's typed + const filteredStates = typedText + ? states.filter((entityState) => + entityState.label + .toLowerCase() + .startsWith(typedText.toLowerCase()) + ) + : states; + + return { + from: afterListMarker, + options: filteredStates, + validFor: /^[a-z_]*\.?\w*$/, + }; + } + } + } + } + } + + // Original entity completion logic for non-YAML or when not in entity_id field const entityWord = context.matchBefore(/[a-z_]{3,}\.\w*/); if ( @@ -340,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 ab5ecfe4c3..44e55c55d9 100644 --- a/src/components/ha-combo-box.ts +++ b/src/components/ha-combo-box.ts @@ -19,6 +19,7 @@ import type { HomeAssistant } from "../types"; import "./ha-combo-box-item"; import "./ha-combo-box-textfield"; import "./ha-icon-button"; +import "./ha-input-helper-text"; import "./ha-textfield"; import type { HaTextField } from "./ha-textfield"; @@ -195,8 +196,6 @@ export class HaComboBox extends LitElement { >
`} .icon=${this.icon} .invalid=${this.invalid} - .helper=${this.helper} - helperPersistent .disableSetValue=${this._disableSetValue} > @@ -222,9 +221,18 @@ export class HaComboBox extends LitElement { @click=${this._toggleOpen} > + ${this._renderHelper()} `; } + private _renderHelper() { + return this.helper + ? html`${this.helper}` + : ""; + } + private _defaultRowRenderer: ComboBoxLitRenderer< string | Record > = (item) => html` @@ -361,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%; @@ -398,6 +405,9 @@ export class HaComboBox extends LitElement { inset-inline-end: 36px; direction: var(--direction); } + ha-input-helper-text { + margin-top: 4px; + } `; } 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-floor-icon.ts b/src/components/ha-floor-icon.ts index 595f8add1a..a36b7ec391 100644 --- a/src/components/ha-floor-icon.ts +++ b/src/components/ha-floor-icon.ts @@ -30,6 +30,22 @@ export const floorDefaultIconPath = ( return mdiHome; }; +export const floorDefaultIcon = (floor: Pick) => { + switch (floor.level) { + case 0: + return "mdi:home-floor-0"; + case 1: + return "mdi:home-floor-1"; + case 2: + return "mdi:home-floor-2"; + case 3: + return "mdi:home-floor-3"; + case -1: + return "mdi:home-floor-negative-1"; + } + return "mdi:home"; +}; + @customElement("ha-floor-icon") export class HaFloorIcon extends LitElement { @property({ attribute: false }) public floor!: Pick< diff --git a/src/components/ha-form/ha-form-integer.ts b/src/components/ha-form/ha-form-integer.ts index 777056c184..9bcd93d3be 100644 --- a/src/components/ha-form/ha-form-integer.ts +++ b/src/components/ha-form/ha-form-integer.ts @@ -71,7 +71,9 @@ export class HaFormInteger extends LitElement implements HaFormElement { > ${this.helper - ? html`${this.helper}` + ? html`${this.helper}` : ""} `; 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-input-helper-text.ts b/src/components/ha-input-helper-text.ts index 6d817d1bf6..86ba8cb280 100644 --- a/src/components/ha-input-helper-text.ts +++ b/src/components/ha-input-helper-text.ts @@ -19,6 +19,11 @@ class InputHelperText extends LitElement { padding-right: 16px; padding-inline-start: 16px; padding-inline-end: 16px; + letter-spacing: var( + --mdc-typography-caption-letter-spacing, + 0.0333333333em + ); + line-height: normal; } :host([disabled]) { color: var(--mdc-text-field-disabled-ink-color, rgba(0, 0, 0, 0.6)); 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-labeled-slider.ts b/src/components/ha-labeled-slider.ts index 483d5aefd6..7c473fc5c1 100644 --- a/src/components/ha-labeled-slider.ts +++ b/src/components/ha-labeled-slider.ts @@ -47,7 +47,9 @@ class HaLabeledSlider extends LitElement { > ${this.helper - ? html` ${this.helper} ` + ? html` + ${this.helper} + ` : nothing} `; } 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-datetime.ts b/src/components/ha-selector/ha-selector-datetime.ts index 1c5e2bf8db..3f0c4f544d 100644 --- a/src/components/ha-selector/ha-selector-datetime.ts +++ b/src/components/ha-selector/ha-selector-datetime.ts @@ -54,7 +54,9 @@ export class HaDateTimeSelector extends LitElement { > ${this.helper - ? html`${this.helper}` + ? html`${this.helper}` : ""} `; } 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 11a55d001d..c4b3a25f11 100644 --- a/src/components/ha-selector/ha-selector-media.ts +++ b/src/components/ha-selector/ha-selector-media.ts @@ -1,6 +1,6 @@ import { mdiPlayBox, mdiPlus } from "@mdi/js"; import type { PropertyValues } from "lit"; -import { css, html, LitElement } from "lit"; +import { css, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import { fireEvent } from "../../common/dom/fire_event"; @@ -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,85 +88,104 @@ export class HaMediaSelector extends LitElement { (stateObj && supportsFeature(stateObj, MediaPlayerEntityFeature.BROWSE_MEDIA)); - return html` + const hasAccept = this.selector?.media?.accept?.length; + + return html` + ${hasAccept + ? nothing + : html` + + `} ${!supportsBrowse - ? html` + ? html` + ${this.hass.localize( "ui.components.selectors.media.browse_not_supported" )} ` - : html` -
- ${this.value?.metadata?.thumbnail - ? html` -
- ` - : html` -
- -
- `} -
-
- ${!this.value?.media_content_id + > + ` + : html` + - `}`; + @click=${this._pickMedia} + @keydown=${this._handleKeyDown} + class=${this.disabled || (!this.value?.entity_id && !hasAccept) + ? "disabled" + : ""} + > +
+
+ ${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} +
+
+ + `} + `; } private _computeLabelCallback = ( @@ -184,8 +207,9 @@ export class HaMediaSelector extends LitElement { private _pickMedia() { showMediaBrowserDialog(this, { action: "pick", - entityId: this.value!.entity_id!, - navigateIds: this.value!.metadata?.navigateIds, + entityId: this.value?.entity_id, + navigateIds: this.value?.metadata?.navigateIds, + accept: this.selector.media?.accept, mediaPickedCallback: (pickedMedia: MediaPickedEvent) => { fireEvent(this, "value-changed", { value: { @@ -208,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; @@ -222,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; @@ -269,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 3249f96aad..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} @@ -106,7 +117,9 @@ export class HaNumberSelector extends LitElement {
${!isBox && this.helper - ? html`${this.helper}` + ? html`${this.helper}` : nothing} `; } diff --git a/src/components/ha-selector/ha-selector-object.ts b/src/components/ha-selector/ha-selector-object.ts index e328112f35..626699c4fe 100644 --- a/src/components/ha-selector/ha-selector-object.ts +++ b/src/components/ha-selector/ha-selector-object.ts @@ -1,16 +1,27 @@ -import type { PropertyValues } from "lit"; -import { html, LitElement } from "lit"; +import { mdiClose, mdiDelete, mdiDrag, mdiPencil } from "@mdi/js"; +import { css, html, LitElement, nothing, type PropertyValues } from "lit"; import { customElement, property, query } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { ensureArray } from "../../common/array/ensure-array"; import { fireEvent } from "../../common/dom/fire_event"; +import type { ObjectSelector } from "../../data/selector"; +import { formatSelectorValue } from "../../data/selector/format_selector_value"; +import { showFormDialog } from "../../dialogs/form/show-form-dialog"; import type { HomeAssistant } from "../../types"; -import "../ha-yaml-editor"; +import type { HaFormSchema } from "../ha-form/types"; import "../ha-input-helper-text"; +import "../ha-md-list"; +import "../ha-md-list-item"; +import "../ha-sortable"; +import "../ha-yaml-editor"; import type { HaYamlEditor } from "../ha-yaml-editor"; @customElement("ha-selector-object") export class HaObjectSelector extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; + @property({ attribute: false }) public selector!: ObjectSelector; + @property() public value?: any; @property() public label?: string; @@ -23,11 +34,132 @@ export class HaObjectSelector extends LitElement { @property({ type: Boolean }) public required = true; - @query("ha-yaml-editor", true) private _yamlEditor!: HaYamlEditor; + @property({ attribute: false }) public localizeValue?: ( + key: string + ) => string; + + @query("ha-yaml-editor", true) private _yamlEditor?: HaYamlEditor; private _valueChangedFromChild = false; + private _computeLabel = (schema: HaFormSchema): string => { + const translationKey = this.selector.object?.translation_key; + + if (this.localizeValue && translationKey) { + const label = this.localizeValue( + `${translationKey}.fields.${schema.name}` + ); + if (label) { + return label; + } + } + return this.selector.object?.fields?.[schema.name]?.label || schema.name; + }; + + private _renderItem(item: any, index: number) { + const labelField = + this.selector.object!.label_field || + Object.keys(this.selector.object!.fields!)[0]; + + const labelSelector = this.selector.object!.fields![labelField].selector; + + const label = labelSelector + ? formatSelectorValue(this.hass, item[labelField], labelSelector) + : ""; + + let description = ""; + + const descriptionField = this.selector.object!.description_field; + if (descriptionField) { + const descriptionSelector = + this.selector.object!.fields![descriptionField].selector; + + description = descriptionSelector + ? formatSelectorValue( + this.hass, + item[descriptionField], + descriptionSelector + ) + : ""; + } + + const reorderable = this.selector.object!.multiple || false; + const multiple = this.selector.object!.multiple || false; + return html` + + ${reorderable + ? html` + + ` + : nothing} +
${label}
+ ${description + ? html`
+ ${description} +
` + : nothing} + + +
+ `; + } + protected render() { + if (this.selector.object?.fields) { + if (this.selector.object.multiple) { + const items = ensureArray(this.value ?? []); + return html` + ${this.label ? html`` : nothing} +
+ + + ${items.map((item, index) => this._renderItem(item, index))} + + + + ${this.hass.localize("ui.common.add")} + +
+ `; + } + + return html` + ${this.label ? html`` : nothing} +
+ ${this.value + ? html` + ${this._renderItem(this.value, 0)} + ` + : html` + + ${this.hass.localize("ui.common.add")} + + `} +
+ `; + } + return html` ${this.helper - ? html`${this.helper}` + ? html`${this.helper}` : ""} `; } + private _schema = memoizeOne((selector: ObjectSelector) => { + if (!selector.object || !selector.object.fields) { + return []; + } + return Object.entries(selector.object.fields).map(([key, field]) => ({ + name: key, + selector: field.selector, + required: field.required ?? false, + })); + }); + + private _itemMoved(ev) { + ev.stopPropagation(); + const newIndex = ev.detail.newIndex; + const oldIndex = ev.detail.oldIndex; + if (!this.selector.object!.multiple) { + return; + } + const newValue = ensureArray(this.value ?? []).concat(); + const item = newValue.splice(oldIndex, 1)[0]; + newValue.splice(newIndex, 0, item); + fireEvent(this, "value-changed", { value: newValue }); + } + + private async _addItem(ev) { + ev.stopPropagation(); + + const newItem = await showFormDialog(this, { + title: this.hass.localize("ui.common.add"), + schema: this._schema(this.selector), + data: {}, + computeLabel: this._computeLabel, + submitText: this.hass.localize("ui.common.add"), + }); + + if (newItem === null) { + return; + } + + if (!this.selector.object!.multiple) { + fireEvent(this, "value-changed", { value: newItem }); + return; + } + + const newValue = ensureArray(this.value ?? []).concat(); + newValue.push(newItem); + fireEvent(this, "value-changed", { value: newValue }); + } + + private async _editItem(ev) { + ev.stopPropagation(); + const item = ev.currentTarget.item; + const index = ev.currentTarget.index; + + const updatedItem = await showFormDialog(this, { + title: this.hass.localize("ui.common.edit"), + schema: this._schema(this.selector), + data: item, + computeLabel: this._computeLabel, + submitText: this.hass.localize("ui.common.save"), + }); + + if (updatedItem === null) { + return; + } + + if (!this.selector.object!.multiple) { + fireEvent(this, "value-changed", { value: updatedItem }); + return; + } + + const newValue = ensureArray(this.value ?? []).concat(); + newValue[index] = updatedItem; + fireEvent(this, "value-changed", { value: newValue }); + } + + private _deleteItem(ev) { + ev.stopPropagation(); + const index = ev.currentTarget.index; + + if (!this.selector.object!.multiple) { + fireEvent(this, "value-changed", { value: undefined }); + return; + } + + const newValue = ensureArray(this.value ?? []).concat(); + newValue.splice(index, 1); + fireEvent(this, "value-changed", { value: newValue }); + } + protected updated(changedProps: PropertyValues) { super.updated(changedProps); - if (changedProps.has("value") && !this._valueChangedFromChild) { + if ( + changedProps.has("value") && + !this._valueChangedFromChild && + this._yamlEditor + ) { this._yamlEditor.setValue(this.value); } this._valueChangedFromChild = false; @@ -61,6 +289,42 @@ export class HaObjectSelector extends LitElement { } fireEvent(this, "value-changed", { value }); } + + static get styles() { + return [ + css` + ha-md-list { + gap: 8px; + } + ha-md-list-item { + border: 1px solid var(--divider-color); + border-radius: 8px; + --ha-md-list-item-gap: 0; + --md-list-item-top-space: 0; + --md-list-item-bottom-space: 0; + --md-list-item-leading-space: 12px; + --md-list-item-trailing-space: 4px; + --md-list-item-two-line-container-height: 48px; + --md-list-item-one-line-container-height: 48px; + } + .handle { + cursor: move; + padding: 8px; + margin-inline-start: -8px; + } + label { + margin-bottom: 8px; + display: block; + } + ha-md-list-item .label, + ha-md-list-item .description { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + `, + ]; + } } declare global { diff --git a/src/components/ha-selector/ha-selector-select.ts b/src/components/ha-selector/ha-selector-select.ts index c136290114..f6b31bc33d 100644 --- a/src/components/ha-selector/ha-selector-select.ts +++ b/src/components/ha-selector/ha-selector-select.ts @@ -285,7 +285,9 @@ export class HaSelectSelector extends LitElement { private _renderHelper() { return this.helper - ? html`${this.helper}` + ? html`${this.helper}` : ""; } diff --git a/src/components/ha-selector/ha-selector-selector.ts b/src/components/ha-selector/ha-selector-selector.ts index af38d908d6..0cb50aa39a 100644 --- a/src/components/ha-selector/ha-selector-selector.ts +++ b/src/components/ha-selector/ha-selector-selector.ts @@ -80,7 +80,16 @@ const SELECTOR_SCHEMAS = { ] as const, icon: [] as const, location: [] as const, - media: [] as const, + media: [ + { + name: "accept", + selector: { + text: { + multiple: true, + }, + }, + }, + ] as const, number: [ { name: "min", diff --git a/src/components/ha-selector/ha-selector-template.ts b/src/components/ha-selector/ha-selector-template.ts index 50f6e039ed..555f09bd17 100644 --- a/src/components/ha-selector/ha-selector-template.ts +++ b/src/components/ha-selector/ha-selector-template.ts @@ -63,7 +63,9 @@ export class HaTemplateSelector extends LitElement { linewrap > ${this.helper - ? html`${this.helper}` + ? html`${this.helper}` : nothing} `; } 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-target-picker.ts b/src/components/ha-target-picker.ts index 01624644ae..0f5a4ae05c 100644 --- a/src/components/ha-target-picker.ts +++ b/src/components/ha-target-picker.ts @@ -289,7 +289,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { ${this._renderPicker()} ${this.helper - ? html`${this.helper}` + ? html`${this.helper}` : ""} `; } diff --git a/src/components/ha-yaml-editor.ts b/src/components/ha-yaml-editor.ts index 561feb0f3f..53a7e579c7 100644 --- a/src/components/ha-yaml-editor.ts +++ b/src/components/ha-yaml-editor.ts @@ -11,6 +11,7 @@ import { showToast } from "../util/toast"; import { copyToClipboard } from "../common/util/copy-clipboard"; import type { HaCodeEditor } from "./ha-code-editor"; import "./ha-button"; +import "./ha-alert"; const isEmpty = (obj: Record): boolean => { if (typeof obj !== "object" || obj === null) { @@ -43,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 }) @@ -51,8 +55,15 @@ export class HaYamlEditor extends LitElement { @property({ attribute: "has-extra-actions", type: Boolean }) public hasExtraActions = false; + @property({ attribute: "show-errors", type: Boolean }) + public showErrors = true; + @state() private _yaml = ""; + @state() private _error = ""; + + @state() private _showingError = false; + @query("ha-code-editor") _codeEditor?: HaCodeEditor; public setValue(value): void { @@ -102,13 +113,18 @@ export class HaYamlEditor extends LitElement { .hass=${this.hass} .value=${this._yaml} .readOnly=${this.readOnly} + .disableFullscreen=${this.disableFullscreen} mode="yaml" autocomplete-entities autocomplete-icons .error=${this.isValid === false} @value-changed=${this._onChange} + @blur=${this._onBlur} dir="ltr" > + ${this._showingError + ? html`${this._error}` + : nothing} ${this.copyClipboard || this.hasExtraActions ? html`
@@ -146,6 +162,10 @@ export class HaYamlEditor extends LitElement { } else { parsed = {}; } + this._error = errorMsg ?? ""; + if (isValid) { + this._showingError = false; + } this.value = parsed; this.isValid = isValid; @@ -157,6 +177,12 @@ export class HaYamlEditor extends LitElement { } as any); } + private _onBlur(): void { + if (this.showErrors && this._error) { + this._showingError = true; + } + } + get yaml() { return this._yaml; } diff --git a/src/components/media-player/dialog-media-player-browse.ts b/src/components/media-player/dialog-media-player-browse.ts index 7210065897..1254579492 100644 --- a/src/components/media-player/dialog-media-player-browse.ts +++ b/src/components/media-player/dialog-media-player-browse.ts @@ -164,6 +164,7 @@ class DialogMediaPlayerBrowse extends LitElement { .navigateIds=${this._navigateIds} .action=${this._action} .preferredLayout=${this._preferredLayout} + .accept=${this._params.accept} @close-dialog=${this.closeDialog} @media-picked=${this._mediaPicked} @media-browsed=${this._mediaBrowsed} diff --git a/src/components/media-player/ha-media-player-browse.ts b/src/components/media-player/ha-media-player-browse.ts index aa7e8ad8b0..0a0d6fb3f1 100644 --- a/src/components/media-player/ha-media-player-browse.ts +++ b/src/components/media-player/ha-media-player-browse.ts @@ -78,7 +78,7 @@ export interface MediaPlayerItemId { export class HaMediaPlayerBrowse extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; - @property({ attribute: false }) public entityId!: string; + @property({ attribute: false }) public entityId?: string; @property() public action: MediaPlayerBrowseAction = "play"; @@ -89,6 +89,8 @@ export class HaMediaPlayerBrowse extends LitElement { @property({ attribute: false }) public navigateIds: MediaPlayerItemId[] = []; + @property({ attribute: false }) public accept?: string[]; + // @todo Consider reworking to eliminate need for attribute since it is manipulated internally @property({ type: Boolean, reflect: true }) public narrow = false; @@ -250,6 +252,7 @@ export class HaMediaPlayerBrowse extends LitElement { }); } else if ( err.code === "entity_not_found" && + this.entityId && isUnavailableState(this.hass.states[this.entityId]?.state) ) { this._setError({ @@ -334,7 +337,37 @@ export class HaMediaPlayerBrowse extends LitElement { const subtitle = this.hass.localize( `ui.components.media-browser.class.${currentItem.media_class}` ); - const children = currentItem.children || []; + let children = currentItem.children || []; + const canPlayChildren = new Set(); + + // Filter children based on accept property if provided + if (this.accept && children.length > 0) { + let checks: ((t: string) => boolean)[] = []; + + for (const type of this.accept) { + if (type.endsWith("/*")) { + const baseType = type.slice(0, -1); + checks.push((t) => t.startsWith(baseType)); + } else if (type === "*") { + checks = [() => true]; + break; + } else { + checks.push((t) => t === type); + } + } + + children = children.filter((child) => { + const contentType = child.media_content_type.toLowerCase(); + const canPlay = + child.media_content_type && + checks.some((check) => check(contentType)); + if (canPlay) { + canPlayChildren.add(child.media_content_id); + } + return !child.media_content_type || child.can_expand || canPlay; + }); + } + const mediaClass = MediaClassBrowserSettings[currentItem.media_class]; const childrenMediaClass = currentItem.children_media_class ? MediaClassBrowserSettings[currentItem.children_media_class] @@ -367,7 +400,12 @@ export class HaMediaPlayerBrowse extends LitElement { "" )}" > - ${this.narrow && currentItem?.can_play + ${this.narrow && + currentItem?.can_play && + (!this.accept || + canPlayChildren.has( + currentItem.media_content_id + )) ? html` { - return entityId !== BROWSER_PLAYER + return entityId && entityId !== BROWSER_PLAYER ? browseMediaPlayer(this.hass, entityId, mediaContentId, mediaContentType) : browseLocalMediaPlayer(this.hass, mediaContentId); } diff --git a/src/components/media-player/show-media-browser-dialog.ts b/src/components/media-player/show-media-browser-dialog.ts index 9136be8db0..5fdfeac789 100644 --- a/src/components/media-player/show-media-browser-dialog.ts +++ b/src/components/media-player/show-media-browser-dialog.ts @@ -7,10 +7,11 @@ import type { MediaPlayerItemId } from "./ha-media-player-browse"; export interface MediaPlayerBrowseDialogParams { action: MediaPlayerBrowseAction; - entityId: string; + entityId?: string; mediaPickedCallback: (pickedMedia: MediaPickedEvent) => void; navigateIds?: MediaPlayerItemId[]; minimumNavigateLevel?: number; + accept?: string[]; } export const showMediaBrowserDialog = ( diff --git a/src/data/ai_task.ts b/src/data/ai_task.ts new file mode 100644 index 0000000000..c89f6607fe --- /dev/null +++ b/src/data/ai_task.ts @@ -0,0 +1,53 @@ +import type { HomeAssistant } from "../types"; +import type { Selector } from "./selector"; + +export interface AITaskPreferences { + gen_data_entity_id: string | null; +} + +export interface GenDataTaskResult { + conversation_id: string; + data: T; +} + +export interface AITaskStructureField { + description?: string; + required?: boolean; + selector: Selector; +} + +export type AITaskStructure = Record; + +export const fetchAITaskPreferences = (hass: HomeAssistant) => + hass.callWS({ + type: "ai_task/preferences/get", + }); + +export const saveAITaskPreferences = ( + hass: HomeAssistant, + preferences: Partial +) => + hass.callWS({ + type: "ai_task/preferences/set", + ...preferences, + }); + +export const generateDataAITask = async ( + hass: HomeAssistant, + task: { + task_name: string; + entity_id?: string; + instructions: string; + structure?: AITaskStructure; + } +): Promise> => { + const result = await hass.callService>( + "ai_task", + "generate_data", + task, + undefined, + true, + true + ); + return result.response!; +}; 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 93cd21fee1..c42b8e0f21 100644 --- a/src/data/icons.ts +++ b/src/data/icons.ts @@ -37,6 +37,7 @@ import { mdiRoomService, mdiScriptText, mdiSpeakerMessage, + mdiStarFourPoints, mdiThermostat, mdiTimerOutline, mdiToggleSwitch, @@ -66,6 +67,7 @@ export const DEFAULT_DOMAIN_ICON = mdiBookmark; /** Fallback icons for each domain */ export const FALLBACK_DOMAIN_ICONS = { + ai_task: mdiStarFourPoints, air_quality: mdiAirFilter, alert: mdiAlert, automation: mdiRobot, @@ -352,7 +354,10 @@ const getIconFromTranslations = ( } // 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); + return ( + getIconFromRange(Number(state), translations.range) ?? + translations.default + ); } // Fallback to default icon return translations.default; @@ -502,14 +507,28 @@ 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) ?? + translations.default + ); + } + // 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/preview.ts b/src/data/preview.ts index fb009f3981..dadbd8d859 100644 --- a/src/data/preview.ts +++ b/src/data/preview.ts @@ -13,7 +13,7 @@ export const subscribePreviewGeneric = ( hass: HomeAssistant, domain: string, flow_id: string, - flow_type: "config_flow" | "options_flow", + flow_type: "config_flow" | "options_flow" | "config_subentries_flow", user_input: Record, callback: (preview: GenericPreview) => void ): Promise => diff --git a/src/data/script.ts b/src/data/script.ts index 9faf0b4127..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; @@ -415,7 +427,7 @@ export const migrateAutomationAction = ( return action.map(migrateAutomationAction) as Action[]; } - if ("service" in action) { + if (typeof action === "object" && action !== null && "service" in action) { if (!("action" in action)) { action.action = action.service; } @@ -423,7 +435,7 @@ export const migrateAutomationAction = ( } // legacy scene (scene: scene_name) - if ("scene" in action) { + if (typeof action === "object" && action !== null && "scene" in action) { action.action = "scene.turn_on"; action.target = { entity_id: action.scene, @@ -431,7 +443,7 @@ export const migrateAutomationAction = ( delete action.scene; } - if ("sequence" in action) { + if (typeof action === "object" && action !== null && "sequence" in action) { for (const sequenceAction of (action as SequenceAction).sequence) { migrateAutomationAction(sequenceAction); } diff --git a/src/data/selector.ts b/src/data/selector.ts index 60d2b133fe..dacd2c0c56 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -303,7 +303,9 @@ export interface LocationSelectorValue { } export interface MediaSelector { - media: {} | null; + media: { + accept?: string[]; + } | null; } export interface MediaSelectorValue { @@ -331,11 +333,24 @@ export interface NumberSelector { mode?: "box" | "slider"; unit_of_measurement?: string; slider_ticks?: boolean; + translation_key?: string; } | null; } +interface ObjectSelectorField { + selector: Selector; + label?: string; + required?: boolean; +} + export interface ObjectSelector { - object: {} | null; + object?: { + label_field?: string; + description_field?: string; + translation_key?: string; + fields?: Record; + multiple?: boolean; + } | null; } export interface AssistPipelineSelector { diff --git a/src/data/selector/format_selector_value.ts b/src/data/selector/format_selector_value.ts new file mode 100644 index 0000000000..47950879d3 --- /dev/null +++ b/src/data/selector/format_selector_value.ts @@ -0,0 +1,104 @@ +import { ensureArray } from "../../common/array/ensure-array"; +import { computeAreaName } from "../../common/entity/compute_area_name"; +import { computeDeviceName } from "../../common/entity/compute_device_name"; +import { computeEntityName } from "../../common/entity/compute_entity_name"; +import { getEntityContext } from "../../common/entity/context/get_entity_context"; +import { blankBeforeUnit } from "../../common/translations/blank_before_unit"; +import type { HomeAssistant } from "../../types"; +import type { Selector } from "../selector"; + +export const formatSelectorValue = ( + hass: HomeAssistant, + value: any, + selector?: Selector +) => { + if (value == null) { + return ""; + } + + if (!selector) { + return ensureArray(value).join(", "); + } + + if ("text" in selector) { + const { prefix, suffix } = selector.text || {}; + + const texts = ensureArray(value); + return texts + .map((text) => `${prefix || ""}${text}${suffix || ""}`) + .join(", "); + } + + if ("number" in selector) { + const { unit_of_measurement } = selector.number || {}; + const numbers = ensureArray(value); + return numbers + .map((number) => { + const num = Number(number); + if (isNaN(num)) { + return number; + } + return unit_of_measurement + ? `${num}${blankBeforeUnit(unit_of_measurement, hass.locale)}${unit_of_measurement}` + : num.toString(); + }) + .join(", "); + } + + if ("floor" in selector) { + const floors = ensureArray(value); + return floors + .map((floorId) => { + const floor = hass.floors[floorId]; + if (!floor) { + return floorId; + } + return floor.name || floorId; + }) + .join(", "); + } + + if ("area" in selector) { + const areas = ensureArray(value); + return areas + .map((areaId) => { + const area = hass.areas[areaId]; + if (!area) { + return areaId; + } + return computeAreaName(area); + }) + .join(", "); + } + + if ("entity" in selector) { + const entities = ensureArray(value); + return entities + .map((entityId) => { + const stateObj = hass.states[entityId]; + if (!stateObj) { + return entityId; + } + const { device } = getEntityContext(stateObj, hass); + const deviceName = device ? computeDeviceName(device) : undefined; + const entityName = computeEntityName(stateObj, hass); + return [deviceName, entityName].filter(Boolean).join(" ") || entityId; + }) + .join(", "); + } + + if ("device" in selector) { + const devices = ensureArray(value); + return devices + .map((deviceId) => { + const device = hass.devices[deviceId]; + if (!device) { + return deviceId; + } + return device.name || deviceId; + }) + .join(", "); + } + + return ensureArray(value).join(", "); +}; diff --git a/src/data/system_health.ts b/src/data/system_health.ts index d8fb40c924..2723dc9fe7 100644 --- a/src/data/system_health.ts +++ b/src/data/system_health.ts @@ -34,6 +34,7 @@ export type SystemHealthInfo = Partial<{ dev: boolean; hassio: boolean; docker: boolean; + container_arch: string; user: string; virtualenv: boolean; python_version: string; 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/dialogs/form/dialog-form.ts b/src/dialogs/form/dialog-form.ts new file mode 100644 index 0000000000..af755a2224 --- /dev/null +++ b/src/dialogs/form/dialog-form.ts @@ -0,0 +1,89 @@ +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../../common/dom/fire_event"; +import "../../components/ha-button"; +import { createCloseHeading } from "../../components/ha-dialog"; +import "../../components/ha-form/ha-form"; +import type { HomeAssistant } from "../../types"; +import type { HassDialog } from "../make-dialog-manager"; +import type { FormDialogData, FormDialogParams } from "./show-form-dialog"; +import { haStyleDialog } from "../../resources/styles"; + +@customElement("dialog-form") +export class DialogForm + extends LitElement + implements HassDialog +{ + @property({ attribute: false }) public hass?: HomeAssistant; + + @state() private _params?: FormDialogParams; + + @state() private _data: FormDialogData = {}; + + public async showDialog(params: FormDialogParams): Promise { + this._params = params; + this._data = params.data || {}; + } + + public closeDialog() { + this._params = undefined; + this._data = {}; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + return true; + } + + private _submit(): void { + this._params?.submit?.(this._data); + this.closeDialog(); + } + + private _cancel(): void { + this._params?.cancel?.(); + this.closeDialog(); + } + + private _valueChanged(ev: CustomEvent): void { + this._data = ev.detail.value; + } + + protected render() { + if (!this._params || !this.hass) { + return nothing; + } + + return html` + + + + + ${this._params.cancelText || this.hass.localize("ui.common.cancel")} + + + ${this._params.submitText || this.hass.localize("ui.common.save")} + + + `; + } + + static styles = [haStyleDialog, css``]; +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-form": DialogForm; + } +} diff --git a/src/dialogs/form/show-form-dialog.ts b/src/dialogs/form/show-form-dialog.ts new file mode 100644 index 0000000000..3cd49fd6ce --- /dev/null +++ b/src/dialogs/form/show-form-dialog.ts @@ -0,0 +1,45 @@ +import { fireEvent } from "../../common/dom/fire_event"; +import type { HaFormSchema } from "../../components/ha-form/types"; + +export type FormDialogData = Record; + +export interface FormDialogParams { + title: string; + schema: HaFormSchema[]; + data?: FormDialogData; + submit?: (data?: FormDialogData) => void; + cancel?: () => void; + computeLabel?: (schema, data) => string | undefined; + computeHelper?: (schema) => string | undefined; + submitText?: string; + cancelText?: string; +} + +export const showFormDialog = ( + element: HTMLElement, + dialogParams: FormDialogParams +) => + new Promise((resolve) => { + const origCancel = dialogParams.cancel; + const origSubmit = dialogParams.submit; + + fireEvent(element, "show-dialog", { + dialogTag: "dialog-form", + dialogImport: () => import("./dialog-form"), + dialogParams: { + ...dialogParams, + cancel: () => { + resolve(null); + if (origCancel) { + origCancel(); + } + }, + submit: (data: FormDialogData) => { + resolve(data); + if (origSubmit) { + origSubmit(data); + } + }, + }, + }); + }); diff --git a/src/dialogs/sidebar/dialog-edit-sidebar.ts b/src/dialogs/sidebar/dialog-edit-sidebar.ts index fed10ad593..692443a40e 100644 --- a/src/dialogs/sidebar/dialog-edit-sidebar.ts +++ b/src/dialogs/sidebar/dialog-edit-sidebar.ts @@ -208,6 +208,7 @@ class DialogEditSidebar extends LitElement { ha-md-dialog { min-width: 600px; max-height: 90%; + --dialog-content-padding: 8px 24px; } @media all and (max-width: 600px), all and (max-height: 500px) { 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/automation-save-dialog/dialog-automation-save.ts b/src/panels/config/automation/automation-save-dialog/dialog-automation-save.ts index 4df25b260c..6500646071 100644 --- a/src/panels/config/automation/automation-save-dialog/dialog-automation-save.ts +++ b/src/panels/config/automation/automation-save-dialog/dialog-automation-save.ts @@ -1,8 +1,9 @@ import "@material/mwc-button"; -import type { CSSResultGroup } from "lit"; +import type { CSSResultGroup, PropertyValues } from "lit"; import { css, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; -import { mdiClose, mdiPlus } from "@mdi/js"; +import { mdiClose, mdiPlus, mdiStarFourPoints } from "@mdi/js"; +import { dump } from "js-yaml"; import { fireEvent } from "../../../../common/dom/fire_event"; import "../../../../components/ha-alert"; import "../../../../components/ha-domain-icon"; @@ -24,6 +25,14 @@ import type { SaveDialogParams, } from "./show-dialog-automation-save"; import { supportsMarkdownHelper } from "../../../../common/translations/markdown_support"; +import { + fetchAITaskPreferences, + generateDataAITask, +} from "../../../../data/ai_task"; +import { isComponentLoaded } from "../../../../common/config/is_component_loaded"; +import { computeStateDomain } from "../../../../common/entity/compute_state_domain"; +import { subscribeOne } from "../../../../common/util/subscribe-one"; +import { subscribeLabelRegistry } from "../../../../data/label_registry"; @customElement("ha-dialog-automation-save") class DialogAutomationSave extends LitElement implements HassDialog { @@ -37,9 +46,11 @@ class DialogAutomationSave extends LitElement implements HassDialog { @state() private _entryUpdates!: EntityRegistryUpdate; + @state() private _canSuggest = false; + private _params!: SaveDialogParams; - private _newName?: string; + @state() private _newName?: string; private _newIcon?: string; @@ -67,7 +78,7 @@ class DialogAutomationSave extends LitElement implements HassDialog { this._entryUpdates.category ? "category" : "", this._entryUpdates.labels.length > 0 ? "labels" : "", this._entryUpdates.area ? "area" : "", - ]; + ].filter(Boolean); } public closeDialog() { @@ -81,6 +92,15 @@ class DialogAutomationSave extends LitElement implements HassDialog { return true; } + protected firstUpdated(changedProperties: PropertyValues): void { + super.firstUpdated(changedProperties); + if (isComponentLoaded(this.hass, "ai_task")) { + fetchAITaskPreferences(this.hass).then((prefs) => { + this._canSuggest = prefs.gen_data_entity_id !== null; + }); + } + } + protected _renderOptionalChip(id: string, label: string) { if (this._visibleOptionals.includes(id)) { return nothing; @@ -250,6 +270,21 @@ class DialogAutomationSave extends LitElement implements HassDialog { .path=${mdiClose} > ${this._params.title || title} + ${this._canSuggest + ? html` + + + + ` + : nothing} ${this._error ? html` + Object.fromEntries(labs.map((lab) => [lab.label_id, lab.name])) + ); + const automationInspiration: string[] = []; + + for (const automation of Object.values(this.hass.states)) { + const entityEntry = this.hass.entities[automation.entity_id]; + if ( + computeStateDomain(automation) !== "automation" || + automation.attributes.restored || + !automation.attributes.friendly_name || + !entityEntry + ) { + continue; + } + + let inspiration = `- ${automation.attributes.friendly_name}`; + + if (entityEntry.labels.length) { + inspiration += ` (labels: ${entityEntry.labels + .map((label) => labels[label]) + .join(", ")})`; + } + + automationInspiration.push(inspiration); + } + + const result = await generateDataAITask<{ + name: string; + description: string | undefined; + labels: string[] | undefined; + }>(this.hass, { + task_name: "frontend:automation:save", + instructions: `Suggest in language "${this.hass.language}" a name, description, and labels for the following Home Assistant automation. + +The name should be relevant to the automation's purpose. +${ + automationInspiration.length + ? `The name should be in same style as existing automations. +Suggest labels if relevant to the automation's purpose. +Only suggest labels that are already used by existing automations.` + : `The name should be short, descriptive, sentence case, and written in the language ${this.hass.language}.` +} +If the automation contains 5+ steps, include a short description. + +For inspiration, here are existing automations: +${automationInspiration.join("\n")} + +The automation configuration is as follows: +${dump(this._params.config)} +`, + structure: { + name: { + description: "The name of the automation", + required: true, + selector: { + text: {}, + }, + }, + description: { + description: "A short description of the automation", + required: false, + selector: { + text: {}, + }, + }, + labels: { + description: "Labels for the automation", + required: false, + selector: { + text: { + multiple: true, + }, + }, + }, + }, + }); + this._newName = result.data.name; + if (result.data.description) { + this._newDescription = result.data.description; + if (!this._visibleOptionals.includes("description")) { + this._visibleOptionals = [...this._visibleOptionals, "description"]; + } + } + if (result.data.labels?.length) { + // We get back label names, convert them to IDs + const newLabels: Record = Object.fromEntries( + result.data.labels.map((name) => [name, undefined]) + ); + let toFind = result.data.labels.length; + for (const [labelId, labelName] of Object.entries(labels)) { + if (labelName in newLabels && newLabels[labelName] === undefined) { + newLabels[labelName] = labelId; + toFind--; + if (toFind === 0) { + break; + } + } + } + const foundLabels = Object.values(newLabels).filter( + (labelId) => labelId !== undefined + ); + if (foundLabels.length) { + this._entryUpdates = { + ...this._entryUpdates, + labels: foundLabels, + }; + if (!this._visibleOptionals.includes("labels")) { + this._visibleOptionals = [...this._visibleOptionals, "labels"]; + } + } + } + } + private async _save(): Promise { if (!this._newName) { this._error = "Name is required"; @@ -381,6 +534,10 @@ class DialogAutomationSave extends LitElement implements HassDialog { .destructive { --mdc-theme-primary: var(--error-color); } + + #suggest { + margin: 8px 16px; + } `, ]; } diff --git a/src/panels/config/automation/ha-automation-editor.ts b/src/panels/config/automation/ha-automation-editor.ts index 5fae94ede3..691fbb4e82 100644 --- a/src/panels/config/automation/ha-automation-editor.ts +++ b/src/panels/config/automation/ha-automation-editor.ts @@ -501,6 +501,8 @@ export class HaAutomationEditor extends PreventUnsavedMixin( .defaultValue=${this._preprocessYaml()} .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` diff --git a/src/panels/config/cloud/account/cloud-tts-pref.ts b/src/panels/config/cloud/account/cloud-tts-pref.ts index 8adc58e737..59370ae919 100644 --- a/src/panels/config/cloud/account/cloud-tts-pref.ts +++ b/src/panels/config/cloud/account/cloud-tts-pref.ts @@ -1,6 +1,7 @@ import "@material/mwc-button"; import { css, html, LitElement, nothing } from "lit"; +import { mdiContentCopy } from "@mdi/js"; import { customElement, property, state } from "lit/decorators"; import memoizeOne from "memoize-one"; import { fireEvent } from "../../../../common/dom/fire_event"; @@ -20,6 +21,8 @@ import { import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box"; import type { HomeAssistant } from "../../../../types"; import { showTryTtsDialog } from "./show-dialog-cloud-tts-try"; +import { copyToClipboard } from "../../../../common/util/copy-clipboard"; +import { showToast } from "../../../../util/toast"; export const getCloudTtsSupportedVoices = ( language: string, @@ -46,6 +49,8 @@ export class CloudTTSPref extends LitElement { @property({ attribute: false }) public cloudStatus?: CloudStatusLoggedIn; + @property({ type: Boolean, reflect: true }) public narrow = false; + @state() private savingPreferences = false; @state() private ttsInfo?: CloudTTSInfo; @@ -103,6 +108,25 @@ export class CloudTTSPref extends LitElement {
+
+
+ ${this.hass.localize( + "ui.components.media-browser.tts.selected_voice_id" + )} +
+ ${defaultVoice[1]} + ${this.narrow + ? nothing + : html` + + `} +
+
${this.hass.localize("ui.panel.config.cloud.account.tts.try")} @@ -196,6 +220,14 @@ export class CloudTTSPref extends LitElement { } } + private async _copyVoiceId(ev) { + ev.preventDefault(); + await copyToClipboard(this.cloudStatus!.prefs.tts_default_voice[1]); + showToast(this, { + message: this.hass.localize("ui.common.copied_clipboard"), + }); + } + static styles = css` a { color: var(--primary-color); @@ -226,7 +258,34 @@ export class CloudTTSPref extends LitElement { } .card-actions { display: flex; - flex-direction: row-reverse; + align-items: center; + } + code { + margin-left: 6px; + font-weight: var(--ha-font-weight-bold); + } + .voice-id { + display: flex; + align-items: center; + font-size: var(--ha-font-size-s); + color: var(--secondary-text-color); + --mdc-icon-size: 14px; + --mdc-icon-button-size: 24px; + } + :host([narrow]) .voice-id { + flex-direction: column; + font-size: var(--ha-font-size-xs); + align-items: start; + align-items: left; + } + :host([narrow]) .label { + text-transform: uppercase; + } + :host([narrow]) code { + margin-left: 0; + } + .flex { + flex: 1; } `; } diff --git a/src/panels/config/core/ai-task-pref.ts b/src/panels/config/core/ai-task-pref.ts new file mode 100644 index 0000000000..2547f8a4ad --- /dev/null +++ b/src/panels/config/core/ai-task-pref.ts @@ -0,0 +1,160 @@ +import "@material/mwc-button"; +import { mdiHelpCircle, mdiStarFourPoints } from "@mdi/js"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import "../../../components/ha-card"; +import "../../../components/ha-settings-row"; +import "../../../components/entity/ha-entity-picker"; +import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker"; +import type { HomeAssistant } from "../../../types"; +import { brandsUrl } from "../../../util/brands-url"; +import { + fetchAITaskPreferences, + saveAITaskPreferences, + type AITaskPreferences, +} from "../../../data/ai_task"; +import { documentationUrl } from "../../../util/documentation-url"; + +@customElement("ai-task-pref") +export class AITaskPref extends LitElement { + @property({ type: Boolean, reflect: true }) public narrow = false; + + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _prefs?: AITaskPreferences; + + protected firstUpdated(changedProps) { + super.firstUpdated(changedProps); + fetchAITaskPreferences(this.hass).then((prefs) => { + this._prefs = prefs; + }); + } + + protected render() { + if (!this._prefs) { + return nothing; + } + + return html` + +

+ ${this.hass.localize("ui.panel.config.ai_task.header")} +

+
+ + + +
+
+

+ ${this.hass!.localize("ui.panel.config.ai_task.description", { + button: html``, + })} +

+ + + ${this.hass!.localize("ui.panel.config.ai_task.gen_data_header")} + + + ${this.hass!.localize( + "ui.panel.config.ai_task.gen_data_description" + )} + + + +
+
+ `; + } + + private async _handlePrefChange( + ev: CustomEvent<{ value: string | undefined }> + ) { + const input = ev.target as HaEntityPicker; + const key = input.getAttribute("data-name") as keyof AITaskPreferences; + const entityId = ev.detail.value || null; + const oldPrefs = this._prefs; + this._prefs = { ...this._prefs!, [key]: entityId }; + try { + this._prefs = await saveAITaskPreferences(this.hass, { + [key]: entityId, + }); + } catch (_err: any) { + this._prefs = oldPrefs; + } + } + + static styles = css` + .card-header { + display: flex; + align-items: center; + } + .card-header img { + max-width: 28px; + margin-right: 16px; + } + a { + color: var(--primary-color); + } + ha-settings-row { + padding: 0; + } + .header-actions { + position: absolute; + right: 0px; + inset-inline-end: 0px; + inset-inline-start: initial; + top: 24px; + display: flex; + flex-direction: row; + } + .header-actions .icon-link { + margin-top: -16px; + margin-right: 8px; + margin-inline-end: 8px; + margin-inline-start: initial; + direction: var(--direction); + color: var(--secondary-text-color); + } + ha-entity-picker { + flex: 1; + margin-left: 16px; + } + :host([narrow]) ha-entity-picker { + margin-left: 0; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ai-task-pref": AITaskPref; + } +} diff --git a/src/panels/config/core/ha-config-section-general.ts b/src/panels/config/core/ha-config-section-general.ts index a3647c8ec1..1d014dfd2b 100644 --- a/src/panels/config/core/ha-config-section-general.ts +++ b/src/panels/config/core/ha-config-section-general.ts @@ -25,8 +25,10 @@ import type { ConfigUpdateValues } from "../../../data/core"; import { saveCoreConfig } from "../../../data/core"; import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box"; import "../../../layouts/hass-subpage"; +import "./ai-task-pref"; import { haStyle } from "../../../resources/styles"; import type { HomeAssistant, ValueChangedEvent } from "../../../types"; +import { isComponentLoaded } from "../../../common/config/is_component_loaded"; @customElement("ha-config-section-general") class HaConfigSectionGeneral extends LitElement { @@ -265,6 +267,12 @@ class HaConfigSectionGeneral extends LitElement {
+ ${isComponentLoaded(this.hass, "ai_task") + ? html`` + : nothing} `; @@ -377,7 +385,8 @@ class HaConfigSectionGeneral extends LitElement { max-width: 1040px; margin: 0 auto; } - ha-card { + ha-card, + ai-task-pref { max-width: 600px; margin: 0 auto; height: 100%; @@ -385,6 +394,10 @@ class HaConfigSectionGeneral extends LitElement { flex-direction: column; display: flex; } + ha-card, + ai-task-pref { + margin-bottom: 24px; + } .card-content { display: flex; justify-content: space-between; diff --git a/src/panels/config/dashboard/dashboard-card.ts b/src/panels/config/dashboard/dashboard-card.ts index b737152442..663500dc33 100644 --- a/src/panels/config/dashboard/dashboard-card.ts +++ b/src/panels/config/dashboard/dashboard-card.ts @@ -58,7 +58,7 @@ export class DashboardCard extends LitElement { .card-header { padding: 12px; display: block; - text-align: left; + text-align: var(--float-start); gap: 8px; } .preview { diff --git a/src/panels/config/dashboard/ha-config-navigation.ts b/src/panels/config/dashboard/ha-config-navigation.ts index 87b2f1bc62..01720f45b5 100644 --- a/src/panels/config/dashboard/ha-config-navigation.ts +++ b/src/panels/config/dashboard/ha-config-navigation.ts @@ -54,6 +54,9 @@ class HaConfigNavigation extends LitElement { `, })); return 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} entry.device_id === device.id + ); + if (provisioningEntry && !provisioningEntry.nodeId) { + return [ + { + label: hass.localize("ui.panel.config.devices.delete_device"), + classes: "warning", + icon: mdiDelete, + action: async () => { + const confirm = await showConfirmationDialog(el, { + title: hass.localize( + "ui.panel.config.zwave_js.provisioned.confirm_unprovision_title" + ), + text: hass.localize( + "ui.panel.config.zwave_js.provisioned.confirm_unprovision_text", + { name: device.name_by_user || device.name } + ), + confirmText: hass.localize("ui.common.remove"), + destructive: true, + }); + + if (confirm) { + await unprovisionZwaveSmartStartNode( + hass, + entryId, + provisioningEntry.dsk + ); + } + }, + }, + ]; + } const nodeStatus = await fetchZwaveNodeStatus(hass, device.id); if (!nodeStatus) { @@ -84,16 +124,6 @@ export const getZwaveDeviceActions = async ( device, }), }, - { - label: hass.localize( - "ui.panel.config.zwave_js.device_info.remove_failed" - ), - icon: mdiDeleteForever, - action: () => - showZWaveJSRemoveFailedNodeDialog(el, { - device_id: device.id, - }), - }, { label: hass.localize( "ui.panel.config.zwave_js.device_info.node_statistics" @@ -103,6 +133,16 @@ export const getZwaveDeviceActions = async ( showZWaveJSNodeStatisticsDialog(el, { device, }), + }, + { + label: hass.localize("ui.panel.config.devices.delete_device"), + classes: "warning", + icon: mdiDelete, + action: () => + showZWaveJSRemoveNodeDialog(el, { + deviceId: device.id, + entryId, + }), } ); } diff --git a/src/panels/config/devices/ha-config-device-page.ts b/src/panels/config/devices/ha-config-device-page.ts index 44530ab333..c062dc699f 100644 --- a/src/panels/config/devices/ha-config-device-page.ts +++ b/src/panels/config/devices/ha-config-device-page.ts @@ -10,11 +10,12 @@ import { mdiPlusCircle, mdiRestore, } from "@mdi/js"; -import type { CSSResultGroup, TemplateResult } from "lit"; +import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit"; import { LitElement, css, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { ifDefined } from "lit/directives/if-defined"; import memoizeOne from "memoize-one"; +import type { HassEntity } from "home-assistant-js-websocket"; import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { ASSIST_ENTITIES, SENSOR_ENTITIES } from "../../../common/const"; import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name"; @@ -185,6 +186,27 @@ export class HaConfigDevicePage extends LitElement { ) ); + private _getEntitiesSorted = (entities: HassEntity[]) => + entities.sort((ent1, ent2) => + stringCompare( + ent1.attributes.friendly_name || `zzz${ent1.entity_id}`, + ent2.attributes.friendly_name || `zzz${ent2.entity_id}`, + this.hass.locale.language + ) + ); + + private _getRelated = memoizeOne((related?: RelatedResult) => ({ + automation: this._getEntitiesSorted( + (related?.automation ?? []).map((entityId) => this.hass.states[entityId]) + ), + scene: this._getEntitiesSorted( + (related?.scene ?? []).map((entityId) => this.hass.states[entityId]) + ), + script: this._getEntitiesSorted( + (related?.script ?? []).map((entityId) => this.hass.states[entityId]) + ), + })); + private _deviceIdInList = memoizeOne((deviceId: string) => [deviceId]); private _entityIds = memoizeOne( @@ -251,22 +273,24 @@ export class HaConfigDevicePage extends LitElement { findBatteryChargingEntity(this.hass, entities) ); - public willUpdate(changedProps) { + public willUpdate(changedProps: PropertyValues) { 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) { @@ -433,23 +457,25 @@ export class HaConfigDevicePage extends LitElement { ${this._related?.automation?.length ? html`
- ${this._related.automation.map((automation) => { - const entityState = this.hass.states[automation]; - return entityState - ? html` - - ${computeStateName(entityState)} - - - ` - : nothing; - })} + ${this._getRelated(this._related).automation.map( + (automation) => { + const entityState = automation; + return entityState + ? html` + + ${computeStateName(entityState)} + + + ` + : nothing; + } + )}
` : html` @@ -510,8 +536,8 @@ export class HaConfigDevicePage extends LitElement { ${this._related?.scene?.length ? html`
- ${this._related.scene.map((scene) => { - const entityState = this.hass.states[scene]; + ${this._getRelated(this._related).scene.map((scene) => { + const entityState = scene; return entityState && entityState.attributes.id ? html` - ${this._related.script.map((script) => { - const entityState = this.hass.states[script]; + ${this._getRelated(this._related).script.map((script) => { + const entityState = script; const entry = this._entityReg.find( - (e) => e.entity_id === script + (e) => e.entity_id === script.entity_id ); let url = `/config/script/show/${entityState.entity_id}`; if (entry) { @@ -965,6 +991,7 @@ export class HaConfigDevicePage extends LitElement { } private _getDeleteActions() { + const deviceId = this.deviceId; const device = this.hass.devices[this.deviceId]; if (!device) { @@ -1034,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) { @@ -1133,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) { @@ -1164,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(() => { @@ -1293,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 538b9af027..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; } @@ -1115,9 +1115,10 @@ ${ const domain = this._searchParms.get("domain"); const configEntry = this._searchParms.get("config_entry"); const subEntry = this._searchParms.get("sub_entry"); - const label = this._searchParms.has("label"); + const device = this._searchParms.get("device"); + const label = this._searchParms.get("label"); - if (!domain && !configEntry && !label) { + if (!domain && !configEntry && !label && !device) { return; } @@ -1126,21 +1127,11 @@ ${ this._filters = { "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() { @@ -1150,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/helpers/forms/ha-timer-form.ts b/src/panels/config/helpers/forms/ha-timer-form.ts index d5d8a43762..236681051d 100644 --- a/src/panels/config/helpers/forms/ha-timer-form.ts +++ b/src/panels/config/helpers/forms/ha-timer-form.ts @@ -5,10 +5,14 @@ import { fireEvent } from "../../../../common/dom/fire_event"; import "../../../../components/ha-checkbox"; import "../../../../components/ha-formfield"; import "../../../../components/ha-icon-picker"; +import "../../../../components/ha-duration-input"; import "../../../../components/ha-textfield"; import type { DurationDict, Timer } from "../../../../data/timer"; import { haStyle } from "../../../../resources/styles"; import type { HomeAssistant } from "../../../../types"; +import { createDurationData } from "../../../../common/datetime/create_duration_data"; +import type { HaDurationData } from "../../../../components/ha-duration-input"; +import type { ForDict } from "../../../../data/automation"; @customElement("ha-timer-form") class HaTimerForm extends LitElement { @@ -24,6 +28,8 @@ class HaTimerForm extends LitElement { @state() private _duration!: string | number | DurationDict; + @state() private _duration_data!: HaDurationData | undefined; + @state() private _restore!: boolean; set item(item: Timer) { @@ -39,6 +45,8 @@ class HaTimerForm extends LitElement { this._duration = "00:00:00"; this._restore = false; } + + this._setDurationData(); } public focus() { @@ -79,14 +87,11 @@ class HaTimerForm extends LitElement { "ui.dialogs.helper_settings.generic.icon" )} > - + .data=${this._duration_data} + @value-changed=${this._valueChanged} + > + + + ${this.hass.localize( + `component.${this._params.domain}.config_subentries.${this._params.subFlowType}.initiate_flow.user` + )} + + + ${this._params.configEntries.map( + (entry) => + html`${entry.title}` + )} + + + `; + } + + private _itemPicked(ev: Event) { + this._params?.configEntryPicked((ev.currentTarget as any).entry); + this.closeDialog(); + } + + static styles = css` + :host { + --dialog-content-padding: 0; + } + @media all and (min-width: 600px) { + ha-dialog { + --mdc-dialog-min-width: 400px; + } + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-pick-config-entry": DialogPickConfigEntry; + } +} diff --git a/src/panels/config/integrations/ha-config-entry-device-row.ts b/src/panels/config/integrations/ha-config-entry-device-row.ts new file mode 100644 index 0000000000..6783240f46 --- /dev/null +++ b/src/panels/config/integrations/ha-config-entry-device-row.ts @@ -0,0 +1,330 @@ +import { + 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 { + disableConfigEntry, + type ConfigEntry, + type DisableConfigEntryResult, +} from "../../../data/config_entries"; +import { + removeConfigEntryFromDevice, + updateDeviceRegistryEntry, + type DeviceRegistryEntry, +} from "../../../data/device_registry"; +import type { EntityRegistryEntry } from "../../../data/entity_registry"; +import { haStyle } from "../../../resources/styles"; +import type { HomeAssistant } from "../../../types"; +import { + showAlertDialog, + showConfirmationDialog, +} from "../../lovelace/custom-card-helpers"; +import { showDeviceRegistryDetailDialog } from "../devices/device-registry-detail/show-dialog-device-registry-detail"; +import "./ha-config-sub-entry-row"; + +@customElement("ha-config-entry-device-row") +class HaConfigEntryDeviceRow extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean, reflect: true }) public narrow = false; + + @property({ attribute: false }) public entry!: ConfigEntry; + + @property({ attribute: false }) public device!: DeviceRegistryEntry; + + @property({ attribute: false }) public entities!: EntityRegistryEntry[]; + + protected render() { + const device = this.device; + + const entities = this._getEntities(); + + const { area } = getDeviceContext(device, this.hass); + + const supportingText = [ + device.model || device.sw_version || device.manufacturer, + area ? area.name : undefined, + ].filter(Boolean); + + return html` + +
${computeDeviceNameDisplay(device, this.hass)}
+ ${supportingText.join(" • ")} + ${supportingText.length && entities.length ? " • " : nothing} + ${entities.length + ? this.hass.localize( + "ui.panel.config.integrations.config_entry.entities", + { count: entities.length } + ) + : nothing} + ${!this.narrow + ? html` ` + : nothing} +
+ ${!this.narrow + ? html`` + : nothing} + + + + ${this.narrow + ? 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.entities`, + { count: entities.length } + )} + + + ` + : 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} + +
`; + } + + private _getEntities = (): EntityRegistryEntry[] => + this.entities?.filter((entity) => entity.device_id === this.device.id); + + private _handleEditDevice(ev: MouseEvent) { + ev.stopPropagation(); // Prevent triggering the click handler on the list item + showDeviceRegistryDetailDialog(this, { + device: this.device, + updateEntry: async (updates) => { + await updateDeviceRegistryEntry(this.hass, this.device.id, updates); + }, + }); + } + + 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: disable ? "user" : null, + }); + } + + private async _handleDeleteDevice() { + const entry = this.entry; + const confirmed = await showConfirmationDialog(this, { + text: this.hass.localize("ui.panel.config.devices.confirm_delete"), + confirmText: this.hass.localize("ui.common.delete"), + dismissText: this.hass.localize("ui.common.cancel"), + destructive: true, + }); + + if (!confirmed) { + return; + } + + try { + await removeConfigEntryFromDevice( + this.hass!, + this.device.id, + entry.entry_id + ); + } catch (err: any) { + showAlertDialog(this, { + title: this.hass.localize("ui.panel.config.devices.error_delete"), + text: err.message, + }); + } + } + + private _handleNavigateToDevice() { + navigate(`/config/devices/device/${this.device.id}`); + } + + static styles = [ + haStyle, + css` + :host { + border-top: 1px solid var(--divider-color); + } + 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%; + width: 1px; + background: var(--divider-color); + } + a { + text-decoration: none; + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-entry-device-row": HaConfigEntryDeviceRow; + } +} diff --git a/src/panels/config/integrations/ha-config-entry-row.ts b/src/panels/config/integrations/ha-config-entry-row.ts new file mode 100644 index 0000000000..37b1020096 --- /dev/null +++ b/src/panels/config/integrations/ha-config-entry-row.ts @@ -0,0 +1,787 @@ +import { + mdiAlertCircle, + mdiChevronDown, + mdiCogOutline, + mdiDelete, + mdiDevices, + mdiDotsVertical, + mdiDownload, + mdiHandExtendedOutline, + mdiPlayCircleOutline, + mdiPlus, + mdiProgressHelper, + mdiReload, + mdiReloadAlert, + mdiRenameBox, + mdiShapeOutline, + mdiStopCircleOutline, + mdiWrench, +} from "@mdi/js"; +import type { PropertyValues, TemplateResult } from "lit"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import memoizeOne from "memoize-one"; +import { isDevVersion } from "../../../common/config/version"; +import { + deleteApplicationCredential, + fetchApplicationCredentialsConfigEntry, +} from "../../../data/application_credential"; +import { getSignedPath } from "../../../data/auth"; +import type { + ConfigEntry, + DisableConfigEntryResult, + SubEntry, +} from "../../../data/config_entries"; +import { + deleteConfigEntry, + disableConfigEntry, + enableConfigEntry, + ERROR_STATES, + getSubEntries, + RECOVERABLE_STATES, + reloadConfigEntry, + updateConfigEntry, +} from "../../../data/config_entries"; +import type { DeviceRegistryEntry } from "../../../data/device_registry"; +import type { DiagnosticInfo } from "../../../data/diagnostics"; +import { getConfigEntryDiagnosticsDownloadUrl } from "../../../data/diagnostics"; +import type { EntityRegistryEntry } from "../../../data/entity_registry"; +import type { IntegrationManifest } from "../../../data/integration"; +import { + domainToName, + fetchIntegrationManifest, + integrationsWithPanel, +} from "../../../data/integration"; +import { showConfigEntrySystemOptionsDialog } from "../../../dialogs/config-entry-system-options/show-dialog-config-entry-system-options"; +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"; +import { + showAlertDialog, + showConfirmationDialog, + showPromptDialog, +} from "../../lovelace/custom-card-helpers"; +import "./ha-config-entry-device-row"; +import { renderConfigEntryError } from "./ha-config-integration-page"; +import "./ha-config-sub-entry-row"; + +@customElement("ha-config-entry-row") +class HaConfigEntryRow extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean, reflect: true }) public narrow = false; + + @property({ attribute: false }) public manifest?: IntegrationManifest; + + @property({ attribute: false }) public diagnosticHandler?: DiagnosticInfo; + + @property({ attribute: false }) public entities!: EntityRegistryEntry[]; + + @property({ attribute: false }) public entry!: ConfigEntry; + + @state() private _expanded = true; + + @state() private _devicesExpanded = true; + + @state() private _subEntries?: SubEntry[]; + + protected willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has("entry")) { + this._fetchSubEntries(); + } + } + + protected render() { + const item = this.entry; + + let stateText: Parameters | undefined; + let stateTextExtra: TemplateResult | string | undefined; + let icon: string = mdiAlertCircle; + + if (!item.disabled_by && item.state === "not_loaded") { + stateText = ["ui.panel.config.integrations.config_entry.not_loaded"]; + } else if (item.state === "setup_in_progress") { + icon = mdiProgressHelper; + stateText = [ + "ui.panel.config.integrations.config_entry.setup_in_progress", + ]; + } else if (ERROR_STATES.includes(item.state)) { + if (item.state === "setup_retry") { + icon = mdiReloadAlert; + } + stateText = [ + `ui.panel.config.integrations.config_entry.state.${item.state}`, + ]; + stateTextExtra = renderConfigEntryError(this.hass, item); + } + + const devices = this._getDevices(); + const services = this._getServices(); + const entities = this._getEntities(); + + const ownDevices = [...devices, ...services].filter( + (device) => + !device.config_entries_subentries[item.entry_id].length || + device.config_entries_subentries[item.entry_id][0] === null + ); + + const statusLine: (TemplateResult | string)[] = []; + + if (item.disabled_by) { + statusLine.push( + this.hass.localize( + "ui.panel.config.integrations.config_entry.disable.disabled_cause", + { + cause: + this.hass.localize( + `ui.panel.config.integrations.config_entry.disable.disabled_by.${item.disabled_by}` + ) || item.disabled_by, + } + ) + ); + if (item.state === "failed_unload") { + statusLine.push(`. + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.disable_restart_confirm" + )}.`); + } + } else if (!devices.length && !services.length && entities.length) { + statusLine.push( + html`
${this.hass.localize( + "ui.panel.config.integrations.config_entry.entities", + { count: entities.length } + )}` + ); + } + + const configPanel = this._configPanel(item.domain, this.hass.panels); + + const subEntries = this._subEntries || []; + + return html` + 0, + })} + > + ${subEntries.length || ownDevices.length + ? html`` + : nothing} +
+ ${item.title || domainToName(this.hass.localize, item.domain)} +
+
+
${statusLine}
+ ${stateText + ? html` +
+ +
+ ${this.hass.localize(...stateText)}${stateTextExtra + ? html`: ${stateTextExtra}` + : nothing} +
+
+ ` + : nothing} +
+ ${item.disabled_by === "user" + ? html` + ${this.hass.localize("ui.common.enable")} + ` + : configPanel && + (item.domain !== "matter" || + isDevVersion(this.hass.config.version)) && + !stateText + ? html` + ` + : item.supports_options + ? html` + + + ` + : nothing} + + + ${devices.length + ? html` + + + ${this.hass.localize( + `ui.panel.config.integrations.config_entry.devices`, + { count: devices.length } + )} + + + ` + : nothing} + ${services.length + ? html` + + ${this.hass.localize( + `ui.panel.config.integrations.config_entry.services`, + { count: services.length } + )} + + ` + : nothing} + ${entities.length + ? html` + + + ${this.hass.localize( + `ui.panel.config.integrations.config_entry.entities`, + { count: entities.length } + )} + + + ` + : nothing} + ${!item.disabled_by && + RECOVERABLE_STATES.includes(item.state) && + item.supports_unload && + item.source !== "system" + ? html` + + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.reload" + )} + + ` + : nothing} + + + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.rename" + )} + + + ${Object.keys(item.supported_subentry_types).map( + (flowType) => + html` + + ${this.hass.localize( + `component.${item.domain}.config_subentries.${flowType}.initiate_flow.user` + )}` + )} + + + + ${this.diagnosticHandler && item.state === "loaded" + ? html` + + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.download_diagnostics" + )} + + ` + : nothing} + ${!item.disabled_by && + item.supports_reconfigure && + item.source !== "system" + ? html` + + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.reconfigure" + )} + + ` + : nothing} + + + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.system_options" + )} + + ${item.disabled_by === "user" + ? html` + + + ${this.hass.localize("ui.common.enable")} + + ` + : item.source !== "system" + ? html` + + + ${this.hass.localize("ui.common.disable")} + + ` + : nothing} + ${item.source !== "system" + ? html` + + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.delete" + )} + + ` + : nothing} + +
+ ${this._expanded + ? subEntries.length + ? html`${ownDevices.length + ? 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) => + html`` + )} + ` + : nothing} +
`; + } + + private async _fetchSubEntries() { + this._subEntries = this.entry.num_subentries + ? await getSubEntries(this.hass, this.entry.entry_id) + : undefined; + } + + private _configPanel = memoizeOne( + (domain: string, panels: HomeAssistant["panels"]): string | undefined => + Object.values(panels).find( + (panel) => panel.config_panel_domain === domain + )?.url_path || integrationsWithPanel[domain] + ); + + private _getEntities = (): EntityRegistryEntry[] => + this.entities.filter( + (entity) => entity.config_entry_id === this.entry.entry_id + ); + + private _getDevices = (): DeviceRegistryEntry[] => + Object.values(this.hass.devices).filter( + (device) => + device.config_entries.includes(this.entry.entry_id) && + device.entry_type !== "service" + ); + + private _getServices = (): DeviceRegistryEntry[] => + Object.values(this.hass.devices).filter( + (device) => + device.config_entries.includes(this.entry.entry_id) && + device.entry_type === "service" + ); + + private _toggleExpand() { + this._expanded = !this._expanded; + } + + private _toggleOwnDevices() { + this._devicesExpanded = !this._devicesExpanded; + } + + private _showOptions() { + showOptionsFlowDialog(this, this.entry, { manifest: this.manifest }); + } + + // Return an application credentials id for this config entry to prompt the + // user for removal. This is best effort so we don't stop overall removal + // if the integration isn't loaded or there is some other error. + private async _applicationCredentialForRemove(entryId: string) { + try { + return (await fetchApplicationCredentialsConfigEntry(this.hass, entryId)) + .application_credentials_id; + } catch (_err: any) { + // We won't prompt the user to remove credentials + return null; + } + } + + private async _removeApplicationCredential(applicationCredentialsId: string) { + const confirmed = await showConfirmationDialog(this, { + title: this.hass.localize( + "ui.panel.config.integrations.config_entry.application_credentials.delete_title" + ), + text: html`${this.hass.localize( + "ui.panel.config.integrations.config_entry.application_credentials.delete_prompt" + )}, +
+
+ ${this.hass.localize( + "ui.panel.config.integrations.config_entry.application_credentials.delete_detail" + )} +
+
+ + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.application_credentials.learn_more" + )} + `, + destructive: true, + confirmText: this.hass.localize("ui.common.remove"), + dismissText: this.hass.localize( + "ui.panel.config.integrations.config_entry.application_credentials.dismiss" + ), + }); + if (!confirmed) { + return; + } + try { + await deleteApplicationCredential(this.hass, applicationCredentialsId); + } catch (err: any) { + showAlertDialog(this, { + title: this.hass.localize( + "ui.panel.config.integrations.config_entry.application_credentials.delete_error_title" + ), + text: err.message, + }); + } + } + + private async _handleReload() { + const result = await reloadConfigEntry(this.hass, this.entry.entry_id); + const locale_key = result.require_restart + ? "reload_restart_confirm" + : "reload_confirm"; + showAlertDialog(this, { + text: this.hass.localize( + `ui.panel.config.integrations.config_entry.${locale_key}` + ), + }); + } + + private async _handleReconfigure() { + showConfigFlowDialog(this, { + startFlowHandler: this.entry.domain, + showAdvanced: this.hass.userData?.showAdvanced, + manifest: await fetchIntegrationManifest(this.hass, this.entry.domain), + entryId: this.entry.entry_id, + navigateToResult: true, + }); + } + + private async _handleRename() { + const newName = await showPromptDialog(this, { + title: this.hass.localize("ui.panel.config.integrations.rename_dialog"), + defaultValue: this.entry.title, + inputLabel: this.hass.localize( + "ui.panel.config.integrations.rename_input_label" + ), + }); + if (newName === null) { + return; + } + await updateConfigEntry(this.hass, this.entry.entry_id, { + title: newName, + }); + } + + private async _signUrl(ev) { + const anchor = ev.currentTarget; + ev.preventDefault(); + const signedUrl = await getSignedPath( + this.hass, + anchor.getAttribute("href") + ); + fileDownload(signedUrl.path); + } + + private async _handleDisable() { + const entryId = this.entry.entry_id; + + const confirmed = await showConfirmationDialog(this, { + title: this.hass.localize( + "ui.panel.config.integrations.config_entry.disable_confirm_title", + { title: this.entry.title } + ), + text: this.hass.localize( + "ui.panel.config.integrations.config_entry.disable_confirm_text" + ), + confirmText: this.hass!.localize("ui.common.disable"), + dismissText: this.hass!.localize("ui.common.cancel"), + destructive: true, + }); + + if (!confirmed) { + return; + } + let result: DisableConfigEntryResult; + try { + result = await disableConfigEntry(this.hass, entryId); + } 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" + ), + }); + } + } + + private async _handleEnable() { + const entryId = this.entry.entry_id; + + let result: DisableConfigEntryResult; + try { + result = await enableConfigEntry(this.hass, entryId); + } 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.enable_restart_confirm" + ), + }); + } + } + + private async _handleDelete() { + const entryId = this.entry.entry_id; + + const applicationCredentialsId = + await this._applicationCredentialForRemove(entryId); + + const confirmed = await showConfirmationDialog(this, { + title: this.hass.localize( + "ui.panel.config.integrations.config_entry.delete_confirm_title", + { title: this.entry.title } + ), + text: this.hass.localize( + "ui.panel.config.integrations.config_entry.delete_confirm_text" + ), + confirmText: this.hass!.localize("ui.common.delete"), + dismissText: this.hass!.localize("ui.common.cancel"), + destructive: true, + }); + + if (!confirmed) { + return; + } + const result = await deleteConfigEntry(this.hass, entryId); + + if (result.require_restart) { + showAlertDialog(this, { + text: this.hass.localize( + "ui.panel.config.integrations.config_entry.restart_confirm" + ), + }); + } + if (applicationCredentialsId) { + this._removeApplicationCredential(applicationCredentialsId); + } + } + + private _handleSystemOptions() { + showConfigEntrySystemOptionsDialog(this, { + entry: this.entry, + manifest: this.manifest, + }); + } + + private _addSubEntry(ev) { + showSubConfigFlowDialog(this, this.entry, ev.target.flowType, { + startFlowHandler: this.entry.entry_id, + }); + } + + static styles = [ + haStyle, + 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; + } + a ha-icon-button { + color: var( + --md-list-item-trailing-icon-color, + 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; + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-entry-row": HaConfigEntryRow; + } +} diff --git a/src/panels/config/integrations/ha-config-integration-page.ts b/src/panels/config/integrations/ha-config-integration-page.ts index a60d0b32b9..74f5be55d6 100644 --- a/src/panels/config/integrations/ha-config-integration-page.ts +++ b/src/panels/config/integrations/ha-config-integration-page.ts @@ -1,77 +1,37 @@ import { - mdiAlertCircle, - mdiBookshelf, mdiBug, mdiBugPlay, mdiBugStop, - mdiCog, - mdiDelete, - mdiDevices, mdiDotsVertical, - mdiDownload, mdiFileCodeOutline, - mdiHandExtendedOutline, + mdiHelpCircleOutline, mdiOpenInNew, mdiPackageVariant, - mdiPlayCircleOutline, mdiPlus, - mdiProgressHelper, - mdiReload, - mdiReloadAlert, - mdiRenameBox, - mdiShapeOutline, - mdiStopCircleOutline, mdiWeb, - mdiWrench, } from "@mdi/js"; import type { UnsubscribeFunc } from "home-assistant-js-websocket"; import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit"; import { LitElement, css, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; -import { classMap } from "lit/directives/class-map"; import { until } from "lit/directives/until"; import memoizeOne from "memoize-one"; import { isComponentLoaded } from "../../../common/config/is_component_loaded"; -import { isDevVersion } from "../../../common/config/version"; import { caseInsensitiveStringCompare } from "../../../common/string/compare"; import { nextRender } from "../../../common/util/render-status"; import "../../../components/ha-button"; -import "../../../components/ha-card"; -import "../../../components/ha-md-divider"; -import "../../../components/ha-list-item"; import "../../../components/ha-md-button-menu"; +import "../../../components/ha-md-divider"; import "../../../components/ha-md-list"; import "../../../components/ha-md-list-item"; import "../../../components/ha-md-menu-item"; -import { - deleteApplicationCredential, - fetchApplicationCredentialsConfigEntry, -} from "../../../data/application_credential"; import { getSignedPath } from "../../../data/auth"; -import type { - ConfigEntry, - DisableConfigEntryResult, - SubEntry, -} from "../../../data/config_entries"; -import { - ERROR_STATES, - RECOVERABLE_STATES, - deleteConfigEntry, - deleteSubEntry, - disableConfigEntry, - enableConfigEntry, - getConfigEntries, - getSubEntries, - reloadConfigEntry, - updateConfigEntry, -} from "../../../data/config_entries"; +import type { ConfigEntry } from "../../../data/config_entries"; +import { ERROR_STATES, getConfigEntries } from "../../../data/config_entries"; import { ATTENTION_SOURCES } from "../../../data/config_flow"; import type { DeviceRegistryEntry } from "../../../data/device_registry"; import type { DiagnosticInfo } from "../../../data/diagnostics"; -import { - fetchDiagnosticHandler, - getConfigEntryDiagnosticsDownloadUrl, -} from "../../../data/diagnostics"; +import { fetchDiagnosticHandler } from "../../../data/diagnostics"; import type { EntityRegistryEntry } from "../../../data/entity_registry"; import { subscribeEntityRegistry } from "../../../data/entity_registry"; import { fetchEntitySourcesWithCache } from "../../../data/entity_sources"; @@ -85,18 +45,13 @@ import { domainToName, fetchIntegrationManifest, integrationIssuesUrl, - integrationsWithPanel, setIntegrationLogLevel, subscribeLogInfo, } from "../../../data/integration"; -import { showConfigEntrySystemOptionsDialog } from "../../../dialogs/config-entry-system-options/show-dialog-config-entry-system-options"; +import { QUALITY_SCALE_MAP } from "../../../data/integration_quality_scale"; import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow"; -import { showOptionsFlowDialog } from "../../../dialogs/config-flow/show-dialog-options-flow"; -import { - showAlertDialog, - showConfirmationDialog, - showPromptDialog, -} from "../../../dialogs/generic/show-dialog-box"; +import { showSubConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-sub-config-flow"; +import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box"; import "../../../layouts/hass-error-screen"; import "../../../layouts/hass-subpage"; import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; @@ -105,10 +60,10 @@ import type { HomeAssistant } from "../../../types"; import { brandsUrl } from "../../../util/brands-url"; import { documentationUrl } from "../../../util/documentation-url"; import { fileDownload } from "../../../util/file_download"; +import "./ha-config-entry-row"; import type { DataEntryFlowProgressExtended } from "./ha-config-integrations"; import { showAddIntegrationDialog } from "./show-add-integration-dialog"; -import { QUALITY_SCALE_MAP } from "../../../data/integration_quality_scale"; -import { showSubConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-sub-config-flow"; +import { showPickConfigEntryDialog } from "./show-pick-config-entry-dialog"; export const renderConfigEntryError = ( hass: HomeAssistant, @@ -175,15 +130,6 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { @state() private _domainEntities: Record = {}; - @state() private _subEntries: Record = {}; - - private _configPanel = memoizeOne( - (domain: string, panels: HomeAssistant["panels"]): string | undefined => - Object.values(panels).find( - (panel) => panel.config_panel_domain === domain - )?.url_path || integrationsWithPanel[domain] - ); - private _domainConfigEntries = memoizeOne( (domain: string, configEntries?: ConfigEntry[]): ConfigEntry[] => configEntries @@ -225,12 +171,6 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { this._fetchDiagnostics(); this._fetchEntitySources(); } - if ( - changedProperties.has("configEntries") || - changedProperties.has("_extraConfigEntries") - ) { - this._fetchSubEntries(); - } } private async _fetchEntitySources() { @@ -270,6 +210,14 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { this._extraConfigEntries || this.configEntries ); + const supportedSubentryTypes = new Set(); + + configEntries.forEach((entry) => { + Object.keys(entry.supported_subentry_types).forEach((type) => { + supportedSubentryTypes.add(type); + }); + }); + const configEntriesInProgress = this._domainConfigEntriesInProgress( this.domain, this.configEntriesInProgress @@ -309,7 +257,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { ); }); - const devices = this._getDevices(configEntries, this.hass.devices); + const devicesRegs = this._getDevices(configEntries, this.hass.devices); const entities = this._getEntities(configEntries, this._entities); let numberOfEntities = entities.length; @@ -328,242 +276,295 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { } } - const services = !devices.some((device) => device.entry_type !== "service"); + const services = devicesRegs.filter( + (device) => device.entry_type === "service" + ); + const devices = devicesRegs.filter( + (device) => device.entry_type !== "service" + ); return html` - -
-
- -
-
- ${domainToName(this.hass.localize, -
- ${this._manifest?.version != null - ? html`
${this._manifest.version}
` - : nothing} - ${this._manifest?.is_built_in === false - ? html`
+ ${this._manifest + ? html` + + + + ` + : nothing} + ${this._manifest?.config_flow || this._logInfo + ? html` + + ${this._manifest && + (this._manifest.is_built_in || this._manifest.issue_tracker) + ? html` + - - - ${this.hass.localize( - this._manifest.overwrites_built_in - ? "ui.panel.config.integrations.config_entry.custom_overwrites_core" - : "ui.panel.config.integrations.config_entry.custom_integration" - )} - -
` - : nothing} - ${this._manifest?.iot_class?.startsWith("cloud_") - ? html`
- ${this.hass.localize( - "ui.panel.config.integrations.config_entry.depends_on_cloud" + "ui.panel.config.integrations.config_entry.known_issues" )} -
` - : nothing} - ${normalEntries.length === 0 && - this._manifest && - !this._manifest.config_flow && - this.hass.config.components.find( - (comp) => comp.split(".")[0] === this.domain - ) - ? html`
- ${this.hass.localize( - "ui.panel.config.integrations.config_entry.no_config_flow" - )} -
` - : nothing} -
- -
- ${this._manifest?.is_built_in && - this._manifest.quality_scale && - Object.keys(QUALITY_SCALE_MAP).includes( - this._manifest.quality_scale - ) - ? html` - - - - ${this.hass.localize( - QUALITY_SCALE_MAP[this._manifest.quality_scale] - .translationKey - )} - - - - ` - : nothing} - ${devices.length > 0 - ? html` - - - ${this.hass.localize( - `ui.panel.config.integrations.config_entry.${ - services ? "services" : "devices" - }`, - { count: devices.length } - )} - - - ` - : nothing} - ${numberOfEntities > 0 - ? html` - - - ${this.hass.localize( - `ui.panel.config.integrations.config_entry.entities`, - { count: numberOfEntities } - )} - - - ` - : nothing} - ${this._manifest - ? html` - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.documentation" - )} - - - - ` - : nothing} - ${this._manifest && - (this._manifest.is_built_in || this._manifest.issue_tracker) - ? html` - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.known_issues" - )} - - - - ` - : nothing} - ${this._logInfo - ? html` - ${this._logInfo.level === LogSeverity.DEBUG - ? this.hass.localize( - "ui.panel.config.integrations.config_entry.disable_debug_logging" - ) - : this.hass.localize( - "ui.panel.config.integrations.config_entry.enable_debug_logging" - )} + - ` - : nothing} + + ` + : nothing} + ${this._logInfo + ? html` + ${this._logInfo.level === LogSeverity.DEBUG + ? this.hass.localize( + "ui.panel.config.integrations.config_entry.disable_debug_logging" + ) + : this.hass.localize( + "ui.panel.config.integrations.config_entry.enable_debug_logging" + )} + + ` + : nothing} + ` + : nothing} + +
+
+
+
+ ${domainToName(this.hass.localize,
- +
+

${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._manifest?.iot_class?.startsWith("cloud_") + ? html`
+ + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.depends_on_cloud" + )} +
` + : nothing} + ${normalEntries.length === 0 && + this._manifest && + !this._manifest.config_flow && + this.hass.config.components.find( + (comp) => comp.split(".")[0] === this.domain + ) + ? html`
+ ${this.hass.localize( + "ui.panel.config.integrations.config_entry.no_config_flow" + )} +
` + : nothing} + ${this._manifest?.is_built_in && + this._manifest.quality_scale && + Object.keys(QUALITY_SCALE_MAP).includes( + this._manifest.quality_scale + ) + ? html` + + ` + : nothing} +
+
+ ${devices.length + ? html` + + ${this.hass.localize( + `ui.panel.config.integrations.config_entry.devices`, + { count: devices.length } + )} + + ` + : nothing} + ${devices.length && services.length ? " • " : ""} + ${services.length + ? html` + ${this.hass.localize( + `ui.panel.config.integrations.config_entry.services`, + { count: services.length } + )} + ` + : nothing} + ${(devices.length || services.length) && numberOfEntities + ? " • " + : ""} + ${numberOfEntities + ? html` + + ${this.hass.localize( + `ui.panel.config.integrations.config_entry.entities`, + { count: numberOfEntities } + )} + + ` + : nothing} +
+
+
+
+ + ${this._manifest?.integration_type + ? this.hass.localize( + `ui.panel.config.integrations.integration_page.add_${this._manifest.integration_type}` + ) + : this.hass.localize( + `ui.panel.config.integrations.integration_page.add_entry` + )} + + ${Array.from(supportedSubentryTypes).map( + (flowType) => + html` + + ${this.hass.localize( + `component.${this.domain}.config_subentries.${flowType}.initiate_flow.user` + )}` + )} +
-
- ${discoveryFlows.length - ? html` -

+ + ${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` +
+

${this.hass.localize( "ui.panel.config.integrations.discovered" )} -

- +

+ ${discoveryFlows.map( (flow) => html` @@ -578,106 +579,104 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { ` )} -
` - : nothing} - ${attentionFlows.length || attentionEntries.length - ? html` -

+

+ ` + : nothing} + ${attentionFlows.length || attentionEntries.length + ? html` +
+

${this.hass.localize( `ui.panel.config.integrations.integration_page.attention_entries` )} -

- - ${attentionFlows.map((flow) => { - const attention = ATTENTION_SOURCES.includes( - flow.context.source - ); - return html` - ${flow.localized_title} - ${this.hass.localize( - `ui.panel.config.integrations.${ - attention ? "attention" : "discovered" - }` - )} - - `; - })} - ${attentionEntries.map( - (item, index) => - html`${this._renderConfigEntry(item)} - ${index < attentionEntries.length - 1 - ? html` ` - : nothing} ` - )} - - ` - : nothing} + + ${attentionFlows.length + ? html` + ${attentionFlows.map((flow) => { + const attention = ATTENTION_SOURCES.includes( + flow.context.source + ); + return html` + ${flow.localized_title} + ${this.hass.localize( + `ui.panel.config.integrations.${ + attention ? "attention" : "discovered" + }` + )} + ${this.hass.localize( + `ui.panel.config.integrations.${ + attention ? "reconfigure" : "configure" + }` + )} + `; + })} + ` + : nothing} + ${attentionEntries.map( + (item) => + html`` + )} +
+ ` + : nothing} - -

- ${this._manifest?.integration_type - ? this.hass.localize( - `ui.panel.config.integrations.integration_page.entries_${this._manifest.integration_type}` - ) - : this.hass.localize( - `ui.panel.config.integrations.integration_page.entries` - )} -

- ${normalEntries.length === 0 - ? html`
- ${this._manifest && - !this._manifest.config_flow && - this.hass.config.components.find( - (comp) => comp.split(".")[0] === this.domain - ) - ? this.hass.localize( - "ui.panel.config.integrations.integration_page.yaml_entry" - ) - : this.hass.localize( - "ui.panel.config.integrations.integration_page.no_entries" - )} -
` - : html` - ${normalEntries.map( - (item, index) => - html`${this._renderConfigEntry(item)} - ${index < normalEntries.length - 1 - ? html` ` - : nothing}` - )} - `} -
- - ${this._manifest?.integration_type +
+

+ ${this._manifest?.integration_type + ? this.hass.localize( + `ui.panel.config.integrations.integration_page.entries_${this._manifest.integration_type}` + ) + : this.hass.localize( + `ui.panel.config.integrations.integration_page.entries` + )} +

+ ${normalEntries.length === 0 + ? html`
+ ${this._manifest && + !this._manifest.config_flow && + this.hass.config.components.find( + (comp) => comp.split(".")[0] === this.domain + ) ? this.hass.localize( - `ui.panel.config.integrations.integration_page.add_${this._manifest.integration_type}` + "ui.panel.config.integrations.integration_page.yaml_entry" ) : this.hass.localize( - `ui.panel.config.integrations.integration_page.add_entry` + "ui.panel.config.integrations.integration_page.no_entries" )} - -
- +
` + : html` + ${normalEntries.map( + (item) => + html`` + )} + `}
@@ -692,421 +691,6 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { ev.target.style.display = "none"; } - private _renderDeviceLine( - item: ConfigEntry, - devices: DeviceRegistryEntry[], - services: DeviceRegistryEntry[], - entities: EntityRegistryEntry[], - subItem?: SubEntry - ) { - let devicesLine: (TemplateResult | string)[] = []; - for (const [items, localizeKey] of [ - [devices, "devices"], - [services, "services"], - ] as const) { - if (items.length === 0) { - continue; - } - const url = - items.length === 1 - ? `/config/devices/device/${items[0].id}` - : `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}${subItem ? `&sub_entry=${subItem.subentry_id}` : ""}`; - devicesLine.push( - // no white space before/after template on purpose - html`${this.hass.localize( - `ui.panel.config.integrations.config_entry.${localizeKey}`, - { count: items.length } - )}` - ); - } - - if (entities.length) { - devicesLine.push( - // no white space before/after template on purpose - html`${this.hass.localize( - "ui.panel.config.integrations.config_entry.entities", - { count: entities.length } - )}` - ); - } - - if (devicesLine.length === 0) { - devicesLine = [ - this.hass.localize( - "ui.panel.config.integrations.config_entry.no_devices_or_entities" - ), - ]; - } else if (devicesLine.length === 2) { - devicesLine = [ - devicesLine[0], - ` ${this.hass.localize("ui.common.and")} `, - devicesLine[1], - ]; - } else if (devicesLine.length === 3) { - devicesLine = [ - devicesLine[0], - ", ", - devicesLine[1], - ` ${this.hass.localize("ui.common.and")} `, - devicesLine[2], - ]; - } - return devicesLine; - } - - private _renderConfigEntry(item: ConfigEntry) { - let stateText: Parameters | undefined; - let stateTextExtra: TemplateResult | string | undefined; - let icon: string = mdiAlertCircle; - - if (!item.disabled_by && item.state === "not_loaded") { - stateText = ["ui.panel.config.integrations.config_entry.not_loaded"]; - } else if (item.state === "setup_in_progress") { - icon = mdiProgressHelper; - stateText = [ - "ui.panel.config.integrations.config_entry.setup_in_progress", - ]; - } else if (ERROR_STATES.includes(item.state)) { - if (item.state === "setup_retry") { - icon = mdiReloadAlert; - } - stateText = [ - `ui.panel.config.integrations.config_entry.state.${item.state}`, - ]; - stateTextExtra = renderConfigEntryError(this.hass, item); - } - - const devices = this._getConfigEntryDevices(item); - const services = this._getConfigEntryServices(item); - const entities = this._getConfigEntryEntities(item); - - let devicesLine: (TemplateResult | string)[] = []; - - if (item.disabled_by) { - devicesLine.push( - this.hass.localize( - "ui.panel.config.integrations.config_entry.disable.disabled_cause", - { - cause: - this.hass.localize( - `ui.panel.config.integrations.config_entry.disable.disabled_by.${item.disabled_by}` - ) || item.disabled_by, - } - ) - ); - if (item.state === "failed_unload") { - devicesLine.push(`. - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.disable_restart_confirm" - )}.`); - } - } else { - devicesLine = this._renderDeviceLine(item, devices, services, entities); - } - - const configPanel = this._configPanel(item.domain, this.hass.panels); - - const subEntries = this._subEntries[item.entry_id] || []; - - return html` -
- ${item.title || domainToName(this.hass.localize, item.domain)} -
-
-
${devicesLine}
- ${stateText - ? html` -
- -
- ${this.hass.localize(...stateText)}${stateTextExtra - ? html`: ${stateTextExtra}` - : nothing} -
-
- ` - : nothing} -
- ${item.disabled_by === "user" - ? html` - ${this.hass.localize("ui.common.enable")} - ` - : configPanel && - (item.domain !== "matter" || - isDevVersion(this.hass.config.version)) && - !stateText - ? html` - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.configure" - )} - ` - : item.supports_options - ? html` - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.configure" - )} - - ` - : nothing} - - - ${item.disabled_by && devices.length - ? html` - - - ${this.hass.localize( - `ui.panel.config.integrations.config_entry.devices`, - { count: devices.length } - )} - - - ` - : nothing} - ${item.disabled_by && services.length - ? html` - - ${this.hass.localize( - `ui.panel.config.integrations.config_entry.services`, - { count: services.length } - )} - - ` - : nothing} - ${item.disabled_by && entities.length - ? html` - - - ${this.hass.localize( - `ui.panel.config.integrations.config_entry.entities`, - { count: entities.length } - )} - - - ` - : nothing} - ${!item.disabled_by && - RECOVERABLE_STATES.includes(item.state) && - item.supports_unload && - item.source !== "system" - ? html` - - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.reload" - )} - - ` - : nothing} - - - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.rename" - )} - - - ${Object.keys(item.supported_subentry_types).map( - (flowType) => - html` - - ${this.hass.localize( - `component.${item.domain}.config_subentries.${flowType}.initiate_flow.user` - )}` - )} - - - - ${this._diagnosticHandler && item.state === "loaded" - ? html` - - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.download_diagnostics" - )} - - ` - : nothing} - ${!item.disabled_by && - item.supports_reconfigure && - item.source !== "system" - ? html` - - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.reconfigure" - )} - - ` - : nothing} - - - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.system_options" - )} - - ${item.disabled_by === "user" - ? html` - - - ${this.hass.localize("ui.common.enable")} - - ` - : item.source !== "system" - ? html` - - - ${this.hass.localize("ui.common.disable")} - - ` - : nothing} - ${item.source !== "system" - ? html` - - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.delete" - )} - - ` - : nothing} - -
- ${subEntries.map((subEntry) => this._renderSubEntry(item, subEntry))}`; - } - - private _renderSubEntry(configEntry: ConfigEntry, subEntry: SubEntry) { - const devices = this._getConfigEntryDevices(configEntry).filter((device) => - device.config_entries_subentries[configEntry.entry_id]?.includes( - subEntry.subentry_id - ) - ); - const services = this._getConfigEntryServices(configEntry).filter( - (device) => - device.config_entries_subentries[configEntry.entry_id]?.includes( - subEntry.subentry_id - ) - ); - const entities = this._getConfigEntryEntities(configEntry).filter( - (entity) => entity.config_subentry_id === subEntry.subentry_id - ); - - return html` - ${subEntry.title} - ${this.hass.localize( - `component.${configEntry.domain}.config_subentries.${subEntry.subentry_type}.entry_type` - )} - - - ${this._renderDeviceLine( - configEntry, - devices, - services, - entities, - subEntry - )} - ${configEntry.supported_subentry_types[subEntry.subentry_type] - ?.supports_reconfigure - ? html` - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.configure" - )} - - ` - : nothing} - - - - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.delete" - )} - - - `; - } - private async _highlightEntry() { await nextRender(); const entryId = this._searchParms.get("config_entry")!; @@ -1146,27 +730,6 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { } } - private async _fetchSubEntries() { - const subEntriesPromises = ( - this._extraConfigEntries || this.configEntries - )?.map((entry) => - entry.num_subentries - ? getSubEntries(this.hass, entry.entry_id).then((subEntries) => ({ - entry_id: entry.entry_id, - subEntries, - })) - : undefined - ); - if (subEntriesPromises) { - const subEntries = await Promise.all(subEntriesPromises); - this._subEntries = {}; - subEntries.forEach((entry) => { - if (!entry) return; - this._subEntries[entry.entry_id] = entry.subEntries; - }); - } - } - private async _fetchDiagnostics() { if (!this.domain || !isComponentLoaded(this.hass, "diagnostics")) { return; @@ -1239,361 +802,6 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { } ); - private _getConfigEntryEntities = ( - configEntry: ConfigEntry - ): EntityRegistryEntry[] => { - const entries = this._domainConfigEntries( - this.domain, - this._extraConfigEntries || this.configEntries - ); - const entityRegistryEntries = this._getEntities(entries, this._entities); - return entityRegistryEntries.filter( - (entity) => entity.config_entry_id === configEntry.entry_id - ); - }; - - private _getConfigEntryDevices = ( - configEntry: ConfigEntry - ): DeviceRegistryEntry[] => { - const entries = this._domainConfigEntries( - this.domain, - this._extraConfigEntries || this.configEntries - ); - const deviceRegistryEntries = this._getDevices(entries, this.hass.devices); - return Object.values(deviceRegistryEntries).filter( - (device) => - device.config_entries.includes(configEntry.entry_id) && - device.entry_type !== "service" - ); - }; - - private _getConfigEntryServices = ( - configEntry: ConfigEntry - ): DeviceRegistryEntry[] => { - const entries = this._domainConfigEntries( - this.domain, - this._extraConfigEntries || this.configEntries - ); - const deviceRegistryEntries = this._getDevices(entries, this.hass.devices); - return Object.values(deviceRegistryEntries).filter( - (device) => - device.config_entries.includes(configEntry.entry_id) && - device.entry_type === "service" - ); - }; - - private _showOptions(ev) { - showOptionsFlowDialog( - this, - ev.target.closest(".config_entry").configEntry, - { manifest: this._manifest } - ); - } - - private _handleRename(ev: Event): void { - this._editEntryName( - ((ev.target as HTMLElement).closest(".config_entry") as any).configEntry - ); - } - - private _handleReload(ev: Event): void { - this._reloadIntegration( - ((ev.target as HTMLElement).closest(".config_entry") as any).configEntry - ); - } - - private _handleReconfigure(ev: Event): void { - this._reconfigureIntegration( - ((ev.target as HTMLElement).closest(".config_entry") as any).configEntry - ); - } - - private _handleDelete(ev: Event): void { - this._removeIntegration( - ((ev.target as HTMLElement).closest(".config_entry") as any).configEntry - ); - } - - private async _handleReconfigureSub(ev: Event): Promise { - const configEntry = ( - (ev.target as HTMLElement).closest(".sub-entry") as any - ).configEntry; - const subEntry = ((ev.target as HTMLElement).closest(".sub-entry") as any) - .subEntry; - - showSubConfigFlowDialog( - this, - configEntry, - subEntry.flowType || subEntry.subentry_type, - { - startFlowHandler: configEntry.entry_id, - subEntryId: subEntry.subentry_id, - } - ); - } - - private async _handleDeleteSub(ev: Event): Promise { - const configEntry = ( - (ev.target as HTMLElement).closest(".sub-entry") as any - ).configEntry; - const subEntry = ((ev.target as HTMLElement).closest(".sub-entry") as any) - .subEntry; - const confirmed = await showConfirmationDialog(this, { - title: this.hass.localize( - "ui.panel.config.integrations.config_entry.delete_confirm_title", - { title: subEntry.title } - ), - text: this.hass.localize( - "ui.panel.config.integrations.config_entry.delete_confirm_text" - ), - confirmText: this.hass!.localize("ui.common.delete"), - dismissText: this.hass!.localize("ui.common.cancel"), - destructive: true, - }); - - if (!confirmed) { - return; - } - await deleteSubEntry(this.hass, configEntry.entry_id, subEntry.subentry_id); - } - - private _handleDisable(ev: Event): void { - this._disableIntegration( - ((ev.target as HTMLElement).closest(".config_entry") as any).configEntry - ); - } - - private _handleEnable(ev: Event): void { - this._enableIntegration( - ((ev.target as HTMLElement).closest(".config_entry") as any).configEntry - ); - } - - private _handleSystemOptions(ev: Event): void { - this._showSystemOptions( - ((ev.target as HTMLElement).closest(".config_entry") as any).configEntry - ); - } - - private _showSystemOptions(configEntry: ConfigEntry) { - showConfigEntrySystemOptionsDialog(this, { - entry: configEntry, - manifest: this._manifest, - }); - } - - private async _disableIntegration(configEntry: ConfigEntry) { - const entryId = configEntry.entry_id; - - const confirmed = await showConfirmationDialog(this, { - title: this.hass.localize( - "ui.panel.config.integrations.config_entry.disable_confirm_title", - { title: configEntry.title } - ), - text: this.hass.localize( - "ui.panel.config.integrations.config_entry.disable_confirm_text" - ), - confirmText: this.hass!.localize("ui.common.disable"), - dismissText: this.hass!.localize("ui.common.cancel"), - destructive: true, - }); - - if (!confirmed) { - return; - } - let result: DisableConfigEntryResult; - try { - result = await disableConfigEntry(this.hass, entryId); - } 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" - ), - }); - } - } - - private async _enableIntegration(configEntry: ConfigEntry) { - const entryId = configEntry.entry_id; - - let result: DisableConfigEntryResult; - try { - result = await enableConfigEntry(this.hass, entryId); - } 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.enable_restart_confirm" - ), - }); - } - } - - private async _removeIntegration(configEntry: ConfigEntry) { - const entryId = configEntry.entry_id; - - const applicationCredentialsId = - await this._applicationCredentialForRemove(entryId); - - const confirmed = await showConfirmationDialog(this, { - title: this.hass.localize( - "ui.panel.config.integrations.config_entry.delete_confirm_title", - { title: configEntry.title } - ), - text: this.hass.localize( - "ui.panel.config.integrations.config_entry.delete_confirm_text" - ), - confirmText: this.hass!.localize("ui.common.delete"), - dismissText: this.hass!.localize("ui.common.cancel"), - destructive: true, - }); - - if (!confirmed) { - return; - } - const result = await deleteConfigEntry(this.hass, entryId); - - if (result.require_restart) { - showAlertDialog(this, { - text: this.hass.localize( - "ui.panel.config.integrations.config_entry.restart_confirm" - ), - }); - } - if (applicationCredentialsId) { - this._removeApplicationCredential(applicationCredentialsId); - } - } - - // Return an application credentials id for this config entry to prompt the - // user for removal. This is best effort so we don't stop overall removal - // if the integration isn't loaded or there is some other error. - private async _applicationCredentialForRemove(entryId: string) { - try { - return (await fetchApplicationCredentialsConfigEntry(this.hass, entryId)) - .application_credentials_id; - } catch (_err: any) { - // We won't prompt the user to remove credentials - return null; - } - } - - private async _removeApplicationCredential(applicationCredentialsId: string) { - const confirmed = await showConfirmationDialog(this, { - title: this.hass.localize( - "ui.panel.config.integrations.config_entry.application_credentials.delete_title" - ), - text: html`${this.hass.localize( - "ui.panel.config.integrations.config_entry.application_credentials.delete_prompt" - )}, -
-
- ${this.hass.localize( - "ui.panel.config.integrations.config_entry.application_credentials.delete_detail" - )} -
-
- - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.application_credentials.learn_more" - )} - `, - destructive: true, - confirmText: this.hass.localize("ui.common.remove"), - dismissText: this.hass.localize( - "ui.panel.config.integrations.config_entry.application_credentials.dismiss" - ), - }); - if (!confirmed) { - return; - } - try { - await deleteApplicationCredential(this.hass, applicationCredentialsId); - } catch (err: any) { - showAlertDialog(this, { - title: this.hass.localize( - "ui.panel.config.integrations.config_entry.application_credentials.delete_error_title" - ), - text: err.message, - }); - } - } - - private async _reloadIntegration(configEntry: ConfigEntry) { - const entryId = configEntry.entry_id; - - const result = await reloadConfigEntry(this.hass, entryId); - const locale_key = result.require_restart - ? "reload_restart_confirm" - : "reload_confirm"; - showAlertDialog(this, { - text: this.hass.localize( - `ui.panel.config.integrations.config_entry.${locale_key}` - ), - }); - } - - private async _reconfigureIntegration(configEntry: ConfigEntry) { - showConfigFlowDialog(this, { - startFlowHandler: configEntry.domain, - showAdvanced: this.hass.userData?.showAdvanced, - manifest: await fetchIntegrationManifest(this.hass, configEntry.domain), - entryId: configEntry.entry_id, - navigateToResult: true, - }); - } - - private async _editEntryName(configEntry: ConfigEntry) { - const newName = await showPromptDialog(this, { - title: this.hass.localize("ui.panel.config.integrations.rename_dialog"), - defaultValue: configEntry.title, - inputLabel: this.hass.localize( - "ui.panel.config.integrations.rename_input_label" - ), - }); - if (newName === null) { - return; - } - await updateConfigEntry(this.hass, configEntry.entry_id, { - title: newName, - }); - } - - private async _signUrl(ev) { - const anchor = ev.currentTarget; - ev.preventDefault(); - const signedUrl = await getSignedPath( - this.hass, - anchor.getAttribute("href") - ); - fileDownload(signedUrl.path); - } - private async _addIntegration() { if (!this._manifest?.config_flow) { showAlertDialog(this, { @@ -1636,8 +844,33 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { } private async _addSubEntry(ev) { - showSubConfigFlowDialog(this, ev.target.entry, ev.target.flowType, { - startFlowHandler: ev.target.entry.entry_id, + const flowType = ev.target.flowType; + + const configEntries = this._domainConfigEntries( + this.domain, + this._extraConfigEntries || this.configEntries + ).filter((entry) => entry.source !== "ignore"); + + if (!configEntries.length) { + return; + } + + if (configEntries.length === 1 && configEntries[0].state === "loaded") { + showSubConfigFlowDialog(this, configEntries[0], flowType, { + startFlowHandler: configEntries[0].entry_id, + }); + return; + } + + showPickConfigEntryDialog(this, { + domain: this.domain, + subFlowType: flowType, + configEntries, + configEntryPicked: (entry) => { + showSubConfigFlowDialog(this, entry, flowType, { + startFlowHandler: entry.entry_id, + }); + }, }); } @@ -1650,35 +883,50 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { flex-wrap: wrap; margin: auto; max-width: 1000px; - margin-top: 32px; - margin-bottom: 32px; + padding: 32px; + } + .container > * { + flex-grow: 1; + } + .header { + display: flex; + flex-wrap: wrap; + gap: 24px; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; + } + .title-container { + display: flex; + align-items: center; + } + .title { + display: flex; + gap: 4px; + flex-direction: column; + justify-content: space-between; + } + .title h1 { + font-family: var(--ha-font-family-body); + font-size: 32px; + font-weight: 700; + line-height: 40px; + text-align: left; + text-underline-position: from-font; + text-decoration-skip-ink: none; + margin: 0; + } + .sub { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; + align-items: center; } .card-content { padding: 16px 0 8px; } - .column { - width: 33%; - flex-grow: 1; - } - .column.small { - max-width: 300px; - } - .column, - .fullwidth { - padding: 8px; - box-sizing: border-box; - } - .column > *:not(:first-child) { - margin-top: 16px; - } - - :host([narrow]) .column { - width: 100%; - max-width: unset; - } - :host([narrow]) .container { - margin-top: 0; + padding: 16px; } .card-header { padding-bottom: 0; @@ -1689,12 +937,15 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { .logo-container { display: flex; justify-content: center; - margin-bottom: 8px; + margin-right: 16px; + margin-inline-end: 16px; + margin-inline-start: initial; + padding: 0 8px; + } + .logo-container img { + width: 80px; } .version { - padding-top: 8px; - display: flex; - justify-content: center; color: var(--secondary-text-color); } .overview .card-actions { @@ -1710,12 +961,32 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { mask-position: left; } } + .actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + .section { + width: 100%; + } + .section-header { + margin-inline-start: 16px; + margin-top: 6px; + margin-bottom: 6px; + font-family: var(--ha-font-family-body); + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.10000000149011612px; + text-align: left; + text-underline-position: from-font; + text-decoration-skip-ink: none; + color: var(--secondary-text-color); + } .integration-info { display: flex; align-items: center; - gap: 20px; - padding: 0 20px; - min-height: 48px; + gap: 8px; } .integration-info ha-svg-icon { min-width: 24px; @@ -1747,14 +1018,30 @@ 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; } + ha-md-list { + border: 1px solid var(--divider-color); + border-radius: 8px; + padding: 0; + } + .discovered { + --md-list-container-color: rgba(var(--rgb-success-color), 0.2); + } + .discovered ha-button { + --mdc-theme-primary: var(--success-color); + } + .attention { + --md-list-container-color: rgba(var(--rgb-warning-color), 0.2); + } + .attention ha-button { + --mdc-theme-primary: var(--warning-color); + } ha-md-list-item { + --md-list-item-top-space: 4px; + --md-list-item-bottom-space: 4px; position: relative; } ha-md-list-item.discovered { @@ -1770,8 +1057,9 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { pointer-events: none; content: ""; } - ha-md-list-item.sub-entry { - --md-list-item-leading-space: 50px; + ha-config-entry-row { + display: block; + margin-bottom: 16px; } a { text-decoration: none; @@ -1779,19 +1067,9 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { .highlight::after { background-color: var(--info-color); } - .attention { - primary-color: var(--error-color); - } .warning { color: var(--error-color); } - .state-error { - --state-message-color: var(--error-color); - --text-on-state-color: var(--text-primary-color); - } - .state-error::after { - background-color: var(--error-color); - } .state-failed-unload { --state-message-color: var(--warning-color); --text-on-state-color: var(--primary-text-color); @@ -1837,6 +1115,13 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { margin-top: 8px; margin-bottom: 8px; } + a[slot="toolbar-icon"] { + color: var(--sidebar-icon-color); + } + ha-svg-icon.open-external { + min-width: 14px; + width: 14px; + } `, ]; } 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`` + : nothing} + ${subEntry.title} + ${this.hass.localize( + `component.${configEntry.domain}.config_subentries.${subEntry.subentry_type}.entry_type` + )} + ${configEntry.supported_subentry_types[subEntry.subentry_type] + ?.supports_reconfigure + ? html` + + + ` + : nothing} + + + ${devices.length || services.length + ? html` + + + ${this.hass.localize( + `ui.panel.config.integrations.config_entry.devices`, + { count: devices.length } + )} + + + ` + : nothing} + ${services.length + ? html` + + ${this.hass.localize( + `ui.panel.config.integrations.config_entry.services`, + { count: services.length } + )} + + ` + : nothing} + ${entities.length + ? html` + + + ${this.hass.localize( + `ui.panel.config.integrations.config_entry.entities`, + { count: entities.length } + )} + + + ` + : nothing} + + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.delete" + )} + + + + ${this._expanded + ? html` + ${devices.map( + (device) => + html`` + )} + ${services.map( + (service) => + html`` + )} + ` + : nothing} + `; + } + + private _toggleExpand() { + this._expanded = !this._expanded; + } + + private _getEntities = (): EntityRegistryEntry[] => + this.entities.filter( + (entity) => entity.config_subentry_id === this.subEntry.subentry_id + ); + + private _getDevices = (): DeviceRegistryEntry[] => + Object.values(this.hass.devices).filter( + (device) => + device.config_entries_subentries[this.entry.entry_id]?.includes( + this.subEntry.subentry_id + ) && device.entry_type !== "service" + ); + + private _getServices = (): DeviceRegistryEntry[] => + Object.values(this.hass.devices).filter( + (device) => + device.config_entries_subentries[this.entry.entry_id]?.includes( + this.subEntry.subentry_id + ) && device.entry_type === "service" + ); + + private async _handleReconfigureSub(): Promise { + showSubConfigFlowDialog(this, this.entry, this.subEntry.subentry_type, { + startFlowHandler: this.entry.entry_id, + subEntryId: this.subEntry.subentry_id, + }); + } + + private async _handleDeleteSub(): Promise { + const confirmed = await showConfirmationDialog(this, { + title: this.hass.localize( + "ui.panel.config.integrations.config_entry.delete_confirm_title", + { title: this.subEntry.title } + ), + text: this.hass.localize( + "ui.panel.config.integrations.config_entry.delete_confirm_text" + ), + confirmText: this.hass!.localize("ui.common.delete"), + dismissText: this.hass!.localize("ui.common.cancel"), + destructive: true, + }); + + if (!confirmed) { + return; + } + await deleteSubEntry( + this.hass, + this.entry.entry_id, + this.subEntry.subentry_id + ); + } + + 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); + border-radius: var(--ha-card-border-radius, 12px); + padding: 0; + margin: 16px; + margin-top: 0; + } + ha-md-list-item.has-subentries { + border-bottom: 1px solid var(--divider-color); + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-sub-entry-row": HaConfigSubEntryRow; + } +} diff --git a/src/panels/config/integrations/integration-panels/dhcp/dhcp-config-panel.ts b/src/panels/config/integrations/integration-panels/dhcp/dhcp-config-panel.ts index 683719ee43..fdf8768c6f 100644 --- a/src/panels/config/integrations/integration-panels/dhcp/dhcp-config-panel.ts +++ b/src/panels/config/integrations/integration-panels/dhcp/dhcp-config-panel.ts @@ -60,6 +60,7 @@ export class DHCPConfigPanel extends SubscribeMixin(LitElement) { title: localize("ui.panel.config.dhcp.ip_address"), filterable: true, sortable: true, + type: "ip", }, }; diff --git a/src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-configure-device.ts b/src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-configure-device.ts index 721c1a9417..9155086490 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-configure-device.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/add-node/zwave-js-add-node-configure-device.ts @@ -80,9 +80,7 @@ export class ZWaveJsAddNodeConfigureDevice extends LitElement { options: [ { value: Protocols.ZWaveLongRange.toString(), - label: localize( - "ui.panel.config.zwave_js.add_node.configure_device.long_range_label" - ), + label: "Long Range", // brand name and we should not translate that description: localize( "ui.panel.config.zwave_js.add_node.configure_device.long_range_description" ), diff --git a/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-remove-failed-node.ts b/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-remove-failed-node.ts deleted file mode 100644 index e5b46004a1..0000000000 --- a/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-remove-failed-node.ts +++ /dev/null @@ -1,234 +0,0 @@ -import "@material/mwc-button/mwc-button"; -import { mdiCheckCircle, mdiCloseCircle, mdiRobotDead } from "@mdi/js"; -import type { UnsubscribeFunc } from "home-assistant-js-websocket"; -import type { CSSResultGroup } from "lit"; -import { css, html, LitElement, nothing } from "lit"; -import { customElement, property, state } from "lit/decorators"; -import { fireEvent } from "../../../../../common/dom/fire_event"; -import "../../../../../components/ha-spinner"; -import { createCloseHeading } from "../../../../../components/ha-dialog"; -import type { ZWaveJSRemovedNode } from "../../../../../data/zwave_js"; -import { removeFailedZwaveNode } from "../../../../../data/zwave_js"; -import { haStyleDialog } from "../../../../../resources/styles"; -import type { HomeAssistant } from "../../../../../types"; -import type { ZWaveJSRemoveFailedNodeDialogParams } from "./show-dialog-zwave_js-remove-failed-node"; - -@customElement("dialog-zwave_js-remove-failed-node") -class DialogZWaveJSRemoveFailedNode extends LitElement { - @property({ attribute: false }) public hass!: HomeAssistant; - - @state() private device_id?: string; - - @state() private _status = ""; - - @state() private _error?: any; - - @state() private _node?: ZWaveJSRemovedNode; - - private _subscribed?: Promise; - - public disconnectedCallback(): void { - super.disconnectedCallback(); - this._unsubscribe(); - } - - public async showDialog( - params: ZWaveJSRemoveFailedNodeDialogParams - ): Promise { - this.device_id = params.device_id; - } - - public closeDialog(): void { - this._unsubscribe(); - this.device_id = undefined; - this._status = ""; - - fireEvent(this, "dialog-closed", { dialog: this.localName }); - } - - public closeDialogFinished(): void { - history.back(); - this.closeDialog(); - } - - protected render() { - if (!this.device_id) { - return nothing; - } - - return html` - - ${this._status === "" - ? html` -
- -
- ${this.hass.localize( - "ui.panel.config.zwave_js.remove_failed_node.introduction" - )} -
-
- - ${this.hass.localize( - "ui.panel.config.zwave_js.remove_failed_node.remove_device" - )} - - ` - : ``} - ${this._status === "started" - ? html` -
- -
-

- - ${this.hass.localize( - "ui.panel.config.zwave_js.remove_failed_node.in_progress" - )} - -

-
-
- ` - : ``} - ${this._status === "failed" - ? html` -
- -
-

- ${this.hass.localize( - "ui.panel.config.zwave_js.remove_failed_node.removal_failed" - )} -

- ${this._error - ? html`

${this._error.message}

` - : ``} -
-
- - ${this.hass.localize("ui.common.close")} - - ` - : ``} - ${this._status === "finished" - ? html` -
- -
-

- ${this.hass.localize( - "ui.panel.config.zwave_js.remove_failed_node.removal_finished", - { id: this._node!.node_id } - )} -

-
-
- - ${this.hass.localize("ui.common.close")} - - ` - : ``} -
- `; - } - - private _startExclusion(): void { - if (!this.hass) { - return; - } - this._status = "started"; - this._subscribed = removeFailedZwaveNode( - this.hass, - this.device_id!, - (message: any) => this._handleMessage(message) - ).catch((error) => { - this._status = "failed"; - this._error = error; - return undefined; - }); - } - - private _handleMessage(message: any): void { - if (message.event === "exclusion started") { - this._status = "started"; - } - if (message.event === "node removed") { - this._status = "finished"; - this._node = message.node; - this._unsubscribe(); - } - } - - private async _unsubscribe(): Promise { - if (this._subscribed) { - const unsubFunc = await this._subscribed; - if (unsubFunc instanceof Function) { - unsubFunc(); - } - this._subscribed = undefined; - } - if (this._status !== "finished") { - this._status = ""; - } - } - - static get styles(): CSSResultGroup { - return [ - haStyleDialog, - css` - .success { - color: var(--success-color); - } - - .failed { - color: var(--warning-color); - } - - .flex-container { - display: flex; - align-items: center; - } - - ha-svg-icon { - width: 68px; - height: 48px; - } - - .flex-container ha-spinner, - .flex-container ha-svg-icon { - margin-right: 20px; - margin-inline-end: 20px; - margin-inline-start: initial; - } - `, - ]; - } -} - -declare global { - interface HTMLElementTagNameMap { - "dialog-zwave_js-remove-failed-node": DialogZWaveJSRemoveFailedNode; - } -} diff --git a/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-remove-node.ts b/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-remove-node.ts index 528e35740d..58e88b3c00 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-remove-node.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-remove-node.ts @@ -2,6 +2,7 @@ import { mdiCheckCircle, mdiClose, mdiCloseCircle, + mdiRobotDead, mdiVectorSquareRemove, } from "@mdi/js"; import type { UnsubscribeFunc } from "home-assistant-js-websocket"; @@ -17,6 +18,14 @@ import "../../../../../components/ha-spinner"; import { haStyleDialog } from "../../../../../resources/styles"; import type { HomeAssistant } from "../../../../../types"; import type { ZWaveJSRemoveNodeDialogParams } from "./show-dialog-zwave_js-remove-node"; +import { + fetchZwaveNodeStatus, + NodeStatus, + removeFailedZwaveNode, +} from "../../../../../data/zwave_js"; +import "../../../../../components/ha-list-item"; +import "../../../../../components/ha-icon-next"; +import type { DeviceRegistryEntry } from "../../../../../data/device_registry"; const EXCLUSION_TIMEOUT_SECONDS = 120; @@ -30,10 +39,16 @@ export interface ZWaveJSRemovedNode { class DialogZWaveJSRemoveNode extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; - @state() private entry_id?: string; + @state() private _entryId?: string; + + @state() private _deviceId?: string; + + private _device?: DeviceRegistryEntry; @state() private _step: | "start" + | "start_exclusion" + | "start_removal" | "exclusion" | "remove" | "finished" @@ -42,7 +57,7 @@ class DialogZWaveJSRemoveNode extends LitElement { @state() private _node?: ZWaveJSRemovedNode; - @state() private _removedCallback?: () => void; + @state() private _onClose?: () => void; private _removeNodeTimeoutHandle?: number; @@ -58,15 +73,23 @@ class DialogZWaveJSRemoveNode extends LitElement { public async showDialog( params: ZWaveJSRemoveNodeDialogParams ): Promise { - this.entry_id = params.entry_id; - this._removedCallback = params.removedCallback; - if (params.skipConfirmation) { + this._entryId = params.entryId; + this._deviceId = params.deviceId; + this._onClose = params.onClose; + if (this._deviceId) { + const nodeStatus = await fetchZwaveNodeStatus(this.hass, this._deviceId!); + this._device = this.hass.devices[this._deviceId]; + this._step = + nodeStatus.status === NodeStatus.Dead ? "start_removal" : "start"; + } else if (params.skipConfirmation) { this._startExclusion(); + } else { + this._step = "start_exclusion"; } } protected render() { - if (!this.entry_id) { + if (!this._entryId) { return nothing; } @@ -75,7 +98,12 @@ class DialogZWaveJSRemoveNode extends LitElement { ); return html` - + + + `; + } + + if (this._step === "start_removal") { + return html` + +

+ ${this.hass.localize( + "ui.panel.config.zwave_js.remove_node.failed_node_intro", + { name: this._device!.name_by_user || this._device!.name } + )} +

+ `; + } + + if (this._step === "start_exclusion") { + return html` + +

+ ${this.hass.localize( + "ui.panel.config.zwave_js.remove_node.exclusion_intro" + )} +

`; } @@ -143,30 +212,59 @@ class DialogZWaveJSRemoveNode extends LitElement { `; } - private _renderAction(): TemplateResult { + private _renderAction() { + if (this._step === "start") { + return nothing; + } + + if (this._step === "start_removal") { + return html` + + ${this.hass.localize("ui.common.cancel")} + + + ${this.hass.localize("ui.common.remove")} + + `; + } + + if (this._step === "start_exclusion") { + return html` + + ${this.hass.localize("ui.common.cancel")} + + + ${this.hass.localize( + "ui.panel.config.zwave_js.remove_node.start_exclusion" + )} + + `; + } + return html` - + ${this.hass.localize( - this._step === "start" - ? "ui.panel.config.zwave_js.remove_node.start_exclusion" - : this._step === "exclusion" - ? "ui.panel.config.zwave_js.remove_node.cancel_exclusion" - : "ui.common.close" + this._step === "exclusion" + ? "ui.panel.config.zwave_js.remove_node.cancel_exclusion" + : "ui.common.close" )} `; } - private _startExclusion(): void { + private _startExclusion() { this._subscribed = this.hass.connection - .subscribeMessage((message) => this._handleMessage(message), { + .subscribeMessage(this._handleMessage, { type: "zwave_js/remove_node", - entry_id: this.entry_id, + entry_id: this._entryId, }) .catch((err) => { this._step = "failed"; @@ -180,7 +278,20 @@ class DialogZWaveJSRemoveNode extends LitElement { }, EXCLUSION_TIMEOUT_SECONDS * 1000); } - private _handleMessage(message: any): void { + private _startRemoval() { + this._subscribed = removeFailedZwaveNode( + this.hass, + this._deviceId!, + this._handleMessage + ).catch((err) => { + this._step = "failed"; + this._error = err.message; + return undefined; + }); + this._step = "remove"; + } + + private _handleMessage = (message: any) => { if (message.event === "exclusion failed") { this._unsubscribe(); this._step = "failed"; @@ -192,17 +303,14 @@ class DialogZWaveJSRemoveNode extends LitElement { this._step = "finished"; this._node = message.node; this._unsubscribe(); - if (this._removedCallback) { - this._removedCallback(); - } } - } + }; private _stopExclusion(): void { try { this.hass.callWS({ type: "zwave_js/stop_exclusion", - entry_id: this.entry_id, + entry_id: this._entryId, }); } catch (err) { // eslint-disable-next-line no-console @@ -224,10 +332,16 @@ class DialogZWaveJSRemoveNode extends LitElement { }; public closeDialog(): void { - this._unsubscribe(); - this.entry_id = undefined; - this._step = "start"; + this._entryId = undefined; + } + public handleDialogClosed(): void { + this._unsubscribe(); + this._entryId = undefined; + this._step = "start"; + if (this._onClose) { + this._onClose(); + } fireEvent(this, "dialog-closed", { dialog: this.localName }); } @@ -266,6 +380,14 @@ class DialogZWaveJSRemoveNode extends LitElement { ha-alert { width: 100%; } + + .menu-options { + align-self: stretch; + } + + ha-list-item { + --mdc-list-side-padding: 24px; + } `, ]; } diff --git a/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-update-firmware-node.ts b/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-update-firmware-node.ts index 706d394b85..cf068f7ec5 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-update-firmware-node.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-update-firmware-node.ts @@ -1,4 +1,3 @@ -import "@material/mwc-button/mwc-button"; import "@material/mwc-linear-progress/mwc-linear-progress"; import { mdiCheckCircle, mdiCloseCircle, mdiFileUpload } from "@mdi/js"; import type { UnsubscribeFunc } from "home-assistant-js-websocket"; @@ -37,6 +36,7 @@ import { } from "../../../../../dialogs/generic/show-dialog-box"; import { haStyleDialog } from "../../../../../resources/styles"; import type { HomeAssistant } from "../../../../../types"; +import "../../../../../components/ha-button"; import type { ZWaveJSUpdateFirmwareNodeDialogParams } from "./show-dialog-zwave_js-update-firmware-node"; const firmwareTargetSchema: HaFormSchema[] = [ @@ -130,7 +130,7 @@ class DialogZWaveJSUpdateFirmwareNode extends LitElement { .schema=${firmwareTargetSchema} @value-changed=${this._firmwareTargetChanged} >`} - `; + `; const status = this._updateFinishedMessage ? this._updateFinishedMessage.success @@ -153,13 +153,23 @@ class DialogZWaveJSUpdateFirmwareNode extends LitElement { const abortFirmwareUpdateButton = this._nodeStatus.is_controller_node ? nothing : html` - + ${this.hass.localize( "ui.panel.config.zwave_js.update_firmware.abort" )} - + `; + const closeButton = html` + + ${this.hass.localize("ui.common.close")} + + `; + return html` - ${abortFirmwareUpdateButton} + ${abortFirmwareUpdateButton} ${closeButton} ` : this._updateProgressMessage && !this._updateFinishedMessage ? html` @@ -242,7 +252,7 @@ class DialogZWaveJSUpdateFirmwareNode extends LitElement { } )}

- ${abortFirmwareUpdateButton} + ${abortFirmwareUpdateButton} ${closeButton} ` : html`
diff --git a/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-remove-failed-node.ts b/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-remove-failed-node.ts deleted file mode 100644 index ae902f28ae..0000000000 --- a/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-remove-failed-node.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { fireEvent } from "../../../../../common/dom/fire_event"; - -export interface ZWaveJSRemoveFailedNodeDialogParams { - device_id: string; -} - -export const loadRemoveFailedNodeDialog = () => - import("./dialog-zwave_js-remove-failed-node"); - -export const showZWaveJSRemoveFailedNodeDialog = ( - element: HTMLElement, - removeFailedNodeDialogParams: ZWaveJSRemoveFailedNodeDialogParams -): void => { - fireEvent(element, "show-dialog", { - dialogTag: "dialog-zwave_js-remove-failed-node", - dialogImport: loadRemoveFailedNodeDialog, - dialogParams: removeFailedNodeDialogParams, - }); -}; diff --git a/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-remove-node.ts b/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-remove-node.ts index ac5fe5b063..e501a92140 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-remove-node.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-remove-node.ts @@ -1,9 +1,10 @@ import { fireEvent } from "../../../../../common/dom/fire_event"; export interface ZWaveJSRemoveNodeDialogParams { - entry_id: string; + entryId: string; + deviceId?: string; skipConfirmation?: boolean; - removedCallback?: () => void; + onClose?: () => void; } export const loadRemoveNodeDialog = () => diff --git a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts index f82c38de9c..70776bb5bd 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts @@ -414,7 +414,7 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) { InclusionState.SmartStart)} > ${this.hass.localize( - "ui.panel.config.zwave_js.common.remove_node" + "ui.panel.config.zwave_js.common.remove_a_node" )} { if (!this.configEntryId) { return; } @@ -638,7 +638,7 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) { this._dataCollectionOptIn = dataCollectionStatus.opted_in === true || dataCollectionStatus.enabled === true; - } + }; private async _addNodeClicked() { this._openInclusionDialog(); @@ -646,10 +646,10 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) { private async _removeNodeClicked() { showZWaveJSRemoveNodeDialog(this, { - entry_id: this.configEntryId!, + entryId: this.configEntryId!, skipConfirmation: this._network?.controller.inclusion_state === InclusionState.Excluding, - removedCallback: () => this._fetchData(), + onClose: this._fetchData, }); } diff --git a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-provisioned.ts b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-provisioned.ts index 38a26852bd..e4b13608d6 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-provisioned.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-provisioned.ts @@ -123,18 +123,21 @@ class ZWaveJSProvisioned extends LitElement { } private _unprovision = async (ev) => { - const dsk = ev.currentTarget.provisioningEntry.dsk; + const { dsk, nodeId } = ev.currentTarget.provisioningEntry; const confirm = await showConfirmationDialog(this, { title: this.hass.localize( "ui.panel.config.zwave_js.provisioned.confirm_unprovision_title" ), text: this.hass.localize( - "ui.panel.config.zwave_js.provisioned.confirm_unprovision_text" + nodeId + ? "ui.panel.config.zwave_js.provisioned.confirm_unprovision_text_included" + : "ui.panel.config.zwave_js.provisioned.confirm_unprovision_text" ), confirmText: this.hass.localize( - "ui.panel.config.zwave_js.provisioned.unprovison" + "ui.panel.config.zwave_js.provisioned.unprovision" ), + destructive: true, }); if (!confirm) { diff --git a/src/panels/config/integrations/show-pick-config-entry-dialog.ts b/src/panels/config/integrations/show-pick-config-entry-dialog.ts new file mode 100644 index 0000000000..0bd3fbd604 --- /dev/null +++ b/src/panels/config/integrations/show-pick-config-entry-dialog.ts @@ -0,0 +1,20 @@ +import { fireEvent } from "../../../common/dom/fire_event"; +import type { ConfigEntry } from "../../../data/config_entries"; + +export interface PickConfigEntryDialogParams { + domain: string; + subFlowType: string; + configEntries: ConfigEntry[]; + configEntryPicked: (configEntry: ConfigEntry) => void; +} + +export const showPickConfigEntryDialog = ( + element: HTMLElement, + dialogParams?: PickConfigEntryDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-pick-config-entry", + dialogImport: () => import("./dialog-pick-config-entry"), + dialogParams: dialogParams, + }); +}; 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 d118cbf9c9..f0d66cef4f 100644 --- a/src/panels/config/scene/ha-scene-editor.ts +++ b/src/panels/config/scene/ha-scene-editor.ts @@ -320,6 +320,8 @@ export class HaSceneEditor extends PreventUnsavedMixin( .hass=${this.hass} .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 0e422ae834..af68900fde 100644 --- a/src/panels/config/script/ha-script-editor.ts +++ b/src/panels/config/script/ha-script-editor.ts @@ -440,7 +440,9 @@ export class HaScriptEditor extends SubscribeMixin( .hass=${this.hass} .defaultValue=${this._preprocessYaml()} .readOnly=${this._readOnly} + disable-fullscreen @value-changed=${this._yamlChanged} + .showErrors=${false} >` : nothing}
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 new file mode 100644 index 0000000000..77fbbd50d0 --- /dev/null +++ b/src/panels/lovelace/card-features/hui-area-controls-card-feature.ts @@ -0,0 +1,335 @@ +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 { + 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 { + 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: { + // Overrides the icons for lights + offIcon: "mdi:lightbulb-off", + onIcon: "mdi:lightbulb", + filter: { + domain: "light", + }, + }, + fan: { + filter: { + domain: "fan", + }, + }, + switch: { + filter: { + domain: "switch", + }, + }, + "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 = ( + hass: HomeAssistant, + context: LovelaceCardFeatureContext +) => { + const area = context.area_id ? hass.areas[context.area_id] : undefined; + return !!area; +}; + +export const getAreaControlEntities = ( + controls: AreaControl[], + areaId: string, + excludeEntities: string[] | undefined, + hass: HomeAssistant +): Record => + controls.reduce( + (acc, control) => { + const controlButton = AREA_CONTROLS_BUTTONS[control]; + const filter = generateEntityFilter(hass, { + area: areaId, + entity_category: "none", + ...controlButton.filter, + }); + + acc[control] = Object.keys(hass.entities).filter( + (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 + implements LovelaceCardFeature +{ + @property({ attribute: false }) public hass?: HomeAssistant; + + @property({ attribute: false }) public context?: AreaCardFeatureContext; + + @property({ attribute: false }) + public position?: LovelaceCardFeaturePosition; + + @state() private _config?: AreaControlsCardFeatureConfig; + + private get _area() { + if (!this.hass || !this.context || !this.context.area_id) { + return undefined; + } + return this.hass.areas[this.context.area_id!] as + | AreaRegistryEntry + | undefined; + } + + private get _controls() { + return ( + this._config?.controls || (AREA_CONTROLS as unknown as AreaControl[]) + ); + } + + static getStubConfig(): AreaControlsCardFeatureConfig { + return { + type: "area-controls", + }; + } + + public static async getConfigElement(): Promise { + await import( + "../editor/config-elements/hui-area-controls-card-feature-editor" + ); + return document.createElement("hui-area-controls-card-feature-editor"); + } + + public setConfig(config: AreaControlsCardFeatureConfig): void { + if (!config) { + throw new Error("Invalid configuration"); + } + this._config = config; + } + + private _handleButtonTap(ev: MouseEvent) { + ev.stopPropagation(); + + if (!this.context?.area_id || !this.hass || !this._config) { + return; + } + const control = (ev.currentTarget as any).control as AreaControl; + + const controlEntities = this._controlEntities( + this._controls, + this.context.area_id, + this.context.exclude_entities, + this.hass!.entities, + this.hass!.devices, + this.hass!.areas + ); + const entitiesIds = controlEntities[control]; + + const entities = entitiesIds + .map((entityId) => this.hass!.states[entityId] as HassEntity | undefined) + .filter((v): v is HassEntity => Boolean(v)); + + forwardHaptic("light"); + toggleGroupEntities(this.hass, entities); + } + + private _controlEntities = memoizeOne( + ( + controls: AreaControl[], + areaId: string, + excludeEntities: string[] | undefined, + // needed to update memoized function when entities, devices or areas change + _entities: HomeAssistant["entities"], + _devices: HomeAssistant["devices"], + _areas: HomeAssistant["areas"] + ) => getAreaControlEntities(controls, areaId, excludeEntities, this.hass!) + ); + + protected render() { + if ( + !this._config || + !this.hass || + !this.context || + !this._area || + !supportsAreaControlsCardFeature(this.hass, this.context) + ) { + return nothing; + } + + const controlEntities = this._controlEntities( + this._controls, + this.context.area_id!, + this.context.exclude_entities, + this.hass!.entities, + this.hass!.devices, + this.hass!.areas + ); + + const supportedControls = this._controls.filter( + (control) => controlEntities[control].length > 0 + ); + + 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` + + ${displayControls.map((control) => { + const button = AREA_CONTROLS_BUTTONS[control]; + + 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` + + + + `; + })} + + `; + } + + static get styles() { + 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); + } + ha-control-button.active { + --control-button-background-color: var(--active-color); + --control-button-icon-color: var(--active-color); + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-area-controls-card-feature": HuiAreaControlsCardFeature; + } +} 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 743f1a607f..9ded542fe3 100644 --- a/src/panels/lovelace/card-features/types.ts +++ b/src/panels/lovelace/card-features/types.ts @@ -158,6 +158,31 @@ export interface UpdateActionsCardFeatureConfig { backup?: "yes" | "no" | "ask"; } +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[]; +} + +export type LovelaceCardFeaturePosition = "bottom" | "inline"; + export type LovelaceCardFeatureConfig = | AlarmModesCardFeatureConfig | ClimateFanModesCardFeatureConfig @@ -187,8 +212,10 @@ export type LovelaceCardFeatureConfig = | ToggleCardFeatureConfig | UpdateActionsCardFeatureConfig | VacuumCommandsCardFeatureConfig - | WaterHeaterOperationModesCardFeatureConfig; + | WaterHeaterOperationModesCardFeatureConfig + | AreaControlsCardFeatureConfig; export interface LovelaceCardFeatureContext { entity_id?: string; + area_id?: string; } diff --git a/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts index 917d9e5950..d3d4839431 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts @@ -81,7 +81,6 @@ export class HuiEnergyDevicesDetailGraphCard key: this._config?.collection_key, }).subscribe((data) => { this._data = data; - this._processStatistics(); }), ]; } @@ -103,10 +102,7 @@ export class HuiEnergyDevicesDetailGraphCard } protected willUpdate(changedProps: PropertyValues) { - if ( - (changedProps.has("_hiddenStats") || changedProps.has("_config")) && - this._data - ) { + if (changedProps.has("_config") || changedProps.has("_data")) { this._processStatistics(); } } @@ -206,7 +202,10 @@ export class HuiEnergyDevicesDetailGraphCard ); private _processStatistics() { - const energyData = this._data!; + if (!this._data) { + return; + } + const energyData = this._data; this._start = energyData.start; this._end = energyData.end || endOfToday(); @@ -393,7 +392,7 @@ export class HuiEnergyDevicesDetailGraphCard this.hass.themes.darkMode, false, compare, - "--state-unavailable-color" + "--history-unknown-color" ), }, barMaxWidth: 50, @@ -402,7 +401,7 @@ export class HuiEnergyDevicesDetailGraphCard this.hass.themes.darkMode, true, compare, - "--state-unavailable-color" + "--history-unknown-color" ), data: untrackedConsumption, stack: compare ? "devicesCompare" : "devices", diff --git a/src/panels/lovelace/cards/energy/hui-energy-distribution-card.ts b/src/panels/lovelace/cards/energy/hui-energy-distribution-card.ts index d2cb3af1c4..60a0a801db 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-distribution-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-distribution-card.ts @@ -205,7 +205,7 @@ class HuiEnergyDistrubutionCard let homeHighCarbonCircumference: number | undefined; // This fallback is used in the demo - let electricityMapUrl = "https://app.electricitymap.org"; + let electricityMapUrl = "https://app.electricitymaps.com"; if ( hasGrid && diff --git a/src/panels/lovelace/cards/energy/hui-energy-sankey-card.ts b/src/panels/lovelace/cards/energy/hui-energy-sankey-card.ts index da162673e3..5f1a9cf146 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-sankey-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-sankey-card.ts @@ -238,7 +238,6 @@ class HuiEnergySankeyCard if (value < 0.01) { return; } - untrackedConsumption -= value; const node = { id: device.stat_consumption, label: @@ -260,6 +259,8 @@ class HuiEnergySankeyCard source: node.parent, target: node.id, }); + } else { + untrackedConsumption -= value; } deviceNodes.push(node); }); diff --git a/src/panels/lovelace/cards/hui-area-card.ts b/src/panels/lovelace/cards/hui-area-card.ts index 0b390386b0..68dce2164e 100644 --- a/src/panels/lovelace/cards/hui-area-card.ts +++ b/src/panels/lovelace/cards/hui-area-card.ts @@ -1,23 +1,21 @@ +import { mdiTextureBox } from "@mdi/js"; +import type { HassEntity } from "home-assistant-js-websocket"; import { - mdiFan, - mdiFanOff, - mdiLightbulbMultiple, - mdiLightbulbMultipleOff, - mdiRun, - mdiToggleSwitch, - mdiToggleSwitchOff, - mdiWaterAlert, -} from "@mdi/js"; -import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; -import type { PropertyValues, TemplateResult } from "lit"; -import { LitElement, css, html, nothing } from "lit"; + css, + html, + LitElement, + nothing, + type PropertyValues, + type TemplateResult, +} from "lit"; import { customElement, property, state } from "lit/decorators"; -import { classMap } from "lit/directives/class-map"; +import { ifDefined } from "lit/directives/if-defined"; import { styleMap } from "lit/directives/style-map"; import memoizeOne from "memoize-one"; -import { STATES_OFF } from "../../../common/const"; -import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; -import { computeDomain } from "../../../common/entity/compute_domain"; +import { computeCssColor } from "../../../common/color/compute-color"; +import { BINARY_STATE_ON } from "../../../common/const"; +import { computeAreaName } from "../../../common/entity/compute_area_name"; +import { generateEntityFilter } from "../../../common/entity/entity_filter"; import { navigate } from "../../../common/navigate"; import { formatNumber, @@ -25,23 +23,22 @@ import { } from "../../../common/number/format_number"; import { blankBeforeUnit } from "../../../common/translations/blank_before_unit"; import parseAspectRatio from "../../../common/util/parse-aspect-ratio"; -import { subscribeOne } from "../../../common/util/subscribe-one"; +import "../../../components/ha-aspect-ratio"; import "../../../components/ha-card"; +import "../../../components/ha-control-button"; +import "../../../components/ha-control-button-group"; import "../../../components/ha-domain-icon"; -import "../../../components/ha-icon-button"; -import "../../../components/ha-state-icon"; -import type { AreaRegistryEntry } from "../../../data/area_registry"; -import { subscribeAreaRegistry } from "../../../data/area_registry"; -import type { DeviceRegistryEntry } from "../../../data/device_registry"; -import { subscribeDeviceRegistry } from "../../../data/device_registry"; +import "../../../components/ha-icon"; +import "../../../components/ha-ripple"; +import "../../../components/ha-svg-icon"; +import "../../../components/tile/ha-tile-badge"; +import "../../../components/tile/ha-tile-icon"; +import "../../../components/tile/ha-tile-info"; import { isUnavailableState } from "../../../data/entity"; -import type { EntityRegistryEntry } from "../../../data/entity_registry"; -import { subscribeEntityRegistry } from "../../../data/entity_registry"; -import { forwardHaptic } from "../../../data/haptics"; -import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import type { HomeAssistant } from "../../../types"; -import "../components/hui-image"; -import "../components/hui-warning"; +import "../card-features/hui-card-features"; +import type { LovelaceCardFeatureContext } from "../card-features/types"; +import { actionHandler } from "../common/directives/action-handler-directive"; import type { LovelaceCard, LovelaceCardEditor, @@ -51,284 +48,364 @@ import type { AreaCardConfig } from "./types"; export const DEFAULT_ASPECT_RATIO = "16:9"; -const SENSOR_DOMAINS = ["sensor"]; - -const ALERT_DOMAINS = ["binary_sensor"]; - -const TOGGLE_DOMAINS = ["light", "switch", "fan"]; - -const OTHER_DOMAINS = ["camera"]; - export const DEVICE_CLASSES = { sensor: ["temperature", "humidity"], binary_sensor: ["motion", "moisture"], }; -const DOMAIN_ICONS = { - light: { on: mdiLightbulbMultiple, off: mdiLightbulbMultipleOff }, - switch: { on: mdiToggleSwitch, off: mdiToggleSwitchOff }, - fan: { on: mdiFan, off: mdiFanOff }, - binary_sensor: { - motion: mdiRun, - moisture: mdiWaterAlert, - }, -}; +export interface AreaCardFeatureContext extends LovelaceCardFeatureContext { + exclude_entities?: string[]; +} @customElement("hui-area-card") -export class HuiAreaCard - extends SubscribeMixin(LitElement) - implements LovelaceCard -{ - public static async getConfigElement(): Promise { - await import("../editor/config-elements/hui-area-card-editor"); - return document.createElement("hui-area-card-editor"); - } - - public static async getStubConfig( - hass: HomeAssistant - ): Promise { - const areas = await subscribeOne(hass.connection, subscribeAreaRegistry); - return { type: "area", area: areas[0]?.area_id || "" }; - } - +export class HuiAreaCard extends LitElement implements LovelaceCard { @property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public layout?: string; @state() private _config?: AreaCardConfig; - @state() private _entities?: EntityRegistryEntry[]; - - @state() private _devices?: DeviceRegistryEntry[]; - - @state() private _areas?: AreaRegistryEntry[]; - - private _deviceClasses: Record = DEVICE_CLASSES; + @state() private _featureContext: AreaCardFeatureContext = {}; private _ratio: { w: number; h: number; } | null = null; - private _entitiesByDomain = memoizeOne( - ( - areaId: string, - devicesInArea: Set, - registryEntities: EntityRegistryEntry[], - deviceClasses: Record, - states: HomeAssistant["states"] - ) => { - const entitiesInArea = registryEntities - .filter( - (entry) => - !entry.entity_category && - !entry.hidden_by && - (entry.area_id - ? entry.area_id === areaId - : entry.device_id && devicesInArea.has(entry.device_id)) - ) - .map((entry) => entry.entity_id); - - const entitiesByDomain: Record = {}; - - for (const entity of entitiesInArea) { - const domain = computeDomain(entity); - if ( - !TOGGLE_DOMAINS.includes(domain) && - !SENSOR_DOMAINS.includes(domain) && - !ALERT_DOMAINS.includes(domain) && - !OTHER_DOMAINS.includes(domain) - ) { - continue; - } - const stateObj: HassEntity | undefined = states[entity]; - - if (!stateObj) { - continue; - } - - if ( - (SENSOR_DOMAINS.includes(domain) || ALERT_DOMAINS.includes(domain)) && - !deviceClasses[domain].includes( - stateObj.attributes.device_class || "" - ) - ) { - continue; - } - - if (!(domain in entitiesByDomain)) { - entitiesByDomain[domain] = []; - } - entitiesByDomain[domain].push(stateObj); - } - - return entitiesByDomain; - } - ); - - private _isOn(domain: string, deviceClass?: string): HassEntity | undefined { - const entities = this._entitiesByDomain( - this._config!.area, - this._devicesInArea(this._config!.area, this._devices!), - this._entities!, - this._deviceClasses, - this.hass.states - )[domain]; - if (!entities) { - return undefined; - } - return ( - deviceClass - ? entities.filter( - (entity) => entity.attributes.device_class === deviceClass - ) - : entities - ).find( - (entity) => - !isUnavailableState(entity.state) && !STATES_OFF.includes(entity.state) - ); - } - - private _average(domain: string, deviceClass?: string): string | undefined { - const entities = this._entitiesByDomain( - this._config!.area, - this._devicesInArea(this._config!.area, this._devices!), - this._entities!, - this._deviceClasses, - this.hass.states - )[domain].filter((entity) => - deviceClass ? entity.attributes.device_class === deviceClass : true - ); - if (!entities) { - return undefined; - } - let uom; - const values = entities.filter((entity) => { - if (!isNumericState(entity) || isNaN(Number(entity.state))) { - return false; - } - if (!uom) { - uom = entity.attributes.unit_of_measurement; - return true; - } - return entity.attributes.unit_of_measurement === uom; - }); - if (!values.length) { - return undefined; - } - const sum = values.reduce( - (total, entity) => total + Number(entity.state), - 0 - ); - return `${formatNumber(sum / values.length, this.hass!.locale, { - maximumFractionDigits: 1, - })}${uom ? blankBeforeUnit(uom, this.hass!.locale) : ""}${uom || ""}`; - } - - private _area = memoizeOne( - (areaId: string | undefined, areas: AreaRegistryEntry[]) => - areas.find((area) => area.area_id === areaId) || null - ); - - private _devicesInArea = memoizeOne( - (areaId: string | undefined, devices: DeviceRegistryEntry[]) => - new Set( - areaId - ? devices - .filter((device) => device.area_id === areaId) - .map((device) => device.id) - : [] - ) - ); - - public hassSubscribe(): UnsubscribeFunc[] { - return [ - subscribeAreaRegistry(this.hass!.connection, (areas) => { - this._areas = areas; - }), - subscribeDeviceRegistry(this.hass!.connection, (devices) => { - this._devices = devices; - }), - subscribeEntityRegistry(this.hass!.connection, (entries) => { - this._entities = entries; - }), - ]; - } - - public getCardSize(): number { - return 3; + public static async getConfigElement(): Promise { + await import("../editor/config-elements/hui-area-card-editor"); + return document.createElement("hui-area-card-editor"); } public setConfig(config: AreaCardConfig): void { if (!config.area) { - throw new Error("Area Required"); + throw new Error("Specify an area"); } - this._config = config; + const displayType = + config.display_type || (config.show_camera ? "camera" : "picture"); + this._config = { + ...config, + display_type: displayType, + }; - this._deviceClasses = { ...DEVICE_CLASSES }; - if (config.sensor_classes) { - this._deviceClasses.sensor = config.sensor_classes; - } - if (config.alert_classes) { - this._deviceClasses.binary_sensor = config.alert_classes; - } + this._featureContext = { + area_id: config.area, + exclude_entities: config.exclude_entities, + }; } - protected shouldUpdate(changedProps: PropertyValues): boolean { - if (changedProps.has("_config") || !this._config) { - return true; - } + public static async getStubConfig( + hass: HomeAssistant + ): Promise { + const areas = Object.values(hass.areas); + return { type: "area", area: areas[0]?.area_id || "" }; + } - if ( - changedProps.has("_devicesInArea") || - changedProps.has("_areas") || - changedProps.has("_entities") - ) { - return true; - } - - if (!changedProps.has("hass")) { - return false; - } - - const oldHass = changedProps.get("hass") as HomeAssistant | undefined; - - if ( - !oldHass || - oldHass.themes !== this.hass!.themes || - oldHass.locale !== this.hass!.locale - ) { - return true; - } - - if ( - !this._devices || - !this._devicesInArea(this._config.area, this._devices) || - !this._entities - ) { - return false; - } - - const entities = this._entitiesByDomain( - this._config.area, - this._devicesInArea(this._config.area, this._devices), - this._entities, - this._deviceClasses, - this.hass.states + public getCardSize(): number { + const featuresPosition = + this._config && this._featurePosition(this._config); + const displayType = this._config?.display_type || "picture"; + const featuresCount = this._config?.features?.length || 0; + return ( + 1 + + (displayType === "compact" ? 0 : 2) + + (featuresPosition === "inline" ? 0 : featuresCount) ); + } - for (const domainEntities of Object.values(entities)) { - for (const stateObj of domainEntities) { - if (oldHass!.states[stateObj.entity_id] !== stateObj) { - return true; - } + public getGridOptions(): LovelaceGridOptions { + const columns = 6; + let min_columns = 6; + let rows = 1; + const featurePosition = this._config + ? this._featurePosition(this._config) + : "bottom"; + const featuresCount = this._config?.features?.length || 0; + if (featuresCount) { + if (featurePosition === "inline") { + min_columns = 12; + } else { + rows += featuresCount; } } - return false; + const displayType = this._config?.display_type || "picture"; + + if (displayType !== "compact") { + rows += 2; + } + + return { + columns, + rows, + min_columns, + min_rows: rows, + }; } + private get _hasCardAction() { + return this._config?.navigation_path; + } + + private _handleAction() { + if (this._config?.navigation_path) { + navigate(this._config.navigation_path); + } + } + + private _groupEntitiesByDeviceClass = ( + entityIds: string[] + ): Map => + entityIds.reduce((acc, entityId) => { + const stateObj = this.hass.states[entityId]; + const deviceClass = stateObj.attributes.device_class!; + if (!acc.has(deviceClass)) { + acc.set(deviceClass, []); + } + acc.get(deviceClass)!.push(stateObj.entity_id); + return acc; + }, new Map()); + + private _groupedSensorEntityIds = memoizeOne( + ( + entities: HomeAssistant["entities"], + areaId: string, + sensorClasses: string[], + excludeEntities?: string[] + ): Map => { + const sensorFilter = generateEntityFilter(this.hass, { + area: areaId, + entity_category: "none", + domain: "sensor", + device_class: sensorClasses, + }); + const entityIds = Object.keys(entities).filter( + (id) => sensorFilter(id) && !excludeEntities?.includes(id) + ); + + return this._groupEntitiesByDeviceClass(entityIds); + } + ); + + private _groupedBinarySensorEntityIds = memoizeOne( + ( + entities: HomeAssistant["entities"], + areaId: string, + binarySensorClasses: string[], + excludeEntities?: string[] + ): Map => { + const binarySensorFilter = generateEntityFilter(this.hass, { + area: areaId, + entity_category: "none", + domain: "binary_sensor", + device_class: binarySensorClasses, + }); + + const entityIds = Object.keys(entities).filter( + (id) => binarySensorFilter(id) && !excludeEntities?.includes(id) + ); + + return this._groupEntitiesByDeviceClass(entityIds); + } + ); + + private _getCameraEntity = memoizeOne( + ( + entities: HomeAssistant["entities"], + areaId: string + ): string | undefined => { + const cameraFilter = generateEntityFilter(this.hass, { + area: areaId, + entity_category: "none", + domain: "camera", + }); + const cameraEntities = Object.keys(entities).filter(cameraFilter); + return cameraEntities.length > 0 ? cameraEntities[0] : undefined; + } + ); + + private _computeActiveAlertStates(): HassEntity[] { + 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, + excludeEntities + ); + + return ( + alertClasses + .map((alertClass) => { + const entityIds = groupedEntities.get(alertClass) || []; + if (!entityIds) { + return []; + } + return entityIds + .map( + (entityId) => this.hass.states[entityId] as HassEntity | undefined + ) + .filter((stateObj) => stateObj?.state === BINARY_STATE_ON); + }) + .filter((activeAlerts) => activeAlerts.length > 0) + // Only return the first active entity for each alert class + .map((activeAlerts) => activeAlerts[0]!) + ); + } + + private _renderAlertSensorBadge(): TemplateResult<1> | typeof nothing { + const states = this._computeActiveAlertStates(); + + if (states.length === 0) { + return nothing; + } + + // Only render the first one when using a badge + const stateObj = states[0] as HassEntity | undefined; + + return html` + + + + `; + } + + private _renderAlertSensors(): TemplateResult<1> | typeof nothing { + const states = this._computeActiveAlertStates(); + + if (states.length === 0) { + return nothing; + } + return html` +
+ ${states.map( + (stateObj) => html` +
+ +
+ ` + )} +
+ `; + } + + private _computeSensorsDisplay(): string | undefined { + 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; + } + + const groupedEntities = this._groupedSensorEntityIds( + this.hass.entities, + area.area_id, + sensorClasses, + excludeEntities + ); + + const sensorStates = sensorClasses + .map((sensorClass) => { + if (sensorClass === "temperature" && area.temperature_entity_id) { + const stateObj = this.hass.states[area.temperature_entity_id] as + | HassEntity + | undefined; + return !stateObj || isUnavailableState(stateObj.state) + ? "" + : this.hass.formatEntityState(stateObj); + } + if (sensorClass === "humidity" && area.humidity_entity_id) { + const stateObj = this.hass.states[area.humidity_entity_id] as + | HassEntity + | undefined; + return !stateObj || isUnavailableState(stateObj.state) + ? "" + : this.hass.formatEntityState(stateObj); + } + + const entityIds = groupedEntities.get(sensorClass); + + if (!entityIds) { + return undefined; + } + + // Ensure all entities have state + const entities = entityIds + .map((entityId) => this.hass.states[entityId]) + .filter(Boolean); + + if (entities.length === 0) { + 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 + )?.attributes.unit_of_measurement; + + // Ensure all entities have the same unit_of_measurement + const validEntities = entities.filter( + (entity) => + entity.attributes.unit_of_measurement === uom && + isNumericState(entity) && + !isNaN(Number(entity.state)) + ); + + if (validEntities.length === 0) { + return undefined; + } + + const value = + validEntities.reduce((acc, entity) => acc + Number(entity.state), 0) / + validEntities.length; + + const formattedAverage = formatNumber(value, this.hass!.locale, { + maximumFractionDigits: 1, + }); + const formattedUnit = uom + ? `${blankBeforeUnit(uom, this.hass!.locale)}${uom}` + : ""; + + return `${formattedAverage}${formattedUnit}`; + }) + .filter(Boolean) + .join(" · "); + + return sensorStates; + } + + private _featurePosition = memoizeOne( + (config: AreaCardConfig) => config.features_position || "bottom" + ); + + private _displayedFeatures = memoizeOne((config: AreaCardConfig) => { + const features = config.features || []; + const featurePosition = this._featurePosition(config); + + if (featurePosition === "inline") { + return features.slice(0, 1); + } + return features; + }); + public willUpdate(changedProps: PropertyValues) { if (changedProps.has("_config") || this._ratio === null) { this._ratio = this._config?.aspect_ratio @@ -342,26 +419,14 @@ export class HuiAreaCard } protected render() { - if ( - !this._config || - !this.hass || - !this._areas || - !this._devices || - !this._entities - ) { + if (!this._config || !this.hass) { return nothing; } - const entitiesByDomain = this._entitiesByDomain( - this._config.area, - this._devicesInArea(this._config.area, this._devices), - this._entities, - this._deviceClasses, - this.hass.states - ); - const area = this._area(this._config.area, this._areas); + const areaId = this._config?.area; + const area = areaId ? this.hass.areas[areaId] : undefined; - if (area === null) { + if (!area) { return html` ${this.hass.localize("ui.card.area.area_not_found")} @@ -369,315 +434,279 @@ export class HuiAreaCard `; } - const sensors: TemplateResult[] = []; - SENSOR_DOMAINS.forEach((domain) => { - if (!(domain in entitiesByDomain)) { - return; - } - this._deviceClasses[domain].forEach((deviceClass) => { - let areaSensorEntityId: string | null = null; - switch (deviceClass) { - case "temperature": - areaSensorEntityId = area.temperature_entity_id; - break; - case "humidity": - areaSensorEntityId = area.humidity_entity_id; - break; - } - const areaEntity = - areaSensorEntityId && - this.hass.states[areaSensorEntityId] && - !isUnavailableState(this.hass.states[areaSensorEntityId].state) - ? this.hass.states[areaSensorEntityId] - : undefined; - if ( - areaEntity || - entitiesByDomain[domain].some( - (entity) => entity.attributes.device_class === deviceClass - ) - ) { - let value = areaEntity - ? this.hass.formatEntityState(areaEntity) - : this._average(domain, deviceClass); - if (!value) value = "—"; - sensors.push(html` -
- - ${value} -
- `); - } - }); - }); + const icon = area.icon; - let cameraEntityId: string | undefined; - if (this._config.show_camera && "camera" in entitiesByDomain) { - cameraEntityId = entitiesByDomain.camera[0].entity_id; - } + const name = this._config.name || computeAreaName(area); - const imageClass = area.picture || cameraEntityId; + const primary = name; + const secondary = this._computeSensorsDisplay(); - const ignoreAspectRatio = this.layout === "grid"; + const featurePosition = this._featurePosition(this._config); + const features = this._displayedFeatures(this._config); + + const containerOrientationClass = + featurePosition === "inline" ? "horizontal" : ""; + + const displayType = this._config.display_type || "picture"; + + const cameraEntityId = + displayType === "camera" + ? this._getCameraEntity(this.hass.entities, area.area_id) + : undefined; + + const ignoreAspectRatio = this.layout === "grid" || this.layout === "panel"; + + const color = this._config.color + ? computeCssColor(this._config.color) + : undefined; + + const style = { + "--tile-color": color, + }; return html` - - ${area.picture || cameraEntityId - ? html` - - ` - : area.icon - ? html` -
- + +
+ +
+ ${displayType === "compact" + ? nothing + : html` +
+
+ ${(displayType === "picture" || displayType === "camera") && + (cameraEntityId || area.picture) + ? html` + + ` + : html` + +
+ ${area.icon + ? html`` + : nothing} +
+
+ `}
+ ${this._renderAlertSensors()} +
+ `} +
+
+ + ${displayType === "compact" + ? this._renderAlertSensorBadge() + : nothing} + ${icon + ? html`` + : html` + + `} + + +
+ ${features.length > 0 + ? html` + ` : nothing} - - `; } - protected updated(changedProps: PropertyValues): void { - super.updated(changedProps); - if (!this._config || !this.hass) { - return; - } - const oldHass = changedProps.get("hass") as HomeAssistant | undefined; - const oldConfig = changedProps.get("_config") as AreaCardConfig | undefined; - - if ( - (changedProps.has("hass") && - (!oldHass || oldHass.themes !== this.hass.themes)) || - (changedProps.has("_config") && - (!oldConfig || oldConfig.theme !== this._config.theme)) - ) { - applyThemesOnElement(this, this.hass.themes, this._config.theme); - } - } - - private _handleNavigation() { - if (this._config!.navigation_path) { - navigate(this._config!.navigation_path); - } - } - - private _toggle(ev: Event) { - ev.stopPropagation(); - const domain = (ev.currentTarget as any).domain as string; - if (TOGGLE_DOMAINS.includes(domain)) { - this.hass.callService( - domain, - this._isOn(domain) ? "turn_off" : "turn_on", - undefined, - { - area_id: this._config!.area, - } - ); - } - forwardHaptic("light"); - } - - getGridOptions(): LovelaceGridOptions { - return { - columns: 12, - rows: 3, - min_columns: 3, - }; - } - static styles = css` - ha-card { - overflow: hidden; - position: relative; - background-size: cover; - height: 100%; + :host { + --tile-color: var(--state-icon-color); + -webkit-tap-highlight-color: transparent; } - - .container { + ha-card:has(.background:focus-visible) { + --shadow-default: var(--ha-card-box-shadow, 0 0 0 0 transparent); + --shadow-focus: 0 0 0 1px var(--tile-color); + border-color: var(--tile-color); + box-shadow: var(--shadow-default), var(--shadow-focus); + } + ha-card { + --ha-ripple-color: var(--tile-color); + --ha-ripple-hover-opacity: 0.04; + --ha-ripple-pressed-opacity: 0.12; + height: 100%; + transition: + box-shadow 180ms ease-in-out, + border-color 180ms ease-in-out; display: flex; flex-direction: column; justify-content: space-between; + } + [role="button"] { + cursor: pointer; + pointer-events: auto; + } + [role="button"]:focus { + outline: none; + } + .background { position: absolute; top: 0; - bottom: 0; left: 0; + bottom: 0; right: 0; - background: linear-gradient( - 0, - rgba(33, 33, 33, 0.9) 0%, - rgba(33, 33, 33, 0) 45% - ); + border-radius: var(--ha-card-border-radius, 12px); + margin: calc(-1 * var(--ha-card-border-width, 1px)); + overflow: hidden; } - - ha-card:not(.image) .container::before { + .header { + flex: 1; + overflow: hidden; + border-radius: var(--ha-card-border-radius, 12px); + border-end-end-radius: 0; + border-end-start-radius: 0; + pointer-events: none; + } + .picture { + height: 100%; + width: 100%; + background-size: cover; + background-position: center; + position: relative; + } + .picture hui-image { + height: 100%; + } + .picture .icon-container { + height: 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + --mdc-icon-size: 48px; + color: var(--tile-color); + } + .picture .icon-container::before { position: absolute; content: ""; width: 100%; height: 100%; - background-color: var(--sidebar-selected-icon-color); + background-color: var(--tile-color); opacity: 0.12; } - - .image hui-image { - height: 100%; + .container { + margin: calc(-1 * var(--ha-card-border-width, 1px)); + display: flex; + flex-direction: column; + flex: 1; + } + .header + .container { + height: auto; + flex: none; + } + .container.horizontal { + flex-direction: row; } - .icon-container { + .content { + position: relative; + display: flex; + flex-direction: row; + align-items: center; + padding: 10px; + flex: 1; + min-width: 0; + box-sizing: border-box; + pointer-events: none; + gap: 10px; + } + + ha-tile-icon { + --tile-icon-color: var(--tile-color); + position: relative; + padding: 6px; + margin: -6px; + } + ha-tile-badge { + position: absolute; + top: 3px; + right: 3px; + inset-inline-end: 3px; + inset-inline-start: initial; + } + ha-tile-info { + position: relative; + min-width: 0; + transition: background-color 180ms ease-in-out; + box-sizing: border-box; + } + hui-card-features { + --feature-color: var(--tile-color); + padding: 0 12px 12px 12px; + } + .container.horizontal hui-card-features { + width: calc(50% - var(--column-gap, 0px) / 2 - 12px); + flex: none; + --feature-height: 36px; + padding: 0 12px; + padding-inline-start: 0; + } + .alert-badge { + --tile-badge-background-color: var(--orange-color); + } + .alerts { position: absolute; top: 0; left: 0; - right: 0; - bottom: 0; display: flex; - align-items: center; - justify-content: center; - } - - .icon-container ha-icon { - --mdc-icon-size: 60px; - color: var(--sidebar-selected-icon-color); - } - - .sensors { - color: #e3e3e3; - font-size: var(--ha-font-size-l); - --mdc-icon-size: 24px; - opacity: 0.6; - margin-top: 8px; - } - - .sensor { - white-space: nowrap; - float: left; - margin-right: 4px; - margin-inline-end: 4px; - margin-inline-start: initial; - } - - .alerts { - padding: 16px; - } - - ha-state-icon { - display: inline-flex; - align-items: center; - justify-content: center; - position: relative; - } - - .alerts ha-state-icon { - background: var(--accent-color); - color: var(--text-accent-color, var(--text-primary-color)); + flex-direction: row; + gap: 8px; padding: 8px; - margin-right: 8px; - margin-inline-end: 8px; - margin-inline-start: initial; - border-radius: 50%; + pointer-events: none; + z-index: 1; } - - .name { - color: white; - font-size: var(--ha-font-size-2xl); - } - - .bottom { + .alert { + background-color: var(--orange-color); + border-radius: 12px; + width: 24px; + height: 24px; + padding: 2px; + box-sizing: border-box; + --mdc-icon-size: 16px; display: flex; - justify-content: space-between; align-items: center; - padding: 16px; - } - - .navigate { - cursor: pointer; - } - - ha-icon-button { + justify-content: center; color: white; - background-color: var(--area-button-color, #727272b2); - border-radius: 50%; - margin-left: 8px; - margin-inline-start: 8px; - margin-inline-end: initial; - --mdc-icon-button-size: 44px; - } - .on { - color: var(--state-light-active-color); } `; } diff --git a/src/panels/lovelace/cards/hui-card.ts b/src/panels/lovelace/cards/hui-card.ts index 271e2cd027..12be541b86 100644 --- a/src/panels/lovelace/cards/hui-card.ts +++ b/src/panels/lovelace/cards/hui-card.ts @@ -224,7 +224,9 @@ export class HuiCard extends ReactiveElement { this._element.preview = this.preview; // For backwards compatibility (this._element as any).editMode = this.preview; - fireEvent(this, "card-updated"); + if (this.hasUpdated) { + fireEvent(this, "card-updated"); + } } catch (e: any) { // eslint-disable-next-line no-console console.error(this.config?.type, e); diff --git a/src/panels/lovelace/cards/hui-statistics-graph-card.ts b/src/panels/lovelace/cards/hui-statistics-graph-card.ts index d7be2a06d2..7d493f1575 100644 --- a/src/panels/lovelace/cards/hui-statistics-graph-card.ts +++ b/src/panels/lovelace/cards/hui-statistics-graph-card.ts @@ -97,8 +97,8 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard { } if (this._config?.energy_date_selection) { this._subscribeEnergy(); - } else { - this._setFetchStatisticsTimer(); + } else if (this._interval === undefined) { + this._setFetchStatisticsTimer(true); } } @@ -213,9 +213,7 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard { changedProps.has("_config") && oldConfig?.entities !== this._config.entities ) { - this._getStatisticsMetaData(this._entities).then(() => { - this._setFetchStatisticsTimer(); - }); + this._setFetchStatisticsTimer(true); return; } @@ -230,10 +228,14 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard { } } - private _setFetchStatisticsTimer() { - this._getStatistics(); - // statistics are created every hour + private async _setFetchStatisticsTimer(fetchMetadata = false) { clearInterval(this._interval); + this._interval = 0; // block concurrent calls + if (fetchMetadata) { + await this._getStatisticsMetaData(this._entities); + } + await this._getStatistics(); + // statistics are created every hour if (!this._config?.energy_date_selection) { this._interval = window.setInterval( () => this._getStatistics(), diff --git a/src/panels/lovelace/cards/hui-weather-forecast-card.ts b/src/panels/lovelace/cards/hui-weather-forecast-card.ts index 2140038978..aa5d4ff488 100644 --- a/src/panels/lovelace/cards/hui-weather-forecast-card.ts +++ b/src/panels/lovelace/cards/hui-weather-forecast-card.ts @@ -82,7 +82,7 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard { const width = entries[0]?.contentRect.width; if (width < 245) { - result.height = "very-very-narrow"; + result.width = "very-very-narrow"; } else if (width < 300) { result.width = "very-narrow"; } else if (width < 375) { @@ -93,7 +93,6 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard { if (height < 235) { result.height = "short"; } - return result; }, }); @@ -243,11 +242,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 +265,12 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard { return html` ([ "alarm-modes", + "area-controls", "climate-fan-modes", "climate-swing-modes", "climate-swing-horizontal-modes", diff --git a/src/panels/lovelace/editor/card-editor/hui-dialog-edit-card.ts b/src/panels/lovelace/editor/card-editor/hui-dialog-edit-card.ts index ed1a507966..a72abfec65 100644 --- a/src/panels/lovelace/editor/card-editor/hui-dialog-edit-card.ts +++ b/src/panels/lovelace/editor/card-editor/hui-dialog-edit-card.ts @@ -74,8 +74,6 @@ export class HuiDialogEditCard @state() private _dirty = false; - @state() private _isEscapeEnabled = true; - public async showDialog(params: EditCardDialogParams): Promise { 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 [ { name: "area", selector: { area: {} } }, - { name: "show_camera", required: false, selector: { boolean: {} } }, - ...(showCamera - ? ([ - { - name: "camera_view", - selector: { - select: { - options: ["auto", "live"].map((value) => ({ - value, - label: localize( - `ui.panel.lovelace.editor.card.generic.camera_view_options.${value}` + { + name: "content", + flatten: true, + type: "expandable", + iconPath: mdiTextShort, + schema: [ + { + name: "", + type: "grid", + schema: [ + { name: "name", selector: { text: {} } }, + { name: "color", selector: { ui_color: {} } }, + { + name: "display_type", + required: true, + selector: { + select: { + options: ["compact", "icon", "picture", "camera"].map( + (value) => ({ + value, + label: localize( + `ui.panel.lovelace.editor.card.area.display_type_options.${value}` + ), + }) ), - })), - mode: "dropdown", + mode: "dropdown", + }, }, }, + ...(showCamera + ? ([ + { + name: "camera_view", + selector: { + select: { + options: ["auto", "live"].map((value) => ({ + value, + label: localize( + `ui.panel.lovelace.editor.card.generic.camera_view_options.${value}` + ), + })), + mode: "dropdown", + }, + }, + }, + ] as const satisfies readonly HaFormSchema[]) + : []), + ], + }, + { + name: "alert_classes", + selector: { + select: { + reorder: true, + multiple: true, + custom_value: true, + options: binaryClasses, + }, }, - ] as const) - : []), + }, + { + name: "sensor_classes", + selector: { + select: { + reorder: true, + multiple: true, + custom_value: true, + options: sensorClasses, + }, + }, + }, + ], + }, { - name: "", - type: "grid", + name: "interactions", + type: "expandable", + flatten: true, + iconPath: mdiGestureTap, schema: [ { name: "navigation_path", required: false, selector: { navigation: {} }, }, - { name: "theme", required: false, selector: { theme: {} } }, - { - name: "aspect_ratio", - default: DEFAULT_ASPECT_RATIO, - selector: { text: {} }, - }, ], }, - { - name: "alert_classes", - selector: { - select: { - reorder: true, - multiple: true, - custom_value: true, - options: binaryClasses, - }, - }, - }, - { - name: "sensor_classes", - selector: { - select: { - reorder: true, - multiple: true, - custom_value: true, - options: sensorClasses, - }, - }, - }, - ] as const + ] as const satisfies readonly HaFormSchema[] ); - private _binaryClassesForArea = memoizeOne((area: string): string[] => - this._classesForArea(area, "binary_sensor") + private _binaryClassesForArea = memoizeOne( + ( + area: string | undefined, + excludeEntities: string[] | undefined + ): string[] => { + if (!area) { + return []; + } + + const binarySensorFilter = generateEntityFilter(this.hass!, { + domain: "binary_sensor", + area, + entity_category: "none", + }); + + const classes = Object.keys(this.hass!.entities) + .filter( + (id) => binarySensorFilter(id) && !excludeEntities?.includes(id) + ) + .map((id) => this.hass!.states[id]?.attributes.device_class) + .filter((c): c is string => Boolean(c)); + + return [...new Set(classes)]; + } ); private _sensorClassesForArea = memoizeOne( - (area: string, numericDeviceClasses?: string[]): string[] => - this._classesForArea(area, "sensor", numericDeviceClasses) + ( + area: string | undefined, + excludeEntities: string[] | undefined, + numericDeviceClasses: string[] | undefined + ): string[] => { + if (!area) { + return []; + } + + const sensorFilter = generateEntityFilter(this.hass!, { + domain: "sensor", + area, + device_class: numericDeviceClasses, + entity_category: "none", + }); + + const classes = Object.keys(this.hass!.entities) + .filter((id) => sensorFilter(id) && !excludeEntities?.includes(id)) + .map((id) => this.hass!.states[id]?.attributes.device_class) + .filter((c): c is string => Boolean(c)); + + return [...new Set(classes)]; + } ); - private _classesForArea( - area: string, - domain: "sensor" | "binary_sensor", - numericDeviceClasses?: string[] | undefined - ): string[] { - const entities = Object.values(this.hass!.entities).filter( - (e) => - computeDomain(e.entity_id) === domain && - !e.entity_category && - !e.hidden && - (e.area_id === area || - (e.device_id && this.hass!.devices[e.device_id]?.area_id === area)) - ); - - const classes = entities - .map((e) => this.hass!.states[e.entity_id]?.attributes.device_class || "") - .filter( - (c) => - c && - (domain !== "sensor" || - !numericDeviceClasses || - numericDeviceClasses.includes(c)) - ); - - return [...new Set(classes)]; - } - private _buildBinaryOptions = memoizeOne( (possibleClasses: string[], currentClasses: string[]): SelectOption[] => this._buildOptions("binary_sensor", possibleClasses, currentClasses) @@ -191,7 +258,19 @@ export class HuiAreaCardEditor public setConfig(config: AreaCardConfig): void { assert(config, cardConfigStruct); - this._config = config; + + const displayType = + config.display_type || (config.show_camera ? "camera" : "picture"); + this._config = { + ...config, + display_type: displayType, + }; + delete this._config.show_camera; + + this._featureContext = { + area_id: config.area, + exclude_entities: config.exclude_entities, + }; } protected async updated() { @@ -202,16 +281,52 @@ export class HuiAreaCardEditor } } + private _featuresSchema = memoizeOne( + (localize: LocalizeFunc) => + [ + { + name: "features_position", + required: true, + selector: { + select: { + mode: "box", + options: ["bottom", "inline"].map((value) => ({ + label: localize( + `ui.panel.lovelace.editor.card.tile.features_position_options.${value}` + ), + description: localize( + `ui.panel.lovelace.editor.card.tile.features_position_options.${value}_description` + ), + value, + image: { + src: `/static/images/form/tile_features_position_${value}.svg`, + src_dark: `/static/images/form/tile_features_position_${value}_dark.svg`, + flip_rtl: true, + }, + })), + }, + }, + }, + ] as const satisfies readonly HaFormSchema[] + ); + + private _hasCompatibleFeatures = memoizeOne( + (context: LovelaceCardFeatureContext) => + getSupportedFeaturesType(this.hass!, context).length > 0 + ); + protected render() { if (!this.hass || !this._config) { return nothing; } const possibleBinaryClasses = this._binaryClassesForArea( - this._config.area || "" + this._config.area, + this._config.exclude_entities ); const possibleSensorClasses = this._sensorClassesForArea( - this._config.area || "", + this._config.area, + this._config.exclude_entities, this._numericDeviceClasses ); const binarySelectOptions = this._buildBinaryOptions( @@ -223,68 +338,195 @@ export class HuiAreaCardEditor this._config.sensor_classes || DEVICE_CLASSES.sensor ); + const showCamera = this._config.display_type === "camera"; + + const displayType = + this._config.display_type || this._config.show_camera + ? "camera" + : "picture"; + const schema = this._schema( this.hass.localize, - this._config.show_camera || false, + showCamera, binarySelectOptions, sensorSelectOptions ); + const featuresSchema = this._featuresSchema(this.hass.localize); + const data = { camera_view: "auto", alert_classes: DEVICE_CLASSES.binary_sensor, sensor_classes: DEVICE_CLASSES.sensor, + features_position: "bottom", + display_type: displayType, ...this._config, }; + const hasCompatibleFeatures = this._hasCompatibleFeatures( + this._featureContext + ); + return html` + + +

+ ${this.hass!.localize( + "ui.panel.lovelace.editor.card.generic.features" + )} +

+
+ ${hasCompatibleFeatures + ? html` + + ` + : nothing} + +
+
`; } private _valueChanged(ev: CustomEvent): void { - const config = ev.detail.value; - if (!config.show_camera) { + const newConfig = ev.detail.value as AreaCardConfig; + + const config: AreaCardConfig = { + features: this._config!.features, + ...newConfig, + }; + + if (config.display_type !== "camera") { delete config.camera_view; } + fireEvent(this, "config-changed", { config }); } + private _featuresChanged(ev: CustomEvent) { + ev.stopPropagation(); + if (!this._config || !this.hass) { + return; + } + + const features = ev.detail.features as LovelaceCardFeatureConfig[]; + const config: AreaCardConfig = { + ...this._config, + features, + }; + + if (features.length === 0) { + delete config.features; + } + + fireEvent(this, "config-changed", { config }); + } + + private _editDetailElement(ev: HASSDomEvent): void { + const index = ev.detail.subElementConfig.index; + const config = this._config!.features![index!]; + + fireEvent(this, "edit-sub-element", { + config: config, + saveConfig: (newConfig) => this._updateFeature(index!, newConfig), + context: this._featureContext, + type: "feature", + } as EditSubElementEvent< + LovelaceCardFeatureConfig, + LovelaceCardFeatureContext + >); + } + + private _updateFeature(index: number, feature: LovelaceCardFeatureConfig) { + const features = this._config!.features!.concat(); + features[index] = feature; + const config = { ...this._config!, features }; + fireEvent(this, "config-changed", { + config: config, + }); + } + + private _computeHelperCallback = ( + schema: + | SchemaUnion> + | SchemaUnion> + ): string | undefined => { + switch (schema.name) { + case "alert_classes": + if (this._config?.display_type === "compact") { + return this.hass!.localize( + `ui.panel.lovelace.editor.card.area.alert_classes_helper` + ); + } + return undefined; + default: + return undefined; + } + }; + private _computeLabelCallback = ( - schema: SchemaUnion> + schema: + | SchemaUnion> + | SchemaUnion> ) => { switch (schema.name) { - case "theme": - return `${this.hass!.localize( - "ui.panel.lovelace.editor.card.generic.theme" - )} (${this.hass!.localize( - "ui.panel.lovelace.editor.card.config.optional" - )})`; case "area": return this.hass!.localize("ui.panel.lovelace.editor.card.area.name"); + case "name": + case "camera_view": + case "content": + return this.hass!.localize( + `ui.panel.lovelace.editor.card.generic.${schema.name}` + ); case "navigation_path": return this.hass!.localize( "ui.panel.lovelace.editor.action-editor.navigation_path" ); - case "aspect_ratio": + case "interactions": + case "features_position": return this.hass!.localize( - "ui.panel.lovelace.editor.card.generic.aspect_ratio" - ); - case "camera_view": - return this.hass!.localize( - "ui.panel.lovelace.editor.card.generic.camera_view" + `ui.panel.lovelace.editor.card.tile.${schema.name}` ); } return this.hass!.localize( `ui.panel.lovelace.editor.card.area.${schema.name}` ); }; + + static get styles() { + return [ + configElementStyle, + css` + ha-form { + display: block; + margin-bottom: 24px; + } + .features-form { + margin-bottom: 8px; + } + `, + ]; + } } declare global { 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 new file mode 100644 index 0000000000..18089a26b7 --- /dev/null +++ b/src/panels/lovelace/editor/config-elements/hui-area-controls-card-feature-editor.ts @@ -0,0 +1,187 @@ +import { 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 type { LocalizeFunc } from "../../../../common/translations/localize"; +import "../../../../components/ha-form/ha-form"; +import type { + HaFormSchema, + SchemaUnion, +} from "../../../../components/ha-form/types"; +import type { HomeAssistant } from "../../../../types"; +import { + getAreaControlEntities, + MAX_DEFAULT_AREA_CONTROLS, +} from "../../card-features/hui-area-controls-card-feature"; +import { + AREA_CONTROLS, + type AreaControl, + type AreaControlsCardFeatureConfig, +} from "../../card-features/types"; +import type { AreaCardFeatureContext } from "../../cards/hui-area-card"; +import type { LovelaceCardFeatureEditor } from "../../types"; + +type AreaControlsCardFeatureData = AreaControlsCardFeatureConfig & { + customize_controls: boolean; +}; + +@customElement("hui-area-controls-card-feature-editor") +export class HuiAreaControlsCardFeatureEditor + extends LitElement + implements LovelaceCardFeatureEditor +{ + @property({ attribute: false }) public hass?: HomeAssistant; + + @property({ attribute: false }) public context?: AreaCardFeatureContext; + + @state() private _config?: AreaControlsCardFeatureConfig; + + public setConfig(config: AreaControlsCardFeatureConfig): void { + this._config = config; + } + + private _schema = memoizeOne( + ( + localize: LocalizeFunc, + customizeControls: boolean, + compatibleControls: AreaControl[] + ) => + [ + { + name: "customize_controls", + selector: { + boolean: {}, + }, + }, + ...(customizeControls + ? ([ + { + name: "controls", + selector: { + select: { + reorder: true, + multiple: true, + options: compatibleControls.map((control) => ({ + value: control, + label: localize( + `ui.panel.lovelace.editor.features.types.area-controls.controls_options.${control}` + ), + })), + }, + }, + }, + ] as const satisfies readonly HaFormSchema[]) + : []), + ] as const satisfies readonly HaFormSchema[] + ); + + 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"], + _areas: HomeAssistant["areas"] + ) => { + if (!this.hass) { + return []; + } + const controlEntities = getAreaControlEntities( + AREA_CONTROLS as unknown as AreaControl[], + areaId, + excludeEntities, + this.hass! + ); + return ( + Object.keys(controlEntities) as (keyof typeof controlEntities)[] + ).filter((control) => controlEntities[control].length > 0); + } + ); + + protected render() { + if (!this.hass || !this._config || !this.context?.area_id) { + return nothing; + } + + const supportedControls = this._supportedControls( + this.context.area_id, + this.context.exclude_entities, + this.hass.entities, + this.hass.devices, + this.hass.areas + ); + + if (supportedControls.length === 0) { + return html` + + ${this.hass.localize( + "ui.panel.lovelace.editor.features.types.area-controls.no_compatible_controls" + )} + + `; + } + + const data: AreaControlsCardFeatureData = { + ...this._config, + customize_controls: this._config.controls !== undefined, + }; + + const schema = this._schema( + this.hass.localize, + data.customize_controls, + supportedControls + ); + + return html` + + `; + } + + private _valueChanged(ev: CustomEvent): void { + const { customize_controls, ...config } = ev.detail + .value as AreaControlsCardFeatureData; + + if (customize_controls && !config.controls) { + config.controls = this._supportedControls( + this.context!.area_id!, + this.context!.exclude_entities, + this.hass!.entities, + this.hass!.devices, + this.hass!.areas + ).slice(0, MAX_DEFAULT_AREA_CONTROLS); // Limit to max default controls + } + + if (!customize_controls && config.controls) { + delete config.controls; + } + + fireEvent(this, "config-changed", { config: config }); + } + + private _computeLabelCallback = ( + schema: SchemaUnion> + ) => { + switch (schema.name) { + case "controls": + case "customize_controls": + return this.hass!.localize( + `ui.panel.lovelace.editor.features.types.area-controls.${schema.name}` + ); + default: + return ""; + } + }; +} + +declare global { + interface HTMLElementTagNameMap { + "hui-area-controls-card-feature-editor": HuiAreaControlsCardFeatureEditor; + } +} diff --git a/src/panels/lovelace/editor/config-elements/hui-card-features-editor.ts b/src/panels/lovelace/editor/config-elements/hui-card-features-editor.ts index b92723ecc2..befbb73c7b 100644 --- a/src/panels/lovelace/editor/config-elements/hui-card-features-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-card-features-editor.ts @@ -18,6 +18,7 @@ import { } from "../../../../data/lovelace_custom_cards"; import type { HomeAssistant } from "../../../../types"; import { supportsAlarmModesCardFeature } from "../../card-features/hui-alarm-modes-card-feature"; +import { supportsAreaControlsCardFeature } from "../../card-features/hui-area-controls-card-feature"; import { supportsClimateFanModesCardFeature } from "../../card-features/hui-climate-fan-modes-card-feature"; import { supportsClimateHvacModesCardFeature } from "../../card-features/hui-climate-hvac-modes-card-feature"; import { supportsClimatePresetModesCardFeature } from "../../card-features/hui-climate-preset-modes-card-feature"; @@ -61,6 +62,7 @@ type SupportsFeature = ( const UI_FEATURE_TYPES = [ "alarm-modes", + "area-controls", "climate-fan-modes", "climate-hvac-modes", "climate-preset-modes", @@ -95,6 +97,7 @@ type UiFeatureTypes = (typeof UI_FEATURE_TYPES)[number]; const EDITABLES_FEATURE_TYPES = new Set([ "alarm-modes", + "area-controls", "climate-fan-modes", "climate-hvac-modes", "climate-preset-modes", @@ -116,6 +119,7 @@ const SUPPORTS_FEATURE_TYPES: Record< SupportsFeature | undefined > = { "alarm-modes": supportsAlarmModesCardFeature, + "area-controls": supportsAreaControlsCardFeature, "climate-fan-modes": supportsClimateFanModesCardFeature, "climate-swing-modes": supportsClimateSwingModesCardFeature, "climate-swing-horizontal-modes": diff --git a/src/panels/lovelace/editor/config-elements/hui-graph-footer-editor.ts b/src/panels/lovelace/editor/config-elements/hui-graph-footer-editor.ts index 706f18177d..e3f49edc23 100644 --- a/src/panels/lovelace/editor/config-elements/hui-graph-footer-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-graph-footer-editor.ts @@ -1,5 +1,5 @@ import type { CSSResultGroup } from "lit"; -import { html, LitElement, nothing } from "lit"; +import { css, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { assert } from "superstruct"; import type { HASSDomEvent } from "../../../../common/dom/fire_event"; @@ -139,7 +139,14 @@ export class HuiGraphFooterEditor } static get styles(): CSSResultGroup { - return configElementStyle; + return [ + configElementStyle, + css` + .card-config ha-switch { + margin: 1px 0; + } + `, + ]; } } 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/editor/hui-element-editor.ts b/src/panels/lovelace/editor/hui-element-editor.ts index 9ac4546bab..b8a21bb35f 100644 --- a/src/panels/lovelace/editor/hui-element-editor.ts +++ b/src/panels/lovelace/editor/hui-element-editor.ts @@ -243,6 +243,7 @@ export abstract class HuiElementEditor< @blur=${this._onBlurYaml} @keydown=${this._ignoreKeydown} dir="ltr" + .showErrors=${false} >
`} 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/area-view-strategy.ts b/src/panels/lovelace/strategies/areas/area-view-strategy.ts index 2fc8a0256d..3ce785fbfd 100644 --- a/src/panels/lovelace/strategies/areas/area-view-strategy.ts +++ b/src/panels/lovelace/strategies/areas/area-view-strategy.ts @@ -191,12 +191,6 @@ export class AreaViewStrategy extends ReactiveElement { type: "sections", header: { badges_position: "bottom", - layout: "responsive", - card: { - type: "markdown", - text_only: true, - content: `## ${area.name}`, - }, }, max_columns: maxColumns, sections: sections, diff --git a/src/panels/lovelace/strategies/areas/areas-dashboard-strategy.ts b/src/panels/lovelace/strategies/areas/areas-dashboard-strategy.ts index ef397b4a8e..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; } @@ -66,6 +69,7 @@ export class AreasDashboardStrategy extends ReactiveElement { return { title: area.name, path: path, + subview: true, strategy: { type: "area", area: area.area_id, @@ -77,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 c8cda13888..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,16 +1,21 @@ import { ReactiveElement } from "lit"; import { customElement } from "lit/decorators"; +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 { AREA_CONTROLS, type AreaControl } from "../../card-features/types"; +import type { AreaCardConfig, HeadingCardConfig } from "../../cards/types"; import type { EntitiesDisplay } from "./area-view-strategy"; import { computeAreaPath, - computeAreaTileCardConfig, - getAreaGroupedEntities, getAreas, + getFloors, } from "./helpers/areas-strategy-helper"; +const UNASSIGNED_FLOOR = "__unassigned__"; + interface AreaOptions { groups_options?: Record; } @@ -21,6 +26,9 @@ export interface AreasViewStrategyConfig { hidden?: string[]; order?: string[]; }; + floors_display?: { + order?: string[]; + }; areas_options?: Record; } @@ -30,77 +38,114 @@ 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 areaSections = areas - .map((area) => { - const path = computeAreaPath(area.area_id); + const floors = getFloors(hass.floors, config.floors_display?.order); - const areaConfig = config.areas_options?.[area.area_id]; - - const groups = getAreaGroupedEntities( - area.area_id, - hass, - areaConfig?.groups_options + const floorSections = [ + ...floors, + { + floor_id: UNASSIGNED_FLOOR, + name: hass.localize("ui.panel.lovelace.strategy.areas.other_areas"), + level: null, + icon: null, + }, + ] + .map((floor) => { + const areasInFloors = displayedAreas.filter( + (area) => + area.floor_id === floor.floor_id || + (!area.floor_id && floor.floor_id === UNASSIGNED_FLOOR) ); - const entities = [ - ...groups.lights, - ...groups.covers, - ...groups.climate, - ...groups.media_players, - ...groups.security, - ...groups.actions, - ...groups.others, - ]; + 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 computeTileCard = computeAreaTileCardConfig(hass, area.name); + const areaOptions = config.areas_options?.[area.area_id] || {}; + + const hiddenEntities = Object.values(areaOptions.groups_options || {}) + .map((display) => display.hidden || []) + .flat(); + + 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, + 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: sensorClasses, + exclude_entities: hiddenEntities, + features: filteredControls.length + ? [ + { + type: "area-controls", + controls: filteredControls, + }, + ] + : [], + 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: headingTitle, + icon: floor.icon || floorDefaultIcon(floor), + }; return { + max_columns: 3, type: "grid", - cards: [ - { - type: "heading", - heading: area.name, - icon: area.icon || undefined, - badges: [ - ...(area.temperature_entity_id - ? [{ entity: area.temperature_entity_id }] - : []), - ...(area.humidity_entity_id - ? [{ entity: area.humidity_entity_id }] - : []), - ], - tap_action: { - action: "navigate", - navigation_path: path, - }, - }, - ...(entities.length - ? entities.map(computeTileCard) - : [ - { - type: "markdown", - content: hass.localize( - "ui.panel.lovelace.strategy.areas.no_entities" - ), - }, - ]), - ], + cards: [headingCard, ...areasCards], }; }) - .filter( - (section): section is LovelaceSectionConfig => section !== undefined - ); + ?.filter((section) => section !== undefined); return { type: "sections", max_columns: 3, - sections: areaSections, + sections: floorSections || [], }; } } 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 6edf10b22c..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,27 +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"; @customElement("hui-areas-dashboard-strategy-editor") export class HuiAreasDashboardStrategyEditor @@ -57,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`