Merge branch 'dev' into zwave-graph

This commit is contained in:
Petar Petrov 2025-07-08 09:07:39 +03:00
commit 4151ce5dba
152 changed files with 8724 additions and 4008 deletions

592
.github/copilot-instructions.md vendored Normal file
View File

@ -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`<div>Content</div>`;
}
}
```
### 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<T>` 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
<ha-form
.hass=${this.hass}
.data=${this._data}
.schema=${this._schema}
.error=${this._errors}
.computeLabel=${(schema) => this.hass.localize(`ui.panel.${schema.name}`)}
@value-changed=${this._valueChanged}
></ha-form>
````
### 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
<ha-alert alert-type="error">Error message</ha-alert>
<ha-alert alert-type="warning" title="Warning">Description</ha-alert>
<ha-alert alert-type="success" dismissable>Success message</ha-alert>
```
## 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<MyDialogParams>
{
@property({ attribute: false })
hass!: HomeAssistant;
@state()
private _params?: MyDialogParams;
public async showDialog(params: MyDialogParams): Promise<void> {
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`
<ha-dialog
open
@closed=${this.closeDialog}
.heading=${createCloseHeading(this.hass, this._params.title)}
>
<!-- Dialog content -->
<ha-button @click=${this.closeDialog} slot="secondaryAction">
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button @click=${this._submit} slot="primaryAction">
${this.hass.localize("ui.common.save")}
</ha-button>
</ha-dialog>
`;
}
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

4
.gitignore vendored
View File

@ -53,3 +53,7 @@ src/cast/dev_const.ts
# test coverage
test/coverage/
# AI tooling
.claude

1
CLAUDE.md Symbolic link
View File

@ -0,0 +1 @@
.github/copilot-instructions.md

View File

@ -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: "%",
},
},
},
},
},
},
},
},
},
];

View File

@ -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"
},

View File

@ -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"

View File

@ -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",

View File

@ -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) {

View File

@ -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);
},

View File

@ -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,
});
};

View File

@ -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(

View File

@ -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(":");
}

View File

@ -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) => {

View File

@ -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 {
<div class="chart"></div>
</div>
${this._renderLegend()}
<div class="chart-controls">
<div class="chart-controls ${classMap({ small: this.smallControls })}">
${this._isZoomed
? html`<ha-icon-button
class="zoom-reset"
@ -386,6 +390,7 @@ export class HaChartBase extends LitElement {
type: "inside",
orient: "horizontal",
filterMode: "none",
xAxisIndex: 0,
moveOnMouseMove: !this._isTouchDevice || this._isZoomed,
preventDefaultMouseMove: !this._isTouchDevice || this._isZoomed,
zoomLock: !this._isTouchDevice && !this._modifierPressed,
@ -639,44 +644,46 @@ export class HaChartBase extends LitElement {
const yAxis = (this.options?.yAxis?.[0] ?? this.options?.yAxis) as
| YAXisOption
| undefined;
const series = ensureArray(this.data)
.filter((d) => !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);

View File

@ -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);

View File

@ -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}
></ha-chart-base>
`;

View File

@ -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;

View File

@ -72,6 +72,7 @@ export interface DataTableColumnData<T = any> 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<string, DataTableRowData[]> = 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 (

View File

@ -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);
}

View File

@ -438,10 +438,8 @@ export class HaStatisticPicker extends LitElement {
`
: nothing}
<span slot="headline">${item.primary} </span>
${item.secondary || item.type
? html`<span slot="supporting-text"
>${item.secondary} - ${item.type}</span
>`
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
${item.statistic_id && showEntityId
? html`<span slot="supporting-text" class="code">

View File

@ -173,7 +173,6 @@ class HaStatisticsPicker extends LitElement {
static styles = css`
:host {
width: 200px;
display: block;
}
ha-statistic-picker {

View File

@ -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"
)}

View File

@ -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`<label>${this.label}</label>` : nothing}
<ha-sortable
draggable-selector=".draggable"
handle-selector=".handle"
@item-moved=${this._floorMoved}
.disabled=${this.disabled || !canReorderFloors}
invert-swap
>
<div>
${repeat(
filteredFloors,
(floor) => floor.floor_id,
(floor: FloorRegistryEntry) => html`
<ha-expansion-panel
outlined
.header=${computeFloorName(floor)}
left-chevron
class=${floor.floor_id === UNASSIGNED_FLOOR ? "" : "draggable"}
>
<ha-floor-icon
slot="leading-icon"
.floor=${floor}
></ha-floor-icon>
${floor.floor_id === UNASSIGNED_FLOOR || !canReorderFloors
? nothing
: html`
<ha-svg-icon
class="handle"
slot="icons"
.path=${mdiDrag}
></ha-svg-icon>
`}
<ha-items-display-editor
.hass=${this.hass}
.items=${groupedAreasItems[floor.floor_id]}
.value=${value}
.floorId=${floor.floor_id}
@value-changed=${this._areaDisplayChanged}
.showNavigationButton=${this.showNavigationButton}
></ha-items-display-editor>
</ha-expansion-panel>
`
)}
</div>
</ha-sortable>
`;
}
private _groupedAreasItems = memoizeOne(
(
hassAreas: HomeAssistant["areas"],
// update items if floors change
_hassFloors: HomeAssistant["floors"]
): Record<string, DisplayItem[]> => {
const compare = areaCompare(hassAreas);
const areas = Object.values(hassAreas).sort((areaA, areaB) =>
compare(areaA.area_id, areaB.area_id)
);
const groupedItems: Record<string, DisplayItem[]> = 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<string, DisplayItem[]>
);
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<HASSDomEvents["item-moved"]>) {
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;
}
}

View File

@ -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`<slot></slot>`;
}
return html`
<div
class="ratio"
style=${styleMap({
paddingBottom: `${((100 * this._ratio!.h) / this._ratio!.w).toFixed(2)}%`,
})}
>
<slot></slot>
</div>
`;
}
static styles = css`
.ratio ::slotted(*) {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-aspect-ratio": HaAspectRatio;
}
}

View File

@ -271,7 +271,9 @@ export class HaBaseTimeInput extends LitElement {
</ha-select>`}
</div>
${this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
: nothing}
`;
}

View File

@ -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<CompletionResult | null> {
// 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);
}
`;
}

View File

@ -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 {
></div>`}
.icon=${this.icon}
.invalid=${this.invalid}
.helper=${this.helper}
helperPersistent
.disableSetValue=${this._disableSetValue}
>
<slot name="icon" slot="leadingIcon"></slot>
@ -222,9 +221,18 @@ export class HaComboBox extends LitElement {
@click=${this._toggleOpen}
></ha-svg-icon>
</vaadin-combo-box-light>
${this._renderHelper()}
`;
}
private _renderHelper() {
return this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
: "";
}
private _defaultRowRenderer: ComboBoxLitRenderer<
string | Record<string, any>
> = (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;
}
`;
}

View File

