Compare commits

..

8 Commits

Author SHA1 Message Date
Claude 2ad86643ab Keep supervisor feature flags typed
Flags are temporary by design, so a union is worth more here than a plain
string key: when one is retired from the Supervisor, removing it from this
type surfaces every place in the frontend still reading it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKb38CdFmbwAwfy8xbci8S
2026-07-31 09:44:17 +00:00
Claude 1c4c7c5192 Keep iot_class and the ZHA radio type as unions
Both are stable enumerations rather than growing registries, so a closed
union is the better trade. Retain the members that were genuinely missing:
"calculated" on iot_class and ziggurat on the radio type.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKb38CdFmbwAwfy8xbci8S
2026-07-31 06:30:14 +00:00
Claude 8479efdc66 Do not model open backend value sets as closed unions
Several types enumerated values that come from a backend list or mapping
rather than a fixed protocol enum. Those unions go stale silently whenever
the backend gains an entry, and none of them are narrowed on in the frontend.

- Thread router brand is looked up in the backend's vendor-name-to-brand map,
  which grows per vendor. It is only passed to brandsUrl as a domain.
- The ZHA radio type grows whenever a new radio library is supported; this
  session already had to add ziggurat to it. It is only rendered as text.
- Supervisor feature flags are development toggles that come and go, and are
  only read as a record of booleans.
- iot_class comes from a hand-maintained hassfest list that had already gained
  "calculated" without the frontend following. Every consumer only tests
  startsWith("cloud_").

installation_type is a genuinely closed set, so it stays a union, but the
literals were wrong: the backend emits "Home Assistant OS", not "Home
Assistant Operating System", and "Unsupported Third Party Container" was
missing. The onboarding supervisor check already tested for the correct
value, so it was comparing against a string the declared type said was
impossible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKb38CdFmbwAwfy8xbci8S
2026-07-31 06:17:16 +00:00
Claude ec3bba3469 Correct media browsing, TTS and STT response types
- BrowseMedia.as_dict always emits children_media_class, search_media_classes
  and thumbnail, nullable rather than absent. Only not_shown and children are
  conditional, and those are already optional.
- The tts engine list only sets deprecated for legacy providers shadowed by an
  entity-based engine, and supported_languages is always present.
- The stt engine list never sets deprecated at all, so the picker's filter on
  it is currently inert. Kept the field, marked optional to match the wire.

Verified as already correct: conversation agents, wake word info, media source
resolution, and the media_player browse payload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKb38CdFmbwAwfy8xbci8S
2026-07-29 05:56:03 +00:00
Claude 299514d04f Sync cloud status and preference types with the backend
- The strict_connection preference no longer exists anywhere in the cloud
  integration. It was still modelled, still offered as an update parameter
  (which the update_prefs schema would now reject), and still gated an
  unreachable drop-connection warning in the remote panel.
- google_domains is likewise not part of the account payload any more, while
  google_local_connected is sent and was not modelled.
- The preferences payload also carries alexa_default_expose and
  google_default_expose, both nullable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKb38CdFmbwAwfy8xbci8S
2026-07-29 05:49:12 +00:00
Claude 21754d40d1 Verify ZHA, Matter and Z-Wave types against their client libraries
Installed zha 2.0.1, matter-python-client 1.3.0 and zwave-js-server-python
0.72.0 to read the dataclasses and enums these integrations serialize,
rather than inferring their shapes from the core wrappers.

ZHA:
- lqi and rssi come from zigpy as int | None; rssi was typed as a string
- manufacturer_code is nullable, and exposes_features and endpoint_names
  were missing from the device payload entirely
- Neighbor only modelled 5 of the 9 keys the topology payload sends
- Network backups carry a version, and ziggurat was missing from the radio
  type union

Matter:
- dataclass_to_dict uses asdict without dropping None, so network_name,
  mac_address, fabric_label and vendor_name are always present and nullable
  rather than optional
- NetworkType, NodeType and the commissioning parameters already matched

Z-Wave JS:
- controller status is the ControllerStatus enum (ready/unresponsive/jammed),
  now modelled instead of a bare number

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKb38CdFmbwAwfy8xbci8S
2026-07-29 05:43:49 +00:00
Claude 30eb330cfb Fix request payload and remaining response type mismatches
Extracted every websocket command schema from core (466 commands, plus the
57 storage-collection CRUD call sites) and compared them against every
callWS/sendMessagePromise/subscribeMessage payload the frontend sends. The
command schemas extend BASE_COMMAND_MESSAGE_SCHEMA, which is PREVENT_EXTRA,
so an unknown key is a hard validation error rather than a silent no-op.

Request payloads:
- Assist pipeline conversation_language was sent as null when the selected
  conversation agent reports no supported languages, but the backend schema
  requires a string. Send the "*" match-all value the backend already uses,
  and type the field as non-nullable on both the pipeline and its mutable
  params.
- The backup config schedule days had a null branch the backend schema does
  not accept. The frontend already sends [] to clear it.

Responses:
- Z-Wave JS network status reports client.server_logging_enabled and
  controller.status, neither of which were modelled.
- Thread router addresses is a list, not a one-element tuple, brand covers
  every vendor in the backend's brand map and can be null, and the router
  removal event carries no data, so model the discovery event as a
  discriminated union.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKb38CdFmbwAwfy8xbci8S
2026-07-29 05:35:34 +00:00
Claude aa10b9b715 Align frontend API types with the backend
Audited the TypeScript types under src/data against the core websocket/REST
serializers and the Supervisor REST API, and corrected the mismatches.

Core:
- ConfigEntry: add the unload_in_progress state, and created_at/modified_at
- SubEntry.unique_id and DeviceRegistryEntry config_entry_id/config_subentry_id
- removeConfigEntryFromDevice can resolve to null
- config_entries/flow/subscribe: removed messages carry no flow, so model the
  message as a discriminated union
- Entity registry: original_name/translation_key are always sent (nullable),
  options is never null, hidden_by is only user/integration, and the extended
  entry's capabilities/device_class/original_device_class/original_icon are
  always present and nullable
- CategoryRegistryEntry gains the registry timestamps
- updateFloorRegistryEntry returned an area entry
- search/related can return floor, label and person results
- Manifest zeroconf entries can be objects, and integration_type can be virtual
- LoggedError.message holds up to five deduplicated messages
- StatisticsMetaData: add display_unit_of_measurement and has_mean, name is
  always present
- Backup: add the blocked manager state, the event reason, the core_restart
  restore state, nullable homeassistant_version/addon names, and the
  agent_errors on backup details
- Energy validation placeholders are nullable
- Entity feature flags: add MEDIA_ANNOUNCE/MEDIA_ENQUEUE/SEARCH_MEDIA,
  water heater ON_OFF and assist satellite START_CONVERSATION, and drop the
  removed climate AUX_HEAT along with the stale aux_heat attribute

Supervisor:
- Host info nullability, plus the fields the API reports that were missing
- OS info gains boot_slots/version_pending, data_disk is nullable
- Resolution issues and suggestions are objects, not strings, and checks was
  missing entirely
- Supervisor info: addons and addons_repositories are object lists, plus
  auto_update/country/detect_blocking_io/feature_flags
- Add-on details: services and uart were named services_role and auto_uart,
  and boot_config/dns/host_uts/ingress_port/udev/usb/video were missing
