mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-19 01:25:51 +00:00
Compare commits
83 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef22539175 | |||
| 7346e9408a | |||
| 6b0f543fdb | |||
| bbd26ce5a9 | |||
| d3c23dd704 | |||
| 7d4e5dc03f | |||
| db22be5a30 | |||
| b896b20409 | |||
| cb3b528ba3 | |||
| 2ed8e0bd0d | |||
| adc3393422 | |||
| 06b9b08809 | |||
| ece84d42dc | |||
| e031e59b31 | |||
| 5bdccd2e4a | |||
| be27615851 | |||
| 48f3d45a2e | |||
| f2a049b7c6 | |||
| 1e691f0b64 | |||
| 23d7f4772a | |||
| bd23592e77 | |||
| 0fe80ef5f9 | |||
| 4327871c30 | |||
| 88381e0b11 | |||
| f44590a2aa | |||
| 1c03daac41 | |||
| a2216c6bf5 | |||
| 4a755e00ac | |||
| 36a9da37f8 | |||
| cee5361525 | |||
| 5f46594ee2 | |||
| 58bcf1e865 | |||
| 196d71c4f7 | |||
| 7e60fbb66e | |||
| cea86f0aa0 | |||
| 85eb2c9f6e | |||
| b401cd1500 | |||
| 598e8d4a45 | |||
| 030d777f26 | |||
| 194b7edf3a | |||
| 97ed3afe4b | |||
| 77cc3cfe32 | |||
| 1350fd3674 | |||
| 9a283aba78 | |||
| 7f04b0884b | |||
| a4c07e5fc2 | |||
| 42e0a1e4b5 | |||
| 438e2e5173 | |||
| 0548f99057 | |||
| ea75339392 | |||
| 7e244a706b | |||
| 7911aef6c0 | |||
| ed00d4ded1 | |||
| be55a2197e | |||
| a73c659840 | |||
| 207025d9a4 | |||
| e9431105b0 | |||
| 1e5560fd42 | |||
| 369e446149 | |||
| 318dac3bf1 | |||
| 8ee9c95454 | |||
| 8ea65987a7 | |||
| ada5be6af5 | |||
| 5bcb6628b0 | |||
| 24fd2241df | |||
| db0813904c | |||
| b30668995d | |||
| 9ba003f86a | |||
| 0536e2cd2a | |||
| 0c25f061ef | |||
| 62e5c9dee9 | |||
| 36fa14ee26 | |||
| 14294998e7 | |||
| 60549695b0 | |||
| 41068657bd | |||
| 7d11f283d2 | |||
| 0927a55a0c | |||
| 9e75f9282e | |||
| 56af189032 | |||
| 02c99dd61d | |||
| f00342d522 | |||
| 5538be0d0a | |||
| 96b5c2910e |
@@ -0,0 +1,98 @@
|
||||
---
|
||||
name: ha-frontend-components
|
||||
description: Home Assistant frontend component patterns. Use when implementing or reviewing dialogs, ha-form, ha-alert, keyboard shortcuts, tooltips, panels, Lovelace cards, or ha-button usage.
|
||||
---
|
||||
|
||||
# HA Frontend Components
|
||||
|
||||
Use this skill when creating or reviewing Home Assistant UI components and common interaction patterns.
|
||||
|
||||
## Dialogs
|
||||
|
||||
Open dialogs through the fire-event pattern:
|
||||
|
||||
```ts
|
||||
fireEvent(this, "show-dialog", {
|
||||
dialogTag: "dialog-example",
|
||||
dialogImport: () => import("./dialog-example"),
|
||||
dialogParams: { title: "Example", data: someData },
|
||||
});
|
||||
```
|
||||
|
||||
Dialog implementation requirements:
|
||||
|
||||
- Use `ha-dialog`.
|
||||
- Implement `HassDialog<T>`.
|
||||
- Use `@state() private _open = false` to control visibility.
|
||||
- Set `_open = true` in `showDialog()` and `_open = false` in `closeDialog()`.
|
||||
- Return `nothing` while required params are absent.
|
||||
- Fire `dialog-closed` in the close handler.
|
||||
- Use `header-title` and `header-subtitle` for simple header text.
|
||||
- Use slots when standard header attributes are not enough.
|
||||
- Use `ha-dialog-footer` with `primaryAction` and `secondaryAction` slots.
|
||||
- Add `autofocus` to the first focusable element, such as `<ha-form autofocus>`, and forward it internally if needed.
|
||||
|
||||
Use standard dialog widths: `small`, `medium`, `large`, or `full`. Avoid custom dialog sizing unless there is a clear product need.
|
||||
|
||||
## Buttons
|
||||
|
||||
`ha-button` wraps the Web Awesome button in `src/components/ha-button.ts`.
|
||||
|
||||
Axes:
|
||||
|
||||
- `variant`: `brand`, `neutral`, `danger`, `warning`, `success`.
|
||||
- `appearance`: `accent`, `filled`, `outlined`, `plain`.
|
||||
- `size`: `xs`, `s`, `m`, `l`, `xl`.
|
||||
|
||||
Common usage:
|
||||
|
||||
- Use `appearance="filled"` for primary emphasis when needed.
|
||||
- Use `appearance="plain"` for cancel and dismiss actions.
|
||||
- Use `variant="danger"` for destructive actions.
|
||||
- Place primary actions in `slot="primaryAction"` and secondary actions in `slot="secondaryAction"`.
|
||||
|
||||
## Forms
|
||||
|
||||
`ha-form` is schema-driven with `HaFormSchema[]` and supports common selectors for entities, devices, areas, targets, numbers, booleans, time, actions, text, objects, selects, icons, media, and location.
|
||||
|
||||
Use `computeLabel`, `computeError`, and `computeHelper` for translated labels, validation, and helper text.
|
||||
|
||||
```ts
|
||||
<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>
|
||||
```
|
||||
|
||||
## Alerts
|
||||
|
||||
Use `ha-alert` for user-visible status messaging.
|
||||
|
||||
- Alert types: `error`, `warning`, `info`, `success`.
|
||||
- Useful properties: `title`, `alert-type`, `dismissable`, `narrow`.
|
||||
- Slots: `icon` for custom leading icon, `action` for custom action content.
|
||||
- Content is 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>
|
||||
```
|
||||
|
||||
## Shortcuts And Tooltips
|
||||
|
||||
Use `ShortcutManager` from `src/common/keyboard/shortcuts.ts` for keyboard shortcuts. It blocks shortcuts in input fields, can prevent shortcuts during text selection, and supports character and KeyCode shortcuts for non-latin keyboards. See `src/state/quick-bar-mixin.ts` for global shortcut examples.
|
||||
|
||||
Use `ha-tooltip` from `src/components/ha-tooltip.ts` for contextual hover help. See `src/components/ha-label.ts` for an example.
|
||||
|
||||
## Panels And Lovelace Cards
|
||||
|
||||
Panels commonly extend `SubscribeMixin(LitElement)` and receive route and narrow-layout properties.
|
||||
|
||||
Lovelace cards should implement `LovelaceCard`, validate config in `setConfig()`, handle loading, error, unavailable, and missing-entity states, and add a configuration editor when needed.
|
||||
|
||||
Cards are user-story surfaces. Support different households, entity types, responsive layouts, and accessible interaction states.
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
name: ha-frontend-contexts
|
||||
description: Home Assistant frontend Lit context and hass migration guidance. Use when adding or changing component state access, replacing hass reads, consuming entity or registry contexts, or reviewing rerender behavior.
|
||||
---
|
||||
|
||||
# HA Frontend Contexts
|
||||
|
||||
Use this skill when a component reads Home Assistant state, registries, localization, services, config, UI data, connection state, or API helpers.
|
||||
|
||||
## Goal
|
||||
|
||||
Move leaf components away from the broad `hass: HomeAssistant` object. Broad `hass` access rerenders components for unrelated changes, hides the data a component depends on, and makes tests harder to mock.
|
||||
|
||||
Container components may keep `hass` when they own it and feed providers. Leaf components should consume the narrowest context that covers their reads.
|
||||
|
||||
## Core Files
|
||||
|
||||
- Context definitions: `src/data/context/index.ts`
|
||||
- Entity-scoped consume helpers: `src/common/decorators/consume-context-entry.ts`
|
||||
- Transform decorator: `src/common/decorators/transform.ts`
|
||||
- Canonical migration example: `src/panels/lovelace/cards/hui-button-card.ts`
|
||||
- Providers are wired by `contextMixin` on `HassBaseEl`; consumers do not wire providers manually.
|
||||
|
||||
## Context Selection
|
||||
|
||||
| Context | Replaces |
|
||||
| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| `statesContext` | `hass.states` |
|
||||
| `entitiesContext`, `devicesContext`, `areasContext`, `floorsContext` | `hass.entities`, `hass.devices`, `hass.areas`, `hass.floors` |
|
||||
| `registriesContext` | all four registries together |
|
||||
| `servicesContext` | `hass.services` |
|
||||
| `internationalizationContext` | `hass.localize`, `hass.locale`, `hass.language` |
|
||||
| `formattersContext` | entity and attribute formatters |
|
||||
| `configContext` | `hass.config`, `hass.user`, `hass.auth`, `hass.userData` |
|
||||
| `connectionContext` | `hass.connection`, `hass.connected`, `hass.hassUrl` |
|
||||
| `apiContext` | `hass.callService`, `hass.callApi`, `hass.callWS`, `hass.sendWS`, `hass.fetchWithAuth` |
|
||||
| `uiContext` | themes, selected theme, panels, sidebar, and UI state |
|
||||
| `narrowViewportContext` | narrow-layout boolean |
|
||||
|
||||
Lazy contexts subscribe on first consumer and tear down after the last consumer: `labelsContext`, `fullEntitiesContext`, `configEntriesContext`, and `manifestsContext`.
|
||||
|
||||
The single-field contexts such as `localizeContext`, `themesContext`, and `userContext` are deprecated. Use grouped contexts instead.
|
||||
|
||||
## Consumption Patterns
|
||||
|
||||
Use entity-scoped helpers when the component watches an entity id held on the host:
|
||||
|
||||
```ts
|
||||
@state() @consumeEntityState({ entityIdPath: ["_config", "entity"] })
|
||||
private _stateObj?: HassEntity;
|
||||
|
||||
@state() @consumeEntityRegistryEntry({ entityIdPath: ["_config", "entity"] })
|
||||
private _entity?: EntityRegistryDisplayEntry;
|
||||
|
||||
@state() @consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
```
|
||||
|
||||
For a single field from a grouped context, pair `@consume` with `@transform`:
|
||||
|
||||
```ts
|
||||
@state()
|
||||
@consume({ context: uiContext, subscribe: true })
|
||||
@transform<HomeAssistantUI, Themes>({ transformer: ({ themes }) => themes })
|
||||
private _themes!: Themes;
|
||||
```
|
||||
|
||||
Use `@transform` with `watch` when the transformer depends on a host property, such as a computed entity id. `consumeEntityState` only watches the first path segment.
|
||||
|
||||
To consume a whole group untransformed, omit `@transform` and type the field as `ContextType<typeof statesContext>` or the matching context type.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- The component consumes the narrowest context needed for the data it reads.
|
||||
- A broad `hass` property is kept only when the component is a container or external API requires it.
|
||||
- Entity-scoped reads use the consume helpers rather than ad hoc context transforms.
|
||||
- Context fields are marked `@state()` so updates trigger rendering.
|
||||
- Tests and mocks only provide the data the component actually consumes.
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: ha-frontend-review
|
||||
description: Home Assistant frontend PR and review guidance. Use when reviewing frontend changes, preparing a PR, checking recurring review issues, or applying the PR template.
|
||||
---
|
||||
|
||||
# HA Frontend Review
|
||||
|
||||
Use this skill when reviewing Home Assistant frontend changes or preparing a pull request.
|
||||
|
||||
## Pull Request Body
|
||||
|
||||
When creating a pull request, use `.github/PULL_REQUEST_TEMPLATE.md` as the body.
|
||||
|
||||
- Do not omit, reorder, or rewrite template sections.
|
||||
- Check the appropriate "Type of change" box based on the actual change.
|
||||
- Do not check checklist items on behalf of the user.
|
||||
- If the PR includes UI changes, remind the user to add screenshots or a short video.
|
||||
- Explain what the change does for users, not only implementation details.
|
||||
- Use Markdown.
|
||||
|
||||
## Pre-Submission Checklist
|
||||
|
||||
- `yarn lint` passes when practical for the scope.
|
||||
- `yarn test` or focused relevant tests are green when practical for the scope.
|
||||
- Tests are added or updated for new data processing and utilities where applicable.
|
||||
- User-facing text is localized and follows `ha-frontend-user-facing-text` guidance.
|
||||
- Components handle loading, error, unavailable, and missing-entity states.
|
||||
- Entity existence is checked before property access.
|
||||
- Event listeners and subscriptions are cleaned up.
|
||||
- UI is accessible to screen readers and keyboard users.
|
||||
|
||||
## Recurring Review Issues
|
||||
|
||||
User experience and accessibility:
|
||||
|
||||
- Forms need proper labels, helper text, and validation feedback.
|
||||
- Form markup should not cause password managers to identify fields incorrectly.
|
||||
- Clickable areas should be large enough for touch interaction.
|
||||
- Hover, active, disabled, loading, and focus states should be clear.
|
||||
|
||||
Dialog and modal patterns:
|
||||
|
||||
- Multi-step operations should show progress.
|
||||
- Dialog state should survive background operations correctly.
|
||||
- Cancel and close buttons should behave consistently.
|
||||
- Defaults should be helpful without blocking user override.
|
||||
|
||||
Component design patterns:
|
||||
|
||||
- Terminology should be consistent. Use words like "Join" or "Apply" instead of "Group" when that better matches the user action.
|
||||
- Visual hierarchy should use appropriate font sizes, weights, and spacing ratios.
|
||||
- Components should align to the design grid.
|
||||
- Badges and indicators should be placed consistently.
|
||||
|
||||
Code quality:
|
||||
|
||||
- Null and undefined paths should be handled explicitly.
|
||||
- Potentially undefined array and object access should be guarded.
|
||||
- Event handlers, timers, observers, and subscriptions should be cleaned up.
|
||||
|
||||
Configuration and props:
|
||||
|
||||
- Make configuration fields optional when sensible.
|
||||
- Provide reasonable defaults.
|
||||
- Keep APIs extensible without adding speculative abstractions.
|
||||
- Validate configuration before applying changes.
|
||||
|
||||
## Review Flow
|
||||
|
||||
- Identify behavioral regressions, bugs, accessibility issues, and missing tests first.
|
||||
- Keep style-only comments secondary unless they affect maintainability or user experience.
|
||||
- Prefer small, direct fixes over large refactors during review follow-up.
|
||||
- Cross-load `ha-frontend-contexts`, `ha-frontend-components`, `ha-frontend-styling`, `ha-frontend-testing`, or `ha-frontend-user-facing-text` when a finding falls in that area.
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
name: ha-frontend-styling
|
||||
description: Home Assistant frontend styling, theming, spacing, responsive layout, RTL, and View Transitions guidance. Use when editing CSS, layout, motion, or visual component structure.
|
||||
---
|
||||
|
||||
# HA Frontend Styling
|
||||
|
||||
Use this skill when editing CSS, layout, visual hierarchy, theme integration, responsive behavior, RTL support, or view transitions.
|
||||
|
||||
## Theme And Layout Basics
|
||||
|
||||
- Use Home Assistant CSS custom properties instead of hardcoded colors.
|
||||
- Use `--ha-space-*` spacing tokens instead of hardcoded spacing where possible.
|
||||
- Keep components mobile-first and enhance for desktop.
|
||||
- Keep layouts RTL-safe. Prefer logical properties when they fit.
|
||||
- Prefer `ha-*` components and current Web Awesome wrappers.
|
||||
- Avoid adding new legacy Material Web Components (`mwc-*`).
|
||||
- Scope styles to the component. Do not rely on global styles for component internals.
|
||||
|
||||
Spacing tokens are defined in `src/resources/theme/core.globals.ts`. The scale runs from `--ha-space-1` at 4px through `--ha-space-20` at 80px in 4px increments. Common values are `--ha-space-2` at 8px, `--ha-space-4` at 16px, and `--ha-space-8` at 32px.
|
||||
|
||||
```ts
|
||||
static get styles() {
|
||||
return css`
|
||||
:host {
|
||||
padding: var(--ha-space-4);
|
||||
color: var(--primary-text-color);
|
||||
background-color: var(--card-background-color);
|
||||
}
|
||||
|
||||
.content {
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
:host {
|
||||
padding: var(--ha-space-2);
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
```
|
||||
|
||||
## Interaction States
|
||||
|
||||
- Make touch targets large enough for mobile.
|
||||
- Provide clear hover, active, focus, disabled, loading, error, and unavailable states.
|
||||
- Preserve keyboard navigation and visible focus indicators.
|
||||
- Maintain WCAG AA contrast for text and essential UI affordances.
|
||||
|
||||
## View Transitions
|
||||
|
||||
Use the View Transitions API only for meaningful continuity between DOM states.
|
||||
|
||||
Core resources:
|
||||
|
||||
- Utility wrapper: `src/common/util/view-transition.ts`, `withViewTransition()`.
|
||||
- Launch-screen fade example: `src/util/launch-screen.ts`.
|
||||
- Animation keyframes: `src/resources/theme/animations.globals.ts`.
|
||||
- Animation duration tokens: `src/resources/theme/core.globals.ts`.
|
||||
|
||||
Implementation rules:
|
||||
|
||||
- Use `withViewTransition()` for fallback behavior.
|
||||
- Keep transitions simple. Subtle fades and crossfades usually work best.
|
||||
- Use `--ha-animation-duration-fast`, `--ha-animation-duration-normal`, or `--ha-animation-duration-slow` for timing.
|
||||
- Ensure each `view-transition-name` is unique at any given time.
|
||||
- Remember only one view transition can run at a time.
|
||||
- View transitions operate at document level and do not work inside Shadow DOM style isolation. For web components, set `view-transition-name` on `:host` or use document-level transitions.
|
||||
- The root gets `view-transition-name: root` by default. Target `::view-transition-group(root)` to customize the default page transition.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- Styling uses theme variables and spacing tokens where practical.
|
||||
- Layout is mobile-first and RTL-safe.
|
||||
- Component styles are scoped.
|
||||
- Interactive states are clear and accessible.
|
||||
- Motion is subtle, tokenized, and respects reduced-motion behavior through existing globals.
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
name: ha-frontend-testing
|
||||
description: Home Assistant frontend validation workflow. Use when running lint, TypeScript checks, Vitest, Playwright e2e suites, dev servers, or chart-data benchmarks.
|
||||
---
|
||||
|
||||
# HA Frontend Testing
|
||||
|
||||
Use this skill when choosing or running validation for frontend changes.
|
||||
|
||||
## Core Commands
|
||||
|
||||
```bash
|
||||
yarn lint # ESLint + Prettier + TypeScript + Lit
|
||||
yarn format # Auto-fix ESLint + Prettier
|
||||
yarn lint:types # TypeScript compiler, run without file arguments
|
||||
yarn test # Vitest
|
||||
yarn dev # App dev server
|
||||
yarn dev:serve # Local serving dev server
|
||||
```
|
||||
|
||||
Never run `tsc` or `yarn lint:types` with file arguments. File arguments make `tsc` ignore `tsconfig.json` and can emit `.js` files into `src/`.
|
||||
|
||||
For focused type feedback on one file, use editor diagnostics instead of a file-scoped `tsc` command.
|
||||
|
||||
## Unit And Utility Tests
|
||||
|
||||
- Add or update Vitest tests for data processing, utility code, and behavior that can be tested without a browser.
|
||||
- Mock WebSocket connections and API calls at boundaries.
|
||||
- Cover loading, error, unavailable, and missing-entity states where relevant.
|
||||
- Test accessibility-sensitive behavior when it can be asserted without brittle DOM internals.
|
||||
|
||||
## Dev Servers
|
||||
|
||||
`yarn dev` builds and watches the app, served by a running Home Assistant core configured through `development_repo`.
|
||||
|
||||
`yarn dev:serve` also serves locally and supports `-c` for the core URL and `-p` for the port. Default local serving port is 8124.
|
||||
|
||||
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`.
|
||||
|
||||
## Playwright E2E
|
||||
|
||||
Each suite has its own dev server port. Playwright reuses an existing server locally when the port is already running; otherwise it performs a slow full build. The rspack watcher recompiles on save, so reruns should not need a restart.
|
||||
|
||||
Start the relevant suite server, then run that suite:
|
||||
|
||||
| Suite | Server | Test command |
|
||||
| ------- | ------------------------------- | ----------------------- |
|
||||
| App | `yarn test:e2e:app:dev` on 8095 | `yarn test:e2e:app` |
|
||||
| Demo | `yarn dev:demo` on 8090 | `yarn test:e2e:demo` |
|
||||
| Gallery | `yarn dev:gallery` on 8100 | `yarn test:e2e:gallery` |
|
||||
|
||||
Server reuse and `--stop` use the `/__ha_dev_status` health check, so starting or stopping twice is harmless.
|
||||
|
||||
Use `-g "<title>" --project=chromium` to narrow a run. `yarn test:e2e` runs all three suites. Run suites directly; piping through output truncation hides progress and failures.
|
||||
|
||||
The app suite uses a stripped-down harness for e2e. Demo and gallery use their normal dev servers.
|
||||
|
||||
## Benchmarks
|
||||
|
||||
For chart data transforms such as history, statistics, energy, and downsampling, follow `test/benchmarks/README.md`.
|
||||
|
||||
Use seeded fixtures, characterization snapshot tests, and `yarn test:bench` before and after optimization. Optimizations must keep output bit-identical.
|
||||
|
||||
## Verification Selection
|
||||
|
||||
- Documentation-only change: no code test required unless examples or commands changed.
|
||||
- Type-only or utility change: run focused Vitest if available, then `yarn lint:types` if practical.
|
||||
- Lit component change: run relevant tests plus lint or typecheck depending on scope.
|
||||
- E2E-sensitive flow: start the relevant e2e dev server and run the narrow Playwright suite.
|
||||
- Broad refactor: run `yarn lint` and relevant test suites when practical.
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
name: ha-frontend-user-facing-text
|
||||
description: Home Assistant frontend copy, localization, terminology, and user-facing text guidance. Use when adding or reviewing labels, buttons, dialogs, errors, translations, or UI strings.
|
||||
---
|
||||
|
||||
# HA Frontend User-Facing Text
|
||||
|
||||
Use this skill for all user-facing text, translations, labels, buttons, dialog copy, errors, helper text, and review comments about wording.
|
||||
|
||||
## Localization
|
||||
|
||||
- All user-facing text must be translatable.
|
||||
- Add translation keys to `src/translations/en.json` when introducing new strings.
|
||||
- Use the localization system instead of inline user-visible strings.
|
||||
- Prefer complete localized strings with placeholders over concatenating translated fragments.
|
||||
- Give translators enough context through key naming and placeholders.
|
||||
|
||||
```ts
|
||||
this.hass.localize("ui.panel.config.updates.update_available", {
|
||||
count: 5,
|
||||
});
|
||||
```
|
||||
|
||||
## Voice And Style
|
||||
|
||||
- Use American English.
|
||||
- Use a friendly, informational tone.
|
||||
- Address users directly with "you" and "your" when appropriate.
|
||||
- Be inclusive, objective, and non-discriminatory.
|
||||
- Be concise and clear.
|
||||
- Use active voice.
|
||||
- Avoid jargon where a familiar home automation term works.
|
||||
- Always write "Home Assistant" in full. Do not use "HA" or "HASS" in user-facing copy.
|
||||
- Spell out terms when possible.
|
||||
- Use sentence case for titles, headings, buttons, labels, and UI elements.
|
||||
- Use the Oxford comma in lists.
|
||||
- Prefer "like" over "e.g." and "for example" over "i.e.".
|
||||
- Avoid all caps for emphasis. Use wording, bold, or italics instead.
|
||||
- Write for both technical and non-technical users.
|
||||
|
||||
Sentence case examples:
|
||||
|
||||
- Use: "Create new automation"
|
||||
- Avoid: "Create New Automation"
|
||||
- Use: "Device settings"
|
||||
- Avoid: "Device Settings"
|
||||
|
||||
## Terminology
|
||||
|
||||
Use "integration" instead of "component" for user-facing product language unless referring to a frontend component in developer context.
|
||||
|
||||
Technical product terms are lowercase in prose: automation, entity, device, service.
|
||||
|
||||
## Delete, Remove, Create, Add
|
||||
|
||||
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 a 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.
|
||||
|
||||
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 a 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.
|
||||
|
||||
Create pairs with Delete. Add pairs with Remove.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- Text is localized.
|
||||
- Copy uses sentence case.
|
||||
- "Home Assistant" is written in full.
|
||||
- Delete/Remove and Create/Add match recoverability and object lifecycle.
|
||||
- Placeholders are used instead of string concatenation.
|
||||
- The wording is concise and understandable without implementation knowledge.
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../.agents/skills
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Build frontend target
|
||||
description: Run a gulp build target
|
||||
|
||||
inputs:
|
||||
target:
|
||||
description: gulp target to run
|
||||
required: true
|
||||
github-token:
|
||||
description: GitHub token for fetching nightly translations; omit to build English-only
|
||||
default: ""
|
||||
is-test:
|
||||
description: Set IS_TEST for the build (skips source maps and compression)
|
||||
default: "false"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Build ${{ inputs.target }}
|
||||
shell: bash
|
||||
run: ./node_modules/.bin/gulp ${{ inputs.target }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ inputs.github-token }}
|
||||
IS_TEST: ${{ inputs.is-test }}
|
||||
@@ -0,0 +1,52 @@
|
||||
name: Deploy to Netlify
|
||||
description: Deploy a directory to Netlify (production when alias is empty, otherwise to the alias)
|
||||
|
||||
inputs:
|
||||
dir:
|
||||
description: Directory to deploy
|
||||
required: true
|
||||
alias:
|
||||
description: Deploy alias; leave empty to deploy to production
|
||||
default: ""
|
||||
auth-token:
|
||||
description: NETLIFY_AUTH_TOKEN
|
||||
required: true
|
||||
site-id:
|
||||
description: NETLIFY_SITE_ID
|
||||
required: true
|
||||
|
||||
outputs:
|
||||
netlify_url:
|
||||
description: The deployed URL
|
||||
value: ${{ steps.deploy.outputs.netlify_url }}
|
||||
unique_deploy_url:
|
||||
description: The unique hash-based deployment URL for this specific build
|
||||
value: ${{ steps.deploy.outputs.unique_deploy_url }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
shell: bash
|
||||
env:
|
||||
DIR: ${{ inputs.dir }}
|
||||
ALIAS: ${{ inputs.alias }}
|
||||
NETLIFY_AUTH_TOKEN: ${{ inputs.auth-token }}
|
||||
NETLIFY_SITE_ID: ${{ inputs.site-id }}
|
||||
run: |
|
||||
# Execute the deployment
|
||||
if [ -n "$ALIAS" ]; then
|
||||
npx -y netlify-cli deploy --dir="$DIR" --alias "$ALIAS" --json > deploy_output.json
|
||||
else
|
||||
npx -y netlify-cli deploy --dir="$DIR" --prod --json > deploy_output.json
|
||||
fi
|
||||
|
||||
# Collect the urls from the deployment output
|
||||
NETLIFY_URL=$(jq -r '.url // .deploy_url' deploy_output.json)
|
||||
SITE_NAME=$(jq -r '.site_name' deploy_output.json)
|
||||
DEPLOY_ID=$(jq -r '.deploy_id' deploy_output.json)
|
||||
UNIQUE_URL="https://${DEPLOY_ID}--${SITE_NAME}.netlify.app"
|
||||
|
||||
echo "netlify_url=$NETLIFY_URL" >> "$GITHUB_OUTPUT"
|
||||
echo "unique_deploy_url=$UNIQUE_URL" >> "$GITHUB_OUTPUT"
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Setup Node and install
|
||||
description: Set up Node from .nvmrc and install yarn dependencies
|
||||
|
||||
inputs:
|
||||
immutable:
|
||||
description: Pass --immutable to yarn install
|
||||
default: "true"
|
||||
cache:
|
||||
description: Enable the yarn cache in setup-node
|
||||
default: "true"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: ${{ inputs.cache == 'true' && 'yarn' || '' }}
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: yarn install ${{ inputs.immutable == 'true' && '--immutable' || '' }}
|
||||
@@ -1,669 +0,0 @@
|
||||
# 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.
|
||||
|
||||
**Note**: This file contains high-level guidelines and references to implementation patterns. For gallery-specific documentation, demos, page structure, and usage examples, see [`gallery/AGENTS.md`](gallery/AGENTS.md).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Quick Reference](#quick-reference)
|
||||
- [Core Architecture](#core-architecture)
|
||||
- [State Access: Contexts Instead of `hass`](#state-access-contexts-instead-of-hass)
|
||||
- [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 (run WITHOUT file arguments)
|
||||
yarn test # Vitest
|
||||
yarn dev # Dev server (app; --background/--status/--stop/--logs)
|
||||
yarn dev:serve # Dev server with serve (-c core URL, -p port; --background/--status/--stop/--logs)
|
||||
```
|
||||
|
||||
> **WARNING:** Never run `tsc` or `yarn lint:types` with file arguments (e.g., `yarn lint:types src/file.ts`). When `tsc` receives file arguments, it ignores `tsconfig.json` and emits `.js` files into `src/`, polluting the codebase. Always run `yarn lint:types` without arguments. For individual file type checking, rely on IDE diagnostics. If `.js` files are accidentally generated, clean up with `git clean -fd src/`.
|
||||
|
||||
### Component Prefixes
|
||||
|
||||
- `ha-` - Home Assistant components
|
||||
- `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-dialog-box";
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
## State Access: Contexts Instead of `hass`
|
||||
|
||||
Every component used to take the whole `hass: HomeAssistant` object — a god-object that re-renders on any unrelated `hass` change, forces tests to mock everything, and hides what a component actually reads. We're moving leaf components to **fine-grained [Lit context](https://lit.dev/docs/data/context/)**: consume only the slice you need and re-render only when it changes.
|
||||
|
||||
For new code, consume the matching context instead of adding a `hass` property. `hass` stays for container components that own it and feed the providers; the canonical migration is [`hui-button-card.ts`](src/panels/lovelace/cards/hui-button-card.ts). Infrastructure: contexts in [`src/data/context/index.ts`](src/data/context/index.ts), the `consume…` helpers in [`src/common/decorators/consume-context-entry.ts`](src/common/decorators/consume-context-entry.ts), and `@transform` in [`src/common/decorators/transform.ts`](src/common/decorators/transform.ts). Providers are wired automatically by `contextMixin` on `HassBaseEl` — you only consume.
|
||||
|
||||
### Contexts
|
||||
|
||||
Consume the narrowest context that covers your reads:
|
||||
|
||||
| Context | Replaces |
|
||||
| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `statesContext` | `hass.states` |
|
||||
| `entitiesContext` / `devicesContext` / `areasContext` / `floorsContext` | `hass.entities` / `.devices` / `.areas` / `.floors` (or `registriesContext` for all four) |
|
||||
| `servicesContext` | `hass.services` |
|
||||
| `internationalizationContext` | `hass.localize`, `hass.locale`, `hass.language` |
|
||||
| `formattersContext` | `hass.formatEntityName`, `hass.formatEntityState`, `hass.formatEntityAttributeName`, … |
|
||||
| `configContext` | `hass.config`, `hass.user`, `hass.auth`, `hass.userData` |
|
||||
| `connectionContext` | `hass.connection`, `hass.connected`, `hass.hassUrl` |
|
||||
| `apiContext` | `hass.callService`, `hass.callApi`, `hass.callWS`, `hass.sendWS`, `hass.fetchWithAuth` |
|
||||
| `uiContext` | `hass.themes`, `hass.selectedTheme`, `hass.panels`, `hass.dockedSidebar`, … |
|
||||
| `narrowViewportContext` | narrow-layout boolean |
|
||||
|
||||
Lazy contexts (subscribe on first consumer, tear down after the last): `labelsContext`, `fullEntitiesContext`, `configEntriesContext`, `manifestsContext`. The single-field contexts (`localizeContext`, `themesContext`, `userContext`, …) are **deprecated** — use the grouped ones above.
|
||||
|
||||
### Consuming
|
||||
|
||||
Use the `consume…` helpers for entity-scoped and `localize` reads. `entityIdPath` is resolved against `this`, so these watch `this._config.entity`:
|
||||
|
||||
```ts
|
||||
@state() @consumeEntityState({ entityIdPath: ["_config", "entity"] })
|
||||
private _stateObj?: HassEntity; // consumeEntityStates(...) for a record of several
|
||||
|
||||
@state() @consumeEntityRegistryEntry({ entityIdPath: ["_config", "entity"] })
|
||||
private _entity?: EntityRegistryDisplayEntry;
|
||||
|
||||
@state() @consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
```
|
||||
|
||||
For any other single field, pair `@consume` with `@transform`:
|
||||
|
||||
```ts
|
||||
@state()
|
||||
@consume({ context: uiContext, subscribe: true })
|
||||
@transform<HomeAssistantUI, Themes>({ transformer: ({ themes }) => themes })
|
||||
private _themes!: Themes;
|
||||
```
|
||||
|
||||
`@transform`'s `watch` option re-runs the transformer when a host prop changes — needed when an entity id is computed, since `consumeEntityState` only watches the first path segment. To consume a whole group untransformed, drop `@transform` and type it `ContextType<typeof statesContext>`.
|
||||
|
||||
## Development Standards
|
||||
|
||||
### Code Quality Requirements
|
||||
|
||||
**Linting and Formatting (Enforced by Tools)**
|
||||
|
||||
- ESLint config (flat config) extends TypeScript strict, Lit, Web Components, Accessibility (lit-a11y), and import-x
|
||||
- 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
|
||||
- **Prefer contexts over `hass`**: For state reads, consume the relevant Lit context instead of taking the whole `hass` object — see [State Access: Contexts Instead of `hass`](#state-access-contexts-instead-of-hass)
|
||||
- **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
|
||||
- **Use spacing tokens**: Prefer `--ha-space-*` tokens over hardcoded values for consistent spacing
|
||||
- Spacing scale: `--ha-space-1` (4px) through `--ha-space-20` (80px) in 4px increments
|
||||
- Defined in `src/resources/theme/core.globals.ts`
|
||||
- Common values: `--ha-space-2` (8px), `--ha-space-4` (16px), `--ha-space-8` (32px)
|
||||
- **Mobile-first responsive**: Design for mobile, enhance for desktop
|
||||
- **Prefer `ha-*` components**: Build on the Home Assistant component library (many now wrap Web Awesome components); avoid new use of legacy Material Web Components (`mwc-*`), which are being phased out
|
||||
- **Support RTL**: Ensure all layouts work in RTL languages
|
||||
|
||||
```typescript
|
||||
static get styles() {
|
||||
return css`
|
||||
:host {
|
||||
padding: var(--ha-space-4);
|
||||
color: var(--primary-text-color);
|
||||
background-color: var(--card-background-color);
|
||||
}
|
||||
|
||||
.content {
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
:host {
|
||||
padding: var(--ha-space-2);
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
```
|
||||
|
||||
### View Transitions
|
||||
|
||||
The View Transitions API creates smooth animations between DOM state changes. When implementing view transitions:
|
||||
|
||||
**Core Resources:**
|
||||
|
||||
- **Utility wrapper**: `src/common/util/view-transition.ts` - `withViewTransition()` function with graceful fallback
|
||||
- **Real-world example**: `src/util/launch-screen.ts` - Launch screen fade pattern with browser support detection
|
||||
- **Animation keyframes**: `src/resources/theme/animations.globals.ts` - Global `fade-in`, `fade-out`, `scale` animations
|
||||
- **Animation duration**: `src/resources/theme/core.globals.ts` - `--ha-animation-duration-fast` (150ms), `--ha-animation-duration-normal` (250ms), `--ha-animation-duration-slow` (350ms) (all respect `prefers-reduced-motion`)
|
||||
|
||||
**Implementation Guidelines:**
|
||||
|
||||
1. Always use `withViewTransition()` wrapper for automatic fallback
|
||||
2. Keep transitions simple (subtle crossfades and fades work best)
|
||||
3. Use `--ha-animation-duration-*` CSS variables for consistent timing (`fast`, `normal`, `slow`)
|
||||
4. Assign unique `view-transition-name` to elements (must be unique at any given time)
|
||||
5. For Lit components: Override `performUpdate()` or use `::part()` for internal elements
|
||||
|
||||
**Default Root Transition:**
|
||||
|
||||
By default, `:root` receives `view-transition-name: root`, creating a full-page crossfade. Target with [`::view-transition-group(root)`](https://developer.mozilla.org/en-US/docs/Web/CSS/::view-transition-group) to customize the default page transition.
|
||||
|
||||
**Important Constraints:**
|
||||
|
||||
- Each `view-transition-name` must be unique at any given time
|
||||
- Only one view transition can run at a time
|
||||
- **Shadow DOM incompatibility**: View transitions operate at document level and do not work within Shadow DOM due to style isolation ([spec discussion](https://github.com/w3c/csswg-drafts/issues/10303)). For web components, set `view-transition-name` on the `:host` element or use document-level transitions
|
||||
|
||||
**Specification & Documentation:**
|
||||
|
||||
For browser support, API details, and current specifications, refer to these authoritative sources (note: check publication dates as specs evolve):
|
||||
|
||||
- [MDN: View Transition API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API) - Comprehensive API reference
|
||||
- [Chrome for Developers: View Transitions](https://developer.chrome.com/docs/web-platform/view-transitions) - Implementation guide and examples
|
||||
- [W3C Draft Specification](https://drafts.csswg.org/css-view-transitions/) - Official specification (evolving)
|
||||
|
||||
### 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
|
||||
- **Optimizing chart data processing**: When optimizing chart data transforms (history, statistics, energy, downsampling), follow the playbook in [`test/benchmarks/README.md`](test/benchmarks/README.md) — it has seeded fixtures, characterization (snapshot) tests that pin current output, and `vitest bench` benchmarks (`yarn test:bench`) for before/after comparison. Optimizations must keep output bit-identical.
|
||||
|
||||
## Component Library
|
||||
|
||||
### Dialog Component
|
||||
|
||||
**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:**
|
||||
|
||||
- Use `ha-dialog` component
|
||||
- Implement `HassDialog<T>` interface
|
||||
- Use `@state() private _open = false` to control dialog visibility
|
||||
- Set `_open = true` in `showDialog()`, `_open = false` in `closeDialog()`
|
||||
- Return `nothing` when no params (loading state)
|
||||
- Fire `dialog-closed` event in `_dialogClosed()` handler
|
||||
- Use `header-title` attribute for simple titles
|
||||
- Use `header-subtitle` attribute for simple subtitles
|
||||
- Use slots for custom content where the standard attributes are not enough
|
||||
- Use `ha-dialog-footer` with `primaryAction`/`secondaryAction` slots for footer content
|
||||
- Add `autofocus` to first focusable element (e.g., `<ha-form autofocus>`). The component may need to forward this attribute internally.
|
||||
|
||||
**Dialog Sizing:**
|
||||
|
||||
- Use `width` attribute with predefined sizes: `"small"` (320px), `"medium"` (580px - default), `"large"` (1024px), or `"full"`
|
||||
- Custom sizing is NOT recommended - use the standard width presets
|
||||
|
||||
**Button Appearance Guidelines:**
|
||||
|
||||
`ha-button` (wraps the Web Awesome button — see `src/components/ha-button.ts`) has two independent axes plus size:
|
||||
|
||||
- **`variant`** (color): `"brand"` (default), `"neutral"`, `"danger"`, `"warning"`, `"success"`
|
||||
- **`appearance`** (fill style): `"accent"`, `"filled"`, `"outlined"`, `"plain"`
|
||||
- **`size`**: `"xs"` (extra small, 40px), `"s"` (small, 32px), `"m"` (medium, 40px - default), `"l"` (large, 48px), `"xl"` (extra large, 40px)
|
||||
|
||||
Common patterns:
|
||||
|
||||
- **Primary action**: `appearance="filled"` for emphasis (or the default appearance for a lighter look)
|
||||
- **Secondary action**: `appearance="plain"` for cancel/dismiss actions
|
||||
- **Destructive actions**: `variant="danger"` for delete/remove operations (the generic confirmation dialog uses `variant="danger"` for its confirm button — see `src/dialogs/generic/dialog-box.ts`)
|
||||
- Always place primary action in `slot="primaryAction"` and secondary in `slot="secondaryAction"` within `ha-dialog-footer`
|
||||
|
||||
### 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 `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`, `narrow`
|
||||
- Slots: `icon` (override the leading icon), `action` (custom action content)
|
||||
- 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>
|
||||
```
|
||||
|
||||
### Keyboard Shortcuts (ShortcutManager)
|
||||
|
||||
The `ShortcutManager` class provides a unified way to register keyboard shortcuts with automatic input field protection.
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- Automatically blocks shortcuts when input fields are focused
|
||||
- Prevents shortcuts during text selection (configurable via `allowWhenTextSelected`)
|
||||
- Supports both character-based and KeyCode-based shortcuts (for non-latin keyboards)
|
||||
|
||||
**Implementation:**
|
||||
|
||||
- **Class definition**: `src/common/keyboard/shortcuts.ts`
|
||||
- **Real-world example**: `src/state/quick-bar-mixin.ts` - Global shortcuts (e, c, d, m, a, Shift+?) with non-latin keyboard fallbacks
|
||||
|
||||
### Tooltip Component (ha-tooltip)
|
||||
|
||||
The `ha-tooltip` component wraps Web Awesome tooltip with Home Assistant theming. Use for providing contextual help text on hover.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
- **Component definition**: `src/components/ha-tooltip.ts`
|
||||
- **Usage example**: `src/components/ha-label.ts`
|
||||
|
||||
## 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 Lovelace Card
|
||||
|
||||
**Purpose**: Cards allow users to tell different stories about their house.
|
||||
|
||||
```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
|
||||
|
||||
### Dev servers
|
||||
|
||||
`yarn dev` builds and watches the app, served by a running Home Assistant core (`development_repo` setting). `yarn dev:serve` also serves it locally (`-c` core URL, `-p` port, default 8124).
|
||||
|
||||
These and the e2e dev servers below take `--background`, `--status`, `--stop`, and `--logs [--follow]`.
|
||||
|
||||
### End-to-end (e2e) tests
|
||||
|
||||
Each Playwright suite has a dev server on its own port. Playwright reuses a server already on the port (`reuseExistingServer` locally); otherwise it does a slow full build. The rspack watcher recompiles on save, so re-runs need no restart.
|
||||
|
||||
Start the suite's dev server, then run the suite:
|
||||
|
||||
- **App** (8095): `yarn test:e2e:app:dev`, then `yarn test:e2e:app`
|
||||
- **Demo** (8090): `yarn dev:demo`, then `yarn test:e2e:demo`
|
||||
- **Gallery** (8100): `yarn dev:gallery`, then `yarn test:e2e:gallery`
|
||||
|
||||
Server reuse and `--stop` key off a `/__ha_dev_status` health check, so starting or stopping twice is harmless. The app suite uses a stripped-down harness built only for e2e; demo and gallery use their normal dev servers.
|
||||
|
||||
Add `-g "<title>" --project=chromium` to narrow a run; `yarn test:e2e` runs all three. Run the suite directly, since piping through `tail`/`head` hides progress and truncates results.
|
||||
|
||||
### Gallery
|
||||
|
||||
For Gallery-specific structure, page/demo naming, sidebar behavior, content standards, and commands, see [`gallery/AGENTS.md`](gallery/AGENTS.md).
|
||||
|
||||
### Common Pitfalls to Avoid
|
||||
|
||||
- Don't manually query the DOM with `querySelector` - use the `@query`/`@queryAll` decorators 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
|
||||
|
||||
### Pull Requests
|
||||
|
||||
When creating a pull request, you **must** use the PR template located at `.github/PULL_REQUEST_TEMPLATE.md`. Read the template file and use its full content as the PR body, filling in each section appropriately.
|
||||
|
||||
- Do not omit, reorder, or rewrite the template sections
|
||||
- Check the appropriate "Type of change" box based on the changes
|
||||
- Do not check the checklist items on behalf of the user — those are the user's responsibility to review and check
|
||||
- If the PR includes UI changes, remind the user to add screenshots or a short video to the PR after creating it
|
||||
- Be simple and user friendly — explain what the change does, not implementation details
|
||||
- Use markdown so the user can copy it
|
||||
|
||||
### Text and Copy Guidelines
|
||||
|
||||
#### Terminology Standards
|
||||
|
||||
**Delete vs Remove**
|
||||
|
||||
- **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
|
||||
|
||||
- **"integration"** (preferred over "component")
|
||||
- **Technical terms**: Use lowercase (automation, entity, device, service)
|
||||
|
||||
#### Translation Considerations
|
||||
|
||||
All user-facing text must be translatable — see the **Internationalization** section (under Common Patterns) for the `localize` API and placeholder usage. From a copy perspective:
|
||||
|
||||
- **Keep context**: Provide enough context for translators
|
||||
- **Avoid concatenation**: Prefer full localized strings with placeholders over stitching translated fragments together
|
||||
|
||||
### Common Review Issues (From PR Analysis)
|
||||
|
||||
Recurring, easy-to-miss problems surfaced in real PR reviews. These complement the standards above rather than repeating them — items already covered earlier (loading states, error handling, mobile layout, theming, import hygiene) are intentionally not duplicated here.
|
||||
|
||||
#### User Experience and Accessibility
|
||||
|
||||
- **Form validation**: Always provide proper field labels and validation feedback
|
||||
- **Form accessibility**: Prevent password managers from incorrectly identifying fields
|
||||
- **Hit targets**: Make clickable areas large enough for touch interaction
|
||||
- **Visual feedback**: Provide clear indication of interactive states (hover, active, focus)
|
||||
|
||||
#### Dialog and Modal Patterns
|
||||
|
||||
- **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
|
||||
|
||||
#### Code Quality Issues
|
||||
|
||||
- **Null checking**: Always check if entities exist before accessing properties
|
||||
- **TypeScript safety**: Handle potentially undefined array/object access
|
||||
- **Event handling and cleanup**: Subscribe/unsubscribe correctly and remove listeners to avoid memory leaks
|
||||
|
||||
#### 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
|
||||
|
||||
Final pre-submission checklist. Linting and formatting are enforced by tooling, so this focuses on what tools can't catch rather than restating every rule above.
|
||||
|
||||
- [ ] `yarn lint` passes (TypeScript, ESLint, Prettier, Lit analyzer) and `yarn test` is green
|
||||
- [ ] Tests added for new data processing/utilities (where applicable)
|
||||
- [ ] All user-facing text is localized and follows the Text and Copy guidelines (sentence case, "Home Assistant" in full, Delete/Remove + Create/Add)
|
||||
- [ ] Components handle all states (loading, error, unavailable)
|
||||
- [ ] Entity existence checked before property access
|
||||
- [ ] Event/subscription listeners cleaned up (no memory leaks)
|
||||
- [ ] Accessible to screen readers and keyboard
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../AGENTS.md
|
||||
@@ -1,7 +1,14 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
# Dependabot only scans .github/workflows by default; composite actions
|
||||
# under .github/actions must be listed explicitly to stay updated.
|
||||
# https://github.com/dependabot/dependabot-core/issues/6704
|
||||
directories:
|
||||
- "/"
|
||||
- "/.github/actions/setup"
|
||||
- "/.github/actions/build"
|
||||
- "/.github/actions/netlify-deploy"
|
||||
schedule:
|
||||
interval: weekly
|
||||
time: "06:00"
|
||||
|
||||
@@ -42,5 +42,36 @@ Dependencies:
|
||||
GitHub Actions:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- .github/actions/**
|
||||
- .github/workflows/**
|
||||
- .github/*.yml
|
||||
|
||||
"Tests: E2E":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- test/e2e/**
|
||||
|
||||
"Tests: App":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- test/e2e/app.spec.ts
|
||||
- test/e2e/app/**
|
||||
- test/e2e/playwright.app.config.ts
|
||||
|
||||
"Tests: Demo":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- test/e2e/demo.spec.ts
|
||||
- test/e2e/playwright.demo.config.ts
|
||||
|
||||
"Tests: Design":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- test/e2e/gallery.spec.ts
|
||||
- test/e2e/playwright.gallery.config.ts
|
||||
|
||||
"Tests: Unit":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- test/*.ts
|
||||
- "test/!(e2e)/**"
|
||||
|
||||
@@ -6,12 +6,14 @@ on:
|
||||
- dev
|
||||
- master
|
||||
paths:
|
||||
- ".github/actions/**"
|
||||
- ".github/workflows/**"
|
||||
pull_request:
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
paths:
|
||||
- ".github/actions/**"
|
||||
- ".github/workflows/**"
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -29,28 +29,23 @@ jobs:
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build Cast
|
||||
run: ./node_modules/.bin/gulp build-cast
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-cast
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli deploy --dir=cast/dist --alias dev --json > deploy_output.json
|
||||
echo "netlify_url=$(jq -r '.url // .deploy_url' deploy_output.json)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_CAST_SITE_ID }}
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: cast/dist
|
||||
alias: dev
|
||||
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
site-id: ${{ secrets.NETLIFY_CAST_SITE_ID }}
|
||||
|
||||
deploy_master:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -66,25 +61,19 @@ jobs:
|
||||
ref: master
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build Cast
|
||||
run: ./node_modules/.bin/gulp build-cast
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-cast
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli deploy --dir=cast/dist --prod --json > deploy_output.json
|
||||
echo "netlify_url=$(jq -r '.url // .deploy_url' deploy_output.json)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_CAST_SITE_ID }}
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: cast/dist
|
||||
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
site-id: ${{ secrets.NETLIFY_CAST_SITE_ID }}
|
||||
|
||||
+11
-25
@@ -29,13 +29,8 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
- name: Check for duplicate dependencies
|
||||
run: yarn dedupe --check
|
||||
- name: Build resources
|
||||
@@ -74,13 +69,8 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build resources
|
||||
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data
|
||||
env:
|
||||
@@ -98,18 +88,14 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build Application
|
||||
run: ./node_modules/.bin/gulp build-app
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
IS_TEST: "true"
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-app
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
is-test: true
|
||||
- name: Upload bundle stats
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
|
||||
@@ -32,12 +32,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
if: github.event_name != 'push' || github.ref_name != 'master'
|
||||
environment:
|
||||
name: Demo Development
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
url: ${{ steps.deploy.outputs.unique_deploy_url }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
@@ -30,28 +30,22 @@ jobs:
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build Demo
|
||||
run: ./node_modules/.bin/gulp build-demo
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-demo
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli deploy --dir=demo/dist --prod --json > deploy_output.json
|
||||
echo "netlify_url=$(jq -r '.url // .deploy_url' deploy_output.json)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_DEMO_DEV_SITE_ID }}
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: demo/dist
|
||||
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
site-id: ${{ secrets.NETLIFY_DEMO_DEV_SITE_ID }}
|
||||
|
||||
deploy_master:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -67,25 +61,19 @@ jobs:
|
||||
ref: master
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build Demo
|
||||
run: ./node_modules/.bin/gulp build-demo
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-demo
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli deploy --dir=demo/dist --prod --json > deploy_output.json
|
||||
echo "netlify_url=$(jq -r '.url // .deploy_url' deploy_output.json)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_DEMO_SITE_ID }}
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: demo/dist
|
||||
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
site-id: ${{ secrets.NETLIFY_DEMO_SITE_ID }}
|
||||
|
||||
@@ -23,25 +23,19 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build Gallery
|
||||
run: ./node_modules/.bin/gulp build-gallery
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-gallery
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli deploy --dir=gallery/dist --prod --json > deploy_output.json
|
||||
echo "netlify_url=$(jq -r '.url // .deploy_url' deploy_output.json)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_GALLERY_SITE_ID }}
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: gallery/dist
|
||||
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
site-id: ${{ secrets.NETLIFY_GALLERY_SITE_ID }}
|
||||
|
||||
@@ -28,29 +28,23 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build Gallery
|
||||
run: ./node_modules/.bin/gulp build-gallery
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-gallery
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy preview to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli deploy --dir=gallery/dist --alias "deploy-preview-${{ github.event.number }}" \
|
||||
--json > deploy_output.json
|
||||
echo "netlify_url=$(jq -r '.url // .deploy_url' deploy_output.json)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_GALLERY_SITE_ID }}
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: gallery/dist
|
||||
alias: deploy-preview-${{ github.event.number }}
|
||||
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
site-id: ${{ secrets.NETLIFY_GALLERY_SITE_ID }}
|
||||
|
||||
- name: Generate summary
|
||||
run: echo "${{ steps.deploy.outputs.netlify_url }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
+22
-49
@@ -32,19 +32,14 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build demo
|
||||
run: ./node_modules/.bin/gulp build-demo
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-demo
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload demo build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
@@ -64,19 +59,14 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build e2e test app
|
||||
run: ./node_modules/.bin/gulp build-e2e-test-app
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-e2e-test-app
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload e2e test app build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
@@ -96,19 +86,14 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build gallery
|
||||
run: ./node_modules/.bin/gulp build-gallery
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-gallery
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload gallery build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
@@ -133,14 +118,8 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
# Resolve the installed Playwright version so the browser cache tracks
|
||||
# Playwright itself, not every unrelated dependency bump.
|
||||
@@ -205,14 +184,8 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Download blob report (local)
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
|
||||
@@ -29,14 +29,10 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install
|
||||
immutable: false
|
||||
|
||||
- name: Download translations
|
||||
run: ./script/translations_download
|
||||
|
||||
@@ -38,13 +38,11 @@ jobs:
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@f4ca6f671bd429efb108c0f2fa0ae8af0215986c # master
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install
|
||||
immutable: false
|
||||
cache: false
|
||||
|
||||
- name: Download Translations
|
||||
run: ./script/translations_download
|
||||
@@ -97,7 +95,7 @@ jobs:
|
||||
|
||||
# home-assistant/wheels doesn't support SHA pinning
|
||||
- name: Build wheels
|
||||
uses: home-assistant/wheels@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
|
||||
uses: home-assistant/wheels@9e17ab1ed5c4c79d8b61e29fa63de25ca2710716 # 2026.07.0
|
||||
with:
|
||||
abi: cp314
|
||||
tag: musllinux_1_2
|
||||
@@ -116,12 +114,11 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
- name: Install dependencies
|
||||
run: yarn install
|
||||
immutable: false
|
||||
cache: false
|
||||
- name: Download Translations
|
||||
run: ./script/translations_download
|
||||
env:
|
||||
|
||||
@@ -25,14 +25,8 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Regenerate numeric device classes
|
||||
run: ./script/gen_numeric_device_classes
|
||||
|
||||
+6
-1
@@ -6,6 +6,8 @@ build/
|
||||
dist/
|
||||
/hass_frontend/
|
||||
/translations/
|
||||
# Composite action source, not build output
|
||||
!/.github/actions/build/
|
||||
|
||||
# yarn
|
||||
.yarn/*
|
||||
@@ -59,9 +61,12 @@ test/e2e/reports/
|
||||
test/e2e/test-results/
|
||||
# E2E test app build output
|
||||
test/e2e/app/dist/
|
||||
# MCP server
|
||||
.playwright-mcp/
|
||||
|
||||
# AI tooling
|
||||
.claude
|
||||
.claude/*
|
||||
!.claude/skills
|
||||
.cursor
|
||||
.opencode
|
||||
.serena
|
||||
|
||||
+144
-144
File diff suppressed because one or more lines are too long
+1
-1
@@ -13,4 +13,4 @@ nodeLinker: node-modules
|
||||
|
||||
npmMinimalAgeGate: 3d
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.17.0.cjs
|
||||
yarnPath: .yarn/releases/yarn-4.17.1.cjs
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Home Assistant Frontend Agent Guide
|
||||
|
||||
You are helping develop the Home Assistant frontend. This repository is a TypeScript application built from Lit-based Web Components for the Home Assistant web UI.
|
||||
|
||||
For gallery-specific documentation, demos, page structure, and examples, read `gallery/AGENTS.md` when working under `gallery/`.
|
||||
|
||||
## Essential Commands
|
||||
|
||||
```bash
|
||||
yarn lint # ESLint + Prettier + TypeScript + Lit
|
||||
yarn format # Auto-fix ESLint + Prettier
|
||||
yarn lint:types # TypeScript compiler, run without file arguments
|
||||
yarn test # Vitest
|
||||
yarn dev # App dev server, supports --background/--status/--stop/--logs
|
||||
yarn dev:serve # Local serving dev server, supports -c core URL, -p port, and dev flags
|
||||
```
|
||||
|
||||
Never run `tsc` or `yarn lint:types` with file arguments. When `tsc` receives file arguments, it ignores `tsconfig.json` and can emit `.js` files into `src/`. Always run `yarn lint:types` without arguments. For individual file type checking, rely on editor diagnostics.
|
||||
|
||||
## Architecture
|
||||
|
||||
- The frontend uses custom elements built with Lit and TypeScript strict mode.
|
||||
- Components communicate with the backend through the Home Assistant WebSocket API.
|
||||
- Use `ha-` for Home Assistant components, `hui-` for Lovelace UI components, and `dialog-` for dialogs.
|
||||
- Prefer `ha-*` components and current Web Awesome wrappers. Avoid adding new legacy `mwc-*` usage.
|
||||
- Leaf components should consume narrow Lit contexts instead of taking the broad `hass` object unless they are containers that own and provide `hass`.
|
||||
|
||||
## Development Standards
|
||||
|
||||
- Use strict TypeScript, proper interfaces, and `import type` for type-only imports.
|
||||
- Avoid `any`; model data with existing Home Assistant types or narrow new types.
|
||||
- Keep imports organized and remove unused imports.
|
||||
- Do not use `console`; use existing logging or user-visible error patterns.
|
||||
- Use `@state()` for internal Lit state and `@property()` for public API.
|
||||
- Do not query or manipulate DOM manually when Lit decorators, component refs, or render state are appropriate.
|
||||
- Scope styles to components, use theme custom properties, and keep layouts mobile-first and RTL-safe.
|
||||
- All user-facing text must be localized through the translation system.
|
||||
|
||||
## Project Skills
|
||||
|
||||
Detailed guidance lives in project skills under `.agents/skills/`. Load the matching skill before detailed implementation or review:
|
||||
|
||||
- `ha-frontend-contexts`: Lit contexts, `hass` migration, and rerender-sensitive state access.
|
||||
- `ha-frontend-components`: dialogs, forms, alerts, shortcuts, tooltips, panels, and Lovelace cards.
|
||||
- `ha-frontend-styling`: theme variables, spacing tokens, responsive layout, RTL, and view transitions.
|
||||
- `ha-frontend-testing`: lint, typecheck, Vitest, Playwright e2e dev servers, and benchmarks.
|
||||
- `ha-frontend-user-facing-text`: localization, terminology, sentence case, and Home Assistant text style.
|
||||
- `ha-frontend-review`: PR template use, review checklist, and recurring review issues.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
When creating a pull request, use `.github/PULL_REQUEST_TEMPLATE.md` as the PR body. Preserve template sections, check only the appropriate type-of-change boxes, and do not check checklist items on behalf of the user. If the PR includes UI changes, remind the user to add screenshots or a short video.
|
||||
@@ -29,7 +29,7 @@ const LICENSE_OVERRIDES = [
|
||||
// type-fest ships two license files (MIT for code, CC0 for types).
|
||||
// We use the MIT license since that covers the bundled code.
|
||||
packageName: "type-fest",
|
||||
version: "5.7.0",
|
||||
version: "5.8.0",
|
||||
licenseFile: "license-mit",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { mdiClose, mdiFlaskOutline } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../src/common/dom/fire_event";
|
||||
import { mainWindow } from "../../../src/common/dom/get_main_window";
|
||||
import { navigate } from "../../../src/common/navigate";
|
||||
import "../../../src/components/ha-button";
|
||||
import "../../../src/components/ha-card";
|
||||
import "../../../src/components/ha-icon-button";
|
||||
import "../../../src/components/ha-svg-icon";
|
||||
import "../../../src/components/ha-switch";
|
||||
import type { HaSwitch } from "../../../src/components/ha-switch";
|
||||
import type { CloudDemoScenario } from "../stubs/cloud-demo-state";
|
||||
import {
|
||||
getCloudDemoScenario,
|
||||
setCloudDemoScenario,
|
||||
subscribeCloudDemoScenario,
|
||||
} from "../stubs/cloud-demo-state";
|
||||
|
||||
// Walk the DOM, descending into shadow roots, to find the first matching
|
||||
// element. Used to reach <ha-panel-config> (which owns the cloud status) so we
|
||||
// can ask it to re-fetch after a scenario change.
|
||||
const deepQuery = (
|
||||
selector: string,
|
||||
root: Document | ShadowRoot = document
|
||||
): Element | null => {
|
||||
const direct = root.querySelector(selector);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
const elements = root.querySelectorAll("*");
|
||||
for (const element of elements) {
|
||||
const shadow = element.shadowRoot;
|
||||
if (shadow) {
|
||||
const found = deepQuery(selector, shadow);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Demo-only floating panel that flips the mocked Home Assistant Cloud state so
|
||||
* reviewers can preview every UI state of the cloud account page. It writes to
|
||||
* the shared {@link CloudDemoScenario} (which the cloud/backup mocks read) and
|
||||
* then nudges the page to re-read it. Lives entirely under demo/.
|
||||
*/
|
||||
@customElement("cloud-demo-controls")
|
||||
export class CloudDemoControls extends LitElement {
|
||||
@state() private _open = true;
|
||||
|
||||
@state() private _visible = false;
|
||||
|
||||
@state() private _scenario: CloudDemoScenario = getCloudDemoScenario();
|
||||
|
||||
private _unsub?: () => void;
|
||||
|
||||
// The demo uses hash-based routing (navigate() sets location.hash), so the
|
||||
// active route lives in the hash, not the pathname.
|
||||
private get _currentPath(): string {
|
||||
const hash = mainWindow.location.hash;
|
||||
return hash.startsWith("#/") ? hash.slice(1) : mainWindow.location.pathname;
|
||||
}
|
||||
|
||||
private _locationChanged = () => {
|
||||
this._visible = this._currentPath.startsWith("/config/cloud");
|
||||
};
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._locationChanged();
|
||||
mainWindow.addEventListener("location-changed", this._locationChanged);
|
||||
mainWindow.addEventListener("popstate", this._locationChanged);
|
||||
mainWindow.addEventListener("hashchange", this._locationChanged);
|
||||
this._unsub = subscribeCloudDemoScenario((scenario) => {
|
||||
this._scenario = { ...scenario };
|
||||
});
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
mainWindow.removeEventListener("location-changed", this._locationChanged);
|
||||
mainWindow.removeEventListener("popstate", this._locationChanged);
|
||||
mainWindow.removeEventListener("hashchange", this._locationChanged);
|
||||
this._unsub?.();
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._visible) {
|
||||
return nothing;
|
||||
}
|
||||
if (!this._open) {
|
||||
return html`
|
||||
<ha-icon-button
|
||||
class="fab"
|
||||
label="Cloud demo controls"
|
||||
.path=${mdiFlaskOutline}
|
||||
@click=${this._toggleOpen}
|
||||
></ha-icon-button>
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<ha-card>
|
||||
<div class="header">
|
||||
<ha-svg-icon .path=${mdiFlaskOutline}></ha-svg-icon>
|
||||
<span class="title">Cloud demo controls</span>
|
||||
<ha-icon-button
|
||||
label="Close"
|
||||
.path=${mdiClose}
|
||||
@click=${this._toggleOpen}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
<p class="note">
|
||||
Demo only. Flips the mocked cloud state shown on this page.
|
||||
</p>
|
||||
<div class="controls">
|
||||
${this._segment("Subscription", "account", [
|
||||
["active", "Active"],
|
||||
["trialing", "Trialing"],
|
||||
["canceled", "Canceled"],
|
||||
["expired", "Expired"],
|
||||
["unknown", "Unknown"],
|
||||
])}
|
||||
${this._toggle("Onboarded", "onboarded")}
|
||||
${this._toggle("Onboarding postponed", "postponed")}
|
||||
${this._toggle("Remote access", "remote")}
|
||||
${this._segment("Remote status", "remoteStatus", [
|
||||
["ready", "Ready"],
|
||||
["generating", "Preparing"],
|
||||
["loading", "Loading"],
|
||||
["loaded", "Loaded"],
|
||||
["error", "Error"],
|
||||
])}
|
||||
${this._segment("Backups", "backup", [
|
||||
["fresh", "Recent"],
|
||||
["stale", "Old"],
|
||||
["failed", "Failed"],
|
||||
["local", "Local only"],
|
||||
["none", "None"],
|
||||
])}
|
||||
${this._toggle("Alexa linked", "alexa")}
|
||||
${this._toggle("Google linked", "google")}
|
||||
${this._toggle("Cameras (WebRTC)", "webrtc")}
|
||||
${this._toggle("Has webhooks", "webhooks")}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _segment(
|
||||
label: string,
|
||||
field: keyof CloudDemoScenario,
|
||||
options: [string, string][]
|
||||
) {
|
||||
return html`
|
||||
<div class="row">
|
||||
<span>${label}</span>
|
||||
<div class="segment">
|
||||
${options.map(
|
||||
([value, text]) => html`
|
||||
<ha-button
|
||||
size="s"
|
||||
appearance=${
|
||||
this._scenario[field] === value ? "filled" : "plain"
|
||||
}
|
||||
data-field=${field}
|
||||
data-value=${value}
|
||||
@click=${this._segmentClick}
|
||||
>
|
||||
${text}
|
||||
</ha-button>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _toggle(label: string, field: keyof CloudDemoScenario) {
|
||||
return html`
|
||||
<div class="row">
|
||||
<span>${label}</span>
|
||||
<ha-switch
|
||||
.checked=${this._scenario[field] as boolean}
|
||||
data-field=${field}
|
||||
@change=${this._toggleChange}
|
||||
></ha-switch>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _toggleOpen() {
|
||||
this._open = !this._open;
|
||||
}
|
||||
|
||||
private _segmentClick(ev: Event) {
|
||||
const target = ev.currentTarget as HTMLElement;
|
||||
this._set(
|
||||
target.dataset.field as keyof CloudDemoScenario,
|
||||
target.dataset.value!
|
||||
);
|
||||
}
|
||||
|
||||
private _toggleChange(ev: Event) {
|
||||
const target = ev.target as HaSwitch;
|
||||
this._set(target.dataset.field as keyof CloudDemoScenario, target.checked);
|
||||
}
|
||||
|
||||
private _set(field: keyof CloudDemoScenario, value: string | boolean) {
|
||||
setCloudDemoScenario({ [field]: value } as Partial<CloudDemoScenario>);
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
private _refresh() {
|
||||
// Refresh the shared cloud status so login-state changes (signed out) and
|
||||
// status-derived fields update.
|
||||
const panel = deepQuery("ha-panel-config");
|
||||
if (panel) {
|
||||
fireEvent(panel as HTMLElement, "ha-refresh-cloud-status");
|
||||
}
|
||||
// cloud-account fetches its subscription/backup/webhook data once on mount
|
||||
// and is not cached by the router, so bounce through a sibling cloud route
|
||||
// to force a clean remount that re-reads the updated mocks.
|
||||
const path = this._currentPath;
|
||||
if (path.startsWith("/config/cloud") && path !== "/config/cloud/login") {
|
||||
const sibling =
|
||||
path === "/config/cloud/remote"
|
||||
? "/config/cloud/account"
|
||||
: "/config/cloud/remote";
|
||||
navigate(sibling, { replace: true });
|
||||
window.setTimeout(() => navigate(path, { replace: true }), 0);
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
z-index: 9999;
|
||||
}
|
||||
.fab {
|
||||
--mdc-icon-button-size: 48px;
|
||||
--mdc-icon-size: 24px;
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-primary-color, #fff);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
ha-card {
|
||||
display: block;
|
||||
width: 320px;
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 8px 8px 16px;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
.header .title {
|
||||
flex: 1;
|
||||
font-weight: var(--ha-font-weight-medium, 500);
|
||||
}
|
||||
.header ha-svg-icon {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.note {
|
||||
margin: 8px 16px;
|
||||
color: var(--secondary-text-color);
|
||||
font-size: var(--ha-font-size-s, 0.875rem);
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 36px;
|
||||
}
|
||||
.segment {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"cloud-demo-controls": CloudDemoControls;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,16 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import type { Lovelace } from "../../../src/panels/lovelace/types";
|
||||
import { energyEntities } from "../stubs/entities";
|
||||
import type { DemoConfig } from "./types";
|
||||
import { getDemoTheme } from "../stubs/frontend";
|
||||
import type { DemoConfig, DemoTheme } from "./types";
|
||||
|
||||
export const applyDemoTheme = (hass: MockHomeAssistant, theme: DemoTheme) => {
|
||||
if (typeof theme === "function") {
|
||||
hass.mockTheme(theme());
|
||||
return;
|
||||
}
|
||||
hass.mockTheme(null, getDemoTheme(theme));
|
||||
};
|
||||
|
||||
export const demoConfigs: (() => Promise<DemoConfig>)[] = [
|
||||
() => import("./sections").then((mod) => mod.demoSections),
|
||||
@@ -31,5 +40,5 @@ export const setDemoConfig = async (
|
||||
hass.addEntities(config.entities(hass.localize), true);
|
||||
hass.addEntities(energyEntities());
|
||||
lovelace.saveConfig(config.lovelace(hass.localize));
|
||||
hass.mockTheme(config.theme());
|
||||
applyDemoTheme(hass, config.theme);
|
||||
};
|
||||
|
||||
@@ -8,5 +8,5 @@ export const demoSections: DemoConfig = {
|
||||
name: "Home Demo",
|
||||
lovelace: demoLovelaceSections,
|
||||
entities: demoEntitiesSections,
|
||||
theme: () => ({}),
|
||||
theme: { theme: "default", dark: false },
|
||||
};
|
||||
|
||||
@@ -2,6 +2,9 @@ import type { TemplateResult } from "lit";
|
||||
import type { LocalizeFunc } from "../../../src/common/translations/localize";
|
||||
import type { LovelaceConfig } from "../../../src/data/lovelace/config/types";
|
||||
import type { EntityInput } from "../../../src/fake_data/entities/types";
|
||||
import type { ThemeSettings } from "../../../src/types";
|
||||
|
||||
export type DemoTheme = ThemeSettings | (() => Record<string, string> | null);
|
||||
|
||||
export interface DemoConfig {
|
||||
index?: number;
|
||||
@@ -12,5 +15,5 @@ export interface DemoConfig {
|
||||
string | ((localize: LocalizeFunc) => string | TemplateResult<1>);
|
||||
lovelace: (localize: LocalizeFunc) => LovelaceConfig;
|
||||
entities: (localize: LocalizeFunc) => EntityInput[];
|
||||
theme: () => Record<string, string> | null;
|
||||
theme: DemoTheme;
|
||||
}
|
||||
|
||||
+20
-4
@@ -5,7 +5,7 @@ import type { MockHomeAssistant } from "../../src/fake_data/provide_hass";
|
||||
import { provideHass } from "../../src/fake_data/provide_hass";
|
||||
import { HomeAssistantAppEl } from "../../src/layouts/home-assistant";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
import { selectedDemoConfig } from "./configs/demo-configs";
|
||||
import { applyDemoTheme, selectedDemoConfig } from "./configs/demo-configs";
|
||||
import { mockAreaRegistry } from "./stubs/area_registry";
|
||||
import { mockAuth } from "./stubs/auth";
|
||||
import { demoDevices } from "./stubs/devices";
|
||||
@@ -29,11 +29,13 @@ import { mockSystemLog } from "./stubs/system_log";
|
||||
import { mockTemplate } from "./stubs/template";
|
||||
import { mockTodo } from "./stubs/todo";
|
||||
import { mockTranslations } from "./stubs/translations";
|
||||
import "./cloud/cloud-demo-controls";
|
||||
|
||||
// WS command / REST path prefixes whose mocks live in the lazily imported
|
||||
// config-panel chunk (see ./stubs/config-panel). Must stay in sync with it.
|
||||
const CONFIG_PANEL_COMMANDS = [
|
||||
"cloud/",
|
||||
"webhook/list",
|
||||
"validate_config",
|
||||
"config_entries/",
|
||||
"device_automation/",
|
||||
@@ -69,6 +71,22 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
// `false` for contexts: HomeAssistantAppEl already provides them via
|
||||
// `contextMixin`, so let provideHass skip them to avoid duplicate providers.
|
||||
const hass = provideHass(this, initial, true, false);
|
||||
|
||||
// The cloud account page only fetches backup config and the webhook count
|
||||
// when those integrations are loaded. Enable them here (demo only) so the
|
||||
// mocked backup/config/info and webhook/list are queried.
|
||||
hass.updateHass({
|
||||
config: {
|
||||
...hass.config,
|
||||
components: [...(hass.config?.components ?? []), "backup", "webhook"],
|
||||
},
|
||||
});
|
||||
|
||||
// Demo-only floating panel to flip the mocked cloud state. Mounted once at
|
||||
// the document level; it shows itself only on the cloud panel.
|
||||
if (!document.querySelector("cloud-demo-controls")) {
|
||||
document.body.appendChild(document.createElement("cloud-demo-controls"));
|
||||
}
|
||||
const localizePromise =
|
||||
// @ts-ignore
|
||||
this._loadFragmentTranslations(hass.language, "page-demo").then(
|
||||
@@ -155,9 +173,7 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
Promise.all([selectedDemoConfig, localizePromise]).then(
|
||||
([conf, localize]) => {
|
||||
hass.addEntities(conf.entities(localize));
|
||||
if (conf.theme) {
|
||||
hass.mockTheme(conf.theme());
|
||||
}
|
||||
applyDemoTheme(hass, conf.theme);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
+139
-25
@@ -7,42 +7,34 @@ import type {
|
||||
import { BackupScheduleRecurrence } from "../../../src/data/backup";
|
||||
import type { ManagerStateEvent } from "../../../src/data/backup_manager";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import type { DemoCloudBackup } from "./cloud-demo-state";
|
||||
import {
|
||||
getCloudDemoScenario,
|
||||
setCloudDemoScenario,
|
||||
subscribeCloudDemoScenario,
|
||||
} from "./cloud-demo-state";
|
||||
|
||||
const lastBackupDate = new Date(Date.now() - 86400000).toISOString();
|
||||
const nextBackupDate = new Date(Date.now() + 86400000).toISOString();
|
||||
|
||||
const backups: BackupContent[] = [
|
||||
{
|
||||
backup_id: "demo-backup-1",
|
||||
name: "Automatic backup DEMO",
|
||||
date: lastBackupDate,
|
||||
with_automatic_settings: true,
|
||||
agents: {
|
||||
"backup.local": { size: 1024 * 1024 * 512, protected: true },
|
||||
"cloud.cloud": { size: 1024 * 1024 * 512, protected: true },
|
||||
},
|
||||
},
|
||||
];
|
||||
const CLOUD_AGENT = "cloud.cloud";
|
||||
|
||||
const backupInfo: BackupInfo = {
|
||||
backups,
|
||||
backups: [],
|
||||
agent_errors: {},
|
||||
last_attempted_automatic_backup: lastBackupDate,
|
||||
last_completed_automatic_backup: lastBackupDate,
|
||||
last_attempted_automatic_backup: null,
|
||||
last_completed_automatic_backup: null,
|
||||
last_action_event: { manager_state: "idle" },
|
||||
next_automatic_backup: nextBackupDate,
|
||||
next_automatic_backup: null,
|
||||
next_automatic_backup_additional: false,
|
||||
state: "idle",
|
||||
};
|
||||
|
||||
const backupConfig: BackupConfig = {
|
||||
automatic_backups_configured: true,
|
||||
last_attempted_automatic_backup: lastBackupDate,
|
||||
last_completed_automatic_backup: lastBackupDate,
|
||||
next_automatic_backup: nextBackupDate,
|
||||
last_attempted_automatic_backup: null,
|
||||
last_completed_automatic_backup: null,
|
||||
next_automatic_backup: null,
|
||||
next_automatic_backup_additional: false,
|
||||
create_backup: {
|
||||
agent_ids: ["backup.local", "cloud.cloud"],
|
||||
agent_ids: ["backup.local", CLOUD_AGENT],
|
||||
include_addons: [],
|
||||
include_all_addons: true,
|
||||
include_database: true,
|
||||
@@ -69,10 +61,132 @@ const agentsInfo: BackupAgentsInfo = {
|
||||
],
|
||||
};
|
||||
|
||||
// Map the demo "Backups" scenario onto the mutable backup config/info, so the
|
||||
// cloud overview status line and the backup sub-page reflect the chosen state.
|
||||
const applyScenario = () => {
|
||||
const kind = getCloudDemoScenario().backup;
|
||||
const now = Date.now();
|
||||
const recent = new Date(now - 12 * 3600 * 1000).toISOString();
|
||||
const old = new Date(now - 5 * 86400000).toISOString();
|
||||
const future = new Date(now + 86400000).toISOString();
|
||||
// Comfortably past BACKUP_OVERDUE_MARGIN_HOURS (3h) so the "stale" scenario
|
||||
// actually reads as overdue rather than slipping under the margin.
|
||||
const overdue = new Date(now - 6 * 3600 * 1000).toISOString();
|
||||
|
||||
// The cloud agent is a backup target for the cloud-backed states only. For
|
||||
// "local" a backup exists but is stored locally (no cloud copy), and for
|
||||
// "none" there are no automatic backups at all.
|
||||
const cloudEnabled =
|
||||
kind === "fresh" || kind === "stale" || kind === "failed";
|
||||
backupConfig.create_backup.agent_ids = cloudEnabled
|
||||
? ["backup.local", CLOUD_AGENT]
|
||||
: ["backup.local"];
|
||||
|
||||
switch (kind) {
|
||||
case "fresh":
|
||||
backupConfig.automatic_backups_configured = true;
|
||||
backupConfig.last_completed_automatic_backup = recent;
|
||||
backupConfig.last_attempted_automatic_backup = recent;
|
||||
backupConfig.next_automatic_backup = future;
|
||||
break;
|
||||
case "local":
|
||||
// Automatic backups run, but only to the local agent.
|
||||
backupConfig.automatic_backups_configured = true;
|
||||
backupConfig.last_completed_automatic_backup = recent;
|
||||
backupConfig.last_attempted_automatic_backup = recent;
|
||||
backupConfig.next_automatic_backup = future;
|
||||
break;
|
||||
case "stale":
|
||||
backupConfig.automatic_backups_configured = true;
|
||||
backupConfig.last_completed_automatic_backup = old;
|
||||
backupConfig.last_attempted_automatic_backup = old;
|
||||
// Next scheduled backup is in the past, so it reads as overdue.
|
||||
backupConfig.next_automatic_backup = overdue;
|
||||
break;
|
||||
case "failed":
|
||||
backupConfig.automatic_backups_configured = true;
|
||||
backupConfig.last_completed_automatic_backup = old;
|
||||
// Most recent attempt is newer than the last success, so it failed.
|
||||
backupConfig.last_attempted_automatic_backup = recent;
|
||||
backupConfig.next_automatic_backup = future;
|
||||
break;
|
||||
case "none":
|
||||
backupConfig.automatic_backups_configured = false;
|
||||
backupConfig.last_completed_automatic_backup = null;
|
||||
backupConfig.last_attempted_automatic_backup = null;
|
||||
backupConfig.next_automatic_backup = null;
|
||||
break;
|
||||
}
|
||||
|
||||
backupInfo.last_completed_automatic_backup =
|
||||
backupConfig.last_completed_automatic_backup;
|
||||
backupInfo.last_attempted_automatic_backup =
|
||||
backupConfig.last_attempted_automatic_backup;
|
||||
backupInfo.next_automatic_backup = backupConfig.next_automatic_backup;
|
||||
backupInfo.backups =
|
||||
cloudEnabled && backupConfig.last_completed_automatic_backup
|
||||
? [
|
||||
{
|
||||
backup_id: "demo-backup-1",
|
||||
name: "Automatic backup DEMO",
|
||||
date: backupConfig.last_completed_automatic_backup,
|
||||
with_automatic_settings: true,
|
||||
agents: {
|
||||
"backup.local": { size: 1024 * 1024 * 512, protected: true },
|
||||
"cloud.cloud": { size: 1024 * 1024 * 512, protected: true },
|
||||
},
|
||||
} as BackupContent,
|
||||
]
|
||||
: [];
|
||||
};
|
||||
|
||||
applyScenario();
|
||||
subscribeCloudDemoScenario(applyScenario);
|
||||
|
||||
export const mockBackup = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("backup/info", () => backupInfo);
|
||||
hass.mockWS("backup/config/info", () => ({ config: backupConfig }));
|
||||
// Fresh objects each fetch so re-reading after a mutation actually re-renders
|
||||
// (Lit change detection is identity-based; the real WS API returns new
|
||||
// objects too).
|
||||
hass.mockWS("backup/info", () => ({ ...backupInfo }));
|
||||
hass.mockWS("backup/config/info", () => ({ config: { ...backupConfig } }));
|
||||
hass.mockWS("backup/agents/info", () => agentsInfo);
|
||||
hass.mockWS("backup/config/update", (msg) => {
|
||||
const { type, ...update } = msg;
|
||||
if (update.create_backup) {
|
||||
backupConfig.create_backup = {
|
||||
...backupConfig.create_backup,
|
||||
...update.create_backup,
|
||||
};
|
||||
}
|
||||
if (update.automatic_backups_configured !== undefined) {
|
||||
backupConfig.automatic_backups_configured =
|
||||
update.automatic_backups_configured;
|
||||
}
|
||||
if (update.schedule) {
|
||||
backupConfig.schedule = { ...backupConfig.schedule, ...update.schedule };
|
||||
}
|
||||
if (update.retention) {
|
||||
backupConfig.retention = update.retention;
|
||||
}
|
||||
if (update.agents) {
|
||||
backupConfig.agents = { ...backupConfig.agents, ...update.agents };
|
||||
}
|
||||
// Reflect the UI-driven backup change into the demo scenario so the demo
|
||||
// controls panel stays in sync with the mocked state.
|
||||
const cloudNow = backupConfig.create_backup.agent_ids.includes(CLOUD_AGENT);
|
||||
const current = getCloudDemoScenario().backup;
|
||||
const next: DemoCloudBackup = !backupConfig.automatic_backups_configured
|
||||
? "none"
|
||||
: cloudNow
|
||||
? current === "fresh" || current === "stale" || current === "failed"
|
||||
? current
|
||||
: "fresh"
|
||||
: "local";
|
||||
if (next !== current) {
|
||||
setCloudDemoScenario({ backup: next });
|
||||
}
|
||||
return null;
|
||||
});
|
||||
hass.mockWS(
|
||||
"backup/subscribe_events",
|
||||
(_msg, _hass, onChange?: (event: ManagerStateEvent) => void) => {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// Demo-only switchable Home Assistant Cloud scenario.
|
||||
//
|
||||
// The redesigned cloud account page (src/panels/config/cloud/account) renders
|
||||
// purely from real WS data. To let reviewers preview every UI state without a
|
||||
// real cloud account, this module holds a mutable "scenario" that the cloud and
|
||||
// backup mocks read from, plus the floating <cloud-demo-controls> panel writes
|
||||
// to. It is persisted to localStorage so the choice survives the data the page
|
||||
// fetches once per visit (subscription, backup config, webhooks).
|
||||
//
|
||||
// This lives entirely under demo/ — no production code imports it.
|
||||
|
||||
import type { RemoteCertificateStatus } from "../../../src/data/cloud";
|
||||
|
||||
// The five PaymentSubscriptionState values.
|
||||
export type DemoCloudAccount =
|
||||
"active" | "trialing" | "canceled" | "expired" | "unknown";
|
||||
|
||||
// "local": automatic backups are configured, but not to the cloud agent
|
||||
// (a backup exists, just no cloud copy). "none": no automatic backups at all.
|
||||
export type DemoCloudBackup = "fresh" | "stale" | "failed" | "local" | "none";
|
||||
|
||||
export interface CloudDemoScenario {
|
||||
account: DemoCloudAccount;
|
||||
onboarded: boolean;
|
||||
// Onboarding postponed server-side (maps to onboarding_postponed); hides
|
||||
// the onboarding UI without marking it completed.
|
||||
postponed: boolean;
|
||||
remote: boolean;
|
||||
remoteStatus: RemoteCertificateStatus;
|
||||
backup: DemoCloudBackup;
|
||||
alexa: boolean;
|
||||
google: boolean;
|
||||
webrtc: boolean;
|
||||
webhooks: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_CLOUD_DEMO_SCENARIO: CloudDemoScenario = {
|
||||
account: "active",
|
||||
onboarded: true,
|
||||
postponed: false,
|
||||
remote: true,
|
||||
remoteStatus: "ready",
|
||||
backup: "fresh",
|
||||
alexa: true,
|
||||
google: true,
|
||||
webrtc: true,
|
||||
webhooks: true,
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "cloudDemoScenario";
|
||||
|
||||
const readScenario = (): CloudDemoScenario => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
return { ...DEFAULT_CLOUD_DEMO_SCENARIO, ...JSON.parse(raw) };
|
||||
}
|
||||
} catch (_err) {
|
||||
// Ignore malformed or unavailable storage and fall back to the default.
|
||||
}
|
||||
return { ...DEFAULT_CLOUD_DEMO_SCENARIO };
|
||||
};
|
||||
|
||||
let scenario: CloudDemoScenario = readScenario();
|
||||
|
||||
const listeners = new Set<(scenario: CloudDemoScenario) => void>();
|
||||
|
||||
export const getCloudDemoScenario = (): CloudDemoScenario => scenario;
|
||||
|
||||
export const subscribeCloudDemoScenario = (
|
||||
listener: (scenario: CloudDemoScenario) => void
|
||||
): (() => void) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
export const setCloudDemoScenario = (
|
||||
partial: Partial<CloudDemoScenario>
|
||||
): void => {
|
||||
scenario = { ...scenario, ...partial };
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(scenario));
|
||||
} catch (_err) {
|
||||
// Ignore storage failures (e.g. private mode); state still applies in-memory.
|
||||
}
|
||||
listeners.forEach((listener) => listener(scenario));
|
||||
};
|
||||
+157
-2
@@ -2,8 +2,15 @@ import type {
|
||||
CloudStatusLoggedIn,
|
||||
SubscriptionInfo,
|
||||
} from "../../../src/data/cloud";
|
||||
import { ONBOARDING_ITEMS } from "../../../src/data/cloud";
|
||||
import type { CloudTTSInfo } from "../../../src/data/cloud/tts";
|
||||
import type { Webhook } from "../../../src/data/webhook";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import {
|
||||
getCloudDemoScenario,
|
||||
setCloudDemoScenario,
|
||||
subscribeCloudDemoScenario,
|
||||
} from "./cloud-demo-state";
|
||||
|
||||
const emptyFilter = () => ({
|
||||
include_domains: [],
|
||||
@@ -12,8 +19,23 @@ const emptyFilter = () => ({
|
||||
exclude_entities: [],
|
||||
});
|
||||
|
||||
const demoWebhooks: Webhook[] = [
|
||||
{
|
||||
webhook_id: "demo_front_door",
|
||||
domain: "automation",
|
||||
name: "Front door motion",
|
||||
local_only: false,
|
||||
},
|
||||
{
|
||||
webhook_id: "demo_companion_app",
|
||||
domain: "mobile_app",
|
||||
name: "Companion app",
|
||||
local_only: false,
|
||||
},
|
||||
];
|
||||
|
||||
// A single mutable status object so that preference changes made in the demo
|
||||
// are reflected back in the UI.
|
||||
// (both via the real UI and the demo scenario controls) are reflected back.
|
||||
const cloudStatus: CloudStatusLoggedIn = {
|
||||
logged_in: true,
|
||||
cloud: "connected",
|
||||
@@ -35,6 +57,8 @@ const cloudStatus: CloudStatusLoggedIn = {
|
||||
remote_certificate_status: "ready",
|
||||
http_use_ssl: false,
|
||||
active_subscription: true,
|
||||
onboarding_postponed: false,
|
||||
onboarding_completed: true,
|
||||
prefs: {
|
||||
google_enabled: true,
|
||||
alexa_enabled: true,
|
||||
@@ -47,6 +71,8 @@ const cloudStatus: CloudStatusLoggedIn = {
|
||||
google_report_state: true,
|
||||
tts_default_voice: ["en-US", "JennyNeural"],
|
||||
cloud_ice_servers_enabled: true,
|
||||
onboarded_items: [...ONBOARDING_ITEMS],
|
||||
onboarding_postponed_until: null,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -54,6 +80,7 @@ const subscription: SubscriptionInfo = {
|
||||
human_description: "Demo subscription, renews automatically",
|
||||
provider: "Nabu Casa, Inc.",
|
||||
plan_renewal_date: 4102444800,
|
||||
subscription: { status: "active" },
|
||||
};
|
||||
|
||||
const ttsInfo: CloudTTSInfo = {
|
||||
@@ -66,17 +93,140 @@ const ttsInfo: CloudTTSInfo = {
|
||||
],
|
||||
};
|
||||
|
||||
// Map the high-level demo scenario onto the mutable cloud status / subscription.
|
||||
const applyScenario = () => {
|
||||
const scenario = getCloudDemoScenario();
|
||||
|
||||
switch (scenario.account) {
|
||||
case "trialing":
|
||||
cloudStatus.active_subscription = true;
|
||||
subscription.subscription = { status: "trialing" };
|
||||
break;
|
||||
case "canceled":
|
||||
cloudStatus.active_subscription = false;
|
||||
subscription.subscription = { status: "canceled" };
|
||||
break;
|
||||
case "expired":
|
||||
cloudStatus.active_subscription = false;
|
||||
subscription.subscription = { status: "expired" };
|
||||
break;
|
||||
case "unknown":
|
||||
cloudStatus.active_subscription = true;
|
||||
subscription.subscription = { status: "unknown" };
|
||||
break;
|
||||
default:
|
||||
// "active"
|
||||
cloudStatus.active_subscription = true;
|
||||
subscription.subscription = { status: "active" };
|
||||
}
|
||||
|
||||
cloudStatus.prefs.onboarded_items = scenario.onboarded
|
||||
? [...ONBOARDING_ITEMS]
|
||||
: [];
|
||||
cloudStatus.onboarding_completed = scenario.onboarded;
|
||||
cloudStatus.onboarding_postponed = scenario.postponed;
|
||||
cloudStatus.prefs.onboarding_postponed_until = scenario.postponed
|
||||
? new Date(Date.now() + 24 * 3600 * 1000).toISOString()
|
||||
: null;
|
||||
cloudStatus.prefs.remote_enabled = scenario.remote;
|
||||
cloudStatus.remote_connected = scenario.remote;
|
||||
cloudStatus.remote_certificate_status = scenario.remoteStatus;
|
||||
cloudStatus.alexa_registered = scenario.alexa;
|
||||
cloudStatus.google_registered = scenario.google;
|
||||
cloudStatus.prefs.cloud_ice_servers_enabled = scenario.webrtc;
|
||||
|
||||
const hasCloudhooks = Object.keys(cloudStatus.prefs.cloudhooks).length > 0;
|
||||
if (scenario.webhooks && !hasCloudhooks) {
|
||||
cloudStatus.prefs.cloudhooks = Object.fromEntries(
|
||||
demoWebhooks.map((webhook) => [
|
||||
webhook.webhook_id,
|
||||
{
|
||||
webhook_id: webhook.webhook_id,
|
||||
cloudhook_id: `demo-${webhook.webhook_id}`,
|
||||
cloudhook_url: `https://hooks.nabu.casa/demo-${webhook.webhook_id}`,
|
||||
managed: false,
|
||||
},
|
||||
])
|
||||
);
|
||||
} else if (!scenario.webhooks && hasCloudhooks) {
|
||||
cloudStatus.prefs.cloudhooks = {};
|
||||
}
|
||||
};
|
||||
|
||||
applyScenario();
|
||||
subscribeCloudDemoScenario(applyScenario);
|
||||
|
||||
// Reflect UI-driven changes (onboarding toggles, remote connect/disconnect)
|
||||
// back into the demo scenario so the demo controls panel stays in sync with the
|
||||
// mocked state. Only writes when a value actually changed, to avoid needless
|
||||
// re-projection. `applyScenario` re-applies the (now matching) scenario, so
|
||||
// this stays idempotent and does not fight the direct mutation above.
|
||||
const syncScenarioFromStatus = () => {
|
||||
const scenario = getCloudDemoScenario();
|
||||
const next = {
|
||||
onboarded: cloudStatus.onboarding_completed,
|
||||
postponed: cloudStatus.onboarding_postponed,
|
||||
remote: cloudStatus.prefs.remote_enabled,
|
||||
webrtc: cloudStatus.prefs.cloud_ice_servers_enabled,
|
||||
webhooks: Object.keys(cloudStatus.prefs.cloudhooks).length > 0,
|
||||
};
|
||||
if (
|
||||
scenario.onboarded !== next.onboarded ||
|
||||
scenario.postponed !== next.postponed ||
|
||||
scenario.remote !== next.remote ||
|
||||
scenario.webrtc !== next.webrtc ||
|
||||
scenario.webhooks !== next.webhooks
|
||||
) {
|
||||
setCloudDemoScenario(next);
|
||||
}
|
||||
};
|
||||
|
||||
export const mockCloud = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("cloud/status", () => cloudStatus);
|
||||
hass.mockWS("cloud/status", () => ({
|
||||
...cloudStatus,
|
||||
prefs: { ...cloudStatus.prefs },
|
||||
}));
|
||||
hass.mockWS("cloud/subscription", () => subscription);
|
||||
hass.mockWS("cloud/tts/info", () => ttsInfo);
|
||||
hass.mockWS("webhook/list", () => demoWebhooks);
|
||||
|
||||
hass.mockWS("cloud/update_prefs", (msg) => {
|
||||
const { type, ...prefs } = msg;
|
||||
cloudStatus.prefs = { ...cloudStatus.prefs, ...prefs };
|
||||
syncScenarioFromStatus();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
hass.mockWS("cloud/onboarding/postpone", () => {
|
||||
cloudStatus.prefs.onboarding_postponed_until = new Date(
|
||||
Date.now() + 24 * 3600 * 1000
|
||||
).toISOString();
|
||||
cloudStatus.onboarding_postponed = true;
|
||||
syncScenarioFromStatus();
|
||||
// Backend returns the full logged-in status object.
|
||||
return { ...cloudStatus, prefs: { ...cloudStatus.prefs } };
|
||||
});
|
||||
|
||||
hass.mockWS("cloud/onboarding/complete", (msg) => {
|
||||
const items: string[] = msg.items ?? [];
|
||||
const missing = items.filter(
|
||||
(item) => !cloudStatus.prefs.onboarded_items.includes(item)
|
||||
);
|
||||
if (missing.length) {
|
||||
cloudStatus.prefs.onboarded_items = [
|
||||
...cloudStatus.prefs.onboarded_items,
|
||||
...missing,
|
||||
];
|
||||
}
|
||||
cloudStatus.onboarding_completed = ONBOARDING_ITEMS.every(
|
||||
(onboardingItem) =>
|
||||
cloudStatus.prefs.onboarded_items.includes(onboardingItem)
|
||||
);
|
||||
syncScenarioFromStatus();
|
||||
// Backend returns the full logged-in status object.
|
||||
return { ...cloudStatus, prefs: { ...cloudStatus.prefs } };
|
||||
});
|
||||
|
||||
hass.mockWS("cloud/cloudhook/create", (msg) => {
|
||||
const webhook = {
|
||||
webhook_id: msg.webhook_id,
|
||||
@@ -95,15 +245,20 @@ export const mockCloud = (hass: MockHomeAssistant) => {
|
||||
const cloudhooks = { ...cloudStatus.prefs.cloudhooks };
|
||||
delete cloudhooks[msg.webhook_id];
|
||||
cloudStatus.prefs.cloudhooks = cloudhooks;
|
||||
syncScenarioFromStatus();
|
||||
return null;
|
||||
});
|
||||
|
||||
hass.mockWS("cloud/remote/connect", () => {
|
||||
cloudStatus.remote_connected = true;
|
||||
cloudStatus.prefs.remote_enabled = true;
|
||||
syncScenarioFromStatus();
|
||||
return null;
|
||||
});
|
||||
hass.mockWS("cloud/remote/disconnect", () => {
|
||||
cloudStatus.remote_connected = false;
|
||||
cloudStatus.prefs.remote_enabled = false;
|
||||
syncScenarioFromStatus();
|
||||
return null;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,37 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import type { ThemeSettings } from "../../../src/types";
|
||||
|
||||
let sidebarChangeCallback;
|
||||
let sidebarChangeCallback: ((data: { value: unknown }) => void) | undefined;
|
||||
let themeChangeCallback: ((data: { value: ThemeSettings }) => void) | undefined;
|
||||
|
||||
const THEME_STORAGE_KEY = "demo_theme";
|
||||
const DEFAULT_THEME: ThemeSettings = { theme: "default", dark: false };
|
||||
|
||||
export const getDemoTheme = (
|
||||
fallback: ThemeSettings = DEFAULT_THEME
|
||||
): ThemeSettings => {
|
||||
const storedTheme = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
if (!storedTheme) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(storedTheme) as ThemeSettings;
|
||||
} catch {
|
||||
localStorage.removeItem(THEME_STORAGE_KEY);
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
export const mockFrontend = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("frontend/get_user_data", () => ({ value: null }));
|
||||
hass.mockWS("frontend/get_user_data", ({ key }) => ({
|
||||
value: key === "theme" ? getDemoTheme() : null,
|
||||
}));
|
||||
hass.mockWS("frontend/set_user_data", ({ key, value }) => {
|
||||
if (key === "theme") {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(value));
|
||||
themeChangeCallback?.({ value });
|
||||
localStorage.removeItem("selectedTheme");
|
||||
}
|
||||
if (key === "sidebar") {
|
||||
sidebarChangeCallback?.({
|
||||
value: {
|
||||
@@ -14,10 +41,18 @@ export const mockFrontend = (hass: MockHomeAssistant) => {
|
||||
});
|
||||
}
|
||||
});
|
||||
hass.mockWS("frontend/subscribe_user_data", (msg, _hass, onChange) => {
|
||||
hass.mockWS("frontend/subscribe_user_data", (msg, currentHass, onChange) => {
|
||||
if (msg.key === "sidebar") {
|
||||
sidebarChangeCallback = onChange;
|
||||
}
|
||||
if (msg.key === "theme") {
|
||||
themeChangeCallback = onChange;
|
||||
onChange?.({
|
||||
value: getDemoTheme(currentHass.selectedTheme ?? undefined),
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return () => {};
|
||||
}
|
||||
onChange?.({ value: null });
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return () => {};
|
||||
|
||||
+31
-30
@@ -12,7 +12,7 @@
|
||||
"format:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --fix",
|
||||
"lint:prettier": "prettier . --cache --check",
|
||||
"format:prettier": "prettier . --cache --write",
|
||||
"lint:types": "tsc",
|
||||
"lint:types": "node ./node_modules/@typescript/native/bin/tsc",
|
||||
"lint:lit": "lit-analyzer \"{.,*}/src/**/*.ts\"",
|
||||
"lint:licenses": "node --no-deprecation script/check-licenses",
|
||||
"lint": "yarn run lint:eslint && yarn run lint:prettier && yarn run lint:types && yarn run lint:lit",
|
||||
@@ -48,19 +48,19 @@
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/lint": "6.9.7",
|
||||
"@codemirror/search": "6.7.1",
|
||||
"@codemirror/state": "6.7.0",
|
||||
"@codemirror/view": "6.43.4",
|
||||
"@codemirror/state": "6.7.1",
|
||||
"@codemirror/view": "6.43.6",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.4.9",
|
||||
"@formatjs/intl-displaynames": "7.3.10",
|
||||
"@formatjs/intl-durationformat": "0.10.15",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.10",
|
||||
"@formatjs/intl-listformat": "8.3.10",
|
||||
"@formatjs/intl-locale": "5.3.9",
|
||||
"@formatjs/intl-numberformat": "9.3.11",
|
||||
"@formatjs/intl-pluralrules": "6.3.10",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.10",
|
||||
"@formatjs/intl-datetimeformat": "7.5.0",
|
||||
"@formatjs/intl-displaynames": "7.3.12",
|
||||
"@formatjs/intl-durationformat": "0.10.17",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.11",
|
||||
"@formatjs/intl-listformat": "8.3.12",
|
||||
"@formatjs/intl-locale": "5.3.10",
|
||||
"@formatjs/intl-numberformat": "9.3.13",
|
||||
"@formatjs/intl-pluralrules": "6.3.12",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.12",
|
||||
"@fullcalendar/core": "6.1.21",
|
||||
"@fullcalendar/daygrid": "6.1.21",
|
||||
"@fullcalendar/interaction": "6.1.21",
|
||||
@@ -83,13 +83,13 @@
|
||||
"@replit/codemirror-indentation-markers": "6.5.3",
|
||||
"@swc/helpers": "0.5.23",
|
||||
"@thomasloven/round-slider": "0.6.0",
|
||||
"@tsparticles/engine": "4.3.1",
|
||||
"@tsparticles/preset-links": "4.3.1",
|
||||
"@tsparticles/engine": "4.3.2",
|
||||
"@tsparticles/preset-links": "4.3.2",
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@vvo/tzdb": "6.198.0",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
"@webcomponents/webcomponentsjs": "2.8.0",
|
||||
"barcode-detector": "3.2.0",
|
||||
"barcode-detector": "3.2.1",
|
||||
"cally": "0.9.2",
|
||||
"color-name": "2.1.0",
|
||||
"comlink": "4.4.2",
|
||||
@@ -102,12 +102,12 @@
|
||||
"dialog-polyfill": "0.5.6",
|
||||
"echarts": "6.1.0",
|
||||
"element-internals-polyfill": "3.0.2",
|
||||
"fuse.js": "7.4.2",
|
||||
"fuse.js": "7.5.0",
|
||||
"gulp-zopfli-green": "7.0.0",
|
||||
"hls.js": "1.6.16",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.2.6",
|
||||
"intl-messageformat": "11.2.9",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "11.2.11",
|
||||
"js-yaml": "5.2.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
@@ -115,7 +115,7 @@
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"luxon": "3.7.2",
|
||||
"marked": "18.0.5",
|
||||
"marked": "18.0.6",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.4",
|
||||
"object-hash": "3.0.0",
|
||||
@@ -145,14 +145,14 @@
|
||||
"@babel/preset-env": "8.0.2",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.2",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@html-eslint/eslint-plugin": "0.63.0",
|
||||
"@lokalise/node-api": "16.0.0",
|
||||
"@html-eslint/eslint-plugin": "0.64.0",
|
||||
"@lokalise/node-api": "16.1.0",
|
||||
"@octokit/auth-oauth-device": "8.0.3",
|
||||
"@octokit/plugin-retry": "8.1.0",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.61.1",
|
||||
"@rsdoctor/rspack-plugin": "1.5.17",
|
||||
"@rspack/core": "2.1.2",
|
||||
"@rsdoctor/rspack-plugin": "1.5.18",
|
||||
"@rspack/core": "2.1.3",
|
||||
"@rspack/dev-server": "2.1.0",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
@@ -168,12 +168,13 @@
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
"@vitest/coverage-v8": "4.1.9",
|
||||
"@typescript/native": "npm:typescript@7.0.2",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-polyfill-corejs3": "1.0.0",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"del": "8.0.1",
|
||||
"eslint": "10.6.0",
|
||||
"eslint": "10.7.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
@@ -202,17 +203,17 @@
|
||||
"map-stream": "0.0.7",
|
||||
"minify-literals": "2.1.0",
|
||||
"pinst": "3.0.0",
|
||||
"prettier": "3.9.4",
|
||||
"prettier": "3.9.5",
|
||||
"rspack-manifest-plugin": "5.2.2",
|
||||
"serve": "14.2.6",
|
||||
"sinon": "22.0.0",
|
||||
"tar": "7.5.19",
|
||||
"tar": "7.5.20",
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.62.1",
|
||||
"typescript-eslint": "8.64.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.9",
|
||||
"vitest": "4.1.10",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
"webpackbar": "7.0.0",
|
||||
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
|
||||
@@ -228,7 +229,7 @@
|
||||
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
|
||||
"glob@^10.2.2": "^10.5.0"
|
||||
},
|
||||
"packageManager": "yarn@4.17.0",
|
||||
"packageManager": "yarn@4.17.1",
|
||||
"volta": {
|
||||
"node": "24.18.0"
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 8.8 KiB |
@@ -1,5 +1,7 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { ContextEvent } from "@lit/context";
|
||||
import type { Context, ContextCallback } from "@lit/context";
|
||||
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
|
||||
import type { ReactiveController, ReactiveElement } from "lit";
|
||||
import type {
|
||||
HomeAssistant,
|
||||
HomeAssistantInternationalization,
|
||||
@@ -49,8 +51,86 @@ export const preserveUnchangedEntityStatesRecord = <
|
||||
return previous;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reactive controller that subscribes to a Lit context and assigns each
|
||||
* delivered value to a host property — WITHOUT forcing a host update on every
|
||||
* delivery.
|
||||
*
|
||||
* `@lit/context`'s built-in `ContextConsumer` calls `host.requestUpdate()`
|
||||
* unconditionally on every provider notification. For a hot context such as
|
||||
* `statesContext` (replaced on every entity state change) that means every
|
||||
* consumer runs an (often empty) update/render cycle on every unrelated state
|
||||
* change, even when the value it actually reads is unchanged.
|
||||
*
|
||||
* This controller instead leaves update scheduling to the property's own
|
||||
* setter. Combined with {@link transform}, that setter only requests an update
|
||||
* when the *selected* value changes (Lit gates `requestUpdate(key, oldValue)`
|
||||
* with `hasChanged`), so unrelated context churn no longer triggers renders.
|
||||
*/
|
||||
class ContextSubscriptionController<ValueType> implements ReactiveController {
|
||||
private _unsubscribe?: () => void;
|
||||
|
||||
constructor(
|
||||
private readonly _host: ReactiveElement,
|
||||
private readonly _context: Context<unknown, ValueType>,
|
||||
private readonly _assign: (value: ValueType) => void
|
||||
) {
|
||||
this._host.addController(this);
|
||||
}
|
||||
|
||||
public hostConnected(): void {
|
||||
this._host.dispatchEvent(
|
||||
new ContextEvent(this._context, this._host, this._callback, true)
|
||||
);
|
||||
}
|
||||
|
||||
public hostDisconnected(): void {
|
||||
this._unsubscribe?.();
|
||||
this._unsubscribe = undefined;
|
||||
}
|
||||
|
||||
// Class field arrow function so the identity is stable per instance, which the
|
||||
// provider's subscription bookkeeping and `ContextRoot` deduping rely on.
|
||||
private readonly _callback: ContextCallback<ValueType> = (
|
||||
value,
|
||||
unsubscribe
|
||||
) => {
|
||||
// A different provider answered (e.g. re-parenting); drop the stale one.
|
||||
if (this._unsubscribe && this._unsubscribe !== unsubscribe) {
|
||||
this._unsubscribe();
|
||||
}
|
||||
this._unsubscribe = unsubscribe;
|
||||
// Assign through the property setter, which decides — via `hasChanged` —
|
||||
// whether an update is actually needed. We intentionally never call
|
||||
// `host.requestUpdate()` here.
|
||||
this._assign(value);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `@consume({ subscribe: true })` from `@lit/context`, but does not force a
|
||||
* host update on every provider notification — see
|
||||
* {@link ContextSubscriptionController}. Pair with {@link transform} so an
|
||||
* update is scheduled only when the derived value actually changes.
|
||||
*/
|
||||
const subscribeContext =
|
||||
<ValueType>(context: Context<unknown, ValueType>) =>
|
||||
(proto: object, propertyKey: string): void => {
|
||||
(proto.constructor as unknown as typeof ReactiveElement).addInitializer(
|
||||
(host) => {
|
||||
new ContextSubscriptionController<ValueType>(
|
||||
host as ReactiveElement,
|
||||
context,
|
||||
(value) => {
|
||||
(host as unknown as Record<string, unknown>)[propertyKey] = value;
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const composeDecorator = <T, V>(
|
||||
context: Parameters<typeof consume>[0]["context"],
|
||||
context: Context<unknown, T>,
|
||||
watchKey: string | undefined,
|
||||
select: (this: unknown, value: T) => V | undefined
|
||||
) => {
|
||||
@@ -60,7 +140,7 @@ const composeDecorator = <T, V>(
|
||||
},
|
||||
watch: watchKey ? [watchKey] : [],
|
||||
});
|
||||
const consumeDec = consume<any>({ context, subscribe: true });
|
||||
const consumeDec = subscribeContext<T>(context);
|
||||
return (proto: any, propertyKey: string) => {
|
||||
transformDec(proto, propertyKey);
|
||||
consumeDec(proto, propertyKey);
|
||||
@@ -124,10 +204,7 @@ export const consumeEntityStates = (config: ConsumeEntryConfig) => {
|
||||
},
|
||||
watch: watchKey ? [watchKey] : [],
|
||||
});
|
||||
const consumeDec = consume<any>({
|
||||
context: statesContext,
|
||||
subscribe: true,
|
||||
});
|
||||
const consumeDec = subscribeContext<HassEntities>(statesContext);
|
||||
transformDec(proto as never, propertyKey);
|
||||
consumeDec(proto as never, propertyKey);
|
||||
};
|
||||
@@ -151,7 +228,7 @@ export const consumeEntityRegistryEntry = (config: ConsumeEntryConfig) =>
|
||||
/**
|
||||
* Consumes `internationalizationContext` and narrows it to the `localize`
|
||||
* function. No host watching is needed — the decorated property updates
|
||||
* whenever the i18n context changes.
|
||||
* whenever `localize` changes.
|
||||
*/
|
||||
export const consumeLocalize = () =>
|
||||
composeDecorator<HomeAssistantInternationalization, LocalizeFunc>(
|
||||
|
||||
@@ -51,8 +51,9 @@ class StorageClass {
|
||||
storageKey: string,
|
||||
callback: Callback
|
||||
): UnsubscribeFunc {
|
||||
if (this._listeners[storageKey]) {
|
||||
this._listeners[storageKey].push(callback);
|
||||
const listeners = this._listeners[storageKey];
|
||||
if (listeners) {
|
||||
listeners.push(callback);
|
||||
} else {
|
||||
this._listeners[storageKey] = [callback];
|
||||
}
|
||||
|
||||
@@ -64,6 +64,23 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
const replace = options?.replace || false;
|
||||
|
||||
if (__DEMO__) {
|
||||
if (path.includes("#")) {
|
||||
if (replace) {
|
||||
mainWindow.history.replaceState(
|
||||
mainWindow.history.state?.root
|
||||
? { root: true }
|
||||
: (options?.data ?? null),
|
||||
"",
|
||||
path
|
||||
);
|
||||
} else {
|
||||
mainWindow.history.pushState(options?.data ?? null, "", path);
|
||||
}
|
||||
fireEvent(mainWindow, "location-changed", {
|
||||
replace,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (replace) {
|
||||
mainWindow.history.replaceState(
|
||||
mainWindow.history.state?.root
|
||||
|
||||
@@ -1,4 +1,53 @@
|
||||
const regexp =
|
||||
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
// Unanchored regex fragments, shared as the single source of truth for both
|
||||
// the boolean validators below and the HTML `pattern` attribute (the browser
|
||||
// anchors a pattern as `^(?:…)$`).
|
||||
const IPV4 =
|
||||
"(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)";
|
||||
|
||||
export const isIPAddress = (input: string): boolean => regexp.test(input);
|
||||
const IPV6 =
|
||||
"(?:([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))";
|
||||
|
||||
// CIDR prefix lengths: 0-32 for IPv4, 0-128 for IPv6.
|
||||
const IPV4_PREFIX = "(?:3[0-2]|[12]?[0-9])";
|
||||
const IPV6_PREFIX = "(?:12[0-8]|1[01][0-9]|[1-9]?[0-9])";
|
||||
|
||||
// IPv4 or IPv6 address.
|
||||
export const IP_ADDRESS_PATTERN = `${IPV4}|${IPV6}`;
|
||||
|
||||
// IPv4/IPv6 address, optionally with a CIDR prefix (network).
|
||||
export const IP_ADDRESS_OR_NETWORK_PATTERN = `${IPV4}(?:/${IPV4_PREFIX})?|${IPV6}(?:/${IPV6_PREFIX})?`;
|
||||
|
||||
const anchored = (pattern: string): RegExp => new RegExp(`^(?:${pattern})$`);
|
||||
|
||||
const ipv4Regexp = anchored(IPV4);
|
||||
const ipv6Regexp = anchored(IPV6);
|
||||
|
||||
// IPv4 address, e.g. 192.168.1.10
|
||||
export const isIPAddress = (input: string): boolean => ipv4Regexp.test(input);
|
||||
|
||||
// IPv6 address, e.g. fe80::85d:e82c:9446:7995
|
||||
export const isIPv6Address = (input: string): boolean => ipv6Regexp.test(input);
|
||||
|
||||
// IPv4 or IPv6 address
|
||||
export const isIPAddressV4OrV6 = (input: string): boolean =>
|
||||
isIPAddress(input) || isIPv6Address(input);
|
||||
|
||||
// IP network in CIDR notation, e.g. 192.168.1.0/24 or fd00::/8
|
||||
export const isIPNetwork = (input: string): boolean => {
|
||||
const parts = input.split("/");
|
||||
if (parts.length !== 2) {
|
||||
return false;
|
||||
}
|
||||
const [address, prefix] = parts;
|
||||
if (!/^\d{1,3}$/.test(prefix)) {
|
||||
return false;
|
||||
}
|
||||
const prefixLength = Number(prefix);
|
||||
if (isIPAddress(address)) {
|
||||
return prefixLength >= 0 && prefixLength <= 32;
|
||||
}
|
||||
if (isIPv6Address(address)) {
|
||||
return prefixLength >= 0 && prefixLength <= 128;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -33,13 +33,12 @@ const extractVarFromBase = (
|
||||
varName: string,
|
||||
baseVars: Record<string, string>
|
||||
): string | undefined => {
|
||||
if (baseVars[varName] && baseVars[varName].startsWith("var(")) {
|
||||
const baseVarName = baseVars[varName]
|
||||
.substring(6, baseVars[varName].length - 1)
|
||||
.trim();
|
||||
const value = baseVars[varName];
|
||||
if (value && value.startsWith("var(")) {
|
||||
const baseVarName = value.substring(6, value.length - 1).trim();
|
||||
return extractVarFromBase(baseVarName, baseVars);
|
||||
}
|
||||
return baseVars[varName];
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const constructUrlCurrentPath = (searchParams: string): string => {
|
||||
const base = window.location.pathname;
|
||||
const hash = __DEMO__ ? window.location.hash : "";
|
||||
// Prevent trailing "?" if no parameters exist
|
||||
return searchParams ? base + "?" + searchParams : base;
|
||||
return `${searchParams ? `${base}?${searchParams}` : base}${hash}`;
|
||||
};
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import type { BarSeriesOption } from "echarts/types/dist/shared";
|
||||
|
||||
/**
|
||||
* `extraBuckets` (only used when `stacked`) seeds the bucket union with the
|
||||
* expected statistics grid so sparse datasets get zero-filled across the whole
|
||||
* range, including past their last real point. Without it, buckets are only
|
||||
* derived from the data and trailing buckets are never filled (legacy
|
||||
* behavior, kept for callers that don't pass a grid).
|
||||
*/
|
||||
export function fillDataGapsAndRoundCaps(
|
||||
datasets: BarSeriesOption[],
|
||||
stacked = true
|
||||
stacked = true,
|
||||
extraBuckets?: number[]
|
||||
) {
|
||||
if (!stacked) {
|
||||
// For non-stacked charts, we can simply apply an overall border to each stack
|
||||
@@ -44,6 +52,7 @@ export function fillDataGapsAndRoundCaps(
|
||||
dataset.data!.map((datapoint) => Number(datapoint![0]))
|
||||
)
|
||||
.flat()
|
||||
.concat(extraBuckets ?? [])
|
||||
)
|
||||
).sort((a, b) => a - b);
|
||||
|
||||
@@ -61,9 +70,18 @@ export function fillDataGapsAndRoundCaps(
|
||||
const x = item.value?.[0];
|
||||
const stack = datasets[i].stack ?? "";
|
||||
if (x === undefined) {
|
||||
continue;
|
||||
// Past the end of this dataset's data. Only append trailing buckets
|
||||
// when an explicit grid was provided; originally-empty datasets
|
||||
// (e.g. compare placeholders) stay empty either way.
|
||||
if (
|
||||
dataPoint !== undefined ||
|
||||
extraBuckets === undefined ||
|
||||
!datasets[i].data!.length
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (Number(x) !== bucket) {
|
||||
if (x === undefined || Number(x) !== bucket) {
|
||||
datasets[i].data?.splice(index, 0, {
|
||||
value: [bucket, 0],
|
||||
itemStyle: {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
CLIMATE_MODE_CONFIGS,
|
||||
generateStateHistoryChartLineData,
|
||||
} from "./state-history-chart-line-data";
|
||||
import { createYAxisPrecisionBounds } from "./y-axis-fraction-digits";
|
||||
import type { HaECOption } from "../../resources/echarts/echarts";
|
||||
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
|
||||
import {
|
||||
@@ -28,6 +29,10 @@ import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
|
||||
import { computeAttributeValueDisplay } from "../../common/entity/compute_attribute_display";
|
||||
|
||||
// Minimum width reserved for the Y-axis labels; also the value _yWidth is
|
||||
// re-measured up from whenever the tick precision changes on zoom.
|
||||
const MIN_Y_AXIS_WIDTH = 25;
|
||||
|
||||
// Used to recover the underlying entity_id from a legend dataset id.
|
||||
// Kept in sync with the suffixes appended at dataset construction below
|
||||
// for climate / water_heater / humidifier multi-attribute charts.
|
||||
@@ -101,7 +106,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
|
||||
private _hiddenStats = new Set<string>();
|
||||
|
||||
@state() private _yWidth = 25;
|
||||
@state() private _yWidth = MIN_Y_AXIS_WIDTH;
|
||||
|
||||
@state() private _visualMap?: VisualMapComponentOption[];
|
||||
|
||||
@@ -318,8 +323,18 @@ export class StateHistoryChartLine extends LitElement {
|
||||
yAxis: {
|
||||
type: this.logarithmicScale ? "log" : "value",
|
||||
name: this.unit,
|
||||
min: this._clampYAxis(minYAxis),
|
||||
max: this._clampYAxis(maxYAxis),
|
||||
...createYAxisPrecisionBounds({
|
||||
min: this._clampYAxis(minYAxis),
|
||||
max: this._clampYAxis(maxYAxis),
|
||||
onFractionDigits: (digits) => {
|
||||
if (digits !== this._yAxisFractionDigits) {
|
||||
this._yAxisFractionDigits = digits;
|
||||
// Re-measure the gutter for the new precision so it can shrink
|
||||
// again when zooming back out (_yWidth otherwise only grows).
|
||||
this._yWidth = 0;
|
||||
}
|
||||
},
|
||||
}),
|
||||
position: rtl ? "right" : "left",
|
||||
scale: true,
|
||||
nameGap: 2,
|
||||
@@ -448,7 +463,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
minimumFractionDigits: value === 0 ? 0 : this._yAxisFractionDigits,
|
||||
maximumFractionDigits: this._yAxisFractionDigits,
|
||||
});
|
||||
const width = measureTextWidth(label, 12) + 5;
|
||||
const width = Math.max(measureTextWidth(label, 12) + 5, MIN_Y_AXIS_WIDTH);
|
||||
if (width > this._yWidth) {
|
||||
this._yWidth = width;
|
||||
fireEvent(this, "y-width-changed", {
|
||||
|
||||
@@ -34,6 +34,7 @@ import "./ha-chart-base";
|
||||
import { sideTooltipPosition } from "./chart-tooltip-position";
|
||||
import "./ha-chart-tooltip-marker";
|
||||
import { generateStatisticsChartData } from "./statistics-chart-data";
|
||||
import { createYAxisPrecisionBounds } from "./y-axis-fraction-digits";
|
||||
|
||||
export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
|
||||
mean: "mean",
|
||||
@@ -391,6 +392,11 @@ export class StatisticsChart extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
const yAxisScale =
|
||||
this.chartType.startsWith("line") ||
|
||||
this.logarithmicScale ||
|
||||
minYAxis !== undefined ||
|
||||
maxYAxis !== undefined;
|
||||
this._chartOptions = {
|
||||
xAxis: [
|
||||
{
|
||||
@@ -434,13 +440,17 @@ export class StatisticsChart extends LitElement {
|
||||
)
|
||||
? "right"
|
||||
: "left",
|
||||
scale:
|
||||
this.chartType.startsWith("line") ||
|
||||
this.logarithmicScale ||
|
||||
minYAxis !== undefined ||
|
||||
maxYAxis !== undefined,
|
||||
min: this._clampYAxis(minYAxis),
|
||||
max: this._clampYAxis(maxYAxis),
|
||||
scale: yAxisScale,
|
||||
...createYAxisPrecisionBounds({
|
||||
min: this._clampYAxis(minYAxis),
|
||||
max: this._clampYAxis(maxYAxis),
|
||||
// Bar charts stay anchored at 0, so precision must reflect the
|
||||
// 0-based range that is actually rendered.
|
||||
includeZero: !yAxisScale,
|
||||
onFractionDigits: (digits) => {
|
||||
this._yAxisFractionDigits = digits;
|
||||
},
|
||||
}),
|
||||
splitLine: {
|
||||
show: true,
|
||||
},
|
||||
|
||||
@@ -1,9 +1,66 @@
|
||||
// Derive the number of decimal digits to use for Y-axis labels from the
|
||||
// observed data range. We estimate the tick interval as `range / 10` (twice
|
||||
// ECharts' default splitNumber of 5, as a safety margin against finer "nice"
|
||||
// intervals), then derive `ceil(-log10(interval))`.
|
||||
// observed data range. We mirror how ECharts sizes its ticks: it splits the
|
||||
// range into ~5 intervals (its default `splitNumber`) and rounds that raw
|
||||
// interval to a "nice" 1/2/3/5×10ⁿ value, then reports the decimals that nice
|
||||
// interval needs. This matches the precision ECharts actually renders, so
|
||||
// labels are neither truncated to identical values nor padded with extra zeros.
|
||||
export function computeYAxisFractionDigits(min: number, max: number): number {
|
||||
const range = max - min;
|
||||
if (!Number.isFinite(range) || range <= 0) return 1;
|
||||
return Math.max(0, Math.ceil(-Math.log10(range / 10)));
|
||||
const rawInterval = range / 5;
|
||||
const exponent = Math.floor(Math.log10(rawInterval));
|
||||
const mantissa = rawInterval / 10 ** exponent; // in [1, 10)
|
||||
// Rounding the mantissa to a nice value only ever carries to the next power
|
||||
// of ten (mantissa ≥ 7 → 10), which needs one fewer decimal.
|
||||
const niceExponent = mantissa >= 7 ? exponent + 1 : exponent;
|
||||
return Math.max(0, -niceExponent);
|
||||
}
|
||||
|
||||
interface YAxisExtentValues {
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
type YAxisBound =
|
||||
number | ((values: YAxisExtentValues) => number | undefined) | undefined;
|
||||
|
||||
const resolveYAxisBound = (
|
||||
bound: YAxisBound,
|
||||
values: YAxisExtentValues
|
||||
): number | undefined => (typeof bound === "function" ? bound(values) : bound);
|
||||
|
||||
// Wrap the Y-axis `min`/`max` options in callbacks so the tick-label precision
|
||||
// tracks the currently visible axis extent. ECharts re-invokes these callbacks
|
||||
// with the extent of the visible (zoom-filtered) data on every dataZoom, and
|
||||
// always before the label formatter runs, so recomputing the fraction digits
|
||||
// here keeps zoomed-in labels distinct. The callbacks return the original
|
||||
// bounds unchanged, so auto-scaling still applies when a bound is not set.
|
||||
export function createYAxisPrecisionBounds(options: {
|
||||
min?: YAxisBound;
|
||||
max?: YAxisBound;
|
||||
// Axes without `scale: true` (e.g. bar charts) stay anchored at 0, so the
|
||||
// rendered ticks span from 0 even when the data does not. Union the extent
|
||||
// with 0 to match the labels ECharts actually draws.
|
||||
includeZero?: boolean;
|
||||
onFractionDigits: (digits: number) => void;
|
||||
}): {
|
||||
min: (values: YAxisExtentValues) => number | undefined;
|
||||
max: (values: YAxisExtentValues) => number | undefined;
|
||||
} {
|
||||
const { min, max, includeZero, onFractionDigits } = options;
|
||||
return {
|
||||
min: (values) => {
|
||||
const resolvedMin = resolveYAxisBound(min, values);
|
||||
const resolvedMax = resolveYAxisBound(max, values);
|
||||
let extentMin = resolvedMin ?? values.min;
|
||||
let extentMax = resolvedMax ?? values.max;
|
||||
if (includeZero) {
|
||||
extentMin = Math.min(extentMin, 0);
|
||||
extentMax = Math.max(extentMax, 0);
|
||||
}
|
||||
onFractionDigits(computeYAxisFractionDigits(extentMin, extentMax));
|
||||
return resolvedMin;
|
||||
},
|
||||
max: (values) => resolveYAxisBound(max, values),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { mdiCalendar } from "@mdi/js";
|
||||
import "cally";
|
||||
import { isThisYear } from "date-fns";
|
||||
import type { HassConfig } from "home-assistant-js-websocket/dist/types";
|
||||
import type { TemplateResult } from "lit";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { tinykeys } from "tinykeys";
|
||||
@@ -112,17 +112,28 @@ export class HaDateRangePicker extends LitElement {
|
||||
|
||||
this._handleResize();
|
||||
window.addEventListener("resize", this._handleResize);
|
||||
}
|
||||
|
||||
const rangeKeys = this.extendedPresets
|
||||
? [...RANGE_KEYS, ...EXTENDED_RANGE_KEYS]
|
||||
: RANGE_KEYS;
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
this._ranges = {};
|
||||
rangeKeys.forEach((key) => {
|
||||
this._ranges![
|
||||
this._i18n.localize(`ui.components.date-range-picker.ranges.${key}`)
|
||||
] = calcDateRange(this._i18n.locale, this._hassConfig, key);
|
||||
});
|
||||
if (
|
||||
changedProps.has("_i18n") ||
|
||||
changedProps.has("_hassConfig") ||
|
||||
changedProps.has("extendedPresets")
|
||||
) {
|
||||
const rangeKeys = this.extendedPresets
|
||||
? [...RANGE_KEYS, ...EXTENDED_RANGE_KEYS]
|
||||
: RANGE_KEYS;
|
||||
|
||||
const ranges: DateRangePickerRanges = {};
|
||||
rangeKeys.forEach((key) => {
|
||||
ranges[
|
||||
this._i18n.localize(`ui.components.date-range-picker.ranges.${key}`)
|
||||
] = calcDateRange(this._i18n.locale, this._hassConfig, key);
|
||||
});
|
||||
this._ranges = ranges;
|
||||
}
|
||||
}
|
||||
|
||||
public open(): void {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { consume } from "@lit/context";
|
||||
import type { HassEntities } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
@@ -15,7 +16,12 @@ import {
|
||||
} from "../../data/device/device_automation";
|
||||
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
import type { CallWS, HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import "../ha-combo-box-item";
|
||||
import "../ha-generic-picker";
|
||||
import {
|
||||
DEFAULT_ROW_RENDERER_CONTENT,
|
||||
type PickerComboBoxItem,
|
||||
} from "../ha-picker-combo-box";
|
||||
import type { PickerValueRenderer } from "../ha-picker-field";
|
||||
|
||||
const NO_AUTOMATION_KEY = "NO_AUTOMATION";
|
||||
@@ -105,6 +111,7 @@ export abstract class HaDeviceAutomationPicker<
|
||||
.disabled=${!this._automations || this._automations.length === 0}
|
||||
.getItems=${this._getItems(value, this._automations)}
|
||||
@value-changed=${this._automationChanged}
|
||||
.rowRenderer=${this._rowRenderer}
|
||||
.valueRenderer=${this._valueRenderer}
|
||||
.unknownItemText=${this.hass.localize(
|
||||
"ui.panel.config.devices.automation.actions.unknown_action"
|
||||
@@ -160,6 +167,13 @@ export abstract class HaDeviceAutomationPicker<
|
||||
}
|
||||
);
|
||||
|
||||
// Device automation labels (entity name + subtype) are often longer than the
|
||||
// field, so let the option wrap onto multiple lines instead of truncating.
|
||||
private _rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) =>
|
||||
html`<ha-combo-box-item type="button" compact multiline>
|
||||
${DEFAULT_ROW_RENDERER_CONTENT(item)}
|
||||
</ha-combo-box-item>`;
|
||||
|
||||
private _valueRenderer: PickerValueRenderer = (value: string) => {
|
||||
const automation = this._automations?.find(
|
||||
(a, idx) => value === `${a.device_id}_${idx}`
|
||||
|
||||
@@ -0,0 +1,579 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import { resolveTimeZone } from "../common/datetime/resolve-time-zone";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { configContext, internationalizationContext } from "../data/context";
|
||||
import {
|
||||
CLOCK_CARD_DATE_PARTS,
|
||||
formatClockCardDate,
|
||||
} from "../panels/lovelace/cards/clock/clock-date-format";
|
||||
import type { ClockCardDatePart } from "../panels/lovelace/cards/types";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
|
||||
import "./chips/ha-assist-chip";
|
||||
import "./chips/ha-chip-set";
|
||||
import "./chips/ha-input-chip";
|
||||
import "./ha-generic-picker";
|
||||
import type { HaGenericPicker } from "./ha-generic-picker";
|
||||
import "./ha-input-helper-text";
|
||||
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
|
||||
import "./ha-sortable";
|
||||
|
||||
type ClockDatePartSection = "weekday" | "day" | "month" | "year" | "separator";
|
||||
|
||||
type ClockDateSeparatorPart = Extract<
|
||||
ClockCardDatePart,
|
||||
"separator-dash" | "separator-slash" | "separator-dot" | "separator-new-line"
|
||||
>;
|
||||
|
||||
const CLOCK_DATE_PART_SECTION_ORDER: readonly ClockDatePartSection[] = [
|
||||
"day",
|
||||
"month",
|
||||
"year",
|
||||
"weekday",
|
||||
"separator",
|
||||
];
|
||||
|
||||
const CLOCK_DATE_SEPARATOR_VALUES: Record<ClockDateSeparatorPart, string> = {
|
||||
"separator-dash": "-",
|
||||
"separator-slash": "/",
|
||||
"separator-dot": ".",
|
||||
"separator-new-line": "",
|
||||
};
|
||||
|
||||
const getClockDatePartSection = (
|
||||
part: ClockCardDatePart
|
||||
): ClockDatePartSection => {
|
||||
if (part.startsWith("weekday-")) {
|
||||
return "weekday";
|
||||
}
|
||||
|
||||
if (part.startsWith("day-")) {
|
||||
return "day";
|
||||
}
|
||||
|
||||
if (part.startsWith("month-")) {
|
||||
return "month";
|
||||
}
|
||||
|
||||
if (part.startsWith("year-")) {
|
||||
return "year";
|
||||
}
|
||||
|
||||
return "separator";
|
||||
};
|
||||
|
||||
interface ClockDatePartSectionData {
|
||||
id: ClockDatePartSection;
|
||||
title: string;
|
||||
items: PickerComboBoxItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out sections whose group already has a value in `value`, so only
|
||||
* one non-separator value per group can be selected in the picker. The whole
|
||||
* group of the item at `excludeIndex` (the one being edited) stays
|
||||
* selectable, even if that group has more than one value in `value` (e.g. a
|
||||
* pre-existing config authored outside the picker via YAML).
|
||||
* The separator group is always kept.
|
||||
*/
|
||||
export const getAvailableClockDatePartSections = (
|
||||
sections: ClockDatePartSectionData[],
|
||||
value: string[],
|
||||
excludeIndex?: number
|
||||
): ClockDatePartSectionData[] => {
|
||||
const editedItem = excludeIndex != null ? value[excludeIndex] : undefined;
|
||||
const editedSection = editedItem
|
||||
? getClockDatePartSection(editedItem as ClockCardDatePart)
|
||||
: undefined;
|
||||
|
||||
const usedSections = new Set<ClockDatePartSection>();
|
||||
|
||||
value.forEach((item) => {
|
||||
const section = getClockDatePartSection(item as ClockCardDatePart);
|
||||
|
||||
if (section !== "separator" && section !== editedSection) {
|
||||
usedSections.add(section);
|
||||
}
|
||||
});
|
||||
|
||||
return sections.filter(
|
||||
(sectionData) =>
|
||||
sectionData.id === "separator" || !usedSections.has(sectionData.id)
|
||||
);
|
||||
};
|
||||
|
||||
interface ClockDatePartValueItem {
|
||||
key: string;
|
||||
item: string;
|
||||
idx: number;
|
||||
}
|
||||
|
||||
@customElement("ha-clock-date-format-picker")
|
||||
export class HaClockDateFormatPicker extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean }) public required = false;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public value?: string[] | string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _hassConfig!: ContextType<typeof configContext>;
|
||||
|
||||
@query("ha-generic-picker", true) private _picker?: HaGenericPicker;
|
||||
|
||||
@state() private _editIndex?: number;
|
||||
|
||||
protected render() {
|
||||
const value = this._value;
|
||||
const valueItems = this._getValueItems(value);
|
||||
const sections = this._buildSections();
|
||||
const pickerSections = getAvailableClockDatePartSections(
|
||||
sections,
|
||||
value,
|
||||
this._editIndex
|
||||
);
|
||||
|
||||
return html`
|
||||
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required && !value.length}
|
||||
.value=${this._getPickerValue()}
|
||||
.sections=${this._getSectionHeaders(pickerSections)}
|
||||
.getItems=${this._getItems(pickerSections)}
|
||||
@value-changed=${this._pickerValueChanged}
|
||||
>
|
||||
<div slot="field" class="container">
|
||||
<ha-sortable
|
||||
no-style
|
||||
@item-moved=${this._moveItem}
|
||||
.disabled=${this.disabled}
|
||||
handle-selector="button.primary.action"
|
||||
filter=".add"
|
||||
>
|
||||
<ha-chip-set>
|
||||
${repeat(
|
||||
valueItems,
|
||||
(entry: ClockDatePartValueItem) => entry.key,
|
||||
({ item, idx }) => this._renderValueChip(item, idx, sections)
|
||||
)}
|
||||
${
|
||||
this.disabled
|
||||
? nothing
|
||||
: html`
|
||||
<ha-assist-chip
|
||||
@click=${this._addItem}
|
||||
.disabled=${this.disabled}
|
||||
label=${this._i18n.localize("ui.common.add")}
|
||||
class="add"
|
||||
>
|
||||
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
|
||||
</ha-assist-chip>
|
||||
`
|
||||
}
|
||||
</ha-chip-set>
|
||||
</ha-sortable>
|
||||
</div>
|
||||
</ha-generic-picker>
|
||||
${this._renderHelper()}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderHelper() {
|
||||
return this.helper
|
||||
? html`
|
||||
<ha-input-helper-text .disabled=${this.disabled}>
|
||||
${this.helper}
|
||||
</ha-input-helper-text>
|
||||
`
|
||||
: nothing;
|
||||
}
|
||||
|
||||
private _getValueItems = memoizeOne(
|
||||
(value: string[]): ClockDatePartValueItem[] => {
|
||||
const occurrences = new Map<string, number>();
|
||||
|
||||
return value.map((item, idx) => {
|
||||
const occurrence = occurrences.get(item) ?? 0;
|
||||
occurrences.set(item, occurrence + 1);
|
||||
|
||||
return {
|
||||
key: `${item}:${occurrence}`,
|
||||
item,
|
||||
idx,
|
||||
};
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
private _renderValueChip(
|
||||
item: string,
|
||||
idx: number,
|
||||
sections: ClockDatePartSectionData[]
|
||||
) {
|
||||
const label = this._getItemLabel(item, sections);
|
||||
const isValid = !!label;
|
||||
|
||||
return html`
|
||||
<ha-input-chip
|
||||
data-idx=${idx}
|
||||
@remove=${this._removeItem}
|
||||
@click=${this._editItem}
|
||||
.label=${label ?? item}
|
||||
.selected=${!this.disabled}
|
||||
.disabled=${this.disabled}
|
||||
class=${!isValid ? "invalid" : ""}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
></ha-svg-icon>
|
||||
</ha-input-chip>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _addItem(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._editIndex = undefined;
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
}
|
||||
|
||||
private async _editItem(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const idx = parseInt(
|
||||
(ev.currentTarget as HTMLElement).dataset.idx ?? "",
|
||||
10
|
||||
);
|
||||
this._editIndex = idx;
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
}
|
||||
|
||||
private get _value() {
|
||||
return !this.value ? [] : ensureArray(this.value);
|
||||
}
|
||||
|
||||
private _toValue = memoizeOne((value: string[]): string[] | undefined =>
|
||||
value.length === 0 ? undefined : value
|
||||
);
|
||||
|
||||
private _buildSections(): ClockDatePartSectionData[] {
|
||||
const itemsBySection: Record<ClockDatePartSection, PickerComboBoxItem[]> = {
|
||||
weekday: [],
|
||||
day: [],
|
||||
month: [],
|
||||
year: [],
|
||||
separator: [],
|
||||
};
|
||||
|
||||
const previewDate = new Date();
|
||||
const previewTimeZone = resolveTimeZone(
|
||||
this._i18n.locale.time_zone,
|
||||
this._hassConfig.config.time_zone
|
||||
);
|
||||
|
||||
CLOCK_CARD_DATE_PARTS.forEach((part) => {
|
||||
const section = getClockDatePartSection(part);
|
||||
const label =
|
||||
this._i18n.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.date.parts.${part}`
|
||||
) ?? part;
|
||||
|
||||
const secondary =
|
||||
section === "separator"
|
||||
? CLOCK_DATE_SEPARATOR_VALUES[part as ClockDateSeparatorPart]
|
||||
: formatClockCardDate(
|
||||
previewDate,
|
||||
{ parts: [part] },
|
||||
this._i18n.locale.language,
|
||||
previewTimeZone
|
||||
);
|
||||
|
||||
itemsBySection[section].push({
|
||||
id: part,
|
||||
primary: label,
|
||||
secondary,
|
||||
sorting_label: label,
|
||||
});
|
||||
});
|
||||
|
||||
return CLOCK_DATE_PART_SECTION_ORDER.map((section) => ({
|
||||
id: section,
|
||||
title:
|
||||
this._i18n.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.date.sections.${section}`
|
||||
) ?? section,
|
||||
items: itemsBySection[section],
|
||||
})).filter((section) => section.items.length > 0);
|
||||
}
|
||||
|
||||
private _getSectionHeaders(
|
||||
sections: ClockDatePartSectionData[]
|
||||
): { id: string; label: string }[] {
|
||||
return sections.map((section) => ({
|
||||
id: section.id,
|
||||
label: section.title,
|
||||
}));
|
||||
}
|
||||
|
||||
private _getItems = memoizeOne(
|
||||
(sections: ClockDatePartSectionData[]) =>
|
||||
(
|
||||
searchString?: string,
|
||||
section?: string
|
||||
): (PickerComboBoxItem | string)[] => {
|
||||
const normalizedSearch = searchString?.trim().toLowerCase();
|
||||
|
||||
const filteredSections = sections
|
||||
.map((sectionData) => {
|
||||
if (!normalizedSearch) {
|
||||
return sectionData;
|
||||
}
|
||||
|
||||
return {
|
||||
...sectionData,
|
||||
items: sectionData.items.filter(
|
||||
(item) =>
|
||||
item.primary.toLowerCase().includes(normalizedSearch) ||
|
||||
item.secondary?.toLowerCase().includes(normalizedSearch) ||
|
||||
item.id.toLowerCase().includes(normalizedSearch)
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter((sectionData) => sectionData.items.length > 0);
|
||||
|
||||
if (section) {
|
||||
return (
|
||||
filteredSections.find((candidate) => candidate.id === section)
|
||||
?.items || []
|
||||
);
|
||||
}
|
||||
|
||||
const groupedItems: (PickerComboBoxItem | string)[] = [];
|
||||
|
||||
filteredSections.forEach((sectionData) => {
|
||||
groupedItems.push(sectionData.title, ...sectionData.items);
|
||||
});
|
||||
|
||||
return groupedItems;
|
||||
}
|
||||
);
|
||||
|
||||
private _getItemLabel(
|
||||
value: string,
|
||||
sections: ClockDatePartSectionData[]
|
||||
): string | undefined {
|
||||
for (const section of sections) {
|
||||
const item = section.items.find((candidate) => candidate.id === value);
|
||||
|
||||
if (item) {
|
||||
if (section.id === "separator") {
|
||||
if (value === "separator-new-line") {
|
||||
return item.primary;
|
||||
}
|
||||
|
||||
return item.secondary ?? item.primary;
|
||||
}
|
||||
|
||||
return `${item.secondary} [${item.primary} ${section.title}]`;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _getPickerValue(): string | undefined {
|
||||
if (this._editIndex != null) {
|
||||
return this._value[this._editIndex];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async _moveItem(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
const { oldIndex, newIndex } = ev.detail;
|
||||
|
||||
const value = this._value;
|
||||
const newValue = value.concat();
|
||||
const element = newValue.splice(oldIndex, 1)[0];
|
||||
newValue.splice(newIndex, 0, element);
|
||||
|
||||
this._setValue(newValue);
|
||||
}
|
||||
|
||||
private async _removeItem(ev: Event) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
const idx = parseInt(
|
||||
(ev.currentTarget as HTMLElement).dataset.idx ?? "",
|
||||
10
|
||||
);
|
||||
|
||||
if (Number.isNaN(idx)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = [...this._value];
|
||||
value.splice(idx, 1);
|
||||
|
||||
if (this._editIndex !== undefined) {
|
||||
if (this._editIndex === idx) {
|
||||
this._editIndex = undefined;
|
||||
} else if (this._editIndex > idx) {
|
||||
this._editIndex -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
this._setValue(value);
|
||||
}
|
||||
|
||||
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.value;
|
||||
|
||||
if (this.disabled || !value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue = [...this._value];
|
||||
|
||||
if (this._editIndex != null) {
|
||||
newValue[this._editIndex] = value;
|
||||
this._editIndex = undefined;
|
||||
} else {
|
||||
newValue.push(value);
|
||||
}
|
||||
|
||||
this._setValue(newValue);
|
||||
|
||||
if (this._picker) {
|
||||
this._picker.value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _setValue(value: string[]) {
|
||||
const newValue = this._toValue(value);
|
||||
this.value = newValue;
|
||||
|
||||
fireEvent(this, "value-changed", {
|
||||
value: newValue,
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
background-color: var(--mdc-text-field-fill-color, whitesmoke);
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
border-end-end-radius: var(--ha-border-radius-square);
|
||||
border-end-start-radius: var(--ha-border-radius-square);
|
||||
}
|
||||
|
||||
.container:after {
|
||||
display: block;
|
||||
content: "";
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background-color: var(
|
||||
--mdc-text-field-idle-line-color,
|
||||
rgba(0, 0, 0, 0.42)
|
||||
);
|
||||
transition:
|
||||
height 180ms ease-in-out,
|
||||
background-color 180ms ease-in-out;
|
||||
}
|
||||
|
||||
:host([disabled]) .container:after {
|
||||
background-color: var(
|
||||
--mdc-text-field-disabled-line-color,
|
||||
rgba(0, 0, 0, 0.42)
|
||||
);
|
||||
}
|
||||
|
||||
.container:focus-within:after {
|
||||
height: 2px;
|
||||
background-color: var(--mdc-theme-primary);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin: 0 0 var(--ha-space-2);
|
||||
}
|
||||
|
||||
.add {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
ha-chip-set {
|
||||
padding: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.invalid {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.sortable-fallback {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sortable-ghost {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.sortable-drag {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
ha-input-helper-text {
|
||||
display: block;
|
||||
margin: var(--ha-space-2) 0 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-clock-date-format-picker": HaClockDateFormatPicker;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,11 @@ export class HaComboBoxItem extends HaMdListItem {
|
||||
@property({ type: Boolean, reflect: true, attribute: "border-top" })
|
||||
public borderTop = false;
|
||||
|
||||
// Allow the headline/supporting text to wrap onto multiple lines instead of
|
||||
// truncating with an ellipsis. Off by default to preserve single-line rows.
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public multiline = false;
|
||||
|
||||
static override styles = [
|
||||
...haMdListStyles,
|
||||
css`
|
||||
@@ -41,6 +46,10 @@ export class HaComboBoxItem extends HaMdListItem {
|
||||
font-size: var(--ha-font-size-s);
|
||||
white-space: nowrap;
|
||||
}
|
||||
:host([multiline]) [slot="headline"],
|
||||
:host([multiline]) [slot="supporting-text"] {
|
||||
white-space: normal;
|
||||
}
|
||||
::slotted(state-badge),
|
||||
::slotted(img) {
|
||||
width: 32px;
|
||||
|
||||
@@ -135,8 +135,6 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
|
||||
@state()
|
||||
private _bodyScrolled = false;
|
||||
|
||||
private _escapePressed = false;
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.addEventListener(
|
||||
@@ -306,7 +304,6 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
|
||||
|
||||
private _handleKeyDown(ev: KeyboardEvent) {
|
||||
if (ev.key === "Escape") {
|
||||
this._escapePressed = true;
|
||||
if (this.preventScrimClose) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
@@ -316,13 +313,23 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
|
||||
}
|
||||
|
||||
private _handleHide(ev: DialogHideEvent) {
|
||||
const sourceIsDialog = ev.detail?.source === (ev.target as WaDialog).dialog;
|
||||
|
||||
if (this.preventScrimClose && this._escapePressed && sourceIsDialog) {
|
||||
ev.preventDefault();
|
||||
// Ignore wa-hide events bubbling up from nested overlays (pickers,
|
||||
// popovers, bottom sheets, nested dialogs); only handle this dialog's own
|
||||
// hide, like the sibling wa-* handlers above.
|
||||
if (ev.eventPhase !== Event.AT_TARGET) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._escapePressed = false;
|
||||
const sourceIsDialog = ev.detail?.source === (ev.target as WaDialog).dialog;
|
||||
|
||||
// A dialog-sourced hide (Escape, native cancel, or scrim) while the host
|
||||
// still wants the dialog open is a user dismissal, not a programmatic
|
||||
// close. Block it when closing must be guarded, regardless of where focus
|
||||
// currently is — e.g. after a picker overlay took focus and dropped it
|
||||
// outside the dialog, the Escape keydown never reaches this element.
|
||||
if (this.preventScrimClose && sourceIsDialog && this.open) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type {
|
||||
HaFormBaseSchema,
|
||||
HaFormCondition,
|
||||
HaFormDataContainer,
|
||||
HaFormFieldCondition,
|
||||
HaFormSchema,
|
||||
} from "./types";
|
||||
|
||||
const isEmpty = (value: unknown): boolean =>
|
||||
value === undefined || value === null || value === "";
|
||||
|
||||
const matchFieldCondition = (
|
||||
condition: HaFormFieldCondition,
|
||||
data: HaFormDataContainer | undefined
|
||||
): boolean => {
|
||||
const actual = data?.[condition.field];
|
||||
switch (condition.operator ?? "eq") {
|
||||
case "eq":
|
||||
return actual === condition.value;
|
||||
case "not_eq":
|
||||
return actual !== condition.value;
|
||||
case "in":
|
||||
return (
|
||||
Array.isArray(condition.value) &&
|
||||
condition.value.includes(actual as any)
|
||||
);
|
||||
case "not_in":
|
||||
return (
|
||||
Array.isArray(condition.value) &&
|
||||
!condition.value.includes(actual as any)
|
||||
);
|
||||
case "exists":
|
||||
return !isEmpty(actual);
|
||||
case "not_exists":
|
||||
return isEmpty(actual);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const evaluateCondition = (
|
||||
condition: HaFormCondition,
|
||||
data: HaFormDataContainer | undefined
|
||||
): boolean => {
|
||||
if ("condition" in condition) {
|
||||
switch (condition.condition) {
|
||||
case "and":
|
||||
return condition.conditions.every((c) => evaluateCondition(c, data));
|
||||
case "or":
|
||||
return condition.conditions.some((c) => evaluateCondition(c, data));
|
||||
case "not":
|
||||
return !condition.conditions.some((c) => evaluateCondition(c, data));
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return matchFieldCondition(condition, data);
|
||||
};
|
||||
|
||||
export const isFieldHidden = (
|
||||
schema: HaFormSchema,
|
||||
data: HaFormDataContainer | undefined
|
||||
): boolean => {
|
||||
const { hidden } = schema as HaFormBaseSchema;
|
||||
if (!hidden) {
|
||||
return false;
|
||||
}
|
||||
if (hidden === true) {
|
||||
return true;
|
||||
}
|
||||
const conditions = Array.isArray(hidden) ? hidden : [hidden];
|
||||
return conditions.every((condition) => evaluateCondition(condition, data));
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, queryAll } from "lit/decorators";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { isFieldHidden } from "./conditions";
|
||||
import "./ha-form";
|
||||
import type { HaForm } from "./ha-form";
|
||||
import type {
|
||||
@@ -68,19 +69,21 @@ export class HaFormGrid extends LitElement implements HaFormElement {
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${this.schema.schema.map(
|
||||
(item) => html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this.data}
|
||||
.schema=${[item]}
|
||||
.disabled=${this.disabled}
|
||||
.computeLabel=${this.computeLabel}
|
||||
.computeHelper=${this.computeHelper}
|
||||
.localizeValue=${this.localizeValue}
|
||||
></ha-form>
|
||||
`
|
||||
)}
|
||||
${this.schema.schema
|
||||
.filter((item) => !isFieldHidden(item, this.data))
|
||||
.map(
|
||||
(item) => html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this.data}
|
||||
.schema=${[item]}
|
||||
.disabled=${this.disabled}
|
||||
.computeLabel=${this.computeLabel}
|
||||
.computeHelper=${this.computeHelper}
|
||||
.localizeValue=${this.localizeValue}
|
||||
></ha-form>
|
||||
`
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-alert";
|
||||
import "../ha-selector/ha-selector";
|
||||
import { isFieldHidden } from "./conditions";
|
||||
import type { HaFormDataContainer, HaFormElement, HaFormSchema } from "./types";
|
||||
|
||||
const LOAD_ELEMENTS = {
|
||||
@@ -98,7 +99,11 @@ export class HaForm extends LitElement implements HaFormElement {
|
||||
let isValid = true;
|
||||
let firstInvalidElement: HTMLElement | undefined;
|
||||
|
||||
this.schema.forEach((item, index) => {
|
||||
const visibleSchema = this.schema.filter(
|
||||
(item) => !isFieldHidden(item, this.data)
|
||||
);
|
||||
|
||||
visibleSchema.forEach((item, index) => {
|
||||
const element = elements[index];
|
||||
if (!element) {
|
||||
return;
|
||||
@@ -164,6 +169,10 @@ export class HaForm extends LitElement implements HaFormElement {
|
||||
: ""
|
||||
}
|
||||
${this.schema.map((item) => {
|
||||
if (isFieldHidden(item, this.data)) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const error = getError(this.error, item);
|
||||
const warning = getWarning(this.warning, item);
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ export interface HaFormBaseSchema {
|
||||
default?: HaFormData;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
// Field is hidden while the condition holds. Serializable so it can be
|
||||
// shared with the backend and other renderers.
|
||||
hidden?: boolean | HaFormCondition | HaFormCondition[];
|
||||
description?: {
|
||||
suffix?: string;
|
||||
// This value will be set initially when form is loaded
|
||||
@@ -30,6 +33,36 @@ export interface HaFormBaseSchema {
|
||||
context?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type HaFormConditionOperator =
|
||||
"eq" | "not_eq" | "in" | "not_in" | "exists" | "not_exists";
|
||||
|
||||
export interface HaFormFieldCondition {
|
||||
field: string;
|
||||
operator?: HaFormConditionOperator;
|
||||
value?: HaFormData | readonly HaFormData[];
|
||||
}
|
||||
|
||||
export interface HaFormAndCondition {
|
||||
condition: "and";
|
||||
conditions: readonly HaFormCondition[];
|
||||
}
|
||||
|
||||
export interface HaFormOrCondition {
|
||||
condition: "or";
|
||||
conditions: readonly HaFormCondition[];
|
||||
}
|
||||
|
||||
export interface HaFormNotCondition {
|
||||
condition: "not";
|
||||
conditions: readonly HaFormCondition[];
|
||||
}
|
||||
|
||||
export type HaFormCondition =
|
||||
| HaFormFieldCondition
|
||||
| HaFormAndCondition
|
||||
| HaFormOrCondition
|
||||
| HaFormNotCondition;
|
||||
|
||||
export interface HaFormGridSchema extends HaFormBaseSchema {
|
||||
type: "grid";
|
||||
flatten?: boolean;
|
||||
|
||||
@@ -20,18 +20,16 @@ class HaRelativeTime extends ReactiveElement {
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n?: HomeAssistantInternationalization;
|
||||
|
||||
private _interval?: number;
|
||||
private _timeout?: number;
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._clearInterval();
|
||||
this._clearTimeout();
|
||||
}
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (this.datetime) {
|
||||
this._startInterval();
|
||||
}
|
||||
this._updateRelative();
|
||||
}
|
||||
|
||||
protected createRenderRoot() {
|
||||
@@ -40,31 +38,19 @@ class HaRelativeTime extends ReactiveElement {
|
||||
|
||||
protected update(changedProps: PropertyValues<this>) {
|
||||
super.update(changedProps);
|
||||
if (changedProps.has("datetime")) {
|
||||
if (this.datetime) {
|
||||
this._startInterval();
|
||||
} else {
|
||||
this._clearInterval();
|
||||
}
|
||||
}
|
||||
this._updateRelative();
|
||||
}
|
||||
|
||||
private _clearInterval(): void {
|
||||
if (this._interval) {
|
||||
window.clearInterval(this._interval);
|
||||
this._interval = undefined;
|
||||
private _clearTimeout(): void {
|
||||
if (this._timeout) {
|
||||
window.clearTimeout(this._timeout);
|
||||
this._timeout = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _startInterval(): void {
|
||||
this._clearInterval();
|
||||
|
||||
// update every 60 seconds
|
||||
this._interval = window.setInterval(() => this._updateRelative(), 60000);
|
||||
}
|
||||
|
||||
private _updateRelative(): void {
|
||||
this._clearTimeout();
|
||||
|
||||
if (!this._i18n) {
|
||||
return;
|
||||
}
|
||||
@@ -73,23 +59,33 @@ class HaRelativeTime extends ReactiveElement {
|
||||
this.textContent = this._i18n.localize(
|
||||
"ui.components.relative_time.never"
|
||||
);
|
||||
} else {
|
||||
const date =
|
||||
typeof this.datetime === "string"
|
||||
? parseISO(this.datetime)
|
||||
: this.datetime;
|
||||
|
||||
const relTime = relativeTime(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
undefined,
|
||||
true,
|
||||
this.format
|
||||
);
|
||||
this.textContent = this.capitalize
|
||||
? capitalizeFirstLetter(relTime)
|
||||
: relTime;
|
||||
return;
|
||||
}
|
||||
|
||||
const date =
|
||||
typeof this.datetime === "string"
|
||||
? parseISO(this.datetime)
|
||||
: this.datetime;
|
||||
|
||||
const relTime = relativeTime(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
undefined,
|
||||
true,
|
||||
this.format
|
||||
);
|
||||
this.textContent = this.capitalize
|
||||
? capitalizeFirstLetter(relTime)
|
||||
: relTime;
|
||||
|
||||
// Keep the relative time counting up on its own. Refresh every second
|
||||
// while the difference is still measured in seconds, otherwise every
|
||||
// minute.
|
||||
const secondsDiff = Math.abs(Date.now() - date.getTime()) / 1000;
|
||||
this._timeout = window.setTimeout(
|
||||
() => this._updateRelative(),
|
||||
secondsDiff < 60 ? 1000 : 60000
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -181,49 +181,49 @@ export class HaSelectorSelector extends LitElement {
|
||||
return true;
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(choice: string, localize: LocalizeFunc) =>
|
||||
[
|
||||
{
|
||||
name: "type",
|
||||
required: true,
|
||||
selector: {
|
||||
select: {
|
||||
mode: "dropdown",
|
||||
options: Object.keys(SELECTOR_SCHEMAS)
|
||||
.concat("manual")
|
||||
.map((key) => ({
|
||||
label:
|
||||
localize(
|
||||
`ui.components.selectors.selector.types.${key}` as LocalizeKeys
|
||||
) || key,
|
||||
value: key,
|
||||
})),
|
||||
},
|
||||
private _schema = memoizeOne((choice: string, localize: LocalizeFunc) => {
|
||||
const schemas = SELECTOR_SCHEMAS[choice];
|
||||
return [
|
||||
{
|
||||
name: "type",
|
||||
required: true,
|
||||
selector: {
|
||||
select: {
|
||||
mode: "dropdown",
|
||||
options: Object.keys(SELECTOR_SCHEMAS)
|
||||
.concat("manual")
|
||||
.map((key) => ({
|
||||
label:
|
||||
localize(
|
||||
`ui.components.selectors.selector.types.${key}` as LocalizeKeys
|
||||
) || key,
|
||||
value: key,
|
||||
})),
|
||||
},
|
||||
},
|
||||
...(choice === "manual"
|
||||
? ([
|
||||
},
|
||||
...(choice === "manual"
|
||||
? ([
|
||||
{
|
||||
name: "manual",
|
||||
selector: { object: {} },
|
||||
},
|
||||
] as const)
|
||||
: []),
|
||||
...(schemas
|
||||
? schemas.length > 1
|
||||
? [
|
||||
{
|
||||
name: "manual",
|
||||
selector: { object: {} },
|
||||
name: "",
|
||||
type: "expandable",
|
||||
title: localize("ui.components.selectors.selector.options"),
|
||||
schema: schemas,
|
||||
},
|
||||
] as const)
|
||||
: []),
|
||||
...(SELECTOR_SCHEMAS[choice]
|
||||
? SELECTOR_SCHEMAS[choice].length > 1
|
||||
? [
|
||||
{
|
||||
name: "",
|
||||
type: "expandable",
|
||||
title: localize("ui.components.selectors.selector.options"),
|
||||
schema: SELECTOR_SCHEMAS[choice],
|
||||
},
|
||||
]
|
||||
: SELECTOR_SCHEMAS[choice]
|
||||
: []),
|
||||
] as const
|
||||
);
|
||||
]
|
||||
: schemas
|
||||
: []),
|
||||
] as const;
|
||||
});
|
||||
|
||||
protected render() {
|
||||
let data;
|
||||
|
||||
@@ -28,6 +28,10 @@ export class HaTextSelector extends LitElement {
|
||||
|
||||
@query("ha-input, ha-textarea") private _input?: HTMLInputElement;
|
||||
|
||||
@query("ha-input-multi") private _inputMulti?: {
|
||||
reportValidity: () => boolean;
|
||||
};
|
||||
|
||||
public async focus() {
|
||||
await this.updateComplete;
|
||||
this._input?.focus();
|
||||
@@ -35,7 +39,7 @@ export class HaTextSelector extends LitElement {
|
||||
|
||||
public reportValidity(): boolean {
|
||||
if (this.selector.text?.multiple) {
|
||||
return true;
|
||||
return this._inputMulti?.reportValidity() ?? true;
|
||||
}
|
||||
return this._input?.reportValidity() ?? true;
|
||||
}
|
||||
@@ -52,6 +56,8 @@ export class HaTextSelector extends LitElement {
|
||||
.inputPrefix=${this.selector.text?.prefix}
|
||||
.helper=${this.helper}
|
||||
.autocomplete=${this.selector.text?.autocomplete}
|
||||
.pattern=${this.selector.text?.pattern}
|
||||
.validationMessage=${this.selector.text?.validation_message}
|
||||
@value-changed=${this._handleChange}
|
||||
>
|
||||
</ha-input-multi>
|
||||
@@ -80,6 +86,9 @@ export class HaTextSelector extends LitElement {
|
||||
.hint=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
.type=${this.selector.text?.type}
|
||||
.pattern=${this.selector.text?.pattern}
|
||||
.validationMessage=${this.selector.text?.validation_message}
|
||||
.autoValidate=${this.selector.text?.pattern !== undefined}
|
||||
@input=${this._handleChange}
|
||||
@change=${this._handleChange}
|
||||
.label=${this.label || ""}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { UiClockDateFormatSelector } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-clock-date-format-picker";
|
||||
|
||||
@customElement("ha-selector-ui_clock_date_format")
|
||||
export class HaSelectorUiClockDateFormat extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public selector!: UiClockDateFormatSelector;
|
||||
|
||||
@property() public value?: string | string[];
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-clock-date-format-picker
|
||||
.hass=${this.hass}
|
||||
.value=${this.value}
|
||||
.label=${this.label}
|
||||
.helper=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
></ha-clock-date-format-picker>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-selector-ui_clock_date_format": HaSelectorUiClockDateFormat;
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,7 @@ const LOAD_ELEMENTS = {
|
||||
location: () => import("./ha-selector-location"),
|
||||
color_temp: () => import("./ha-selector-color-temp"),
|
||||
ui_action: () => import("./ha-selector-ui-action"),
|
||||
ui_clock_date_format: () => import("./ha-selector-ui-clock-date-format"),
|
||||
ui_color: () => import("./ha-selector-ui-color"),
|
||||
ui_state_content: () => import("./ha-selector-ui-state-content"),
|
||||
ui_time_format: () => import("./ha-selector-ui-time-format"),
|
||||
|
||||
@@ -305,6 +305,10 @@ export class HaServiceControl extends LitElement {
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const isPrimaryEntity = (entityId: string) => {
|
||||
const entity = this.hass.entities[entityId];
|
||||
return !entity?.entity_category && !entity?.hidden;
|
||||
};
|
||||
const targetEntities =
|
||||
ensureArray(
|
||||
value?.target?.entity_id || value?.data?.entity_id
|
||||
@@ -333,11 +337,7 @@ export class HaServiceControl extends LitElement {
|
||||
targetSelector
|
||||
);
|
||||
targetDevices.push(...expanded.devices);
|
||||
const primaryEntities = expanded.entities.filter(
|
||||
(entityId) =>
|
||||
!this.hass.entities[entityId]?.entity_category &&
|
||||
!this.hass.entities[entityId]?.hidden
|
||||
);
|
||||
const primaryEntities = expanded.entities.filter(isPrimaryEntity);
|
||||
targetEntities.push(primaryEntities);
|
||||
targetAreas.push(...expanded.areas);
|
||||
});
|
||||
@@ -362,11 +362,7 @@ export class HaServiceControl extends LitElement {
|
||||
this.hass.entities,
|
||||
targetSelector
|
||||
);
|
||||
const primaryEntities = expanded.entities.filter(
|
||||
(entityId) =>
|
||||
!this.hass.entities[entityId]?.entity_category &&
|
||||
!this.hass.entities[entityId]?.hidden
|
||||
);
|
||||
const primaryEntities = expanded.entities.filter(isPrimaryEntity);
|
||||
targetEntities.push(...primaryEntities);
|
||||
targetDevices.push(...expanded.devices);
|
||||
});
|
||||
@@ -379,11 +375,7 @@ export class HaServiceControl extends LitElement {
|
||||
this.hass.entities,
|
||||
targetSelector
|
||||
);
|
||||
const primaryEntities = expanded.entities.filter(
|
||||
(entityId) =>
|
||||
!this.hass.entities[entityId]?.entity_category &&
|
||||
!this.hass.entities[entityId]?.hidden
|
||||
);
|
||||
const primaryEntities = expanded.entities.filter(isPrimaryEntity);
|
||||
targetEntities.push(...primaryEntities);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -479,6 +479,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
<div class="add-target-wrapper">
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
popover-placement="bottom-start"
|
||||
.disabled=${this.disabled}
|
||||
.autofocus=${this.autofocus}
|
||||
.helper=${this.helper}
|
||||
|
||||
@@ -2,7 +2,13 @@ import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiDeleteOutline, mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import {
|
||||
customElement,
|
||||
property,
|
||||
query,
|
||||
queryAll,
|
||||
state,
|
||||
} from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { uid } from "../../common/util/uid";
|
||||
@@ -50,6 +56,13 @@ class HaInputMulti extends LitElement {
|
||||
|
||||
@property() public autocomplete?: string;
|
||||
|
||||
/** Regular expression each entry is validated against (HTML `pattern`). */
|
||||
@property() public pattern?: string;
|
||||
|
||||
/** Message shown on an entry when it fails `pattern` validation. */
|
||||
@property({ attribute: "validation-message" })
|
||||
public validationMessage?: string;
|
||||
|
||||
@property({ attribute: "add-label" }) public addLabel?: string;
|
||||
|
||||
@property({ attribute: "remove-label" }) public removeLabel?: string;
|
||||
@@ -70,6 +83,8 @@ class HaInputMulti extends LitElement {
|
||||
|
||||
@query("ha-input[data-last]") private _lastInput?: HaInput;
|
||||
|
||||
@queryAll("ha-input") private _inputs?: NodeListOf<HaInput>;
|
||||
|
||||
// Stable key per row, kept in sync with `value`. Because items are plain
|
||||
// strings we cannot use a WeakMap (as the object-based sortable lists do),
|
||||
// so we track keys in a parallel array. Keys stay fixed while a row is
|
||||
@@ -89,6 +104,16 @@ class HaInputMulti extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
public reportValidity(): boolean {
|
||||
let valid = true;
|
||||
this._inputs?.forEach((input) => {
|
||||
if (!input.reportValidity()) {
|
||||
valid = false;
|
||||
}
|
||||
});
|
||||
return valid;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-sortable
|
||||
@@ -109,6 +134,9 @@ class HaInputMulti extends LitElement {
|
||||
.type=${this.inputType}
|
||||
.autocomplete=${this.autocomplete}
|
||||
.disabled=${this.disabled}
|
||||
.pattern=${this.pattern}
|
||||
.validationMessage=${this.validationMessage}
|
||||
.autoValidate=${this.pattern !== undefined}
|
||||
dialogInitialFocus=${index}
|
||||
.index=${index}
|
||||
class="flex-auto"
|
||||
@@ -156,7 +184,7 @@ class HaInputMulti extends LitElement {
|
||||
)}
|
||||
</div>
|
||||
</ha-sortable>
|
||||
<div class="layout horizontal">
|
||||
<div class="layout horizontal add-row">
|
||||
<ha-button
|
||||
size="s"
|
||||
appearance="filled"
|
||||
@@ -258,6 +286,9 @@ class HaInputMulti extends LitElement {
|
||||
margin-bottom: 8px;
|
||||
--ha-input-padding-bottom: 0;
|
||||
}
|
||||
.add-row:has(+ ha-input-helper-text) {
|
||||
margin-bottom: var(--ha-space-1);
|
||||
}
|
||||
ha-icon-button {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -127,6 +127,8 @@ export class HaInput extends WaInputMixin(LitElement) {
|
||||
@query("wa-input")
|
||||
private _input?: WaInput;
|
||||
|
||||
private _startSlotResizeObserver?: ResizeObserver;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
protected i18n?: ContextType<typeof internationalizationContext>;
|
||||
@@ -167,9 +169,15 @@ export class HaInput extends WaInputMixin(LitElement) {
|
||||
// Wait for wa-input to finish its first render
|
||||
await this._input?.updateComplete;
|
||||
this._syncStartSlotWidth();
|
||||
this._observeStartSlot();
|
||||
}
|
||||
}
|
||||
|
||||
public override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._startSlotResizeObserver?.disconnect();
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const hasLabelSlot = this.label
|
||||
? false
|
||||
@@ -291,6 +299,25 @@ export class HaInput extends WaInputMixin(LitElement) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
// Safari can report the start-slot width as 0 during the first render, which
|
||||
// leaves the floating label overlapping the start icon (e.g. the magnify icon
|
||||
// in ha-input-search). Re-sync whenever the wrapper's size changes
|
||||
// (0 -> icon width, or hidden -> shown) so the label padding stays correct.
|
||||
private _observeStartSlot() {
|
||||
if (typeof ResizeObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
const startEl = this._input?.shadowRoot?.querySelector('[part~="start"]');
|
||||
if (!startEl) {
|
||||
return;
|
||||
}
|
||||
this._startSlotResizeObserver?.disconnect();
|
||||
this._startSlotResizeObserver = new ResizeObserver(() =>
|
||||
this._syncStartSlotWidth()
|
||||
);
|
||||
this._startSlotResizeObserver.observe(startEl);
|
||||
}
|
||||
|
||||
private _syncStartSlotWidth = () => {
|
||||
const startEl = this._input?.shadowRoot?.querySelector(
|
||||
'[part~="start"]'
|
||||
|
||||
@@ -181,6 +181,8 @@ export interface RestoreBackupParams {
|
||||
restore_homeassistant?: boolean;
|
||||
}
|
||||
|
||||
export type CloudBackupHealth = "success" | "failed" | "old" | "none";
|
||||
|
||||
export const fetchBackupConfig = (hass: Pick<HomeAssistant, "callWS">) =>
|
||||
hass.callWS<{ config: BackupConfig }>({ type: "backup/config/info" });
|
||||
|
||||
@@ -314,9 +316,61 @@ export const CORE_LOCAL_AGENT = "backup.local";
|
||||
export const HASSIO_LOCAL_AGENT = "hassio.local";
|
||||
export const CLOUD_AGENT = "cloud.cloud";
|
||||
|
||||
// How many hours a scheduled automatic backup may be behind before it reads as
|
||||
// overdue, so a few hours of scheduler lag (or a daylight saving shift) doesn't
|
||||
// show a warning.
|
||||
export const BACKUP_OVERDUE_MARGIN_HOURS = 3;
|
||||
|
||||
export const isLocalAgent = (agentId: string) =>
|
||||
[CORE_LOCAL_AGENT, HASSIO_LOCAL_AGENT].includes(agentId);
|
||||
|
||||
export const getLastCloudBackup = (
|
||||
backups?: BackupContent[]
|
||||
): BackupContent | undefined =>
|
||||
backups
|
||||
?.filter((backup) => CLOUD_AGENT in backup.agents)
|
||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())[0];
|
||||
|
||||
export const cloudBackupEnabled = (backupConfig?: BackupConfig): boolean =>
|
||||
!!backupConfig?.automatic_backups_configured &&
|
||||
backupConfig.create_backup.agent_ids.includes(CLOUD_AGENT);
|
||||
|
||||
const BACKUP_OVERDUE_MARGIN_MS = BACKUP_OVERDUE_MARGIN_HOURS * 60 * 60 * 1000;
|
||||
|
||||
export const cloudBackupHealth = (
|
||||
backupConfig?: BackupConfig
|
||||
): CloudBackupHealth => {
|
||||
if (!cloudBackupEnabled(backupConfig)) {
|
||||
return "none";
|
||||
}
|
||||
|
||||
const completed = backupConfig?.last_completed_automatic_backup
|
||||
? new Date(backupConfig.last_completed_automatic_backup).getTime()
|
||||
: 0;
|
||||
|
||||
const attempted = backupConfig?.last_attempted_automatic_backup
|
||||
? new Date(backupConfig.last_attempted_automatic_backup).getTime()
|
||||
: 0;
|
||||
|
||||
if (!completed && !attempted) {
|
||||
return "none";
|
||||
}
|
||||
|
||||
if (attempted > completed) {
|
||||
return "failed";
|
||||
}
|
||||
|
||||
const next =
|
||||
backupConfig?.next_automatic_backup &&
|
||||
new Date(backupConfig.next_automatic_backup).getTime();
|
||||
|
||||
if (next && next < Date.now() - BACKUP_OVERDUE_MARGIN_MS) {
|
||||
return "old";
|
||||
}
|
||||
|
||||
return "success";
|
||||
};
|
||||
|
||||
export const isNetworkMountAgent = (agentId: string) => {
|
||||
const [domain, name] = agentId.split(".");
|
||||
return domain === "hassio" && name !== "local";
|
||||
|
||||
+39
-2
@@ -28,8 +28,13 @@ export interface CloudPreferences {
|
||||
google_report_state: boolean;
|
||||
tts_default_voice: [string, string];
|
||||
cloud_ice_servers_enabled: boolean;
|
||||
onboarded_items: string[];
|
||||
onboarding_postponed_until: string | null;
|
||||
}
|
||||
|
||||
export type RemoteCertificateStatus =
|
||||
"error" | "generating" | "loading" | "loaded" | "ready";
|
||||
|
||||
export interface CloudStatusLoggedIn {
|
||||
logged_in: true;
|
||||
cloud: "disconnected" | "connecting" | "connected";
|
||||
@@ -44,18 +49,36 @@ export interface CloudStatusLoggedIn {
|
||||
remote_domain: string | undefined;
|
||||
remote_connected: boolean;
|
||||
remote_certificate: undefined | CertificateInformation;
|
||||
remote_certificate_status:
|
||||
null | "error" | "generating" | "loaded" | "loading" | "ready";
|
||||
remote_certificate_status: RemoteCertificateStatus | null;
|
||||
http_use_ssl: boolean;
|
||||
active_subscription: boolean;
|
||||
onboarding_postponed: boolean;
|
||||
onboarding_completed: boolean;
|
||||
}
|
||||
|
||||
export type CloudStatus = CloudStatusNotLoggedIn | CloudStatusLoggedIn;
|
||||
|
||||
// Onboarding items the backend tracks. Mirrors ONBOARDING_ITEMS in the cloud
|
||||
// integration; onboarding is complete once every item has been onboarded.
|
||||
export const ONBOARDING_ITEMS = [
|
||||
"remote",
|
||||
"backup",
|
||||
"voice",
|
||||
"streaming",
|
||||
] as const;
|
||||
|
||||
export type CloudOnboardingItem = (typeof ONBOARDING_ITEMS)[number];
|
||||
|
||||
type SubscriptionStatus =
|
||||
"active" | "canceled" | "expired" | "trialing" | "unknown";
|
||||
|
||||
export interface SubscriptionInfo {
|
||||
human_description: string;
|
||||
provider: string;
|
||||
plan_renewal_date?: number;
|
||||
subscription?: {
|
||||
status?: SubscriptionStatus;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CloudWebhook {
|
||||
@@ -159,6 +182,20 @@ export const updateCloudPref = (
|
||||
...prefs,
|
||||
});
|
||||
|
||||
export const postponeCloudOnboarding = (hass: HomeAssistant) =>
|
||||
hass.callWS<CloudStatusLoggedIn>({
|
||||
type: "cloud/onboarding/postpone",
|
||||
});
|
||||
|
||||
export const completeCloudOnboarding = (
|
||||
hass: HomeAssistant,
|
||||
items: CloudOnboardingItem[]
|
||||
) =>
|
||||
hass.callWS<CloudStatusLoggedIn>({
|
||||
type: "cloud/onboarding/complete",
|
||||
items,
|
||||
});
|
||||
|
||||
export const removeCloudData = (hass: HomeAssistant) =>
|
||||
hass.callWS({
|
||||
type: "cloud/remove_data",
|
||||
|
||||
@@ -842,6 +842,8 @@ export const getEnergyDataCollection = (
|
||||
if (err.code === "not_found") {
|
||||
return {
|
||||
prefs: EMPTY_PREFERENCES,
|
||||
start: collection.start,
|
||||
end: collection.end,
|
||||
} as EnergyData;
|
||||
}
|
||||
throw err;
|
||||
|
||||
@@ -213,12 +213,14 @@ export const findBatteryEntity = <T extends { entity_id: string }>(
|
||||
entities: T[]
|
||||
): T | undefined => {
|
||||
const batteryEntities = entities
|
||||
.filter(
|
||||
(entity) =>
|
||||
states[entity.entity_id] &&
|
||||
states[entity.entity_id].attributes.device_class === "battery" &&
|
||||
.filter((entity) => {
|
||||
const state = states[entity.entity_id];
|
||||
return (
|
||||
state &&
|
||||
state.attributes.device_class === "battery" &&
|
||||
batteryPriorities.includes(computeDomain(entity.entity_id))
|
||||
)
|
||||
);
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
batteryPriorities.indexOf(computeDomain(a.entity_id)) -
|
||||
@@ -235,11 +237,10 @@ export const findBatteryChargingEntity = <T extends { entity_id: string }>(
|
||||
states: HomeAssistant["states"],
|
||||
entities: T[]
|
||||
): T | undefined =>
|
||||
entities.find(
|
||||
(entity) =>
|
||||
states[entity.entity_id] &&
|
||||
states[entity.entity_id].attributes.device_class === "battery_charging"
|
||||
);
|
||||
entities.find((entity) => {
|
||||
const state = states[entity.entity_id];
|
||||
return state && state.attributes.device_class === "battery_charging";
|
||||
});
|
||||
|
||||
export const computeEntityRegistryName = (
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -140,7 +140,7 @@ export interface HassioAddonSetOptionParams {
|
||||
export const reloadHassioAddons = async (hass: HomeAssistant) => {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/addons/reload",
|
||||
endpoint: "/store/reload",
|
||||
method: "post",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -17,9 +17,6 @@ export interface HassioStats {
|
||||
network_tx: number;
|
||||
}
|
||||
|
||||
export const hassioApiResultExtractor = <T>(response: HassioResponse<T>) =>
|
||||
response.data;
|
||||
|
||||
export const extractApiErrorMessage = (error: any): string =>
|
||||
typeof error === "object"
|
||||
? typeof error.body === "object"
|
||||
|
||||
+17
-47
@@ -1,7 +1,4 @@
|
||||
import { atLeastVersion } from "../../common/config/version";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassioResponse } from "./common";
|
||||
import { hassioApiResultExtractor } from "./common";
|
||||
|
||||
type HassioDockerRegistries = Record<
|
||||
string,
|
||||
@@ -10,59 +7,32 @@ type HassioDockerRegistries = Record<
|
||||
|
||||
export const fetchHassioDockerRegistries = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<HassioDockerRegistries> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/docker/registries`,
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<HassioDockerRegistries>>(
|
||||
"GET",
|
||||
"hassio/docker/registries"
|
||||
)
|
||||
);
|
||||
};
|
||||
): Promise<HassioDockerRegistries> =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/docker/registries`,
|
||||
method: "get",
|
||||
});
|
||||
|
||||
export const addHassioDockerRegistry = async (
|
||||
hass: HomeAssistant,
|
||||
data: HassioDockerRegistries
|
||||
) => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/docker/registries`,
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await hass.callApi<HassioResponse<HassioDockerRegistries>>(
|
||||
"POST",
|
||||
"hassio/docker/registries",
|
||||
data
|
||||
);
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/docker/registries`,
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
export const removeHassioDockerRegistry = async (
|
||||
hass: HomeAssistant,
|
||||
registry: string
|
||||
) => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/docker/registries/${registry}`,
|
||||
method: "delete",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await hass.callApi<HassioResponse<void>>(
|
||||
"DELETE",
|
||||
`hassio/docker/registries/${registry}`
|
||||
);
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/docker/registries/${registry}`,
|
||||
method: "delete",
|
||||
});
|
||||
};
|
||||
|
||||
+12
-35
@@ -1,7 +1,4 @@
|
||||
import { atLeastVersion } from "../../common/config/version";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassioResponse } from "./common";
|
||||
import { hassioApiResultExtractor } from "./common";
|
||||
|
||||
export interface HassioHardwareAudioDevice {
|
||||
device?: string | null;
|
||||
@@ -30,38 +27,18 @@ export interface HassioHardwareInfo {
|
||||
|
||||
export const fetchHassioHardwareAudio = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<HassioHardwareAudioList> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/hardware/audio`,
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<HassioHardwareAudioList>>(
|
||||
"GET",
|
||||
"hassio/hardware/audio"
|
||||
)
|
||||
);
|
||||
};
|
||||
): Promise<HassioHardwareAudioList> =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/hardware/audio`,
|
||||
method: "get",
|
||||
});
|
||||
|
||||
export const fetchHassioHardwareInfo = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<HassioHardwareInfo> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/hardware/info`,
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<HassioHardwareInfo>>(
|
||||
"GET",
|
||||
"hassio/hardware/info"
|
||||
)
|
||||
);
|
||||
};
|
||||
): Promise<HassioHardwareInfo> =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/hardware/info`,
|
||||
method: "get",
|
||||
});
|
||||
|
||||
+70
-143
@@ -1,7 +1,4 @@
|
||||
import { atLeastVersion } from "../../common/config/version";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassioResponse } from "./common";
|
||||
import { hassioApiResultExtractor } from "./common";
|
||||
|
||||
export interface HassioHostInfo {
|
||||
agent_version: string;
|
||||
@@ -54,156 +51,86 @@ export interface HostDisksUsage {
|
||||
|
||||
export const fetchHassioHostInfo = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<HassioHostInfo> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/host/info",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
const response = await hass.callApi<HassioResponse<HassioHostInfo>>(
|
||||
"GET",
|
||||
"hassio/host/info"
|
||||
);
|
||||
return hassioApiResultExtractor(response);
|
||||
};
|
||||
): Promise<HassioHostInfo> =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/host/info",
|
||||
method: "get",
|
||||
});
|
||||
|
||||
export const fetchHassioHassOsInfo = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<HassioHassOSInfo> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/os/info",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
): Promise<HassioHassOSInfo> =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/os/info",
|
||||
method: "get",
|
||||
});
|
||||
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<HassioHassOSInfo>>(
|
||||
"GET",
|
||||
"hassio/os/info"
|
||||
)
|
||||
);
|
||||
};
|
||||
export const rebootHost = async (hass: HomeAssistant) =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/host/reboot",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
|
||||
export const rebootHost = async (hass: HomeAssistant) => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/host/reboot",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
}
|
||||
export const shutdownHost = async (hass: HomeAssistant) =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/host/shutdown",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
|
||||
return hass.callApi<HassioResponse<void>>("POST", "hassio/host/reboot");
|
||||
};
|
||||
export const updateOS = async (hass: HomeAssistant) =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/os/update",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
|
||||
export const shutdownHost = async (hass: HomeAssistant) => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/host/shutdown",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
}
|
||||
export const configSyncOS = async (hass: HomeAssistant) =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/os/config/sync",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
|
||||
return hass.callApi<HassioResponse<void>>("POST", "hassio/host/shutdown");
|
||||
};
|
||||
export const changeHostOptions = async (hass: HomeAssistant, options: any) =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/host/options",
|
||||
method: "post",
|
||||
data: options,
|
||||
});
|
||||
|
||||
export const updateOS = async (hass: HomeAssistant) => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/os/update",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
}
|
||||
|
||||
return hass.callApi<HassioResponse<void>>("POST", "hassio/os/update");
|
||||
};
|
||||
|
||||
export const configSyncOS = async (hass: HomeAssistant) => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/os/config/sync",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
}
|
||||
|
||||
return hass.callApi<HassioResponse<void>>("POST", "hassio/os/config/sync");
|
||||
};
|
||||
|
||||
export const changeHostOptions = async (hass: HomeAssistant, options: any) => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/host/options",
|
||||
method: "post",
|
||||
data: options,
|
||||
});
|
||||
}
|
||||
|
||||
return hass.callApi<HassioResponse<void>>(
|
||||
"POST",
|
||||
"hassio/host/options",
|
||||
options
|
||||
);
|
||||
};
|
||||
|
||||
export const moveDatadisk = async (hass: HomeAssistant, device: string) => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/os/datadisk/move",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
data: { device },
|
||||
});
|
||||
}
|
||||
|
||||
return hass.callApi<HassioResponse<void>>("POST", "hassio/os/datadisk/move");
|
||||
};
|
||||
export const moveDatadisk = async (hass: HomeAssistant, device: string) =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/os/datadisk/move",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
data: { device },
|
||||
});
|
||||
|
||||
export const listDatadisks = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<DatadiskList> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS<DatadiskList>({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/os/datadisk/list",
|
||||
method: "get",
|
||||
timeout: null,
|
||||
});
|
||||
}
|
||||
): Promise<DatadiskList> =>
|
||||
hass.callWS<DatadiskList>({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/os/datadisk/list",
|
||||
method: "get",
|
||||
timeout: null,
|
||||
});
|
||||
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<DatadiskList>>("GET", "/os/datadisk/list")
|
||||
);
|
||||
};
|
||||
|
||||
export const fetchHostDisksUsage = async (hass: HomeAssistant) => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS<HostDisksUsage>({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/host/disks/default/usage",
|
||||
method: "get",
|
||||
timeout: 3600, // seconds. This can take a while
|
||||
params: { max_depth: 3 },
|
||||
});
|
||||
}
|
||||
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<HostDisksUsage>>(
|
||||
"GET",
|
||||
"hassio/host/disks/default/usage"
|
||||
)
|
||||
);
|
||||
};
|
||||
export const fetchHostDisksUsage = async (hass: HomeAssistant) =>
|
||||
hass.callWS<HostDisksUsage>({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/host/disks/default/usage",
|
||||
method: "get",
|
||||
timeout: 3600, // seconds. This can take a while
|
||||
params: { max_depth: 3 },
|
||||
});
|
||||
|
||||
+14
-33
@@ -1,9 +1,6 @@
|
||||
import { getCollection, type Connection } from "home-assistant-js-websocket";
|
||||
import { atLeastVersion } from "../../common/config/version";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { supervisorApiWsRequest } from "../supervisor/supervisor";
|
||||
import type { HassioResponse } from "./common";
|
||||
import type { CreateSessionResponse } from "./supervisor";
|
||||
|
||||
function setIngressCookie(session: string): string {
|
||||
document.cookie = `ingress_session=${session};path=/api/hassio_ingress/;SameSite=Strict${
|
||||
@@ -13,21 +10,14 @@ function setIngressCookie(session: string): string {
|
||||
}
|
||||
|
||||
export const createHassioSession = async (
|
||||
hass: HomeAssistant
|
||||
hass: Pick<HomeAssistant, "callWS">
|
||||
): Promise<string> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
const wsResponse: { session: string } = await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/ingress/session",
|
||||
method: "post",
|
||||
});
|
||||
return setIngressCookie(wsResponse.session);
|
||||
}
|
||||
|
||||
const restResponse: { data: { session: string } } = await hass.callApi<
|
||||
HassioResponse<CreateSessionResponse>
|
||||
>("POST", "hassio/ingress/session");
|
||||
return setIngressCookie(restResponse.data.session);
|
||||
const wsResponse: { session: string } = await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/ingress/session",
|
||||
method: "post",
|
||||
});
|
||||
return setIngressCookie(wsResponse.session);
|
||||
};
|
||||
|
||||
export interface IngressPanelInfo {
|
||||
@@ -50,22 +40,13 @@ export const getIngressPanelInfoCollection = (conn: Connection) =>
|
||||
);
|
||||
|
||||
export const validateHassioSession = async (
|
||||
hass: HomeAssistant,
|
||||
hass: Pick<HomeAssistant, "callWS">,
|
||||
session: string
|
||||
): Promise<void> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/ingress/validate_session",
|
||||
method: "post",
|
||||
data: { session },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await hass.callApi<HassioResponse<void>>(
|
||||
"POST",
|
||||
"hassio/ingress/validate_session",
|
||||
{ session }
|
||||
);
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/ingress/validate_session",
|
||||
method: "post",
|
||||
data: { session },
|
||||
});
|
||||
};
|
||||
|
||||
+20
-52
@@ -1,7 +1,4 @@
|
||||
import { atLeastVersion } from "../../common/config/version";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassioResponse } from "./common";
|
||||
import { hassioApiResultExtractor } from "./common";
|
||||
|
||||
interface IpConfiguration {
|
||||
address: string[];
|
||||
@@ -55,66 +52,37 @@ export interface NetworkInfo {
|
||||
|
||||
export const fetchNetworkInfo = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<NetworkInfo> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/network/info",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<NetworkInfo>>(
|
||||
"GET",
|
||||
"hassio/network/info"
|
||||
)
|
||||
);
|
||||
};
|
||||
): Promise<NetworkInfo> =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/network/info",
|
||||
method: "get",
|
||||
});
|
||||
|
||||
export const updateNetworkInterface = async (
|
||||
hass: HomeAssistant,
|
||||
network_interface: string,
|
||||
options: Partial<NetworkInterface>
|
||||
) => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/network/interface/${network_interface}/update`,
|
||||
method: "post",
|
||||
data: options,
|
||||
timeout: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await hass.callApi<HassioResponse<NetworkInfo>>(
|
||||
"POST",
|
||||
`hassio/network/interface/${network_interface}/update`,
|
||||
options
|
||||
);
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/network/interface/${network_interface}/update`,
|
||||
method: "post",
|
||||
data: options,
|
||||
timeout: null,
|
||||
});
|
||||
};
|
||||
|
||||
export const accesspointScan = async (
|
||||
hass: HomeAssistant,
|
||||
network_interface: string
|
||||
): Promise<AccessPoints> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/network/interface/${network_interface}/accesspoints`,
|
||||
method: "get",
|
||||
timeout: null,
|
||||
});
|
||||
}
|
||||
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<AccessPoints>>(
|
||||
"GET",
|
||||
`hassio/network/interface/${network_interface}/accesspoints`
|
||||
)
|
||||
);
|
||||
};
|
||||
): Promise<AccessPoints> =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/network/interface/${network_interface}/accesspoints`,
|
||||
method: "get",
|
||||
timeout: null,
|
||||
});
|
||||
|
||||
export const parseAddress = (address: string) => {
|
||||
const [ip, cidr] = address.split("/");
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { atLeastVersion } from "../../common/config/version";
|
||||
import type { HomeAssistant, TranslationDict } from "../../types";
|
||||
import type { HassioResponse } from "./common";
|
||||
import { hassioApiResultExtractor } from "./common";
|
||||
|
||||
export interface HassioResolution {
|
||||
unsupported: (keyof TranslationDict["ui"]["dialogs"]["unsupported"]["reasons"])[];
|
||||
@@ -12,19 +9,9 @@ export interface HassioResolution {
|
||||
|
||||
export const fetchHassioResolution = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<HassioResolution> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/resolution/info",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<HassioResolution>>(
|
||||
"GET",
|
||||
"hassio/resolution/info"
|
||||
)
|
||||
);
|
||||
};
|
||||
): Promise<HassioResolution> =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/resolution/info",
|
||||
method: "get",
|
||||
});
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { atLeastVersion } from "../../common/config/version";
|
||||
import type { HomeAssistant, PanelInfo } from "../../types";
|
||||
import type { SupervisorArch } from "../supervisor/supervisor";
|
||||
import type { HassioResponse } from "./common";
|
||||
import { hassioApiResultExtractor } from "./common";
|
||||
|
||||
export interface HassioHomeAssistantInfo {
|
||||
arch: SupervisorArch;
|
||||
@@ -77,10 +75,6 @@ export type HassioPanelInfo = PanelInfo<
|
||||
}
|
||||
>;
|
||||
|
||||
export interface CreateSessionResponse {
|
||||
session: string;
|
||||
}
|
||||
|
||||
export interface SupervisorOptions {
|
||||
channel?: "beta" | "dev" | "stable";
|
||||
diagnostics?: boolean;
|
||||
@@ -88,99 +82,57 @@ export interface SupervisorOptions {
|
||||
}
|
||||
|
||||
export const reloadSupervisor = async (hass: HomeAssistant) => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/supervisor/reload",
|
||||
method: "post",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await hass.callApi<HassioResponse<void>>("POST", `hassio/supervisor/reload`);
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/supervisor/reload",
|
||||
method: "post",
|
||||
});
|
||||
};
|
||||
|
||||
export const restartSupervisor = async (hass: HomeAssistant) => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/supervisor/restart",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await hass.callApi<HassioResponse<void>>("POST", `hassio/supervisor/restart`);
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/supervisor/restart",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
};
|
||||
|
||||
export const updateSupervisor = async (hass: HomeAssistant) => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/supervisor/update",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await hass.callApi<HassioResponse<void>>("POST", `hassio/supervisor/update`);
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/supervisor/update",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchHassioHomeAssistantInfo = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<HassioHomeAssistantInfo> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/core/info",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<HassioHomeAssistantInfo>>(
|
||||
"GET",
|
||||
"hassio/core/info"
|
||||
)
|
||||
);
|
||||
};
|
||||
): Promise<HassioHomeAssistantInfo> =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/core/info",
|
||||
method: "get",
|
||||
});
|
||||
|
||||
export const fetchHassioSupervisorInfo = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<HassioSupervisorInfo> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/supervisor/info",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<HassioSupervisorInfo>>(
|
||||
"GET",
|
||||
"hassio/supervisor/info"
|
||||
)
|
||||
);
|
||||
};
|
||||
): Promise<HassioSupervisorInfo> =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/supervisor/info",
|
||||
method: "get",
|
||||
});
|
||||
|
||||
export const fetchHassioInfo = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<HassioInfo> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/info",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<HassioInfo>>("GET", "hassio/info")
|
||||
);
|
||||
};
|
||||
): Promise<HassioInfo> =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/info",
|
||||
method: "get",
|
||||
});
|
||||
|
||||
export const fetchHassioBoots = async (hass: HomeAssistant) =>
|
||||
hass.callApi<HassioResponse<HassioBoots>>("GET", `hassio/host/logs/boots`);
|
||||
@@ -263,21 +215,12 @@ export const setSupervisorOption = async (
|
||||
hass: HomeAssistant,
|
||||
data: SupervisorOptions
|
||||
) => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/supervisor/options",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await hass.callApi<HassioResponse<void>>(
|
||||
"POST",
|
||||
"hassio/supervisor/options",
|
||||
data
|
||||
);
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/supervisor/options",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
export const coreLatestLogsUrl = "/api/hassio/core/logs/latest";
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
export interface HttpConfig {
|
||||
server_host?: string[];
|
||||
server_port?: number;
|
||||
ssl_certificate?: string;
|
||||
ssl_peer_certificate?: string;
|
||||
ssl_key?: string;
|
||||
cors_allowed_origins?: string[];
|
||||
use_x_forwarded_for?: boolean;
|
||||
trusted_proxies?: string[];
|
||||
use_x_frame_options?: boolean;
|
||||
ip_ban_enabled?: boolean;
|
||||
login_attempts_threshold?: number;
|
||||
ssl_profile?: "modern" | "intermediate";
|
||||
}
|
||||
|
||||
export interface HttpConfigState {
|
||||
stable: HttpConfig;
|
||||
pending: HttpConfig | null;
|
||||
revert_at: string | null;
|
||||
}
|
||||
|
||||
export const HTTP_CONFIG_FIELDS: (keyof HttpConfig)[] = [
|
||||
"server_port",
|
||||
"server_host",
|
||||
"ssl_certificate",
|
||||
"ssl_key",
|
||||
"ssl_peer_certificate",
|
||||
"ssl_profile",
|
||||
"cors_allowed_origins",
|
||||
"use_x_forwarded_for",
|
||||
"trusted_proxies",
|
||||
"use_x_frame_options",
|
||||
"ip_ban_enabled",
|
||||
"login_attempts_threshold",
|
||||
];
|
||||
|
||||
export interface SaveHttpConfigResult {
|
||||
restart: boolean;
|
||||
}
|
||||
|
||||
export const fetchHttpConfig = (hass: HomeAssistant) =>
|
||||
hass.callWS<HttpConfigState>({ type: "http/config" });
|
||||
|
||||
export const saveHttpConfig = (
|
||||
hass: HomeAssistant,
|
||||
config: HttpConfig | null
|
||||
) =>
|
||||
hass.callWS<SaveHttpConfigResult>({
|
||||
type: "http/config/configure",
|
||||
config,
|
||||
});
|
||||
|
||||
export const promoteHttpConfig = (hass: HomeAssistant) =>
|
||||
hass.callWS<undefined>({ type: "http/config/promote" });
|
||||
+14
-15
@@ -325,35 +325,34 @@ export const getCategoryIcons = async <
|
||||
domain?: string,
|
||||
force = false
|
||||
): Promise<CategoryType[T] | Record<string, CategoryType[T]> | undefined> => {
|
||||
const categoryResources = resources[category];
|
||||
if (!domain) {
|
||||
if (!force && resources[category].all) {
|
||||
return resources[category].all as Promise<
|
||||
Record<string, CategoryType[T]>
|
||||
>;
|
||||
if (!force && categoryResources.all) {
|
||||
return categoryResources.all as Promise<Record<string, CategoryType[T]>>;
|
||||
}
|
||||
resources[category].all = getHassIcons(connection, category).then((res) => {
|
||||
resources[category].domains = res.resources as any;
|
||||
categoryResources.all = getHassIcons(connection, category).then((res) => {
|
||||
categoryResources.domains = res.resources as any;
|
||||
return res?.resources as Record<string, CategoryType[T]>;
|
||||
}) as any;
|
||||
return resources[category].all as Promise<Record<string, CategoryType[T]>>;
|
||||
return categoryResources.all as Promise<Record<string, CategoryType[T]>>;
|
||||
}
|
||||
if (!force && domain in resources[category].domains) {
|
||||
return resources[category].domains[domain] as Promise<CategoryType[T]>;
|
||||
if (!force && domain in categoryResources.domains) {
|
||||
return categoryResources.domains[domain] as Promise<CategoryType[T]>;
|
||||
}
|
||||
if (resources[category].all && !force) {
|
||||
await resources[category].all;
|
||||
if (domain in resources[category].domains) {
|
||||
return resources[category].domains[domain] as Promise<CategoryType[T]>;
|
||||
if (categoryResources.all && !force) {
|
||||
await categoryResources.all;
|
||||
if (domain in categoryResources.domains) {
|
||||
return categoryResources.domains[domain] as Promise<CategoryType[T]>;
|
||||
}
|
||||
}
|
||||
if (!isComponentLoaded(hassConfig, domain)) {
|
||||
return undefined;
|
||||
}
|
||||
const result = getHassIcons(connection, category, domain);
|
||||
resources[category].domains[domain] = result.then(
|
||||
categoryResources.domains[domain] = result.then(
|
||||
(res) => res?.resources[domain]
|
||||
) as any;
|
||||
return resources[category].domains[domain] as Promise<CategoryType[T]>;
|
||||
return categoryResources.domains[domain] as Promise<CategoryType[T]>;
|
||||
};
|
||||
|
||||
export const getServiceIcons = async (
|
||||
|
||||
@@ -5,6 +5,23 @@ export interface ActionHandlerOptions {
|
||||
hasHold?: boolean;
|
||||
hasDoubleClick?: boolean;
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Only listen for keyboard activation; pointer gestures are handled
|
||||
* elsewhere (a container binding with `resolve` owns them).
|
||||
*/
|
||||
keyboardOnly?: boolean;
|
||||
/**
|
||||
* Container binding: resolve a pointer gesture at viewport coordinates
|
||||
* (x, y) to the element that should receive the action. Returning null
|
||||
* leaves the event alone. The resolver runs at both the start and the end
|
||||
* of a gesture; a release resolving to a different target aborts it.
|
||||
*/
|
||||
resolve?: (x: number, y: number, ev: Event) => ActionHandlerResolution | null;
|
||||
}
|
||||
|
||||
export interface ActionHandlerResolution {
|
||||
target: HTMLElement;
|
||||
options: ActionHandlerOptions;
|
||||
}
|
||||
|
||||
export interface ActionHandlerDetail {
|
||||
|
||||
@@ -7,6 +7,11 @@ export interface CustomPanelConfig {
|
||||
js_url?: string;
|
||||
module_url?: string;
|
||||
html_url?: string;
|
||||
// When true, the panel takes care of the safe-area insets itself (e.g. it
|
||||
// consumes the `--safe-area-inset-*` variables or draws into the safe area on
|
||||
// purpose). Home Assistant then skips adding its own safe-area padding around
|
||||
// the panel to avoid doubling the insets.
|
||||
handle_safe_area?: boolean;
|
||||
}
|
||||
|
||||
export type CustomPanelInfo<T = Record<string, unknown>> = PanelInfo<
|
||||
|
||||
@@ -80,6 +80,7 @@ export type Selector =
|
||||
| TTSVoiceSelector
|
||||
| SerialPortSelector
|
||||
| UiActionSelector
|
||||
| UiClockDateFormatSelector
|
||||
| UiColorSelector
|
||||
| UiStateContentSelector
|
||||
| UiTimeFormatSelector
|
||||
@@ -530,6 +531,10 @@ export interface StringSelector {
|
||||
placeholder?: string;
|
||||
autocomplete?: string;
|
||||
multiple?: true;
|
||||
// Regular expression the value must match (HTML `pattern`); with `multiple`
|
||||
// every entry is validated. `validation_message` is shown when it fails.
|
||||
pattern?: string;
|
||||
validation_message?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -577,6 +582,10 @@ export interface UiActionSelector {
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface UiClockDateFormatSelector {
|
||||
ui_clock_date_format: {} | null;
|
||||
}
|
||||
|
||||
export interface UiColorExtraOption {
|
||||
value: string;
|
||||
label: string;
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { atLeastVersion } from "../../common/config/version";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassioResponse } from "../hassio/common";
|
||||
import { hassioApiResultExtractor } from "../hassio/common";
|
||||
|
||||
export interface SupervisorApiCallOptions {
|
||||
method?: "get" | "post" | "delete";
|
||||
@@ -13,23 +10,11 @@ export const supervisorApiCall = async <T>(
|
||||
hass: HomeAssistant,
|
||||
endpoint: string,
|
||||
options?: SupervisorApiCallOptions
|
||||
): Promise<T> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
// Websockets was added in 2021.2.4
|
||||
return hass.callWS<T>({
|
||||
type: "supervisor/api",
|
||||
endpoint,
|
||||
method: options?.method || "get",
|
||||
timeout: options?.timeout ?? null,
|
||||
data: options?.data,
|
||||
});
|
||||
}
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<T>>(
|
||||
// @ts-ignore
|
||||
(options.method || "get").toUpperCase(),
|
||||
`hassio${endpoint}`,
|
||||
options?.data
|
||||
)
|
||||
);
|
||||
};
|
||||
): Promise<T> =>
|
||||
hass.callWS<T>({
|
||||
type: "supervisor/api",
|
||||
endpoint,
|
||||
method: options?.method || "get",
|
||||
timeout: options?.timeout ?? null,
|
||||
data: options?.data,
|
||||
});
|
||||
|
||||
@@ -1,32 +1,12 @@
|
||||
import { atLeastVersion } from "../../common/config/version";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassioResponse } from "../hassio/common";
|
||||
|
||||
export const restartCore = async (hass: HomeAssistant) => {
|
||||
await hass.callService("homeassistant", "restart");
|
||||
};
|
||||
|
||||
export const updateCore = async (hass: HomeAssistant, backup: boolean) => {
|
||||
if (atLeastVersion(hass.config.version, 2025, 2, 0)) {
|
||||
await hass.callWS({
|
||||
type: "hassio/update/core",
|
||||
backup: backup,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/core/update",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
data: { backup },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await hass.callApi<HassioResponse<void>>("POST", "hassio/core/update", {
|
||||
backup,
|
||||
await hass.callWS({
|
||||
type: "hassio/update/core",
|
||||
backup: backup,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -15,3 +15,7 @@ export const fetchWebhooks = (hass: HomeAssistant): Promise<Webhook[]> =>
|
||||
hass.callWS({
|
||||
type: "webhook/list",
|
||||
});
|
||||
|
||||
export const isActiveCloudWebhook = (hook: Webhook): boolean =>
|
||||
!hook.local_only &&
|
||||
(hook.domain !== "mobile_app" || hook.name !== "Deleted Webhook");
|
||||
|
||||
@@ -117,10 +117,14 @@ export interface SetZwaveUserParams {
|
||||
user_type?: string;
|
||||
credential_rule?: string;
|
||||
active?: boolean;
|
||||
credential_type?: ZwaveCredentialType;
|
||||
credential_slot?: number;
|
||||
credential_data?: string;
|
||||
}
|
||||
|
||||
export interface SetZwaveUserResult {
|
||||
user_id: number;
|
||||
credential_slot?: number | null;
|
||||
}
|
||||
|
||||
export interface SetZwaveCredentialParams {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
subscribeDataEntryFlowProgressed,
|
||||
} from "../../data/data_entry_flow";
|
||||
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
|
||||
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
|
||||
import { haStyleDialog } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { documentationUrl } from "../../util/documentation-url";
|
||||
@@ -74,7 +75,10 @@ declare global {
|
||||
}
|
||||
|
||||
@customElement("dialog-data-entry-flow")
|
||||
class DataEntryFlowDialog extends LitElement {
|
||||
class DataEntryFlowDialog extends DirtyStateProviderMixin<
|
||||
Record<string, unknown>,
|
||||
"form"
|
||||
>()(LitElement) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _params?: DataEntryFlowDialogParams;
|
||||
@@ -99,6 +103,8 @@ class DataEntryFlowDialog extends LitElement {
|
||||
|
||||
@state() private _createEntryHasPendingUpdates = false;
|
||||
|
||||
@state() private _flowHasProgressed = false;
|
||||
|
||||
private _formStepRef = createRef<FormStepElement>();
|
||||
|
||||
private _abortStepRef = createRef<AbortStepElement>();
|
||||
@@ -108,6 +114,8 @@ class DataEntryFlowDialog extends LitElement {
|
||||
private _unsubDataEntryFlowProgress?: UnsubscribeFunc;
|
||||
|
||||
public async showDialog(params: DataEntryFlowDialogParams): Promise<void> {
|
||||
this._initDirtyTracking({ type: "deep" });
|
||||
this._flowHasProgressed = Boolean(params.continueFlowId);
|
||||
this._params = params;
|
||||
this._instance = instance++;
|
||||
this._open = true;
|
||||
@@ -175,7 +183,7 @@ class DataEntryFlowDialog extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
this._processStep(step);
|
||||
this._processStep(step, this._flowHasProgressed);
|
||||
this._loading = undefined;
|
||||
}
|
||||
|
||||
@@ -213,6 +221,7 @@ class DataEntryFlowDialog extends LitElement {
|
||||
|
||||
this._loading = undefined;
|
||||
this._step = undefined;
|
||||
this._flowHasProgressed = false;
|
||||
this._params = undefined;
|
||||
this._handler = undefined;
|
||||
if (this._unsubDataEntryFlowProgress) {
|
||||
@@ -334,7 +343,7 @@ class DataEntryFlowDialog extends LitElement {
|
||||
return html`
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
prevent-scrim-close
|
||||
.preventScrimClose=${this._preventScrimClose}
|
||||
@after-show=${this._focusFormStep}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
@@ -484,6 +493,18 @@ class DataEntryFlowDialog extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private get _preventScrimClose(): boolean {
|
||||
return (
|
||||
this.isDirtyState ||
|
||||
this._flowHasProgressed ||
|
||||
this._loading !== undefined ||
|
||||
this._formStepLoading ||
|
||||
this._createEntryHasPendingUpdates ||
|
||||
this._step?.type === "external" ||
|
||||
this._step?.type === "progress"
|
||||
);
|
||||
}
|
||||
|
||||
private _renderFooter() {
|
||||
if (!this._step || this._loading) {
|
||||
return nothing;
|
||||
@@ -588,13 +609,15 @@ class DataEntryFlowDialog extends LitElement {
|
||||
}
|
||||
|
||||
private async _processStep(
|
||||
step: DataEntryFlowStep | undefined | Promise<DataEntryFlowStep>
|
||||
step: DataEntryFlowStep | undefined | Promise<DataEntryFlowStep>,
|
||||
flowHasProgressed = true
|
||||
): Promise<void> {
|
||||
if (step === undefined) {
|
||||
this.closeDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
this._flowHasProgressed ||= flowHasProgressed;
|
||||
const delayedLoading = setTimeout(() => {
|
||||
// only show loading for slow steps to avoid flickering
|
||||
this._loading = "loading_step";
|
||||
@@ -615,11 +638,11 @@ class DataEntryFlowDialog extends LitElement {
|
||||
clearTimeout(delayedLoading);
|
||||
this._loading = undefined;
|
||||
}
|
||||
|
||||
this._step = undefined;
|
||||
this._formStepLoading = false;
|
||||
this._createEntryHasPendingUpdates = false;
|
||||
await this.updateComplete;
|
||||
this._initDirtyTracking({ type: "deep" });
|
||||
this._step = _step;
|
||||
if (
|
||||
(_step.type === "create_entry" || _step.type === "abort") &&
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { consume } from "@lit/context";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -16,6 +17,10 @@ import type {
|
||||
import "../../components/ha-markdown";
|
||||
import "../../components/ha-spinner";
|
||||
import { autocompleteLoginFields } from "../../data/auth";
|
||||
import {
|
||||
dirtyStateContext,
|
||||
type DirtyStateContext,
|
||||
} from "../../data/context/dirty-state";
|
||||
import type { DataEntryFlowStepForm } from "../../data/data_entry_flow";
|
||||
import { previewModule } from "../../data/preview";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
@@ -49,6 +54,10 @@ class StepFlowForm extends LitElement {
|
||||
|
||||
@state() private _errorMsg?: string;
|
||||
|
||||
@consume({ context: dirtyStateContext, subscribe: true })
|
||||
@state()
|
||||
private _dirtyState?: DirtyStateContext<Record<string, unknown>, "form">;
|
||||
|
||||
private _errors?: Record<string, string>;
|
||||
|
||||
private _formRef = createRef<HTMLElementTagNameMap["ha-form"]>();
|
||||
@@ -150,6 +159,7 @@ class StepFlowForm extends LitElement {
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
this.addEventListener("keydown", this._handleKeyDown);
|
||||
this._dirtyState?.setState(this._stepDataProcessed, "form");
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues): void {
|
||||
@@ -303,6 +313,7 @@ class StepFlowForm extends LitElement {
|
||||
ev: ValueChangedEvent<Record<string, unknown>>
|
||||
): void {
|
||||
this._stepData = ev.detail.value;
|
||||
this._dirtyState?.setState(this._stepData, "form");
|
||||
}
|
||||
|
||||
private _labelCallback = (field: HaFormSchema, _data, options): string =>
|
||||
|
||||
@@ -80,27 +80,26 @@ class StepFlowMenu extends LitElement {
|
||||
return html`
|
||||
${description ? html`<div class="content">${description}</div>` : nothing}
|
||||
<div class="options">
|
||||
${options.map(
|
||||
(option) => html`
|
||||
${options.map((option) => {
|
||||
const optionDescription = optionDescriptions[option];
|
||||
return html`
|
||||
<ha-list-item
|
||||
hasMeta
|
||||
.step=${option}
|
||||
@click=${this._handleStep}
|
||||
?twoline=${optionDescriptions[option]}
|
||||
?multiline-secondary=${optionDescriptions[option]}
|
||||
?twoline=${optionDescription}
|
||||
?multiline-secondary=${optionDescription}
|
||||
>
|
||||
<span>${translations[option]}</span>
|
||||
${
|
||||
optionDescriptions[option]
|
||||
? html`<span slot="secondary">
|
||||
${optionDescriptions[option]}
|
||||
</span>`
|
||||
optionDescription
|
||||
? html`<span slot="secondary"> ${optionDescription} </span>`
|
||||
: nothing
|
||||
}
|
||||
<ha-icon-next slot="meta"></ha-icon-next>
|
||||
</ha-list-item>
|
||||
`
|
||||
)}
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
import { mdiArrowRight } from "@mdi/js";
|
||||
import { ERR_CONNECTION_LOST } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { formatNumericDuration } from "../../common/datetime/format_duration";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import "../../components/ha-alert";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-dialog-footer";
|
||||
import "../../components/ha-dialog";
|
||||
import "../../components/ha-svg-icon";
|
||||
import type { HttpConfig } from "../../data/http";
|
||||
import {
|
||||
HTTP_CONFIG_FIELDS,
|
||||
promoteHttpConfig,
|
||||
saveHttpConfig,
|
||||
} from "../../data/http";
|
||||
import { haStyleDialog } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassDialog } from "../make-dialog-manager";
|
||||
import type { HttpPendingConfigDialogParams } from "./show-dialog-http-pending-config";
|
||||
|
||||
@customElement("dialog-http-pending-config")
|
||||
export class DialogHttpPendingConfig
|
||||
extends LitElement
|
||||
implements HassDialog<HttpPendingConfigDialogParams>
|
||||
{
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _params?: HttpPendingConfigDialogParams;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@state() private _busy: "confirm" | "revert" | undefined;
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@state() private _secondsRemaining?: number;
|
||||
|
||||
@state() private _reverted = false;
|
||||
|
||||
private _interval?: number;
|
||||
|
||||
public showDialog(params: HttpPendingConfigDialogParams): void {
|
||||
this._params = params;
|
||||
this._open = true;
|
||||
this._busy = undefined;
|
||||
this._error = undefined;
|
||||
this._reverted = false;
|
||||
this._startCountdown();
|
||||
// The field labels live in the config panel fragment, which is not loaded
|
||||
// yet when this dialog pops up on startup. Load it so the changed-field
|
||||
// names resolve; the dialog re-renders once hass updates.
|
||||
this.hass.loadFragmentTranslation("config");
|
||||
}
|
||||
|
||||
public closeDialog(): boolean {
|
||||
this._open = false;
|
||||
this._stopCountdown();
|
||||
return true;
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._stopCountdown();
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._params = undefined;
|
||||
this._busy = undefined;
|
||||
this._error = undefined;
|
||||
this._reverted = false;
|
||||
this._secondsRemaining = undefined;
|
||||
this._stopCountdown();
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
private _startCountdown(): void {
|
||||
this._stopCountdown();
|
||||
const revertAt = this._params?.state.revert_at;
|
||||
if (!revertAt) {
|
||||
this._secondsRemaining = undefined;
|
||||
return;
|
||||
}
|
||||
const target = new Date(revertAt).getTime();
|
||||
const tick = () => {
|
||||
const remaining = Math.max(0, Math.ceil((target - Date.now()) / 1000));
|
||||
this._secondsRemaining = remaining;
|
||||
if (remaining === 0) {
|
||||
this._stopCountdown();
|
||||
this._reverted = true;
|
||||
}
|
||||
};
|
||||
tick();
|
||||
if (this._secondsRemaining && this._secondsRemaining > 0) {
|
||||
this._interval = window.setInterval(tick, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
private _stopCountdown(): void {
|
||||
if (this._interval) {
|
||||
window.clearInterval(this._interval);
|
||||
this._interval = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private get _changedFields(): (keyof HttpConfig)[] {
|
||||
if (!this._params?.state.pending) {
|
||||
return [];
|
||||
}
|
||||
const { stable, pending } = this._params.state;
|
||||
return HTTP_CONFIG_FIELDS.filter(
|
||||
(key) => JSON.stringify(stable[key]) !== JSON.stringify(pending[key])
|
||||
);
|
||||
}
|
||||
|
||||
private _formatValue(key: keyof HttpConfig, value: unknown): string {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return this.hass.localize("ui.dialogs.http_pending_config.not_set");
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return this.hass.localize(value ? "ui.common.yes" : "ui.common.no");
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.length
|
||||
? value.join(", ")
|
||||
: this.hass.localize("ui.dialogs.http_pending_config.not_set");
|
||||
}
|
||||
if (key === "ssl_profile") {
|
||||
return (
|
||||
this.hass.localize(
|
||||
`ui.panel.config.network.http.ssl_profile_${value}` as any
|
||||
) || String(value)
|
||||
);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const changes = this._changedFields;
|
||||
const { stable, pending } = this._params.state;
|
||||
const rtl = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
.headerTitle=${this.hass.localize(
|
||||
"ui.dialogs.http_pending_config.title"
|
||||
)}
|
||||
prevent-scrim-close
|
||||
width="medium"
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<span slot="headerNavigationIcon"></span>
|
||||
<div class="content">
|
||||
${
|
||||
this._reverted
|
||||
? html`
|
||||
<ha-alert alert-type="info">
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.http_pending_config.reverted"
|
||||
)}
|
||||
</ha-alert>
|
||||
`
|
||||
: html`
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.http_pending_config.description"
|
||||
)}
|
||||
</p>
|
||||
${
|
||||
this._secondsRemaining !== undefined
|
||||
? html`
|
||||
<p class="countdown">
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.http_pending_config.auto_revert",
|
||||
{
|
||||
time:
|
||||
formatNumericDuration(this.hass.locale, {
|
||||
minutes: Math.floor(
|
||||
this._secondsRemaining / 60
|
||||
),
|
||||
seconds: this._secondsRemaining % 60,
|
||||
}) ?? "0",
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
`
|
||||
}
|
||||
${
|
||||
changes.length
|
||||
? html`
|
||||
<p class="changes-label">
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.http_pending_config.changes_label"
|
||||
)}
|
||||
</p>
|
||||
<ul>
|
||||
${changes.map(
|
||||
(key) => html`
|
||||
<li>
|
||||
<span class="field">
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.network.http.fields.${key}` as any
|
||||
)}
|
||||
</span>
|
||||
<span class="values">
|
||||
<span class="old"
|
||||
>${this._formatValue(key, stable[key])}</span
|
||||
>
|
||||
<ha-svg-icon
|
||||
.path=${mdiArrowRight}
|
||||
style=${styleMap({
|
||||
transform: rtl ? "scaleX(-1)" : "",
|
||||
})}
|
||||
></ha-svg-icon>
|
||||
<span class="new"
|
||||
>${this._formatValue(key, pending![key])}</span
|
||||
>
|
||||
</span>
|
||||
</li>
|
||||
`
|
||||
)}
|
||||
</ul>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<ha-dialog-footer slot="footer">
|
||||
${
|
||||
this._reverted
|
||||
? html`
|
||||
<ha-button slot="primaryAction" @click=${this._close}>
|
||||
${this.hass.localize("ui.dialogs.http_pending_config.close")}
|
||||
</ha-button>
|
||||
`
|
||||
: html`
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
appearance="plain"
|
||||
.loading=${this._busy === "revert"}
|
||||
.disabled=${this._busy === "confirm"}
|
||||
@click=${this._revert}
|
||||
>
|
||||
${this.hass.localize("ui.dialogs.http_pending_config.revert")}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
.loading=${this._busy === "confirm"}
|
||||
.disabled=${this._busy === "revert"}
|
||||
@click=${this._confirm}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.http_pending_config.confirm"
|
||||
)}
|
||||
</ha-button>
|
||||
`
|
||||
}
|
||||
</ha-dialog-footer>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _confirm(): Promise<void> {
|
||||
if (this._busy) {
|
||||
return;
|
||||
}
|
||||
this._busy = "confirm";
|
||||
this._error = undefined;
|
||||
try {
|
||||
await promoteHttpConfig(this.hass);
|
||||
this._notifyResolved();
|
||||
this._open = false;
|
||||
} catch (err: any) {
|
||||
// A confirm fired right as the backend auto-reverts may race the
|
||||
// restart and surface as a connection-lost error. Treat it as resolved;
|
||||
// the disconnected overlay takes over.
|
||||
if (err?.error?.code === ERR_CONNECTION_LOST) {
|
||||
this._notifyResolved();
|
||||
this._open = false;
|
||||
return;
|
||||
}
|
||||
this._error = this.hass.localize(
|
||||
"ui.dialogs.http_pending_config.confirm_error",
|
||||
{ error: err.message ?? "" }
|
||||
);
|
||||
this._busy = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _close(): void {
|
||||
this._notifyResolved();
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
private async _revert(): Promise<void> {
|
||||
if (this._busy || !this._params) {
|
||||
return;
|
||||
}
|
||||
this._busy = "revert";
|
||||
this._error = undefined;
|
||||
try {
|
||||
await saveHttpConfig(this.hass, null);
|
||||
this._notifyResolved();
|
||||
this._open = false;
|
||||
} catch (err: any) {
|
||||
// The restart triggered by clearing pending may cut the WS connection
|
||||
// before we get a reply. The disconnected overlay takes over from here.
|
||||
if (err?.error?.code === ERR_CONNECTION_LOST) {
|
||||
this._notifyResolved();
|
||||
this._open = false;
|
||||
return;
|
||||
}
|
||||
this._error = this.hass.localize(
|
||||
"ui.dialogs.http_pending_config.revert_error",
|
||||
{ error: err.message ?? "" }
|
||||
);
|
||||
this._busy = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _notifyResolved(): void {
|
||||
this._params?.onResolved?.();
|
||||
// The form on Settings > System > Network may be mounted and showing
|
||||
// stale state; let it know to refetch.
|
||||
window.dispatchEvent(new Event("http-config-resolved"));
|
||||
}
|
||||
|
||||
static styles = [
|
||||
haStyleDialog,
|
||||
css`
|
||||
.content {
|
||||
line-height: var(--ha-line-height-normal);
|
||||
}
|
||||
p {
|
||||
margin: 0 0 var(--ha-space-4) 0;
|
||||
}
|
||||
.countdown {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.changes-label {
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
margin-bottom: var(--ha-space-2);
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0 0 var(--ha-space-4) 0;
|
||||
padding: 0;
|
||||
}
|
||||
li {
|
||||
padding: var(--ha-space-2) 0;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.field {
|
||||
display: block;
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
margin-bottom: var(--ha-space-1);
|
||||
}
|
||||
.values {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--ha-space-2);
|
||||
color: var(--secondary-text-color);
|
||||
word-break: break-word;
|
||||
}
|
||||
.values .new {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.values ha-svg-icon {
|
||||
--mdc-icon-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
ha-alert {
|
||||
display: block;
|
||||
margin-top: var(--ha-space-4);
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"dialog-http-pending-config": DialogHttpPendingConfig;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { HttpConfigState } from "../../data/http";
|
||||
|
||||
export interface HttpPendingConfigDialogParams {
|
||||
state: HttpConfigState;
|
||||
onResolved?: () => void;
|
||||
}
|
||||
|
||||
export const loadHttpPendingConfigDialog = () =>
|
||||
import("./dialog-http-pending-config");
|
||||
|
||||
export const showHttpPendingConfigDialog = (
|
||||
element: HTMLElement,
|
||||
dialogParams: HttpPendingConfigDialogParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "dialog-http-pending-config",
|
||||
dialogImport: loadHttpPendingConfigDialog,
|
||||
dialogParams,
|
||||
addHistory: false,
|
||||
});
|
||||
};
|
||||
@@ -257,10 +257,6 @@ class HaMoreInfoDetails extends LitElement {
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
|
||||
ha-card {
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: var(--ha-space-2) var(--ha-space-4);
|
||||
}
|
||||
|
||||
@@ -249,6 +249,7 @@ class DialogShortcuts extends DialogMixin(LitElement) {
|
||||
static styles = [
|
||||
css`
|
||||
.shortcut {
|
||||
direction: ltr;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user