@ -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`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
const icon = domainIcon(
this.hass,
this.domain,
this.deviceClass,
this.state
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
);
return this._renderFallback();
});
return html`${until(icon)}`;
}

View File

@ -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;
}

View File

@ -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<RelatedResult>[] = [];
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<string>();
for (const result of results) {

View File

@ -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<RelatedResult>[] = [];
if (!this.value?.length) {
this.value = [];
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}

View File

@ -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,

View File

@ -30,6 +30,22 @@ export const floorDefaultIconPath = (
return mdiHome;
};
export const floorDefaultIcon = (floor: Pick<FloorRegistryEntry, "level">) => {
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<

View File

@ -71,7 +71,9 @@ export class HaFormInteger extends LitElement implements HaFormElement {
></ha-slider>
</div>
${this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
: ""}
</div>
`;

View File

@ -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`
<ha-selector-select
.hass=${this.hass}
.schema=${this.schema}
.value=${this.data}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.schema.required}
.selector=${this._selectSchema(this.schema.options)}
.required=${this.schema.required || false}
.selector=${this._selectSchema(this.schema)}
.localizeValue=${this.localizeValue}
@value-changed=${this._valueChanged}
></ha-selector-select>
`;

View File

@ -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));

View File

@ -122,22 +122,6 @@ export class HaItemDisplayEditor extends LitElement {
${description
? html`<span slot="supporting-text">${description}</span>`
: nothing}
${isVisible && !disableSorting
? html`
<ha-svg-icon
tabindex=${ifDefined(
this.showNavigationButton ? "0" : undefined
)}
.idx=${idx}
@keydown=${this.showNavigationButton
? this._dragHandleKeydown
: undefined}
class="handle"
.path=${mdiDrag}
slot="start"
></ha-svg-icon>
`
: html`<ha-svg-icon slot="start"></ha-svg-icon>`}
${!showIcon
? nothing
: icon
@ -162,6 +146,9 @@ export class HaItemDisplayEditor extends LitElement {
<span slot="end"> ${this.actionsRenderer(item)} </span>
`
: nothing}
${this.showNavigationButton
? html`<ha-icon-next slot="end"></ha-icon-next>`
: nothing}
<ha-icon-button
.path=${isVisible ? mdiEye : mdiEyeOff}
slot="end"
@ -174,9 +161,22 @@ export class HaItemDisplayEditor extends LitElement {
.value=${value}
@click=${this._toggle}
></ha-icon-button>
${this.showNavigationButton
? html` <ha-icon-next slot="end"></ha-icon-next> `
: nothing}
${isVisible && !disableSorting
? html`
<ha-svg-icon
tabindex=${ifDefined(
this.showNavigationButton ? "0" : undefined
)}
.idx=${idx}
@keydown=${this.showNavigationButton
? this._dragHandleKeydown
: undefined}
class="handle"
.path=${mdiDrag}
slot="end"
></ha-svg-icon>
`
: html`<ha-svg-icon slot="end"></ha-svg-icon>`}
</ha-md-list-item>
`;
}

View File

@ -47,7 +47,9 @@ class HaLabeledSlider extends LitElement {
></ha-slider>
</div>
${this.helper
? html`<ha-input-helper-text> ${this.helper} </ha-input-helper-text>`
? html`<ha-input-helper-text .disabled=${this.disabled}>
${this.helper}
</ha-input-helper-text>`
: nothing}
`;
}

View File

@ -44,7 +44,7 @@ class HaNavigationList extends LitElement {
>
<ha-svg-icon .path=${page.iconPath}></ha-svg-icon>
</div>
<span>${page.name}</span>
<span slot="headline">${page.name}</span>
${this.hasSecondary
? html`<span slot="supporting-text">${page.description}</span>`
: ""}

View File

@ -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 });

View File

@ -54,7 +54,9 @@ export class HaDateTimeSelector extends LitElement {
></ha-time-input>
</div>
${this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
: ""}
`;
}

View File

@ -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 });

View File

@ -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 });

View File

@ -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 });

View File

@ -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`<ha-entity-picker
.hass=${this.hass}
.value=${this.value?.entity_id}
.label=${this.label ||
this.hass.localize("ui.components.selectors.media.pick_media_player")}
.disabled=${this.disabled}
.helper=${this.helper}
.required=${this.required}
include-domains='["media_player"]'
allow-custom-entity
@value-changed=${this._entityChanged}
></ha-entity-picker>
const hasAccept = this.selector?.media?.accept?.length;
return html`
${hasAccept
? nothing
: html`
<ha-entity-picker
.hass=${this.hass}
.value=${this.value?.entity_id}
.label=${this.label ||
this.hass.localize(
"ui.components.selectors.media.pick_media_player"
)}
.disabled=${this.disabled}
.helper=${this.helper}
.required=${this.required}
.includeDomains=${INCLUDE_DOMAINS}
allow-custom-entity
@value-changed=${this._entityChanged}
></ha-entity-picker>
`}
${!supportsBrowse
? html`<ha-alert>
? html`
<ha-alert>
${this.hass.localize(
"ui.components.selectors.media.browse_not_supported"
)}
</ha-alert>
<ha-form
.hass=${this.hass}
.data=${this.value}
.data=${this.value || EMPTY_FORM}
.schema=${MANUAL_SCHEMA}
.computeLabel=${this._computeLabelCallback}
></ha-form>`
: html`<ha-card
outlined
@click=${this._pickMedia}
class=${this.disabled || !this.value?.entity_id ? "disabled" : ""}
>
<div
class="thumbnail ${classMap({
portrait:
!!this.value?.metadata?.media_class &&
MediaClassBrowserSettings[
this.value.metadata.children_media_class ||
this.value.metadata.media_class
].thumbnail_ratio === "portrait",
})}"
>
${this.value?.metadata?.thumbnail
? html`
<div
class="${classMap({
"centered-image":
!!this.value.metadata.media_class &&
["app", "directory"].includes(
this.value.metadata.media_class
),
})}
image"
style=${this._thumbnailUrl
? `background-image: url(${this._thumbnailUrl});`
: ""}
></div>
`
: html`
<div class="icon-holder image">
<ha-svg-icon
class="folder"
.path=${!this.value?.media_content_id
? mdiPlus
: this.value?.metadata?.media_class
? MediaClassBrowserSettings[
this.value.metadata.media_class === "directory"
? this.value.metadata.children_media_class ||
this.value.metadata.media_class
: this.value.metadata.media_class
].icon
: mdiPlayBox}
></ha-svg-icon>
</div>
`}
</div>
<div class="title">
${!this.value?.media_content_id
></ha-form>
`
: html`
<ha-card
outlined
tabindex="0"
role="button"
aria-label=${!this.value?.media_content_id
? this.hass.localize("ui.components.selectors.media.pick_media")
: this.value.metadata?.title || this.value.media_content_id}
</div>
</ha-card>`}`;
@click=${this._pickMedia}
@keydown=${this._handleKeyDown}
class=${this.disabled || (!this.value?.entity_id && !hasAccept)
? "disabled"
: ""}
>
<div class="content-container">
<div class="thumbnail">
${this.value?.metadata?.thumbnail
? html`
<div
class="${classMap({
"centered-image":
!!this.value.metadata.media_class &&
["app", "directory"].includes(
this.value.metadata.media_class
),
})}
image"
style=${this._thumbnailUrl
? `background-image: url(${this._thumbnailUrl});`
: ""}
></div>
`
: html`
<div class="icon-holder image">
<ha-svg-icon
class="folder"
.path=${!this.value?.media_content_id
? mdiPlus
: this.value?.metadata?.media_class
? MediaClassBrowserSettings[
this.value.metadata.media_class ===
"directory"
? this.value.metadata
.children_media_class ||
this.value.metadata.media_class
: this.value.metadata.media_class
].icon
: mdiPlayBox}
></ha-svg-icon>
</div>
`}
</div>
<div class="title">
${!this.value?.media_content_id
? this.hass.localize(
"ui.components.selectors.media.pick_media"
)
: this.value.metadata?.title || this.value.media_content_id}
</div>
</div>
</ha-card>
`}
`;
}
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%;
}
`;
}

View File

@ -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 {
</ha-textfield>
</div>
${!isBox && this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
: nothing}
`;
}