- Store add-on details apparmor is a profile name, long_description is nullable
- Mounts: the state enum mirrors the systemd unit state, read_only and
  user_path were missing, and port is optional

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKb38CdFmbwAwfy8xbci8S
2026-07-28 13:19:46 +00:00
246 changed files with 2576 additions and 8475 deletions
-65
View File
@@ -1,65 +0,0 @@
---
name: ha-frontend-demo
description: Home Assistant standalone demo structure, configurations, URL navigation, mocked APIs, shared demo stubs used by gallery, and verification. Use when changing files under demo/, changing gallery consumers of demo/src/stubs/, or running and validating the demo.
---
# HA Frontend Demo
Use this skill for standalone demo work and changes to shared demo stubs consumed by the gallery. Follow the persistent repository guidance in `AGENTS.md` and load matching specialist skills alongside this skill, especially `ha-frontend-testing` when running or validating the demo.
## Purpose
The demo is the full Home Assistant frontend running against a mocked backend and is published at <https://demo.home-assistant.io>. It needs no Home Assistant server, which makes it the easiest way to load the real UI in a browser, for example to take screenshots.
## Running The Demo
Run commands from the repository root:
```bash
yarn dev:demo # Development server on http://localhost:8090
yarn dev:demo --background # Detached; also supports --status/--stop/--logs
```
Use the E2E workflows documented in `ha-frontend-testing` when validating the demo.
## Opening A Specific Demo
The demo contains multiple demo configurations. Select one directly with the `demo` query parameter, for example `http://localhost:8090/?demo=<slug>`. The valid slugs are defined in `demoConfigs` in `demo/src/configs/demo-configs.ts`, so "the second demo" means the slug of the second entry in that list. An unknown slug falls back to the default, which is the first entry.
## Opening A Specific Page
The demo build uses hash-based routing: the frontend path goes in the URL hash, and the `demo` query parameter goes before the `#`. The URL format is:
```text
http://localhost:8090/?demo=<slug>#/<path>
```
Useful paths:
- `/lovelace/0`: The selected demo's dashboard, also the default when no hash is given.
- `/energy`: The energy dashboard. Its tabs are views: `/energy/overview` (Summary), `/energy/electricity`, `/energy/gas`, `/energy/water`, and `/energy/now`.
- `/map`, `/history`, `/todo`, `/config`: Other sidebar panels.
For example, the water tab of the energy dashboard of the second demo is:
```text
http://localhost:8090/?demo=<second slug>#/energy/water
```
## Structure
- `demo/src/ha-demo.ts`: Root element that sets up all backend mocks.
- `demo/src/configs/<slug>/`: One directory per demo configuration, containing entities, dashboard, and theme data.
- `demo/src/configs/demo-configs.ts`: Registry of demo configurations and URL slug handling.
- `demo/src/stubs/`: Mocked WebSocket and REST APIs.
- `demo/script/develop_demo`, `demo/script/build_demo`: Development server and static build wrappers.
## Shared Gallery Stubs
`demo/src/stubs/` is shared, not demo-private: gallery pages import from it directly. Before changing or removing anything a stub does, search for its callers across `demo/src/` and `gallery/src/`, then check the affected gallery pages as well as the demo.
The two consumers use a stub differently, so demo behavior does not predict gallery behavior. A gallery page calls stubs against the `hass` from `provideHass`, where `hass.config` is the shared `demoConfig` object. A stub that mutates `hass.config` in place is therefore visible to the page. `demo/src/ha-demo.ts` copies `components` into a new array before the stubs run, so the same mutation never reaches the demo. A change can look correct in the demo while quietly breaking a gallery page.
## Verification
Load `ha-frontend-testing` and use its demo and gallery workflows. Validate both consumers when changing shared stubs. Documentation-only changes do not require code tests unless examples or commands change.
+2 -23
View File
@@ -20,7 +20,6 @@ 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 build # Full production build
yarn dev # App dev server
yarn dev:serve # Local serving dev server
```
@@ -29,26 +28,6 @@ Never run `tsc` or `yarn lint:types` with file arguments. File arguments make `t
For focused type feedback on one file, use editor diagnostics instead of a file-scoped `tsc` command.
## Production Builds
Production builds support foreground and managed background execution:
```bash
yarn build # Full foreground build
yarn build --background # Full managed background build
yarn build --modern # Modern frontend_latest bundle only
yarn build --modern --background # Modern managed background build
yarn build --status
yarn build --logs [--follow]
yarn build --stop
```
Use `yarn build --modern --background` for production bundle-size or browser performance comparisons that only need modern browser output. It runs the normal metadata and static preparation, minifies and compresses the modern `frontend_latest` bundle and shared static assets, and generates modern-only entry pages and service workers. It deliberately skips the legacy bundle and its service worker.
Do not pass `--help`, `--background`, or `--modern` to `script/build_frontend`; that raw script does not parse arguments and always starts the full foreground build. Use `yarn build` for managed builds. App builds and development servers keep exclusive ownership of `hass_frontend/` for their lifetime.
Managed app, demo, gallery, and E2E app workflows share one lifetime lock, so only one build or development server can run at a time.
## Unit And Utility Tests
- Add or update Vitest tests for data processing, utility code, and behavior that can be tested without a browser.
@@ -62,7 +41,7 @@ Managed app, demo, gallery, and E2E app workflows share one lifetime lock, so on
`yarn dev:serve` also serves locally and supports `-c` for the core URL and `-p` for the port. The default is 8124, or 8123 in a devcontainer.
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`. `yarn dev`, `yarn dev:serve`, `yarn dev:demo`, and `yarn dev:gallery` also support `--fetch-translations`; this runs translation fetching, including first-time GitHub device authentication, under the workflow lock before starting the watcher. It works in foreground and background modes. Prefer managed background mode while iterating so the watcher stays available across test runs without occupying the terminal. `yarn dev` and `yarn dev:serve` share one managed process slot because both write the app output.
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`. Prefer managed background mode while iterating so the watcher stays available across test runs without occupying the terminal.
## Playwright E2E
@@ -80,7 +59,7 @@ The custom development wrappers use `/__ha_dev_status` to identify and manage th
Local runs against a watched development server do not always match CI's clean build artifacts, environment, sharding, or worker configuration. Use background servers for the fast iteration loop, but confirm the relevant CI jobs complete successfully before considering E2E changes verified.
Use `-g "<title>" --project=chromium` to narrow a run. `yarn test:e2e` runs suites sequentially when managed servers are unavailable to prevent cold builds racing over shared generated assets. Run suites directly; piping through output truncation hides progress and failures.
Use `-g "<title>" --project=chromium` to narrow a run. `yarn test:e2e` runs all three suites in parallel when every managed server is available, otherwise it runs them sequentially to prevent cold builds racing over shared generated assets. 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.
+1 -1
View File
@@ -1 +1 @@
24.18.1
24.18.0
+2 -1
View File
@@ -2,6 +2,8 @@
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 the standalone demo — including how to open a specific demo configuration or page via URL — read `demo/AGENTS.md`.
## Essential Commands
```bash
@@ -45,7 +47,6 @@ Detailed guidance lives in project skills under `.agents/skills/`. Load the matc
- `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.
- `ha-frontend-gallery`: gallery pages, demos, sidebar structure, content, and verification.
- `ha-frontend-demo`: standalone demo structure, configurations, navigation, shared stubs, and verification.
## Pull Requests
+1 -1
View File
@@ -12,7 +12,7 @@ This is the repository for the official [Home Assistant](https://home-assistant.
- Initial setup: `script/setup`
- Development: [Instructions](https://developers.home-assistant.io/docs/frontend/development/)
- Production build: `yarn build`
- Production build: `script/build_frontend`
- Gallery: `cd gallery && script/develop_gallery`
## Frontend development
-311
View File
@@ -1,311 +0,0 @@
// Manage a Home Assistant frontend production build with an agent-friendly
// interface, matching build-scripts/dev-server.mjs.
//
// node build-scripts/build-manager.mjs [--modern] [mode]
//
// (no mode) Run in the foreground.
// --background Start detached, print the pid, then exit and leave it
// running.
// --status Report whether a managed build is running.
// --stop Stop a managed build.
// --logs [--follow] Print (or follow) the background build log.
//
// --modern Build only the modern frontend_latest bundle.
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
LIFECYCLE_MODE_FLAGS,
acquireProcessRecord,
isProcessRecordAlive,
outputLog,
offerToStopProcessRecord,
processStartTime,
readProcessRecord,
releaseProcessRecord,
runCli,
spawnDetachedToLog,
spawnForeground,
terminateDetachedProcess,
terminateProcess,
waitFor,
writeProcessRecord,
} from "./managed-process.mjs";
import {
buildCacheDir,
describeOutputOwner,
workflowLockEnv,
workflowLockFile,
} from "./output-lock.mjs";
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
".."
);
const gulpBin = path.join(repoRoot, "node_modules", ".bin", "gulp");
const stateDir = path.join(buildCacheDir, "ha-build");
const logFile = path.join(stateDir, "build.log");
const lockFile = workflowLockFile;
const usage = () => {
process.stderr.write(
"Usage: node build-scripts/build-manager.mjs [--modern] " +
"[--background | --status | --stop | --logs [--follow]]\n"
);
};
const parseArgs = (argv) => {
const args = {
mode: "foreground",
modes: [],
follow: false,
modern: false,
unknown: [],
};
for (const arg of argv) {
if (LIFECYCLE_MODE_FLAGS.has(arg)) {
args.mode = LIFECYCLE_MODE_FLAGS.get(arg);
args.modes.push(arg);
} else if (arg === "--modern") {
args.modern = true;
} else if (arg === "--follow") {
args.follow = true;
} else {
args.unknown.push(arg);
}
}
return args;
};
const hints = () =>
" Stop: yarn build --stop\n" +
" Status: yarn build --status\n" +
" Logs: yarn build --logs\n";
const devCommand = (suite) => {
switch (suite) {
case "app-serve":
return "dev:serve";
case "demo":
return "dev:demo";
case "gallery":
return "dev:gallery";
case "e2e-app":
return "test:e2e:app:dev";
default:
return "dev";
}
};
const readBuild = () => readProcessRecord(lockFile);
const releaseBuild = (token) => releaseProcessRecord(lockFile, token);
const stopCommandFor = (owner) =>
owner?.kind === "build"
? "yarn build --stop"
: owner?.kind === "dev"
? `yarn ${devCommand(owner.suite)} --stop`
: undefined;
const acquireBuild = async (modern, foreground) => {
const token = `${process.pid}-${Date.now()}-${Math.random()}`;
const record = {
pid: process.pid,
startTime: processStartTime(process.pid),
processGroup: false,
foreground,
kind: "build",
modern,
starting: true,
token,
};
const result = acquireProcessRecord(lockFile, record);
if (result.acquired) {
return { token };
}
reportExisting(result.existing);
return (await offerToStopProcessRecord({
file: lockFile,
owner: result.existing,
ownerDescription: describeOutputOwner(result.existing),
stopCommand: stopCommandFor(result.existing),
}))
? acquireBuild(modern, foreground)
: { existing: result.existing };
};
const updateBuild = (token, child, processGroup) => {
const existing = readBuild();
if (existing?.token !== token) {
throw Error("Frontend build lock ownership was lost during startup.");
}
writeProcessRecord(lockFile, {
...existing,
pid: child.pid,
startTime: processStartTime(child.pid),
processGroup,
starting: false,
});
};
const taskFor = (modern) => (modern ? "build-app-modern" : "build-app");
const reportExisting = (existing) => {
if (existing?.kind === "output") {
process.stdout.write(
`${describeOutputOwner(existing)} already owns the build and development workflow` +
`${existing.pid ? ` (pid ${existing.pid})` : ""}.\n`
);
return;
}
if (existing?.kind === "dev") {
const command = devCommand(existing.suite);
process.stdout.write(
`Dev server (${existing.suite}) already running` +
`${existing.pid ? ` (pid ${existing.pid})` : ""}.\n` +
` Stop: yarn ${command} --stop\n` +
` Status: yarn ${command} --status\n` +
` Logs: yarn ${command} --logs\n`
);
return;
}
process.stdout.write(
`Frontend ${existing?.modern ? "modern " : ""}build already running` +
`${existing?.pid ? ` (pid ${existing.pid})` : ""}.\n${hints()}`
);
};
const runForeground = async (modern) => {
const lock = await acquireBuild(modern, true);
if (!lock.token) {
return 1;
}
try {
return await spawnForeground({
cmd: gulpBin,
args: [taskFor(modern)],
cwd: repoRoot,
env: workflowLockEnv(lock.token),
processGroup: true,
onSpawn: (child) => updateBuild(lock.token, child, true),
});
} finally {
releaseBuild(lock.token);
}
};
const runBackground = async (modern) => {
const lock = await acquireBuild(modern, false);
if (!lock.token) {
return 1;
}
let child;
try {
child = await spawnDetachedToLog({
cmd: gulpBin,
args: [taskFor(modern)],
cwd: repoRoot,
env: workflowLockEnv(lock.token),
logFile,
});
updateBuild(lock.token, child, true);
process.stdout.write(
`Started ${modern ? "modern " : ""}frontend build (pid ${child.pid})\n` +
hints()
);
return 0;
} catch (err) {
if (child) {
await terminateDetachedProcess(child);
}
releaseBuild(lock.token);
throw err;
}
};
const runStatus = () => {
const existing = readBuild();
if (existing?.kind === "build" && isProcessRecordAlive(existing)) {
process.stdout.write(
`Frontend ${existing.modern ? "modern " : ""}build running (pid ${existing.pid}).\n`
);
} else {
if (existing?.kind === "build") {
releaseBuild(existing.token);
}
process.stdout.write("Frontend build not running.\n");
}
return 0;
};
const runStop = async () => {
let existing = readBuild();
if (existing?.kind !== "build") {
process.stdout.write("Frontend build not running.\n");
return 0;
}
if (existing?.starting) {
const token = existing.token;
await waitFor(
() => {
const current = readBuild();
return !current?.starting || current.token !== token;
},
100,
5000
);
existing = readBuild();
}
if (
!existing ||
existing.kind !== "build" ||
!isProcessRecordAlive(existing)
) {
if (existing) releaseBuild(existing.token);
process.stdout.write("Frontend build not running.\n");
return 0;
}
if (
!(await terminateProcess({
pid: existing.pid,
processGroup: existing.processGroup,
isStopped: () => !isProcessRecordAlive(existing),
}))
) {
process.stderr.write(
`Failed to stop frontend build (pid ${existing.pid}). Stop it manually.\n`
);
return 1;
}
releaseBuild(existing.token);
process.stdout.write(`Stopped frontend build (pid ${existing.pid}).\n`);
return 0;
};
const runLogs = (follow) =>
outputLog(logFile, follow, `No frontend build log yet (${logFile}).\n`);
const main = async () => {
const args = parseArgs(process.argv.slice(2));
if (args.unknown.length) {
process.stderr.write(`Unknown arguments: ${args.unknown.join(" ")}\n`);
usage();
return 1;
}
if (args.modes.length > 1 || (args.follow && args.mode !== "logs")) {
process.stderr.write("Invalid combination of build arguments.\n");
usage();
return 1;
}
const handlers = {
foreground: () => runForeground(args.modern),
background: () => runBackground(args.modern),
status: runStatus,
stop: runStop,
logs: () => runLogs(args.follow),
};
return handlers[args.mode]();
};
runCli(main);
+8 -8
View File
@@ -1,15 +1,15 @@
{
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
"frontend-modern": {
"app": 595204,
"core": 57741,
"authorize": 576928,
"onboarding": 685964
"app": 561513,
"core": 54473,
"authorize": 544272,
"onboarding": 647136
},
"frontend-legacy": {
"app": 861452,
"core": 258557,
"authorize": 834356,
"onboarding": 1001360
"app": 790323,
"core": 237208,
"authorize": 765464,
"onboarding": 918679
}
}
File diff suppressed because it is too large Load Diff
-27
View File
@@ -1,6 +1,5 @@
import gulp from "gulp";
import env from "../env.cjs";
import { createWorkflowLockTask } from "../output-lock.mjs";
import "./clean.js";
import "./compress.js";
import "./entry-html.js";
@@ -18,7 +17,6 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "development";
},
createWorkflowLockTask("develop-app"),
"clean",
gulp.parallel(
"gen-service-worker-app-dev",
@@ -38,7 +36,6 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "production";
},
createWorkflowLockTask("build-app"),
"clean",
gulp.parallel(
"gen-icons-json",
@@ -54,30 +51,6 @@ gulp.task(
)
);
gulp.task(
"build-app-modern",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "production";
},
createWorkflowLockTask("build-app-modern"),
"clean",
gulp.parallel(
"gen-icons-json",
"build-translations",
"build-locale-data",
"gen-licenses"
),
"copy-static-app",
"rspack-prod-app-modern",
gulp.parallel(
"gen-pages-app-prod-modern",
"gen-service-worker-app-prod-modern"
),
...(env.isTestBuild() || env.isStatsBuild() ? [] : ["compress-app"])
)
);
gulp.task(
"analyze-app",
gulp.series(
-3
View File
@@ -1,5 +1,4 @@
import gulp from "gulp";
import { createWorkflowLockTask } from "../output-lock.mjs";
import "./clean.js";
import "./entry-html.js";
import "./gather-static.js";
@@ -14,7 +13,6 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "development";
},
createWorkflowLockTask("develop-demo"),
"clean-demo",
"translations-enable-merge-backend",
gulp.parallel(
@@ -34,7 +32,6 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "production";
},
createWorkflowLockTask("build-demo"),
"clean-demo",
// Cast needs to be backwards compatible and older HA has no translations
"translations-enable-merge-backend",
-3
View File
@@ -1,5 +1,4 @@
import gulp from "gulp";
import { createWorkflowLockTask } from "../output-lock.mjs";
import "./clean.js";
import "./entry-html.js";
import "./gather-static.js";
@@ -13,7 +12,6 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "development";
},
createWorkflowLockTask("develop-e2e-test-app"),
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel(
@@ -33,7 +31,6 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "production";
},
createWorkflowLockTask("build-e2e-test-app"),
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
+2 -27
View File
@@ -58,12 +58,6 @@ const getCommonTemplateVars = () => {
return {
modernRegex: compileRegex(browserRegexes.concat(haMacOSRegex)).toString(),
hassUrl: process.env.HASS_URL || "",
// Single source for the stale-build recovery patterns, shared with the
// bundled src/util/recover-stale-build.ts and injected into the inline
// boot guard (_bootstrap_recovery.html.template).
staleBuildPatterns: fs.readJsonSync(
resolve(paths.root_dir, "src/util/stale-build-patterns.json")
),
};
};
@@ -113,9 +107,6 @@ const genPagesDevTask =
resolve(inputRoot, inputSub, `${page}.template`),
{
...commonVars,
// Dev entries are unhashed, so the stale-index recovery guard has
// nothing to key off and rebuild churn could cause spurious reloads.
useCacheRecovery: false,
latestEntryJS: entries.map(
(entry) => `${publicRoot}/frontend_latest/${entry}.js`
),
@@ -155,15 +146,10 @@ const genPagesProdTask =
resolve(inputRoot, inputSub, `${page}.template`),
{
...commonVars,
// Recover from a stale index.html that pins deleted hashed entry
// bundles (see _bootstrap_recovery.html.template).
useCacheRecovery: true,
latestEntryJS: entries.map((entry) => latestManifest[`${entry}.js`]),
es5EntryJS: outputES5
? entries.map((entry) => es5Manifest[`${entry}.js`])
: [],
es5EntryJS: entries.map((entry) => es5Manifest[`${entry}.js`]),
latestCustomPanelJS: latestManifest["custom-panel.js"],
es5CustomPanelJS: outputES5 ? es5Manifest["custom-panel.js"] : "",
es5CustomPanelJS: es5Manifest["custom-panel.js"],
}
);
minifiedHTML.push(
@@ -198,17 +184,6 @@ gulp.task(
)
);
gulp.task(
"gen-pages-app-prod-modern",
genPagesProdTask(
APP_PAGE_ENTRIES,
paths.root_dir,
paths.app_output_root,
paths.app_output_latest,
undefined
)
);
const CAST_PAGE_ENTRIES = {
"faq.html": ["launcher"],
"index.html": ["launcher"],
-3
View File
@@ -4,7 +4,6 @@ import gulp from "gulp";
import { load as loadYaml } from "js-yaml";
import { marked } from "marked";
import path from "path";
import { createWorkflowLockTask } from "../output-lock.mjs";
import paths from "../paths.cjs";
import "./clean.js";
import "./entry-html.js";
@@ -165,7 +164,6 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "development";
},
createWorkflowLockTask("develop-gallery"),
"clean-gallery",
"translations-enable-merge-backend",
gulp.parallel(
@@ -197,7 +195,6 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "production";
},
createWorkflowLockTask("build-gallery"),
"clean-gallery",
"translations-enable-merge-backend",
gulp.parallel(
+7 -26
View File
@@ -108,7 +108,7 @@ const runDevServer = async ({
}
};
const doneHandler = () => (err, stats) => {
const doneHandler = (done) => (err, stats) => {
if (err) {
log.error(err.stack || err);
if (err.details) {
@@ -122,26 +122,18 @@ const doneHandler = () => (err, stats) => {
}
log(`Build done @ ${new Date().toLocaleTimeString()}`);
if (done) {
done();
}
};
const prodBuild = (conf) =>
new Promise((resolve, reject) => {
new Promise((resolve) => {
rspack(
conf,
// Resolve promise when done. Because we pass a callback, rspack closes itself
(err, stats) => {
if (err) {
reject(err);
} else if (stats.hasErrors()) {
reject(Error(stats.toString("errors-only")));
} else {
if (stats.hasWarnings()) {
console.log(stats.toString("minimal"));
}
log(`Build done @ ${new Date().toLocaleTimeString()}`);
resolve();
}
}
doneHandler(resolve)
);
});
@@ -168,17 +160,6 @@ gulp.task("rspack-prod-app", () =>
)
);
gulp.task("rspack-prod-app-modern", () =>
prodBuild(
createAppConfig({
isProdBuild: true,
isStatsBuild: env.isStatsBuild(),
isTestBuild: env.isTestBuild(),
latestBuild: true,
})
)
);
gulp.task("rspack-dev-server-demo", () =>
runDevServer({
compiler: rspack(
+3 -10
View File
@@ -34,9 +34,9 @@ gulp.task("gen-service-worker-app-dev", async () => {
);
});
const genServiceWorker = (builds) =>
gulp.task("gen-service-worker-app-prod", () =>
Promise.all(
builds.map(async ([outPath, build]) => {
Object.entries(SW_MAP).map(async ([outPath, build]) => {
const manifest = JSON.parse(
await readFile(join(outPath, "manifest.json"), "utf-8")
);
@@ -83,12 +83,5 @@ const genServiceWorker = (builds) =>
await symlink(basename(swDest), swOld);
}
})
);
gulp.task("gen-service-worker-app-prod", () =>
genServiceWorker(Object.entries(SW_MAP))
);
gulp.task("gen-service-worker-app-prod-modern", () =>
genServiceWorker([[paths.app_output_latest, "modern"]])
)
);
+2
View File
@@ -1,3 +1,5 @@
/* eslint-disable max-classes-per-file */
import { deleteAsync } from "del";
import { glob } from "glob";
import gulp from "gulp";
-552
View File
@@ -1,552 +0,0 @@
import { spawn, execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { createInterface } from "node:readline/promises";
export const LIFECYCLE_MODE_FLAGS = new Map([
["--background", "background"],
["--status", "status"],
["--stop", "stop"],
["--logs", "logs"],
]);
const AGENT_PROVIDERS = [
{
id: "opencode",
env: [
"OPENCODE",
"OPENCODE_BIN_PATH",
"OPENCODE_SERVER",
"OPENCODE_APP_INFO",
"OPENCODE_MODES",
],
processes: ["opencode"],
},
{
id: "claude-code",
env: ["CLAUDECODE"],
processes: ["claude"],
},
{
id: "cursor",
env: ["CURSOR_TRACE_ID"],
processes: [],
},
{
id: "github-copilot",
matchesEnv: (env) =>
env.TERM_PROGRAM === "vscode" && env.GIT_PAGER === "cat",
processes: [],
},
{
id: "generic",
env: ["AGENT", "AI_AGENT"],
processes: [],
},
];
const MAX_ANCESTRY_HOPS = 24;
const readProcessParent = (pid) => {
try {
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
const commandEnd = stat.lastIndexOf(")");
const commandStart = stat.indexOf("(");
if (commandStart === -1 || commandEnd === -1) {
return undefined;
}
const parentPid = Number.parseInt(
stat.slice(commandEnd + 2).split(" ")[1] ?? "",
10
);
return Number.isFinite(parentPid)
? {
command: stat.slice(commandStart + 1, commandEnd).toLowerCase(),
parentPid,
}
: undefined;
} catch {
return undefined;
}
};
export const detectCodingAgent = (env = process.env, pid = process.pid) => {
if (env.HA_CODING_AGENT === "0") {
return undefined;
}
const envMatch = AGENT_PROVIDERS.find(
(provider) =>
provider.matchesEnv?.(env) || provider.env?.some((name) => env[name])
);
if (envMatch) {
return envMatch.id;
}
if (env.HA_CODING_AGENT === "1") {
return "unknown";
}
let currentPid = pid;
for (let hop = 0; hop < MAX_ANCESTRY_HOPS; hop++) {
const processInfo = readProcessParent(currentPid);
if (!processInfo) {
return undefined;
}
const processMatch = AGENT_PROVIDERS.find((provider) =>
provider.processes.some((name) => processInfo.command.includes(name))
);
if (processMatch) {
return processMatch.id;
}
if (processInfo.parentPid <= 1) {
return undefined;
}
currentPid = processInfo.parentPid;
}
return undefined;
};
const canPromptForConflict = () =>
Boolean(process.stdin.isTTY && process.stderr.isTTY && !detectCodingAgent());
const formatCommand = (command) =>
!("NO_COLOR" in process.env)
? `\u001b[1;36m${command}\u001b[0m`
: `\`${command}\``;
const confirmStopConflict = async (ownerDescription, stopCommand) => {
const readline = createInterface({
input: process.stdin,
output: process.stderr,
});
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30_000);
try {
process.stderr.write(
`${ownerDescription} can be stopped with ${formatCommand(stopCommand)}.\n`
);
const answer = await readline.question("Stop it and continue? [y/N] ", {
signal: controller.signal,
});
return ["y", "yes"].includes(answer.trim().toLowerCase());
} catch (err) {
if (err?.name !== "AbortError") {
throw err;
}
process.stderr.write("\n");
return false;
} finally {
clearTimeout(timeout);
readline.close();
}
};
export const sleep = (ms) =>
new Promise((resolve) => {
setTimeout(resolve, ms);
});
export const waitFor = async (predicate, intervalMs, timeoutMs) => {
const deadline = Date.now() + timeoutMs;
const poll = async () => {
if (await predicate()) {
return true;
}
if (Date.now() >= deadline) {
return false;
}
await sleep(intervalMs);
return poll();
};
return poll();
};
export const isProcessAlive = (pid) => {
if (!Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch (err) {
// EPERM means the process exists but is owned by someone else.
return err.code === "EPERM";
}
};
export const processStartTime = (pid) => {
if (!isProcessAlive(pid)) {
return undefined;
}
try {
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
return stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19];
} catch {
try {
return execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
} catch {
return undefined;
}
}
};
export const isProcessRecordAlive = ({ pid, startTime }) =>
Boolean(startTime) && processStartTime(pid) === startTime;
export const readProcessRecord = (file) => {
try {
const data = JSON.parse(fs.readFileSync(file, "utf8"));
return data && Number.isInteger(data.pid) ? data : undefined;
} catch (err) {
if (err.code === "ENOENT" || err instanceof SyntaxError) {
return undefined;
}
throw err;
}
};
export const writeProcessRecord = (file, data) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
try {
fs.writeFileSync(temporary, JSON.stringify(data));
fs.renameSync(temporary, file);
} finally {
removeFileIfExists(temporary);
}
};
export const removeProcessRecord = (file) => {
removeFileIfExists(file);
};
export const acquireProcessRecord = (file, data) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
for (let attempt = 0; attempt < 2; attempt++) {
try {
const fd = fs.openSync(file, "wx");
try {
fs.writeFileSync(fd, JSON.stringify(data));
} finally {
fs.closeSync(fd);
}
return { acquired: true };
} catch (err) {
if (err.code !== "EEXIST") {
throw err;
}
const existing = readProcessRecord(file);
if (existing && isProcessRecordAlive(existing)) {
return { acquired: false, existing };
}
if (!existing && isRecentFile(file)) {
return { acquired: false };
}
const removed = withExclusiveFileLockSync(`${file}.cleanup`, () => {
const current = readProcessRecord(file);
if (
current &&
(current.token !== existing?.token || isProcessRecordAlive(current))
) {
return false;
}
removeProcessRecord(file);
return true;
});
if (!removed.acquired || !removed.value) {
return { acquired: false, existing: readProcessRecord(file) };
}
}
}
return { acquired: false, existing: readProcessRecord(file) };
};
export const releaseProcessRecord = (file, token, onRelease) => {
if (!token) {
return;
}
withExclusiveFileLockSync(`${file}.cleanup`, () => {
const existing = readProcessRecord(file);
if (!existing || existing.token === token) {
onRelease?.();
if (existing) {
removeProcessRecord(file);
}
}
});
};
const removeFileIfExists = (file) => {
try {
fs.rmSync(file);
} catch (err) {
if (err.code !== "ENOENT") {
throw err;
}
}
};
const isRecentFile = (file) => {
try {
return Date.now() - fs.statSync(file).mtimeMs < 5000;
} catch (err) {
if (err.code === "ENOENT") {
return false;
}
throw err;
}
};
export const withExclusiveFileLockSync = (file, operation) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
for (let attempt = 0; attempt < 2; attempt++) {
let fd;
try {
fd = fs.openSync(file, "wx");
} catch (err) {
if (err.code !== "EEXIST") {
throw err;
}
const owner = readProcessRecord(file);
if (owner && isProcessRecordAlive(owner)) {
return { acquired: false };
}
if (!owner && isRecentFile(file)) {
return { acquired: false };
}
removeFileIfExists(file);
continue;
}
try {
fs.writeFileSync(
fd,
JSON.stringify({
pid: process.pid,
startTime: processStartTime(process.pid),
})
);
return { acquired: true, value: operation() };
} finally {
try {
fs.closeSync(fd);
} finally {
removeFileIfExists(file);
}
}
}
return { acquired: false };
};
export const spawnForeground = ({
cmd,
args,
cwd,
env,
processGroup = false,
onSpawn,
}) =>
new Promise((resolve, reject) => {
const child = spawn(cmd, args, {
cwd,
detached: processGroup,
env,
stdio: "inherit",
});
let settled = false;
const forwardSigint = () =>
signalProcess(child.pid, "SIGINT", processGroup);
const forwardSigterm = () =>
signalProcess(child.pid, "SIGTERM", processGroup);
const forwardSighup = () =>
signalProcess(child.pid, "SIGHUP", processGroup);
const removeSignalHandlers = () => {
process.off("SIGINT", forwardSigint);
process.off("SIGTERM", forwardSigterm);
process.off("SIGHUP", forwardSighup);
};
if (processGroup) {
process.on("SIGINT", forwardSigint);
process.on("SIGTERM", forwardSigterm);
process.on("SIGHUP", forwardSighup);
}
child.once("spawn", () => {
try {
onSpawn?.(child);
} catch (err) {
settled = true;
signalProcess(child.pid, "SIGTERM", processGroup);
removeSignalHandlers();
reject(err);
}
});
child.once("error", (err) => {
if (!settled) {
settled = true;
removeSignalHandlers();
process.stderr.write(`Failed to start ${cmd}: ${err.message}\n`);
resolve(1);
}
});
child.once("exit", (code) => {
if (!settled) {
settled = true;
removeSignalHandlers();
resolve(code ?? 1);
}
});
});
export const spawnDetachedToLog = ({ cmd, args, cwd, env, logFile }) =>
new Promise((resolve, reject) => {
fs.mkdirSync(path.dirname(logFile), { recursive: true });
const fd = fs.openSync(logFile, "w");
let child;
try {
child = spawn(cmd, args, {
cwd,
detached: true,
env,
stdio: ["ignore", fd, fd],
});
} finally {
fs.closeSync(fd);
}
child.once("spawn", () => {
child.unref();
resolve(child);
});
child.once("error", reject);
});
const signalProcess = (pid, signal, processGroup) => {
if (processGroup) {
try {
process.kill(-pid, signal);
return;
} catch {
// Fall back to the process itself.
}
}
try {
process.kill(pid, signal);
} catch {
// Already gone.
}
};
export const terminateProcess = async ({
pid,
isStopped,
processGroup = true,
graceMs = 10_000,
}) => {
signalProcess(pid, "SIGTERM", processGroup);
if (await waitFor(isStopped, 300, graceMs)) {
return true;
}
signalProcess(pid, "SIGKILL", processGroup);
await sleep(300);
return await isStopped();
};
const sameProcessRecord = (current, expected) =>
Boolean(expected?.token) && current?.token === expected.token;
const stopProcessRecord = async (file, owner) => {
let current = readProcessRecord(file);
if (!current) {
return true;
}
if (!sameProcessRecord(current, owner)) {
return false;
}
if (!isProcessRecordAlive(current)) {
releaseProcessRecord(file, current.token);
return true;
}
const stopped = await terminateProcess({
pid: current.pid,
processGroup: current.processGroup ?? false,
isStopped: () => {
const latest = readProcessRecord(file);
return (
!sameProcessRecord(latest, owner) ||
!latest ||
!isProcessRecordAlive(latest)
);
},
});
current = readProcessRecord(file);
if (current && !sameProcessRecord(current, owner)) {
return false;
}
if (!stopped && current) {
return false;
}
releaseProcessRecord(file, owner.token);
return true;
};
export const offerToStopProcessRecord = async ({
file,
owner,
ownerDescription,
stopCommand,
}) => {
if (
!owner ||
!stopCommand ||
!canPromptForConflict() ||
!(await confirmStopConflict(ownerDescription, stopCommand))
) {
return false;
}
return stopProcessRecord(file, owner);
};
export const terminateDetachedProcess = (child) =>
terminateProcess({
pid: child.pid,
processGroup: true,
isStopped: () => !isProcessAlive(child.pid),
});
export const outputLog = (logFile, follow, missingMessage) => {
if (!fs.existsSync(logFile)) {
process.stdout.write(missingMessage);
return Promise.resolve(0);
}
if (!follow) {
process.stdout.write(fs.readFileSync(logFile, "utf8"));
return Promise.resolve(0);
}
return new Promise((resolve) => {
const tail = spawn("tail", ["-f", logFile], { stdio: "inherit" });
let settled = false;
tail.once("error", () => {
if (!settled) {
settled = true;
process.stdout.write(fs.readFileSync(logFile, "utf8"));
resolve(0);
}
});
tail.once("exit", (code) => {
if (!settled) {
settled = true;
resolve(code ?? 1);
}
});
});
};
export const runCli = (main) => {
main().then(
(code) => {
process.exitCode = code;
},
(err) => {
process.stderr.write(`${err?.stack || err}\n`);
process.exitCode = 1;
}
);
};
@@ -12,42 +12,18 @@
const remapping = require("@ampproject/remapping");
// minify-literals is ESM-only, so load it via dynamic import from this CJS loader.
// Also map to cache loader promises per environment (e.g., 'modern', 'legacy')
const loaderInitPromises = new Map();
const initLoader = (browserslistEnv) => {
if (!loaderInitPromises.has(browserslistEnv)) {
loaderInitPromises.set(
browserslistEnv,
Promise.all([
import("minify-literals"),
import("browserslist"),
import("lightningcss"),
]).then(([minifyModule, browserslistModule, lightningcssModule]) => {
const browserslist = browserslistModule.default;
const { browserslistToTargets } = lightningcssModule;
const rawTargets = browserslist(null, { env: browserslistEnv });
if (!rawTargets.length) {
throw new Error(
`No browsers resolved for browserslist environment "${browserslistEnv}"`
);
}
const lightningcssTargets = browserslistToTargets(rawTargets);
return {
minifyHTMLLiterals: minifyModule.minifyHTMLLiterals,
lightningcssTargets,
};
})
);
let minifyPromise;
const getMinifier = () => {
if (!minifyPromise) {
minifyPromise = import("minify-literals").then((m) => m.minifyHTMLLiterals);
}
return loaderInitPromises.get(browserslistEnv);
return minifyPromise;
};
// HTML options mirror the previous babel-plugin-template-html-minifier config
// (html-minifier-next is option-compatible with html-minifier-terser). CSS in
// css`` templates and inline <style> is handled by minify-literals'
// lightningcss. We pass in the targets from browserslist so lightningcss
// can minify CSS appropriately for the build environment.
// css`` templates and inline <style> is handled by minify-literals' lightningcss
// default.
//
// `keepClosingSlash` is required for `svg`` templates: SVG elements such as
// `<path />` and `<circle />` are not void elements in HTML, so dropping the
@@ -64,16 +40,11 @@ const htmlOptions = {
module.exports = function minifyTemplateLiteralsLoader(source, map, meta) {
const callback = this.async();
const { browserslistEnv } = this.getOptions();
initLoader(browserslistEnv)
.then(({ minifyHTMLLiterals, lightningcssTargets }) =>
getMinifier()
.then((minifyHTMLLiterals) =>
minifyHTMLLiterals(source, {
fileName: this.resourcePath,
html: htmlOptions,
css: {
targets: lightningcssTargets,
},
})
)
.then((result) => {
-127
View File
@@ -1,127 +0,0 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
acquireProcessRecord,
processStartTime,
readProcessRecord,
releaseProcessRecord,
} from "./managed-process.mjs";
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
".."
);
export const buildCacheDir =
process.env.HA_BUILD_CACHE_DIR ??
path.join(repoRoot, "node_modules", ".cache");
const WORKFLOW_LOCK_TOKEN_ENV = "HA_WORKFLOW_LOCK_TOKEN";
const signalCleanups = new Set();
const cleanupSignals = ["SIGINT", "SIGTERM", "SIGHUP"];
const handleSignal = (signal) => {
for (const cleanup of signalCleanups) {
cleanup();
}
for (const cleanupSignal of cleanupSignals) {
process.off(cleanupSignal, handleSignal);
}
process.kill(process.pid, signal);
};
const registerSignalCleanup = (cleanup) => {
if (signalCleanups.size === 0) {
for (const signal of cleanupSignals) {
process.on(signal, handleSignal);
}
}
signalCleanups.add(cleanup);
};
const unregisterSignalCleanup = (cleanup) => {
signalCleanups.delete(cleanup);
if (signalCleanups.size === 0) {
for (const signal of cleanupSignals) {
process.off(signal, handleSignal);
}
}
};
export const workflowLockFile = path.join(buildCacheDir, "ha-workflow.lock");
export const workflowLockEnv = (token) => ({
...process.env,
[WORKFLOW_LOCK_TOKEN_ENV]: token,
});
export const describeOutputOwner = (owner) => {
if (owner?.kind === "build") {
return `frontend ${owner.modern ? "modern " : ""}build`;
}
if (owner?.kind === "dev") {
return `dev server (${owner.suite ?? "app"})`;
}
return owner?.target ? `Gulp task ${owner.target}` : "another process";
};
const createLockTask = ({ file, inheritedTokenEnv, kind, label, target }) => {
let exitToken;
const cleanup = () => {
if (!exitToken) {
return;
}
releaseProcessRecord(file, exitToken);
exitToken = undefined;
process.off("exit", cleanup);
unregisterSignalCleanup(cleanup);
};
const acquire = async () => {
const inheritedToken = process.env[inheritedTokenEnv];
if (inheritedToken) {
if (readProcessRecord(file)?.token !== inheritedToken) {
throw Error(
`${label} lock ownership was lost before ${target} started.`
);
}
exitToken = inheritedToken;
process.once("exit", cleanup);
registerSignalCleanup(cleanup);
return;
}
const token = `${process.pid}-${Date.now()}-${Math.random()}`;
const record = {
pid: process.pid,
startTime: processStartTime(process.pid),
processGroup: false,
kind,
target,
token,
};
const result = acquireProcessRecord(file, record);
if (!result.acquired) {
const pid = result.existing?.pid;
throw Error(
`Cannot run ${target}: ${describeOutputOwner(result.existing)} ` +
`already owns ${label}${pid ? ` (pid ${pid})` : ""}.`
);
}
exitToken = token;
process.once("exit", cleanup);
registerSignalCleanup(cleanup);
};
acquire.displayName = `lock-${label}:${target}`;
return acquire;
};
export const createWorkflowLockTask = (target) =>
createLockTask({
file: workflowLockFile,
inheritedTokenEnv: WORKFLOW_LOCK_TOKEN_ENV,
kind: "output",
label: "build and development workflow",
target,
});
-5
View File
@@ -96,11 +96,6 @@ const createRspackConfig = ({
__dirname,
"minify-template-literals-loader.cjs"
),
options: {
browserslistEnv: latestBuild
? "modern"
: `legacy${info.issuerLayer === "sw" ? "-sw" : ""}`,
},
},
!latestBuild &&
info.resource.startsWith(
+56
View File
@@ -0,0 +1,56 @@
# Demo Agent Instructions
This file applies to all files under `demo/`. Follow the root `AGENTS.md` for repository-wide standards.
The demo is the full Home Assistant frontend running against a mocked backend (published at https://demo.home-assistant.io). It needs no Home Assistant server, which makes it the easiest way to load the real UI in a browser, for example to take screenshots.
## Running the demo
Run from the repository root:
```bash
yarn dev:demo # dev server on http://localhost:8090
yarn dev:demo --background # detached; also supports --status/--stop/--logs
```
## Opening a specific demo
The demo contains multiple demo configurations. Select one directly with the `demo` query parameter, e.g. `http://localhost:8090/?demo=<slug>`. The valid slugs are defined in `demoConfigs` in `src/configs/demo-configs.ts`, so "the second demo" means the slug of the second entry in that list. An unknown slug falls back to the default (the first entry).
## Opening a specific page
The demo build uses hash-based routing: the frontend path goes in the URL hash, and the `demo` query parameter goes before the `#`. The URL format is:
```
http://localhost:8090/?demo=<slug>#/<path>
```
Useful paths:
- `/lovelace/0` — the selected demo's dashboard (also the default when no hash is given)
- `/energy` — energy dashboard. Its tabs are views: `/energy/overview` (Summary), `/energy/electricity`, `/energy/gas`, `/energy/water`, `/energy/now`
- `/map`, `/history`, `/todo`, `/config` — other sidebar panels
Example — the water tab of the energy dashboard of the second demo:
```
http://localhost:8090/?demo=<second slug>#/energy/water
```
## Structure
- `src/ha-demo.ts`: Root element; sets up all backend mocks.
- `src/configs/<slug>/`: One directory per demo configuration (entities, dashboard, theme).
- `src/configs/demo-configs.ts`: Registry of demo configurations and URL slug handling.
- `src/stubs/`: Mocked WebSocket/REST APIs.
- `script/develop_demo`, `script/build_demo`: Dev server and static build wrappers.
## The gallery imports these stubs too
`demo/src/stubs/` is shared, not demo-private: gallery pages import from it directly. Before changing or removing anything a stub does, grep for its callers across `gallery/` as well as `demo/`, and check the affected gallery pages, not just the demo.
```bash
grep -rn "stubs/<name>" demo/src gallery/src
```
The two consume a stub differently, so demo behavior does not predict gallery behavior. A gallery page calls stubs against the `hass` from `provideHass`, where `hass.config` is the shared `demoConfig` object, so a stub that mutates `hass.config` in place is visible to the page. `ha-demo.ts` copies `components` into a new array before the stubs run, so the same mutation never reaches the demo. A change can therefore look fine in the demo while quietly breaking a gallery page.
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+6 -2
View File
@@ -151,7 +151,9 @@ export class HaDemo extends HomeAssistantAppEl {
entity_category: null,
has_entity_name: false,
unique_id: "co2_intensity",
options: null,
original_name: null,
translation_key: null,
options: {},
created_at: 0,
modified_at: 0,
},
@@ -172,7 +174,9 @@ export class HaDemo extends HomeAssistantAppEl {
entity_category: null,
has_entity_name: false,
unique_id: "grid_fossil_fuel_percentage",
options: null,
original_name: null,
translation_key: null,
options: {},
created_at: 0,
modified_at: 0,
},
+10 -4
View File
@@ -34,8 +34,16 @@
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="theme-color" content="#03a9f4" />
<link rel="preload" href="/static/fonts/roboto/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin>
<%= renderTemplate("_social_meta.html.template") %>
<style>
@font-face {
font-family: "Roboto Launch Screen";
font-display: block;
src: url("/static/fonts/roboto/Roboto-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
html {
background-color: var(--primary-background-color, #fafafa);
color: var(--primary-text-color, #212121);
@@ -56,14 +64,12 @@
padding: 0;
}
#ha-launch-screen {
font-family: ui-sans-serif, system-ui, sans-serif;
font-family: "Roboto Launch Screen", sans-serif;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
user-select: none;
-webkit-user-select: none;
transition: opacity var(--ha-animation-duration-normal, 250ms) ease-out;
}
#ha-launch-screen.removing {
@@ -98,7 +104,7 @@
}
#ha-launch-screen .ha-launch-screen-spacer-bottom {
flex: 1;
padding-top: 16px;
padding-top: 48px;
}
.ohf-logo {
margin: max(var(--safe-area-inset-bottom, 0px), 48px) 0;
+3 -2
View File
@@ -43,7 +43,7 @@ const cloudStatus: CloudStatusLoggedIn = {
email: "demo@home-assistant.io",
google_registered: true,
google_entities: emptyFilter(),
google_domains: ["light", "switch", "climate", "cover"],
google_local_connected: true,
alexa_registered: true,
alexa_entities: emptyFilter(),
remote_domain: "demo-instance.ui.nabu.casa",
@@ -64,7 +64,8 @@ const cloudStatus: CloudStatusLoggedIn = {
alexa_enabled: true,
remote_enabled: true,
remote_allow_remote_enable: true,
strict_connection: "disabled",
alexa_default_expose: null,
google_default_expose: null,
google_secure_devices_pin: undefined,
cloudhooks: {},
alexa_report_state: true,
+2
View File
@@ -21,6 +21,8 @@ const baseEntry = {
reason: null,
error_reason_translation_key: null,
error_reason_translation_placeholders: null,
created_at: 0,
modified_at: 0,
};
// Each entry is tagged with its integration type so we can honor the
+2
View File
@@ -14,6 +14,8 @@ const baseDevice = {
name_by_user: null,
disabled_by: null,
configuration_url: null,
config_entry_id: null,
config_subentry_id: null,
created_at: 0,
modified_at: 0,
};
+8 -1
View File
@@ -16,7 +16,14 @@ export const mockEntityRegistry = (
for (const entityId of msg.entity_ids) {
const entry = data.find((e) => e.entity_id === entityId);
if (entry) {
result[entityId] = { ...entry, capabilities: {}, aliases: [] };
result[entityId] = {
...entry,
capabilities: {},
original_icon: null,
device_class: null,
original_device_class: null,
aliases: [],
};
}
}
return result;
+31 -2
View File
@@ -114,13 +114,14 @@ const addonDetails = (addon: HassioAddonInfo): HassioAddonDetails => ({
audio_output: null,
audio: false,
auth_api: false,
auto_uart: false,
auto_update: false,
boot: "auto",
boot_config: "auto",
changelog: false,
devices: [],
devicetree: false,
discovery: [],
dns: [],
docker_api: false,
documentation: false,
full_access: false,
@@ -133,8 +134,10 @@ const addonDetails = (addon: HassioAddonInfo): HassioAddonDetails => ({
host_ipc: false,
host_network: false,
host_pid: false,
host_uts: false,
ingress_entry: null,
ingress_panel: false,
ingress_port: null,
ingress_url: null,
ingress: false,
ip_address: "172.30.33.2",
@@ -148,13 +151,17 @@ const addonDetails = (addon: HassioAddonInfo): HassioAddonDetails => ({
protected: true,
rating: 6,
schema: CONFIG_SCHEMAS[addon.slug] ?? null,
services_role: [],
services: [],
signed: false,
startup: "application",
stdin: false,
system_managed: false,
system_managed_config_entry: null,
translations: {},
uart: false,
udev: false,
usb: false,
video: false,
watchdog: true,
webui: null,
});
@@ -212,6 +219,13 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
debug: false,
debug_block: false,
diagnostics: true,
auto_update: true,
country: "NL",
detect_blocking_io: false,
feature_flags: {
supervisor_v2_api: false,
supervisor_websocket_v2_api: false,
},
addons: DEMO_ADDONS as any,
addons_repositories: [
"https://github.com/music-assistant/home-assistant-addon",
@@ -264,6 +278,7 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
hostname: "homeassistant",
logging: "info",
machine: "green",
machine_id: "0123456789abcdef0123456789abcdef",
state: "running",
operating_system: "Home Assistant OS 18.2",
supervisor: "2026.07.1",
@@ -277,6 +292,9 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
if (msg.endpoint === "/host/info") {
const data: HassioHostInfo = {
agent_version: "1.8.0",
apparmor_version: "3.0.7",
broadcast_llmnr: true,
broadcast_mdns: true,
chassis: "embedded",
cpe: "cpe:2.3:o:home-assistant:haos:18.2:*:production:*:*:*:aarch64:*",
deployment: "production",
@@ -284,12 +302,18 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
disk_free: 22.3,
disk_total: 31.2,
disk_used: 8.9,
dt_synchronized: true,
dt_utc: "2026-07-28T10:00:00+00:00",
features: ["reboot", "shutdown", "network", "hostname", "os_agent"],
hostname: "homeassistant",
kernel: "6.12.48-haos",
llmnr_hostname: "homeassistant",
operating_system: "Home Assistant OS 18.2",
boot_timestamp: 1751932800000000,
startup_time: 12.4,
timezone: "Europe/Amsterdam",
use_ntp: true,
virtualization: "",
};
return data;
}
@@ -298,9 +322,14 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
const data: HassioHassOSInfo = {
board: "green",
boot: "A",
boot_slots: {
A: { state: "booted", status: "good", version: "18.2" },
B: { state: "inactive", status: "good", version: "18.1" },
},
update_available: false,
version: "18.2",
version_latest: "18.2",
version_pending: null,
data_disk: "Home Assistant Green (mmcblk0)",
};
return data;
+6
View File
@@ -69,6 +69,8 @@ const DEVICES: DeviceRegistryEntry[] = [
configuration_url: null,
config_entries: ["config_entry_1"],
config_entries_subentries: {},
config_entry_id: null,
config_subentry_id: null,
connections: [],
disabled_by: null,
entry_type: null,
@@ -93,6 +95,8 @@ const DEVICES: DeviceRegistryEntry[] = [
configuration_url: null,
config_entries: ["config_entry_2"],
config_entries_subentries: {},
config_entry_id: null,
config_subentry_id: null,
connections: [],
disabled_by: null,
entry_type: null,
@@ -117,6 +121,8 @@ const DEVICES: DeviceRegistryEntry[] = [
configuration_url: null,
config_entries: ["config_entry_3"],
config_entries_subentries: {},
config_entry_id: null,
config_subentry_id: null,
connections: [],
disabled_by: null,
entry_type: null,
@@ -1,23 +0,0 @@
---
title: Replaced device selectors
subtitle: How device and target selectors surface devices that were split into separate devices
---
A device that used to belong to multiple config entries is split into one
device per config entry. The original composite device is removed from the
registry, so existing references to it (targets in automations, device
selectors) point at a device that no longer exists.
When a selector holds such a reference, it shows a **replaced** state instead of
a plain "not found", and offers to point the reference at the replacement
device(s). The candidate replacements are filtered through the selector's own
filters, so in practice usually a single device matches:
- **Target selector** — the replaced device row offers **Replace**, which adds
every replacement device that matches the target filters and removes the old
reference.
- **Device selector** — when exactly one replacement matches, **Replace** swaps
to it in one click; when several match, a dialog lets you pick one.
All samples below reference the removed composite device `old_composite`, which
was split into a light device and a switch device.
@@ -1,318 +0,0 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
import "../../../../src/components/ha-selector/ha-selector";
import "../../../../src/components/ha-settings-row";
import "../../../../src/components/ha-target-picker";
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { EntityRegistryDisplayEntry } from "../../../../src/data/entity/entity_registry";
import type { Selector } from "../../../../src/data/selector";
import {
showDialog,
type ShowDialogParams,
} from "../../../../src/dialogs/make-dialog-manager";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { ProvideHassElement } from "../../../../src/mixins/provide-hass-lit-mixin";
import type { HomeAssistant } from "../../../../src/types";
import "../../components/demo-black-white-row";
// The composite device "old_composite" is intentionally NOT in the registry:
// it was split into "device_light" and "device_switch". References to the old
// id (targets, device selectors) should surface a "replaced" state.
const DEVICES: DeviceRegistryEntry[] = [
{
area_id: "bedroom",
configuration_url: null,
config_entries: ["config_entry_light"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
id: "device_light",
identifiers: [["demo", "light"] as [string, string]],
manufacturer: null,
model: null,
model_id: null,
name_by_user: null,
name: "Living room lamp",
sw_version: null,
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
created_at: 0,
modified_at: 0,
primary_config_entry: null,
},
{
area_id: "backyard",
configuration_url: null,
config_entries: ["config_entry_switch"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
id: "device_switch",
identifiers: [["demo", "switch"] as [string, string]],
manufacturer: null,
model: null,
model_id: null,
name_by_user: null,
name: "Garden socket",
sw_version: null,
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
created_at: 0,
modified_at: 0,
primary_config_entry: null,
},
];
const ENTITIES = [
{
entity_id: "light.living_room_lamp",
state: "on",
attributes: { friendly_name: "Living room lamp" },
},
{
entity_id: "switch.garden_socket",
state: "off",
attributes: { friendly_name: "Garden socket" },
},
];
// Registry display entries link the demo entities to the split devices so the
// pickers can filter split candidates by domain.
const ENTITY_REGISTRY: Record<string, EntityRegistryDisplayEntry> = {
"light.living_room_lamp": {
entity_id: "light.living_room_lamp",
name: "Living room lamp",
device_id: "device_light",
area_id: "bedroom",
platform: "demo",
labels: [],
},
"switch.garden_socket": {
entity_id: "switch.garden_socket",
name: "Garden socket",
device_id: "device_switch",
area_id: "backyard",
platform: "demo",
labels: [],
},
};
const AREAS: AreaRegistryEntry[] = [
{
area_id: "backyard",
floor_id: null,
name: "Backyard",
icon: null,
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
{
area_id: "bedroom",
floor_id: null,
name: "Bedroom",
icon: "mdi:bed",
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
];
// Maps the removed composite device to the devices that replaced it.
const COMPOSITE_SPLITS = {
old_composite: {
split_ids: ["device_light", "device_switch"],
primary_id: "device_light",
},
};
interface Sample {
name: string;
description: string;
selector: Selector;
value: unknown;
// Render ha-target-picker directly in compact (chip) mode instead of the
// ha-selector, which does not expose the compact option.
compact?: boolean;
}
const SAMPLES: Sample[] = [
{
name: "Target",
description:
"Migrate adds every replacement device that matches the target filters (here both).",
selector: { target: {} },
value: { device_id: ["old_composite"] },
},
{
name: "Target (compact)",
description:
"In compact mode the replaced reference is shown as a warning chip.",
selector: { target: {} },
value: { device_id: ["old_composite"] },
compact: true,
},
{
name: "Device (unfiltered, multiple matches)",
description:
"Both replacement devices qualify, so Replace opens a dialog to pick one.",
selector: { device: {} },
value: "old_composite",
},
{
name: "Device (filtered to lights, single match)",
description:
"Only the light device passes the filter, so Replace swaps to it in one click.",
selector: { device: { entity: [{ domain: "light" }] } },
value: "old_composite",
},
{
name: "Device (multiple)",
description: "Each slot resolves independently to a matching replacement.",
selector: { device: { multiple: true } },
value: ["old_composite"],
},
];
@customElement("demo-components-ha-selector-replaced-device")
class DemoHaSelectorReplacedDevice
extends LitElement
implements ProvideHassElement
{
@state() public hass!: HomeAssistant;
private _values = SAMPLES.map((sample) => sample.value);
constructor() {
super();
const hass = provideHass(this);
hass.updateTranslations(null, "en");
hass.updateTranslations("config", "en");
hass.addEntities(ENTITIES);
mockEntityRegistry(hass);
mockDeviceRegistry(hass, DEVICES);
mockConfigEntries(hass);
mockHassioSupervisor(hass);
// Provide the demo areas and link the demo entities to the split devices.
// Set them directly via updateHass (typed against the real registry types)
// instead of the area stub, whose demo-specific type differs.
const areas: Record<string, AreaRegistryEntry> = {};
AREAS.forEach((area) => {
areas[area.area_id] = area;
});
hass.updateHass({ areas, entities: ENTITY_REGISTRY });
hass.mockWS(
"config/device_registry/list_composite_splits",
() => COMPOSITE_SPLITS
);
hass.mockWS("extract_from_target", () => ({
referenced_entities: [],
referenced_devices: [],
referenced_areas: [],
}));
hass.mockWS("auth/sign_path", (params) => params);
}
public provideHass(el) {
el.hass = this.hass;
}
public connectedCallback() {
super.connectedCallback();
this.addEventListener("show-dialog", this._dialogManager);
}
public disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener("show-dialog", this._dialogManager);
}
private _dialogManager = (e: HASSDomEvent<ShowDialogParams<unknown>>) => {
const { dialogTag, dialogImport, dialogParams, addHistory, parentElement } =
e.detail;
showDialog(
this,
dialogTag,
dialogParams,
dialogImport,
parentElement,
addHistory
);
};
protected render(): TemplateResult {
return html`
${SAMPLES.map(
(sample, idx) => html`
<demo-black-white-row .title=${sample.name}>
${["light", "dark"].map(
(slot) => html`
<ha-settings-row narrow slot=${slot}>
<span slot="heading">${sample.name}</span>
<span slot="description">${sample.description}</span>
${
sample.compact
? html`<ha-target-picker
compact
.hass=${this.hass}
.value=${this._values[idx] as HassServiceTarget}
.sampleIdx=${idx}
@value-changed=${this._handleValueChanged}
></ha-target-picker>`
: html`<ha-selector
.hass=${this.hass}
.selector=${sample.selector}
.value=${this._values[idx]}
.sampleIdx=${idx}
@value-changed=${this._handleValueChanged}
></ha-selector>`
}
</ha-settings-row>
`
)}
</demo-black-white-row>
`
)}
`;
}
private _handleValueChanged(ev) {
const idx = ev.target.sampleIdx;
this._values[idx] = ev.detail.value;
this.requestUpdate();
}
static styles = css`
ha-settings-row {
--settings-row-content-width: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-selector-replaced-device": DemoHaSelectorReplacedDevice;
}
}
@@ -82,6 +82,8 @@ const DEVICES: DeviceRegistryEntry[] = [
configuration_url: null,
config_entries: ["config_entry_1"],
config_entries_subentries: {},
config_entry_id: null,
config_subentry_id: null,
connections: [],
disabled_by: null,
entry_type: null,
@@ -106,6 +108,8 @@ const DEVICES: DeviceRegistryEntry[] = [
configuration_url: null,
config_entries: ["config_entry_2"],
config_entries_subentries: {},
config_entry_id: null,
config_subentry_id: null,
connections: [],
disabled_by: null,
entry_type: null,
@@ -130,6 +134,8 @@ const DEVICES: DeviceRegistryEntry[] = [
configuration_url: null,
config_entries: ["config_entry_3"],
config_entries_subentries: {},
config_entry_id: null,
config_subentry_id: null,
connections: [],
disabled_by: null,
entry_type: null,
+7 -1
View File
@@ -29,6 +29,8 @@ const createConfigEntry = (
title,
source: "zeroconf",
state: "loaded",
created_at: 0,
modified_at: 0,
supports_options: false,
supports_remove_device: false,
supports_unload: true,
@@ -204,7 +206,9 @@ const createEntityRegistryEntries = (
platform: "updater",
has_entity_name: false,
unique_id: "updater",
options: null,
original_name: null,
translation_key: null,
options: {},
labels: [],
categories: {},
created_at: 0,
@@ -219,6 +223,8 @@ const createDeviceRegistryEntries = (
entry_type: null,
config_entries: [item.entry_id],
config_entries_subentries: {},
config_entry_id: null,
config_subentry_id: null,
connections: [],
manufacturer: "ESPHome",
model: "Mock Device",
+7 -7
View File
@@ -7,7 +7,7 @@
"name": "home-assistant-frontend",
"version": "1.0.0",
"scripts": {
"build": "node build-scripts/build-manager.mjs",
"build": "script/build_frontend",
"lint:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --max-warnings=0",
"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",
@@ -49,7 +49,7 @@
"@codemirror/lint": "6.9.7",
"@codemirror/search": "6.7.1",
"@codemirror/state": "6.7.1",
"@codemirror/view": "6.43.7",
"@codemirror/view": "6.43.6",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.5.2",
@@ -152,7 +152,7 @@
"@octokit/rest": "22.0.1",
"@playwright/test": "1.62.0",
"@rsdoctor/rspack-plugin": "1.6.1",
"@rspack/core": "2.1.6",
"@rspack/core": "2.1.5",
"@rspack/dev-server": "2.1.0",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -186,14 +186,14 @@
"fs-extra": "11.4.0",
"generate-license-file": "4.2.1",
"glob": "13.0.6",
"globals": "17.8.0",
"globals": "17.7.0",
"gulp": "5.0.1",
"gulp-brotli": "3.0.0",
"gulp-json-transform": "0.5.0",
"gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "30.0.0",
"jsdom": "29.1.1",
"jszip": "3.10.1",
"license-checker-rseidelsohn": "5.0.1",
"lint-staged": "17.2.0",
@@ -224,12 +224,12 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.2",
"@fullcalendar/daygrid": "6.1.21",
"globals": "17.8.0",
"globals": "17.7.0",
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
},
"packageManager": "yarn@4.17.1",
"volta": {
"node": "24.18.1"
"node": "24.18.0"
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260729.0"
version = "20260624.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+3 -37
View File
@@ -61,44 +61,10 @@ fi
echo Core is used from ${coreUrl}
# build the frontend so it connects to the passed core
HASS_URL="$coreUrl" ./node_modules/.bin/gulp develop-app &
develop_pid=$!
HASS_URL="$coreUrl" ./script/develop &
# serve the frontend
./node_modules/.bin/serve -p $frontendPort --single --no-port-switching --config ../script/serve-config.json ./hass_frontend &
serve_pid=$!
stop_children() {
trap - EXIT INT TERM HUP
kill "$develop_pid" "$serve_pid" 2>/dev/null || true
wait "$develop_pid" 2>/dev/null || true
wait "$serve_pid" 2>/dev/null || true
}
trap stop_children EXIT
trap 'stop_children; exit 130' INT
trap 'stop_children; exit 143' TERM
trap 'stop_children; exit 129' HUP
while kill -0 "$develop_pid" 2>/dev/null && kill -0 "$serve_pid" 2>/dev/null; do
sleep 1
done
develop_status=
serve_status=
if ! kill -0 "$develop_pid" 2>/dev/null; then
if wait "$develop_pid"; then develop_status=0; else develop_status=$?; fi
fi
if ! kill -0 "$serve_pid" 2>/dev/null; then
if wait "$serve_pid"; then serve_status=0; else serve_status=$?; fi
fi
if [ -n "$develop_status" ] && [ "$develop_status" -ne 0 ]; then
status=$develop_status
elif [ -n "$serve_status" ]; then
status=$serve_status
else
status=$develop_status
fi
exit "$status"
# keep the script running while serving
wait
@@ -1,29 +1,8 @@
let supported: boolean | undefined;
const detect = (): boolean => {
if (
!globalThis.ElementInternals ||
!globalThis.HTMLElement?.prototype.attachInternals
) {
return false;
}
// Native internals keep their WebIDL brand even when `attachInternals` is
// wrapped (e.g. by `@webcomponents/scoped-custom-element-registry`, which
// broke the previous `[native code]` source check in the app bundle).
// `element-internals-polyfill` swaps in a plain class, which has no brand,
// and must not count as native: login on legacy browsers relies on
// validation being skipped there (#51338).
return (
Object.prototype.toString.call(globalThis.ElementInternals.prototype) ===
"[object ElementInternals]"
);
};
/**
* Indicates whether the current browser has native ElementInternals support.
* Probed on first use so importing this module has no side effects.
*/
export const supportsNativeElementInternals = (): boolean => {
supported ??= detect();
return supported;
};
export const nativeElementInternalsSupported =
Boolean(globalThis.ElementInternals) &&
globalThis.HTMLElement?.prototype.attachInternals
?.toString()
.includes("[native code]");
-52
View File
@@ -1,52 +0,0 @@
import { sanitizeNavigationPath } from "./sanitize-navigation-path";
const HOME_ASSISTANT_SCHEME = "homeassistant://";
/**
* Returns the URL if it is safe to use as a link target, `undefined` otherwise.
* Only absolute `http:` and `https:` URLs pass, the same check
* `ha-attribute-value` already applies to attribute links.
*
* Use for every URL that reaches the frontend as data from an integration
* manifest, an add-on, a config flow or an entity attribute so that a
* `javascript:` URI can never become a clickable link.
*/
export const sanitizeHttpUrl = (
url: string | null | undefined
): string | undefined => {
if (!url) {
return undefined;
}
try {
const { protocol } = new URL(url);
return protocol === "http:" || protocol === "https:" ? url : undefined;
} catch (_err) {
return undefined;
}
};
/** Whether the URL is a `homeassistant://` deep link into the frontend. */
export const isHomeAssistantUrl = (url: string | null | undefined): boolean =>
!!url?.startsWith(HOME_ASSISTANT_SCHEME);
/**
* Turns a `homeassistant://` deep link into an in-app path, or returns
* `undefined` when it does not point inside the frontend. Rewriting the scheme
* on its own is not enough: `homeassistant:///example.com` would become
* `//example.com`, which resolves to another origin.
*/
export const homeAssistantUrlToPath = (
url: string | null | undefined
): string | undefined =>
isHomeAssistantUrl(url)
? sanitizeNavigationPath(`/${url!.slice(HOME_ASSISTANT_SCHEME.length)}`)
: undefined;
/**
* Sanitizes a URL that may be either an external link or a `homeassistant://`
* deep link, returning something safe to bind to an `href`.
*/
export const sanitizeLinkUrl = (
url: string | null | undefined
): string | undefined =>
isHomeAssistantUrl(url) ? homeAssistantUrlToPath(url) : sanitizeHttpUrl(url);
@@ -1,26 +0,0 @@
import { mainWindow } from "../dom/get_main_window";
/**
* Checks that a path resolves to the origin the frontend is served from, so it
* is safe to use as a link target. Rejects URIs that carry their own scheme,
* like `javascript:`, and URLs pointing at another origin. Resolves against the
* main window, because that is where `navigate()` applies the path.
*/
const isSameOriginPath = (path: string): boolean => {
try {
const { origin } = mainWindow.location;
return new URL(path, origin).origin === origin;
} catch (_err) {
return false;
}
};
/**
* Returns the path if it is safe to navigate to, `undefined` otherwise. Use for
* paths that can be influenced by a URL parameter or by dashboard config before
* they end up in an `href` or in `navigate()`.
*/
export const sanitizeNavigationPath = (
path: string | null | undefined
): string | undefined =>
path != null && isSameOriginPath(path) ? path : undefined;
+2 -7
View File
@@ -118,20 +118,15 @@ export class HaProgressButton extends LitElement {
width: 100%;
}
/* Fade the content out rather than hiding it, so the button keeps its
accessible name while the result icon covers it. */
ha-button.result::part(start),
ha-button.result::part(end),
ha-button.result::part(label),
ha-button.result::part(caret) {
opacity: 0;
}
ha-button.result::part(caret),
ha-button.result::part(spinner) {
visibility: hidden;
}
.progress ha-svg-icon {
:host([appearance="brand"]) ha-svg-icon {
color: var(--white-color);
}
`;
-1
View File
@@ -27,7 +27,6 @@ export class HaInputChip extends InputChip {
);
--ha-input-chip-selected-container-opacity: 1;
--md-input-chip-label-text-font: Roboto, sans-serif;
--md-input-chip-label-text-weight: 400;
}
/** Set the size of mdc icons **/
::slotted([slot="icon"]) {
+33 -39
View File
@@ -12,7 +12,6 @@ import {
} from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { join } from "lit/directives/join";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { STRINGS_SEPARATOR_DOT } from "../../common/const";
@@ -484,7 +483,13 @@ export class HaDataTable extends LitElement {
: ""
}
${Object.entries(columns).map(([key, column]) => {
if (!this._isColumnVisible(key, column)) {
if (
column.hidden ||
(this.columnOrder && this.columnOrder.includes(key)
? (this.hiddenColumns?.includes(key) ??
column.defaultHidden)
: column.defaultHidden)
) {
return nothing;
}
const sorted = key === this.sortColumn;
@@ -652,7 +657,10 @@ export class HaDataTable extends LitElement {
${Object.entries(columns).map(([key, column]) => {
if (
(narrow && !column.main && !column.showNarrow) ||
!this._isColumnVisible(key, column)
column.hidden ||
(this.columnOrder && this.columnOrder.includes(key)
? (this.hiddenColumns?.includes(key) ?? column.defaultHidden)
: column.defaultHidden)
) {
return nothing;
}
@@ -684,19 +692,28 @@ export class HaDataTable extends LitElement {
: narrow && column.main
? html`<div class="primary">${row[key]}</div>
<div class="secondary">
${join(
Object.entries(columns)
.filter(([key2, column2]) =>
this._isSecondaryColumnVisible(key2, column2)
)
.map(([key2, column2]) =>
column2.template
? column2.template(row)
: row[key2]
)
.filter(this._hasCellValue),
STRINGS_SEPARATOR_DOT
)}
${Object.entries(columns)
.filter(
([key2, column2]) =>
!column2.hidden &&
!column2.main &&
!column2.showNarrow &&
!(this.columnOrder &&
this.columnOrder.includes(key2)
? (this.hiddenColumns?.includes(key2) ??
column2.defaultHidden)
: column2.defaultHidden)
)
.map(
([key2, column2], i) =>
html`${
i !== 0 ? STRINGS_SEPARATOR_DOT : nothing
}${
column2.template
? column2.template(row)
: row[key2]
}`
)}
</div>
${
column.extraTemplate
@@ -716,29 +733,6 @@ export class HaDataTable extends LitElement {
`;
};
private _isColumnVisible(key: string, column: DataTableColumnData): boolean {
if (column.hidden) {
return false;
}
if (!this.columnOrder?.includes(key)) {
return !column.defaultHidden;
}
return !(this.hiddenColumns?.includes(key) ?? column.defaultHidden);
}
private _isSecondaryColumnVisible(
key: string,
column: DataTableColumnData
): boolean {
if (column.main || column.showNarrow) {
return false;
}
return this._isColumnVisible(key, column);
}
private _hasCellValue = (value: unknown): boolean =>
value !== undefined && value !== null && value !== "" && value !== nothing;
private async _sortFilterData() {
const startTime = new Date().getTime();
const timeBetweenUpdate = startTime - this._lastUpdate;
@@ -1,190 +0,0 @@
import { mdiDevices } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { getDeviceArea } from "../../common/entity/context/get_device_context";
import { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
import { domainToName } from "../../data/integration";
import type { HomeAssistant } from "../../types";
import type { HassDialog } from "../../dialogs/make-dialog-manager";
import { brandsUrl } from "../../util/brands-url";
import "../ha-dialog";
import "../ha-svg-icon";
import "../item/ha-list-item-button";
import "../list/ha-list-base";
import type { DeviceReplacedDialogParams } from "./show-dialog-device-replaced";
@customElement("dialog-device-replaced")
export class DialogDeviceReplaced
extends LitElement
implements HassDialog<DeviceReplacedDialogParams>
{
@state() private _params?: DeviceReplacedDialogParams;
@state() private _open = false;
@state() private _configEntryLookup?: Record<string, ConfigEntry>;
@property({ attribute: false }) public hass!: HomeAssistant;
public async showDialog(params: DeviceReplacedDialogParams): Promise<void> {
this._params = params;
this._open = true;
this._loadConfigEntries();
}
private async _loadConfigEntries(): Promise<void> {
const configEntries = await getConfigEntries(this.hass);
this._configEntryLookup = Object.fromEntries(
configEntries.map((entry) => [entry.entry_id, entry])
);
}
public closeDialog(): boolean {
this._open = false;
return true;
}
private _dialogClosed(): void {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private _pick(ev: Event): void {
const item = (ev.target as HTMLElement).closest("ha-list-item-button") as
(HTMLElement & { deviceId?: string }) | null;
if (!item?.deviceId) {
return;
}
this._params?.onResolved(item.deviceId);
this.closeDialog();
}
private _items = memoizeOne(
(
candidates: string[],
primaryId: string | null,
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
configEntryLookup: Record<string, ConfigEntry> | undefined
) =>
candidates.map((deviceId) => {
const device = devices[deviceId];
const area = device ? getDeviceArea(device, areas) : undefined;
const configEntry = device?.primary_config_entry
? configEntryLookup?.[device.primary_config_entry]
: undefined;
return {
deviceId,
name: device ? computeDeviceName(device) : deviceId,
area: area ? computeAreaName(area) : undefined,
domain: configEntry?.domain,
domainName: configEntry
? domainToName(this.hass.localize, configEntry.domain)
: undefined,
isPrimary: deviceId === primaryId,
};
})
);
protected render() {
if (!this._params || !this.hass) {
return nothing;
}
return html`
<ha-dialog
.open=${this._open}
.headerTitle=${this.hass.localize(
"ui.components.device-picker.replaced_dialog.title"
)}
@closed=${this._dialogClosed}
>
<p class="description">
${this.hass.localize(
"ui.components.device-picker.replaced_dialog.description"
)}
</p>
<ha-list-base @click=${this._pick}>
${this._items(
this._params.candidates,
this._params.primaryId,
this.hass.devices,
this.hass.areas,
this._configEntryLookup
).map((item) => {
const supportingText = [
item.area,
item.domainName,
item.isPrimary
? this.hass.localize(
"ui.components.device-picker.replaced_dialog.recommended"
)
: undefined,
]
.filter(Boolean)
.join(" • ");
return html`
<ha-list-item-button .deviceId=${item.deviceId}>
${
item.domain
? html`<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: item.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>`
: html`<ha-svg-icon
slot="start"
.path=${mdiDevices}
></ha-svg-icon>`
}
<span slot="headline">${item.name}</span>
${
supportingText
? html`<span slot="supporting-text"
>${supportingText}</span
>`
: nothing
}
</ha-list-item-button>
`;
})}
</ha-list-base>
</ha-dialog>
`;
}
static styles = css`
ha-dialog {
--dialog-content-padding: 0;
--ha-row-item-padding-inline: var(--ha-space-6);
}
.description {
margin: 0;
padding: 0 var(--ha-space-6) var(--ha-space-4);
color: var(--secondary-text-color);
}
img[slot="start"] {
width: 24px;
height: 24px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"dialog-device-replaced": DialogDeviceReplaced;
}
}
@@ -12,7 +12,6 @@ import { fullEntitiesContext } from "../../data/context";
import type { DeviceAutomation } from "../../data/device/device_automation";
import {
deviceAutomationsEqual,
deviceAutomationsSimilar,
sortDeviceAutomations,
} from "../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
@@ -202,22 +201,12 @@ export abstract class HaDeviceAutomationPicker<
: // No device, clear the list of automations
[];
// If there is no value, or if we have changed the device ID, reset the
// value. When the device changed (for example after replacing a removed
// device), try to keep the same automation type/subtype on the new device
// before falling back to the first available automation.
// If there is no value, or if we have changed the device ID, reset the value.
if (!this.value || this.value.device_id !== this.deviceId) {
const equivalent =
this.value && this.deviceId
? this._automations.find((automation) =>
deviceAutomationsSimilar(automation, this.value!)
)
: undefined;
this._setValue(
equivalent ||
(this._automations.length
? this._automations[0]
: this._createNoAutomation(this.deviceId))
this._automations.length
? this._automations[0]
: this._createNoAutomation(this.deviceId)
);
}
this._renderEmpty = true;
+47 -245
View File
@@ -1,7 +1,6 @@
import { mdiAlertOutline } from "@mdi/js";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
@@ -14,20 +13,12 @@ import {
getDevices,
type DevicePickerItem,
} from "../../data/device/device_picker";
import {
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
type DeviceRegistryEntry,
} from "../../data/device/device_registry";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import "../ha-alert";
import "../ha-button";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import "../ha-svg-icon";
import { showDeviceReplacedDialog } from "./show-dialog-device-replaced";
export type HaDevicePickerDeviceFilterFunc = (
device: DeviceRegistryEntry
@@ -104,10 +95,6 @@ export class HaDevicePicker extends LitElement {
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
@state() private _compositeSplits?: DeviceCompositeSplits;
private _loadingCompositeSplits = false;
private _getDevicesMemoized = memoizeOne(
(
_devices: HomeAssistant["devices"],
@@ -136,29 +123,6 @@ export class HaDevicePicker extends LitElement {
this._loadConfigEntries();
}
protected willUpdate(changedProperties: PropertyValues<this>): void {
if (
!this.hass ||
!this.value ||
this._compositeSplits !== undefined ||
this._loadingCompositeSplits
) {
return;
}
const oldHass = changedProperties.get("hass") as HomeAssistant | undefined;
const devicesChanged =
changedProperties.has("hass") && this.hass.devices !== oldHass?.devices;
if (
(changedProperties.has("value") || devicesChanged) &&
!this.hass.devices[this.value]
) {
// The selected device is not in the registry; it might be a legacy
// composite device that was split into separate devices. Fetch the
// split map so we can offer to replace the reference.
this._loadCompositeSplits();
}
}
private async _loadConfigEntries() {
const configEntries = await getConfigEntries(this.hass);
this._configEntryLookup = Object.fromEntries(
@@ -166,43 +130,6 @@ export class HaDevicePicker extends LitElement {
);
}
private async _loadCompositeSplits() {
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
} catch (_err) {
this._compositeSplits = {};
} finally {
this._loadingCompositeSplits = false;
}
}
private _getReplacement = memoizeOne(
(
value: string | undefined,
_devices: HomeAssistant["devices"],
compositeSplits: DeviceCompositeSplits | undefined,
items: (DevicePickerItem | string)[]
) => {
if (!value || !compositeSplits || this.hass.devices[value]) {
return undefined;
}
const split = compositeSplits[value];
if (!split) {
return undefined;
}
// Keep only the split devices that pass this picker's filters. In
// practice usually exactly one of the split devices matches.
const selectableIds = new Set(
items
.filter((item): item is DevicePickerItem => typeof item !== "string")
.map((item) => item.id)
);
const candidates = split.split_ids.filter((id) => selectableIds.has(id));
return { candidates, primaryId: split.primary_id };
}
);
private _getItems = () =>
this._getDevicesMemoized(
this.hass.devices,
@@ -217,66 +144,49 @@ export class HaDevicePicker extends LitElement {
);
private _valueRenderer = memoizeOne(
(
configEntriesLookup: Record<string, ConfigEntry>,
replacementName: string | undefined
) =>
(value: string) => {
const deviceId = value;
const device = this.hass.devices[deviceId];
(configEntriesLookup: Record<string, ConfigEntry>) => (value: string) => {
const deviceId = value;
const device = this.hass.devices[deviceId];
if (!device) {
// When the device was replaced and a replacement is available, show
// the replacement device's name. Otherwise fall back to the normal
// "not found" display of the raw id.
if (replacementName) {
return html`
<ha-svg-icon
slot="start"
style="color: var(--warning-color)"
.path=${mdiAlertOutline}
></ha-svg-icon>
<span slot="headline">${replacementName}</span>
`;
}
return html`<span slot="headline">${deviceId}</span>`;
}
const area = getDeviceArea(device, this.hass.areas);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const primary = deviceName;
const secondary = areaName;
const configEntry = device.primary_config_entry
? configEntriesLookup[device.primary_config_entry]
: undefined;
return html`
${
configEntry
? html`<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: configEntry.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>`
: nothing
}
<span slot="headline">${primary}</span>
<span slot="supporting-text">${secondary}</span>
`;
if (!device) {
return html`<span slot="headline">${deviceId}</span>`;
}
const area = getDeviceArea(device, this.hass.areas);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const primary = deviceName;
const secondary = areaName;
const configEntry = device.primary_config_entry
? configEntriesLookup[device.primary_config_entry]
: undefined;
return html`
${
configEntry
? html`<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: configEntry.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>`
: nothing
}
<span slot="headline">${primary}</span>
<span slot="supporting-text">${secondary}</span>
`;
}
);
private _rowRenderer: RenderItemFunction<DevicePickerItem> = (item) => html`
@@ -325,39 +235,7 @@ export class HaDevicePicker extends LitElement {
this.placeholder ??
this.hass.localize("ui.components.device-picker.placeholder");
// Only resolve a replacement (which needs the full item list) when the
// value is a missing device that we know was replaced, to avoid computing
// the item list on every render for the common case.
const replacement =
this.value &&
!this.hass.devices[this.value] &&
this._compositeSplits?.[this.value]
? this._getReplacement(
this.value,
this.hass.devices,
this._compositeSplits,
this._getItems()
)
: undefined;
// Only treat the value as "replaced" when there is an available
// replacement device; otherwise fall back to normal "not found" behavior.
const canReplace = !!replacement?.candidates.length;
const replacementName = canReplace
? computeDeviceName(
this.hass.devices[
replacement!.primaryId &&
replacement!.candidates.includes(replacement!.primaryId)
? replacement!.primaryId
: replacement!.candidates[0]
]
)
: undefined;
const valueRenderer = this._valueRenderer(
this._configEntryLookup,
replacementName
);
const valueRenderer = this._valueRenderer(this._configEntryLookup);
return html`
<ha-generic-picker
@@ -378,84 +256,15 @@ export class HaDevicePicker extends LitElement {
.hideClearIcon=${this.hideClearIcon}
.valueRenderer=${valueRenderer}
.searchKeys=${deviceComboBoxKeys}
.unknownItemText=${
replacement?.candidates.length
? this.hass.localize(
"ui.components.device-picker.device_replaced_count",
{ count: replacement.candidates.length }
)
: this.hass.localize("ui.components.device-picker.unknown")
}
.unknownItemText=${this.hass.localize(
"ui.components.device-picker.unknown"
)}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
${canReplace ? this._renderReplacedAlert(replacement!) : nothing}
`;
}
private _renderReplacedAlert(replacement: {
candidates: string[];
primaryId: string | null;
}) {
const { candidates } = replacement;
const replacementName =
candidates.length === 1
? computeDeviceName(this.hass.devices[candidates[0]])
: undefined;
return html`
<ha-alert alert-type="warning">
${
replacementName
? this.hass.localize(
"ui.components.device-picker.device_replaced_by_one",
{ device: replacementName }
)
: this.hass.localize(
"ui.components.device-picker.device_replaced_by_multiple",
{ count: candidates.length }
)
}
<ha-button
slot="action"
appearance="plain"
@click=${this._handleReplace}
>
${this.hass.localize("ui.components.device-picker.replace_device")}
</ha-button>
</ha-alert>
`;
}
private _handleReplace = () => {
const replacement = this._getReplacement(
this.value,
this.hass.devices,
this._compositeSplits,
this._getItems()
);
if (!replacement?.candidates.length) {
return;
}
const { candidates, primaryId } = replacement;
if (candidates.length === 1) {
this._setValue(candidates[0]);
return;
}
showDeviceReplacedDialog(this, {
originalDeviceId: this.value!,
candidates,
primaryId,
onResolved: (deviceId) => this._setValue(deviceId),
});
};
private _setValue(value: string) {
this.value = value;
fireEvent(this, "value-changed", { value });
}
public async open() {
await this.updateComplete;
await this._picker?.open();
@@ -472,13 +281,6 @@ export class HaDevicePicker extends LitElement {
this.hass.localize("ui.components.device-picker.no_match", {
term: html`<b>${search}</b>`,
});
static styles = css`
ha-alert {
display: block;
margin-top: 8px;
}
`;
}
declare global {
@@ -1,22 +0,0 @@
import { fireEvent } from "../../common/dom/fire_event";
export interface DeviceReplacedDialogParams {
/** The removed composite device that is being referenced. */
originalDeviceId: string;
/** The split devices the reference can be pointed at. */
candidates: string[];
/** The split device that took over the composite's primary config entry. */
primaryId: string | null;
/** Called with the device the user picked as replacement. */
onResolved: (deviceId: string) => void;
}
export const showDeviceReplacedDialog = (
element: HTMLElement,
params: DeviceReplacedDialogParams
) =>
fireEvent(element, "show-dialog", {
dialogTag: "dialog-device-replaced",
dialogImport: () => import("./dialog-device-replaced"),
dialogParams: params,
});
@@ -85,10 +85,6 @@ export class HaStatisticPicker extends LitElement {
@property() public helper?: string;
@property({ attribute: "error-message" }) public errorMessage?: string;
@property({ type: Boolean }) public invalid = false;
@property() public placeholder?: string;
@property({ attribute: "statistic-types" })
@@ -530,9 +526,6 @@ export class HaStatisticPicker extends LitElement {
.autofocus=${this.autofocus}
.allowCustomValue=${this.allowCustomEntity}
.disabled=${this.disabled}
.required=${this.required}
.invalid=${this.invalid}
.errorMessage=${this.errorMessage}
.label=${this.label}
use-top-label
.placeholder=${placeholder}
+2 -10
View File
@@ -57,10 +57,7 @@ import "./ha-code-editor-completion-items";
import type { CompletionItem } from "./ha-code-editor-completion-items";
import "./ha-icon";
import "./ha-icon-button-toolbar";
import type {
HaIconButtonToolbar,
HaIconButtonToolbarItem,
} from "./ha-icon-button-toolbar";
import type { HaIconButtonToolbar } from "./ha-icon-button-toolbar";
declare global {
interface HASSDomEvents {
@@ -118,9 +115,6 @@ export class HaCodeEditor extends ReactiveElement {
@property({ type: Boolean, attribute: "has-test" })
public hasTest = false;
@property({ attribute: false })
public toolbarItems?: (HaIconButtonToolbarItem | string)[];
@property({ attribute: false }) public testing = false;
@property({ type: String }) public placeholder?: string;
@@ -357,8 +351,7 @@ export class HaCodeEditor extends ReactiveElement {
changedProps.has("_canCopy") ||
changedProps.has("_canUndo") ||
changedProps.has("_canRedo") ||
changedProps.has("testing") ||
changedProps.has("toolbarItems")
changedProps.has("testing")
) {
this._updateToolbarButtons();
}
@@ -536,7 +529,6 @@ export class HaCodeEditor extends ReactiveElement {
}
this._editorToolbar.items = [
...(this.toolbarItems ?? []),
...(this.hasTest && !this._isFullscreen
? [
{
+10 -37
View File
@@ -1,7 +1,6 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { PropertyValues } from "lit";
import { css, LitElement, svg } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { formatNumber } from "../common/number/format_number";
import { blankBeforePercent } from "../common/translations/blank_before_percent";
@@ -47,32 +46,15 @@ export class HaGauge extends LitElement {
@state() private _segment_label?: string = "";
@query(".text") private _textSvg?: SVGSVGElement;
@query(".value-text") private _valueText?: SVGTextElement;
private _sortedLevels?: LevelDefinition[];
// Set when the value text could not be measured because we have no layout box
// yet, either disconnected or inside a hidden container.
private _rescalePending = false;
// Measure again once we get a layout box, e.g. when a section hidden by a
// visibility condition is revealed. Nothing else re-renders the gauge then.
// @ts-ignore side-effect-only controller, its value is never read
private _resizeController = new ResizeController(this, {
skipInitial: true,
callback: (entries) => {
if (this._rescalePending && entries[0]?.contentRect.width) {
this._rescaleSvg();
}
},
});
private _rescaleOnConnect = false;
public connectedCallback(): void {
super.connectedCallback();
if (this._rescalePending && this.hasUpdated) {
if (this._rescaleOnConnect) {
this._rescaleSvg();
this._rescaleOnConnect = false;
}
}
@@ -239,22 +221,15 @@ export class HaGauge extends LitElement {
// fit the text
// That way it will auto-scale correctly
if (!this._textSvg || !this._valueText || !this.isConnected) {
this._rescalePending = true;
if (!this.isConnected) {
// Retry this later if we're disconnected, otherwise we get a 0 bbox and missing label
this._rescaleOnConnect = true;
return;
}
const box = this._valueText.getBBox();
// An empty box means we have no layout, so keep the last known good viewBox
// and retry later. A viewBox with a 0 width or height would hide the label.
if (!box.width || !box.height) {
this._rescalePending = true;
return;
}
this._rescalePending = false;
this._textSvg.setAttribute(
const svgRoot = this.shadowRoot!.querySelector(".text")!;
const box = svgRoot.querySelector("text")!.getBBox()!;
svgRoot.setAttribute(
"viewBox",
`${box.x} ${box.y} ${box.width} ${box.height}`
);
@@ -273,8 +248,6 @@ export class HaGauge extends LitElement {
static styles = css`
:host {
/* a non replaced inline element never reports a size to a resize observer */
display: block;
position: relative;
}
+3 -17
View File
@@ -304,21 +304,14 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
private _renderHelper() {
const showError = this.invalid && this.errorMessage;
const showHelper = !showError && this.helper;
if (!showError && !this.helper) {
if (!showError && !showHelper) {
return nothing;
}
return html`<ha-input-helper-text .disabled=${this.disabled}>
${
showError
? html`<span class="error">${this.errorMessage}</span> ${
this.helper
? html`<span class="helper">${this.helper}</span>`
: nothing
}`
: this.helper
}
${showError ? this.errorMessage : this.helper}
</ha-input-helper-text>`;
}
@@ -455,13 +448,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
:host([invalid]) ha-input-helper-text {
color: var(--mdc-theme-error, var(--error-color, #b00020));
}
ha-input-helper-text .error,
ha-input-helper-text .helper {
display: block;
}
ha-input-helper-text .helper {
color: var(--secondary-text-color);
}
wa-popover {
--wa-space-l: 0;
+2
View File
@@ -187,6 +187,8 @@ export class HaPictureUpload extends LitElement {
can_play: true,
can_expand: false,
can_search: false,
children_media_class: null,
search_media_classes: null,
thumbnail: generateImageThumbnailUrl(media.id, 256),
} as const;
const navigateIds = [
+9 -7
View File
@@ -488,11 +488,6 @@ export class HaServiceControl extends LitElement {
)) ||
serviceData?.description;
const documentationLink =
this._manifest?.is_built_in && this._value?.action
? documentationUrl(this.hass, `/actions/${this._value.action}`)
: this._manifest?.documentation;
const targetSelector =
serviceData && "target" in serviceData
? this._targetSelector(
@@ -519,9 +514,16 @@ export class HaServiceControl extends LitElement {
<div class="description">
${description ? html`<p>${description}</p>` : ""}
${
documentationLink
this._manifest
? html` <a
href=${documentationLink}
href=${
this._manifest.is_built_in && this._value?.action
? documentationUrl(
this.hass,
`/actions/${this._value.action}`
)
: this._manifest.documentation
}
title=${this.hass.localize(
"ui.components.service-control.integration_doc"
)}
+8 -9
View File
@@ -1,7 +1,6 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import type { HomeAssistant } from "../types";
import { subscribeLabFeature } from "../data/labs";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
@@ -82,14 +81,14 @@ export class HaSnowflakes extends SubscribeMixin(LitElement) {
class="snowflake ${
this.narrow && flake.id >= 30 ? "hide-narrow" : ""
}"
style=${styleMap({
left: `${flake.left}%`,
width: `${flake.size}px`,
height: `${flake.size}px`,
"animation-duration": `${flake.duration}s`,
"animation-delay": `${flake.delay}s`,
"--rotation": `${flake.rotation}deg`,
})}
style="
left: ${flake.left}%;
width: ${flake.size}px;
height: ${flake.size}px;
animation-duration: ${flake.duration}s;
animation-delay: ${flake.delay}s;
--rotation: ${flake.rotation}deg;
"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
-68
View File
@@ -1,68 +0,0 @@
import SplitPanel from "@home-assistant/webawesome/dist/components/split-panel/split-panel";
import type { CSSResultGroup } from "lit";
import { css } from "lit";
import { customElement } from "lit/decorators";
@customElement("ha-split-panel")
export class HaSplitPanel extends SplitPanel {
static get styles(): CSSResultGroup {
return [
SplitPanel.styles,
css`
:host {
--divider-width: var(--ha-split-panel-divider-width, 2px);
--divider-hit-area: var(--ha-split-panel-divider-hit-area, 12px);
--min: var(--ha-split-panel-min, 0);
--max: var(--ha-split-panel-max, 100%);
}
.divider {
background-color: var(--divider-color);
transition: background-color var(--ha-animation-duration-fast)
ease-out;
}
/* Grip affordance so the divider reads as draggable. The divider
already centers its children via flexbox, so keep this in flow.
Consumers slotting their own divider handle can hide it with
--ha-split-panel-grip-display: none. */
.divider::before {
content: "";
width: 2px;
height: var(--ha-space-8);
display: var(--ha-split-panel-grip-display, block);
border-radius: var(--ha-border-radius-pill, 9999px);
background-color: var(--secondary-text-color);
opacity: 0.5;
transition: opacity var(--ha-animation-duration-fast) ease-out;
}
/* In vertical orientation the divider is horizontal, so the grip pill
lies flat instead of standing upright. */
:host([orientation="vertical"]) .divider::before {
width: var(--ha-space-8);
height: 2px;
}
@media (hover: hover) {
:host(:not([disabled])) .divider:hover {
background-color: var(--primary-color);
}
:host(:not([disabled])) .divider:hover::before {
opacity: 1;
}
}
:host(:not([disabled])) .divider:focus-visible {
background-color: var(--primary-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-split-panel": HaSplitPanel;
}
}
-55
View File
@@ -27,10 +27,6 @@ import {
getDevices,
type DevicePickerItem,
} from "../data/device/device_picker";
import {
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
} from "../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../data/entity/entity";
import {
entityComboBoxKeys,
@@ -126,10 +122,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
@state() private _compositeSplits?: DeviceCompositeSplits;
private _loadingCompositeSplits = false;
@state()
@consume({ context: labelsContext, subscribe: true })
private _labelRegistry!: LabelRegistryEntry[];
@@ -219,24 +211,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
this._loadConfigEntries();
}
const devicesChanged =
changedProps.has("hass") &&
this.hass.devices !== changedProps.get("hass")?.devices;
if (
(changedProps.has("value") || devicesChanged) &&
this.hass &&
this._compositeSplits === undefined &&
!this._loadingCompositeSplits &&
this.value?.device_id &&
ensureArray(this.value.device_id).some(
(deviceId) => !this.hass.devices[deviceId]
)
) {
// A referenced device is missing from the registry; it might be a legacy
// composite device that was split. Fetch the split map to offer a fix.
this._loadCompositeSplits();
}
if (
this._pendingEntityId &&
changedProps.has("hass") &&
@@ -323,7 +297,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.hass=${this.hass}
type="device"
.itemId=${device_id}
.compositeSplits=${this._compositeSplits}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
@@ -417,7 +390,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
<ha-target-picker-item-group
@remove-target-item=${this._handleRemove}
@replace-target-item=${this._handleReplace}
@migrate-target-item=${this._handleMigrate}
type="device"
.hass=${this.hass}
.items=${{ device: deviceIds }}
@@ -426,7 +398,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
.compositeSplits=${this._compositeSplits}
>
</ha-target-picker-item-group>
`
@@ -1204,31 +1175,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
);
}
private async _loadCompositeSplits() {
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
} catch (_err) {
this._compositeSplits = {};
} finally {
this._loadingCompositeSplits = false;
}
}
private _handleMigrate(
ev: HASSDomEvent<HASSDomEvents["migrate-target-item"]>
) {
const { id, replacements } = ev.detail;
let value = this._removeItem(this.value, "device", id);
for (const replacement of replacements) {
value = this._addTargetToValue(value, {
type: "device",
id: replacement,
});
}
fireEvent(this, "value-changed", { value });
}
private _renderRow = (
item:
| PickerComboBoxItem
@@ -1411,7 +1357,6 @@ declare global {
"remove-target-item": TargetItem;
"expand-target-item": TargetItem;
"replace-target-item": TargetItem;
"migrate-target-item": { id: string; replacements: string[] };
"remove-target-group": string;
}
}
-3
View File
@@ -228,9 +228,6 @@ export class HaThemeSettings extends LitElement {
}
static styles = css`
a {
color: var(--primary-color);
}
.inputs {
display: flex;
flex-wrap: wrap;
+5 -9
View File
@@ -1,7 +1,7 @@
import { type LitElement, css } from "lit";
import { property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { supportsNativeElementInternals } from "../../common/feature-detect/support-native-element-internals";
import { nativeElementInternalsSupported } from "../../common/feature-detect/support-native-element-internals";
import type { Constructor } from "../../types";
/**
@@ -198,7 +198,7 @@ export const WaInputMixin = <T extends Constructor<LitElement>>(
}
public checkValidity(): boolean {
return supportsNativeElementInternals()
return nativeElementInternalsSupported
? (this._formControl?.checkValidity() ?? true)
: true;
}
@@ -211,7 +211,7 @@ export const WaInputMixin = <T extends Constructor<LitElement>>(
protected _handleInput(): void {
this.value = this._formControl?.value ?? undefined;
if (this._invalid && this.checkValidity()) {
if (this._invalid && this._formControl?.checkValidity()) {
this._invalid = false;
}
}
@@ -222,16 +222,12 @@ export const WaInputMixin = <T extends Constructor<LitElement>>(
protected _handleBlur(): void {
if (this.autoValidate) {
this._invalid = !this.checkValidity();
this._invalid = !this._formControl?.checkValidity();
}
}
protected _handleInvalid(): void {
// Polyfilled internals dispatch `invalid` themselves, so only trust the
// event when validity comes from the platform (#51338).
if (supportsNativeElementInternals()) {
this._invalid = true;
}
this._invalid = true;
}
protected _renderLabel = memoizeOne((label: string, required: boolean) => {
@@ -34,18 +34,15 @@ import {
browseMediaPlayer,
BROWSER_PLAYER,
MediaClassBrowserSettings,
searchMediaPlayer,
} from "../../data/media-player";
import {
browseLocalMediaPlayer,
isManualMediaSourceContentId,
isMediaSourceContentId,
MANUAL_MEDIA_SOURCE_PREFIX,
searchMedia,
} from "../../data/media_source";
import { isTTSMediaSource } from "../../data/tts";
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
import { panelIsReady } from "../../layouts/panel-ready";
import { haStyle, haStyleScrollbar } from "../../resources/styles";
import { loadVirtualizer } from "../../resources/virtualizer";
import type { HomeAssistant } from "../../types";
@@ -97,6 +94,8 @@ const MANUAL_ITEM_BASE: Omit<MediaPlayerItem, "title"> = {
can_play: false,
can_search: false,
children_media_class: "",
search_media_classes: null,
thumbnail: null,
media_class: "app",
media_content_id: MANUAL_MEDIA_SOURCE_PREFIX,
media_content_type: "",
@@ -162,8 +161,6 @@ export class HaMediaPlayerBrowse extends LitElement {
private _resizeObserver?: ResizeObserver;
private _initialReady = false;
public connectedCallback(): void {
super.connectedCallback();
this.updateComplete.then(() => this._attachResizeObserver());
@@ -277,7 +274,6 @@ export class HaMediaPlayerBrowse extends LitElement {
ids: navigateIds,
current: this._currentItem,
});
this._signalInitialReady();
} else {
if (!currentProm) {
currentProm = this._fetchData(
@@ -293,7 +289,6 @@ export class HaMediaPlayerBrowse extends LitElement {
ids: navigateIds,
current: item,
});
this._signalInitialReady();
},
(err) => {
// When we change entity ID, we will first try to see if the new entity is
@@ -327,10 +322,8 @@ export class HaMediaPlayerBrowse extends LitElement {
),
code: "entity_not_found",
});
this._signalInitialReady();
} else {
this._setError(err);
this._signalInitialReady();
}
}
);
@@ -878,31 +871,13 @@ export class HaMediaPlayerBrowse extends LitElement {
const mediaFilterClasses = this._mediaClassFilter.length
? this._mediaClassFilter
: undefined;
// A player's tree can embed media sources, which resolve their own searches;
// everything else in it uses integration specific ids only the entity knows.
const searchEntityId =
this.entityId &&
this.entityId !== BROWSER_PLAYER &&
!isMediaSourceContentId(navigateId.media_content_id ?? "")
? this.entityId
: undefined;
try {
const { result } = searchEntityId
? await searchMediaPlayer(
this.hass,
searchEntityId,
searchQuery,
navigateId.media_content_id,
navigateId.media_content_type,
mediaFilterClasses
)
: await searchMedia(
this.hass,
navigateId.media_content_id,
searchQuery,
mediaFilterClasses
);
const { result } = await searchMedia(
this.hass,
navigateId.media_content_id,
searchQuery,
mediaFilterClasses
);
// Ignore the response if a newer search started or we navigated away
if (requestId !== this._searchRequestId) {
return;
@@ -1152,21 +1127,6 @@ export class HaMediaPlayerBrowse extends LitElement {
fireEvent(this, "close-dialog");
}
private _signalInitialReady(): void {
if (this._initialReady) {
return;
}
this._initialReady = true;
const root = this.getRootNode();
panelIsReady(
root instanceof ShadowRoot &&
root.host instanceof HTMLElement &&
root.host.tagName.startsWith("HA-PANEL-")
? root.host
: this
);
}
private _setError(error: any) {
if (!this.dialog) {
this._error = error;
@@ -1231,8 +1191,8 @@ export class HaMediaPlayerBrowse extends LitElement {
}
private _animateHeaderHeight() {
let start: number | undefined;
const animate = (time: number) => {
let start;
const animate = (time) => {
if (start === undefined) {
start = time;
}
@@ -1,6 +1,5 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type { DeviceCompositeSplits } from "../../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { TargetType, TargetTypeFloorless } from "../../data/target";
import type { HomeAssistant } from "../../types";
@@ -53,9 +52,6 @@ export class HaTargetPickerItemGroup extends LitElement {
@property({ type: Boolean, attribute: "primary-entities-only" })
public primaryEntitiesOnly?: boolean;
@property({ attribute: false })
public compositeSplits?: DeviceCompositeSplits;
protected render() {
let count = 0;
Object.values(this.items).forEach((items) => {
@@ -91,7 +87,6 @@ export class HaTargetPickerItemGroup extends LitElement {
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
.compositeSplits=${this.compositeSplits}
></ha-target-picker-item-row>`
)
: nothing
@@ -34,10 +34,7 @@ import { computeRTL } from "../../common/util/compute_rtl";
import type { AreaRegistryEntry } from "../../data/area/area_registry";
import { getConfigEntry } from "../../data/config_entries";
import { labelsContext } from "../../data/context";
import type {
DeviceCompositeSplits,
DeviceRegistryEntry,
} from "../../data/device/device_registry";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { FloorRegistryEntry } from "../../data/floor_registry";
import { domainToName } from "../../data/integration";
@@ -57,7 +54,6 @@ import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
import { floorDefaultIconPath } from "../ha-floor-icon";
import "../ha-button";
import "../ha-icon-button";
import "../ha-state-icon";
import "../ha-svg-icon";
@@ -112,9 +108,6 @@ export class HaTargetPickerItemRow extends LitElement {
@property({ type: Boolean, attribute: "primary-entities-only" })
public primaryEntitiesOnly?: boolean;
@property({ attribute: false })
public compositeSplits?: DeviceCompositeSplits;
@state() private _iconImg?: string;
@state() private _domainName?: string;
@@ -135,15 +128,6 @@ export class HaTargetPickerItemRow extends LitElement {
const { name, context, iconPath, fallbackIconPath, stateObject, notFound } =
this._itemData(this.type, this.itemId);
const replacement =
this.type === "device" && notFound
? this._getReplacement(this.itemId)
: undefined;
// Only surface the "replaced" state when there is at least one available
// replacement device to migrate to. If every replacement device was
// deleted (or filtered out), fall back to the plain "not found" state.
const canMigrate = !!replacement?.candidates.length;
const showEntities = this.type !== "entity" && !notFound;
const entries = this.parentEntries || this._entries;
@@ -190,20 +174,15 @@ export class HaTargetPickerItemRow extends LitElement {
}
</div>
<div slot="headline">${(canMigrate && replacement?.name) || name}</div>
<div slot="headline">${name}</div>
${
notFound || (context && !this.hideContext)
? html`<span slot="supporting-text"
>${
notFound
? canMigrate
? this.hass.localize(
"ui.components.target-picker.device_replaced",
{ count: replacement!.candidates.length }
)
: this.hass.localize(
`ui.components.target-picker.${this.type}_not_found`
)
? this.hass.localize(
`ui.components.target-picker.${this.type}_not_found`
)
: context
}</span
>`
@@ -250,24 +229,6 @@ export class HaTargetPickerItemRow extends LitElement {
`
: nothing
}
${
canMigrate
? html`
<ha-button
class="migrate"
slot="end"
appearance="plain"
variant="warning"
size="s"
@click=${this._migrate}
>
${this.hass.localize(
"ui.components.target-picker.replace_device"
)}
</ha-button>
`
: nothing
}
${
!this.expand && !this.subEntry
? html`
@@ -746,51 +707,6 @@ export class HaTargetPickerItemRow extends LitElement {
});
}
// Returns the split devices that replaced a removed composite device and
// pass this row's filters, or undefined if the item is not a replaced device.
private _getReplacement(item: string) {
const split = this.compositeSplits?.[item];
if (!split || this.hass.devices[item]) {
return undefined;
}
const candidates = split.split_ids.filter((id) => {
const device = this.hass.devices[id];
return (
device &&
deviceMeetsFilter(
device,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter,
!this.primaryEntitiesOnly
)
);
});
// Display the replaced reference using the primary replacement device's
// name instead of the removed composite device id. Fall back to the first
// available candidate if the primary device itself was deleted.
const nameDevice =
(split.primary_id && this.hass.devices[split.primary_id]) ||
(candidates.length ? this.hass.devices[candidates[0]] : undefined);
const name = nameDevice ? computeDeviceName(nameDevice) : undefined;
return { candidates, name };
}
private _migrate = (ev: MouseEvent) => {
ev.stopPropagation();
const replacement = this._getReplacement(this.itemId);
if (!replacement?.candidates.length) {
return;
}
fireEvent(this, "migrate-target-item", {
id: this.itemId,
replacements: replacement.candidates,
});
};
private _openDetails(ev: MouseEvent) {
ev.stopPropagation();
showTargetDetailsDialog(this, {
@@ -830,11 +746,6 @@ export class HaTargetPickerItemRow extends LitElement {
color: var(--ha-color-on-warning-normal);
}
.migrate {
align-self: center;
white-space: nowrap;
}
.replaceable {
cursor: pointer;
}
@@ -1,7 +1,6 @@
import "@home-assistant/webawesome/dist/components/tag/tag";
import { consume } from "@lit/context";
import {
mdiAlertOutline,
mdiDevices,
mdiHome,
mdiLabel,
@@ -10,7 +9,6 @@ import {
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { computeCssColor } from "../../common/color/compute-color";
import { hex2rgb } from "../../common/color/convert-color";
@@ -21,7 +19,6 @@ import { computeStateName } from "../../common/entity/compute_state_name";
import { slugify } from "../../common/string/slugify";
import { getConfigEntry } from "../../data/config_entries";
import { labelsContext } from "../../data/context";
import type { DeviceCompositeSplits } from "../../data/device/device_registry";
import { domainToName } from "../../data/integration";
import type { LabelRegistryEntry } from "../../data/label/label_registry";
import type { TargetType } from "../../data/target";
@@ -42,9 +39,6 @@ export class HaTargetPickerValueChip extends LitElement {
@property({ attribute: "item-id" }) public itemId!: string;
@property({ attribute: false })
public compositeSplits?: DeviceCompositeSplits;
@state() private _domainName?: string;
@state() private _iconImg?: string;
@@ -57,74 +51,38 @@ export class HaTargetPickerValueChip extends LitElement {
const { name, iconPath, fallbackIconPath, stateObject, color } =
this._itemData(this.type, this.itemId);
const split =
this.type === "device" && !this.hass.devices?.[this.itemId]
? this.compositeSplits?.[this.itemId]
: undefined;
// Show the replaced reference using the primary replacement device's name,
// falling back to the first still-existing split device if the primary
// device itself was deleted. If no replacement device exists at all, fall
// back to the normal "not found" display.
const replacementDevice = split
? (split.primary_id && this.hass.devices?.[split.primary_id]) ||
split.split_ids
.map((id) => this.hass.devices?.[id])
.find((device) => device)
: undefined;
const replaced = !!replacementDevice;
const replacedName = replacementDevice
? computeDeviceNameDisplay(
replacementDevice,
this.hass.localize,
this.hass.states
)
: undefined;
return html`
<wa-tag
pill
with-remove
class=${classMap({ [this.type]: true, replaced })}
class=${this.type}
style=${color ? `--color: rgb(${color});` : ""}
@wa-remove=${this._removeItem}
>
<div class="icon">
${
replaced
? html`<ha-svg-icon .path=${mdiAlertOutline}></ha-svg-icon>`
: iconPath
? html`<ha-icon .icon=${iconPath}></ha-icon>`
: this._iconImg
? html`<img
alt=${this._domainName || ""}
width="24"
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: fallbackIconPath
? html`<ha-svg-icon
.path=${fallbackIconPath}
></ha-svg-icon>`
: stateObject
? html`<ha-state-icon
.stateObj=${stateObject}
></ha-state-icon>`
: nothing
iconPath
? html`<ha-icon .icon=${iconPath}></ha-icon>`
: this._iconImg
? html`<img
alt=${this._domainName || ""}
width="24"
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: fallbackIconPath
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
: stateObject
? html`<ha-state-icon
.stateObj=${stateObject}
></ha-state-icon>`
: nothing
}
</div>
<span class="name">
${
replaced
? replacedName ||
this.hass.localize(
"ui.components.target-picker.replaced_device"
)
: name
}
</span>
<span class="name"> ${name} </span>
${
this.type === "entity" || replaced
this.type === "entity"
? nothing
: html`<ha-tooltip .for="expand-${slugify(this.itemId)}"
>${this.hass.localize(
@@ -167,7 +125,7 @@ export class HaTargetPickerValueChip extends LitElement {
if (type === "device") {
const device = this.hass.devices?.[itemId];
if (device?.primary_config_entry) {
if (device.primary_config_entry) {
this._getDeviceDomain(device.primary_config_entry);
}
@@ -282,11 +240,6 @@ export class HaTargetPickerValueChip extends LitElement {
--background-color: var(--color);
--icon-primary-color: var(--primary-text-color);
}
wa-tag.replaced {
border-color: var(--ha-color-border-warning-normal, var(--warning-color));
--background-color: var(--warning-color);
color: var(--ha-color-on-warning-normal, var(--warning-color));
}
.name {
overflow: hidden;
+2 -17
View File
@@ -15,10 +15,6 @@ export class HaTileContainer extends LitElement {
@property({ type: Boolean })
public vertical = false;
/* reserve a consistent height for the info block instead of sizing to content, so sibling tiles stay aligned */
@property({ type: Boolean, attribute: "fixed-info-height" })
public fixedInfoHeight = false;
@property({ attribute: false })
public interactive = false;
@@ -38,10 +34,7 @@ export class HaTileContainer extends LitElement {
protected render() {
const containerOrientationClass =
this.featurePosition === "inline" ? "horizontal" : "";
const contentClasses = {
vertical: this.vertical,
"fixed-info-height": this.fixedInfoHeight,
};
const contentClasses = { vertical: this.vertical };
return html`
<div
@@ -119,15 +112,7 @@ export class HaTileContainer extends LitElement {
flex-direction: column;
text-align: center;
justify-content: center;
padding: 10px var(--ha-space-2);
}
.vertical.fixed-info-height {
/* pin sizing so every tile in a grid reserves the same height, wrapping or not, secondary or not */
gap: 2px;
--ha-tile-info-gap: 2px;
--ha-tile-info-primary-line-height: var(--ha-space-4);
--ha-tile-info-primary-min-height: var(--ha-space-8);
--ha-tile-info-min-height: var(--ha-space-12);
padding: 10px 0;
}
.vertical ::slotted([slot="info"]) {
width: 100%;
+4 -25
View File
@@ -15,11 +15,6 @@ import { customElement, property } from "lit/decorators";
*
* @property {boolean} secondaryLoading - Whether the secondary text is loading. Shows a skeleton placeholder.
*
* @csspart primary - The primary text. Style it to opt into another truncation, such as a multi line clamp.
*
* @cssprop --ha-tile-info-gap - The vertical gap between the primary and secondary text. defaults to `0`.
* @cssprop --ha-tile-info-min-height - Minimum height of the primary/secondary block. Set this to reserve space for a missing secondary so it doesn't shift surrounding content. defaults to `auto`.
* @cssprop --ha-tile-info-primary-min-height - Minimum height of the primary text block, independent of the number of rendered lines. Lets tiles that never wrap still match the height of tiles that do. defaults to `auto` (sizes to the actual rendered lines).
* @cssprop --ha-tile-info-primary-font-size - The font size of the primary text. defaults to `var(--ha-font-size-m)`.
* @cssprop --ha-tile-info-primary-font-weight - The font weight of the primary text. defaults to `var(--ha-font-weight-medium)`.
* @cssprop --ha-tile-info-primary-line-height - The line height of the primary text. defaults to `var(--ha-line-height-normal)`.
@@ -44,7 +39,7 @@ export class HaTileInfo extends LitElement {
return html`
<div class="info">
<slot name="primary" class="primary">
<span part="primary">${this.primary}</span>
<span>${this.primary}</span>
</slot>
${
this.secondaryLoading
@@ -64,8 +59,6 @@ export class HaTileInfo extends LitElement {
display: block;
width: 100%;
min-width: 0;
--tile-info-gap: var(--ha-tile-info-gap, 0);
--tile-info-min-height: var(--ha-tile-info-min-height, auto);
--tile-info-primary-font-size: var(
--ha-tile-info-primary-font-size,
var(--ha-font-size-m)
@@ -78,10 +71,6 @@ export class HaTileInfo extends LitElement {
--ha-tile-info-primary-line-height,
var(--ha-line-height-normal)
);
--tile-info-primary-min-height: var(
--ha-tile-info-primary-min-height,
auto
);
--tile-info-primary-letter-spacing: var(
--ha-tile-info-primary-letter-spacing,
0.1px
@@ -117,38 +106,28 @@ export class HaTileInfo extends LitElement {
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: var(--tile-info-gap);
min-height: var(--tile-info-min-height);
}
.primary span,
::slotted([slot="primary"]),
.secondary span,
::slotted([slot="secondary"]) {
span,
::slotted(*) {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
width: 100%;
}
.primary {
display: flex;
align-items: center;
width: 100%;
font-size: var(--tile-info-primary-font-size);
font-weight: var(--tile-info-primary-font-weight);
line-height: var(--tile-info-primary-line-height);
letter-spacing: var(--tile-info-primary-letter-spacing);
color: var(--tile-info-primary-color);
min-height: var(--tile-info-primary-min-height);
}
.secondary {
display: flex;
align-items: center;
width: 100%;
font-size: var(--tile-info-secondary-font-size);
font-weight: var(--tile-info-secondary-font-weight);
line-height: var(--tile-info-secondary-line-height);
letter-spacing: var(--tile-info-secondary-letter-spacing);
color: var(--tile-info-secondary-color);
width: 100%;
}
.placeholder {
width: 140px;
+11 -13
View File
@@ -49,25 +49,23 @@ export const YAML_ONLY_ACTION_TYPES = new Set<keyof typeof ACTION_ICONS>([
export const ACTION_COLLECTIONS: AutomationElementGroupCollection[] = [
{
groups: {
device_id: {},
dynamicGroups: {},
event: {},
service: {},
set_conversation_response: {},
},
},
{
titleKey: "ui.panel.config.automation.editor.actions.groups.helpers.label",
groups: {
helpers: {},
},
},
{
titleKey: "ui.panel.config.automation.editor.actions.groups.generic.label",
generic: true,
titleKey: "ui.panel.config.automation.editor.actions.groups.other.label",
groups: {
device_id: {},
},
},
{
titleKey:
"ui.panel.config.automation.editor.actions.groups.integrations.label",
groups: {
integrationGroups: {},
event: {},
service: {},
set_conversation_response: {},
other: {},
},
},
] as const;
+2 -2
View File
@@ -7,7 +7,7 @@ export interface AssistPipeline {
name: string;
language: string;
conversation_engine: string;
conversation_language: string | null;
conversation_language: string;
prefer_local_intents?: boolean;
stt_engine: string | null;
stt_language: string | null;
@@ -27,7 +27,7 @@ export interface AssistPipelineMutableParams {
name: string;
language: string;
conversation_engine: string;
conversation_language: string | null;
conversation_language: string;
prefer_local_intents?: boolean;
stt_engine: string | null;
stt_language: string | null;
+1
View File
@@ -5,6 +5,7 @@ import { UNAVAILABLE } from "./entity/entity";
export enum AssistSatelliteEntityFeature {
ANNOUNCE = 1,
START_CONVERSATION = 2,
}
export interface WakeWordInterceptMessage {
+8 -9
View File
@@ -81,7 +81,8 @@ export interface BackupMutableConfig {
schedule?: {
recurrence: BackupScheduleRecurrence;
time?: string | null;
days?: BackupDay[] | null;
// The backend schema has no null branch for days, send [] to clear
days?: BackupDay[];
};
agents?: BackupAgentsConfig;
}
@@ -121,22 +122,19 @@ export interface BackupContent {
"supervisor.addon_update"?: string;
"supervisor.app_update"?: string;
};
with_automatic_settings: boolean;
with_automatic_settings: boolean | null;
}
export interface BackupData {
addons: BackupAddon[];
database_included: boolean;
folders: string[];
homeassistant_version: string;
// null when homeassistant_included is false
homeassistant_version: string | null;
homeassistant_included: boolean;
}
export interface BackupAddon {
name: string;
slug: string;
version: string;
}
export type BackupAddon = AddonInfo;
export interface BackupContentExtended extends BackupContent, BackupData {}
@@ -152,7 +150,8 @@ export interface BackupInfo {
}
export interface BackupDetails {
backup: BackupContentExtended;
backup: BackupContentExtended | null;
agent_errors: Record<string, string>;
}
export interface BackupAgentsInfo {
+16 -5
View File
@@ -1,7 +1,7 @@
import type { HomeAssistant } from "../types";
export type BackupManagerState =
"idle" | "create_backup" | "receive_backup" | "restore_backup";
"idle" | "blocked" | "create_backup" | "receive_backup" | "restore_backup";
export type CreateBackupStage =
| "addon_repositories"
@@ -39,26 +39,34 @@ export type RestoreBackupStage =
| "remove_delta_addons"
| "remove_delta_apps";
export type RestoreBackupState = "completed" | "failed" | "in_progress";
export type RestoreBackupState =
"completed" | "core_restart" | "failed" | "in_progress";
interface IdleEvent {
manager_state: "idle";
}
interface BlockedEvent {
manager_state: "blocked";
}
interface CreateBackupEvent {
manager_state: "create_backup";
reason: string | null;
stage: CreateBackupStage | null;
state: CreateBackupState;
}
interface ReceiveBackupEvent {
manager_state: "receive_backup";
reason: string | null;
stage: ReceiveBackupStage | null;
state: ReceiveBackupState;
}
interface RestoreBackupEvent {
manager_state: "restore_backup";
reason: string | null;
stage: RestoreBackupStage | null;
state: RestoreBackupState;
}
@@ -70,11 +78,14 @@ export interface UploadBackupEvent {
total_bytes: number;
}
export type ManagerState =
"idle" | "create_backup" | "receive_backup" | "restore_backup";
export type ManagerState = BackupManagerState;
export type ManagerStateEvent =
IdleEvent | CreateBackupEvent | ReceiveBackupEvent | RestoreBackupEvent;
| IdleEvent
| BlockedEvent
| CreateBackupEvent
| ReceiveBackupEvent
| RestoreBackupEvent;
export type BackupSubscriptionEvent = ManagerStateEvent | UploadBackupEvent;
+2 -1
View File
@@ -4,8 +4,9 @@ import type { Store } from "home-assistant-js-websocket/dist/store";
import { stringCompare } from "../common/string/compare";
import type { HomeAssistant } from "../types";
import { debounce } from "../common/util/debounce";
import type { RegistryEntry } from "./registry";
export interface CategoryRegistryEntry {
export interface CategoryRegistryEntry extends RegistryEntry {
category_id: string;
name: string;
icon: string | null;
-1
View File
@@ -64,7 +64,6 @@ export type ClimateEntity = HassEntityBase & {
swing_modes?: string[];
swing_horizontal_mode?: string;
swing_horizontal_modes?: string[];
aux_heat?: "on" | "off";
};
};
+3 -5
View File
@@ -1,8 +1,6 @@
import type { EntityDomainFilter } from "../common/entity/entity_domain_filter";
import type { HomeAssistant } from "../types";
type StrictConnectionMode = "disabled" | "guard_page" | "drop_connection";
interface CloudStatusNotLoggedIn {
logged_in: false;
cloud: "disconnected" | "connecting" | "connected";
@@ -21,7 +19,8 @@ export interface CloudPreferences {
alexa_enabled: boolean;
remote_enabled: boolean;
remote_allow_remote_enable: boolean;
strict_connection: StrictConnectionMode;
alexa_default_expose: string[] | null;
google_default_expose: string[] | null;
google_secure_devices_pin: string | undefined;
cloudhooks: Record<string, CloudWebhook>;
alexa_report_state: boolean;
@@ -42,7 +41,7 @@ export interface CloudStatusLoggedIn {
email: string;
google_registered: boolean;
google_entities: EntityDomainFilter;
google_domains: string[];
google_local_connected: boolean;
alexa_registered: boolean;
alexa_entities: EntityDomainFilter;
prefs: CloudPreferences;
@@ -173,7 +172,6 @@ export const updateCloudPref = (
google_secure_devices_pin?: CloudPreferences["google_secure_devices_pin"];
tts_default_voice?: CloudPreferences["tts_default_voice"];
remote_allow_remote_enable?: CloudPreferences["remote_allow_remote_enable"];
strict_connection?: CloudPreferences["strict_connection"];
cloud_ice_servers_enabled?: CloudPreferences["cloud_ice_servers_enabled"];
}
) =>
+3 -2
View File
@@ -21,6 +21,7 @@ export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [
helpers: {},
template: {},
trigger: {},
other: {},
},
},
{
@@ -34,9 +35,9 @@ export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [
},
{
titleKey:
"ui.panel.config.automation.editor.conditions.groups.integrations.label",
"ui.panel.config.automation.editor.conditions.groups.custom_integrations.label",
groups: {
integrationGroups: {},
customDynamicGroups: {},
},
},
] as const;
+5 -3
View File
@@ -1,8 +1,9 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../types";
import type { IntegrationType } from "./integration";
import type { RegistryEntry } from "./registry";
export interface ConfigEntry {
export interface ConfigEntry extends RegistryEntry {
entry_id: string;
domain: string;
title: string;
@@ -14,7 +15,8 @@ export interface ConfigEntry {
| "setup_retry"
| "not_loaded"
| "failed_unload"
| "setup_in_progress";
| "setup_in_progress"
| "unload_in_progress";
supports_options: boolean;
supports_remove_device: boolean;
supports_unload: boolean;
@@ -33,7 +35,7 @@ export interface SubEntry {
subentry_id: string;
subentry_type: string;
title: string;
unique_id: string;
unique_id: string | null;
}
export const getSubEntries = (hass: HomeAssistant, entry_id: string) =>
+10 -2
View File
@@ -91,12 +91,20 @@ export const fetchConfigFlowInProgress = (
type: "config_entries/flow/progress",
});
export interface ConfigFlowInProgressMessage {
type: null | "added" | "removed";
interface ConfigFlowInProgressAddedMessage {
type: null | "added";
flow_id: string;
flow: DataEntryFlowProgress;
}
interface ConfigFlowInProgressRemovedMessage {
type: "removed";
flow_id: string;
}
export type ConfigFlowInProgressMessage =
ConfigFlowInProgressAddedMessage | ConfigFlowInProgressRemovedMessage;
export const subscribeConfigFlowInProgress = (
hass: HomeAssistant,
onChange: (update: ConfigFlowInProgressMessage[]) => void
+1 -47
View File
@@ -2,10 +2,9 @@ import type { HassEntities } from "home-assistant-js-websocket";
import { computeStateName } from "../../common/entity/compute_state_name";
import type { LocalizeFunc } from "../../common/translations/localize";
import type { HaFormSchema } from "../../components/ha-form/types";
import type { CallWS, HomeAssistant } from "../../types";
import type { CallWS } from "../../types";
import type { BaseTrigger } from "../automation";
import { migrateAutomationTrigger } from "../automation";
import type { DeviceCompositeSplits } from "./device_registry";
import type { EntityRegistryEntry } from "../entity/entity_registry";
import {
entityRegistryByEntityId,
@@ -159,51 +158,6 @@ export const deviceAutomationsEqual = (
return true;
};
// Decides how a device automation editor should handle its referenced device.
// A missing device that was replaced by a split device stays editable so the
// device picker can offer to fix the reference; a genuinely unknown device
// cannot be edited visually. Returns "loading" while the split map is unknown.
export const deviceAutomationEditorMode = (
hass: HomeAssistant,
deviceId: string | undefined,
compositeSplits: DeviceCompositeSplits | undefined
): "editable" | "loading" | "unknown-device" => {
if (!deviceId || deviceId in hass.devices) {
return "editable";
}
if (compositeSplits === undefined) {
return "loading";
}
// Only editable if at least one of the split (replacement) devices still
// exists; otherwise the reference is stale and cannot be fixed here.
const split = compositeSplits[deviceId];
return split?.split_ids.some((id) => id in hass.devices)
? "editable"
: "unknown-device";
};
// Like deviceAutomationsEqual, but ignores device_id and entity_id so an
// automation can be matched to the equivalent one on a different device (for
// example when a referenced device was replaced by a split device).
export const deviceAutomationsSimilar = (
a: DeviceAutomation,
b: DeviceAutomation
) => {
if (typeof a !== typeof b) {
return false;
}
return deviceAutomationIdentifiers
.filter((property) => property !== "device_id" && property !== "entity_id")
.every((property) => {
const inA = property in a;
const inB = property in b;
if (!inA && !inB) {
return true;
}
return Object.is(a[property], b[property]);
});
};
const compareEntityIdWithEntityRegId = (
entityRegistry: EntityRegistryEntry[],
entityIdA?: string,
+3 -65
View File
@@ -1,4 +1,3 @@
import type { Connection } from "home-assistant-js-websocket";
import { computeStateName } from "../../common/entity/compute_state_name";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import type { HomeAssistant } from "../../types";
@@ -19,6 +18,8 @@ export interface DeviceRegistryEntry extends RegistryEntry {
id: string;
config_entries: string[];
config_entries_subentries: Record<string, (string | null)[]>;
config_entry_id: string | null;
config_subentry_id: string | null;
connections: [string, string][];
identifiers: [string, string][];
manufacturer: string | null;
@@ -55,69 +56,6 @@ export interface DeviceRegistryEntryMutableParams {
labels?: string[];
}
/**
* Describes how a legacy composite device (that lived on multiple config
* entries) was split into separate devices. The composite device no longer
* exists in the registry; references to it (in automations, targets, ...) now
* need to point at one or more of the split devices instead.
*/
export interface DeviceCompositeSplit {
/** Ids of the devices that replaced the composite device. */
split_ids: string[];
/** The split device that took over the composite's primary config entry. */
primary_id: string | null;
}
/** Map of removed composite device id -> its split information. */
export type DeviceCompositeSplits = Record<string, DeviceCompositeSplit>;
// The composite split migration in core is a one-time operation, so the split
// map is static for the lifetime of the connection. Cache the request per
// connection so it is fetched once and shared across all pickers, instead of
// being requested again by every device/target picker.
const compositeSplitsCache = new WeakMap<
Connection,
Promise<DeviceCompositeSplits>
>();
export const fetchDeviceCompositeSplits = (
hass: Pick<HomeAssistant, "connection" | "callWS">
): Promise<DeviceCompositeSplits> => {
const conn = hass.connection;
let request = compositeSplitsCache.get(conn);
if (!request) {
request = hass
.callWS<DeviceCompositeSplits>({
type: "config/device_registry/list_composite_splits",
})
.catch((err) => {
// Don't cache failures so the next caller retries.
compositeSplitsCache.delete(conn);
throw err;
});
compositeSplitsCache.set(conn, request);
}
return request;
};
/**
* Fetch the devices that are linked to the given device because they share at
* least one connection or identifier. These are separate devices (one per
* config entry) that represent the same physical hardware, managed by
* different integrations.
*/
export const fetchLinkedDevices = (
hass: Pick<HomeAssistant, "callWS">,
deviceId: string
): Promise<string[]> =>
hass
.callWS<{ linked_devices: string[] }>({
type: "config/device_registry/list_linked_devices",
device_id: deviceId,
})
.then((result) => result.linked_devices);
export const fallbackDeviceName = (
hass: HomeAssistant,
entities: EntityRegistryEntry[] | EntityRegistryDisplayEntry[] | string[]
@@ -151,7 +89,7 @@ export const removeConfigEntryFromDevice = (
deviceId: string,
configEntryId: string
) =>
hass.callWS<DeviceRegistryEntry>({
hass.callWS<DeviceRegistryEntry | null>({
type: "config/device_registry/remove_config_entry",
device_id: deviceId,
config_entry_id: configEntryId,
+1 -1
View File
@@ -243,7 +243,7 @@ export interface EnergyInfo {
export interface EnergyValidationIssue {
type: string;
affected_entities: [string, unknown][];
translation_placeholders: Record<string, string>;
translation_placeholders: Record<string, string> | null;
}
export interface EnergyPreferencesValidation {
+9 -9
View File
@@ -37,7 +37,7 @@ export interface EntityRegistryDisplayEntryResponse {
ec?: number;
en?: string;
ic?: string;
pl?: string;
pl: string;
tk?: string;
hb?: boolean;
dp?: number;
@@ -58,21 +58,21 @@ export interface EntityRegistryEntry extends RegistryEntry {
area_id: string | null;
labels: string[];
disabled_by: "user" | "device" | "integration" | "config_entry" | null;
hidden_by: Exclude<EntityRegistryEntry["disabled_by"], "config_entry">;
hidden_by: "user" | "integration" | null;
entity_category: EntityCategory | null;
has_entity_name: boolean;
original_name?: string;
original_name: string | null;
unique_id: string;
translation_key?: string;
options: EntityRegistryOptions | null;
translation_key: string | null;
options: EntityRegistryOptions;
categories: Record<string, string>;
}
export interface ExtEntityRegistryEntry extends EntityRegistryEntry {
capabilities: Record<string, unknown>;
original_icon?: string;
device_class?: string;
original_device_class?: string;
capabilities: Record<string, unknown> | null;
original_icon: string | null;
device_class: string | null;
original_device_class: string | null;
aliases: (string | null)[];
}
@@ -1,22 +0,0 @@
import type { HomeAssistantApi } from "../../types";
import type { EntityIdFormat } from "../entity_id_format";
export interface EntityRegistrySettings {
entity_id_parts: EntityIdFormat | null;
}
export const fetchEntityRegistrySettings = (
api: HomeAssistantApi
): Promise<EntityRegistrySettings> =>
api.callWS<EntityRegistrySettings>({
type: "config/entity_registry/settings/get",
});
export const updateEntityRegistrySettings = (
api: HomeAssistantApi,
updates: Partial<EntityRegistrySettings>
): Promise<EntityRegistrySettings> =>
api.callWS<EntityRegistrySettings>({
type: "config/entity_registry/settings/update",
...updates,
});
-12
View File
@@ -1,12 +0,0 @@
export type EntityIdPart = "area" | "device" | "entity" | "floor";
export type EntityIdFormat = EntityIdPart[];
export const DEFAULT_ENTITY_ID_FORMAT: EntityIdFormat = [
"area",
"device",
"entity",
];
export const isDefaultEntityIdFormat = (format: EntityIdFormat): boolean =>
JSON.stringify(format) === JSON.stringify(DEFAULT_ENTITY_ID_FORMAT);
@@ -5,7 +5,6 @@ export enum ClimateEntityFeature {
FAN_MODE = 8,
PRESET_MODE = 16,
SWING_MODE = 32,
AUX_HEAT = 64,
TURN_OFF = 128,
TURN_ON = 256,
SWING_HORIZONTAL_MODE = 512,
@@ -19,4 +19,7 @@ export enum MediaPlayerEntityFeature {
BROWSE_MEDIA = 131072,
REPEAT_SET = 262144,
GROUPING = 524288,
MEDIA_ANNOUNCE = 1048576,
MEDIA_ENQUEUE = 2097152,
SEARCH_MEDIA = 4194304,
}
+1 -1
View File
@@ -35,7 +35,7 @@ export const updateFloorRegistryEntry = (
floorId: string,
updates: Partial<FloorRegistryEntryMutableParams>
) =>
hass.callWS<AreaRegistryEntry>({
hass.callWS<FloorRegistryEntry>({
type: "config/floor_registry/update",
floor_id: floorId,
...updates,
+12 -4
View File
@@ -17,6 +17,7 @@ export type AddonCapability = Exclude<
>;
export type AddonStage = "stable" | "experimental" | "deprecated";
export type AddonAppArmour = "disable" | "default" | "profile";
export type AddonBootConfig = "auto" | "manual" | "manual_only";
export type AddonRole = "default" | "homeassistant" | "manager" | "admin";
export type AddonStartup =
"initialize" | "system" | "services" | "application" | "once";
@@ -63,13 +64,14 @@ export interface HassioAddonDetails extends HassioAddonInfo {
audio_output: null | string;
audio: boolean;
auth_api: boolean;
auto_uart: boolean;
auto_update: boolean;
boot: "auto" | "manual";
boot_config: AddonBootConfig;
changelog: boolean;
devices: string[];
devicetree: boolean;
discovery: string[];
dns: string[];
docker_api: boolean;
documentation: boolean;
full_access: boolean;
@@ -82,22 +84,24 @@ export interface HassioAddonDetails extends HassioAddonInfo {
host_ipc: boolean;
host_network: boolean;
host_pid: boolean;
host_uts: boolean;
ingress_entry: null | string;
ingress_panel: boolean;
ingress_port: number | null;
ingress_url: null | string;
ingress: boolean;
ip_address: string;
kernel_modules: boolean;
long_description: null | string;
machine: any;
machine: string[];
network_description: null | Record<string, string>;
network: null | Record<string, number>;
options: Record<string, unknown>;
privileged: any;
privileged: string[];
protected: boolean;
rating: number;
schema: HaFormSchema[] | null;
services_role: string[];
services: string[];
signed: boolean;
slug: string;
startup: AddonStartup;
@@ -105,6 +109,10 @@ export interface HassioAddonDetails extends HassioAddonInfo {
system_managed: boolean;
system_managed_config_entry: string | null;
translations: Record<string, AddonTranslations>;
uart: boolean;
udev: boolean;
usb: boolean;
video: boolean;
watchdog: null | boolean;
webui: null | string;
}
+27 -10
View File
@@ -1,29 +1,46 @@
import type { HomeAssistant } from "../../types";
export interface HassioHostInfo {
agent_version: string;
chassis: string;
cpe: string;
deployment: string;
agent_version: string | null;
apparmor_version: string | null;
boot_timestamp: number | null;
broadcast_llmnr: boolean | null;
broadcast_mdns: boolean | null;
chassis: string | null;
cpe: string | null;
deployment: string | null;
disk_life_time: number | null;
disk_free: number;
disk_total: number;
disk_used: number;
dt_synchronized: boolean | null;
dt_utc: string;
features: string[];
hostname: string;
kernel: string;
operating_system: string;
boot_timestamp: number;
startup_time: number;
hostname: string | null;
kernel: string | null;
llmnr_hostname: string | null;
operating_system: string | null;
startup_time: number | null;
timezone: string | null;
use_ntp: boolean | null;
virtualization: string | null;
}
export interface HassioHassOSBootSlot {
state: string | null;
status: string | null;
version: string | null;
}
export interface HassioHassOSInfo {
board: string | null;
boot: string | null;
boot_slots: Record<string, HassioHassOSBootSlot>;
update_available: boolean;
version_latest: string | null;
version_pending: string | null;
version: string | null;
data_disk: string;
data_disk: string | null;
}
export interface Datadisk {
+20 -2
View File
@@ -1,10 +1,28 @@
import type { HomeAssistant, TranslationDict } from "../../types";
export interface ResolutionIssue {
type: string;
context: string;
reference: string | null;
reference_extra: Record<string, unknown> | null;
uuid: string;
}
export interface ResolutionSuggestion extends ResolutionIssue {
auto: boolean;
}
export interface ResolutionCheck {
enabled: boolean;
slug: string;
}
export interface HassioResolution {
unsupported: (keyof TranslationDict["ui"]["dialogs"]["unsupported"]["reasons"])[];
unhealthy: (keyof TranslationDict["ui"]["dialogs"]["unhealthy"]["reasons"])[];
issues: string[];
suggestions: string[];
issues: ResolutionIssue[];
suggestions: ResolutionSuggestion[];
checks: ResolutionCheck[];
}
export const fetchHassioResolution = async (
+35 -5
View File
@@ -1,5 +1,6 @@
import type { HomeAssistant, PanelInfo } from "../../types";
import type { SupervisorArch } from "../supervisor/supervisor";
import type { AddonState } from "./addon";
import type { HassioResponse } from "./common";
export interface HassioHomeAssistantInfo {
@@ -19,14 +20,41 @@ export interface HassioHomeAssistantInfo {
watchdog: boolean;
}
export interface HassioSupervisorAddonInfo {
name: string;
slug: string;
version: string | null;
version_latest: string;
update_available: boolean;
state: AddonState;
repository: string;
icon: boolean;
}
export interface HassioSupervisorRepositoryInfo {
name: string;
slug: string;
}
/**
* Supervisor development toggles. Kept as a union on purpose: flags are
* temporary, so removing one here surfaces every place still reading it.
*/
export type SupervisorFeatureFlag =
"supervisor_v2_api" | "supervisor_websocket_v2_api";
export interface HassioSupervisorInfo {
addons: string[];
addons_repositories: string[];
addons: HassioSupervisorAddonInfo[];
addons_repositories: HassioSupervisorRepositoryInfo[];
arch: SupervisorArch;
auto_update: boolean;
channel: string;
country: string | null;
debug: boolean;
debug_block: boolean;
detect_blocking_io: boolean;
diagnostics: boolean | null;
feature_flags: Record<SupervisorFeatureFlag, boolean>;
healthy: boolean;
ip_address: string;
logging: string;
@@ -35,6 +63,7 @@ export interface HassioSupervisorInfo {
update_available: boolean;
version: string;
version_latest: string;
/** @deprecated No longer used by the Supervisor */
wait_boot: number;
}
@@ -43,11 +72,12 @@ export interface HassioInfo {
channel: string;
docker: string;
features: string[];
hassos: null;
hassos: string | null;
homeassistant: string;
hostname: string;
hostname: string | null;
logging: string;
machine: string;
machine_id: string | null;
state:
| "initialize"
| "setup"
@@ -57,7 +87,7 @@ export interface HassioInfo {
| "shutdown"
| "stopping"
| "close";
operating_system: string;
operating_system: string | null;
supervisor: string;
supported: boolean;
supported_arch: SupervisorArch[];
+14 -29
View File
@@ -1,7 +1,6 @@
import type { Connection } from "home-assistant-js-websocket";
import { createCollection } from "home-assistant-js-websocket";
import type { LocalizeFunc } from "../common/translations/localize";
import { sanitizeHttpUrl } from "../common/url/sanitize-http-url";
import { debounce } from "../common/util/debounce";
import type { HomeAssistant } from "../types";
@@ -21,6 +20,12 @@ export const integrationsWithPanel = {
export type IntegrationType =
"device" | "helper" | "hub" | "service" | "hardware" | "entity" | "system";
/**
* `integration_type` as reported by manifests and brand data. Virtual
* integrations only exist as metadata, they never have config entries.
*/
export type ManifestIntegrationType = IntegrationType | "virtual";
export type DomainManifestLookup = Record<string, IntegrationManifest>;
export interface IntegrationManifest {
@@ -29,16 +34,16 @@ export interface IntegrationManifest {
domain: string;
name: string;
config_flow: boolean;
documentation?: string;
documentation: string;
issue_tracker?: string;
dependencies?: string[];
after_dependencies?: string[];
codeowners?: string[];
requirements?: string[];
ssdp?: { manufacturer?: string; modelName?: string; st?: string }[];
zeroconf?: string[];
zeroconf?: (string | Record<string, string>)[];
homekit?: { models: string[] };
integration_type?: IntegrationType;
integration_type?: ManifestIntegrationType;
loggers?: string[];
quality_scale?:
| "bronze"
@@ -51,6 +56,7 @@ export interface IntegrationManifest {
| "custom";
iot_class:
| "assumed_state"
| "calculated"
| "cloud_polling"
| "cloud_push"
| "local_polling"
@@ -79,27 +85,11 @@ export enum LogSeverity {
export type IntegrationLogPersistance = "none" | "once" | "permanent";
/**
* A custom integration supplies its own manifest, so its URLs are untrusted
* input. Strip them here, where manifests enter the frontend, so no consumer can
* turn one into a link that runs script.
*/
const sanitizeManifest = <T extends IntegrationManifest | undefined>(
manifest: T
): T =>
manifest
? ({
...manifest,
documentation: sanitizeHttpUrl(manifest.documentation),
issue_tracker: sanitizeHttpUrl(manifest.issue_tracker),
} as T)
: manifest;
export const integrationIssuesUrl = (
domain: string,
manifest: IntegrationManifest
) =>
sanitizeHttpUrl(manifest.issue_tracker) ||
manifest.issue_tracker ||
`https://github.com/home-assistant/core/issues?q=is%3Aissue+is%3Aopen+label%3A%22integration%3A+${domain}%22`;
export const domainToName = (
@@ -118,9 +108,7 @@ export const fetchIntegrationManifests = (
if (integrations) {
params.integrations = integrations;
}
return hass
.callWS<IntegrationManifest[]>(params)
.then((manifests) => manifests.map(sanitizeManifest));
return hass.callWS<IntegrationManifest[]>(params);
};
export const fetchIntegrationManifestsCollection = async (
@@ -132,7 +120,7 @@ export const fetchIntegrationManifestsCollection = async (
});
const manifests: DomainManifestLookup = {};
for (const manifest of fetched) {
manifests[manifest.domain] = sanitizeManifest(manifest);
manifests[manifest.domain] = manifest;
}
setValue(manifests);
// One-time fetch — nothing to unsubscribe from
@@ -144,10 +132,7 @@ export const fetchIntegrationManifestsCollection = async (
export const fetchIntegrationManifest = (
hass: HomeAssistant,
integration: string
) =>
hass
.callWS<IntegrationManifest>({ type: "manifest/get", integration })
.then(sanitizeManifest);
) => hass.callWS<IntegrationManifest>({ type: "manifest/get", integration });
export const fetchIntegrationSetups = (hass: HomeAssistant) =>
hass.callWS<IntegrationSetup[]>({ type: "integration/setup_info" });
+2 -2
View File
@@ -1,10 +1,10 @@
import type { HomeAssistant } from "../types";
import type { IntegrationType } from "./integration";
import type { ManifestIntegrationType } from "./integration";
export type IotStandards = "zwave" | "zigbee" | "homekit" | "matter";
export interface Integration {
integration_type: IntegrationType;
integration_type: ManifestIntegrationType;
name?: string;
config_flow?: boolean;
iot_standards?: IotStandards[];
-1
View File
@@ -23,7 +23,6 @@ export interface LovelaceViewElement extends HTMLElement {
badges?: HuiBadge[];
sections?: HuiSection[];
isStrategy: boolean;
initialRenderComplete?: Promise<void>;
setConfig(config: LovelaceViewConfig): void;
}
+4 -4
View File
@@ -27,17 +27,17 @@ export interface MatterFabricData {
fabric_id: number;
vendor_id: number;
fabric_index: number;
fabric_label?: string;
vendor_name?: string;
fabric_label: string | null;
vendor_name: string | null;
}
export interface MatterNodeDiagnostics {
node_id: number;
network_type: NetworkType;
node_type: NodeType;
network_name?: string;
network_name: string | null;
ip_adresses: string[];
mac_address?: string;
mac_address: string | null;
available: boolean;
active_fabrics: MatterFabricData[];
active_fabric_index: number;
+3 -26
View File
@@ -182,14 +182,14 @@ export interface MediaPlayerItem {
media_content_type: string;
media_content_id: string;
media_class: keyof TranslationDict["ui"]["components"]["media-browser"]["class"];
children_media_class?: string | null;
children_media_class: string | null;
can_play: boolean;
can_expand: boolean;
can_search: boolean;
search_media_classes?:
search_media_classes:
| (keyof TranslationDict["ui"]["components"]["media-browser"]["class"])[]
| null;
thumbnail?: string;
thumbnail: string | null;
iconPath?: string;
children?: MediaPlayerItem[];
not_shown?: number;
@@ -208,29 +208,6 @@ export const browseMediaPlayer = (
media_content_type: mediaContentType,
});
export interface SearchMediaResult {
result: MediaPlayerItem[];
}
export const searchMediaPlayer = (
hass: HomeAssistant,
entityId: string,
searchQuery: string,
mediaContentId?: string,
mediaContentType?: string,
mediaFilterClasses?: string[]
): Promise<SearchMediaResult> =>
hass.callWS<SearchMediaResult>({
type: "media_player/search_media",
entity_id: entityId,
search_query: searchQuery,
// the backend requires these two to be passed together, and JSON
// serialization drops them both when the current item is the root
media_content_id: mediaContentId,
media_content_type: mediaContentType,
media_filter_classes: mediaFilterClasses,
});
export const getCurrentProgress = (stateObj: MediaPlayerEntity): number => {
let progress = stateObj.attributes.media_position!;
+5 -1
View File
@@ -1,5 +1,5 @@
import type { HomeAssistant } from "../types";
import type { MediaPlayerItem, SearchMediaResult } from "./media-player";
import type { MediaPlayerItem } from "./media-player";
export interface ResolvedMediaSource {
url: string;
@@ -24,6 +24,10 @@ export const browseLocalMediaPlayer = (
media_content_id: mediaContentId,
});
export interface SearchMediaResult {
result: MediaPlayerItem[];
}
export const searchMedia = (
hass: HomeAssistant,
mediaContentId: string | undefined,
+2 -1
View File
@@ -4,10 +4,11 @@ import type { CloudStatus } from "./cloud";
export interface InstallationType {
installation_type:
| "Home Assistant Operating System"
| "Home Assistant OS"
| "Home Assistant Container"
| "Home Assistant Supervised"
| "Home Assistant Core"
| "Unsupported Third Party Container"
| "Unknown";
}
+4 -1
View File
@@ -47,9 +47,12 @@ export enum StatisticMeanType {
export interface StatisticsMetaData {
statistics_unit_of_measurement: string | null;
display_unit_of_measurement: string | null;
statistic_id: string;
source: string;
name?: string | null;
name: string | null;
/** @deprecated Use `mean_type` instead, removed in HA Core 2026.4 */
has_mean: boolean;
has_sum: boolean;
mean_type: StatisticMeanType;
unit_class: string | null;
+5
View File
@@ -7,8 +7,11 @@ export interface RelatedResult {
config_entry?: string[];
device?: string[];
entity?: string[];
floor?: string[];
group?: string[];
integration?: string[];
label?: string[];
person?: string[];
scene?: string[];
script?: string[];
script_blueprint?: string[];
@@ -29,7 +32,9 @@ export type ItemType =
| "entity"
| "floor"
| "group"
| "integration"
| "label"
| "person"
| "scene"
| "script"
| "automation_blueprint"
+4 -2
View File
@@ -12,9 +12,11 @@ export interface SpeechMetadata {
export interface STTEngine {
engine_id: string;
supported_languages?: string[];
supported_languages: string[];
/** Only set for legacy providers. */
name?: string;
deprecated: boolean;
/** Not currently set by stt/engine/list, unlike the tts equivalent. */
deprecated?: boolean;
}
export const listSTTEngines = (
+27 -12
View File
@@ -12,49 +12,64 @@ export enum SupervisorMountUsage {
SHARE = "share",
}
/** Mirrors the systemd unit active state the Supervisor reports. */
export enum SupervisorMountState {
ACTIVE = "active",
ACTIVATING = "activating",
DEACTIVATING = "deactivating",
FAILED = "failed",
UNKNOWN = "unknown",
INACTIVE = "inactive",
MAINTENANCE = "maintenance",
RELOADING = "reloading",
}
interface MountOptions {
default_backup_mount?: string | null;
}
/** `auto` is a UI-only value, it is stripped before the mount is submitted. */
export type CIFSVersion = "auto" | "1.0" | "2.0";
interface SupervisorMountBase {
name: string;
usage: SupervisorMountUsage;
type: SupervisorMountType;
read_only?: boolean;
server: string;
port: number;
port?: number;
}
export interface SupervisorMountResponse extends SupervisorMountBase {
state: SupervisorMountState | null;
}
export interface SupervisorNFSMount extends SupervisorMountResponse {
interface SupervisorNFSMountBase extends SupervisorMountBase {
type: SupervisorMountType.NFS;
path: string;
}
export interface SupervisorCIFSMount extends SupervisorMountResponse {
interface SupervisorCIFSMountBase extends SupervisorMountBase {
type: SupervisorMountType.CIFS;
share: string;
version?: CIFSVersion;
version?: CIFSVersion | null;
}
/** Fields the Supervisor adds to a configured mount when reporting it. */
export interface SupervisorMountResponse {
read_only: boolean;
state: SupervisorMountState | null;
user_path: string | null;
}
export type SupervisorNFSMount = SupervisorNFSMountBase &
SupervisorMountResponse;
export type SupervisorCIFSMount = SupervisorCIFSMountBase &
SupervisorMountResponse;
export type SupervisorMount = SupervisorNFSMount | SupervisorCIFSMount;
export type SupervisorNFSMountRequestParams = SupervisorNFSMount;
export type SupervisorNFSMountRequestParams = SupervisorNFSMountBase;
export interface SupervisorCIFSMountRequestParams extends SupervisorCIFSMount {
export interface SupervisorCIFSMountRequestParams extends SupervisorCIFSMountBase {
username?: string;
password?: string;
version?: CIFSVersion;
}
export type SupervisorMountRequestParams =
+9 -6
View File
@@ -1,13 +1,15 @@
import type { HomeAssistant } from "../../types";
import type { AddonRole, AddonStage } from "../hassio/addon";
import type { AddonAppArmour, AddonRole, AddonStage } from "../hassio/addon";
import { supervisorApiCall } from "./common";
import type { SupervisorArch } from "./supervisor";
export interface StoreAddon {
advanced: boolean;
arch: SupervisorArch[];
available: boolean;
build: boolean;
description: string;
documentation: boolean;
homeassistant: string | null;
icon: boolean;
installed: boolean;
@@ -17,19 +19,20 @@ export interface StoreAddon {
slug: string;
stage: AddonStage;
update_available: boolean;
url: string;
url: string | null;
version_latest: string;
// The Supervisor reports the installed version here, but the frontend only
// ever loads store details for add-ons that are not installed. Kept as `null`
// so it discriminates `StoreAddon` from `HassioAddonInfo`.
version: null;
}
export interface StoreAddonDetails extends StoreAddon {
apparmor: boolean;
arch: SupervisorArch[];
apparmor: AddonAppArmour;
auth_api: boolean;
changelog: boolean;
detached: boolean;
docker_api: boolean;
documentation: boolean;
full_access: boolean;
hassio_api: boolean;
hassio_role: AddonRole;
@@ -37,7 +40,7 @@ export interface StoreAddonDetails extends StoreAddon {
host_network: boolean;
host_pid: boolean;
ingress: boolean;
long_description: string;
long_description: string | null;
rating: number;
signed: boolean;
}

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