View File

@ -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`
<ha-md-list-item class="item">
${reorderable
? html`
<ha-svg-icon
class="handle"
.path=${mdiDrag}
slot="start"
></ha-svg-icon>
`
: nothing}
<div slot="headline" class="label">${label}</div>
${description
? html`<div slot="supporting-text" class="description">
${description}
</div>`
: nothing}
<ha-icon-button
slot="end"
.item=${item}
.index=${index}
.label=${this.hass.localize("ui.common.edit")}
.path=${mdiPencil}
@click=${this._editItem}
></ha-icon-button>
<ha-icon-button
slot="end"
.index=${index}
.label=${this.hass.localize("ui.common.delete")}
.path=${multiple ? mdiDelete : mdiClose}
@click=${this._deleteItem}
></ha-icon-button>
</ha-md-list-item>
`;
}
protected render() {
if (this.selector.object?.fields) {
if (this.selector.object.multiple) {
const items = ensureArray(this.value ?? []);
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<div class="items-container">
<ha-sortable
handle-selector=".handle"
draggable-selector=".item"
@item-moved=${this._itemMoved}
>
<ha-md-list>
${items.map((item, index) => this._renderItem(item, index))}
</ha-md-list>
</ha-sortable>
<ha-button outlined @click=${this._addItem}>
${this.hass.localize("ui.common.add")}
</ha-button>
</div>
`;
}
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<div class="items-container">
${this.value
? html`<ha-md-list>
${this._renderItem(this.value, 0)}
</ha-md-list>`
: html`
<ha-button outlined @click=${this._addItem}>
${this.hass.localize("ui.common.add")}
</ha-button>
`}
</div>
`;
}
return html`<ha-yaml-editor
.hass=${this.hass}
.readonly=${this.disabled}
@ -38,13 +170,109 @@ export class HaObjectSelector extends LitElement {
@value-changed=${this._handleChange}
></ha-yaml-editor>
${this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
: ""} `;
}
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 {

View File

@ -285,7 +285,9 @@ export class HaSelectSelector extends LitElement {
private _renderHelper() {
return this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
: "";
}

View File

@ -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",

View File

@ -63,7 +63,9 @@ export class HaTemplateSelector extends LitElement {
linewrap
></ha-code-editor>
${this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
: nothing}
`;
}

View File

@ -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 {
><ha-selector
.hass=${this.hass}
.selector=${this._targetSelector(
serviceData.target as TargetSelector
serviceData.target as TargetSelector,
this._value?.target
)}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
@ -575,7 +602,7 @@ export class HaServiceControl extends LitElement {
private _hasFilteredFields(
dataFields: ExtHassService["fields"],
targetEntities: string[]
targetEntities: string[] | null
) {
return dataFields.some(
(dataField) =>
@ -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;

View File

@ -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(*) {

View File

@ -289,7 +289,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
${this._renderPicker()}
</div>
${this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
: ""}
`;
}

View File

@ -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<string, unknown>): 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"
></ha-code-editor>
${this._showingError
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
${this.copyClipboard || this.hasExtraActions
? html`
<div class="card-actions">
@ -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;
}

View File

@ -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}

View File

@ -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<string>();
// 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`
<ha-fab
mini
@ -748,11 +786,11 @@ export class HaMediaPlayerBrowse extends LitElement {
};
private async _fetchData(
entityId: string,
entityId: string | undefined,
mediaContentId?: string,
mediaContentType?: string
): Promise<MediaPlayerItem> {
return entityId !== BROWSER_PLAYER
return entityId && entityId !== BROWSER_PLAYER
? browseMediaPlayer(this.hass, entityId, mediaContentId, mediaContentType)
: browseLocalMediaPlayer(this.hass, mediaContentId);
}

View File

@ -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 = (

53
src/data/ai_task.ts Normal file
View File

@ -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<T = string> {
conversation_id: string;
data: T;
}
export interface AITaskStructureField {
description?: string;
required?: boolean;
selector: Selector;
}
export type AITaskStructure = Record<string, AITaskStructureField>;
export const fetchAITaskPreferences = (hass: HomeAssistant) =>
hass.callWS<AITaskPreferences>({
type: "ai_task/preferences/get",
});
export const saveAITaskPreferences = (
hass: HomeAssistant,
preferences: Partial<AITaskPreferences>
) =>
hass.callWS<AITaskPreferences>({
type: "ai_task/preferences/set",
...preferences,
});
export const generateDataAITask = async <T = string>(
hass: HomeAssistant,
task: {
task_name: string;
entity_id?: string;
instructions: string;
structure?: AITaskStructure;
}
): Promise<GenDataTaskResult<T>> => {
const result = await hass.callService<GenDataTaskResult<T>>(
"ai_task",
"generate_data",
task,
undefined,
true,
true
);
return result.response!;
};

View File

@ -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

View File

@ -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<string | undefined> => {
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;
};

View File

@ -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:

View File

@ -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<UnsubscribeFunc> => {
@ -140,7 +144,7 @@ export const subscribeLogbook = (
params.device_ids = deviceIds;
}
return hass.connection.subscribeMessage<LogbookStreamMessage>(
(message) => callbackFunction(message),
(message) => callbackFunction(message, subscriptionId),
params
);
};

View File

@ -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<string, any>,
callback: (preview: GenericPreview) => void
): Promise<UnsubscribeFunc> =>

View File

@ -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<ServiceAction> = assign(
export const serviceActionStruct: Describe<ServiceActionWithTemplate> = 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<string, unknown>;
}
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);
}

View File

@ -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<string, ObjectSelectorField>;
multiple?: boolean;
} | null;
}
export interface AssistPipelineSelector {

View File

@ -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(", ");
};

View File

@ -34,6 +34,7 @@ export type SystemHealthInfo = Partial<{
dev: boolean;
hassio: boolean;
docker: boolean;
container_arch: string;
user: string;
virtualenv: boolean;
python_version: string;

View File

@ -286,7 +286,7 @@ class DataEntryFlowDialog extends LitElement {
scrimClickAction
escapeKeyAction
hideActions
.heading=${dialogTitle}
.heading=${dialogTitle || true}
>
<ha-dialog-header slot="heading">
<ha-icon-button

View File

@ -82,7 +82,11 @@ export class FlowPreviewGeneric extends LitElement {
(await this._unsub)();
this._unsub = undefined;
}
if (this.flowType !== "config_flow" && this.flowType !== "options_flow") {
if (
this.flowType !== "config_flow" &&
this.flowType !== "options_flow" &&
this.flowType !== "config_subentries_flow"
) {
return;
}
this._error = undefined;

View File

@ -35,10 +35,16 @@ export const showConfigFlowDialog = (
return step;
},
fetchFlow: async (hass, flowId) => {
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,

View File

@ -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<FormDialogData>
{
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _params?: FormDialogParams;
@state() private _data: FormDialogData = {};
public async showDialog(params: FormDialogParams): Promise<void> {
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`
<ha-dialog
open
scrimClickAction
escapeKeyAction
.heading=${createCloseHeading(this.hass, this._params.title)}
@closed=${this._cancel}
>
<ha-form
dialogInitialFocus
.hass=${this.hass}
.computeLabel=${this._params.computeLabel}
.computeHelper=${this._params.computeHelper}
.data=${this._data}
.schema=${this._params.schema}
@value-changed=${this._valueChanged}
>
</ha-form>
<ha-button @click=${this._cancel} slot="secondaryAction">
${this._params.cancelText || this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button @click=${this._submit} slot="primaryAction">
${this._params.submitText || this.hass.localize("ui.common.save")}
</ha-button>
</ha-dialog>
`;
}
static styles = [haStyleDialog, css``];
}
declare global {
interface HTMLElementTagNameMap {
"dialog-form": DialogForm;
}
}

View File

@ -0,0 +1,45 @@
import { fireEvent } from "../../common/dom/fire_event";
import type { HaFormSchema } from "../../components/ha-form/types";
export type FormDialogData = Record<string, any>;
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<FormDialogData | null>((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);
}
},
},
});
});

View File

@ -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) {

View File

@ -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`
<ha-selector-media
<ha-selector
.selector=${MEDIA_SELECTOR_SCHEMA}
.hass=${this.hass}
.disabled=${this.disabled}
.value=${this._getSelectorValue(this.action)}
@value-changed=${this._valueChanged}
></ha-selector-media>
></ha-selector>
`;
}

View File

@ -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(

View File

@ -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}
></ha-icon-button>
<span slot="title">${this._params.title || title}</span>
${this._canSuggest
? html`
<ha-assist-chip
id="suggest"
slot="actionItems"
@click=${this._suggest}
label=${this.hass.localize("ui.common.suggest_ai")}
>
<ha-svg-icon
slot="icon"
.path=${mdiStarFourPoints}
></ha-svg-icon>
</ha-assist-chip>
`
: nothing}
</ha-dialog-header>
${this._error
? html`<ha-alert alert-type="error"
@ -313,6 +348,124 @@ class DialogAutomationSave extends LitElement implements HassDialog {
this.closeDialog();
}
private async _suggest() {
const labels = await subscribeOne(
this.hass.connection,
subscribeLabelRegistry
).then((labs) =>
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<string, undefined | string> = 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<void> {
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;
}
`,
];
}

View File

@ -501,6 +501,8 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
.defaultValue=${this._preprocessYaml()}
.readOnly=${this._readOnly}
@value-changed=${this._yamlChanged}
.showErrors=${false}
disable-fullscreen
></ha-yaml-editor>`
: nothing}
</div>

View File

@ -566,6 +566,7 @@ export default class HaAutomationTriggerRow extends LitElement {
text: html`
<ha-yaml-editor
read-only
disable-fullscreen
.hass=${this.hass}
.defaultValue=${this._triggered}
></ha-yaml-editor>

View File

@ -173,6 +173,7 @@ export abstract class HaBlueprintGenericEditor extends LitElement {
.content=${value?.description}
></ha-markdown>
${html`<ha-selector
narrow
.hass=${this.hass}
.selector=${selector}
.key=${key}

View File

@ -204,6 +204,7 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
<cloud-tts-pref
.hass=${this.hass}
.narrow=${this.narrow}
.cloudStatus=${this.cloudStatus}
></cloud-tts-pref>

View File

@ -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 {
</div>
</div>
<div class="card-actions">
<div class="voice-id" @click=${this._copyVoiceId}>
<div class="label">
${this.hass.localize(
"ui.components.media-browser.tts.selected_voice_id"
)}
</div>
<code>${defaultVoice[1]}</code>
${this.narrow
? nothing
: html`
<ha-icon-button
.path=${mdiContentCopy}
title=${this.hass.localize(
"ui.components.media-browser.tts.copy_voice_id"
)}
></ha-icon-button>
`}
</div>
<div class="flex"></div>
<mwc-button @click=${this._openTryDialog}>
${this.hass.localize("ui.panel.config.cloud.account.tts.try")}
</mwc-button>
@ -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;
}
`;
}

View File

@ -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`
<ha-card outlined>
<h1 class="card-header">
<img
alt=""
src=${brandsUrl({
domain: "ai_task",
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>${this.hass.localize("ui.panel.config.ai_task.header")}
</h1>
<div class="header-actions">
<a
href=${documentationUrl(this.hass, "/integrations/ai_task/")}
target="_blank"
rel="noreferrer"
class="icon-link"
>
<ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.cloud.account.alexa.link_learn_how_it_works"
)}
.path=${mdiHelpCircle}
></ha-icon-button>
</a>
</div>
<div class="card-content">
<p>
${this.hass!.localize("ui.panel.config.ai_task.description", {
button: html`<ha-svg-icon
.path=${mdiStarFourPoints}
></ha-svg-icon>`,
})}
</p>
<ha-settings-row .narrow=${this.narrow}>
<span slot="heading">
${this.hass!.localize("ui.panel.config.ai_task.gen_data_header")}
</span>
<span slot="description">
${this.hass!.localize(
"ui.panel.config.ai_task.gen_data_description"
)}
</span>
<ha-entity-picker
data-name="gen_data_entity_id"
.hass=${this.hass}
.value=${this._prefs.gen_data_entity_id}
.includeDomains=${["ai_task"]}
@value-changed=${this._handlePrefChange}
></ha-entity-picker>
</ha-settings-row>
</div>
</ha-card>
`;
}
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;
}
}

View File

@ -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 {
</ha-progress-button>
</div>
</ha-card>
${isComponentLoaded(this.hass, "ai_task")
? html`<ai-task-pref
.hass=${this.hass}
.narrow=${this.narrow}
></ai-task-pref>`
: nothing}
</div>
</hass-subpage>
`;
@ -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;

View File

@ -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 {

View File

@ -54,6 +54,9 @@ class HaConfigNavigation extends LitElement {
`,
}));
return html`
<div class="visually-hidden" role="heading" aria-level="2">
${this.hass.localize("panel.config")}
</div>
<ha-navigation-list
has-secondary
.hass=${this.hass}
@ -68,6 +71,17 @@ class HaConfigNavigation extends LitElement {
ha-navigation-list {
--navigation-list-item-title-font-size: var(--ha-font-size-l);
}
/* Accessibility */
.visually-hidden {
position: absolute;
overflow: hidden;
clip: rect(0 0 0 0);
height: 1px;
width: 1px;
margin: -1px;
padding: 0;
border: 0;
}
`;
}

View File

@ -64,7 +64,7 @@ class HaConfigUpdates extends SubscribeMixin(LitElement) {
const updates = this.updateEntities;
return html`
<div class="title">
<div class="title" role="heading" aria-level="2">
${this.hass.localize("ui.panel.config.updates.title", {
count: this.total || this.updateEntities.length,
})}
@ -115,7 +115,7 @@ class HaConfigUpdates extends SubscribeMixin(LitElement) {
></ha-spinner>`
: nothing}
</div>
<span
<span slot="headline"
>${deviceEntry
? computeDeviceNameDisplay(deviceEntry, this.hass)
: entity.attributes.friendly_name}</span

View File

@ -1,6 +1,7 @@
import {
mdiChatQuestion,
mdiCog,
mdiDelete,
mdiDeleteForever,
mdiHospitalBox,
mdiInformation,
@ -16,17 +17,19 @@ import {
fetchZwaveIsNodeFirmwareUpdateInProgress,
fetchZwaveNetworkStatus,
fetchZwaveNodeStatus,
fetchZwaveProvisioningEntries,
unprovisionZwaveSmartStartNode,
} from "../../../../../../data/zwave_js";
import { showConfirmationDialog } from "../../../../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../../../../types";
import { showZWaveJSRebuildNodeRoutesDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-rebuild-node-routes";
import { showZWaveJSNodeStatisticsDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-node-statistics";
import { showZWaveJSReinterviewNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-reinterview-node";
import { showZWaveJSRemoveFailedNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-remove-failed-node";
import { showZWaveJSUpdateFirmwareNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-update-firmware-node";
import type { DeviceAction } from "../../../ha-config-device-page";
import { showZWaveJSHardResetControllerDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-hard-reset-controller";
import { showZWaveJSAddNodeDialog } from "../../../../integrations/integration-panels/zwave_js/add-node/show-dialog-zwave_js-add-node";
import { showZWaveJSRemoveNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-remove-node";
export const getZwaveDeviceActions = async (
el: HTMLElement,
@ -47,6 +50,43 @@ export const getZwaveDeviceActions = async (
const entryId = configEntry.entry_id;
const provisioningEntries = await fetchZwaveProvisioningEntries(
hass,
entryId
);
const provisioningEntry = provisioningEntries.find(
(entry) => 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,
}),
}
);
}

View File

@ -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<this>) {
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`
<div class="items">
${this._related.automation.map((automation) => {
const entityState = this.hass.states[automation];
return entityState
? html`<a
href=${ifDefined(
entityState.attributes.id
? `/config/automation/edit/${encodeURIComponent(entityState.attributes.id)}`
: `/config/automation/show/${entityState.entity_id}`
)}
>
<ha-list-item hasMeta .automation=${entityState}>
${computeStateName(entityState)}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a>`
: nothing;
})}
${this._getRelated(this._related).automation.map(
(automation) => {
const entityState = automation;
return entityState
? html`<a
href=${ifDefined(
entityState.attributes.id
? `/config/automation/edit/${encodeURIComponent(entityState.attributes.id)}`
: `/config/automation/show/${entityState.entity_id}`
)}
>
<ha-list-item hasMeta .automation=${entityState}>
${computeStateName(entityState)}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a>`
: nothing;
}
)}
</div>
`
: html`
@ -510,8 +536,8 @@ export class HaConfigDevicePage extends LitElement {
${this._related?.scene?.length
? html`
<div class="items">
${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`
<a
@ -598,10 +624,10 @@ export class HaConfigDevicePage extends LitElement {
${this._related?.script?.length
? html`
<div class="items">
${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"),
}))

View File

@ -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) {

View File

@ -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"
)}
></ha-icon-picker>
<ha-textfield
<ha-duration-input
.configValue=${"duration"}
.value=${this._duration}
@input=${this._valueChanged}
.label=${this.hass.localize(
"ui.dialogs.helper_settings.timer.duration"
)}
></ha-textfield>
.data=${this._duration_data}
@value-changed=${this._valueChanged}
></ha-duration-input>
<ha-formfield
.label=${this.hass.localize(
"ui.dialogs.helper_settings.timer.restore"
@ -131,6 +136,25 @@ class HaTimerForm extends LitElement {
});
}
private _setDurationData() {
let durationInput: string | number | ForDict;
if (typeof this._duration === "object" && this._duration !== null) {
const d = this._duration as DurationDict;
durationInput = {
hours: typeof d.hours === "string" ? parseFloat(d.hours) : d.hours,
minutes:
typeof d.minutes === "string" ? parseFloat(d.minutes) : d.minutes,
seconds:
typeof d.seconds === "string" ? parseFloat(d.seconds) : d.seconds,
};
} else {
durationInput = this._duration;
}
this._duration_data = createDurationData(durationInput);
}
static get styles(): CSSResultGroup {
return [
haStyle,
@ -138,7 +162,8 @@ class HaTimerForm extends LitElement {
.form {
color: var(--primary-text-color);
}
ha-textfield {
ha-textfield,
ha-duration-input {
display: block;
margin: 8px 0;
}

View File

@ -0,0 +1,98 @@
import { mdiClose } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-dialog-header";
import "../../../components/ha-icon-button";
import "../../../components/ha-md-dialog";
import type { HaMdDialog } from "../../../components/ha-md-dialog";
import "../../../components/ha-md-list";
import "../../../components/ha-md-list-item";
import { ERROR_STATES, RECOVERABLE_STATES } from "../../../data/config_entries";
import type { HomeAssistant } from "../../../types";
import type { PickConfigEntryDialogParams } from "./show-pick-config-entry-dialog";
@customElement("dialog-pick-config-entry")
export class DialogPickConfigEntry extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: PickConfigEntryDialogParams;
@query("ha-md-dialog") private _dialog?: HaMdDialog;
public showDialog(params: PickConfigEntryDialogParams): void {
this._params = params;
}
private _dialogClosed(): void {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
public closeDialog() {
this._dialog?.close();
return true;
}
protected render() {
if (!this._params) {
return nothing;
}
return html`
<ha-md-dialog open @closed=${this._dialogClosed}>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
@click=${this.closeDialog}
></ha-icon-button>
<span
slot="title"
.title=${this.hass.localize(
`component.${this._params.domain}.config_subentries.${this._params.subFlowType}.initiate_flow.user`
)}
>${this.hass.localize(
`component.${this._params.domain}.config_subentries.${this._params.subFlowType}.initiate_flow.user`
)}</span
>
</ha-dialog-header>
<ha-md-list slot="content">
${this._params.configEntries.map(
(entry) =>
html`<ha-md-list-item
type="button"
@click=${this._itemPicked}
.entry=${entry}
.disabled=${!ERROR_STATES.includes(entry.state) &&
!RECOVERABLE_STATES.includes(entry.state)}
>${entry.title}</ha-md-list-item
>`
)}
</ha-md-list>
</ha-md-dialog>
`;
}
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;
}
}

View File

@ -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`<ha-md-list-item
type="button"
@click=${this._handleNavigateToDevice}
class=${classMap({ disabled: Boolean(device.disabled_by) })}
>
<ha-svg-icon
.path=${device.entry_type === "service"
? mdiTransitConnectionVariant
: mdiDevices}
slot="start"
></ha-svg-icon>
<div slot="headline">${computeDeviceNameDisplay(device, this.hass)}</div>
<span slot="supporting-text"
>${supportingText.join(" • ")}
${supportingText.length && entities.length ? " • " : nothing}
${entities.length
? this.hass.localize(
"ui.panel.config.integrations.config_entry.entities",
{ count: entities.length }
)
: nothing}</span
>
${!this.narrow
? html`<ha-icon-next slot="end"> </ha-icon-next>`
: nothing}
<div class="vertical-divider" slot="end" @click=${stopPropagation}></div>
${!this.narrow
? html`<ha-icon-button
slot="end"
@click=${this._handleEditDevice}
.path=${mdiPencil}
.label=${this.hass.localize(
"ui.panel.config.integrations.config_entry.device.edit"
)}
></ha-icon-button>`
: nothing}
<ha-md-button-menu
positioning="popover"
slot="end"
@click=${stopPropagation}
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
${this.narrow
? html`<ha-md-menu-item @click=${this._handleEditDevice}>
<ha-svg-icon .path=${mdiPencil} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.device.edit"
)}
</ha-md-menu-item>`
: nothing}
${entities.length
? html`
<ha-md-menu-item
href=${`/config/entities/?historyBack=1&device=${device.id}`}
>
<ha-svg-icon
.path=${mdiShapeOutline}
slot="start"
></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.entities`,
{ count: entities.length }
)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-menu-item>
`
: nothing}
<ha-md-menu-item
class=${device.disabled_by !== "user" ? "warning" : ""}
@click=${this._handleDisableDevice}
.disabled=${device.disabled_by !== "user" && device.disabled_by}
>
<ha-svg-icon .path=${mdiStopCircleOutline} slot="start"></ha-svg-icon>
${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"
)}
</ha-md-menu-item>
${this.entry.supports_remove_device
? html`<ha-md-menu-item
class="warning"
@click=${this._handleDeleteDevice}
>
<ha-svg-icon .path=${mdiDelete} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.device.delete"
)}
</ha-md-menu-item>`
: nothing}
</ha-md-button-menu>
</ha-md-list-item> `;
}
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;
}
}

View File

@ -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<typeof this.hass.localize> | 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`<a
href=${`/config/entities/?historyBack=1&config_entry=${item.entry_id}`}
>${this.hass.localize(
"ui.panel.config.integrations.config_entry.entities",
{ count: entities.length }
)}</a
>`
);
}
const configPanel = this._configPanel(item.domain, this.hass.panels);
const subEntries = this._subEntries || [];
return html`<ha-md-list>
<ha-md-list-item
class=${classMap({
config_entry: true,
"state-not-loaded": item!.state === "not_loaded",
"state-failed-unload": item!.state === "failed_unload",
"state-setup": item!.state === "setup_in_progress",
"state-error": ERROR_STATES.includes(item!.state),
"state-disabled": item.disabled_by !== null,
"has-subentries": this._expanded && subEntries.length > 0,
})}
>
${subEntries.length || ownDevices.length
? html`<ha-icon-button
class="expand-button ${classMap({ expanded: this._expanded })}"
.path=${mdiChevronDown}
slot="start"
@click=${this._toggleExpand}
></ha-icon-button>`
: nothing}
<div slot="headline">
${item.title || domainToName(this.hass.localize, item.domain)}
</div>
<div slot="supporting-text">
<div>${statusLine}</div>
${stateText
? html`
<div class="message">
<ha-svg-icon .path=${icon}></ha-svg-icon>
<div>
${this.hass.localize(...stateText)}${stateTextExtra
? html`: ${stateTextExtra}`
: nothing}
</div>
</div>
`
: nothing}
</div>
${item.disabled_by === "user"
? html`<ha-button unelevated slot="end" @click=${this._handleEnable}>
${this.hass.localize("ui.common.enable")}
</ha-button>`
: configPanel &&
(item.domain !== "matter" ||
isDevVersion(this.hass.config.version)) &&
!stateText
? html`<a
slot="end"
href=${`/${configPanel}?config_entry=${item.entry_id}`}
><ha-icon-button
.path=${mdiCogOutline}
.label=${this.hass.localize(
"ui.panel.config.integrations.config_entry.configure"
)}
>
</ha-icon-button
></a>`
: item.supports_options
? html`
<ha-icon-button
slot="end"
@click=${this._showOptions}
.path=${mdiCogOutline}
.label=${this.hass.localize(
"ui.panel.config.integrations.config_entry.configure"
)}
>
</ha-icon-button>
`
: nothing}
<ha-md-button-menu positioning="popover" slot="end">
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
${devices.length
? html`
<ha-md-menu-item
href=${devices.length === 1
? `/config/devices/device/${devices[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}`}
>
<ha-svg-icon .path=${mdiDevices} slot="start"></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.devices`,
{ count: devices.length }
)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-menu-item>
`
: nothing}
${services.length
? html`<ha-md-menu-item
href=${services.length === 1
? `/config/devices/device/${services[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}`}
>
<ha-svg-icon
.path=${mdiHandExtendedOutline}
slot="start"
></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.services`,
{ count: services.length }
)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-menu-item> `
: nothing}
${entities.length
? html`
<ha-md-menu-item
href=${`/config/entities?historyBack=1&config_entry=${item.entry_id}`}
>
<ha-svg-icon
.path=${mdiShapeOutline}
slot="start"
></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.entities`,
{ count: entities.length }
)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-menu-item>
`
: nothing}
${!item.disabled_by &&
RECOVERABLE_STATES.includes(item.state) &&
item.supports_unload &&
item.source !== "system"
? html`
<ha-md-menu-item @click=${this._handleReload}>
<ha-svg-icon slot="start" .path=${mdiReload}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.reload"
)}
</ha-md-menu-item>
`
: nothing}
<ha-md-menu-item @click=${this._handleRename} graphic="icon">
<ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.rename"
)}
</ha-md-menu-item>
${Object.keys(item.supported_subentry_types).map(
(flowType) =>
html`<ha-md-menu-item
@click=${this._addSubEntry}
.entry=${item}
.flowType=${flowType}
graphic="icon"
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize(
`component.${item.domain}.config_subentries.${flowType}.initiate_flow.user`
)}</ha-md-menu-item
>`
)}
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
${this.diagnosticHandler && item.state === "loaded"
? html`
<ha-md-menu-item
href=${getConfigEntryDiagnosticsDownloadUrl(item.entry_id)}
target="_blank"
@click=${this._signUrl}
>
<ha-svg-icon slot="start" .path=${mdiDownload}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.download_diagnostics"
)}
</ha-md-menu-item>
`
: nothing}
${!item.disabled_by &&
item.supports_reconfigure &&
item.source !== "system"
? html`
<ha-md-menu-item @click=${this._handleReconfigure}>
<ha-svg-icon slot="start" .path=${mdiWrench}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.reconfigure"
)}
</ha-md-menu-item>
`
: nothing}
<ha-md-menu-item @click=${this._handleSystemOptions} graphic="icon">
<ha-svg-icon slot="start" .path=${mdiCogOutline}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.system_options"
)}
</ha-md-menu-item>
${item.disabled_by === "user"
? html`
<ha-md-menu-item @click=${this._handleEnable}>
<ha-svg-icon
slot="start"
.path=${mdiPlayCircleOutline}
></ha-svg-icon>
${this.hass.localize("ui.common.enable")}
</ha-md-menu-item>
`
: item.source !== "system"
? html`
<ha-md-menu-item
class="warning"
@click=${this._handleDisable}
graphic="icon"
>
<ha-svg-icon
slot="start"
class="warning"
.path=${mdiStopCircleOutline}
></ha-svg-icon>
${this.hass.localize("ui.common.disable")}
</ha-md-menu-item>
`
: nothing}
${item.source !== "system"
? html`
<ha-md-menu-item class="warning" @click=${this._handleDelete}>
<ha-svg-icon
slot="start"
class="warning"
.path=${mdiDelete}
></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.delete"
)}
</ha-md-menu-item>
`
: nothing}
</ha-md-button-menu>
</ha-md-list-item>
${this._expanded
? subEntries.length
? html`${ownDevices.length
? html`<ha-md-list class="devices">
<ha-md-list-item
@click=${this._toggleOwnDevices}
type="button"
class="toggle-devices-row ${classMap({
expanded: this._devicesExpanded,
})}"
>
<ha-icon-button
class="expand-button ${classMap({
expanded: this._devicesExpanded,
})}"
.path=${mdiChevronDown}
slot="start"
>
</ha-icon-button>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.devices_without_subentry"
)}
</ha-md-list-item>
${this._devicesExpanded
? ownDevices.map(
(device) =>
html`<ha-config-entry-device-row
.hass=${this.hass}
.narrow=${this.narrow}
.entry=${item}
.device=${device}
.entities=${entities}
></ha-config-entry-device-row>`
)
: nothing}
</ha-md-list>`
: nothing}
${subEntries.map(
(subEntry) => html`
<ha-config-sub-entry-row
.hass=${this.hass}
.narrow=${this.narrow}
.manifest=${this.manifest}
.diagnosticHandler=${this.diagnosticHandler}
.entities=${this.entities}
.entry=${item}
.subEntry=${subEntry}
data-entry-id=${item.entry_id}
></ha-config-sub-entry-row>
`
)}`
: html`
${ownDevices.map(
(device) =>
html`<ha-config-entry-device-row
.hass=${this.hass}
.narrow=${this.narrow}
.entry=${item}
.device=${device}
.entities=${entities}
></ha-config-entry-device-row>`
)}
`
: nothing}
</ha-md-list>`;
}
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"
)},
<br />
<br />
${this.hass.localize(
"ui.panel.config.integrations.config_entry.application_credentials.delete_detail"
)}
<br />
<br />
<a
href=${documentationUrl(
this.hass,
"/integrations/application_credentials/"
)}
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.application_credentials.learn_more"
)}
</a>`,
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;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -406,11 +406,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
${!this._showDisabled && this.narrow && disabledConfigEntries.length
? html`<span class="badge">${disabledConfigEntries.length}</span>`
: ""}
<ha-button-menu
multi
@action=${this._handleMenuAction}
@click=${this._preventDefault}
>
<ha-button-menu multi @action=${this._handleMenuAction}>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}

View File

@ -0,0 +1,264 @@
import {
mdiChevronDown,
mdiCogOutline,
mdiDelete,
mdiDevices,
mdiDotsVertical,
mdiHandExtendedOutline,
mdiShapeOutline,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { ConfigEntry, SubEntry } from "../../../data/config_entries";
import { deleteSubEntry } from "../../../data/config_entries";
import type { DeviceRegistryEntry } from "../../../data/device_registry";
import type { DiagnosticInfo } from "../../../data/diagnostics";
import type { EntityRegistryEntry } from "../../../data/entity_registry";
import type { IntegrationManifest } from "../../../data/integration";
import { showSubConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-sub-config-flow";
import type { HomeAssistant } from "../../../types";
import { showConfirmationDialog } from "../../lovelace/custom-card-helpers";
import "./ha-config-entry-device-row";
@customElement("ha-config-sub-entry-row")
class HaConfigSubEntryRow 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;
@property({ attribute: false }) public subEntry!: SubEntry;
@state() private _expanded = true;
protected render() {
const subEntry = this.subEntry;
const configEntry = this.entry;
const devices = this._getDevices();
const services = this._getServices();
const entities = this._getEntities();
return html`<ha-md-list>
<ha-md-list-item
class="sub-entry"
data-entry-id=${configEntry.entry_id}
.configEntry=${configEntry}
.subEntry=${subEntry}
>
${devices.length || services.length
? html`<ha-icon-button
class="expand-button ${classMap({ expanded: this._expanded })}"
.path=${mdiChevronDown}
slot="start"
@click=${this._toggleExpand}
></ha-icon-button>`
: nothing}
<span slot="headline">${subEntry.title}</span>
<span slot="supporting-text"
>${this.hass.localize(
`component.${configEntry.domain}.config_subentries.${subEntry.subentry_type}.entry_type`
)}</span
>
${configEntry.supported_subentry_types[subEntry.subentry_type]
?.supports_reconfigure
? html`
<ha-icon-button
slot="end"
@click=${this._handleReconfigureSub}
.path=${mdiCogOutline}
.label=${this.hass.localize(
`component.${configEntry.domain}.config_subentries.${subEntry.subentry_type}.initiate_flow.reconfigure`
) ||
this.hass.localize(
"ui.panel.config.integrations.config_entry.configure"
)}
>
</ha-icon-button>
`
: nothing}
<ha-md-button-menu positioning="popover" slot="end">
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
${devices.length || services.length
? html`
<ha-md-menu-item
href=${devices.length === 1
? `/config/devices/device/${devices[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}`}
>
<ha-svg-icon .path=${mdiDevices} slot="start"></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.devices`,
{ count: devices.length }
)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-menu-item>
`
: nothing}
${services.length
? html`<ha-md-menu-item
href=${services.length === 1
? `/config/devices/device/${services[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}`}
>
<ha-svg-icon
.path=${mdiHandExtendedOutline}
slot="start"
></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.services`,
{ count: services.length }
)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-menu-item> `
: nothing}
${entities.length
? html`
<ha-md-menu-item
href=${`/config/entities?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}`}
>
<ha-svg-icon
.path=${mdiShapeOutline}
slot="start"
></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.entities`,
{ count: entities.length }
)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-menu-item>
`
: nothing}
<ha-md-menu-item class="warning" @click=${this._handleDeleteSub}>
<ha-svg-icon
slot="start"
class="warning"
.path=${mdiDelete}
></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.delete"
)}
</ha-md-menu-item>
</ha-md-button-menu>
</ha-md-list-item>
${this._expanded
? html`
${devices.map(
(device) =>
html`<ha-config-entry-device-row
.hass=${this.hass}
.narrow=${this.narrow}
.entry=${this.entry}
.device=${device}
.entities=${this.entities}
></ha-config-entry-device-row>`
)}
${services.map(
(service) =>
html`<ha-config-entry-device-row
.hass=${this.hass}
.narrow=${this.narrow}
.entry=${this.entry}
.device=${service}
.entities=${this.entities}
></ha-config-entry-device-row>`
)}
`
: nothing}
</ha-md-list>`;
}
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<void> {
showSubConfigFlowDialog(this, this.entry, this.subEntry.subentry_type, {
startFlowHandler: this.entry.entry_id,
subEntryId: this.subEntry.subentry_id,
});
}
private async _handleDeleteSub(): Promise<void> {
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;
}
}

View File

@ -60,6 +60,7 @@ export class DHCPConfigPanel extends SubscribeMixin(LitElement) {
title: localize("ui.panel.config.dhcp.ip_address"),
filterable: true,
sortable: true,
type: "ip",
},
};

View File

@ -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"
),

View File

@ -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<UnsubscribeFunc | undefined>;
public disconnectedCallback(): void {
super.disconnectedCallback();
this._unsubscribe();
}
public async showDialog(
params: ZWaveJSRemoveFailedNodeDialogParams
): Promise<void> {
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`
<ha-dialog
open
@closed=${this.closeDialog}
.heading=${createCloseHeading(
this.hass,
this.hass.localize(
"ui.panel.config.zwave_js.remove_failed_node.title"
)
)}
>
${this._status === ""
? html`
<div class="flex-container">
<ha-svg-icon
.path=${mdiRobotDead}
class="introduction"
></ha-svg-icon>
<div class="status">
${this.hass.localize(
"ui.panel.config.zwave_js.remove_failed_node.introduction"
)}
</div>
</div>
<mwc-button slot="primaryAction" @click=${this._startExclusion}>
${this.hass.localize(
"ui.panel.config.zwave_js.remove_failed_node.remove_device"
)}
</mwc-button>
`
: ``}
${this._status === "started"
? html`
<div class="flex-container">
<ha-spinner></ha-spinner>
<div class="status">
<p>
<b>
${this.hass.localize(
"ui.panel.config.zwave_js.remove_failed_node.in_progress"
)}
</b>
</p>
</div>
</div>
`
: ``}
${this._status === "failed"
? html`
<div class="flex-container">
<ha-svg-icon
.path=${mdiCloseCircle}
class="error"
></ha-svg-icon>
<div class="status">
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.remove_failed_node.removal_failed"
)}
</p>
${this._error
? html` <p><em> ${this._error.message} </em></p> `
: ``}
</div>
</div>
<mwc-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.common.close")}
</mwc-button>
`
: ``}
${this._status === "finished"
? html`
<div class="flex-container">
<ha-svg-icon
.path=${mdiCheckCircle}
class="success"
></ha-svg-icon>
<div class="status">
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.remove_failed_node.removal_finished",
{ id: this._node!.node_id }
)}
</p>
</div>
</div>
<mwc-button
slot="primaryAction"
@click=${this.closeDialogFinished}
>
${this.hass.localize("ui.common.close")}
</mwc-button>
`
: ``}
</ha-dialog>
`;
}
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<void> {
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;
}
}

View File

@ -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<void> {
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`
<ha-dialog open @closed=${this.closeDialog} .heading=${dialogTitle}>
<ha-dialog
open
@closed=${this.handleDialogClosed}
.heading=${dialogTitle}
.hideActions=${this._step === "start"}
>
<ha-dialog-header slot="heading">
<ha-icon-button
slot="navigationIcon"
@ -100,6 +128,47 @@ class DialogZWaveJSRemoveNode extends LitElement {
"ui.panel.config.zwave_js.remove_node.introduction"
)}
</p>
<div class="menu-options">
<ha-list-item hasMeta @click=${this._startExclusion}>
<span
>${this.hass.localize(
"ui.panel.config.zwave_js.remove_node.menu_exclude_device"
)}</span
>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
<ha-list-item hasMeta @click=${this._startRemoval}>
<span
>${this.hass.localize(
"ui.panel.config.zwave_js.remove_node.menu_remove_device"
)}</span
>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</div>
`;
}
if (this._step === "start_removal") {
return html`
<ha-svg-icon .path=${mdiRobotDead}></ha-svg-icon>
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.remove_node.failed_node_intro",
{ name: this._device!.name_by_user || this._device!.name }
)}
</p>
`;
}
if (this._step === "start_exclusion") {
return html`
<ha-svg-icon .path=${mdiVectorSquareRemove}></ha-svg-icon>
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.remove_node.exclusion_intro"
)}
</p>
`;
}
@ -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`
<ha-button slot="secondaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._startRemoval}
destructive
>
${this.hass.localize("ui.common.remove")}
</ha-button>
`;
}
if (this._step === "start_exclusion") {
return html`
<ha-button slot="secondaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._startExclusion}
destructive
>
${this.hass.localize(
"ui.panel.config.zwave_js.remove_node.start_exclusion"
)}
</ha-button>
`;
}
return html`
<ha-button
slot="primaryAction"
@click=${this._step === "start"
? this._startExclusion
: this.closeDialog}
>
<ha-button slot="primaryAction" @click=${this.closeDialog}>
${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"
)}
</ha-button>
`;
}
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;
}
`,
];
}

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