mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-25 15:14:26 +00:00
Compare commits
100
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
901c0a61ad | ||
|
|
f2ad7bbd6a | ||
|
|
25c73b7c0d | ||
|
|
41b7c079a8 | ||
|
|
e386b091f1 | ||
|
|
52c4f360cc | ||
|
|
26690e06b0 | ||
|
|
e7a89e0ac0 | ||
|
|
eb90a50ca1 | ||
|
|
e221ea2b19 | ||
|
|
abd5f9e985 | ||
|
|
2f9a4f99be | ||
|
|
0cf9fcef97 | ||
|
|
3f7f4613d6 | ||
|
|
4a8501a6e5 | ||
|
|
4ae1775644 | ||
|
|
80cd338e77 | ||
|
|
cd8f785ce6 | ||
|
|
4f4f7bd633 | ||
|
|
c2413fd7fa | ||
|
|
ed596fe700 | ||
|
|
e580fb8b5c | ||
|
|
5ba7a906d4 | ||
|
|
c6159615d7 | ||
|
|
b2f63cf345 | ||
|
|
fbd7bb073e | ||
|
|
42fec4fb9c | ||
|
|
bcd7f0dc88 | ||
|
|
44b5dc9446 | ||
|
|
4fd8732160 | ||
|
|
51675e0cd2 | ||
|
|
36bddbff06 | ||
|
|
e8c15f10e2 | ||
|
|
8f870146c6 | ||
|
|
91c0d052df | ||
|
|
dfb5e7d768 | ||
|
|
03fb6f5c29 | ||
|
|
11176a7593 | ||
|
|
8a7b8ff2ac | ||
|
|
f61e1ab509 | ||
|
|
3e75d97b8a | ||
|
|
8588d34fbe | ||
|
|
f4952764d4 | ||
|
|
52d38105ae | ||
|
|
8fa6c1d027 | ||
|
|
972b999195 | ||
|
|
fe6bc5530e | ||
|
|
d5d7093ca3 | ||
|
|
52d30d5b6e | ||
|
|
2a25673659 | ||
|
|
94838cf7d1 | ||
|
|
9214bcac94 | ||
|
|
3be6887df6 | ||
|
|
33374693c9 | ||
|
|
80516f1f00 | ||
|
|
9253af6a19 | ||
|
|
58f1d51c7f | ||
|
|
05d6f614bc | ||
|
|
6e2bb1b2f5 | ||
|
|
f3a6e1028e | ||
|
|
f182b4600d | ||
|
|
b7983e0906 | ||
|
|
6c988150e4 | ||
|
|
b88a39e273 | ||
|
|
ec6b258750 | ||
|
|
ea1fabdfdd | ||
|
|
af4f86743b | ||
|
|
ed492d5b94 | ||
|
|
db00a6b7e7 | ||
|
|
864f0877f6 | ||
|
|
d7062f3d9d | ||
|
|
3051032360 | ||
|
|
54c575efd1 | ||
|
|
e6bc47e6ba | ||
|
|
6244a8db04 | ||
|
|
3f2c1ea538 | ||
|
|
aac947ee4d | ||
|
|
594d9be859 | ||
|
|
80ae2543e5 | ||
|
|
94d176c3d1 | ||
|
|
192d9f785f | ||
|
|
398ffa045b | ||
|
|
e67538fb7c | ||
|
|
025db7ffe7 | ||
|
|
f9c958c80c | ||
|
|
652897a3cb | ||
|
|
3426449315 | ||
|
|
8e19b0249d | ||
|
|
bba034eeca | ||
|
|
31f2767038 | ||
|
|
e0571044ae | ||
|
|
0f00286641 | ||
|
|
9cc166d3cf | ||
|
|
3ad1ed5aa6 | ||
|
|
8ee84c2462 | ||
|
|
eca108032f | ||
|
|
3bfe67f784 | ||
|
|
c27191de6b | ||
|
|
31f626c877 | ||
|
|
b081db7020 |
No files matched your search
@@ -54,6 +54,10 @@ http://localhost:8090/?demo=<second slug>#/energy/water
|
||||
- `demo/src/stubs/`: Mocked WebSocket and REST APIs.
|
||||
- `demo/script/develop_demo`, `demo/script/build_demo`: Development server and static build wrappers.
|
||||
|
||||
## Core Data
|
||||
|
||||
The service descriptions in `src/fake_data/demo_services.ts` and the trigger and condition descriptions and icons in `demo/src/stubs/automation_platforms_*.ts` are generated from Home Assistant Core. Do not edit them by hand. Run `script/gen_demo_core_data` to regenerate them from the core `dev` branch, and add a domain to the lists in `build-scripts/gulp/gen-demo-core-data.js` when the demo needs its services, triggers, or conditions. Their translations come from the backend translations merged into the demo build.
|
||||
|
||||
## 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.
|
||||
|
||||
+2
-10
@@ -4,18 +4,10 @@
|
||||
// .github/workflows/blocking-labels.yaml via actions/github-script:
|
||||
//
|
||||
// const { default: checkBlockingLabels } =
|
||||
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-blocking-labels.mts`);
|
||||
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-blocking-labels.mjs`);
|
||||
// await checkBlockingLabels({ github, context, core });
|
||||
|
||||
import type {
|
||||
GitHubScriptArgs,
|
||||
PullRequestPayload,
|
||||
} from "./github-script.d.ts";
|
||||
|
||||
export default async function checkBlockingLabels({
|
||||
context,
|
||||
core,
|
||||
}: GitHubScriptArgs<PullRequestPayload>) {
|
||||
export default async function checkBlockingLabels({ context, core }) {
|
||||
const blockingLabels = [
|
||||
"wait for backend",
|
||||
"Needs UX",
|
||||
+8
-19
@@ -6,19 +6,14 @@
|
||||
// via actions/github-script:
|
||||
//
|
||||
// const { default: checkStandards } =
|
||||
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mts`);
|
||||
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mjs`);
|
||||
// await checkStandards({ github, context, core });
|
||||
|
||||
import type {
|
||||
GitHubScriptArgs,
|
||||
PullRequestPayload,
|
||||
} from "./github-script.d.ts";
|
||||
|
||||
export default async function checkPullRequestStandards({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
}: GitHubScriptArgs<PullRequestPayload>) {
|
||||
}) {
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
// Exempt bots (Copilot agent, dependabot), drafts, and maintainers.
|
||||
@@ -57,16 +52,11 @@ export default async function checkPullRequestStandards({
|
||||
const normalized = body.toLowerCase();
|
||||
|
||||
// Ignore 404s from mutations that race manual edits or cancelled runs.
|
||||
const ignoreMissing = async <T,>(fn: () => Promise<T>) => {
|
||||
const ignoreMissing = async (fn) => {
|
||||
try {
|
||||
await fn();
|
||||
} catch (error) {
|
||||
if (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"status" in error &&
|
||||
error.status === 404
|
||||
) {
|
||||
if (error.status === 404) {
|
||||
core.info("Target already removed, nothing to do");
|
||||
return;
|
||||
}
|
||||
@@ -75,7 +65,7 @@ export default async function checkPullRequestStandards({
|
||||
};
|
||||
|
||||
// Hide/restore our comment via GraphQL (REST cannot minimize).
|
||||
const setMinimized = async (subjectId: string, minimized: boolean) => {
|
||||
const setMinimized = async (subjectId, minimized) => {
|
||||
const mutation = minimized
|
||||
? `mutation($id: ID!) {
|
||||
minimizeComment(input: { subjectId: $id, classifier: RESOLVED }) {
|
||||
@@ -91,13 +81,13 @@ export default async function checkPullRequestStandards({
|
||||
await github.graphql(mutation, { id: subjectId });
|
||||
} catch (error) {
|
||||
core.info(
|
||||
`Could not ${minimized ? "minimize" : "restore"} comment: ${error instanceof Error ? error.message : error}`
|
||||
`Could not ${minimized ? "minimize" : "restore"} comment: ${error.message}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Content of a "## <name>" section, or null when the heading is absent.
|
||||
const section = (name: string) => {
|
||||
const section = (name) => {
|
||||
const match = body.match(
|
||||
new RegExp(`##\\s${name}([\\s\\S]*?)(?=\\n##\\s|$)`, "i")
|
||||
);
|
||||
@@ -138,8 +128,7 @@ export default async function checkPullRequestStandards({
|
||||
issue_number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const existing = comments.find((c) => c.body?.includes(marker));
|
||||
const existing = comments.find((c) => c.body.includes(marker));
|
||||
const hasLabel = pr.labels.some((l) => l.name === label);
|
||||
|
||||
if (isValid) {
|
||||
+2
-4
@@ -5,16 +5,14 @@
|
||||
// actions/github-script:
|
||||
//
|
||||
// const { default: checkTaskAuthorization } =
|
||||
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mts`);
|
||||
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mjs`);
|
||||
// await checkTaskAuthorization({ github, context, core });
|
||||
|
||||
import type { GitHubScriptArgs, IssuePayload } from "./github-script.d.ts";
|
||||
|
||||
export default async function checkTaskAuthorization({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
}: GitHubScriptArgs<IssuePayload>) {
|
||||
}) {
|
||||
const issueAuthor = context.payload.issue.user.login;
|
||||
|
||||
// Check if user is an organization member
|
||||
@@ -1,48 +0,0 @@
|
||||
// The arguments actions/github-script passes to the scripts in this directory,
|
||||
// limited to the parts they use.
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
|
||||
export interface Summary {
|
||||
addHeading(text: string, level?: number): Summary;
|
||||
addRaw(text: string): Summary;
|
||||
write(): Promise<Summary>;
|
||||
}
|
||||
|
||||
export interface Core {
|
||||
info(message: string): void;
|
||||
setFailed(message: string): void;
|
||||
summary: Summary;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
login: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface PullRequestPayload {
|
||||
pull_request: {
|
||||
number: number;
|
||||
body: string | null;
|
||||
draft: boolean;
|
||||
user: User;
|
||||
labels: { name: string }[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IssuePayload {
|
||||
issue: {
|
||||
user: User;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Context<Payload> {
|
||||
repo: { owner: string; repo: string };
|
||||
issue: { owner: string; repo: string; number: number };
|
||||
payload: Payload;
|
||||
}
|
||||
|
||||
export interface GitHubScriptArgs<Payload> {
|
||||
github: Octokit;
|
||||
context: Context<Payload>;
|
||||
core: Core;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2024",
|
||||
"lib": ["ES2024"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
// actions/github-script imports these directly, relying on Node's type stripping
|
||||
"erasableSyntaxOnly": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"skipLibCheck": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["*.mts", "*.d.ts"]
|
||||
}
|
||||
@@ -30,6 +30,6 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const { default: checkBlockingLabels } = await import(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-blocking-labels.mts`
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-blocking-labels.mjs`
|
||||
);
|
||||
await checkBlockingLabels({ github, context, core });
|
||||
@@ -33,6 +33,6 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const { default: checkStandards } = await import(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mts`
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mjs`
|
||||
);
|
||||
await checkStandards({ github, context, core });
|
||||
@@ -51,6 +51,6 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const { default: checkTaskAuthorization } = await import(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mts`
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mjs`
|
||||
);
|
||||
await checkTaskAuthorization({ github, context, core });
|
||||
@@ -14,7 +14,6 @@ dist/
|
||||
# Node
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
.pnpm-store/
|
||||
|
||||
# Python stuff
|
||||
*.py[cod]
|
||||
|
||||
@@ -5,6 +5,7 @@ import mapStream from "map-stream";
|
||||
import transform from "gulp-json-transform";
|
||||
import { LokaliseApi } from "@lokalise/node-api";
|
||||
import JSZip from "jszip";
|
||||
import { SERVICE_DOMAINS } from "./gen-demo-core-data.js";
|
||||
|
||||
const inDir = "translations";
|
||||
const inDirFrontend = `${inDir}/frontend`;
|
||||
@@ -51,15 +52,34 @@ function convertBackendTranslations(data, _file) {
|
||||
if (!data.component) {
|
||||
return output;
|
||||
}
|
||||
// Keep the entity domains and the domains with automation triggers or
|
||||
// conditions, so the demo can show states and describe automations. Only
|
||||
// keep the services of the domains whose services the demo mocks.
|
||||
Object.keys(data.component).forEach((domain) => {
|
||||
if (!("entity_component" in data.component[domain])) {
|
||||
const {
|
||||
entity_component,
|
||||
services,
|
||||
triggers,
|
||||
conditions,
|
||||
selector,
|
||||
title,
|
||||
} = data.component[domain];
|
||||
if (
|
||||
!entity_component &&
|
||||
!triggers &&
|
||||
!conditions &&
|
||||
!SERVICE_DOMAINS.includes(domain)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
output.component[domain] = { entity_component: {} };
|
||||
Object.keys(data.component[domain].entity_component).forEach((key) => {
|
||||
output.component[domain].entity_component[key] =
|
||||
data.component[domain].entity_component[key];
|
||||
});
|
||||
output.component[domain] = {
|
||||
entity_component,
|
||||
services: SERVICE_DOMAINS.includes(domain) ? services : undefined,
|
||||
triggers,
|
||||
conditions,
|
||||
selector,
|
||||
title,
|
||||
};
|
||||
});
|
||||
return output;
|
||||
}
|
||||
@@ -155,8 +175,9 @@ gulp.task("fetch-lokalise", async function () {
|
||||
fs.mkdir(inDirBackend, { recursive: true }),
|
||||
]);
|
||||
|
||||
// The backend project only provides entity_component translations, which are
|
||||
// merged into the demo, gallery, cast and e2e builds. The shipped app fetches
|
||||
// The backend project only provides the translations the mocked backend
|
||||
// needs (see convertBackendTranslations), which are merged into the demo,
|
||||
// gallery, cast and e2e builds. The shipped app fetches
|
||||
// them live from core, so builds that only produce the app (release, release
|
||||
// landing-page) can skip this second, whole-project export to save time.
|
||||
const projects = Object.entries(lokaliseProjects).filter(
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
// Generates the service, trigger and condition descriptions the demo and
|
||||
// gallery serve instead of a backend, from the files in Home Assistant Core.
|
||||
import gulp from "gulp";
|
||||
import { CORE_SCHEMA, load, mergeTag } from "js-yaml";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import process from "node:process";
|
||||
import prettier from "prettier";
|
||||
import paths from "../paths.cjs";
|
||||
|
||||
const CORE_URL =
|
||||
process.env.CORE_COMPONENTS_URL ||
|
||||
"https://raw.githubusercontent.com/home-assistant/core/refs/heads/dev/homeassistant/components";
|
||||
|
||||
// Integrations whose services the mocked backend provides
|
||||
export const SERVICE_DOMAINS = [
|
||||
"alarm_control_panel",
|
||||
"automation",
|
||||
"button",
|
||||
"calendar",
|
||||
"camera",
|
||||
"climate",
|
||||
"conversation",
|
||||
"counter",
|
||||
"cover",
|
||||
"device_tracker",
|
||||
"fan",
|
||||
"frontend",
|
||||
"group",
|
||||
"homeassistant",
|
||||
"humidifier",
|
||||
"image_processing",
|
||||
"input_boolean",
|
||||
"input_button",
|
||||
"input_datetime",
|
||||
"input_number",
|
||||
"input_select",
|
||||
"input_text",
|
||||
"lawn_mower",
|
||||
"light",
|
||||
"lock",
|
||||
"logger",
|
||||
"media_player",
|
||||
"notify",
|
||||
"number",
|
||||
"persistent_notification",
|
||||
"person",
|
||||
"recorder",
|
||||
"remote",
|
||||
"scene",
|
||||
"schedule",
|
||||
"script",
|
||||
"select",
|
||||
"siren",
|
||||
"switch",
|
||||
"system_log",
|
||||
"text",
|
||||
"timer",
|
||||
"todo",
|
||||
"tts",
|
||||
"update",
|
||||
"vacuum",
|
||||
"valve",
|
||||
"water_heater",
|
||||
"weather",
|
||||
"zone",
|
||||
];
|
||||
|
||||
// Services that return data. Core sets this when it registers a service, it is
|
||||
// not part of services.yaml. The value is whether the response is optional.
|
||||
const RESPONSE_SERVICES = {
|
||||
"calendar.get_events": false,
|
||||
"conversation.process": true,
|
||||
"media_player.browse_media": false,
|
||||
"media_player.search_media": false,
|
||||
"recorder.get_statistics": false,
|
||||
"schedule.get_schedule": false,
|
||||
"todo.get_items": false,
|
||||
"weather.get_forecasts": false,
|
||||
};
|
||||
|
||||
// Services that are still in services.yaml, but that core no longer registers
|
||||
const UNREGISTERED_SERVICES = ["weather.get_forecast"];
|
||||
|
||||
// Integrations that provide the new style triggers and conditions
|
||||
const AUTOMATION_PLATFORM_DOMAINS = [
|
||||
"air_quality",
|
||||
"alarm_control_panel",
|
||||
"assist_satellite",
|
||||
"battery",
|
||||
"button",
|
||||
"calendar",
|
||||
"climate",
|
||||
"counter",
|
||||
"cover",
|
||||
"door",
|
||||
"doorbell",
|
||||
"event",
|
||||
"fan",
|
||||
"garage_door",
|
||||
"gate",
|
||||
"humidifier",
|
||||
"humidity",
|
||||
"illuminance",
|
||||
"lawn_mower",
|
||||
"light",
|
||||
"lock",
|
||||
"media_player",
|
||||
"moisture",
|
||||
"moon",
|
||||
"motion",
|
||||
"occupancy",
|
||||
"power",
|
||||
"remote",
|
||||
"scene",
|
||||
"schedule",
|
||||
"select",
|
||||
"siren",
|
||||
"sun",
|
||||
"switch",
|
||||
"temperature",
|
||||
"text",
|
||||
"timer",
|
||||
"todo",
|
||||
"update",
|
||||
"vacuum",
|
||||
"valve",
|
||||
"vibration",
|
||||
"water_heater",
|
||||
"window",
|
||||
"zone",
|
||||
];
|
||||
|
||||
// YAML 1.2 with merge keys, which matches how core reads these files
|
||||
const YAML_SCHEMA = CORE_SCHEMA.withTags([mergeTag]);
|
||||
|
||||
const HEADER = `// This file is auto-generated from Home Assistant Core. Do not edit by hand.
|
||||
// Regenerate with \`script/gen_demo_core_data\`.`;
|
||||
|
||||
const fetchCoreFile = async (domain, file) => {
|
||||
const url = `${CORE_URL}/${domain}/${file}`;
|
||||
const response = await fetch(url);
|
||||
if (response.status === 404) {
|
||||
return undefined;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${url}: ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
};
|
||||
|
||||
// Fetches a file of every domain, keyed by domain
|
||||
const fetchCoreFiles = async (domains, file) =>
|
||||
Object.fromEntries(
|
||||
await Promise.all(
|
||||
domains.map(async (domain) => [domain, await fetchCoreFile(domain, file)])
|
||||
)
|
||||
);
|
||||
|
||||
const parseYaml = (content) => load(content, { schema: YAML_SCHEMA }) ?? {};
|
||||
|
||||
// Enum references look like "cover.CoverEntityFeature.OPEN"
|
||||
const ENUM_REFERENCE = /^([a-z_]+)\.[A-Z]\w*\.[A-Z0-9_]+$/;
|
||||
|
||||
const enumDomains = (value, domains = new Set()) => {
|
||||
if (typeof value === "string") {
|
||||
const match = value.match(ENUM_REFERENCE);
|
||||
if (match) {
|
||||
domains.add(match[1]);
|
||||
}
|
||||
} else if (value && typeof value === "object") {
|
||||
Object.values(value).forEach((val) => enumDomains(val, domains));
|
||||
}
|
||||
return domains;
|
||||
};
|
||||
|
||||
// Resolves "<domain>.<Enum>.<MEMBER>" to the value of the member, like core
|
||||
const enumResolver = async (yamls) => {
|
||||
const domains = [...enumDomains(yamls)];
|
||||
const [consts, inits] = await Promise.all([
|
||||
fetchCoreFiles(domains, "const.py"),
|
||||
fetchCoreFiles(domains, "__init__.py"),
|
||||
]);
|
||||
return (reference) => {
|
||||
const [domain, enumName, member] = reference.split(".");
|
||||
for (const source of [consts[domain], inits[domain]]) {
|
||||
const match = source
|
||||
?.split(`class ${enumName}(`)[1]
|
||||
?.match(new RegExp(`\\n\\s+${member} = ("[^"]*"|\\d+)`));
|
||||
if (match) {
|
||||
return JSON.parse(match[1]);
|
||||
}
|
||||
}
|
||||
throw new Error(`Unknown enum ${reference}`);
|
||||
};
|
||||
};
|
||||
|
||||
const createResolvers = (enumValue) => {
|
||||
// In entity filters, every item of the supported features, a feature or a
|
||||
// list of features, becomes one bit mask
|
||||
const featureMask = (features) =>
|
||||
[].concat(features).reduce(
|
||||
// eslint-disable-next-line no-bitwise
|
||||
(mask, feature) => mask | enumValue(feature),
|
||||
0
|
||||
);
|
||||
|
||||
const resolveSelector = (value) => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(resolveSelector);
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, val]) => [
|
||||
key,
|
||||
key === "supported_features"
|
||||
? [].concat(val).map(featureMask)
|
||||
: resolveSelector(val),
|
||||
])
|
||||
);
|
||||
};
|
||||
|
||||
// Like core, an empty target allows any target
|
||||
const resolveTarget = (target) => resolveSelector(target ?? {});
|
||||
|
||||
// Field filters keep their lists, with every enum resolved on its own
|
||||
const resolveField = (field) => {
|
||||
const result = { ...field };
|
||||
if (field.selector) {
|
||||
result.selector = resolveSelector(field.selector);
|
||||
}
|
||||
if (field.filter?.supported_features) {
|
||||
result.filter = {
|
||||
...result.filter,
|
||||
supported_features: field.filter.supported_features.map(enumValue),
|
||||
};
|
||||
}
|
||||
if (field.filter?.attribute) {
|
||||
result.filter = {
|
||||
...result.filter,
|
||||
attribute: Object.fromEntries(
|
||||
Object.entries(field.filter.attribute).map(([name, options]) => [
|
||||
name,
|
||||
options.map(enumValue),
|
||||
])
|
||||
),
|
||||
};
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Fields can be grouped in sections, which hold fields themselves
|
||||
const resolveFields = (fields) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(fields ?? {}).map(([key, field]) => [
|
||||
key,
|
||||
field && "fields" in field && !("selector" in field)
|
||||
? { ...field, fields: resolveFields(field.fields) }
|
||||
: resolveField(field ?? {}),
|
||||
])
|
||||
);
|
||||
|
||||
return { resolveFields, resolveTarget };
|
||||
};
|
||||
|
||||
// Mirrors how core builds the response of get_services
|
||||
const serviceDescriptions = (yamls, { resolveFields, resolveTarget }) =>
|
||||
Object.fromEntries(
|
||||
SERVICE_DOMAINS.map((domain) => {
|
||||
if (!yamls[domain]) {
|
||||
throw new Error(`No services.yaml found for ${domain}`);
|
||||
}
|
||||
const services = Object.entries(yamls[domain])
|
||||
.filter(
|
||||
([service]) =>
|
||||
!service.startsWith(".") &&
|
||||
!UNREGISTERED_SERVICES.includes(`${domain}.${service}`)
|
||||
)
|
||||
.map(([service, value]) => {
|
||||
const description = { fields: resolveFields(value?.fields) };
|
||||
for (const item of ["description", "name"]) {
|
||||
if (value && item in value) {
|
||||
description[item] = value[item];
|
||||
}
|
||||
}
|
||||
if (value && "target" in value) {
|
||||
description.target = resolveTarget(value.target);
|
||||
}
|
||||
const key = `${domain}.${service}`;
|
||||
if (key in RESPONSE_SERVICES) {
|
||||
description.response = { optional: RESPONSE_SERVICES[key] };
|
||||
}
|
||||
return [service, description];
|
||||
});
|
||||
return [domain, Object.fromEntries(services)];
|
||||
})
|
||||
);
|
||||
|
||||
// Mirrors how core builds the trigger and condition platform descriptions
|
||||
const platformDescriptions = (yamls, { resolveFields, resolveTarget }) =>
|
||||
Object.fromEntries(
|
||||
AUTOMATION_PLATFORM_DOMAINS.flatMap((domain) =>
|
||||
Object.entries(yamls[domain] ?? {})
|
||||
// Keys starting with a dot only hold YAML anchors
|
||||
.filter(([key, value]) => !key.startsWith(".") && value)
|
||||
.map(([key, value]) => {
|
||||
const absoluteKey = !key.startsWith("_")
|
||||
? `${domain}.${key}`
|
||||
: key.slice(1) || domain;
|
||||
const description = { fields: resolveFields(value.fields) };
|
||||
if ("target" in value) {
|
||||
description.target = resolveTarget(value.target);
|
||||
}
|
||||
return [absoluteKey, description];
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// The trigger or condition icons of every domain that has them
|
||||
const platformIcons = (icons, category) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(icons)
|
||||
.map(([domain, content]) => [
|
||||
domain,
|
||||
content ? JSON.parse(content)[category] : undefined,
|
||||
])
|
||||
.filter(([, categoryIcons]) => categoryIcons)
|
||||
);
|
||||
|
||||
const parseYamls = (files) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(files)
|
||||
.filter(([, content]) => content !== undefined)
|
||||
.map(([domain, content]) => [domain, parseYaml(content)])
|
||||
);
|
||||
|
||||
const writeTs = async (path, content) => {
|
||||
const file = join(paths.root_dir, path);
|
||||
const options = await prettier.resolveConfig(file);
|
||||
await writeFile(
|
||||
file,
|
||||
await prettier.format(`${HEADER}\n${content}`, {
|
||||
...options,
|
||||
filepath: file,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
gulp.task("gen-demo-core-data", async () => {
|
||||
const [servicesFiles, triggersFiles, conditionsFiles, iconsFiles] =
|
||||
await Promise.all([
|
||||
fetchCoreFiles(SERVICE_DOMAINS, "services.yaml"),
|
||||
fetchCoreFiles(AUTOMATION_PLATFORM_DOMAINS, "triggers.yaml"),
|
||||
fetchCoreFiles(AUTOMATION_PLATFORM_DOMAINS, "conditions.yaml"),
|
||||
fetchCoreFiles(AUTOMATION_PLATFORM_DOMAINS, "icons.json"),
|
||||
]);
|
||||
const servicesYamls = parseYamls(servicesFiles);
|
||||
const triggersYamls = parseYamls(triggersFiles);
|
||||
const conditionsYamls = parseYamls(conditionsFiles);
|
||||
const resolvers = createResolvers(
|
||||
await enumResolver([servicesYamls, triggersYamls, conditionsYamls])
|
||||
);
|
||||
for (const key of UNREGISTERED_SERVICES) {
|
||||
const [domain, service] = key.split(".");
|
||||
if (!(service in (servicesYamls[domain] ?? {}))) {
|
||||
throw new Error(`Remove ${key} from UNREGISTERED_SERVICES, it is gone`);
|
||||
}
|
||||
}
|
||||
const services = serviceDescriptions(servicesYamls, resolvers);
|
||||
for (const key of Object.keys(RESPONSE_SERVICES)) {
|
||||
const [domain, service] = key.split(".");
|
||||
if (!services[domain]?.[service]) {
|
||||
throw new Error(`Service ${key} that returns a response does not exist`);
|
||||
}
|
||||
}
|
||||
const triggers = platformDescriptions(triggersYamls, resolvers);
|
||||
const conditions = platformDescriptions(conditionsYamls, resolvers);
|
||||
const icons = {
|
||||
triggers: platformIcons(iconsFiles, "triggers"),
|
||||
conditions: platformIcons(iconsFiles, "conditions"),
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
writeTs(
|
||||
"src/fake_data/demo_services.ts",
|
||||
`import type { HassServices } from "home-assistant-js-websocket";
|
||||
|
||||
// Core sends more than the HassServices type covers, like sections and list
|
||||
// examples.
|
||||
export const demoServices = ${JSON.stringify(services)} as unknown as HassServices;
|
||||
`
|
||||
),
|
||||
writeTs(
|
||||
"demo/src/stubs/automation_platform_domains.ts",
|
||||
`
|
||||
// Integrations that provide the new style triggers and conditions. Their
|
||||
// translations are part of the demo build.
|
||||
export const automationPlatformDomains = ${JSON.stringify(AUTOMATION_PLATFORM_DOMAINS)};
|
||||
`
|
||||
),
|
||||
writeTs(
|
||||
"demo/src/stubs/automation_platforms_data.ts",
|
||||
`import type { ConditionDescriptions } from "../../../src/data/condition";
|
||||
import type { TriggerDescriptions } from "../../../src/data/trigger";
|
||||
|
||||
export const triggerDescriptions: TriggerDescriptions = ${JSON.stringify(triggers)};
|
||||
|
||||
export const conditionDescriptions: ConditionDescriptions = ${JSON.stringify(conditions)};
|
||||
`
|
||||
),
|
||||
writeTs(
|
||||
"demo/src/stubs/automation_platforms_icons.ts",
|
||||
`
|
||||
export const triggerIcons = ${JSON.stringify(icons.triggers)};
|
||||
|
||||
export const conditionIcons = ${JSON.stringify(icons.conditions)};
|
||||
`
|
||||
),
|
||||
]);
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import "./entry-html.js";
|
||||
import "./fetch-nightly-translations.js";
|
||||
import "./gallery.js";
|
||||
import "./gather-static.js";
|
||||
import "./gen-demo-core-data.js";
|
||||
import "./gen-device-classes.js";
|
||||
import "./gen-icons-json.js";
|
||||
import "./gen-sensor-entity-constants.js";
|
||||
|
||||
@@ -54,9 +54,9 @@ const CONFIG_FILES = [
|
||||
path.join(__dirname, "babel-plugins", "inline-constants-plugin.cjs"),
|
||||
];
|
||||
|
||||
// Patches change a package's files but not its version, which is all the
|
||||
// Yarn patches change a package's files but not its version, which is all the
|
||||
// node_modules snapshot checks, so their contents go into the version too.
|
||||
const PATCHES_DIR = path.join(paths.root_dir, "patches");
|
||||
const YARN_PATCHES_DIR = path.join(paths.root_dir, ".yarn", "patches");
|
||||
|
||||
// Content hash of the toolchain versions and our own build files, used as the
|
||||
// persistent cache `version`. Everything here is path-independent so the cache
|
||||
@@ -69,12 +69,12 @@ const cacheVersion = () => {
|
||||
...CONFIG_FILES.map(
|
||||
(file) => `${path.basename(file)}:${fs.readFileSync(file, "utf8")}`
|
||||
),
|
||||
...(existsSync(PATCHES_DIR) ? fs.readdirSync(PATCHES_DIR) : [])
|
||||
...(existsSync(YARN_PATCHES_DIR) ? fs.readdirSync(YARN_PATCHES_DIR) : [])
|
||||
.filter((file) => file.endsWith(".patch"))
|
||||
.sort()
|
||||
.map(
|
||||
(file) =>
|
||||
`${file}:${fs.readFileSync(path.join(PATCHES_DIR, file), "utf8")}`
|
||||
`${file}:${fs.readFileSync(path.join(YARN_PATCHES_DIR, file), "utf8")}`
|
||||
),
|
||||
];
|
||||
return require("crypto")
|
||||
@@ -115,7 +115,10 @@ const createRspackConfig = ({
|
||||
dontHash = new Set();
|
||||
}
|
||||
const ignorePackages = bundle.ignorePackages({ latestBuild });
|
||||
const litHtmlRoot = path.resolve(__dirname, "../node_modules/lit-html");
|
||||
// pnpm links node_modules/lit-html into its store; rspack compares real paths
|
||||
const litHtmlRoot = fs.realpathSync(
|
||||
path.resolve(__dirname, "../node_modules/lit-html")
|
||||
);
|
||||
const litHtmlDevelopmentRoot = path.join(litHtmlRoot, "development");
|
||||
const litDisableDevModeLoader = path.join(
|
||||
__dirname,
|
||||
@@ -489,7 +492,7 @@ const createRspackConfig = ({
|
||||
// `name` is already unique per variant (frontend-modern/-legacy).
|
||||
name,
|
||||
// Content-based version (node major + toolchain versions + our own
|
||||
// build files + patches). Everything is path-independent, so
|
||||
// build files + Yarn patches). Everything is path-independent, so
|
||||
// the cache stays valid when reused on another machine/checkout.
|
||||
// Runtime deps are deliberately absent — rspack's node_modules
|
||||
// snapshot invalidates their modules per-package, so a single
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { navigate } from "../../../src/common/navigate";
|
||||
import { slugify } from "../../../src/common/string/slugify";
|
||||
import type { LocalizeFunc } from "../../../src/common/translations/localize";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import type { Lovelace } from "../../../src/panels/lovelace/types";
|
||||
import { setDemoAreas } from "../stubs/area_registry";
|
||||
import { setPlatformEntityRegistryEntries } from "../stubs/entity_registry";
|
||||
import { energyEntities } from "../stubs/entities";
|
||||
import { connectivityEntities } from "../stubs/connectivity/fixtures";
|
||||
import { setDemoFloors } from "../stubs/floor_registry";
|
||||
import { getDemoTheme } from "../stubs/frontend";
|
||||
import type { DemoConfig, DemoTheme } from "./types";
|
||||
import type { EntityInput } from "../../../src/fake_data/entities/types";
|
||||
import type { DemoAutomation, DemoConfig, DemoTheme } from "./types";
|
||||
|
||||
export const applyDemoTheme = (hass: MockHomeAssistant, theme: DemoTheme) => {
|
||||
if (typeof theme === "function") {
|
||||
@@ -16,6 +20,95 @@ export const applyDemoTheme = (hass: MockHomeAssistant, theme: DemoTheme) => {
|
||||
hass.mockTheme(null, getDemoTheme(theme));
|
||||
};
|
||||
|
||||
export const automationEntity = ({
|
||||
config: automation,
|
||||
entityId,
|
||||
state = "on",
|
||||
icon,
|
||||
lastTriggered,
|
||||
}: DemoAutomation): EntityInput => ({
|
||||
entity_id: entityId ?? `automation.${slugify(automation.alias)}`,
|
||||
platform: "automation",
|
||||
state,
|
||||
attributes: {
|
||||
id: automation.id,
|
||||
friendly_name: automation.alias,
|
||||
last_triggered:
|
||||
lastTriggered === undefined
|
||||
? null
|
||||
: new Date(Date.now() - lastTriggered * 60000).toISOString(),
|
||||
mode: automation.mode ?? "single",
|
||||
current: 0,
|
||||
// Like core, only the modes that can run more than once have a maximum
|
||||
...((automation.mode === "queued" || automation.mode === "parallel") && {
|
||||
max: automation.max ?? 10,
|
||||
}),
|
||||
icon,
|
||||
},
|
||||
});
|
||||
|
||||
// Automations saved in the editor during this session, per demo
|
||||
// Automations saved in the editor during this session, per demo. A deleted
|
||||
// automation is null.
|
||||
const savedAutomations: Record<
|
||||
string,
|
||||
Record<string, DemoAutomation | null>
|
||||
> = {};
|
||||
|
||||
export const saveDemoAutomation = (
|
||||
id: string,
|
||||
automation: DemoAutomation | null
|
||||
) => {
|
||||
savedAutomations[selectedDemo] = {
|
||||
...savedAutomations[selectedDemo],
|
||||
[id]: automation,
|
||||
};
|
||||
};
|
||||
|
||||
// The automations of a demo config, with the ones saved in the editor
|
||||
export const demoAutomations = (
|
||||
demo: string,
|
||||
config: DemoConfig
|
||||
): DemoAutomation[] => {
|
||||
const saved = savedAutomations[demo] ?? {};
|
||||
const configured = config.automations ?? [];
|
||||
const ids = new Set(configured.map((automation) => automation.config.id));
|
||||
return [
|
||||
...configured.map((automation) =>
|
||||
automation.config.id in saved ? saved[automation.config.id] : automation
|
||||
),
|
||||
...Object.values(saved).filter(
|
||||
(automation) => automation && !ids.has(automation.config.id)
|
||||
),
|
||||
].filter((automation): automation is DemoAutomation => automation !== null);
|
||||
};
|
||||
|
||||
// The automation entities are derived from the configs the editor shows, so
|
||||
// their names and IDs always match.
|
||||
export const demoConfigEntities = (
|
||||
demo: string,
|
||||
config: DemoConfig,
|
||||
localize: LocalizeFunc
|
||||
): EntityInput[] => [
|
||||
...config.entities(localize),
|
||||
...demoAutomations(demo, config).map(automationEntity),
|
||||
];
|
||||
|
||||
// Like core, the automations of a demo config are in the entity registry
|
||||
export const registerDemoAutomations = (
|
||||
hass: MockHomeAssistant,
|
||||
demo: string,
|
||||
config: DemoConfig
|
||||
) =>
|
||||
setPlatformEntityRegistryEntries(
|
||||
hass,
|
||||
"automation",
|
||||
demoAutomations(demo, config).map((automation) => ({
|
||||
entity_id: automationEntity(automation).entity_id,
|
||||
unique_id: automation.config.id,
|
||||
}))
|
||||
);
|
||||
|
||||
export const demoConfigs: Record<string, () => Promise<DemoConfig>> = {
|
||||
sections: () => import("./sections").then((mod) => mod.demoSections),
|
||||
home: () => import("./home").then((mod) => mod.demoHome),
|
||||
@@ -52,7 +145,8 @@ export const setDemoConfig = async (
|
||||
|
||||
setDemoFloors(hass, config.floors);
|
||||
setDemoAreas(hass, config.areas);
|
||||
hass.addEntities(config.entities(hass.localize), true);
|
||||
hass.addEntities(demoConfigEntities(demo, config, hass.localize), true);
|
||||
registerDemoAutomations(hass, demo, config);
|
||||
hass.addEntities(energyEntities());
|
||||
// Replaced the whole state map above, so the entities that do not belong to a
|
||||
// demo config have to be added back.
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { HomeDashboardStrategyConfig } from "../../../../src/panels/lovelac
|
||||
import { HomeDashboardStrategy } from "../../../../src/panels/lovelace/strategies/home/home-dashboard-strategy";
|
||||
import type { HomeOverviewViewStrategyConfig } from "../../../../src/panels/lovelace/strategies/home/home-overview-view-strategy";
|
||||
import { HomeOverviewViewStrategy } from "../../../../src/panels/lovelace/strategies/home/home-overview-view-strategy";
|
||||
import { generateLovelaceSectionStrategy } from "../../../../src/panels/lovelace/strategies/get-strategy";
|
||||
import type { HeadingCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
|
||||
export interface DemoHomeDashboardStrategyConfig extends Omit<
|
||||
@@ -61,29 +61,33 @@ class DemoHomeOverviewViewStrategy extends ReactiveElement {
|
||||
{ ...config, type: "home-overview" },
|
||||
hass
|
||||
);
|
||||
// Expand the favorites section so the demo card can be added to it
|
||||
const sections = await Promise.all(
|
||||
(view.sections || []).map(async (section) => {
|
||||
if (
|
||||
!isStrategySection(section) ||
|
||||
section.strategy.type !== "common-controls"
|
||||
) {
|
||||
return section;
|
||||
}
|
||||
// The demo card takes up the space of two tiles
|
||||
const limit = (section.strategy.limit as number | undefined) ?? 8;
|
||||
const favorites = await generateLovelaceSectionStrategy(
|
||||
{ ...section, strategy: { ...section.strategy, limit: limit - 2 } },
|
||||
hass
|
||||
);
|
||||
const [heading, ...cards] = favorites.cards || [];
|
||||
return {
|
||||
...favorites,
|
||||
// Place the demo card first so the tiles fill the rows next to it
|
||||
cards: [heading, { type: "custom:ha-demo-next-card" }, ...cards],
|
||||
};
|
||||
})
|
||||
// Add the demo card to the favorites section, found by its heading
|
||||
const favoritesHeading = hass.localize(
|
||||
"ui.panel.lovelace.strategy.home.favorites"
|
||||
);
|
||||
const sections = (view.sections || []).map((section) => {
|
||||
if (
|
||||
isStrategySection(section) ||
|
||||
!section.cards?.some(
|
||||
(card) =>
|
||||
card.type === "heading" &&
|
||||
(card as HeadingCardConfig).heading === favoritesHeading
|
||||
)
|
||||
) {
|
||||
return section;
|
||||
}
|
||||
const [heading, ...cards] = section.cards;
|
||||
return {
|
||||
...section,
|
||||
// Place the demo card first so the tiles fill the rows next to it. It
|
||||
// takes up the space of two tiles, so keep two less, but at least six.
|
||||
cards: [
|
||||
heading,
|
||||
{ type: "custom:ha-demo-next-card" },
|
||||
...cards.slice(0, Math.max(6, cards.length - 2)),
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...view,
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
import type { DemoAutomation } from "../types";
|
||||
|
||||
export const demoAutomationsSections: DemoAutomation[] = [
|
||||
{
|
||||
lastTriggered: 60 * 9,
|
||||
state: "off",
|
||||
icon: "mdi:auto-mode",
|
||||
config: {
|
||||
id: "1700669321947",
|
||||
alias: "Home Assistant Auto-update",
|
||||
description:
|
||||
"Installs Home Assistant updates at night, so nobody is bothered by the restart.",
|
||||
triggers: [{ trigger: "time", at: "03:00:00" }],
|
||||
conditions: [
|
||||
{
|
||||
condition: "update.is_available",
|
||||
target: { entity_id: "update.home_assistant_core_update" },
|
||||
options: { for: "00:00:00" },
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
action: "update.install",
|
||||
target: { entity_id: "update.home_assistant_core_update" },
|
||||
data: { backup: true },
|
||||
},
|
||||
],
|
||||
mode: "queued",
|
||||
},
|
||||
},
|
||||
{
|
||||
lastTriggered: 25,
|
||||
config: {
|
||||
id: "1700669322001",
|
||||
alias: "Kitchen lights on motion",
|
||||
description:
|
||||
"Turns on the kitchen lights when motion is detected and turns them off again once the kitchen has been empty for 2 minutes.",
|
||||
triggers: [
|
||||
{
|
||||
trigger: "motion.detected",
|
||||
target: { entity_id: "binary_sensor.kitchen_motion" },
|
||||
options: { for: "00:00:00" },
|
||||
},
|
||||
],
|
||||
conditions: [],
|
||||
actions: [
|
||||
{
|
||||
action: "light.turn_on",
|
||||
target: {
|
||||
entity_id: ["light.kitchen_spotlights", "light.worktop_spotlights"],
|
||||
},
|
||||
data: { brightness_pct: 80 },
|
||||
},
|
||||
{
|
||||
wait_for_trigger: [
|
||||
{
|
||||
trigger: "motion.cleared",
|
||||
target: { entity_id: "binary_sensor.kitchen_motion" },
|
||||
options: { for: "00:02:00" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
action: "light.turn_off",
|
||||
target: {
|
||||
entity_id: ["light.kitchen_spotlights", "light.worktop_spotlights"],
|
||||
},
|
||||
},
|
||||
],
|
||||
mode: "restart",
|
||||
},
|
||||
},
|
||||
{
|
||||
lastTriggered: 60 * 3,
|
||||
config: {
|
||||
id: "1700669322002",
|
||||
alias: "Outdoor light from sunset to sunrise",
|
||||
description:
|
||||
"Uses trigger IDs to handle both sunset and sunrise in one automation.",
|
||||
triggers: [
|
||||
{
|
||||
trigger: "sun.sunset",
|
||||
id: "sunset",
|
||||
options: {
|
||||
offset: { days: 0, hours: 0, minutes: 15, seconds: 0 },
|
||||
offset_type: "before",
|
||||
},
|
||||
},
|
||||
{
|
||||
trigger: "sun.sunrise",
|
||||
id: "sunrise",
|
||||
options: {
|
||||
offset: { days: 0, hours: 0, minutes: 0, seconds: 0 },
|
||||
offset_type: "before",
|
||||
},
|
||||
},
|
||||
],
|
||||
conditions: [],
|
||||
actions: [
|
||||
{
|
||||
choose: [
|
||||
{
|
||||
conditions: [{ condition: "trigger", id: "sunset" }],
|
||||
sequence: [
|
||||
{
|
||||
action: "light.turn_on",
|
||||
target: { entity_id: "light.outdoor_light" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
conditions: [{ condition: "trigger", id: "sunrise" }],
|
||||
sequence: [
|
||||
{
|
||||
action: "light.turn_off",
|
||||
target: { entity_id: "light.outdoor_light" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
mode: "single",
|
||||
},
|
||||
},
|
||||
{
|
||||
lastTriggered: 60 * 14,
|
||||
config: {
|
||||
id: "1700669322003",
|
||||
alias: "Flood light on motion when dark",
|
||||
description:
|
||||
"Turns on the flood light for 5 minutes when someone walks by in the dark.",
|
||||
triggers: [
|
||||
{
|
||||
trigger: "motion.detected",
|
||||
target: { entity_id: "binary_sensor.outdoor_motion_sensor_motion" },
|
||||
options: { for: "00:00:00" },
|
||||
},
|
||||
],
|
||||
conditions: [
|
||||
{
|
||||
condition: "illuminance.is_value",
|
||||
target: { entity_id: "sensor.outdoor_motion_sensor_illuminance" },
|
||||
options: {
|
||||
threshold: {
|
||||
type: "below",
|
||||
value: { number: 50, unit_of_measurement: "lx" },
|
||||
},
|
||||
for: "00:00:00",
|
||||
},
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
action: "light.turn_on",
|
||||
target: { entity_id: "light.flood_light" },
|
||||
},
|
||||
{ delay: { hours: 0, minutes: 5, seconds: 0, milliseconds: 0 } },
|
||||
{
|
||||
action: "light.turn_off",
|
||||
target: { entity_id: "light.flood_light" },
|
||||
},
|
||||
],
|
||||
mode: "restart",
|
||||
},
|
||||
},
|
||||
{
|
||||
lastTriggered: 60 * 26,
|
||||
config: {
|
||||
id: "1700669322004",
|
||||
alias: "Close the shutters when it gets hot",
|
||||
description:
|
||||
"Keeps the living room cool by closing the shutters on hot, sunny days.",
|
||||
triggers: [
|
||||
{
|
||||
trigger: "temperature.crossed_threshold",
|
||||
target: { entity_id: "sensor.outdoor_temperature" },
|
||||
options: {
|
||||
threshold: {
|
||||
type: "above",
|
||||
value: { number: 25, unit_of_measurement: "°C" },
|
||||
},
|
||||
for: "00:10:00",
|
||||
},
|
||||
},
|
||||
],
|
||||
conditions: [{ condition: "sun.is_up" }],
|
||||
actions: [
|
||||
{
|
||||
action: "cover.close_cover",
|
||||
target: {
|
||||
entity_id: [
|
||||
"cover.living_room_garden_shutter",
|
||||
"cover.living_room_graveyard_shutter",
|
||||
"cover.living_room_left_shutter",
|
||||
"cover.living_room_right_shutter",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
mode: "single",
|
||||
},
|
||||
},
|
||||
{
|
||||
lastTriggered: 60 * 50,
|
||||
config: {
|
||||
id: "1700669322005",
|
||||
alias: "Fridge door left open",
|
||||
description:
|
||||
"Sends a reminder when the fridge door has been open for 2 minutes.",
|
||||
triggers: [
|
||||
{
|
||||
trigger: "door.opened",
|
||||
target: { entity_id: "binary_sensor.fridge_door" },
|
||||
options: { for: "00:02:00" },
|
||||
},
|
||||
],
|
||||
conditions: [],
|
||||
actions: [
|
||||
{
|
||||
action: "notify.notify",
|
||||
data: {
|
||||
title: "Fridge door",
|
||||
message: "The fridge door has been open for 2 minutes.",
|
||||
},
|
||||
},
|
||||
],
|
||||
mode: "single",
|
||||
},
|
||||
},
|
||||
{
|
||||
lastTriggered: 90,
|
||||
config: {
|
||||
id: "1700669322006",
|
||||
alias: "Meeting mode",
|
||||
description:
|
||||
"Pauses the music in the study and turns on the lights when a meeting starts.",
|
||||
triggers: [
|
||||
{
|
||||
trigger: "switch.turned_on",
|
||||
target: { entity_id: "switch.in_meeting" },
|
||||
options: { for: "00:00:00" },
|
||||
},
|
||||
],
|
||||
conditions: [],
|
||||
actions: [
|
||||
{
|
||||
action: "media_player.media_pause",
|
||||
target: { entity_id: "media_player.study_nest_hub" },
|
||||
},
|
||||
{
|
||||
action: "light.turn_on",
|
||||
target: { entity_id: "light.study_spotlights" },
|
||||
data: { brightness_pct: 100, color_temp_kelvin: 4000 },
|
||||
},
|
||||
],
|
||||
mode: "single",
|
||||
},
|
||||
},
|
||||
{
|
||||
lastTriggered: 60 * 20,
|
||||
config: {
|
||||
id: "1700669322009",
|
||||
alias: "Lights up when the music pauses",
|
||||
description:
|
||||
"Turns on the living room spotlights when the music is paused in the evening.",
|
||||
triggers: [
|
||||
{
|
||||
trigger: "media_player.paused_playing",
|
||||
target: { entity_id: "media_player.living_room_nest_mini" },
|
||||
options: { for: "00:00:00" },
|
||||
},
|
||||
],
|
||||
conditions: [{ condition: "sun.is_set" }],
|
||||
actions: [
|
||||
{
|
||||
action: "light.turn_on",
|
||||
target: { entity_id: "light.living_room_spotlights" },
|
||||
data: { brightness_pct: 40 },
|
||||
},
|
||||
],
|
||||
mode: "single",
|
||||
},
|
||||
},
|
||||
{
|
||||
lastTriggered: 60 * 7,
|
||||
config: {
|
||||
id: "1700669322007",
|
||||
alias: "Lower heating when the car leaves",
|
||||
triggers: [
|
||||
{
|
||||
trigger: "zone.left",
|
||||
target: { entity_id: "device_tracker.car" },
|
||||
options: { zone: "zone.home", for: "00:00:00" },
|
||||
},
|
||||
],
|
||||
conditions: [
|
||||
{
|
||||
condition: "time",
|
||||
weekday: ["mon", "tue", "wed", "thu", "fri"],
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
action: "climate.set_temperature",
|
||||
target: {
|
||||
entity_id: ["climate.ground_floor", "climate.first_floor"],
|
||||
},
|
||||
data: { temperature: 17 },
|
||||
},
|
||||
],
|
||||
mode: "single",
|
||||
},
|
||||
},
|
||||
{
|
||||
lastTriggered: 60 * 11,
|
||||
config: {
|
||||
id: "1700669322008",
|
||||
alias: "Good night",
|
||||
description:
|
||||
"Turns off all lights and closes the shutters at 23:00 on weeknights.",
|
||||
triggers: [
|
||||
{
|
||||
trigger: "time",
|
||||
at: "23:00:00",
|
||||
weekday: ["sun", "mon", "tue", "wed", "thu"],
|
||||
},
|
||||
],
|
||||
conditions: [],
|
||||
actions: [
|
||||
{
|
||||
action: "light.turn_off",
|
||||
target: {
|
||||
entity_id: [
|
||||
"light.floor_lamp",
|
||||
"light.living_room_spotlights",
|
||||
"light.bar_lamp",
|
||||
"light.kitchen_spotlights",
|
||||
"light.worktop_spotlights",
|
||||
"light.study_spotlights",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "cover.close_cover",
|
||||
target: {
|
||||
entity_id: [
|
||||
"cover.living_room_garden_shutter",
|
||||
"cover.living_room_graveyard_shutter",
|
||||
"cover.living_room_left_shutter",
|
||||
"cover.living_room_right_shutter",
|
||||
"cover.kitchen_shutter",
|
||||
"cover.study_shutter",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
mode: "single",
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -140,6 +140,18 @@ export const demoEntitiesSections: DemoConfig["entities"] = (localize) =>
|
||||
friendly_name: "Outdoor humidity",
|
||||
},
|
||||
},
|
||||
// Used by the automation for when the car leaves home
|
||||
"zone.home": {
|
||||
entity_id: "zone.home",
|
||||
state: "0",
|
||||
attributes: {
|
||||
latitude: 52.3731339,
|
||||
longitude: 4.8903147,
|
||||
radius: 100,
|
||||
friendly_name: "Home",
|
||||
icon: "mdi:home",
|
||||
},
|
||||
},
|
||||
"device_tracker.car": {
|
||||
entity_id: "device_tracker.car",
|
||||
state: "not_home",
|
||||
@@ -514,19 +526,6 @@ export const demoEntitiesSections: DemoConfig["entities"] = (localize) =>
|
||||
friendly_name: "Outdoor motion sensor Illuminance",
|
||||
},
|
||||
},
|
||||
"automation.home_assistant_auto_update": {
|
||||
entity_id: "automation.home_assistant_auto_update",
|
||||
state: "off",
|
||||
attributes: {
|
||||
id: "1700669321947",
|
||||
last_triggered: "2024-02-29T18:02:05.343139+00:00",
|
||||
mode: "queued",
|
||||
current: 0,
|
||||
max: 50,
|
||||
icon: "mdi:auto-mode",
|
||||
friendly_name: "Home Assistant Auto-update",
|
||||
},
|
||||
},
|
||||
"update.home_assistant_operating_system_update": {
|
||||
entity_id: "update.home_assistant_operating_system_update",
|
||||
state: "off",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DemoConfig } from "../types";
|
||||
import { demoAutomationsSections } from "./automations";
|
||||
import { demoEntitiesSections } from "./entities";
|
||||
import { demoLovelaceSections } from "./lovelace";
|
||||
|
||||
@@ -8,5 +9,6 @@ export const demoSections: DemoConfig = {
|
||||
name: "Home Demo",
|
||||
lovelace: demoLovelaceSections,
|
||||
entities: demoEntitiesSections,
|
||||
automations: demoAutomationsSections,
|
||||
theme: { theme: "default", dark: false },
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import type { LocalizeFunc } from "../../../src/common/translations/localize";
|
||||
import type { ManualAutomationConfig } from "../../../src/data/automation";
|
||||
import type { LovelaceRawConfig } from "../../../src/data/lovelace/config/types";
|
||||
import type { EntityInput } from "../../../src/fake_data/entities/types";
|
||||
import type { ThemeSettings } from "../../../src/types";
|
||||
@@ -8,6 +9,16 @@ import type { DemoFloor } from "../stubs/floor_registry";
|
||||
|
||||
export type DemoTheme = ThemeSettings | (() => Record<string, string> | null);
|
||||
|
||||
export interface DemoAutomation {
|
||||
config: ManualAutomationConfig & { id: string; alias: string };
|
||||
/** Defaults to the alias as slug */
|
||||
entityId?: string;
|
||||
state?: "on" | "off";
|
||||
icon?: string;
|
||||
/** Minutes ago */
|
||||
lastTriggered?: number;
|
||||
}
|
||||
|
||||
export interface DemoConfig {
|
||||
index?: number;
|
||||
name: string;
|
||||
@@ -17,6 +28,7 @@ export interface DemoConfig {
|
||||
string | ((localize: LocalizeFunc) => string | TemplateResult<1>);
|
||||
lovelace: (localize: LocalizeFunc) => LovelaceRawConfig;
|
||||
entities: (localize: LocalizeFunc) => EntityInput[];
|
||||
automations?: DemoAutomation[];
|
||||
floors?: DemoFloor[];
|
||||
areas?: DemoArea[];
|
||||
theme: DemoTheme;
|
||||
|
||||
+37
-12
@@ -1,11 +1,18 @@
|
||||
import { customElement } from "lit/decorators";
|
||||
import type { LocalizeFunc } from "../../src/common/translations/localize";
|
||||
import { isNavigationClick } from "../../src/common/dom/is-navigation-click";
|
||||
import { navigate } from "../../src/common/navigate";
|
||||
import type { MockHomeAssistant } from "../../src/fake_data/provide_hass";
|
||||
import { provideHass } from "../../src/fake_data/provide_hass";
|
||||
import { HomeAssistantAppEl } from "../../src/layouts/home-assistant";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
import { applyDemoTheme, selectedDemoConfig } from "./configs/demo-configs";
|
||||
import {
|
||||
applyDemoTheme,
|
||||
demoConfigEntities,
|
||||
registerDemoAutomations,
|
||||
selectedDemo,
|
||||
selectedDemoConfig,
|
||||
} from "./configs/demo-configs";
|
||||
import { mockAreaRegistry, setDemoAreas } from "./stubs/area_registry";
|
||||
import {
|
||||
connectivityCommands,
|
||||
@@ -18,7 +25,10 @@ import { demoDevices } from "./stubs/devices";
|
||||
import { mockDeviceRegistry } from "./stubs/device_registry";
|
||||
import { mockEnergy } from "./stubs/energy";
|
||||
import { energyEntities } from "./stubs/entities";
|
||||
import { mockEntityRegistry } from "./stubs/entity_registry";
|
||||
import {
|
||||
mockEntityRegistry,
|
||||
mockEntityRegistryDisplay,
|
||||
} from "./stubs/entity_registry";
|
||||
import { mockEvents } from "./stubs/events";
|
||||
import { mockFloorRegistry, setDemoFloors } from "./stubs/floor_registry";
|
||||
import { mockFrontend } from "./stubs/frontend";
|
||||
@@ -26,6 +36,7 @@ import { mockHardware } from "./stubs/hardware";
|
||||
import { mockHassioSupervisor } from "./stubs/hassio_supervisor";
|
||||
import { mockIntegration } from "./stubs/integration";
|
||||
import { mockLabelRegistry } from "./stubs/label_registry";
|
||||
import { automationPlatformDomains } from "./stubs/automation_platform_domains";
|
||||
import { mockIcons } from "./stubs/icons";
|
||||
import { mockHistory } from "./stubs/history";
|
||||
import { mockLovelace } from "./stubs/lovelace";
|
||||
@@ -58,6 +69,12 @@ const CONFIG_PANEL_COMMANDS = [
|
||||
"system_health/",
|
||||
"backup/",
|
||||
"automation/config",
|
||||
"trigger_platforms/subscribe",
|
||||
"condition_platforms/subscribe",
|
||||
"get_triggers_for_target",
|
||||
"get_conditions_for_target",
|
||||
"get_services_for_target",
|
||||
"extract_from_target",
|
||||
"script/config",
|
||||
"config/automation/config",
|
||||
"config/script/config",
|
||||
@@ -88,14 +105,18 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
config: {
|
||||
...hass.config,
|
||||
components: [
|
||||
...(hass.config?.components ?? []),
|
||||
"backup",
|
||||
"webhook",
|
||||
"usage_prediction",
|
||||
"assist_pipeline",
|
||||
"hassio",
|
||||
"hardware",
|
||||
...connectivityComponents,
|
||||
// Some automation platforms are loaded already
|
||||
...new Set([
|
||||
...(hass.config?.components ?? []),
|
||||
"backup",
|
||||
"webhook",
|
||||
"usage_prediction",
|
||||
"assist_pipeline",
|
||||
"hassio",
|
||||
"hardware",
|
||||
...connectivityComponents,
|
||||
...automationPlatformDomains,
|
||||
]),
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -103,7 +124,9 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
const localizePromise =
|
||||
// @ts-ignore
|
||||
this._loadFragmentTranslations(hass.language, "page-demo").then(
|
||||
() => this.hass!.localize
|
||||
// hass is not updated yet when another fragment started loading
|
||||
// meanwhile, so use the localize with these translations directly
|
||||
(localize?: LocalizeFunc) => localize ?? this.hass!.localize
|
||||
);
|
||||
|
||||
mockLovelace(hass, localizePromise);
|
||||
@@ -138,6 +161,7 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
mockFloorRegistry(hass);
|
||||
mockLabelRegistry(hass);
|
||||
mockUsagePrediction(hass);
|
||||
mockEntityRegistryDisplay(hass);
|
||||
mockEntityRegistry(hass, [
|
||||
{
|
||||
config_entry_id: "co2signal",
|
||||
@@ -193,7 +217,8 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
([conf, localize]) => {
|
||||
setDemoFloors(hass, conf.floors);
|
||||
setDemoAreas(hass, conf.areas);
|
||||
hass.addEntities(conf.entities(localize));
|
||||
hass.addEntities(demoConfigEntities(selectedDemo, conf, localize));
|
||||
registerDemoAutomations(hass, selectedDemo, conf);
|
||||
applyDemoTheme(hass, conf.theme);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -39,6 +39,9 @@
|
||||
html {
|
||||
background-color: var(--primary-background-color, #fafafa);
|
||||
color: var(--primary-text-color, #212121);
|
||||
/* Prevent iOS Safari from inflating text when rotating to landscape */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { OwnProfileMutableParams } from "../../../src/data/person";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockAuth = (hass: MockHomeAssistant) => {
|
||||
@@ -6,4 +7,18 @@ export const mockAuth = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("auth/sign_path", (msg: { path: string }) => ({
|
||||
path: msg.path,
|
||||
}));
|
||||
|
||||
let user = hass.user!;
|
||||
// The profile dialog refreshes the user subscription after saving, which
|
||||
// writes the updated user back to hass.user.
|
||||
hass.mockWS("auth/current_user", () => user);
|
||||
hass.mockWS(
|
||||
"person/update_own_profile",
|
||||
(msg: Partial<OwnProfileMutableParams>) => {
|
||||
if (msg.name) {
|
||||
user = { ...user, name: msg.name };
|
||||
}
|
||||
return { user_name: user.name, person: null };
|
||||
}
|
||||
);
|
||||
};
|
||||
+142
-56
@@ -1,69 +1,155 @@
|
||||
import type { AutomationConfig } from "../../../src/data/automation";
|
||||
import type { ScriptConfig } from "../../../src/data/script";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import { computeDomain } from "../../../src/common/entity/compute_domain";
|
||||
import { slugify } from "../../../src/common/string/slugify";
|
||||
import {
|
||||
automationEntity,
|
||||
demoAutomations,
|
||||
saveDemoAutomation,
|
||||
selectedDemo,
|
||||
selectedDemoConfig,
|
||||
} from "../configs/demo-configs";
|
||||
import type { DemoAutomation } from "../configs/types";
|
||||
import { mockAutomationPlatforms } from "./automation_platforms";
|
||||
import {
|
||||
addEntityRegistryEntry,
|
||||
getEntityRegistryEntry,
|
||||
removeEntityRegistryEntry,
|
||||
} from "./entity_registry";
|
||||
|
||||
const demoAutomationConfig = (entityId: string): AutomationConfig => ({
|
||||
id: entityId.split(".")[1],
|
||||
alias: "Demo automation",
|
||||
description: "An example automation shown in the demo.",
|
||||
triggers: [
|
||||
{ trigger: "state", entity_id: "binary_sensor.basement_floor_wet" },
|
||||
],
|
||||
conditions: [],
|
||||
actions: [
|
||||
{
|
||||
action: "light.turn_on",
|
||||
target: { entity_id: "light.bed_light" },
|
||||
},
|
||||
],
|
||||
mode: "single",
|
||||
});
|
||||
const automationConfig = async (
|
||||
id: string | undefined
|
||||
): Promise<AutomationConfig> => {
|
||||
const automation = demoAutomations(
|
||||
selectedDemo,
|
||||
await selectedDemoConfig
|
||||
).find((a) => a.config.id === id);
|
||||
if (!automation) {
|
||||
throw new Error("Automation not found");
|
||||
}
|
||||
return automation.config;
|
||||
};
|
||||
|
||||
const demoScriptConfig = (): ScriptConfig => ({
|
||||
alias: "Demo script",
|
||||
description: "An example script shown in the demo.",
|
||||
sequence: [
|
||||
{
|
||||
action: "light.turn_on",
|
||||
target: { entity_id: "light.bed_light" },
|
||||
},
|
||||
],
|
||||
mode: "single",
|
||||
});
|
||||
const scriptConfig = (
|
||||
currentHass: MockHomeAssistant,
|
||||
entityId: string
|
||||
): ScriptConfig => {
|
||||
const alias =
|
||||
currentHass.states[entityId]?.attributes.friendly_name ?? entityId;
|
||||
return {
|
||||
alias,
|
||||
sequence: [
|
||||
{
|
||||
action: "persistent_notification.create",
|
||||
data: { message: `${alias} ran` },
|
||||
},
|
||||
],
|
||||
mode: "single",
|
||||
};
|
||||
};
|
||||
|
||||
export const mockAutomation = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("automation/config", (msg: { entity_id: string }) => ({
|
||||
config: demoAutomationConfig(msg.entity_id),
|
||||
}));
|
||||
hass.mockWS("script/config", () => ({ config: demoScriptConfig() }));
|
||||
|
||||
hass.mockAPI(/config\/automation\/config\/.+/, () =>
|
||||
demoAutomationConfig("automation.demo")
|
||||
);
|
||||
hass.mockAPI(/config\/script\/config\/.+/, () => demoScriptConfig());
|
||||
|
||||
// Trigger/condition type pickers subscribe for integration-provided
|
||||
// platforms. The demo only uses the built-in ones, so emit empty records.
|
||||
hass.mockWS(
|
||||
"trigger_platforms/subscribe",
|
||||
(
|
||||
_msg,
|
||||
_hass,
|
||||
onChange?: (descriptions: Record<string, unknown>) => void
|
||||
) => {
|
||||
onChange?.({});
|
||||
return () => undefined;
|
||||
"automation/config",
|
||||
async (msg: { entity_id: string }, currentHass: MockHomeAssistant) => ({
|
||||
config: await automationConfig(
|
||||
currentHass.states[msg.entity_id]?.attributes.id
|
||||
),
|
||||
})
|
||||
);
|
||||
hass.mockAPI(
|
||||
/config\/automation\/config\/.+/,
|
||||
(currentHass, method, path, parameters) => {
|
||||
const id = decodeURIComponent(path.split("/").pop()!);
|
||||
const existing = Object.values(currentHass.states).find(
|
||||
(stateObj) =>
|
||||
computeDomain(stateObj.entity_id) === "automation" &&
|
||||
stateObj.attributes.id === id
|
||||
);
|
||||
if (method === "DELETE") {
|
||||
// Like core, remove the automation with its entity
|
||||
saveDemoAutomation(id, null);
|
||||
if (existing) {
|
||||
const { [existing.entity_id]: _state, ...states } =
|
||||
currentHass.states;
|
||||
const { [existing.entity_id]: _entity, ...entities } =
|
||||
currentHass.entities;
|
||||
currentHass.updateHass({ states, entities });
|
||||
// Otherwise a service call to it would add its state again
|
||||
delete currentHass.mockEntities[existing.entity_id];
|
||||
removeEntityRegistryEntry(currentHass, existing.entity_id);
|
||||
}
|
||||
return { result: "ok" };
|
||||
}
|
||||
if (method === "POST") {
|
||||
// Core stores the ID in the config
|
||||
const config = {
|
||||
...(parameters as DemoAutomation["config"]),
|
||||
id,
|
||||
};
|
||||
config.alias ||= id;
|
||||
// Like core, update the automation entity, or add it for a new one
|
||||
// A new automation gets a free entity ID, like core
|
||||
const slug = `automation.${slugify(config.alias)}`;
|
||||
let entityId = existing?.entity_id ?? slug;
|
||||
for (let i = 2; !existing && entityId in currentHass.states; i++) {
|
||||
entityId = `${slug}_${i}`;
|
||||
}
|
||||
const lastTriggered = existing?.attributes.last_triggered;
|
||||
const automation: DemoAutomation = {
|
||||
config,
|
||||
entityId,
|
||||
state: existing?.state as DemoAutomation["state"],
|
||||
icon: existing?.attributes.icon,
|
||||
lastTriggered: lastTriggered
|
||||
? (Date.now() - Date.parse(lastTriggered)) / 60000
|
||||
: undefined,
|
||||
};
|
||||
// Kept per demo, so it is still there after switching demos
|
||||
saveDemoAutomation(id, automation);
|
||||
// A display entry of a new automation is left over from another demo
|
||||
const display = existing ? currentHass.entities[entityId] : undefined;
|
||||
currentHass.addEntities(automationEntity(automation));
|
||||
// Adding the entity resets its display entry, keep what the registry
|
||||
// holds
|
||||
if (display) {
|
||||
const registryName = getEntityRegistryEntry(entityId)?.name;
|
||||
currentHass.entities[entityId] = {
|
||||
...currentHass.entities[entityId],
|
||||
...(registryName && { name: registryName }),
|
||||
area_id: display.area_id,
|
||||
labels: display.labels,
|
||||
icon: display.icon,
|
||||
hidden: display.hidden,
|
||||
};
|
||||
}
|
||||
if (!existing) {
|
||||
addEntityRegistryEntry(currentHass, {
|
||||
entity_id: entityId,
|
||||
platform: "automation",
|
||||
unique_id: id,
|
||||
});
|
||||
}
|
||||
return { result: "ok" };
|
||||
}
|
||||
return automationConfig(id);
|
||||
}
|
||||
);
|
||||
|
||||
// Scripts do not have demo configs yet, show the script under its own name.
|
||||
hass.mockWS(
|
||||
"condition_platforms/subscribe",
|
||||
(
|
||||
_msg,
|
||||
_hass,
|
||||
onChange?: (descriptions: Record<string, unknown>) => void
|
||||
) => {
|
||||
onChange?.({});
|
||||
return () => undefined;
|
||||
}
|
||||
"script/config",
|
||||
(msg: { entity_id: string }, currentHass: MockHomeAssistant) => ({
|
||||
config: scriptConfig(currentHass, msg.entity_id),
|
||||
})
|
||||
);
|
||||
hass.mockAPI(/config\/script\/config\/.+/, (currentHass, _method, path) =>
|
||||
scriptConfig(
|
||||
currentHass,
|
||||
`script.${decodeURIComponent(path.split("/").pop()!)}`
|
||||
)
|
||||
);
|
||||
|
||||
mockAutomationPlatforms(hass);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
// This file is auto-generated from Home Assistant Core. Do not edit by hand.
|
||||
// Regenerate with `script/gen_demo_core_data`.
|
||||
|
||||
// Integrations that provide the new style triggers and conditions. Their
|
||||
// translations are part of the demo build.
|
||||
export const automationPlatformDomains = [
|
||||
"air_quality",
|
||||
"alarm_control_panel",
|
||||
"assist_satellite",
|
||||
"battery",
|
||||
"button",
|
||||
"calendar",
|
||||
"climate",
|
||||
"counter",
|
||||
"cover",
|
||||
"door",
|
||||
"doorbell",
|
||||
"event",
|
||||
"fan",
|
||||
"garage_door",
|
||||
"gate",
|
||||
"humidifier",
|
||||
"humidity",
|
||||
"illuminance",
|
||||
"lawn_mower",
|
||||
"light",
|
||||
"lock",
|
||||
"media_player",
|
||||
"moisture",
|
||||
"moon",
|
||||
"motion",
|
||||
"occupancy",
|
||||
"power",
|
||||
"remote",
|
||||
"scene",
|
||||
"schedule",
|
||||
"select",
|
||||
"siren",
|
||||
"sun",
|
||||
"switch",
|
||||
"temperature",
|
||||
"text",
|
||||
"timer",
|
||||
"todo",
|
||||
"update",
|
||||
"vacuum",
|
||||
"valve",
|
||||
"vibration",
|
||||
"water_heater",
|
||||
"window",
|
||||
"zone",
|
||||
];
|
||||
@@ -0,0 +1,270 @@
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { ensureArray } from "../../../src/common/array/ensure-array";
|
||||
import { computeDomain } from "../../../src/common/entity/compute_domain";
|
||||
import type { ConditionDescriptions } from "../../../src/data/condition";
|
||||
import type { ExtractFromTargetResult } from "../../../src/data/target";
|
||||
import type { TriggerDescriptions } from "../../../src/data/trigger";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import { getLabelIds } from "./label_registry";
|
||||
import {
|
||||
conditionDescriptions,
|
||||
triggerDescriptions,
|
||||
} from "./automation_platforms_data";
|
||||
|
||||
type Descriptions = TriggerDescriptions | ConditionDescriptions;
|
||||
|
||||
interface EntityFilter {
|
||||
integration?: string;
|
||||
domain?: string | string[];
|
||||
device_class?: string | string[];
|
||||
supported_features?: number[];
|
||||
}
|
||||
|
||||
// Expands a target to its areas, devices and entities, like core does
|
||||
const expandTarget = (
|
||||
hass: MockHomeAssistant,
|
||||
target: HassServiceTarget,
|
||||
primaryEntitiesOnly = true
|
||||
) => {
|
||||
const missing = {
|
||||
floors: [] as string[],
|
||||
areas: [] as string[],
|
||||
devices: [] as string[],
|
||||
labels: [] as string[],
|
||||
};
|
||||
const knownLabelIds = getLabelIds();
|
||||
const floorIds = ensureArray(target.floor_id ?? []);
|
||||
const labelIds = ensureArray(target.label_id ?? []);
|
||||
missing.floors.push(...floorIds.filter((id) => !(id in hass.floors)));
|
||||
// Like core, unknown labels are reported, but still expanded
|
||||
missing.labels.push(...labelIds.filter((id) => !knownLabelIds.includes(id)));
|
||||
const hasLabel = (labels?: string[]) =>
|
||||
labelIds.some((labelId) => labels?.includes(labelId));
|
||||
|
||||
// Unknown areas and devices are reported, but still referenced
|
||||
const areaIds = new Set(ensureArray(target.area_id ?? []));
|
||||
missing.areas.push(...[...areaIds].filter((id) => !(id in hass.areas)));
|
||||
for (const area of Object.values(hass.areas)) {
|
||||
if (
|
||||
(area.floor_id && floorIds.includes(area.floor_id)) ||
|
||||
hasLabel(area.labels)
|
||||
) {
|
||||
areaIds.add(area.area_id);
|
||||
}
|
||||
}
|
||||
|
||||
// A targeted or labeled device includes its child devices
|
||||
const deviceIds = new Set<string>();
|
||||
const addDevice = (deviceId: string) => {
|
||||
deviceIds.add(deviceId);
|
||||
for (const device of Object.values(hass.devices)) {
|
||||
if (device.parent_device_id === deviceId) {
|
||||
deviceIds.add(device.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const deviceId of ensureArray(target.device_id ?? [])) {
|
||||
if (!(deviceId in hass.devices)) {
|
||||
missing.devices.push(deviceId);
|
||||
}
|
||||
addDevice(deviceId);
|
||||
}
|
||||
for (const device of Object.values(hass.devices)) {
|
||||
if (hasLabel(device.labels)) {
|
||||
addDevice(device.id);
|
||||
}
|
||||
}
|
||||
|
||||
// A child device without its own area is in the area of its parent device
|
||||
const deviceArea = (deviceId: string) => {
|
||||
const device = hass.devices[deviceId];
|
||||
return (
|
||||
device?.area_id ??
|
||||
(device?.parent_device_id
|
||||
? hass.devices[device.parent_device_id]?.area_id
|
||||
: undefined)
|
||||
);
|
||||
};
|
||||
const areaDeviceIds = new Set<string>();
|
||||
for (const device of Object.values(hass.devices)) {
|
||||
const areaId = deviceArea(device.id);
|
||||
if (areaId && areaIds.has(areaId)) {
|
||||
areaDeviceIds.add(device.id);
|
||||
}
|
||||
}
|
||||
|
||||
const entityIds = new Set(ensureArray(target.entity_id ?? []));
|
||||
for (const entity of Object.values(hass.entities)) {
|
||||
// The display registry keeps the entities of a previous demo, and like
|
||||
// core, hidden entities are never included indirectly
|
||||
if (!(entity.entity_id in hass.states) || entity.hidden) {
|
||||
continue;
|
||||
}
|
||||
const primary = !primaryEntitiesOnly || !entity.entity_category;
|
||||
if (
|
||||
hasLabel(entity.labels) ||
|
||||
(primary &&
|
||||
((entity.device_id && deviceIds.has(entity.device_id)) ||
|
||||
(entity.area_id && areaIds.has(entity.area_id)) ||
|
||||
// An entity with its own area is not in the area of its device
|
||||
(!entity.area_id &&
|
||||
entity.device_id &&
|
||||
areaDeviceIds.has(entity.device_id))))
|
||||
) {
|
||||
entityIds.add(entity.entity_id);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
areas: [...areaIds],
|
||||
devices: [...new Set([...deviceIds, ...areaDeviceIds])],
|
||||
entities: [...entityIds],
|
||||
missing,
|
||||
};
|
||||
};
|
||||
|
||||
const matchesFilter = (
|
||||
hass: MockHomeAssistant,
|
||||
entityId: string,
|
||||
filter: EntityFilter
|
||||
) =>
|
||||
(!filter.integration ||
|
||||
hass.entities[entityId]?.platform === filter.integration) &&
|
||||
(!filter.domain ||
|
||||
ensureArray(filter.domain).includes(computeDomain(entityId))) &&
|
||||
(!filter.device_class ||
|
||||
ensureArray(filter.device_class).includes(
|
||||
hass.states[entityId]?.attributes.device_class ?? ""
|
||||
)) &&
|
||||
// Like core, the entity needs all the features of one of the feature sets
|
||||
(!filter.supported_features ||
|
||||
filter.supported_features.some(
|
||||
(features) =>
|
||||
// eslint-disable-next-line no-bitwise
|
||||
((hass.states[entityId]?.attributes.supported_features ?? 0) &
|
||||
features) ===
|
||||
features
|
||||
));
|
||||
|
||||
type TargetDescriptions = Record<
|
||||
string,
|
||||
{
|
||||
target?: {
|
||||
entity?: EntityFilter | EntityFilter[];
|
||||
primary_entities_only?: boolean;
|
||||
} | null;
|
||||
}
|
||||
>;
|
||||
|
||||
// Like core, the components with a target that apply to at least one entity
|
||||
// of the target. A target without entity filters applies to any entity.
|
||||
const componentsForTarget = (
|
||||
hass: MockHomeAssistant,
|
||||
target: HassServiceTarget,
|
||||
descriptions: TargetDescriptions
|
||||
) => {
|
||||
const entityIds = expandTarget(hass, target, false).entities;
|
||||
const targetedIds = ensureArray(target.entity_id ?? []);
|
||||
return Object.entries(descriptions)
|
||||
.filter(([, description]) => {
|
||||
if (!description.target) {
|
||||
return false;
|
||||
}
|
||||
const filters = ensureArray(description.target.entity ?? []);
|
||||
const primaryEntitiesOnly =
|
||||
description.target.primary_entities_only ?? true;
|
||||
return entityIds.some(
|
||||
(entityId) =>
|
||||
// Entities with a category only count when targeted directly,
|
||||
// unless the component allows them
|
||||
(!primaryEntitiesOnly ||
|
||||
targetedIds.includes(entityId) ||
|
||||
!hass.entities[entityId]?.entity_category) &&
|
||||
(!filters.length ||
|
||||
filters.some((filter) => matchesFilter(hass, entityId, filter)))
|
||||
);
|
||||
})
|
||||
.map(([key]) => key);
|
||||
};
|
||||
|
||||
const mockForTarget = (
|
||||
hass: MockHomeAssistant,
|
||||
type: string,
|
||||
descriptions: Descriptions
|
||||
) =>
|
||||
hass.mockWS(
|
||||
type,
|
||||
(msg: { target: HassServiceTarget }, currentHass: MockHomeAssistant) =>
|
||||
componentsForTarget(
|
||||
currentHass,
|
||||
msg.target,
|
||||
descriptions as TargetDescriptions
|
||||
)
|
||||
);
|
||||
|
||||
const mockPlatformSubscription = (
|
||||
hass: MockHomeAssistant,
|
||||
type: string,
|
||||
descriptions: Descriptions
|
||||
) =>
|
||||
hass.mockWS(
|
||||
type,
|
||||
(_msg, _hass, onChange?: (descriptions: Descriptions) => void) => {
|
||||
onChange?.(descriptions);
|
||||
return () => undefined;
|
||||
}
|
||||
);
|
||||
|
||||
export const mockAutomationPlatforms = (hass: MockHomeAssistant) => {
|
||||
mockPlatformSubscription(
|
||||
hass,
|
||||
"trigger_platforms/subscribe",
|
||||
triggerDescriptions
|
||||
);
|
||||
mockPlatformSubscription(
|
||||
hass,
|
||||
"condition_platforms/subscribe",
|
||||
conditionDescriptions
|
||||
);
|
||||
mockForTarget(hass, "get_triggers_for_target", triggerDescriptions);
|
||||
mockForTarget(hass, "get_conditions_for_target", conditionDescriptions);
|
||||
hass.mockWS(
|
||||
"extract_from_target",
|
||||
(
|
||||
msg: { target: HassServiceTarget; primary_entities_only?: boolean },
|
||||
currentHass: MockHomeAssistant
|
||||
): ExtractFromTargetResult => {
|
||||
const { areas, devices, entities, missing } = expandTarget(
|
||||
currentHass,
|
||||
msg.target,
|
||||
msg.primary_entities_only ?? true
|
||||
);
|
||||
return {
|
||||
missing_areas: missing.areas,
|
||||
missing_devices: missing.devices,
|
||||
missing_floors: missing.floors,
|
||||
missing_labels: missing.labels,
|
||||
referenced_areas: areas,
|
||||
referenced_devices: devices,
|
||||
referenced_entities: entities,
|
||||
};
|
||||
}
|
||||
);
|
||||
hass.mockWS(
|
||||
"get_services_for_target",
|
||||
(msg: { target: HassServiceTarget }, currentHass: MockHomeAssistant) =>
|
||||
componentsForTarget(
|
||||
currentHass,
|
||||
msg.target,
|
||||
// Keyed by <domain>.<service>, like the triggers and conditions
|
||||
Object.fromEntries(
|
||||
Object.entries(currentHass.services).flatMap(([domain, services]) =>
|
||||
Object.entries(services).map(([service, description]) => [
|
||||
`${domain}.${service}`,
|
||||
description,
|
||||
])
|
||||
)
|
||||
) as TargetDescriptions
|
||||
)
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,495 @@
|
||||
// This file is auto-generated from Home Assistant Core. Do not edit by hand.
|
||||
// Regenerate with `script/gen_demo_core_data`.
|
||||
|
||||
export const triggerIcons = {
|
||||
air_quality: {
|
||||
co2_changed: { trigger: "mdi:molecule-co2" },
|
||||
co2_crossed_threshold: { trigger: "mdi:molecule-co2" },
|
||||
co_changed: { trigger: "mdi:molecule-co" },
|
||||
co_cleared: { trigger: "mdi:check-circle" },
|
||||
co_crossed_threshold: { trigger: "mdi:molecule-co" },
|
||||
co_detected: { trigger: "mdi:molecule-co" },
|
||||
gas_cleared: { trigger: "mdi:check-circle" },
|
||||
gas_detected: { trigger: "mdi:gas-cylinder" },
|
||||
n2o_changed: { trigger: "mdi:factory" },
|
||||
n2o_crossed_threshold: { trigger: "mdi:factory" },
|
||||
no2_changed: { trigger: "mdi:factory" },
|
||||
no2_crossed_threshold: { trigger: "mdi:factory" },
|
||||
no_changed: { trigger: "mdi:factory" },
|
||||
no_crossed_threshold: { trigger: "mdi:factory" },
|
||||
ozone_changed: { trigger: "mdi:weather-sunny-alert" },
|
||||
ozone_crossed_threshold: { trigger: "mdi:weather-sunny-alert" },
|
||||
pm10_changed: { trigger: "mdi:blur" },
|
||||
pm10_crossed_threshold: { trigger: "mdi:blur" },
|
||||
pm1_changed: { trigger: "mdi:blur" },
|
||||
pm1_crossed_threshold: { trigger: "mdi:blur" },
|
||||
pm25_changed: { trigger: "mdi:blur" },
|
||||
pm25_crossed_threshold: { trigger: "mdi:blur" },
|
||||
pm4_changed: { trigger: "mdi:blur" },
|
||||
pm4_crossed_threshold: { trigger: "mdi:blur" },
|
||||
smoke_cleared: { trigger: "mdi:check-circle" },
|
||||
smoke_detected: { trigger: "mdi:smoke-detector-variant" },
|
||||
so2_changed: { trigger: "mdi:factory" },
|
||||
so2_crossed_threshold: { trigger: "mdi:factory" },
|
||||
voc_changed: { trigger: "mdi:air-filter" },
|
||||
voc_crossed_threshold: { trigger: "mdi:air-filter" },
|
||||
voc_ratio_changed: { trigger: "mdi:air-filter" },
|
||||
voc_ratio_crossed_threshold: { trigger: "mdi:air-filter" },
|
||||
},
|
||||
alarm_control_panel: {
|
||||
armed: { trigger: "mdi:shield" },
|
||||
armed_away: { trigger: "mdi:shield-lock" },
|
||||
armed_home: { trigger: "mdi:shield-home" },
|
||||
armed_night: { trigger: "mdi:shield-moon" },
|
||||
armed_vacation: { trigger: "mdi:shield-airplane" },
|
||||
disarmed: { trigger: "mdi:shield-off" },
|
||||
triggered: { trigger: "mdi:bell-ring" },
|
||||
},
|
||||
assist_satellite: {
|
||||
idle: { trigger: "mdi:chat-sleep" },
|
||||
listening: { trigger: "mdi:chat-question" },
|
||||
processing: { trigger: "mdi:chat-processing" },
|
||||
responding: { trigger: "mdi:chat-alert" },
|
||||
},
|
||||
battery: {
|
||||
became_low: { trigger: "mdi:battery-alert" },
|
||||
level_changed: { trigger: "mdi:battery-unknown" },
|
||||
level_crossed_threshold: { trigger: "mdi:battery-alert" },
|
||||
no_longer_low: { trigger: "mdi:battery" },
|
||||
started_charging: { trigger: "mdi:battery-charging" },
|
||||
stopped_charging: { trigger: "mdi:battery" },
|
||||
},
|
||||
button: { pressed: { trigger: "mdi:gesture-tap-button" } },
|
||||
calendar: {
|
||||
event_ended: { trigger: "mdi:calendar-end" },
|
||||
event_started: { trigger: "mdi:calendar-start" },
|
||||
},
|
||||
climate: {
|
||||
hvac_mode_changed: { trigger: "mdi:thermostat" },
|
||||
started_cooling: { trigger: "mdi:snowflake" },
|
||||
started_drying: { trigger: "mdi:water-percent" },
|
||||
started_heating: { trigger: "mdi:fire" },
|
||||
target_humidity_changed: { trigger: "mdi:water-percent" },
|
||||
target_humidity_crossed_threshold: { trigger: "mdi:water-percent" },
|
||||
target_temperature_changed: { trigger: "mdi:thermometer" },
|
||||
target_temperature_crossed_threshold: { trigger: "mdi:thermometer" },
|
||||
turned_off: { trigger: "mdi:power-off" },
|
||||
turned_on: { trigger: "mdi:power-on" },
|
||||
},
|
||||
counter: {
|
||||
decremented: { trigger: "mdi:numeric-negative-1" },
|
||||
incremented: { trigger: "mdi:numeric-positive-1" },
|
||||
maximum_reached: { trigger: "mdi:sort-numeric-ascending-variant" },
|
||||
minimum_reached: { trigger: "mdi:sort-numeric-descending-variant" },
|
||||
reset: { trigger: "mdi:refresh" },
|
||||
},
|
||||
cover: {
|
||||
awning_closed: { trigger: "mdi:storefront-outline" },
|
||||
awning_opened: { trigger: "mdi:storefront-outline" },
|
||||
blind_closed: { trigger: "mdi:blinds-horizontal-closed" },
|
||||
blind_opened: { trigger: "mdi:blinds-horizontal" },
|
||||
curtain_closed: { trigger: "mdi:curtains-closed" },
|
||||
curtain_opened: { trigger: "mdi:curtains" },
|
||||
shade_closed: { trigger: "mdi:roller-shade-closed" },
|
||||
shade_opened: { trigger: "mdi:roller-shade" },
|
||||
shutter_closed: { trigger: "mdi:window-shutter" },
|
||||
shutter_opened: { trigger: "mdi:window-shutter-open" },
|
||||
},
|
||||
door: {
|
||||
closed: { trigger: "mdi:door-closed" },
|
||||
opened: { trigger: "mdi:door-open" },
|
||||
},
|
||||
doorbell: { rang: { trigger: "mdi:doorbell" } },
|
||||
event: { received: { trigger: "mdi:eye-check" } },
|
||||
fan: {
|
||||
turned_off: { trigger: "mdi:fan-off" },
|
||||
turned_on: { trigger: "mdi:fan" },
|
||||
},
|
||||
garage_door: {
|
||||
closed: { trigger: "mdi:garage" },
|
||||
opened: { trigger: "mdi:garage-open" },
|
||||
},
|
||||
gate: {
|
||||
closed: { trigger: "mdi:gate" },
|
||||
opened: { trigger: "mdi:gate-open" },
|
||||
},
|
||||
humidifier: {
|
||||
mode_changed: { trigger: "mdi:air-humidifier" },
|
||||
started_drying: { trigger: "mdi:arrow-down-bold" },
|
||||
started_humidifying: { trigger: "mdi:arrow-up-bold" },
|
||||
turned_off: { trigger: "mdi:air-humidifier-off" },
|
||||
turned_on: { trigger: "mdi:air-humidifier" },
|
||||
},
|
||||
humidity: {
|
||||
changed: { trigger: "mdi:water-percent" },
|
||||
crossed_threshold: { trigger: "mdi:water-percent" },
|
||||
},
|
||||
illuminance: {
|
||||
changed: { trigger: "mdi:brightness-6" },
|
||||
cleared: { trigger: "mdi:brightness-4" },
|
||||
crossed_threshold: { trigger: "mdi:brightness-6" },
|
||||
detected: { trigger: "mdi:brightness-7" },
|
||||
},
|
||||
lawn_mower: {
|
||||
became_idle: { trigger: "mdi:stop" },
|
||||
errored: { trigger: "mdi:alert-circle-outline" },
|
||||
paused_mowing: { trigger: "mdi:pause" },
|
||||
returned_to_dock: { trigger: "mdi:home-import-outline" },
|
||||
started_mowing: { trigger: "mdi:play" },
|
||||
started_returning: { trigger: "mdi:home-import-outline" },
|
||||
},
|
||||
light: {
|
||||
brightness_changed: { trigger: "mdi:lightbulb-on-50" },
|
||||
brightness_crossed_threshold: { trigger: "mdi:lightbulb-on-50" },
|
||||
turned_off: { trigger: "mdi:lightbulb-off" },
|
||||
turned_on: { trigger: "mdi:lightbulb-on" },
|
||||
},
|
||||
lock: {
|
||||
jammed: { trigger: "mdi:lock-alert" },
|
||||
locked: { trigger: "mdi:lock" },
|
||||
opened: { trigger: "mdi:lock-open-variant" },
|
||||
unlocked: { trigger: "mdi:lock-open-variant" },
|
||||
},
|
||||
media_player: {
|
||||
muted: { trigger: "mdi:volume-mute" },
|
||||
paused_playing: { trigger: "mdi:pause" },
|
||||
started_playing: { trigger: "mdi:play" },
|
||||
stopped_playing: { trigger: "mdi:stop" },
|
||||
turned_off: { trigger: "mdi:power" },
|
||||
turned_on: { trigger: "mdi:power" },
|
||||
unmuted: { trigger: "mdi:volume-high" },
|
||||
volume_changed: { trigger: "mdi:volume-medium" },
|
||||
volume_crossed_threshold: { trigger: "mdi:volume-medium" },
|
||||
},
|
||||
moisture: {
|
||||
changed: { trigger: "mdi:water-percent" },
|
||||
cleared: { trigger: "mdi:water-off" },
|
||||
crossed_threshold: { trigger: "mdi:water-percent" },
|
||||
detected: { trigger: "mdi:water" },
|
||||
},
|
||||
moon: { phase_changed: { trigger: "mdi:moon-waning-crescent" } },
|
||||
motion: {
|
||||
cleared: { trigger: "mdi:motion-sensor-off" },
|
||||
detected: { trigger: "mdi:motion-sensor" },
|
||||
},
|
||||
occupancy: {
|
||||
cleared: { trigger: "mdi:home-outline" },
|
||||
detected: { trigger: "mdi:home-account" },
|
||||
},
|
||||
power: {
|
||||
changed: { trigger: "mdi:flash" },
|
||||
crossed_threshold: { trigger: "mdi:flash" },
|
||||
},
|
||||
remote: {
|
||||
turned_off: { trigger: "mdi:remote-off" },
|
||||
turned_on: { trigger: "mdi:remote" },
|
||||
},
|
||||
scene: { activated: { trigger: "mdi:palette" } },
|
||||
schedule: {
|
||||
block_ended: { trigger: "mdi:calendar-blank" },
|
||||
block_started: { trigger: "mdi:calendar-clock" },
|
||||
},
|
||||
select: { selection_changed: { trigger: "mdi:format-list-bulleted" } },
|
||||
siren: {
|
||||
turned_off: { trigger: "mdi:bullhorn-outline" },
|
||||
turned_on: { trigger: "mdi:bullhorn" },
|
||||
},
|
||||
sun: {
|
||||
blue_hour_ended: { trigger: "mdi:weather-sunset" },
|
||||
blue_hour_started: { trigger: "mdi:weather-sunset" },
|
||||
dawn: { trigger: "mdi:weather-sunset-up" },
|
||||
dusk: { trigger: "mdi:weather-sunset-down" },
|
||||
elevation_changed: { trigger: "mdi:sun-angle" },
|
||||
elevation_crossed_threshold: { trigger: "mdi:sun-angle" },
|
||||
golden_hour_ended: { trigger: "mdi:weather-sunset" },
|
||||
golden_hour_started: { trigger: "mdi:weather-sunset" },
|
||||
midnight_sun_ended: { trigger: "mdi:weather-sunset" },
|
||||
midnight_sun_started: { trigger: "mdi:weather-sunny" },
|
||||
polar_night_ended: { trigger: "mdi:weather-sunset-up" },
|
||||
polar_night_started: { trigger: "mdi:weather-night" },
|
||||
solar_midnight: { trigger: "mdi:weather-night" },
|
||||
solar_noon: { trigger: "mdi:weather-sunny" },
|
||||
sunrise: { trigger: "mdi:weather-sunset-up" },
|
||||
sunset: { trigger: "mdi:weather-sunset-down" },
|
||||
},
|
||||
switch: {
|
||||
turned_off: { trigger: "mdi:toggle-switch-variant-off" },
|
||||
turned_on: { trigger: "mdi:toggle-switch-variant" },
|
||||
},
|
||||
temperature: {
|
||||
changed: { trigger: "mdi:thermometer" },
|
||||
crossed_threshold: { trigger: "mdi:thermometer" },
|
||||
},
|
||||
text: { changed: { trigger: "mdi:form-textbox" } },
|
||||
timer: {
|
||||
cancelled: { trigger: "mdi:timer-cancel" },
|
||||
finished: { trigger: "mdi:timer-check" },
|
||||
paused: { trigger: "mdi:timer-pause" },
|
||||
remaining_time_reached: { trigger: "mdi:timer-alert-outline" },
|
||||
restarted: { trigger: "mdi:timer-refresh" },
|
||||
started: { trigger: "mdi:timer-play" },
|
||||
},
|
||||
todo: {
|
||||
item_added: { trigger: "mdi:clipboard-plus" },
|
||||
item_completed: { trigger: "mdi:clipboard-check" },
|
||||
item_removed: { trigger: "mdi:clipboard-minus" },
|
||||
},
|
||||
update: { became_available: { trigger: "mdi:package-up" } },
|
||||
vacuum: {
|
||||
errored: { trigger: "mdi:alert-circle-outline" },
|
||||
paused_cleaning: { trigger: "mdi:pause" },
|
||||
returned_to_dock: { trigger: "mdi:home-outline" },
|
||||
started_cleaning: { trigger: "mdi:play" },
|
||||
started_returning: { trigger: "mdi:home-import-outline" },
|
||||
},
|
||||
valve: {
|
||||
closed: { trigger: "mdi:valve-closed" },
|
||||
opened: { trigger: "mdi:valve-open" },
|
||||
},
|
||||
vibration: {
|
||||
cleared: { trigger: "mdi:vibrate-off" },
|
||||
detected: { trigger: "mdi:vibrate" },
|
||||
},
|
||||
water_heater: {
|
||||
operation_mode_changed: { trigger: "mdi:water-boiler" },
|
||||
target_temperature_changed: { trigger: "mdi:thermometer" },
|
||||
target_temperature_crossed_threshold: { trigger: "mdi:thermometer" },
|
||||
turned_off: { trigger: "mdi:water-boiler-off" },
|
||||
turned_on: { trigger: "mdi:water-boiler" },
|
||||
},
|
||||
window: {
|
||||
closed: { trigger: "mdi:window-closed" },
|
||||
opened: { trigger: "mdi:window-open" },
|
||||
},
|
||||
zone: {
|
||||
entered: { trigger: "mdi:map-marker-plus" },
|
||||
left: { trigger: "mdi:map-marker-minus" },
|
||||
occupancy_cleared: { trigger: "mdi:account-off" },
|
||||
occupancy_detected: { trigger: "mdi:account-group" },
|
||||
},
|
||||
};
|
||||
|
||||
export const conditionIcons = {
|
||||
air_quality: {
|
||||
is_co2_value: { condition: "mdi:molecule-co2" },
|
||||
is_co_cleared: { condition: "mdi:check-circle" },
|
||||
is_co_detected: { condition: "mdi:molecule-co" },
|
||||
is_co_value: { condition: "mdi:molecule-co" },
|
||||
is_gas_cleared: { condition: "mdi:check-circle" },
|
||||
is_gas_detected: { condition: "mdi:gas-cylinder" },
|
||||
is_n2o_value: { condition: "mdi:factory" },
|
||||
is_no2_value: { condition: "mdi:factory" },
|
||||
is_no_value: { condition: "mdi:factory" },
|
||||
is_ozone_value: { condition: "mdi:weather-sunny-alert" },
|
||||
is_pm10_value: { condition: "mdi:blur" },
|
||||
is_pm1_value: { condition: "mdi:blur" },
|
||||
is_pm25_value: { condition: "mdi:blur" },
|
||||
is_pm4_value: { condition: "mdi:blur" },
|
||||
is_smoke_cleared: { condition: "mdi:check-circle" },
|
||||
is_smoke_detected: { condition: "mdi:smoke-detector-variant" },
|
||||
is_so2_value: { condition: "mdi:factory" },
|
||||
is_voc_ratio_value: { condition: "mdi:air-filter" },
|
||||
is_voc_value: { condition: "mdi:air-filter" },
|
||||
},
|
||||
alarm_control_panel: {
|
||||
is_armed: { condition: "mdi:shield" },
|
||||
is_armed_away: { condition: "mdi:shield-lock" },
|
||||
is_armed_home: { condition: "mdi:shield-home" },
|
||||
is_armed_night: { condition: "mdi:shield-moon" },
|
||||
is_armed_vacation: { condition: "mdi:shield-airplane" },
|
||||
is_disarmed: { condition: "mdi:shield-off" },
|
||||
is_triggered: { condition: "mdi:bell-ring" },
|
||||
},
|
||||
assist_satellite: {
|
||||
is_idle: { condition: "mdi:chat-sleep" },
|
||||
is_listening: { condition: "mdi:chat-question" },
|
||||
is_processing: { condition: "mdi:chat-processing" },
|
||||
is_responding: { condition: "mdi:chat-alert" },
|
||||
},
|
||||
battery: {
|
||||
is_charging: { condition: "mdi:battery-charging" },
|
||||
is_level: { condition: "mdi:battery-unknown" },
|
||||
is_low: { condition: "mdi:battery-alert" },
|
||||
is_not_charging: { condition: "mdi:battery" },
|
||||
is_not_low: { condition: "mdi:battery" },
|
||||
},
|
||||
calendar: { is_event_active: { condition: "mdi:calendar-check" } },
|
||||
climate: {
|
||||
is_cooling: { condition: "mdi:snowflake" },
|
||||
is_drying: { condition: "mdi:water-percent" },
|
||||
is_heating: { condition: "mdi:fire" },
|
||||
is_hvac_mode: { condition: "mdi:thermostat" },
|
||||
is_off: { condition: "mdi:power-off" },
|
||||
is_on: { condition: "mdi:power-on" },
|
||||
is_target_humidity: { condition: "mdi:water-percent" },
|
||||
is_target_temperature: { condition: "mdi:thermometer" },
|
||||
},
|
||||
counter: { is_value: { condition: "mdi:counter" } },
|
||||
cover: {
|
||||
awning_is_closed: { condition: "mdi:storefront-outline" },
|
||||
awning_is_open: { condition: "mdi:storefront-outline" },
|
||||
blind_is_closed: { condition: "mdi:blinds-horizontal-closed" },
|
||||
blind_is_open: { condition: "mdi:blinds-horizontal" },
|
||||
curtain_is_closed: { condition: "mdi:curtains-closed" },
|
||||
curtain_is_open: { condition: "mdi:curtains" },
|
||||
shade_is_closed: { condition: "mdi:roller-shade-closed" },
|
||||
shade_is_open: { condition: "mdi:roller-shade" },
|
||||
shutter_is_closed: { condition: "mdi:window-shutter" },
|
||||
shutter_is_open: { condition: "mdi:window-shutter-open" },
|
||||
},
|
||||
door: {
|
||||
is_closed: { condition: "mdi:door-closed" },
|
||||
is_open: { condition: "mdi:door-open" },
|
||||
},
|
||||
fan: {
|
||||
is_off: { condition: "mdi:fan-off" },
|
||||
is_on: { condition: "mdi:fan" },
|
||||
},
|
||||
garage_door: {
|
||||
is_closed: { condition: "mdi:garage" },
|
||||
is_open: { condition: "mdi:garage-open" },
|
||||
},
|
||||
gate: {
|
||||
is_closed: { condition: "mdi:gate" },
|
||||
is_open: { condition: "mdi:gate-open" },
|
||||
},
|
||||
humidifier: {
|
||||
is_drying: { condition: "mdi:arrow-down-bold" },
|
||||
is_humidifying: { condition: "mdi:arrow-up-bold" },
|
||||
is_mode: { condition: "mdi:air-humidifier" },
|
||||
is_off: { condition: "mdi:air-humidifier-off" },
|
||||
is_on: { condition: "mdi:air-humidifier" },
|
||||
is_target_humidity: { condition: "mdi:water-percent" },
|
||||
},
|
||||
humidity: { is_value: { condition: "mdi:water-percent" } },
|
||||
illuminance: {
|
||||
is_detected: { condition: "mdi:brightness-7" },
|
||||
is_not_detected: { condition: "mdi:brightness-4" },
|
||||
is_value: { condition: "mdi:brightness-percent" },
|
||||
},
|
||||
lawn_mower: {
|
||||
is_docked: { condition: "mdi:home-import-outline" },
|
||||
is_encountering_an_error: { condition: "mdi:alert-circle-outline" },
|
||||
is_idle: { condition: "mdi:stop" },
|
||||
is_mowing: { condition: "mdi:play" },
|
||||
is_paused: { condition: "mdi:pause" },
|
||||
is_returning: { condition: "mdi:home-import-outline" },
|
||||
},
|
||||
light: {
|
||||
is_brightness: { condition: "mdi:lightbulb-on-50" },
|
||||
is_off: { condition: "mdi:lightbulb-off" },
|
||||
is_on: { condition: "mdi:lightbulb-on" },
|
||||
},
|
||||
lock: {
|
||||
is_jammed: { condition: "mdi:lock-alert" },
|
||||
is_locked: { condition: "mdi:lock" },
|
||||
is_open: { condition: "mdi:lock-open-variant" },
|
||||
is_unlocked: { condition: "mdi:lock-open-variant" },
|
||||
},
|
||||
media_player: {
|
||||
is_muted: { condition: "mdi:volume-mute" },
|
||||
is_not_playing: { condition: "mdi:stop" },
|
||||
is_off: { condition: "mdi:cast-off" },
|
||||
is_on: { condition: "mdi:cast" },
|
||||
is_paused: { condition: "mdi:pause" },
|
||||
is_playing: { condition: "mdi:play" },
|
||||
is_unmuted: { condition: "mdi:volume-high" },
|
||||
is_volume: { condition: "mdi:volume-medium" },
|
||||
},
|
||||
moisture: {
|
||||
is_detected: { condition: "mdi:water" },
|
||||
is_not_detected: { condition: "mdi:water-off" },
|
||||
is_value: { condition: "mdi:water-percent" },
|
||||
},
|
||||
moon: {
|
||||
is_phase: { condition: "mdi:moon-full" },
|
||||
is_waning: { condition: "mdi:moon-waning-crescent" },
|
||||
is_waxing: { condition: "mdi:moon-waxing-crescent" },
|
||||
},
|
||||
motion: {
|
||||
is_detected: { condition: "mdi:motion-sensor" },
|
||||
is_not_detected: { condition: "mdi:motion-sensor-off" },
|
||||
},
|
||||
occupancy: {
|
||||
is_detected: { condition: "mdi:home-account" },
|
||||
is_not_detected: { condition: "mdi:home-outline" },
|
||||
},
|
||||
power: { is_value: { condition: "mdi:flash" } },
|
||||
remote: {
|
||||
is_off: { condition: "mdi:remote-off" },
|
||||
is_on: { condition: "mdi:remote" },
|
||||
},
|
||||
schedule: {
|
||||
is_off: { condition: "mdi:calendar-blank" },
|
||||
is_on: { condition: "mdi:calendar-clock" },
|
||||
},
|
||||
select: { is_option_selected: { condition: "mdi:format-list-bulleted" } },
|
||||
siren: {
|
||||
is_off: { condition: "mdi:bullhorn-outline" },
|
||||
is_on: { condition: "mdi:bullhorn" },
|
||||
},
|
||||
sun: {
|
||||
elevation: { condition: "mdi:sun-angle" },
|
||||
is_ascending: { condition: "mdi:weather-sunset-up" },
|
||||
is_blue_hour: { condition: "mdi:weather-sunset" },
|
||||
is_descending: { condition: "mdi:weather-sunset-down" },
|
||||
is_evening_twilight: { condition: "mdi:weather-sunset-down" },
|
||||
is_golden_hour: { condition: "mdi:weather-sunset" },
|
||||
is_midnight_sun: { condition: "mdi:weather-sunny" },
|
||||
is_morning_twilight: { condition: "mdi:weather-sunset-up" },
|
||||
is_night: { condition: "mdi:weather-night" },
|
||||
is_polar_night: { condition: "mdi:weather-night" },
|
||||
is_set: { condition: "mdi:weather-sunny-off" },
|
||||
is_up: { condition: "mdi:weather-sunny" },
|
||||
},
|
||||
switch: {
|
||||
is_off: { condition: "mdi:toggle-switch-variant-off" },
|
||||
is_on: { condition: "mdi:toggle-switch-variant" },
|
||||
},
|
||||
temperature: { is_value: { condition: "mdi:thermometer" } },
|
||||
text: { is_equal_to: { condition: "mdi:form-textbox" } },
|
||||
timer: {
|
||||
is_active: { condition: "mdi:timer" },
|
||||
is_idle: { condition: "mdi:timer-off" },
|
||||
is_paused: { condition: "mdi:timer-pause" },
|
||||
},
|
||||
todo: {
|
||||
all_completed: { condition: "mdi:clipboard-check" },
|
||||
incomplete: { condition: "mdi:clipboard-alert" },
|
||||
},
|
||||
update: {
|
||||
is_available: { condition: "mdi:package-up" },
|
||||
is_not_available: { condition: "mdi:package" },
|
||||
},
|
||||
vacuum: {
|
||||
is_cleaning: { condition: "mdi:play" },
|
||||
is_docked: { condition: "mdi:home-import-outline" },
|
||||
is_encountering_an_error: { condition: "mdi:alert-circle-outline" },
|
||||
is_paused: { condition: "mdi:pause" },
|
||||
is_returning: { condition: "mdi:home-import-outline" },
|
||||
},
|
||||
valve: {
|
||||
is_closed: { condition: "mdi:valve-closed" },
|
||||
is_open: { condition: "mdi:valve-open" },
|
||||
},
|
||||
vibration: {
|
||||
is_detected: { condition: "mdi:vibrate" },
|
||||
is_not_detected: { condition: "mdi:vibrate-off" },
|
||||
},
|
||||
water_heater: {
|
||||
is_off: { condition: "mdi:water-boiler-off" },
|
||||
is_on: { condition: "mdi:water-boiler" },
|
||||
is_operation_mode: { condition: "mdi:water-boiler" },
|
||||
is_target_temperature: { condition: "mdi:thermometer" },
|
||||
},
|
||||
window: {
|
||||
is_closed: { condition: "mdi:window-closed" },
|
||||
is_open: { condition: "mdi:window-open" },
|
||||
},
|
||||
zone: {
|
||||
in_zone: { condition: "mdi:map-marker-check" },
|
||||
not_in_zone: { condition: "mdi:map-marker-remove" },
|
||||
occupancy_is_detected: { condition: "mdi:account-group" },
|
||||
occupancy_is_not_detected: { condition: "mdi:account-off" },
|
||||
},
|
||||
};
|
||||
@@ -1,25 +1,244 @@
|
||||
import type {
|
||||
EntityRegistryDisplayEntryResponse,
|
||||
EntityRegistryEntry,
|
||||
ExtEntityRegistryEntry,
|
||||
UpdateEntityRegistryEntryResult,
|
||||
} from "../../../src/data/entity/entity_registry";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
let entries: EntityRegistryEntry[] = [];
|
||||
|
||||
const newEntry = (
|
||||
entry: Pick<EntityRegistryEntry, "entity_id" | "platform" | "unique_id">
|
||||
): EntityRegistryEntry => {
|
||||
const now = Date.now() / 1000;
|
||||
return {
|
||||
config_entry_id: null,
|
||||
config_subentry_id: null,
|
||||
device_id: null,
|
||||
area_id: null,
|
||||
disabled_by: null,
|
||||
id: entry.entity_id,
|
||||
name: null,
|
||||
icon: null,
|
||||
labels: [],
|
||||
categories: {},
|
||||
hidden_by: null,
|
||||
entity_category: null,
|
||||
has_entity_name: false,
|
||||
options: null,
|
||||
created_at: now,
|
||||
modified_at: now,
|
||||
...entry,
|
||||
};
|
||||
};
|
||||
|
||||
// The registry settings that the display registry holds. A display entry
|
||||
// without a registry name shows the name of its state.
|
||||
const displayFields = (entry: EntityRegistryEntry) => ({
|
||||
...(entry.name && { name: entry.name }),
|
||||
icon: entry.icon ?? undefined,
|
||||
area_id: entry.area_id ?? undefined,
|
||||
labels: entry.labels,
|
||||
hidden: entry.hidden_by !== null,
|
||||
});
|
||||
|
||||
const extEntry = (entry: EntityRegistryEntry): ExtEntityRegistryEntry => ({
|
||||
...entry,
|
||||
capabilities: {},
|
||||
aliases: [],
|
||||
});
|
||||
|
||||
export const mockEntityRegistry = (
|
||||
hass: MockHomeAssistant,
|
||||
data: EntityRegistryEntry[] = []
|
||||
) => {
|
||||
hass.mockWS("config/entity_registry/list", () => data);
|
||||
entries = [...data];
|
||||
// A new array, so subscribers see the registry changed after an update
|
||||
hass.mockWS("config/entity_registry/list", () => [...entries]);
|
||||
hass.mockWS(
|
||||
"config/entity_registry/get_entries",
|
||||
(msg: { entity_ids: string[] }) => {
|
||||
const result: Record<string, ExtEntityRegistryEntry> = {};
|
||||
for (const entityId of msg.entity_ids) {
|
||||
const entry = data.find((e) => e.entity_id === entityId);
|
||||
const entry = entries.find((e) => e.entity_id === entityId);
|
||||
if (entry) {
|
||||
result[entityId] = { ...entry, capabilities: {}, aliases: [] };
|
||||
result[entityId] = extEntry(entry);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
);
|
||||
hass.mockWS(
|
||||
"config/entity_registry/update",
|
||||
(
|
||||
msg: Pick<
|
||||
EntityRegistryEntry,
|
||||
| "entity_id"
|
||||
| "name"
|
||||
| "icon"
|
||||
| "area_id"
|
||||
| "labels"
|
||||
| "categories"
|
||||
| "hidden_by"
|
||||
| "disabled_by"
|
||||
> & { new_entity_id?: string },
|
||||
currentHass: MockHomeAssistant
|
||||
): UpdateEntityRegistryEntryResult => {
|
||||
const {
|
||||
entity_id: entityId,
|
||||
new_entity_id: newEntityId,
|
||||
...updates
|
||||
} = msg;
|
||||
// Renaming would have to move the entity in every mock, so refuse it
|
||||
// instead of reporting a rename that did not happen
|
||||
if (newEntityId && newEntityId !== entityId) {
|
||||
throw new Error("Changing the entity ID is not supported in the demo");
|
||||
}
|
||||
// The demo entities without an entry get one, like any core entity has
|
||||
const entry = {
|
||||
...(entries.find((e) => e.entity_id === entityId) ??
|
||||
newEntry({
|
||||
entity_id: entityId,
|
||||
platform: currentHass.entities[entityId]?.platform ?? "demo",
|
||||
unique_id: entityId,
|
||||
})),
|
||||
...Object.fromEntries(
|
||||
Object.entries(updates).filter(([key]) => key !== "type")
|
||||
),
|
||||
modified_at: Date.now() / 1000,
|
||||
};
|
||||
entries = [...entries.filter((e) => e.entity_id !== entityId), entry];
|
||||
// Core updates the display registry too, only change what was updated
|
||||
const display = currentHass.entities[entityId];
|
||||
if (display) {
|
||||
const fields = displayFields(entry);
|
||||
currentHass.updateHass({
|
||||
entities: {
|
||||
...currentHass.entities,
|
||||
[entityId]: {
|
||||
...display,
|
||||
...("name" in updates && { name: fields.name }),
|
||||
...("icon" in updates && { icon: fields.icon }),
|
||||
...("area_id" in updates && { area_id: fields.area_id }),
|
||||
...("labels" in updates && { labels: fields.labels }),
|
||||
...("hidden_by" in updates && { hidden: fields.hidden }),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
currentHass.mockEvent("entity_registry_updated");
|
||||
return { entity_entry: extEntry(entry) };
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Entries of a platform that were replaced, so their settings are restored
|
||||
// when the entity comes back after switching demos
|
||||
const retiredEntries = new Map<string, EntityRegistryEntry>();
|
||||
|
||||
// Replaces the entries of a platform, like core does for the entities of a
|
||||
// demo config
|
||||
export const setPlatformEntityRegistryEntries = (
|
||||
hass: MockHomeAssistant,
|
||||
platform: string,
|
||||
platformEntries: Pick<EntityRegistryEntry, "entity_id" | "unique_id">[]
|
||||
) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.platform === platform) {
|
||||
retiredEntries.set(`${platform}.${entry.unique_id}`, entry);
|
||||
}
|
||||
}
|
||||
const restored: EntityRegistryEntry[] = [];
|
||||
const platformRegistryEntries = platformEntries.map((entry) => {
|
||||
const retired = retiredEntries.get(`${platform}.${entry.unique_id}`);
|
||||
if (!retired) {
|
||||
return newEntry({ ...entry, platform });
|
||||
}
|
||||
const restoredEntry = { ...retired, entity_id: entry.entity_id };
|
||||
restored.push(restoredEntry);
|
||||
return restoredEntry;
|
||||
});
|
||||
entries = [
|
||||
...entries.filter((e) => e.platform !== platform),
|
||||
...platformRegistryEntries,
|
||||
];
|
||||
// The display registry was rebuilt with the entities, restore its settings
|
||||
if (restored.length) {
|
||||
const entities = { ...hass.entities };
|
||||
for (const entry of restored) {
|
||||
if (entities[entry.entity_id]) {
|
||||
entities[entry.entity_id] = {
|
||||
...entities[entry.entity_id],
|
||||
...displayFields(entry),
|
||||
};
|
||||
}
|
||||
}
|
||||
hass.updateHass({ entities });
|
||||
}
|
||||
hass.mockEvent("entity_registry_updated");
|
||||
};
|
||||
|
||||
export const getEntityRegistryEntry = (entityId: string) =>
|
||||
entries.find((e) => e.entity_id === entityId);
|
||||
|
||||
export const removeEntityRegistryEntry = (
|
||||
hass: MockHomeAssistant,
|
||||
entityId: string
|
||||
) => {
|
||||
entries = entries.filter((e) => e.entity_id !== entityId);
|
||||
hass.mockEvent("entity_registry_updated");
|
||||
};
|
||||
|
||||
// Adds an entry like core does when an integration creates an entity
|
||||
export const addEntityRegistryEntry = (
|
||||
hass: MockHomeAssistant,
|
||||
entry: Pick<EntityRegistryEntry, "entity_id" | "platform" | "unique_id">
|
||||
) => {
|
||||
entries = [
|
||||
...entries.filter((e) => e.entity_id !== entry.entity_id),
|
||||
newEntry(entry),
|
||||
];
|
||||
hass.mockEvent("entity_registry_updated");
|
||||
};
|
||||
|
||||
// The app refetches the display registry on entity_registry_updated events.
|
||||
// Answer with the current display registry, which the demo keeps in hass.
|
||||
export const mockEntityRegistryDisplay = (hass: MockHomeAssistant) =>
|
||||
hass.mockWS(
|
||||
"config/entity_registry/list_for_display",
|
||||
(
|
||||
_msg,
|
||||
currentHass: MockHomeAssistant
|
||||
): EntityRegistryDisplayEntryResponse => {
|
||||
const categories = [
|
||||
...new Set(
|
||||
Object.values(currentHass.entities)
|
||||
.map((entity) => entity.entity_category)
|
||||
.filter((category) => category !== undefined)
|
||||
),
|
||||
];
|
||||
return {
|
||||
entities: Object.values(currentHass.entities).map((entity) => ({
|
||||
ei: entity.entity_id,
|
||||
di: entity.device_id,
|
||||
ai: entity.area_id,
|
||||
np: entity.next_name_part,
|
||||
lb: entity.labels,
|
||||
ec: entity.entity_category
|
||||
? categories.indexOf(entity.entity_category)
|
||||
: undefined,
|
||||
en: entity.name,
|
||||
ic: entity.icon,
|
||||
pl: entity.platform ?? "demo",
|
||||
tk: entity.translation_key,
|
||||
hb: entity.hidden,
|
||||
dp: entity.display_precision,
|
||||
hn: entity.has_entity_name,
|
||||
})),
|
||||
entity_categories: Object.fromEntries(
|
||||
categories.map((category, index) => [index, category])
|
||||
),
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -12,6 +12,17 @@ export const mockIcons = (hass: MockHomeAssistant) => {
|
||||
category: IconCategory;
|
||||
integration?: string;
|
||||
}) => {
|
||||
if (category === "triggers" || category === "conditions") {
|
||||
const { triggerIcons, conditionIcons } =
|
||||
await import("./automation_platforms_icons");
|
||||
const icons: Record<string, unknown> =
|
||||
category === "triggers" ? triggerIcons : conditionIcons;
|
||||
return {
|
||||
resources: integration
|
||||
? { [integration]: icons[integration] ?? {} }
|
||||
: icons,
|
||||
};
|
||||
}
|
||||
if (integration) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { LabelRegistryEntry } from "../../../src/data/label/label_registry";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
let labels: LabelRegistryEntry[] = [];
|
||||
|
||||
export const getLabelIds = () => labels.map((label) => label.label_id);
|
||||
|
||||
export const mockLabelRegistry = (
|
||||
hass: MockHomeAssistant,
|
||||
data: LabelRegistryEntry[] = []
|
||||
) => hass.mockWS("config/label_registry/list", () => data);
|
||||
) => {
|
||||
labels = data;
|
||||
hass.mockWS("config/label_registry/list", () => data);
|
||||
};
|
||||
+1
-1
@@ -290,7 +290,7 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [".github/scripts/*.mts"],
|
||||
files: [".github/scripts/*.mjs"],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
|
||||
@@ -101,6 +101,7 @@ export default [
|
||||
"entity-button-card",
|
||||
"entity-filter-card",
|
||||
"glance-card",
|
||||
"sensor-card",
|
||||
"tile-card",
|
||||
"area-card",
|
||||
],
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
<script type="module" src="<%= entry %>"></script>
|
||||
<% } %>
|
||||
<style>
|
||||
html {
|
||||
/* Prevent iOS Safari from inflating text when rotating to landscape */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
body {
|
||||
font-family: Roboto, Noto, sans-serif;
|
||||
margin: 0;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
---
|
||||
title: Sensor card
|
||||
---
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import { mockHistory } from "../../../../demo/src/stubs/history";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { SensorCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import "../../components/demo-cards";
|
||||
|
||||
const ENTITIES = [
|
||||
{
|
||||
entity_id: "sensor.outside_temperature",
|
||||
state: "15.6",
|
||||
attributes: {
|
||||
friendly_name: "Outside temperature",
|
||||
device_class: "temperature",
|
||||
unit_of_measurement: "°C",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.outside_humidity",
|
||||
state: "54",
|
||||
attributes: {
|
||||
friendly_name: "Outside humidity",
|
||||
device_class: "humidity",
|
||||
unit_of_measurement: "%",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.phone_battery",
|
||||
state: "18",
|
||||
attributes: {
|
||||
friendly_name: "Phone battery",
|
||||
device_class: "battery",
|
||||
unit_of_measurement: "%",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.not_working",
|
||||
state: "unavailable",
|
||||
attributes: {
|
||||
friendly_name: "Not working",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Basic example",
|
||||
config: {
|
||||
type: "sensor",
|
||||
entity: "sensor.outside_temperature",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "With line graph",
|
||||
config: {
|
||||
type: "sensor",
|
||||
entity: "sensor.outside_temperature",
|
||||
graph: "line",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "With graph detail and hours to show",
|
||||
config: {
|
||||
type: "sensor",
|
||||
entity: "sensor.outside_humidity",
|
||||
graph: "line",
|
||||
detail: 2,
|
||||
hours_to_show: 6,
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "With state color",
|
||||
config: {
|
||||
type: "sensor",
|
||||
entity: "sensor.phone_battery",
|
||||
icon: "mdi:battery-20",
|
||||
graph: "line",
|
||||
state_color: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "With name, icon and unit",
|
||||
config: {
|
||||
type: "sensor",
|
||||
entity: "sensor.outside_humidity",
|
||||
name: "Humidity",
|
||||
icon: "mdi:water",
|
||||
unit: "pct",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Without tap action (not focusable)",
|
||||
config: {
|
||||
type: "sensor",
|
||||
entity: "sensor.outside_temperature",
|
||||
tap_action: { action: "none" },
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Unavailable",
|
||||
config: {
|
||||
type: "sensor",
|
||||
entity: "sensor.not_working",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Invalid entity",
|
||||
config: {
|
||||
type: "sensor",
|
||||
entity: "sensor.invalid_entity",
|
||||
},
|
||||
},
|
||||
] satisfies DemoCardConfig<SensorCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-sensor-card")
|
||||
class DemoSensorCard extends LitElement {
|
||||
@query("#demos") private _demoRoot!: HTMLElement;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`<demo-cards id="demos" .configs=${CONFIGS}></demo-cards>`;
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProperties);
|
||||
const hass = provideHass(this._demoRoot);
|
||||
hass.updateTranslations(null, "en");
|
||||
hass.updateTranslations("lovelace", "en");
|
||||
hass.updateHass({
|
||||
config: {
|
||||
...hass.config,
|
||||
components: [...hass.config.components, "history"],
|
||||
},
|
||||
});
|
||||
hass.addEntities(ENTITIES);
|
||||
mockIcons(hass);
|
||||
mockHistory(hass);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-lovelace-sensor-card": DemoSensorCard;
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -12,7 +12,7 @@
|
||||
"format:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --fix",
|
||||
"lint:prettier": "prettier . --cache --check",
|
||||
"format:prettier": "prettier . --cache --write",
|
||||
"lint:types": "node ./node_modules/@typescript/native/bin/tsc && node ./node_modules/@typescript/native/bin/tsc -p .github/scripts",
|
||||
"lint:types": "node ./node_modules/@typescript/native/bin/tsc",
|
||||
"lint:lit": "lit-analyzer \"{.,*}/src/**/*.ts\"",
|
||||
"lint:licenses": "node --no-deprecation script/check-licenses",
|
||||
"lint": "pnpm run lint:eslint && pnpm run lint:prettier && pnpm run lint:types && pnpm run lint:lit",
|
||||
@@ -69,7 +69,7 @@
|
||||
"@fullcalendar/timegrid": "6.1.21",
|
||||
"@home-assistant/webawesome": "3.7.0-ha.1",
|
||||
"@lezer/common": "1.5.3",
|
||||
"@lezer/highlight": "1.2.5",
|
||||
"@lezer/highlight": "1.2.4",
|
||||
"@lit-labs/motion": "1.1.0",
|
||||
"@lit-labs/observers": "2.1.0",
|
||||
"@lit-labs/virtualizer": "2.1.1",
|
||||
@@ -87,7 +87,7 @@
|
||||
"@swc/helpers": "0.5.23",
|
||||
"@tsparticles/engine": "4.4.0",
|
||||
"@tsparticles/preset-links": "4.4.0",
|
||||
"@versatiles/style": "6.0.2",
|
||||
"@versatiles/style": "6.0.1",
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@vvo/tzdb": "6.198.0",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
@@ -106,7 +106,7 @@
|
||||
"element-internals-polyfill": "3.0.2",
|
||||
"fuse.js": "7.5.0",
|
||||
"hls.js": "1.7.3",
|
||||
"home-assistant-js-websocket": "9.7.0",
|
||||
"home-assistant-js-websocket": "9.7.1",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "12.1.2",
|
||||
"js-yaml": "5.4.2",
|
||||
@@ -129,7 +129,7 @@
|
||||
"sortablejs": "1.15.6",
|
||||
"stacktrace-js": "2.0.2",
|
||||
"superstruct": "2.0.2",
|
||||
"tinykeys": "4.0.1",
|
||||
"tinykeys": "4.0.0",
|
||||
"weekstart": "2.0.0",
|
||||
"workbox-cacheable-response": "7.4.1",
|
||||
"workbox-core": "7.4.1",
|
||||
@@ -227,7 +227,7 @@
|
||||
"webpackbar": "7.0.0",
|
||||
"workbox-build": "7.4.1"
|
||||
},
|
||||
"packageManager": "pnpm@12.8.0",
|
||||
"packageManager": "pnpm@11.28.0",
|
||||
"volta": {
|
||||
"node": "24.21.0"
|
||||
}
|
||||
|
||||
File renamed without changes.
Generated
+29
-186
@@ -1,161 +1,3 @@
|
||||
---
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
configDependencies: {}
|
||||
packageManagerDependencies:
|
||||
pnpm:
|
||||
specifier: 12.8.0
|
||||
version: 12.8.0
|
||||
|
||||
packages:
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
resolution: {integrity: sha512-gilfZS0Aae1fXlh6WJVCxlB4t0Rq/kOjowRSKTeVuUGHD/Agn91j/wH9V000NDKf4D6IEzGuWbnBPf6teGM64w==}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
resolution: {integrity: sha512-sVUvq8Jg1wINrtSxCoYfyYs1uKQ6NWjkPWWsjIznmyLX/iXywBY1GJtQ2cwfCbzzsy74L/VFkK78d/oeejUecg==}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
resolution: {integrity: sha512-EaEGvBt4GzKKoH6MdzUHkwL/+FW8q1/JLJwtbYmn/dYsrADyIwaZuHmwRiCUUCDlEsk/yGjhrTuhPOwJno78pw==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
resolution: {integrity: sha512-bB7uMzzb3KPegc/hRtyf1fmbKXifj6t6Fp4YDzs59fOVK2s+2LDohN0EY2Dkw4QAL9dn5BIJwPeZtLTV16g1Kg==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
resolution: {integrity: sha512-r1HPVjrZYzLSr2oEieUimJt+gbkHvaXW9nsJeVtAbRxS+t72bsKLRe1GGtuyx6yF76VR4pcB/tdcumkHaMCWbA==}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
resolution: {integrity: sha512-UbCu3XOpG6CDMX3MXAf4IF3XQatlVOLwNkU3ltrboiNdBHD1+7bqvUWfCMLWMZmZDFZJwH7V4sPJmDIE6N/3UQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
resolution: {integrity: sha512-jVcRvNCF/lSD5N43XQImbspPiqUXB7WzNCtPeAG0u+K4KlKahMC0dwFajZL5kolRUzuxZUZIM9KdzuqM0XxmeQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
resolution: {integrity: sha512-lO+mX8b6ixVJv5fb1/2SZU+5HAwmn/kEbALESYN7H7J73f1lkEijIck3ny2kL/mtX5Bh9cJsDydn+vXCM8hvgQ==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
resolution: {integrity: sha512-3U1LrGcMY16F++DhjCKHm+miIcW4DOjjkyQ2Yitg3z8xEG8KR+9ZEAMwrv3tGVvsY8nesdQKCnkWBOMsVLkeCw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
resolution: {integrity: sha512-f99oXQsr63TL/h6/VAh/cE7KEsyQCVFgG8eNfyiBlLpehwnufo/FSvWudMEFyNA6fpG+dIGRlEnGVI8eVEz5gQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
resolution: {integrity: sha512-Citic9qoi/INXqjLgxikqODVT5FKNa5uLHdIoNOQZq8i4dFPFD7vkY2cbzqS5hExrrWTdjaKp63Vk/W9VXte5g==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
resolution: {integrity: sha512-oTX7gtrsyXl1vuTBOoZoK3ouv5cEIKqgbSAVS0Cv4yr2HbR6VHG1Sy2CeO1sXeSBGJ4xTv3HYy3Dw+Xf1nNLWQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
resolution: {integrity: sha512-vU7Zpxyvnj5Rqjj6rXDVwJOdLaQtsII54H3LmCXR1rhl9TQ/J9wrB+lH9ypvVL8iURlqM/3dA3TMqM/PRkqE/A==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
resolution: {integrity: sha512-QS1bgThRBVb8e2BjCkk/i+OohAgnRB85I/yfyyXe+mFDstdQWztx50D/AEOVdhVscUbCbIJTUUObVLczdEWLRg==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ORbpVZzfvd99ySHomZB2WEQlFh3nc/bKv39HRn8NlFK/qJponsJFdTrT1Vq8s89wbmZ6wSBi28nGJfvXK8og3A==}
|
||||
engines: {node: '>=18.*'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@pnpm/[email protected]':
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
optionalDependencies:
|
||||
'@pnpm/exe.android-arm64': 12.8.0
|
||||
'@pnpm/exe.android-x64': 12.8.0
|
||||
'@pnpm/exe.darwin-arm64': 12.8.0
|
||||
'@pnpm/exe.darwin-x64': 12.8.0
|
||||
'@pnpm/exe.freebsd-x64': 12.8.0
|
||||
'@pnpm/exe.linux-arm64': 12.8.0
|
||||
'@pnpm/exe.linux-arm64-musl': 12.8.0
|
||||
'@pnpm/exe.linux-ppc64': 12.8.0
|
||||
'@pnpm/exe.linux-riscv64': 12.8.0
|
||||
'@pnpm/exe.linux-s390x': 12.8.0
|
||||
'@pnpm/exe.linux-x64': 12.8.0
|
||||
'@pnpm/exe.linux-x64-musl': 12.8.0
|
||||
'@pnpm/exe.win32-arm64': 12.8.0
|
||||
'@pnpm/exe.win32-x64': 12.8.0
|
||||
|
||||
---
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
@@ -175,7 +17,7 @@ patchedDependencies:
|
||||
'@material/[email protected]': 9450e509a86887d8f68ca9cf3f81e6ed70e94e49de13c5f6f20f94519ad4a987
|
||||
[email protected]: b02e30d0fb3e2b865321c17ca1234593db0d05a1365cb6c932cd47c0e673fca0
|
||||
[email protected]: f2ec2ae6877dfc201c542b4f9ded1d03831258bf328ae7bba876d14ce4508a06
|
||||
[email protected].1: cdc7ece96d53357d38ff3c124a2553f86e3a5dc333d641d223eb6f07e28275f0
|
||||
[email protected].0: cdc7ece96d53357d38ff3c124a2553f86e3a5dc333d641d223eb6f07e28275f0
|
||||
[email protected]: 75e6346ad8d8af554c167f77ac7d9b9652f010398efdc6b399925a4795133099
|
||||
|
||||
importers:
|
||||
@@ -273,8 +115,8 @@ importers:
|
||||
specifier: 1.5.3
|
||||
version: 1.5.3
|
||||
'@lezer/highlight':
|
||||
specifier: 1.2.5
|
||||
version: 1.2.5
|
||||
specifier: 1.2.4
|
||||
version: 1.2.4
|
||||
'@lit-labs/motion':
|
||||
specifier: 1.1.0
|
||||
version: 1.1.0
|
||||
@@ -327,8 +169,8 @@ importers:
|
||||
specifier: 4.4.0
|
||||
version: 4.4.0
|
||||
'@versatiles/style':
|
||||
specifier: 6.0.2
|
||||
version: 6.0.2([email protected])
|
||||
specifier: 6.0.1
|
||||
version: 6.0.1([email protected])
|
||||
'@vibrant/color':
|
||||
specifier: 4.0.4
|
||||
version: 4.0.4
|
||||
@@ -384,8 +226,8 @@ importers:
|
||||
specifier: 1.7.3
|
||||
version: 1.7.3
|
||||
home-assistant-js-websocket:
|
||||
specifier: 9.7.0
|
||||
version: 9.7.0
|
||||
specifier: 9.7.1
|
||||
version: 9.7.1
|
||||
idb-keyval:
|
||||
specifier: 6.3.0
|
||||
version: 6.3.0
|
||||
@@ -453,8 +295,8 @@ importers:
|
||||
specifier: 2.0.2
|
||||
version: 2.0.2
|
||||
tinykeys:
|
||||
specifier: 4.0.1
|
||||
version: 4.0.1(patch_hash=cdc7ece96d53357d38ff3c124a2553f86e3a5dc333d641d223eb6f07e28275f0)
|
||||
specifier: 4.0.0
|
||||
version: 4.0.0(patch_hash=cdc7ece96d53357d38ff3c124a2553f86e3a5dc333d641d223eb6f07e28275f0)
|
||||
weekstart:
|
||||
specifier: 2.0.0
|
||||
version: 2.0.0
|
||||
@@ -2194,8 +2036,8 @@ packages:
|
||||
'@lezer/[email protected]':
|
||||
resolution: {integrity: sha512-YJE78Wcg+zX8f10hiHWQ4Az48Qr/c13eId0VtRQYLBpxHDmDeSrXIlkbl+fJGW42rWC/uoUco9mhBZeVWP/A1g==}
|
||||
|
||||
'@lezer/[email protected].5':
|
||||
resolution: {integrity: sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==}
|
||||
'@lezer/[email protected].4':
|
||||
resolution: {integrity: sha512-4UeRVFO0lsBaM7GA32zOqbmqNPme9jedo8Ey+5c5656MU3MLitMLIA8aCZTYjUMEpEsuSeXEm78Uea6Iq3bvSg==}
|
||||
|
||||
'@lezer/[email protected]':
|
||||
resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==}
|
||||
@@ -3704,8 +3546,8 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@versatiles/[email protected].2':
|
||||
resolution: {integrity: sha512-NjAwDDMxguNFqiq23DsK+I3i+YY5CkfV3wVhaRAYXj40+6WMKWFgZEDN4hVOYe/NiEAx40GV7tgeij1GaqPrAg==}
|
||||
'@versatiles/[email protected].1':
|
||||
resolution: {integrity: sha512-Zw/6C+bNFCheoprcSihkGaLVWrrfgFq6UyHhBbEZZ4SP7WfCryWNB0CDtuWof+6/3UA7SXhtc/GM2a7c2V3lKg==}
|
||||
engines: {node: '>=20'}
|
||||
peerDependencies:
|
||||
maplibre-gl: '>=5.0.0'
|
||||
@@ -5212,8 +5054,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==}
|
||||
|
||||
[email protected].0:
|
||||
resolution: {integrity: sha512-AE/VtwY+sKi7B3934KuIgvj2g06QY9aFVdd2zh1r5ygbpMo2TZbvN9Zoma8L2wHNYFsngTRiZxYOnLlCysIOzA==}
|
||||
[email protected].1:
|
||||
resolution: {integrity: sha512-zOAiyF+gUc/IsiswYdWKns6SCBbvWb8iJtMX9+Dk0wlbdfcp+I38T2Vx36krWeWMk/GPwoSiYwCs5hk/+Hsn7w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==}
|
||||
@@ -5672,6 +5514,7 @@ packages:
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==}
|
||||
engines: {'0': node >= 0.2.0}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==}
|
||||
@@ -7166,8 +7009,8 @@ packages:
|
||||
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
[email protected].1:
|
||||
resolution: {integrity: sha512-rClxhLQl+I5UMB5Ny7sfnZqDNa51u7h7h4gJV2UWslwADHEw75IuVPCnWrpO/otM7jHrHpwcZdRHd+//SiI55A==}
|
||||
[email protected].0:
|
||||
resolution: {integrity: sha512-z5bQRQHL1PSx3lGOZEAMM2oKp0EBtn5T5f7HJnaKPOzk6vEH0wUPvdHLeQ7F4tmkek8AgoTQISUFmn/5SNF2xA==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
[email protected]:
|
||||
@@ -9178,7 +9021,7 @@ snapshots:
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.13
|
||||
'@lezer/common': 1.5.3
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/highlight': 1.2.4
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@codemirror/[email protected]':
|
||||
@@ -9187,7 +9030,7 @@ snapshots:
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.6
|
||||
'@lezer/common': 1.5.3
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/highlight': 1.2.4
|
||||
'@lezer/lr': 1.4.10
|
||||
'@lezer/yaml': 1.0.4
|
||||
|
||||
@@ -9196,7 +9039,7 @@ snapshots:
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.13
|
||||
'@lezer/common': 1.5.3
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/highlight': 1.2.4
|
||||
'@lezer/lr': 1.4.10
|
||||
style-mod: 4.1.3
|
||||
|
||||
@@ -9667,23 +9510,23 @@ snapshots:
|
||||
'@lezer/[email protected]':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.3
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/highlight': 1.2.4
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@lezer/[email protected].5':
|
||||
'@lezer/[email protected].4':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.3
|
||||
|
||||
'@lezer/[email protected]':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.3
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/highlight': 1.2.4
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@lezer/[email protected]':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.3
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/highlight': 1.2.4
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@lezer/[email protected]':
|
||||
@@ -9693,7 +9536,7 @@ snapshots:
|
||||
'@lezer/[email protected]':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.3
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/highlight': 1.2.4
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@lit-labs/[email protected]':
|
||||
@@ -11270,7 +11113,7 @@ snapshots:
|
||||
'@unrs/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@versatiles/[email protected].2([email protected])':
|
||||
'@versatiles/[email protected].1([email protected])':
|
||||
dependencies:
|
||||
'@maplibre/maplibre-gl-style-spec': 26.4.4
|
||||
optionalDependencies:
|
||||
@@ -12927,7 +12770,7 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected].0: {}
|
||||
[email protected].1: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
@@ -15040,7 +14883,7 @@ snapshots:
|
||||
fdir: 6.5.0([email protected])
|
||||
picomatch: 4.0.7
|
||||
|
||||
[email protected].1(patch_hash=cdc7ece96d53357d38ff3c124a2553f86e3a5dc333d641d223eb6f07e28275f0): {}
|
||||
[email protected].0(patch_hash=cdc7ece96d53357d38ff3c124a2553f86e3a5dc333d641d223eb6f07e28275f0): {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
|
||||
+4
-1
@@ -3,6 +3,9 @@ autoInstallPeers: false
|
||||
|
||||
# Supply-chain protection
|
||||
minimumReleaseAge: 4320 # 3 days, in minutes
|
||||
minimumReleaseAgeExclude:
|
||||
# Our own packages, published from our own CI
|
||||
- home-assistant-js-websocket
|
||||
trustPolicy: no-downgrade
|
||||
trustPolicyExclude:
|
||||
# Backports published after provenance-signed 7.x releases
|
||||
@@ -30,5 +33,5 @@ patchedDependencies:
|
||||
"@material/[email protected]": patches/@[email protected]
|
||||
[email protected]: patches/[email protected]
|
||||
[email protected]: patches/[email protected]
|
||||
[email protected].1: patches/[email protected].1.patch
|
||||
[email protected].0: patches/[email protected].0.patch
|
||||
[email protected]: patches/[email protected]
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20260930.0"
|
||||
version = "20260930.5"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Safe bash settings
|
||||
# -e Exit on command fail
|
||||
# -u Exit on unset variable
|
||||
# -o pipefail Exit if piped command has error code
|
||||
set -eu -o pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
./node_modules/.bin/gulp gen-demo-core-data
|
||||
@@ -39,3 +39,14 @@ export const hexBlend = (c1: string, c2: string, blend = 50): string => {
|
||||
}
|
||||
return `#${color}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a CSS color, like a color name, to `#rrggbb`.
|
||||
* @param color - Any CSS color (name, hex, rgb(), hsl() and so on).
|
||||
* @returns The hex color, or undefined for invalid colors and colors with
|
||||
* transparency.
|
||||
*/
|
||||
export const cssColorToHex = (color: string): string | undefined => {
|
||||
const parsed = parse(color.trim());
|
||||
return parsed && (parsed.alpha ?? 1) === 1 ? formatHex(parsed) : undefined;
|
||||
};
|
||||
@@ -66,7 +66,7 @@ export class ScrollFadeController {
|
||||
if (!this._element) {
|
||||
return;
|
||||
}
|
||||
const rtl = this._element.matches(":dir(rtl)");
|
||||
const rtl = getComputedStyle(this._element).direction === "rtl";
|
||||
const scrolled = rtl ? -this._element.scrollLeft : this._element.scrollLeft;
|
||||
const maxScroll = this._element.scrollWidth - this._element.clientWidth;
|
||||
const start = scrolled > 1;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
const SWIPE_LOCKED_COMPONENTS = new Set([
|
||||
"ha-control-slider",
|
||||
"ha-slider",
|
||||
"ha-control-switch",
|
||||
"ha-control-circular-slider",
|
||||
"ha-hs-color-picker",
|
||||
"ha-map",
|
||||
"ha-more-info-control-select-container",
|
||||
"ha-filter-chip",
|
||||
]);
|
||||
|
||||
const SWIPE_LOCKED_CLASSES = new Set(["volume-slider-container", "forecast"]);
|
||||
|
||||
/**
|
||||
* Whether a touch inside a bottom sheet belongs to its content rather than to
|
||||
* the sheet: it started in something scrolled away from its top, or in a
|
||||
* control that drags on its own. Only the path up to the sheet is checked.
|
||||
*/
|
||||
export const isSwipeLocked = (
|
||||
path: EventTarget[],
|
||||
sheet: EventTarget
|
||||
): boolean => {
|
||||
for (const target of path) {
|
||||
if (target === sheet) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
target instanceof HTMLElement &&
|
||||
(target.scrollTop > 0 ||
|
||||
SWIPE_LOCKED_COMPONENTS.has(target.localName) ||
|
||||
Array.from(target.classList).some((cls) =>
|
||||
SWIPE_LOCKED_CLASSES.has(cls)
|
||||
))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
@@ -17,7 +17,7 @@ import { capitalizeFirstLetter } from "../string/capitalize-first-letter";
|
||||
import { isDate } from "../string/is_date";
|
||||
import { isTimestamp } from "../string/is_timestamp";
|
||||
import { blankBeforeUnit } from "../translations/blank_before_unit";
|
||||
import type { LocalizeFunc } from "../translations/localize";
|
||||
import type { LocalizeFunc, LocalizeKeys } from "../translations/localize";
|
||||
import { computeDomain } from "./compute_domain";
|
||||
import { computeStateDomain } from "./compute_state_domain";
|
||||
|
||||
@@ -198,6 +198,8 @@ export const computeAttributeNameDisplay = (
|
||||
localize(
|
||||
`component.${domain}.entity_component._.state_attributes.${attribute}.name`
|
||||
) ||
|
||||
// Attributes every entity can have, which no integration translates
|
||||
localize(`ui.components.attributes.names.${attribute}` as LocalizeKeys) ||
|
||||
capitalizeFirstLetter(
|
||||
attribute
|
||||
.replace(/_/g, " ")
|
||||
|
||||
@@ -6,7 +6,10 @@ import type {
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { computeDeviceName } from "./compute_device_name";
|
||||
import { computeStateName } from "./compute_state_name";
|
||||
import { stripPrefixFromEntityName } from "./strip_prefix_from_entity_name";
|
||||
import {
|
||||
isDeviceName,
|
||||
stripPrefixFromEntityName,
|
||||
} from "./strip_prefix_from_entity_name";
|
||||
|
||||
export const computeEntityName = (
|
||||
stateObj: HassEntity,
|
||||
@@ -48,8 +51,12 @@ export const computeEntityEntryName = (
|
||||
|
||||
const deviceName = computeDeviceName(device);
|
||||
|
||||
// An empty entity name or one equal to the device name means the entity uses the device name
|
||||
if (!name || deviceName === name) {
|
||||
if (entry.name == null || entry.next_name_part !== "device") {
|
||||
return name || undefined;
|
||||
}
|
||||
|
||||
// An empty entity name or one that is only the device name means the entity uses the device name
|
||||
if (!name || (deviceName && isDeviceName(name, deviceName))) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,4 +30,19 @@ export const stripPrefixFromEntityName = (
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether an entity name is only the device name, which core treats as no name of its own.
|
||||
*/
|
||||
export const isDeviceName = (entityName: string, deviceName: string) => {
|
||||
if (!deviceName) {
|
||||
return false;
|
||||
}
|
||||
const lowerCasedEntityName = entityName.toLowerCase();
|
||||
const lowerCasedDeviceName = deviceName.toLowerCase();
|
||||
return (
|
||||
lowerCasedEntityName.startsWith(lowerCasedDeviceName) &&
|
||||
/^[ :-]*$/.test(lowerCasedEntityName.slice(lowerCasedDeviceName.length))
|
||||
);
|
||||
};
|
||||
|
||||
const hasUpperCase = (str: string): boolean => str.toLowerCase() !== str;
|
||||
@@ -106,6 +106,22 @@ const useDemoUpstream = (style: StyleSpecification): StyleSpecification => {
|
||||
// MapLibre rejects a relative sprite URL
|
||||
const absoluteSprite = (url: string) => new URL(url, location.href).href;
|
||||
|
||||
// Shared only while in flight, or a style that failed once would never be
|
||||
// fetched again. Text rather than a parsed style: MapLibre mutates what it
|
||||
// is handed.
|
||||
const pendingStyles = new Map<string, Promise<string>>();
|
||||
|
||||
const fetchStyle = (url: string): Promise<string> => {
|
||||
let pending = pendingStyles.get(url);
|
||||
if (!pending) {
|
||||
pending = fetch(url)
|
||||
.then((response) => response.text())
|
||||
.finally(() => pendingStyles.delete(url));
|
||||
pendingStyles.set(url, pending);
|
||||
}
|
||||
return pending;
|
||||
};
|
||||
|
||||
export const loadStyle = async (
|
||||
mapStyle: ResolvedMapStyle
|
||||
): Promise<StyleSpecification> => {
|
||||
@@ -114,7 +130,7 @@ export const loadStyle = async (
|
||||
// Anything that does not ship is built here, from the same builder: its own
|
||||
// chunk, so a map on the default style never downloads it.
|
||||
const style: StyleSpecification = shipped
|
||||
? await (await fetch(shipped)).json()
|
||||
? JSON.parse(await fetchStyle(shipped))
|
||||
: await (
|
||||
await import("./build-map-style")
|
||||
).buildMapStyle(
|
||||
@@ -204,6 +220,9 @@ const createVectorLayer = async (
|
||||
let latestRequest = 0;
|
||||
let vector = true;
|
||||
let refused = false;
|
||||
// A fallback hands this to the raster layer, and a rotation that already
|
||||
// happened is not announced again.
|
||||
let currentToken = token;
|
||||
|
||||
const glMap = layer.getMaplibreMap();
|
||||
let fallbackTimeout: number | undefined;
|
||||
@@ -224,7 +243,7 @@ const createVectorLayer = async (
|
||||
} catch {
|
||||
// Nothing left to detach.
|
||||
}
|
||||
createRasterLayer(leaflet, map, token);
|
||||
createRasterLayer(leaflet, map, currentToken);
|
||||
};
|
||||
|
||||
const scheduleSwap = () => {
|
||||
@@ -291,7 +310,8 @@ const createVectorLayer = async (
|
||||
|
||||
// Only a new token clears the refusal. A style change in between applies a
|
||||
// style that is refused just as the last one was, so it proves nothing.
|
||||
const unsubscribeToken = subscribeMapTilesToken(() => {
|
||||
const unsubscribeToken = subscribeMapTilesToken((newToken) => {
|
||||
currentToken = newToken;
|
||||
if (vector && refused) {
|
||||
refused = false;
|
||||
applyStyle(requestedStyle);
|
||||
@@ -327,11 +347,27 @@ const createRasterLayer = (
|
||||
} as TokenTileLayerOptions)
|
||||
.addTo(map);
|
||||
|
||||
// Substituted per request, so a refreshed token needs no new layer.
|
||||
// The only sign this path gets that its token is stale. Throttled, or a
|
||||
// tile missing for another reason asks on every pan.
|
||||
let refused = false;
|
||||
let lastRecovery = 0;
|
||||
layer.on("tileerror", () => {
|
||||
refused = true;
|
||||
if (Date.now() - lastRecovery < RECOVERY_THROTTLE) {
|
||||
return;
|
||||
}
|
||||
lastRecovery = Date.now();
|
||||
refreshMapTilesToken();
|
||||
});
|
||||
|
||||
// Substituted per request, so later tiles pick up a new token by
|
||||
// themselves. Only the ones already cached as failures need a redraw.
|
||||
const unsubscribe = subscribeMapTilesToken((newToken) => {
|
||||
(layer.options as TokenTileLayerOptions).token = newToken;
|
||||
// Tiles that 403'd are cached as failures; only a redraw asks again.
|
||||
layer.redraw();
|
||||
if (refused) {
|
||||
refused = false;
|
||||
layer.redraw();
|
||||
}
|
||||
});
|
||||
map.on("unload", unsubscribe);
|
||||
|
||||
|
||||
@@ -13,16 +13,18 @@ import { DecoratedMarker } from "../decorated_marker";
|
||||
import { isTouch } from "../../../util/is_touch";
|
||||
import type {
|
||||
MapClusterOptions,
|
||||
MapCircleHandle,
|
||||
MapCircleOptions,
|
||||
MapControlPosition,
|
||||
MapEngine,
|
||||
MapEngineOptions,
|
||||
MapFitOptions,
|
||||
MapItemHandle,
|
||||
MapLatLng,
|
||||
MapMarkerHandle,
|
||||
MapMarkerOptions,
|
||||
MapPath,
|
||||
MapPathHandle,
|
||||
MapView,
|
||||
} from "../map-engine";
|
||||
import type { ResolvedMapStyle } from "../map-styles";
|
||||
import { setMarkerAccessibility } from "../marker-accessibility";
|
||||
@@ -106,6 +108,9 @@ export class LeafletMapEngine implements MapEngine {
|
||||
if (events.moveStart) {
|
||||
map.on("movestart", () => events.moveStart!());
|
||||
}
|
||||
// Leaflet draws its container as soon as the layer is on it, and fills the
|
||||
// tiles in over an opaque background from there
|
||||
events.drawn?.();
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
@@ -167,6 +172,14 @@ export class LeafletMapEngine implements MapEngine {
|
||||
this.leafletMap?.setView(center, zoom);
|
||||
}
|
||||
|
||||
public getView(): MapView | undefined {
|
||||
if (!this.leafletMap) {
|
||||
return undefined;
|
||||
}
|
||||
const center = this.leafletMap.getCenter();
|
||||
return { center: [center.lat, center.lng], zoom: this._getZoom() };
|
||||
}
|
||||
|
||||
public setZoom(zoom: number): void {
|
||||
this.leafletMap?.setZoom(zoom);
|
||||
}
|
||||
@@ -185,7 +198,10 @@ export class LeafletMapEngine implements MapEngine {
|
||||
return;
|
||||
}
|
||||
const bounds = this.Leaflet.latLngBounds(points).pad(options?.pad ?? 0.5);
|
||||
this.leafletMap.fitBounds(bounds, {
|
||||
const fit = options?.fly
|
||||
? this.leafletMap.flyToBounds
|
||||
: this.leafletMap.fitBounds;
|
||||
fit.call(this.leafletMap, bounds, {
|
||||
maxZoom: options?.maxZoom,
|
||||
animate: options?.animate,
|
||||
paddingTopLeft: [options?.padding?.left ?? 0, options?.padding?.top ?? 0],
|
||||
@@ -213,6 +229,7 @@ export class LeafletMapEngine implements MapEngine {
|
||||
? this.Leaflet!.circle(location, {
|
||||
interactive: false,
|
||||
color: options.decoration.color,
|
||||
stroke: options.decoration.outline !== false,
|
||||
radius: options.decoration.radius,
|
||||
})
|
||||
: undefined;
|
||||
@@ -221,8 +238,12 @@ export class LeafletMapEngine implements MapEngine {
|
||||
// activation handlers never hear a key; the element itself takes focus
|
||||
const interactive = options.interactive ?? true;
|
||||
const focusable = options.focusable ?? interactive;
|
||||
if (options.title && options.nativeTitle !== false) {
|
||||
element.title = options.title;
|
||||
}
|
||||
setMarkerAccessibility(element, options.title, focusable);
|
||||
const marker: HandledMarker = new DecoratedMarker(location, decoration, {
|
||||
zIndexOffset: options.raised ? 1000 : 0,
|
||||
icon: this.Leaflet!.divIcon({
|
||||
html: element,
|
||||
iconSize: options.size,
|
||||
@@ -231,7 +252,6 @@ export class LeafletMapEngine implements MapEngine {
|
||||
}),
|
||||
interactive,
|
||||
keyboard: false,
|
||||
title: options.title,
|
||||
});
|
||||
|
||||
const handle: LeafletMarkerHandle = {
|
||||
@@ -261,16 +281,39 @@ export class LeafletMapEngine implements MapEngine {
|
||||
public addCircle(
|
||||
center: MapLatLng,
|
||||
options: MapCircleOptions
|
||||
): MapItemHandle {
|
||||
): MapCircleHandle {
|
||||
const circle = this.Leaflet!.circle(center, {
|
||||
interactive: false,
|
||||
color: options.color,
|
||||
stroke: options.outline !== false,
|
||||
radius: options.radius,
|
||||
}).addTo(this.leafletMap!);
|
||||
return { remove: () => circle.remove() };
|
||||
return {
|
||||
update: (newCenter, newOptions) => {
|
||||
circle
|
||||
.setLatLng(newCenter)
|
||||
.setRadius(newOptions.radius)
|
||||
.setStyle({
|
||||
color: newOptions.color,
|
||||
stroke: newOptions.outline !== false,
|
||||
});
|
||||
},
|
||||
remove: () => circle.remove(),
|
||||
};
|
||||
}
|
||||
|
||||
public addPath(path: MapPath): MapItemHandle {
|
||||
public addPath(path: MapPath): MapPathHandle {
|
||||
let items = this._drawPath(path);
|
||||
return {
|
||||
update: (next) => {
|
||||
items.forEach((item) => item.remove());
|
||||
items = this._drawPath(next);
|
||||
},
|
||||
remove: () => items.forEach((item) => item.remove()),
|
||||
};
|
||||
}
|
||||
|
||||
private _drawPath(path: MapPath): (Polyline | CircleMarker)[] {
|
||||
const items: (Polyline | CircleMarker)[] = [];
|
||||
for (const segment of path.segments) {
|
||||
items.push(
|
||||
@@ -293,7 +336,7 @@ export class LeafletMapEngine implements MapEngine {
|
||||
);
|
||||
}
|
||||
items.forEach((item) => item.addTo(this.leafletMap!));
|
||||
return { remove: () => items.forEach((item) => item.remove()) };
|
||||
return items;
|
||||
}
|
||||
|
||||
public setClustering(options: MapClusterOptions | null): void {
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { deepEqual } from "../../util/deep-equal";
|
||||
import { isTouch } from "../../../util/is_touch";
|
||||
import type {
|
||||
MapCircleHandle,
|
||||
MapCircleOptions,
|
||||
MapClusterIcon,
|
||||
MapClusterOptions,
|
||||
@@ -49,6 +50,8 @@ import type {
|
||||
MapLatLng,
|
||||
MapMarkerOptions,
|
||||
MapPath,
|
||||
MapPathHandle,
|
||||
MapView,
|
||||
} from "../map-engine";
|
||||
import { destinationPoint, distanceMeters, pointEastOf } from "../map-engine";
|
||||
import type { ResolvedMapStyle } from "../map-styles";
|
||||
@@ -86,6 +89,11 @@ const WHEEL_ZOOM_RATE = 1 / 200;
|
||||
// Regroup clusters once continuous zooming settles, not on every wheel notch
|
||||
export const CLUSTER_REBUILD_DELAY = 120;
|
||||
|
||||
const ATTRIBUTION_SHOW_DURATION = 5000;
|
||||
const ATTRIBUTION_SHOWN_CLASS = "maplibregl-compact-show";
|
||||
// The app's narrow-layout breakpoint (home-assistant-main)
|
||||
const NARROW_VIEWPORT_QUERY = "(max-width: 870px)";
|
||||
|
||||
type GeoJSONSourceSpecification = Extract<
|
||||
Parameters<MapLibreMap["addSource"]>[1],
|
||||
{ type: "geojson" }
|
||||
@@ -121,6 +129,42 @@ const circlePolygon = (
|
||||
};
|
||||
};
|
||||
|
||||
const coloredCircle = (
|
||||
center: MapLatLng,
|
||||
options: MapCircleOptions
|
||||
): Feature<Polygon> => ({
|
||||
...circlePolygon(center, options.radius),
|
||||
properties: { color: options.color, outline: options.outline !== false },
|
||||
});
|
||||
|
||||
const pathLines = (path: MapPath): FeatureCollection => ({
|
||||
type: "FeatureCollection",
|
||||
features: path.segments.map((segment) => ({
|
||||
type: "Feature",
|
||||
properties: { color: path.color, opacity: segment.opacity ?? 1 },
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: segment.points.map((point) => [point[1], point[0]]),
|
||||
},
|
||||
})),
|
||||
});
|
||||
|
||||
const pathPoints = (path: MapPath): FeatureCollection => ({
|
||||
type: "FeatureCollection",
|
||||
features: path.markers.map((pathMarker) => ({
|
||||
type: "Feature",
|
||||
properties: {
|
||||
color: path.color,
|
||||
opacity: pathMarker.opacity ?? 1,
|
||||
tooltip: pathMarker.tooltipHtml,
|
||||
},
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates: [pathMarker.location[1], pathMarker.location[0]],
|
||||
},
|
||||
})),
|
||||
});
|
||||
|
||||
interface ManagedMarker {
|
||||
element: HTMLElement;
|
||||
location: MapLatLng;
|
||||
@@ -154,10 +198,15 @@ const iconFootprint = (icon: MapClusterIcon, center: MapLatLng): Footprint => ({
|
||||
half: Math.max(...icon.size) / 2,
|
||||
});
|
||||
|
||||
const markerFootprint = (managed: ManagedMarker): Footprint => ({
|
||||
location: managed.location,
|
||||
offset: [0, 0],
|
||||
half: Math.max(...managed.options.size) / 2,
|
||||
const markerFootprint = ({ location, options }: ManagedMarker): Footprint => ({
|
||||
location,
|
||||
offset: options.anchor
|
||||
? [
|
||||
options.size[0] / 2 - options.anchor[0],
|
||||
options.size[1] / 2 - options.anchor[1],
|
||||
]
|
||||
: [0, 0],
|
||||
half: Math.max(...options.size) / 2,
|
||||
});
|
||||
|
||||
interface ClusterGroup {
|
||||
@@ -194,6 +243,14 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
|
||||
private _scaleControl?: IControl;
|
||||
|
||||
private _attributionControl?: IControl;
|
||||
|
||||
private _attributionObserver?: MutationObserver;
|
||||
|
||||
private _attributionTimeout?: number;
|
||||
|
||||
private _narrowViewport = window.matchMedia(NARROW_VIEWPORT_QUERY);
|
||||
|
||||
private _markers: ManagedMarker[] = [];
|
||||
|
||||
private _clusterOptions: MapClusterOptions | null = null;
|
||||
@@ -296,8 +353,7 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
touchPitch: false,
|
||||
// Rendered with a device font, so these glyphs are never requested
|
||||
localIdeographFontFamily: "sans-serif",
|
||||
// Inline on wide maps, collapsible (open by default) on narrow ones
|
||||
attributionControl: {},
|
||||
attributionControl: false,
|
||||
// Proxied by core behind a token; absolute so the worker can resolve them
|
||||
transformRequest: (url) => ({
|
||||
...withMapTilesToken(url),
|
||||
@@ -335,6 +391,8 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
|
||||
this._zoomControl = new maplibre.NavigationControl({ showCompass: false });
|
||||
map.addControl(this._zoomControl, POSITIONS[options.zoomControlPosition]);
|
||||
this._setUpAttribution();
|
||||
this._narrowViewport.addEventListener("change", this._setUpAttribution);
|
||||
|
||||
map.on("click", (ev) => {
|
||||
// Clicks on path points (they have tooltips) are not map clicks
|
||||
@@ -345,6 +403,9 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
this._events.click?.([ev.lngLat.lat, ev.lngLat.lng]);
|
||||
}
|
||||
});
|
||||
// Everything the first view needs has been drawn; before this the canvas
|
||||
// can still be blank, whatever the style says
|
||||
map.once("load", () => this._events.drawn?.());
|
||||
map.on("zoomstart", () => this._events.zoomStart?.());
|
||||
map.on("movestart", () => {
|
||||
// resize() fires movestart even when nothing changed (see _resize)
|
||||
@@ -395,6 +456,62 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
}
|
||||
};
|
||||
|
||||
// Compact on a narrow viewport too, not only on a narrow map
|
||||
private _setUpAttribution = (): void => {
|
||||
const map = this._map;
|
||||
if (!map || !this._maplibre) {
|
||||
return;
|
||||
}
|
||||
if (this._attributionControl) {
|
||||
map.removeControl(this._attributionControl);
|
||||
}
|
||||
this._attributionControl = new this._maplibre.AttributionControl({
|
||||
compact: this._narrowViewport.matches ? true : undefined,
|
||||
});
|
||||
map.addControl(this._attributionControl, "bottom-right");
|
||||
const attribution = map
|
||||
.getContainer()
|
||||
.querySelector<HTMLElement>(".maplibregl-ctrl-attrib");
|
||||
if (attribution) {
|
||||
this._autoHideAttribution(attribution);
|
||||
}
|
||||
};
|
||||
|
||||
private _autoHideAttribution(attribution: HTMLElement): void {
|
||||
clearTimeout(this._attributionTimeout);
|
||||
this._attributionObserver?.disconnect();
|
||||
let byUser = false;
|
||||
attribution
|
||||
.querySelector(".maplibregl-ctrl-attrib-button")
|
||||
?.addEventListener("click", () => {
|
||||
// MapLibre's click handler already ran; the observer sees this flag
|
||||
byUser = true;
|
||||
queueMicrotask(() => {
|
||||
byUser = false;
|
||||
});
|
||||
});
|
||||
let shown = false;
|
||||
const sync = () => {
|
||||
const nowShown = attribution.classList.contains(ATTRIBUTION_SHOWN_CLASS);
|
||||
if (nowShown === shown) {
|
||||
return;
|
||||
}
|
||||
shown = nowShown;
|
||||
clearTimeout(this._attributionTimeout);
|
||||
if (shown && !byUser) {
|
||||
this._attributionTimeout = window.setTimeout(() => {
|
||||
attribution.classList.remove(ATTRIBUTION_SHOWN_CLASS);
|
||||
}, ATTRIBUTION_SHOW_DURATION);
|
||||
}
|
||||
};
|
||||
this._attributionObserver = new MutationObserver(sync);
|
||||
this._attributionObserver.observe(attribution, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
sync();
|
||||
}
|
||||
|
||||
private _scheduleFatal(): void {
|
||||
clearTimeout(this._fallbackTimeout);
|
||||
// Backgrounding drops the context too, and there it comes back on return
|
||||
@@ -413,6 +530,10 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
this._unsubscribeToken?.();
|
||||
clearTimeout(this._fallbackTimeout);
|
||||
clearTimeout(this._clusterRebuildTimeout);
|
||||
clearTimeout(this._attributionTimeout);
|
||||
this._attributionObserver?.disconnect();
|
||||
this._attributionObserver = undefined;
|
||||
this._narrowViewport.removeEventListener("change", this._setUpAttribution);
|
||||
document.removeEventListener("visibilitychange", this._handleVisibility);
|
||||
this._revealEmerged(true);
|
||||
this._clusterGroups.forEach((group) => group.iconMarker?.remove());
|
||||
@@ -539,6 +660,17 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
});
|
||||
}
|
||||
|
||||
public getView(): MapView | undefined {
|
||||
if (!this._map) {
|
||||
return undefined;
|
||||
}
|
||||
const center = this._map.getCenter();
|
||||
return {
|
||||
center: [center.lat, center.lng],
|
||||
zoom: this._map.getZoom() + ZOOM_OFFSET,
|
||||
};
|
||||
}
|
||||
|
||||
public setZoom(zoom: number): void {
|
||||
this._map?.easeTo({ zoom: zoom - ZOOM_OFFSET });
|
||||
}
|
||||
@@ -566,24 +698,75 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
|
||||
public fitBounds(points: MapLatLng[], options?: MapFitOptions): void {
|
||||
const fit = this._fitFor(points, options);
|
||||
if (fit) {
|
||||
this._map!.fitBounds(fit.bounds, {
|
||||
...fit.options,
|
||||
animate: options?.animate,
|
||||
});
|
||||
if (!fit) {
|
||||
return;
|
||||
}
|
||||
const map = this._map!;
|
||||
if ("center" in fit) {
|
||||
// cameraForBounds cannot solve a zero-area bound; center on it instead
|
||||
if (this._clusterOptions) {
|
||||
this._groupingZoom = fit.zoom;
|
||||
try {
|
||||
this._rebuildClusters(true);
|
||||
} finally {
|
||||
this._groupingZoom = undefined;
|
||||
}
|
||||
}
|
||||
// Padding as an offset, not map padding, which would stick
|
||||
const { top, right, bottom, left } = fit.padding;
|
||||
const camera = {
|
||||
center: [fit.center[1], fit.center[0]] as [number, number],
|
||||
zoom: fit.zoom,
|
||||
offset: [(left - right) / 2, (top - bottom) / 2] as [number, number],
|
||||
animate: options?.animate,
|
||||
};
|
||||
// flyTo drops the offset under reduced motion (it jumps); easeTo keeps it
|
||||
if (
|
||||
options?.fly &&
|
||||
!window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
) {
|
||||
map.flyTo(camera);
|
||||
} else {
|
||||
map.easeTo(camera);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Regroup now for the zoom the fit lands on
|
||||
const zoom = this._clusterOptions
|
||||
? map.cameraForBounds(fit.bounds, fit.options)?.zoom
|
||||
: undefined;
|
||||
if (zoom !== undefined) {
|
||||
this._groupingZoom = zoom;
|
||||
try {
|
||||
this._rebuildClusters(true);
|
||||
} finally {
|
||||
this._groupingZoom = undefined;
|
||||
}
|
||||
}
|
||||
map.fitBounds(fit.bounds, {
|
||||
...fit.options,
|
||||
animate: options?.animate,
|
||||
linear: !options?.fly,
|
||||
});
|
||||
}
|
||||
|
||||
// The zoom a fit would land on, without moving the map
|
||||
private _zoomAfterFit(points: MapLatLng[], options?: MapFitOptions): number {
|
||||
const fit = this._fitFor(points, options);
|
||||
return (
|
||||
(fit && this._map!.cameraForBounds(fit.bounds, fit.options)?.zoom) ??
|
||||
this._map!.getZoom()
|
||||
);
|
||||
}
|
||||
|
||||
private _fitFor(points: MapLatLng[], options?: MapFitOptions) {
|
||||
private _fitFor(
|
||||
points: MapLatLng[],
|
||||
options?: MapFitOptions
|
||||
):
|
||||
| {
|
||||
center: MapLatLng;
|
||||
zoom: number;
|
||||
padding: { top: number; right: number; bottom: number; left: number };
|
||||
}
|
||||
| {
|
||||
bounds: [[number, number], [number, number]];
|
||||
options: {
|
||||
maxZoom: number | undefined;
|
||||
padding: { top: number; right: number; bottom: number; left: number };
|
||||
};
|
||||
}
|
||||
| undefined {
|
||||
if (!this._map || !this._maplibre || !points.length) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -611,20 +794,19 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
if (minLat === maxLat && minLng === maxLng) {
|
||||
// Zero-area bounds: center on the point, keeping the zoom unless given
|
||||
return {
|
||||
bounds: [
|
||||
[minLng, minLat],
|
||||
[minLng, minLat],
|
||||
] as [[number, number], [number, number]],
|
||||
options: { maxZoom: maxZoom ?? this._map.getZoom(), padding },
|
||||
center: [minLat, minLng] as MapLatLng,
|
||||
zoom: maxZoom ?? this._map.getZoom(),
|
||||
padding,
|
||||
};
|
||||
}
|
||||
const pad = options?.pad ?? 0.5;
|
||||
const latPad = (maxLat - minLat) * pad;
|
||||
const lngPad = (maxLng - minLng) * pad;
|
||||
// MapLibre rejects a latitude beyond the poles
|
||||
return {
|
||||
bounds: [
|
||||
[minLng - lngPad, minLat - latPad],
|
||||
[maxLng + lngPad, maxLat + latPad],
|
||||
[minLng - lngPad, Math.max(-90, minLat - latPad)],
|
||||
[maxLng + lngPad, Math.min(90, maxLat + latPad)],
|
||||
] as [[number, number], [number, number]],
|
||||
options: { maxZoom, padding },
|
||||
};
|
||||
@@ -661,7 +843,7 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
): MapEditableMarkerHandle {
|
||||
element.style.width = `${options.size[0]}px`;
|
||||
element.style.height = `${options.size[1]}px`;
|
||||
if (options.title) {
|
||||
if (options.title && options.nativeTitle !== false) {
|
||||
element.title = options.title;
|
||||
}
|
||||
const interactive = options.interactive ?? true;
|
||||
@@ -671,6 +853,7 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
element.style.pointerEvents = "none";
|
||||
}
|
||||
setMarkerAccessibility(element, options.title, focusable);
|
||||
element.style.zIndex = options.raised ? "1" : "";
|
||||
if (draggable) {
|
||||
// The engine, not the host, knows whether this element really drags
|
||||
element.style.cursor = "move";
|
||||
@@ -773,25 +956,31 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
public addCircle(
|
||||
center: MapLatLng,
|
||||
options: MapCircleOptions
|
||||
): MapItemHandle {
|
||||
): MapCircleHandle {
|
||||
if (!this._map) {
|
||||
return { remove: () => undefined };
|
||||
return { update: () => undefined, remove: () => undefined };
|
||||
}
|
||||
const id = `${CUSTOM_PREFIX}circle-${this._idCounter++}`;
|
||||
this._addCustomSource(id, circlePolygon(center, options.radius));
|
||||
this._addCustomSource(id, coloredCircle(center, options));
|
||||
this._addCustomLayer({
|
||||
id: `${id}-fill`,
|
||||
type: "fill",
|
||||
source: id,
|
||||
paint: { "fill-color": options.color, "fill-opacity": 0.2 },
|
||||
paint: { "fill-color": ["get", "color"], "fill-opacity": 0.2 },
|
||||
});
|
||||
this._addCustomLayer({
|
||||
id: `${id}-line`,
|
||||
type: "line",
|
||||
source: id,
|
||||
paint: { "line-color": options.color, "line-width": 3 },
|
||||
paint: {
|
||||
"line-color": ["get", "color"],
|
||||
"line-width": ["case", ["get", "outline"], 3, 0],
|
||||
},
|
||||
});
|
||||
return {
|
||||
update: (newCenter, newOptions) => {
|
||||
this._setCustomSourceData(id, coloredCircle(newCenter, newOptions));
|
||||
},
|
||||
remove: () => {
|
||||
this._removeCustomLayer(`${id}-fill`);
|
||||
this._removeCustomLayer(`${id}-line`);
|
||||
@@ -853,7 +1042,7 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
}
|
||||
centerEl.style.width = `${centerSize[0]}px`;
|
||||
centerEl.style.height = `${centerSize[1]}px`;
|
||||
if (options.title) {
|
||||
if (options.title && options.nativeTitle !== false) {
|
||||
centerEl.title = options.title;
|
||||
}
|
||||
setMarkerAccessibility(centerEl, options.title, !!options.onClick);
|
||||
@@ -871,9 +1060,26 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
|
||||
let resizeMarker: MapLibreMarker | undefined;
|
||||
let resizeHandle: HTMLElement | undefined;
|
||||
// The user's hand wins while dragging; the host is the truth otherwise
|
||||
let dragging = false;
|
||||
// Hidden while the circle is under the center element
|
||||
const syncResizeHandleVisibility = () => {
|
||||
if (!resizeHandle || dragging) {
|
||||
return;
|
||||
}
|
||||
const east = pointEastOf(currentCenter, currentRadius);
|
||||
const centerPoint = map.project([currentCenter[1], currentCenter[0]]);
|
||||
const eastPoint = map.project([east[1], east[0]]);
|
||||
const covered =
|
||||
Math.hypot(eastPoint.x - centerPoint.x, eastPoint.y - centerPoint.y) <
|
||||
centerSize[0] / 2;
|
||||
resizeHandle.style.display =
|
||||
covered && !resizeHandle.matches(":focus-visible") ? "none" : "";
|
||||
};
|
||||
const placeResizeHandle = () => {
|
||||
const east = pointEastOf(currentCenter, currentRadius);
|
||||
resizeMarker?.setLngLat([east[1], east[0]]);
|
||||
syncResizeHandleVisibility();
|
||||
const radiusText = String(Math.round(currentRadius));
|
||||
resizeHandle?.setAttribute(
|
||||
"aria-valuemax",
|
||||
@@ -884,10 +1090,8 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
resizeHandle?.setAttribute("aria-valuetext", radiusText);
|
||||
};
|
||||
|
||||
// The user's hand wins while dragging; the host is the truth otherwise
|
||||
let dragging = false;
|
||||
|
||||
if (options.resizable) {
|
||||
map.on("zoom", syncResizeHandleVisibility);
|
||||
const east = pointEastOf(center, options.radius);
|
||||
resizeHandle = createResizeHandleElement(options.resizeLabel);
|
||||
resizeMarker = new maplibre.Marker({
|
||||
@@ -947,7 +1151,10 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
};
|
||||
resizeHandle.addEventListener("keyup", commitKeyboardResize);
|
||||
// Focus can leave while a key is still held
|
||||
resizeHandle.addEventListener("blur", commitKeyboardResize);
|
||||
resizeHandle.addEventListener("blur", () => {
|
||||
commitKeyboardResize();
|
||||
syncResizeHandleVisibility();
|
||||
});
|
||||
// Like the other markers: a click on the handle is not a map click
|
||||
resizeHandle.addEventListener("click", (ev) => ev.stopPropagation());
|
||||
}
|
||||
@@ -958,6 +1165,7 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
});
|
||||
handleMarker?.on("dragend", () => {
|
||||
dragging = false;
|
||||
syncResizeHandleVisibility();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1031,6 +1239,7 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
resetMarkerElement(centerEl);
|
||||
clearMarkerAccessibility(centerEl);
|
||||
this._placedElements.delete(centerEl);
|
||||
map.off("zoom", syncResizeHandleVisibility);
|
||||
resizeMarker?.remove();
|
||||
this._removeCustomLayer(`${id}-fill`);
|
||||
this._removeCustomLayer(`${id}-line`);
|
||||
@@ -1039,46 +1248,20 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
};
|
||||
}
|
||||
|
||||
public addPath(path: MapPath): MapItemHandle {
|
||||
public addPath(path: MapPath): MapPathHandle {
|
||||
if (!this._map || !this._maplibre) {
|
||||
return { remove: () => undefined };
|
||||
return { update: () => undefined, remove: () => undefined };
|
||||
}
|
||||
const id = `${CUSTOM_PREFIX}path-${this._idCounter++}`;
|
||||
|
||||
const lines: FeatureCollection = {
|
||||
type: "FeatureCollection",
|
||||
features: path.segments.map((segment) => ({
|
||||
type: "Feature",
|
||||
properties: { opacity: segment.opacity ?? 1 },
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: segment.points.map((point) => [point[1], point[0]]),
|
||||
},
|
||||
})),
|
||||
};
|
||||
const points: FeatureCollection = {
|
||||
type: "FeatureCollection",
|
||||
features: path.markers.map((pathMarker) => ({
|
||||
type: "Feature",
|
||||
properties: {
|
||||
opacity: pathMarker.opacity ?? 1,
|
||||
tooltip: pathMarker.tooltipHtml,
|
||||
},
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates: [pathMarker.location[1], pathMarker.location[0]],
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
this._addCustomSource(`${id}-lines`, lines);
|
||||
this._addCustomSource(`${id}-points`, points);
|
||||
this._addCustomSource(`${id}-lines`, pathLines(path));
|
||||
this._addCustomSource(`${id}-points`, pathPoints(path));
|
||||
this._addCustomLayer({
|
||||
id: `${id}-lines`,
|
||||
type: "line",
|
||||
source: `${id}-lines`,
|
||||
paint: {
|
||||
"line-color": path.color,
|
||||
"line-color": ["get", "color"],
|
||||
"line-width": 3,
|
||||
"line-opacity": ["get", "opacity"],
|
||||
},
|
||||
@@ -1089,10 +1272,10 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
source: `${id}-points`,
|
||||
paint: {
|
||||
"circle-radius": isTouch ? 8 : 3,
|
||||
"circle-color": path.color,
|
||||
"circle-color": ["get", "color"],
|
||||
"circle-opacity": ["get", "opacity"],
|
||||
"circle-stroke-width": 2,
|
||||
"circle-stroke-color": path.color,
|
||||
"circle-stroke-color": ["get", "color"],
|
||||
"circle-stroke-opacity": ["get", "opacity"],
|
||||
},
|
||||
});
|
||||
@@ -1135,6 +1318,10 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
this._pathPointLayers.add(layerId);
|
||||
|
||||
return {
|
||||
update: (next) => {
|
||||
this._setCustomSourceData(`${id}-lines`, pathLines(next));
|
||||
this._setCustomSourceData(`${id}-points`, pathPoints(next));
|
||||
},
|
||||
remove: () => {
|
||||
map.off("mouseenter", layerId, onEnter);
|
||||
map.off("mouseleave", layerId, onLeave);
|
||||
@@ -1455,21 +1642,14 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
}
|
||||
const { icon } = group;
|
||||
this._placeIcon(group, icon);
|
||||
// Zooms in on the members, regrouped up front as they will sit once the
|
||||
// zoom lands: leavers part now and the bubble opens for the rest
|
||||
const zoomToMembers = () => {
|
||||
const { members } = group;
|
||||
const locations = members.map((managed) => managed.location);
|
||||
const fit = { pad: 0.3, maxZoom: this._getMaxZoom() };
|
||||
this._openAfterRegroup = members;
|
||||
this._groupingZoom = this._zoomAfterFit(locations, fit);
|
||||
try {
|
||||
this._rebuildClusters(true, group);
|
||||
} finally {
|
||||
this._groupingZoom = undefined;
|
||||
}
|
||||
this.fitBounds(
|
||||
members.map((managed) => managed.location),
|
||||
{ pad: 0.3, maxZoom: this._getMaxZoom() }
|
||||
);
|
||||
this._hideEmerging(members);
|
||||
this.fitBounds(locations, fit);
|
||||
};
|
||||
setMarkerAccessibility(icon.element, this._groupTitle(group), true);
|
||||
icon.element.addEventListener("click", (ev) => {
|
||||
|
||||
@@ -4,39 +4,42 @@ import { computeDomain } from "../entity/compute_domain";
|
||||
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
|
||||
/**
|
||||
* Map colors for entities, by registry creation order, so an entity has the
|
||||
* same color on every map. The registry comes from the fullEntitiesContext;
|
||||
* entities without an entry (e.g. YAML zones) get a color derived from their
|
||||
* id.
|
||||
* Map colors for entities, from their place in the registry, so an entity has
|
||||
* the same color on every map. Zones have their own sequence. Persons and
|
||||
* trackers share another, persons first, so trackers discovered over time
|
||||
* shift neither. Entities without an entry (e.g. YAML zones) get a color
|
||||
* derived from their id.
|
||||
*/
|
||||
|
||||
export const HOME_ZONE_ENTITY_ID = "zone.home";
|
||||
|
||||
/** Domains whose entities are colored by creation order */
|
||||
const ORDERED_DOMAINS = ["zone", "person", "device_tracker"];
|
||||
const byCreation = (a: EntityRegistryEntry, b: EntityRegistryEntry) =>
|
||||
a.created_at - b.created_at || a.id.localeCompare(b.id);
|
||||
|
||||
// One index per registry update, shared by every map on the page. Zones,
|
||||
// persons and trackers share one sequence, so a person never has the color
|
||||
// of the zone it is in.
|
||||
const creationIndex = memoizeOne(
|
||||
(entries: EntityRegistryEntry[]): Record<string, number> => {
|
||||
const index: Record<string, number> = {};
|
||||
entries
|
||||
.filter(
|
||||
(entry) =>
|
||||
ORDERED_DOMAINS.includes(computeDomain(entry.entity_id)) &&
|
||||
// The home zone has a fixed color and does not take a palette slot
|
||||
entry.entity_id !== HOME_ZONE_ENTITY_ID
|
||||
)
|
||||
.sort((a, b) => a.created_at - b.created_at || a.id.localeCompare(b.id))
|
||||
.forEach((entry, i) => {
|
||||
index[entry.entity_id] = i;
|
||||
});
|
||||
return index;
|
||||
}
|
||||
);
|
||||
const domainEntries = (entries: EntityRegistryEntry[], domain: string) =>
|
||||
entries
|
||||
.filter((entry) => computeDomain(entry.entity_id) === domain)
|
||||
.sort(byCreation);
|
||||
|
||||
// For entities without a registry entry
|
||||
const paletteIndex = memoizeOne((entries: EntityRegistryEntry[]) => {
|
||||
// The home zone has a fixed color and does not take a palette slot
|
||||
const zones = domainEntries(entries, "zone").filter(
|
||||
(entry) => entry.entity_id !== HOME_ZONE_ENTITY_ID
|
||||
);
|
||||
const personsAndTrackers = [
|
||||
...domainEntries(entries, "person"),
|
||||
...domainEntries(entries, "device_tracker"),
|
||||
];
|
||||
const byEntityId: Record<string, number> = {};
|
||||
[zones, personsAndTrackers].forEach((sequence) =>
|
||||
sequence.forEach((entry, i) => {
|
||||
byEntityId[entry.entity_id] = i;
|
||||
})
|
||||
);
|
||||
return { byEntityId, zoneCount: zones.length };
|
||||
});
|
||||
|
||||
// For entities outside both sequences
|
||||
const hashIndex = (entityId: string): number => {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < entityId.length; i++) {
|
||||
@@ -52,11 +55,11 @@ export const entityMapColor = (
|
||||
computedStyles: CSSStyleDeclaration
|
||||
): string =>
|
||||
getColorByIndex(
|
||||
creationIndex(entries)[entityId] ?? hashIndex(entityId),
|
||||
paletteIndex(entries).byEntityId[entityId] ?? hashIndex(entityId),
|
||||
computedStyles
|
||||
);
|
||||
|
||||
/** The color a new zone will take once created, from the next creation-order slot */
|
||||
/** The color a new zone will take once created, from the slot after the last zone */
|
||||
export const nextZoneColor = (
|
||||
passive: boolean,
|
||||
entries: EntityRegistryEntry[],
|
||||
@@ -65,10 +68,7 @@ export const nextZoneColor = (
|
||||
if (passive) {
|
||||
return computedStyles.getPropertyValue("--secondary-text-color");
|
||||
}
|
||||
return getColorByIndex(
|
||||
Object.keys(creationIndex(entries)).length,
|
||||
computedStyles
|
||||
);
|
||||
return getColorByIndex(paletteIndex(entries).zoneCount, computedStyles);
|
||||
};
|
||||
|
||||
/** A zone's color: primary for home, muted for passive, its entity map color otherwise */
|
||||
|
||||
@@ -11,6 +11,11 @@ import type { ResolvedMapStyle } from "./map-styles";
|
||||
|
||||
export type MapLatLng = [latitude: number, longitude: number];
|
||||
|
||||
export interface MapView {
|
||||
center: MapLatLng;
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
export type MapControlPosition =
|
||||
"topleft" | "topright" | "bottomleft" | "bottomright";
|
||||
|
||||
@@ -23,6 +28,11 @@ export interface MapEngineEvents {
|
||||
moveStart(): void;
|
||||
/** The engine can no longer render; the host switches to the fallback */
|
||||
fatal(): void;
|
||||
/**
|
||||
* The map has something to show: MapLibre's first complete view, or Leaflet's
|
||||
* layer in place. The host keeps the container hidden until then.
|
||||
*/
|
||||
drawn(): void;
|
||||
}
|
||||
|
||||
export interface MapEngineOptions {
|
||||
@@ -45,6 +55,8 @@ export interface MapFitOptions {
|
||||
pad?: number;
|
||||
/** Ease the camera to the bounds instead of jumping; defaults to true */
|
||||
animate?: boolean;
|
||||
/** Fly in an arc instead of easing straight there; defaults to false */
|
||||
fly?: boolean;
|
||||
/** Viewport pixels covered by overlays; the bounds fit inside the rest */
|
||||
padding?: MapFitPadding;
|
||||
}
|
||||
@@ -61,12 +73,16 @@ export interface MapMarkerOptions {
|
||||
size: [width: number, height: number];
|
||||
/** Point of the element placed on the coordinate, from its top left; defaults to the center */
|
||||
anchor?: [x: number, y: number];
|
||||
/** Drawn above the other markers */
|
||||
raised?: boolean;
|
||||
/** Takes pointer input; defaults to true */
|
||||
interactive?: boolean;
|
||||
/** A keyboard-focusable button, for markers that act on activation; defaults to interactive */
|
||||
focusable?: boolean;
|
||||
/** Accessible name */
|
||||
title?: string;
|
||||
/** Also show the title as the browser's tooltip; defaults to true */
|
||||
nativeTitle?: boolean;
|
||||
/** A meter-radius circle sharing the marker's lifecycle (GPS accuracy) */
|
||||
decoration?: MapCircleOptions;
|
||||
/** Cluster this marker; it appears once setClustering is called */
|
||||
@@ -80,6 +96,8 @@ export interface MapCircleOptions {
|
||||
radius: number;
|
||||
/** Stroke color; the fill is derived from it, translucent */
|
||||
color: string;
|
||||
/** Stroke the circle; defaults to true */
|
||||
outline?: boolean;
|
||||
}
|
||||
|
||||
export interface MapPathSegment {
|
||||
@@ -110,6 +128,16 @@ export interface MapMarkerHandle extends MapItemHandle {
|
||||
readonly clusterData?: unknown;
|
||||
}
|
||||
|
||||
export interface MapCircleHandle extends MapItemHandle {
|
||||
/** Move, resize or recolor without removing it first */
|
||||
update(center: MapLatLng, options: MapCircleOptions): void;
|
||||
}
|
||||
|
||||
export interface MapPathHandle extends MapItemHandle {
|
||||
/** Replace the drawn trail without removing it first */
|
||||
update(path: MapPath): void;
|
||||
}
|
||||
|
||||
export interface MapDraggableMarkerOptions extends MapMarkerOptions {
|
||||
onDragEnd?(location: MapLatLng): void;
|
||||
}
|
||||
@@ -123,6 +151,7 @@ export interface MapEditableCircleOptions {
|
||||
centerElement?: HTMLElement;
|
||||
centerSize?: [width: number, height: number];
|
||||
title?: string;
|
||||
nativeTitle?: boolean;
|
||||
/** The center can be dragged */
|
||||
moveable?: boolean;
|
||||
/** A handle on the edge can be dragged to change the radius */
|
||||
@@ -215,6 +244,8 @@ export interface MapEngine {
|
||||
|
||||
setView(center: MapLatLng, zoom?: number): void;
|
||||
|
||||
getView(): MapView | undefined;
|
||||
|
||||
setZoom(zoom: number): void;
|
||||
|
||||
/** Fit the given points into view; a single point centers on it */
|
||||
@@ -234,13 +265,13 @@ export interface MapEngine {
|
||||
): MapMarkerHandle;
|
||||
|
||||
/** Draw a meter-radius circle (zone radius) */
|
||||
addCircle(center: MapLatLng, options: MapCircleOptions): MapItemHandle;
|
||||
addCircle(center: MapLatLng, options: MapCircleOptions): MapCircleHandle;
|
||||
|
||||
/** Editing support, MapLibre only; undefined on the Leaflet fallback */
|
||||
editing?: MapEditingSupport;
|
||||
|
||||
/** Draw one history trail (points with tooltips, connecting segments) */
|
||||
addPath(path: MapPath): MapItemHandle;
|
||||
addPath(path: MapPath): MapPathHandle;
|
||||
|
||||
/** Cluster the markers added with cluster: true; call after each batch of addMarker calls */
|
||||
setClustering(options: MapClusterOptions | null): void;
|
||||
@@ -320,3 +351,27 @@ export const circleBoundsPoints = (
|
||||
[latMax, center[1] + dLng],
|
||||
];
|
||||
};
|
||||
|
||||
const MERCATOR_MAX_LAT = 85.051129;
|
||||
|
||||
const projectMercator = ([lat, lng]: MapLatLng, scale: number) => {
|
||||
const sinLat = Math.sin(
|
||||
toRadians(Math.max(-MERCATOR_MAX_LAT, Math.min(MERCATOR_MAX_LAT, lat)))
|
||||
);
|
||||
return {
|
||||
x: (lng / 360 + 0.5) * scale,
|
||||
y: (0.5 - Math.log((1 + sinLat) / (1 - sinLat)) / (4 * Math.PI)) * scale,
|
||||
};
|
||||
};
|
||||
|
||||
export const pixelDistance = (
|
||||
a: MapLatLng,
|
||||
b: MapLatLng,
|
||||
zoom: number
|
||||
): number => {
|
||||
const scale = 256 * 2 ** zoom;
|
||||
const pa = projectMercator(a, scale);
|
||||
const pb = projectMercator(b, scale);
|
||||
const dx = Math.abs(pa.x - pb.x);
|
||||
return Math.hypot(Math.min(dx, scale - dx), pa.y - pb.y);
|
||||
};
|
||||
@@ -59,7 +59,7 @@ const NAME_EN: ExpressionSpecification = ["get", "name_en"];
|
||||
|
||||
// Strings compare by code point: anything from Basic Latin up to Latin
|
||||
// Extended-B, digits and punctuation included.
|
||||
const IS_LATIN: ExpressionSpecification = ["<", NAME, "ɐ"];
|
||||
const IS_LATIN: ExpressionSpecification = ["<", ["coalesce", NAME, ""], "ɐ"];
|
||||
|
||||
const ENGLISH_SCALE = 0.8;
|
||||
|
||||
@@ -98,8 +98,19 @@ const addLatinLabels = (style: StyleSpecification): StyleSpecification => ({
|
||||
}),
|
||||
});
|
||||
|
||||
// The atmosphere MapLibre scatters around the globe, which VersaTiles leaves
|
||||
// off for a crisp edge. Against the space ha-map draws behind it, that edge is
|
||||
// what wants softening.
|
||||
const ATMOSPHERE_BLEND = 0.5;
|
||||
|
||||
const addAtmosphere = (style: StyleSpecification): StyleSpecification => ({
|
||||
...style,
|
||||
sky: { ...style.sky, "atmosphere-blend": ATMOSPHERE_BLEND },
|
||||
});
|
||||
|
||||
/** Everything a freshly built style needs before it is handed to MapLibre */
|
||||
export const finalizeMapStyle = (
|
||||
name: string,
|
||||
style: StyleSpecification
|
||||
): StyleSpecification => addLatinLabels(useTileJson(name, style));
|
||||
): StyleSpecification =>
|
||||
addAtmosphere(addLatinLabels(useTileJson(name, style)));
|
||||
@@ -1,13 +1,19 @@
|
||||
import { getContrastedColorHex } from "../color/rgb";
|
||||
import { wcagContrast } from "culori";
|
||||
import { theme2hex } from "../color/convert-color";
|
||||
|
||||
/** The zone marker: a colored circle with the zone's icon or initials, shared by the map and the zone editor */
|
||||
|
||||
export const ZONE_CIRCLE_SIZE = 36;
|
||||
|
||||
// Content color contrasting the fill; not every theme color parses
|
||||
// WCAG minimum contrast for icons and other graphics
|
||||
const MIN_CONTENT_CONTRAST = 3;
|
||||
|
||||
// White content while it contrasts enough with the fill, black otherwise;
|
||||
// not every theme color parses
|
||||
export const contrastingZoneContent = (color: string): string => {
|
||||
try {
|
||||
return getContrastedColorHex(color.trim());
|
||||
const whiteContrast = wcagContrast(theme2hex(color.trim()), "#ffffff");
|
||||
return whiteContrast >= MIN_CONTENT_CONTRAST ? "#ffffff" : "#000000";
|
||||
} catch {
|
||||
return "#ffffff";
|
||||
}
|
||||
|
||||
@@ -241,6 +241,7 @@ export class HaSankeyChart extends LitElement {
|
||||
distance: LABEL_DISTANCE,
|
||||
minMargin: LABEL_MIN_MARGIN,
|
||||
overflow: "break",
|
||||
lineOverflow: this.vertical ? "truncate" : "none",
|
||||
},
|
||||
labelLayout: (params) => {
|
||||
if (this.vertical) {
|
||||
@@ -270,7 +271,10 @@ export class HaSankeyChart extends LitElement {
|
||||
(wrapWidth / wordWidth) * FONT_SIZE
|
||||
);
|
||||
return {
|
||||
fontSize: fontSize > 1 ? fontSize : 0,
|
||||
fontSize,
|
||||
// Safari draws a 0px canvas font at its default size, so hide
|
||||
// unreadable labels by dropping all of their lines instead.
|
||||
height: fontSize > 1 ? undefined : 0,
|
||||
width: availableWidth,
|
||||
align: "center",
|
||||
dy: -2, // shift up or the lowest row labels may be cut off
|
||||
|
||||
@@ -436,7 +436,7 @@ export function generateStateHistoryChartLineData(
|
||||
const stateObj = hass.states[states.entity_id];
|
||||
const currentValue = stateObj ? safeParseFloat(stateObj.state) : null;
|
||||
if (currentValue !== null) {
|
||||
data[0].data!.push([nowMs, currentValue]);
|
||||
data[0].data!.push([Math.max(nowMs, endTimeMs), currentValue]);
|
||||
trackY(currentValue);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
|
||||
private _hiddenStats = new Set<string>();
|
||||
|
||||
@state() private _yWidth = MIN_Y_AXIS_WIDTH;
|
||||
@state() private _yWidth = 0;
|
||||
|
||||
@state() private _visualMap?: VisualMapComponentOption[];
|
||||
|
||||
|
||||
@@ -213,7 +213,7 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
const labelWidth =
|
||||
showNames && !insideLabels
|
||||
? Math.max(this.paddingYAxis, this._yWidth)
|
||||
: 0;
|
||||
: this.paddingYAxis;
|
||||
const labelMargin = 5;
|
||||
const rtl = computeRTL(
|
||||
this.hass.language,
|
||||
|
||||
@@ -273,7 +273,12 @@ export class StateHistoryCharts extends LitElement {
|
||||
)
|
||||
) {
|
||||
// Don't recompute times when we just want to update layout
|
||||
const now = new Date();
|
||||
// A browser clock behind the server's would put the newest states past
|
||||
// the end time, and the charts drop those. Rounded up because Date
|
||||
// truncates their sub-millisecond timestamps.
|
||||
const now = new Date(
|
||||
Math.max(Date.now(), Math.ceil(this._newestStateTime()))
|
||||
);
|
||||
|
||||
this._computedEndTime =
|
||||
this.upToNow || !this.endTime || this.endTime > now
|
||||
@@ -284,7 +289,7 @@ export class StateHistoryCharts extends LitElement {
|
||||
this._computedStartTime = this.startTime;
|
||||
} else if (this.hoursToShow) {
|
||||
this._computedStartTime = new Date(
|
||||
new Date().getTime() - 60 * 60 * this.hoursToShow * 1000
|
||||
now.getTime() - 60 * 60 * this.hoursToShow * 1000
|
||||
);
|
||||
} else {
|
||||
let minTimeAll = (this.historyData?.timeline ?? []).reduce(
|
||||
@@ -383,6 +388,25 @@ export class StateHistoryCharts extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _newestStateTime(): number {
|
||||
let newest = 0;
|
||||
for (const entity of this.historyData?.timeline ?? []) {
|
||||
newest = Math.max(
|
||||
newest,
|
||||
entity.data[entity.data.length - 1]?.last_changed ?? 0
|
||||
);
|
||||
}
|
||||
for (const unit of this.historyData?.line ?? []) {
|
||||
for (const entity of unit.data) {
|
||||
newest = Math.max(
|
||||
newest,
|
||||
entity.states[entity.states.length - 1]?.last_changed ?? 0
|
||||
);
|
||||
}
|
||||
}
|
||||
return newest;
|
||||
}
|
||||
|
||||
private _isHistoryEmpty(): boolean {
|
||||
const historyDataEmpty =
|
||||
!this.historyData ||
|
||||
|
||||
@@ -332,6 +332,7 @@ export class HaChipBase extends HaButton {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
vertical-align: middle;
|
||||
border-radius: var(--ha-button-border-radius);
|
||||
--ha-button-height: 32px;
|
||||
--ha-button-border-radius: var(--ha-border-radius-pill);
|
||||
--wa-font-weight-action: var(--ha-font-weight-normal);
|
||||
|
||||
@@ -66,15 +66,15 @@ export class DialogDataTableSettings extends LitElement {
|
||||
}
|
||||
|
||||
private _preserveLastFixed(columnOrder) {
|
||||
let strippedColumnOrder;
|
||||
const lastFixedCount = this._lastFixedCount();
|
||||
if (lastFixedCount && columnOrder) {
|
||||
strippedColumnOrder = [...columnOrder];
|
||||
strippedColumnOrder.splice(
|
||||
columnOrder.length - lastFixedCount,
|
||||
lastFixedCount
|
||||
);
|
||||
if (!lastFixedCount || !columnOrder) {
|
||||
return columnOrder;
|
||||
}
|
||||
const strippedColumnOrder = [...columnOrder];
|
||||
strippedColumnOrder.splice(
|
||||
columnOrder.length - lastFixedCount,
|
||||
lastFixedCount
|
||||
);
|
||||
return strippedColumnOrder;
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,14 @@ export const greetingTranslationLanguage = (
|
||||
if (!pipelineLanguage || pipelineLanguage === interfaceLanguage) {
|
||||
return undefined;
|
||||
}
|
||||
// A bare pipeline language ("zh") covers the interface's variant of it
|
||||
// ("zh-Hans"), which a lookup of the bare language may not pick
|
||||
if (
|
||||
!pipelineLanguage.includes("-") &&
|
||||
interfaceLanguage?.startsWith(`${pipelineLanguage}-`)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const language = findAvailableLanguage(pipelineLanguage);
|
||||
return language && language !== interfaceLanguage ? language : undefined;
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { customElement, property, query, state } from "lit/decorators";
|
||||
import { consume } from "../common/decorators/consume";
|
||||
import type { HASSDomEvent } from "../common/dom/fire_event";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { isSwipeLocked } from "../common/dom/is-swipe-locked";
|
||||
import { SwipeGestureRecognizer } from "../common/util/swipe-gesture-recognizer";
|
||||
import { configContext } from "../data/context";
|
||||
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
|
||||
@@ -14,19 +15,6 @@ import { isIosApp } from "../util/is_ios";
|
||||
|
||||
export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300;
|
||||
|
||||
const SWIPE_LOCKED_COMPONENTS = new Set([
|
||||
"ha-control-slider",
|
||||
"ha-slider",
|
||||
"ha-control-switch",
|
||||
"ha-control-circular-slider",
|
||||
"ha-hs-color-picker",
|
||||
"ha-map",
|
||||
"ha-more-info-control-select-container",
|
||||
"ha-filter-chip",
|
||||
]);
|
||||
|
||||
const SWIPE_LOCKED_CLASSES = new Set(["volume-slider-container", "forecast"]);
|
||||
|
||||
/**
|
||||
* Home Assistant bottom sheet component.
|
||||
*
|
||||
@@ -255,28 +243,8 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = ev.composedPath();
|
||||
|
||||
for (const target of path) {
|
||||
if (target === this._drawer) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
// Check if any element inside drawer in the composed path has scrollTop > 0 (list)
|
||||
target.scrollTop > 0 ||
|
||||
// Check if the element is a swipe locked component or has a swipe locked class
|
||||
SWIPE_LOCKED_COMPONENTS.has(target.localName) ||
|
||||
Array.from(target.classList).some((cls) =>
|
||||
SWIPE_LOCKED_CLASSES.has(cls)
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (isSwipeLocked(ev.composedPath(), this._drawer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop propagation so parent bottom sheets don't also start tracking
|
||||
|
||||
@@ -32,6 +32,7 @@ import { customElement } from "lit/decorators";
|
||||
* @cssprop --ha-checkbox-checked-icon-scale - The size of the checked and indeterminate icons relative to the checkbox. Defaults to `0.9`.
|
||||
* @cssprop --ha-checkbox-border-radius - The border radius of the checkbox control. Defaults to `--ha-border-radius-sm`.
|
||||
* @cssprop --ha-checkbox-border-width - The border width of the checkbox control. Defaults to `--ha-border-width-md`.
|
||||
* @cssprop --ha-checkbox-min-touch-size - Minimum touch target size around the checkbox. Defaults to `40px`.
|
||||
* @cssprop --ha-checkbox-required-marker - The marker shown after the label for required fields. Defaults to `"*"`.
|
||||
* @cssprop --ha-checkbox-required-marker-offset - Offset of the required marker. Defaults to `0.1rem`.
|
||||
*
|
||||
@@ -114,6 +115,22 @@ export class HaCheckbox extends WaCheckbox {
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
[part~="control"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: var(--ha-checkbox-min-touch-size, 40px);
|
||||
min-height: var(--ha-checkbox-min-touch-size, 40px);
|
||||
}
|
||||
|
||||
label:has(input:disabled) [part~="control"]::before {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[part~="label"] {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type {
|
||||
LocalizeFunc,
|
||||
LocalizeKeys,
|
||||
} from "../common/translations/localize";
|
||||
import type { YamlFieldSchema } from "../resources/yaml_field_schema";
|
||||
|
||||
/**
|
||||
* Tooltip element rendered inside a CodeMirror hoverTooltip for YAML field
|
||||
* keys in the automation / script / card YAML editors.
|
||||
*
|
||||
* Shows:
|
||||
* - Field name (monospace)
|
||||
* - "required" badge when applicable
|
||||
* - Description paragraph
|
||||
* - Selector type hint
|
||||
* - Example value
|
||||
* - Default value
|
||||
*/
|
||||
@customElement("ha-code-editor-yaml-hover")
|
||||
export class HaCodeEditorYamlHover extends LitElement {
|
||||
@property({ attribute: false }) public fieldName = "";
|
||||
|
||||
@property({ attribute: false }) public fieldSchema!: YamlFieldSchema;
|
||||
|
||||
/**
|
||||
* Optional localize callback forwarded from the editor so translated
|
||||
* descriptions can be rendered. When absent, strings are shown verbatim.
|
||||
*/
|
||||
@property({ attribute: false }) public localize?: LocalizeFunc;
|
||||
|
||||
render() {
|
||||
const schema = this.fieldSchema;
|
||||
if (!schema) return nothing;
|
||||
|
||||
// Built-in field descriptions are translation keys; descriptions coming
|
||||
// from the backend are already translated and fall through unchanged.
|
||||
const description = schema.description
|
||||
? this.localize?.(schema.description as LocalizeKeys) ||
|
||||
schema.description
|
||||
: undefined;
|
||||
|
||||
const selectorType = schema.selector
|
||||
? Object.keys(schema.selector)[0]
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
<div class="header">
|
||||
<code class="key">${this.fieldName}</code>
|
||||
${
|
||||
schema.required
|
||||
? html`<span class="badge required"
|
||||
>${this._label("required", "required")}</span
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
selectorType
|
||||
? html`<span class="badge type">${selectorType}</span>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${description ? html`<div class="desc">${description}</div>` : nothing}
|
||||
${
|
||||
schema.example != null
|
||||
? html`<div class="meta">
|
||||
<span class="meta-label"
|
||||
>${this._label("example", "Example:")}</span
|
||||
>
|
||||
<code>${String(schema.example)}</code>
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
schema.default != null
|
||||
? html`<div class="meta">
|
||||
<span class="meta-label"
|
||||
>${this._label("default", "Default:")}</span
|
||||
>
|
||||
<code>${String(schema.default)}</code>
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
private _label(
|
||||
key: "required" | "example" | "default",
|
||||
fallback: string
|
||||
): string {
|
||||
return (
|
||||
this.localize?.(`ui.components.yaml-editor.schema.${key}`) || fallback
|
||||
);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 6px 10px;
|
||||
max-width: 320px;
|
||||
line-height: 1.5;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
code.key {
|
||||
font-family: var(--ha-font-family-code);
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
border-radius: 4px;
|
||||
padding: 0 5px;
|
||||
font-size: 0.78em;
|
||||
line-height: 1.6;
|
||||
font-family: var(--ha-font-family-body);
|
||||
}
|
||||
|
||||
.badge.required {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--error-color, #db4437) 15%,
|
||||
transparent
|
||||
);
|
||||
color: var(--error-color, #db4437);
|
||||
}
|
||||
|
||||
.badge.type {
|
||||
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.desc {
|
||||
color: var(--secondary-text-color);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: baseline;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.75;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.meta code {
|
||||
font-family: var(--ha-font-family-code);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-code-editor-yaml-hover": HaCodeEditorYamlHover;
|
||||
}
|
||||
}
|
||||
@@ -38,19 +38,10 @@ import { computeAreaName } from "../common/entity/compute_area_name";
|
||||
import { computeFloorName } from "../common/entity/compute_floor_name";
|
||||
import { copyToClipboard } from "../common/util/copy-clipboard";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import {
|
||||
buildEntityCompletions,
|
||||
buildDeviceCompletions,
|
||||
buildAreaCompletions,
|
||||
buildFloorCompletions,
|
||||
buildLabelCompletions,
|
||||
} from "../resources/ha_completion_items";
|
||||
import type {
|
||||
JinjaArgType,
|
||||
HassArgHoverContext,
|
||||
} from "../resources/jinja_ha_completions";
|
||||
import type { YamlFieldSchemaMap } from "../resources/yaml_field_schema";
|
||||
import "./ha-code-editor-yaml-hover";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { showToast } from "../util/toast";
|
||||
import { documentationUrl } from "../util/documentation-url";
|
||||
@@ -99,14 +90,6 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
|
||||
@property() public mode = "yaml";
|
||||
|
||||
/**
|
||||
* Optional field schema for YAML mode. When set, the editor will provide
|
||||
* field-aware key/value completions, hover tooltips, and linting for the
|
||||
* known fields described by this map.
|
||||
*/
|
||||
@property({ attribute: false })
|
||||
public yamlFieldSchema?: YamlFieldSchemaMap;
|
||||
|
||||
// eslint-disable-next-line lit/no-native-attributes
|
||||
@property({ type: Boolean }) public autofocus = false;
|
||||
|
||||
@@ -186,12 +169,6 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
|
||||
private _completionInfoDestroy?: () => void;
|
||||
|
||||
// Stored YAML syntax error set by setYamlError(); consumed by _yamlSyntaxLinter.
|
||||
private _yamlSyntaxError: {
|
||||
mark?: { position: number; line: number; column: number };
|
||||
reason?: string;
|
||||
} | null = null;
|
||||
|
||||
private _completionInfoRequest = 0;
|
||||
|
||||
private _completionInfoKey?: string;
|
||||
@@ -226,10 +203,6 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
* Push a YAML parse error (or null to clear) into the lint gutter as a
|
||||
* diagnostic. Avoids re-parsing the document — the caller (ha-yaml-editor)
|
||||
* already has the error from its own js-yaml load() call.
|
||||
*
|
||||
* Stores the error and triggers forceLinting() so the yamlLintCompartment
|
||||
* linter re-runs and returns it as a diagnostic — rather than calling
|
||||
* setDiagnostics() which would wipe diagnostics from other linters.
|
||||
*/
|
||||
public setYamlError(
|
||||
err: {
|
||||
@@ -237,10 +210,27 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
reason?: string;
|
||||
} | null
|
||||
): void {
|
||||
this._yamlSyntaxError = err;
|
||||
if (this.codemirror && this._loadedCodeMirror) {
|
||||
this._loadedCodeMirror.forceLinting(this.codemirror);
|
||||
if (!this.codemirror || !this._loadedCodeMirror) return;
|
||||
let diagnostics: {
|
||||
from: number;
|
||||
to: number;
|
||||
severity: "error";
|
||||
message: string;
|
||||
}[] = [];
|
||||
if (err) {
|
||||
const doc = this.codemirror.state.doc;
|
||||
const pos = err.mark ? Math.min(err.mark.position, doc.length) : 0;
|
||||
const line = doc.lineAt(pos);
|
||||
const message = `${
|
||||
err.reason ||
|
||||
this._i18n?.localize("ui.components.yaml-editor.error") ||
|
||||
"YAML syntax error"
|
||||
}${err.mark ? ` (${this._i18n?.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
|
||||
diagnostics = [{ from: pos, to: line.to, severity: "error", message }];
|
||||
}
|
||||
this.codemirror.dispatch(
|
||||
this._loadedCodeMirror.setDiagnostics(this.codemirror.state, diagnostics)
|
||||
);
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
@@ -301,7 +291,9 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
effects: [
|
||||
this._loadedCodeMirror!.langCompartment!.reconfigure(this._mode),
|
||||
this._loadedCodeMirror!.yamlLintCompartment!.reconfigure(
|
||||
this._buildYamlSyntaxLinter()
|
||||
this.lint && !this.readOnly
|
||||
? [this._loadedCodeMirror!.lintGutter()]
|
||||
: []
|
||||
),
|
||||
],
|
||||
});
|
||||
@@ -313,23 +305,20 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
this._loadedCodeMirror!.EditorView!.editable.of(!this.readOnly)
|
||||
),
|
||||
this._loadedCodeMirror!.yamlLintCompartment!.reconfigure(
|
||||
this._buildYamlSyntaxLinter()
|
||||
this.lint && !this.readOnly
|
||||
? [this._loadedCodeMirror!.lintGutter()]
|
||||
: []
|
||||
),
|
||||
],
|
||||
});
|
||||
this._updateToolbarButtons();
|
||||
}
|
||||
if (changedProps.has("lint") || changedProps.has("yamlFieldSchema")) {
|
||||
if (changedProps.has("lint")) {
|
||||
transactions.push({
|
||||
effects: this._loadedCodeMirror!.yamlLintCompartment!.reconfigure(
|
||||
this._buildYamlSyntaxLinter()
|
||||
),
|
||||
});
|
||||
}
|
||||
if (changedProps.has("yamlFieldSchema") || changedProps.has("readOnly")) {
|
||||
transactions.push({
|
||||
effects: this._loadedCodeMirror!.yamlSchemaCompartment!.reconfigure(
|
||||
this._buildSchemaLinter()
|
||||
this.lint && !this.readOnly
|
||||
? [this._loadedCodeMirror!.lintGutter()]
|
||||
: []
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -383,65 +372,6 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
return this._loadedCodeMirror!.langs[this.mode];
|
||||
}
|
||||
|
||||
private _buildSchemaLinter() {
|
||||
if (!this._loadedCodeMirror || !this.yamlFieldSchema || this.readOnly) {
|
||||
return [];
|
||||
}
|
||||
const schema = this.yamlFieldSchema;
|
||||
return [
|
||||
this._loadedCodeMirror.linter(
|
||||
(view) =>
|
||||
this._loadedCodeMirror!.haYamlLintSource(
|
||||
view,
|
||||
schema,
|
||||
this._i18n?.localize
|
||||
),
|
||||
{ delay: 500 }
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the yamlLintCompartment extensions: a linter that surfaces the
|
||||
* stored _yamlSyntaxError (set by setYamlError), plus the lint gutter when
|
||||
* either syntax linting or schema linting is active.
|
||||
*
|
||||
* Using a linter() instead of setDiagnostics() means this linter's
|
||||
* diagnostics are managed independently of the schema linter's diagnostics —
|
||||
* they don't overwrite each other.
|
||||
*/
|
||||
private _buildYamlSyntaxLinter() {
|
||||
if (this.readOnly) return [];
|
||||
const showGutter = this.lint || !!this.yamlFieldSchema;
|
||||
const extensions: Extension[] = [];
|
||||
if (showGutter) {
|
||||
extensions.push(this._loadedCodeMirror!.lintGutter());
|
||||
}
|
||||
if (this.lint) {
|
||||
extensions.push(
|
||||
this._loadedCodeMirror!.linter(
|
||||
(view) => {
|
||||
const err = this._yamlSyntaxError;
|
||||
if (!err) return [];
|
||||
const doc = view.state.doc;
|
||||
const pos = err.mark ? Math.min(err.mark.position, doc.length) : 0;
|
||||
const line = doc.lineAt(pos);
|
||||
const message = `${
|
||||
err.reason ||
|
||||
this._i18n?.localize("ui.components.yaml-editor.error") ||
|
||||
"YAML syntax error"
|
||||
}${err.mark ? ` (${this._i18n?.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
|
||||
return [
|
||||
{ from: pos, to: line.to, severity: "error" as const, message },
|
||||
];
|
||||
},
|
||||
{ delay: 0 }
|
||||
)
|
||||
);
|
||||
}
|
||||
return extensions;
|
||||
}
|
||||
|
||||
private _createCodeMirror() {
|
||||
if (!this._loadedCodeMirror) {
|
||||
throw new Error("Cannot create editor before CodeMirror is loaded");
|
||||
@@ -490,10 +420,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
this.linewrap ? this._loadedCodeMirror.EditorView.lineWrapping : []
|
||||
),
|
||||
this._loadedCodeMirror.yamlLintCompartment.of(
|
||||
this._buildYamlSyntaxLinter()
|
||||
),
|
||||
this._loadedCodeMirror.yamlSchemaCompartment.of(
|
||||
this._buildSchemaLinter()
|
||||
this.lint && !this.readOnly ? [this._loadedCodeMirror.lintGutter()] : []
|
||||
),
|
||||
this._loadedCodeMirror.EditorView.updateListener.of(this._onUpdate),
|
||||
this._loadedCodeMirror.tooltips({
|
||||
@@ -509,21 +436,6 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
),
|
||||
{ hoverTime: 300 }
|
||||
),
|
||||
...(this.mode === "yaml"
|
||||
? [
|
||||
this._loadedCodeMirror.hoverTooltip(
|
||||
(view, pos) =>
|
||||
this.yamlFieldSchema
|
||||
? this._loadedCodeMirror!.haYamlHoverSource(view, pos, {
|
||||
schema: this.yamlFieldSchema,
|
||||
localize: this._i18n?.localize,
|
||||
hassContext: this._hassArgHoverContext(),
|
||||
})
|
||||
: null,
|
||||
{ hoverTime: 300 }
|
||||
),
|
||||
]
|
||||
: []),
|
||||
...(this.placeholder ? [placeholder(this.placeholder)] : []),
|
||||
];
|
||||
|
||||
@@ -531,19 +443,6 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
const completionSources: CompletionSource[] = [
|
||||
this._loadedCodeMirror.haJinjaCompletionSource,
|
||||
];
|
||||
if (this.mode === "yaml") {
|
||||
completionSources.push(
|
||||
this._loadedCodeMirror.haYamlCompletionSource(() => ({
|
||||
schema: this.yamlFieldSchema,
|
||||
localize: this._i18n?.localize,
|
||||
states: this._states,
|
||||
devices: this._registries?.devices,
|
||||
areas: this._registries?.areas,
|
||||
floors: this._registries?.floors,
|
||||
labels: this._labels,
|
||||
}))
|
||||
);
|
||||
}
|
||||
if (this.autocompleteEntities) {
|
||||
completionSources.push(this._entityCompletions.bind(this));
|
||||
}
|
||||
@@ -554,7 +453,6 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
this._loadedCodeMirror.autocompletion({
|
||||
override: completionSources,
|
||||
maxRenderedOptions: 10,
|
||||
activateOnCompletion: (completion) => completion.type === "yaml-key",
|
||||
}),
|
||||
this._loadedCodeMirror.closeBrackets(),
|
||||
this._loadedCodeMirror.closeBracketsOverride,
|
||||
@@ -1143,9 +1041,23 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
});
|
||||
};
|
||||
|
||||
private _getStates = memoizeOne((states: HassEntities): Completion[] =>
|
||||
buildEntityCompletions(states)
|
||||
);
|
||||
private _getStates = memoizeOne((states: HassEntities): Completion[] => {
|
||||
if (!states) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const options = Object.keys(states).map((key) => ({
|
||||
type: "variable",
|
||||
label: states[key].attributes.friendly_name
|
||||
? `${states[key].attributes.friendly_name} ${key}` // label is used for searching, so include both name and entity_id here
|
||||
: key,
|
||||
displayLabel: key,
|
||||
detail: states[key].attributes.friendly_name,
|
||||
apply: key,
|
||||
}));
|
||||
|
||||
return options;
|
||||
});
|
||||
|
||||
// Map of HA Jinja function name → (arg index → JinjaArgType).
|
||||
// Derived from the snippet definitions in jinja_ha_completions.ts.
|
||||
@@ -1540,7 +1452,18 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
|
||||
private _getDevices = memoizeOne(
|
||||
(devices: HomeAssistant["devices"]): Completion[] =>
|
||||
buildDeviceCompletions(devices)
|
||||
Object.values(devices)
|
||||
.filter((device) => !device.disabled_by)
|
||||
.map((device) => {
|
||||
const name = computeDeviceName(device);
|
||||
return {
|
||||
type: "variable",
|
||||
label: `${name} ${device.id}`,
|
||||
displayLabel: name ?? device.id,
|
||||
detail: device.id,
|
||||
apply: device.id,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
/** Build a CompletionResult for device IDs, with `from` set inside the quotes. */
|
||||
@@ -1559,7 +1482,17 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
}
|
||||
|
||||
private _getAreas = memoizeOne(
|
||||
(areas: HomeAssistant["areas"]): Completion[] => buildAreaCompletions(areas)
|
||||
(areas: HomeAssistant["areas"]): Completion[] =>
|
||||
Object.values(areas).map((area) => {
|
||||
const name = computeAreaName(area) ?? area.area_id;
|
||||
return {
|
||||
type: "variable",
|
||||
label: `${name} ${area.area_id}`, // label is used for searching, so include both name and ID here
|
||||
displayLabel: name,
|
||||
detail: area.area_id,
|
||||
apply: area.area_id,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
/** Build a CompletionResult for area IDs, with `from` set inside the quotes. */
|
||||
@@ -1579,7 +1512,16 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
|
||||
private _getFloors = memoizeOne(
|
||||
(floors: HomeAssistant["floors"]): Completion[] =>
|
||||
buildFloorCompletions(floors)
|
||||
Object.values(floors).map((floor) => {
|
||||
const name = computeFloorName(floor) ?? floor.floor_id;
|
||||
return {
|
||||
type: "variable",
|
||||
label: `${name} ${floor.floor_id}`, // label is used for searching, so include both name and ID here
|
||||
displayLabel: name,
|
||||
detail: floor.floor_id,
|
||||
apply: floor.floor_id,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
/** Build a CompletionResult for floor IDs, with `from` set inside the quotes. */
|
||||
@@ -1599,7 +1541,16 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
|
||||
private _getLabels = memoizeOne(
|
||||
(labels: LabelRegistryEntry[]): Completion[] =>
|
||||
buildLabelCompletions(labels)
|
||||
labels.map((label) => {
|
||||
const name = label.name.trim() || label.label_id;
|
||||
return {
|
||||
type: "variable",
|
||||
label: `${name} ${label.label_id}`, // label is used for searching, so include both name and ID here
|
||||
displayLabel: name,
|
||||
detail: label.label_id,
|
||||
apply: label.label_id,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
/** Build a CompletionResult for label IDs, with `from` set inside the quotes. */
|
||||
|
||||
@@ -334,6 +334,22 @@ export class HaControlSelect extends LitElement {
|
||||
width: 100%;
|
||||
hyphens: auto;
|
||||
}
|
||||
:host(:not([vertical])) .option {
|
||||
min-width: 0;
|
||||
}
|
||||
:host(:not([vertical])) .option .content {
|
||||
padding-inline: var(--ha-space-1);
|
||||
}
|
||||
:host(:not([vertical])) .option .content span {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
hyphens: manual;
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
}
|
||||
:host([vertical]) {
|
||||
width: var(--control-select-thickness);
|
||||
height: auto;
|
||||
|
||||
@@ -108,6 +108,10 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
|
||||
@property({ attribute: "header-title" })
|
||||
public headerTitle?: string;
|
||||
|
||||
/** Accessible name for dialogs that put their title in a slot. */
|
||||
@property({ attribute: false })
|
||||
public accessibleLabel?: string;
|
||||
|
||||
@property({ attribute: "header-subtitle" })
|
||||
public headerSubtitle?: string;
|
||||
|
||||
@@ -157,15 +161,16 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
|
||||
}
|
||||
|
||||
protected render() {
|
||||
// The title lives in a shadow root that an aria-labelledby IDREF on
|
||||
// wa-dialog's inner <dialog> can't reach. Name the dialog with the title
|
||||
// text instead, which wa-dialog uses as aria-label without its own header.
|
||||
return html`
|
||||
<wa-dialog
|
||||
.open=${this._open}
|
||||
.lightDismiss=${!this.preventScrimClose}
|
||||
without-header
|
||||
aria-labelledby=${ifDefined(
|
||||
this.ariaLabelledBy ||
|
||||
(this.headerTitle !== undefined ? "ha-dialog-title" : undefined)
|
||||
)}
|
||||
.label=${this.accessibleLabel ?? this.headerTitle ?? ""}
|
||||
aria-labelledby=${ifDefined(this.ariaLabelledBy)}
|
||||
aria-describedby=${ifDefined(this.ariaDescribedBy)}
|
||||
@keydown=${this._handleKeyDown}
|
||||
@wa-hide=${this._handleHide}
|
||||
|
||||
@@ -59,6 +59,13 @@ export class HaDropdownItem extends DropdownItem {
|
||||
:host([selected]:hover) {
|
||||
background-color: var(--ha-color-fill-primary-quiet-hover);
|
||||
}
|
||||
|
||||
/* Long submenus, like the label list on a phone, scroll instead of
|
||||
running off the screen. */
|
||||
#submenu[popover] {
|
||||
max-height: calc(100dvh - var(--ha-space-4));
|
||||
overflow-y: auto;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -110,6 +110,22 @@ export class HaDropdown extends Dropdown {
|
||||
wa-popup::part(popup) {
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
/* The auto size ignores safe areas, so a long menu could end up
|
||||
behind the Android navigation bar or the notch. */
|
||||
wa-popup[data-current-placement^="bottom"] #menu {
|
||||
max-height: calc(
|
||||
var(--auto-size-available-height) - var(
|
||||
--safe-area-inset-bottom,
|
||||
0px
|
||||
)
|
||||
) !important;
|
||||
}
|
||||
wa-popup[data-current-placement^="top"] #menu {
|
||||
max-height: calc(
|
||||
var(--auto-size-available-height) - var(--safe-area-inset-top, 0px)
|
||||
) !important;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -335,11 +335,10 @@ class HaHLSPlayer extends LitElement {
|
||||
lowLatencyMode: this._isLLHLSSupported(),
|
||||
});
|
||||
this._hlsPolyfillInstance = hls;
|
||||
// Load the source once, before attaching. MEDIA_ATTACHED fires again on
|
||||
// every recoverMediaError(), and loading there would restart the stream.
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(videoEl);
|
||||
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||
this._resetError();
|
||||
hls.loadSource(url);
|
||||
});
|
||||
hls.on(Hls.Events.FRAG_LOADED, (_event, _data: any) => {
|
||||
this._resetError();
|
||||
});
|
||||
|
||||
@@ -12,7 +12,9 @@ export class HaIconButton extends LitElement {
|
||||
// SVG icon path (if you need a non SVG icon instead, use the provided slot to pass an <ha-icon> in)
|
||||
@property({ type: String }) path?: string;
|
||||
|
||||
// Label that is used for ARIA support and as tooltip
|
||||
// Label that is used for ARIA support and as tooltip. Passed to ha-button as
|
||||
// properties, so only its inner button gets them: as attributes on the
|
||||
// ha-button host, screen readers also announce the host as a named group.
|
||||
@property({ type: String }) label?: string;
|
||||
|
||||
// These should always be set as properties, not attributes,
|
||||
@@ -42,8 +44,8 @@ export class HaIconButton extends LitElement {
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
variant="neutral"
|
||||
aria-label=${ifDefined(this.label)}
|
||||
title=${ifDefined(this.hideTitle ? undefined : this.label)}
|
||||
.ariaLabel=${this.label ?? null}
|
||||
.title=${this.hideTitle ? "" : (this.label ?? "")}
|
||||
aria-haspopup=${ifDefined(this.ariaHasPopup)}
|
||||
.disabled=${this.disabled}
|
||||
.iconTag=${this.path ? "ha-svg-icon" : "span"}
|
||||
|
||||
@@ -16,16 +16,13 @@ import "../ha-input-helper-text";
|
||||
import "../ha-select-box";
|
||||
import type { SelectBoxOption } from "../ha-select-box";
|
||||
|
||||
export const TRIGGER_BEHAVIORS: AutomationBehaviorTriggerMode[] = [
|
||||
const TRIGGER_BEHAVIORS: AutomationBehaviorTriggerMode[] = [
|
||||
"each",
|
||||
"first",
|
||||
"all",
|
||||
];
|
||||
|
||||
export const CONDITION_BEHAVIORS: AutomationBehaviorConditionMode[] = [
|
||||
"any",
|
||||
"all",
|
||||
];
|
||||
const CONDITION_BEHAVIORS: AutomationBehaviorConditionMode[] = ["any", "all"];
|
||||
|
||||
@customElement("ha-selector-automation_behavior")
|
||||
export class HaSelectorAutomationBehavior extends LitElement {
|
||||
|
||||
@@ -100,7 +100,7 @@ export class HaNumberSelector extends LitElement {
|
||||
labeled
|
||||
.min=${this.selector.number!.min}
|
||||
.max=${this.selector.number!.max}
|
||||
.value=${this.value ?? this.placeholder}
|
||||
.value=${this.value}
|
||||
.step=${sliderStep}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
|
||||
@@ -79,6 +79,10 @@ const isFullWidthSelector = (selector?: Selector): boolean =>
|
||||
FULL_WIDTH_SELECTOR_TYPES.has(selectorType)
|
||||
);
|
||||
|
||||
// A switch fits next to its label, even when the editor is narrow.
|
||||
const isBooleanSelector = (selector?: Selector): boolean =>
|
||||
!!selector && "boolean" in selector;
|
||||
|
||||
interface Field extends Omit<HassService["fields"][string], "selector"> {
|
||||
key: string;
|
||||
selector?: Selector;
|
||||
@@ -693,7 +697,10 @@ export class HaServiceControl extends LitElement {
|
||||
|
||||
return dataField.selector
|
||||
? html`<ha-settings-row
|
||||
.narrow=${this.narrow || isFullWidthSelector(selector)}
|
||||
.narrow=${
|
||||
(this.narrow && !isBooleanSelector(selector)) ||
|
||||
isFullWidthSelector(selector)
|
||||
}
|
||||
>
|
||||
${
|
||||
!showOptional
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { isSwipeLocked } from "../common/dom/is-swipe-locked";
|
||||
import { popoverSupported } from "../common/feature-detect/support-popover";
|
||||
import { SwipeGestureRecognizer } from "../common/util/swipe-gesture-recognizer";
|
||||
|
||||
export type BottomSheetPosition = "collapsed" | "half" | "full";
|
||||
|
||||
const HALF_VIEWPORT_SHARE = 0.5;
|
||||
|
||||
// How far a touch moves before its direction decides what it drags
|
||||
const TOUCH_SLOP_PX = 4;
|
||||
|
||||
interface Stop {
|
||||
position: BottomSheetPosition;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A bottom sheet that slides up from the bottom of the screen without blocking
|
||||
* the page behind it. It sits in the top layer, above the toolbar, like other
|
||||
* sheets; a backdrop fades in as it rises from half to full height, and a tap
|
||||
* on it brings the sheet back to half.
|
||||
*
|
||||
* It rests collapsed, at half or at full height and snaps to the nearest of
|
||||
* them when a drag ends, or to the next one in the direction of a quick swipe.
|
||||
* The handle drags with a mouse or a finger. On touch the whole sheet drags
|
||||
* too, until it is at full height: then its content scrolls, and pulling down
|
||||
* from the top brings the sheet down again. Dragging down never closes it; it
|
||||
* collapses to `minHeight` so a host keeps a strip of content reachable, and
|
||||
* only `closeSheet` closes it.
|
||||
*
|
||||
* @fires bottom-sheet-closed - Fired when the bottom sheet is closed
|
||||
* @fires bottom-sheet-resized - Fired with the sheet's height in pixels
|
||||
* whenever it settles at a new height, or when `halfHeight` changes
|
||||
*
|
||||
* @cssprop --ha-bottom-sheet-handle-padding - How far below the handle the
|
||||
* grab area reaches; shrink it when content sits right under the handle
|
||||
*/
|
||||
@customElement("ha-snap-bottom-sheet")
|
||||
export class HaSnapBottomSheet extends LitElement {
|
||||
@query(".sheet") private _sheet!: HTMLElement;
|
||||
|
||||
@query(".backdrop") private _backdrop!: HTMLElement;
|
||||
|
||||
@query(".handle") private _handle!: HTMLElement;
|
||||
|
||||
/**
|
||||
* The height in pixels of the strip the collapsed sheet keeps visible, e.g.
|
||||
* what a host measures; the sheet adds the bottom safe area below it.
|
||||
* Without it the sheet does not collapse.
|
||||
*/
|
||||
@property({ type: Number, attribute: "min-height" })
|
||||
public minHeight?: number;
|
||||
|
||||
/** Names the sheet for assistive technologies */
|
||||
@property() public label?: string;
|
||||
|
||||
@state() private _shown = false;
|
||||
|
||||
@state() private _position: BottomSheetPosition = "half";
|
||||
|
||||
@state() private _dragHeight?: number;
|
||||
|
||||
private _gesture = new SwipeGestureRecognizer();
|
||||
|
||||
private _dragging = false;
|
||||
|
||||
private _dragStartHeight = 0;
|
||||
|
||||
// A touch on the content that has not moved yet, so it is unknown whether
|
||||
// it drags the sheet, scrolls the content or is a tap
|
||||
private _pendingTouch?: { x: number; y: number };
|
||||
|
||||
private _reported?: { height: number; halfHeight: number };
|
||||
|
||||
private _closing = false;
|
||||
|
||||
public get position(): BottomSheetPosition {
|
||||
return this._position;
|
||||
}
|
||||
|
||||
public get halfHeight(): number {
|
||||
return window.innerHeight * HALF_VIEWPORT_SHARE;
|
||||
}
|
||||
|
||||
public snapTo(position: BottomSheetPosition) {
|
||||
this._position = position;
|
||||
}
|
||||
|
||||
public async closeSheet() {
|
||||
if (this._closing) {
|
||||
return;
|
||||
}
|
||||
this._closing = true;
|
||||
this._shown = false;
|
||||
await this._transitionsDone();
|
||||
if (popoverSupported) {
|
||||
this._sheet.hidePopover();
|
||||
this._backdrop.hidePopover();
|
||||
}
|
||||
fireEvent(this, "bottom-sheet-closed");
|
||||
}
|
||||
|
||||
render() {
|
||||
const full = this._position === "full";
|
||||
const dragging = this._dragHeight !== undefined;
|
||||
const popover = ifDefined(popoverSupported ? "manual" : undefined);
|
||||
return html`<div
|
||||
class="backdrop ${classMap({ active: this._shown && full, dragging })}"
|
||||
popover=${popover}
|
||||
style=${styleMap({ opacity: String(this._backdropOpacity()) })}
|
||||
@click=${this._handleBackdropClick}
|
||||
></div>
|
||||
<div
|
||||
class="sheet ${classMap({ show: this._shown, full, dragging })}"
|
||||
popover=${popover}
|
||||
role="region"
|
||||
aria-label=${ifDefined(this.label)}
|
||||
style=${styleMap({
|
||||
height: this._height(),
|
||||
"min-height": this._collapsedHeight(),
|
||||
})}
|
||||
@touchstart=${this._handleTouchStart}
|
||||
>
|
||||
<div class="handle-wrapper">
|
||||
<div @mousedown=${this._handleMouseDown} class="handle"></div>
|
||||
</div>
|
||||
<slot></slot>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProperties);
|
||||
this._showPopovers();
|
||||
requestAnimationFrame(() => {
|
||||
if (!this._closing) {
|
||||
this._shown = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected updated(changedProperties: PropertyValues) {
|
||||
super.updated(changedProperties);
|
||||
if (
|
||||
changedProperties.has("_position") ||
|
||||
changedProperties.has("minHeight") ||
|
||||
(changedProperties.has("_dragHeight") && this._dragHeight === undefined)
|
||||
) {
|
||||
this._reportWhenSettled();
|
||||
}
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
// Leaving the page, e.g. with a cached dashboard view, took the sheet out
|
||||
// of the top layer, and the host may have dropped its height meanwhile
|
||||
if (this.hasUpdated && this._shown) {
|
||||
this._showPopovers();
|
||||
this._reported = undefined;
|
||||
this._reportWhenSettled();
|
||||
}
|
||||
document.addEventListener("mousemove", this._handleMouseMove);
|
||||
document.addEventListener("mouseup", this._handleMouseUp);
|
||||
document.addEventListener("touchmove", this._handleTouchMove, {
|
||||
passive: false,
|
||||
});
|
||||
document.addEventListener("touchend", this._handleTouchEnd);
|
||||
document.addEventListener("touchcancel", this._handleTouchEnd);
|
||||
window.addEventListener("resize", this._handleViewportResize);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this._dragging) {
|
||||
document.body.style.removeProperty("cursor");
|
||||
}
|
||||
this._dragging = false;
|
||||
this._pendingTouch = undefined;
|
||||
this._dragHeight = undefined;
|
||||
document.removeEventListener("mousemove", this._handleMouseMove);
|
||||
document.removeEventListener("mouseup", this._handleMouseUp);
|
||||
document.removeEventListener("touchmove", this._handleTouchMove);
|
||||
document.removeEventListener("touchend", this._handleTouchEnd);
|
||||
document.removeEventListener("touchcancel", this._handleTouchEnd);
|
||||
window.removeEventListener("resize", this._handleViewportResize);
|
||||
}
|
||||
|
||||
private _collapsedHeight(): string | undefined {
|
||||
return this.minHeight === undefined
|
||||
? undefined
|
||||
: `calc(${this.minHeight}px + var(--safe-area-inset-bottom, 0px))`;
|
||||
}
|
||||
|
||||
private _height(): string | undefined {
|
||||
if (this._dragHeight !== undefined) {
|
||||
return `${this._dragHeight}px`;
|
||||
}
|
||||
return this._position === "collapsed" ? this._collapsedHeight() : undefined;
|
||||
}
|
||||
|
||||
private _showPopovers() {
|
||||
if (!popoverSupported) {
|
||||
return;
|
||||
}
|
||||
// Shown first so the sheet stacks above it
|
||||
for (const element of [this._backdrop, this._sheet]) {
|
||||
if (!element.matches(":popover-open")) {
|
||||
element.showPopover();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _backdropOpacity(): number {
|
||||
if (!this._shown) {
|
||||
return 0;
|
||||
}
|
||||
if (this._dragHeight === undefined) {
|
||||
return this._position === "full" ? 1 : 0;
|
||||
}
|
||||
const half = this.halfHeight;
|
||||
const progress = (this._dragHeight - half) / (this._fullHeight() - half);
|
||||
return Math.min(1, Math.max(0, progress));
|
||||
}
|
||||
|
||||
private _handleBackdropClick = () => {
|
||||
this._position = "half";
|
||||
};
|
||||
|
||||
// The half and full heights follow the viewport, for example on rotation
|
||||
private _handleViewportResize = () => {
|
||||
this._reportWhenSettled();
|
||||
};
|
||||
|
||||
private _handleMouseDown = (ev: MouseEvent) => {
|
||||
document.body.style.setProperty("cursor", "grabbing");
|
||||
this._startDrag(ev.clientY);
|
||||
};
|
||||
|
||||
private _handleMouseMove = (ev: MouseEvent) => {
|
||||
if (this._dragging) {
|
||||
this._moveDrag(ev.clientY);
|
||||
}
|
||||
};
|
||||
|
||||
private _handleMouseUp = () => {
|
||||
this._endDrag();
|
||||
};
|
||||
|
||||
private _handleTouchStart = (ev: TouchEvent) => {
|
||||
if (ev.touches.length !== 1) {
|
||||
return;
|
||||
}
|
||||
const touch = ev.touches[0];
|
||||
const path = ev.composedPath();
|
||||
if (path.includes(this._handle)) {
|
||||
// Prevent the browser from interpreting this as a scroll/PTR gesture.
|
||||
ev.preventDefault();
|
||||
this._startDrag(touch.clientY);
|
||||
return;
|
||||
}
|
||||
const content = path.slice(0, path.indexOf(this._sheet));
|
||||
// Dialogs and menus opened inside the content handle their own swipes
|
||||
if (
|
||||
content.some(
|
||||
(target) =>
|
||||
target instanceof HTMLDialogElement ||
|
||||
(popoverSupported &&
|
||||
target instanceof HTMLElement &&
|
||||
target.matches(":popover-open"))
|
||||
) ||
|
||||
isSwipeLocked(content, this._sheet)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this._pendingTouch = { x: touch.clientX, y: touch.clientY };
|
||||
};
|
||||
|
||||
private _handleTouchMove = (ev: TouchEvent) => {
|
||||
const touch = ev.touches[0];
|
||||
if (this._pendingTouch) {
|
||||
const start = this._pendingTouch;
|
||||
const deltaX = touch.clientX - start.x;
|
||||
const deltaY = touch.clientY - start.y;
|
||||
if (ev.cancelable && Math.hypot(deltaX, deltaY) < TOUCH_SLOP_PX) {
|
||||
return;
|
||||
}
|
||||
this._pendingTouch = undefined;
|
||||
// Sideways moves and scrolls the browser already started stay with the
|
||||
// content, and so does scrolling up once the sheet is at full height
|
||||
if (
|
||||
!ev.cancelable ||
|
||||
Math.abs(deltaX) > Math.abs(deltaY) ||
|
||||
(deltaY < 0 && this._position === "full")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this._startDrag(start.y);
|
||||
}
|
||||
if (!this._dragging) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
this._moveDrag(touch.clientY);
|
||||
};
|
||||
|
||||
private _handleTouchEnd = () => {
|
||||
this._pendingTouch = undefined;
|
||||
this._endDrag();
|
||||
};
|
||||
|
||||
private _startDrag(clientY: number) {
|
||||
this._dragging = true;
|
||||
this._dragStartHeight = this._sheet.offsetHeight;
|
||||
this._gesture.start(clientY);
|
||||
}
|
||||
|
||||
private _moveDrag(clientY: number) {
|
||||
const delta = this._gesture.move(clientY);
|
||||
this._dragHeight = Math.max(
|
||||
this._stops()[0].height,
|
||||
this._dragStartHeight + delta
|
||||
);
|
||||
}
|
||||
|
||||
private _endDrag() {
|
||||
if (!this._dragging) {
|
||||
return;
|
||||
}
|
||||
this._dragging = false;
|
||||
document.body.style.removeProperty("cursor");
|
||||
const { isSwipe, isDownwardSwipe } = this._gesture.end();
|
||||
this._position = this._stopAfterDrag(
|
||||
this._sheet.offsetHeight,
|
||||
isSwipe ? (isDownwardSwipe ? "down" : "up") : undefined
|
||||
);
|
||||
this._dragHeight = undefined;
|
||||
}
|
||||
|
||||
// A swipe goes on to the next stop in its direction, a slow drag settles on
|
||||
// the nearest one
|
||||
private _stopAfterDrag(
|
||||
height: number,
|
||||
direction?: "up" | "down"
|
||||
): BottomSheetPosition {
|
||||
const stops = this._stops();
|
||||
if (direction === "up") {
|
||||
return (
|
||||
stops.find((stop) => stop.height > height) ?? stops[stops.length - 1]
|
||||
).position;
|
||||
}
|
||||
if (direction === "down") {
|
||||
return (
|
||||
[...stops].reverse().find((stop) => stop.height < height) ?? stops[0]
|
||||
).position;
|
||||
}
|
||||
return stops.reduce((nearest, stop) =>
|
||||
Math.abs(stop.height - height) < Math.abs(nearest.height - height)
|
||||
? stop
|
||||
: nearest
|
||||
).position;
|
||||
}
|
||||
|
||||
private _stops(): Stop[] {
|
||||
const stops: Stop[] = [];
|
||||
if (this.minHeight !== undefined) {
|
||||
stops.push({
|
||||
position: "collapsed",
|
||||
height: parseFloat(getComputedStyle(this._sheet).minHeight),
|
||||
});
|
||||
}
|
||||
stops.push(
|
||||
{ position: "half", height: this.halfHeight },
|
||||
{ position: "full", height: this._fullHeight() }
|
||||
);
|
||||
return stops;
|
||||
}
|
||||
|
||||
private _fullHeight(): number {
|
||||
return parseFloat(getComputedStyle(this._sheet).maxHeight);
|
||||
}
|
||||
|
||||
private async _transitionsDone(): Promise<boolean> {
|
||||
await this.updateComplete;
|
||||
try {
|
||||
await Promise.all(
|
||||
this._sheet.getAnimations().map((animation) => animation.finished)
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
// A newer change interrupted the transition and settles on its own
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Hosts lay out around the sheet once it settles, not on every frame
|
||||
private async _reportWhenSettled() {
|
||||
if (!(await this._transitionsDone()) || this._dragging) {
|
||||
return;
|
||||
}
|
||||
const height = this._sheet.offsetHeight;
|
||||
const halfHeight = this.halfHeight;
|
||||
if (
|
||||
height === this._reported?.height &&
|
||||
halfHeight === this._reported.halfHeight
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this._reported = { height, halfHeight };
|
||||
fireEvent(this, "bottom-sheet-resized", { height });
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.handle-wrapper {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
padding-bottom: 2px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
.handle-wrapper .handle {
|
||||
height: 20px;
|
||||
width: 200px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 7;
|
||||
/* Extends the grab area over the content below the handle */
|
||||
padding-bottom: var(--ha-bottom-sheet-handle-padding, 76px);
|
||||
}
|
||||
.handle-wrapper .handle::after {
|
||||
content: "";
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
height: 4px;
|
||||
background: var(--ha-bottom-sheet-handle-color, var(--divider-color));
|
||||
width: 40px;
|
||||
}
|
||||
.handle-wrapper .handle:active::after {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.sheet {
|
||||
height: 50vh;
|
||||
height: 50dvh;
|
||||
max-height: calc(100vh - max(var(--safe-area-inset-top, 0px), 48px));
|
||||
max-height: calc(100dvh - max(var(--safe-area-inset-top, 0px), 48px));
|
||||
background-color: var(
|
||||
--ha-bottom-sheet-surface-background,
|
||||
var(
|
||||
--ha-dialog-surface-background,
|
||||
var(--card-background-color, var(--ha-color-surface-default))
|
||||
)
|
||||
);
|
||||
backdrop-filter: var(
|
||||
--ha-bottom-sheet-surface-backdrop-filter,
|
||||
var(--ha-dialog-surface-backdrop-filter, none)
|
||||
);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
top: 0;
|
||||
inset-inline-start: 0;
|
||||
position: fixed;
|
||||
--sheet-inset-left: var(
|
||||
--ha-bottom-sheet-inset-left,
|
||||
var(--safe-area-inset-left)
|
||||
);
|
||||
--sheet-inset-right: var(
|
||||
--ha-bottom-sheet-inset-right,
|
||||
var(--safe-area-inset-right)
|
||||
);
|
||||
width: calc(100% - var(--sheet-inset-left) - var(--sheet-inset-right));
|
||||
max-width: 100%;
|
||||
border: none;
|
||||
box-shadow: var(--wa-shadow-l);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
top: auto;
|
||||
inset-inline-end: auto;
|
||||
bottom: 0;
|
||||
inset-inline-start: 0;
|
||||
box-shadow: 0px -8px 16px rgba(0, 0, 0, 0.2);
|
||||
border-top-left-radius: var(
|
||||
--ha-bottom-sheet-border-radius,
|
||||
var(--ha-dialog-border-radius, var(--ha-border-radius-2xl))
|
||||
);
|
||||
border-top-right-radius: var(
|
||||
--ha-bottom-sheet-border-radius,
|
||||
var(--ha-dialog-border-radius, var(--ha-border-radius-2xl))
|
||||
);
|
||||
transform: translateY(100%);
|
||||
transition:
|
||||
transform var(--ha-animation-duration-normal) ease,
|
||||
height var(--ha-animation-duration-normal) ease;
|
||||
margin-left: var(--sheet-inset-left);
|
||||
margin-right: var(--sheet-inset-right);
|
||||
outline: none;
|
||||
overflow: visible;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
-webkit-backdrop-filter: var(
|
||||
--ha-bottom-sheet-scrim-backdrop-filter,
|
||||
var(
|
||||
--ha-dialog-scrim-backdrop-filter,
|
||||
var(--dialog-backdrop-filter, none)
|
||||
)
|
||||
);
|
||||
backdrop-filter: var(
|
||||
--ha-bottom-sheet-scrim-backdrop-filter,
|
||||
var(
|
||||
--ha-dialog-scrim-backdrop-filter,
|
||||
var(--dialog-backdrop-filter, none)
|
||||
)
|
||||
);
|
||||
background-color: var(
|
||||
--ha-bottom-sheet-scrim-color,
|
||||
var(--mdc-dialog-scrim-color, transparent)
|
||||
);
|
||||
pointer-events: none;
|
||||
transition: opacity var(--ha-animation-duration-normal) ease;
|
||||
}
|
||||
|
||||
.backdrop.active {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.backdrop.dragging {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.sheet.full {
|
||||
height: calc(100vh - max(var(--safe-area-inset-top, 0px), 48px));
|
||||
height: calc(100dvh - max(var(--safe-area-inset-top, 0px), 48px));
|
||||
}
|
||||
|
||||
.sheet.dragging {
|
||||
transition: transform var(--ha-animation-duration-normal) ease;
|
||||
}
|
||||
|
||||
.sheet.show {
|
||||
transform: translateY(0);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-snap-bottom-sheet": HaSnapBottomSheet;
|
||||
}
|
||||
|
||||
interface HASSDomEvents {
|
||||
"bottom-sheet-closed": undefined;
|
||||
"bottom-sheet-resized": { height: number };
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import { consume } from "../common/decorators/consume";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { copyToClipboard } from "../common/util/copy-clipboard";
|
||||
import { haStyle } from "../resources/styles";
|
||||
import type { YamlFieldSchemaMap } from "../resources/yaml_field_schema";
|
||||
import { showToast } from "../util/toast";
|
||||
import "./ha-button";
|
||||
import "./ha-code-editor";
|
||||
@@ -33,14 +32,6 @@ export class HaYamlEditor extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public yamlSchema: Schema = YAML11_SCHEMA;
|
||||
|
||||
/**
|
||||
* Optional field schema for YAML mode. When provided, the code editor will
|
||||
* offer field-aware key/value completions, hover tooltips, and linting.
|
||||
* This is forwarded directly to ha-code-editor.
|
||||
*/
|
||||
@property({ attribute: false })
|
||||
public yamlFieldSchema?: YamlFieldSchemaMap;
|
||||
|
||||
@property({ attribute: false }) public defaultValue?: any;
|
||||
|
||||
@property({ attribute: "is-valid", type: Boolean }) public isValid = true;
|
||||
@@ -132,9 +123,8 @@ export class HaYamlEditor extends LitElement {
|
||||
.inDialog=${this.inDialog}
|
||||
mode="yaml"
|
||||
lint
|
||||
.autocompleteEntities=${!this.yamlFieldSchema}
|
||||
autocomplete-entities
|
||||
autocomplete-icons
|
||||
.yamlFieldSchema=${this.yamlFieldSchema}
|
||||
.error=${this.isValid === false}
|
||||
@value-changed=${this._onChange}
|
||||
@editor-save=${this._onEditorSave}
|
||||
|
||||
@@ -176,8 +176,12 @@ export class HaInput extends WaInputMixin(LitElement) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Compare against wa-input, not our own value: a value set right before
|
||||
// (like clearing the field and focusing it again) only reaches the native
|
||||
// input on the next render, and must not be overwritten by the old text.
|
||||
const nativeValue = native.value;
|
||||
if ((this.value ?? "") === nativeValue) {
|
||||
const currentValue = this._input ? this._input.value : this.value;
|
||||
if ((currentValue ?? "") === nativeValue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -212,6 +216,22 @@ export class HaInput extends WaInputMixin(LitElement) {
|
||||
}
|
||||
}
|
||||
|
||||
protected override updated(changedProperties: PropertyValues<this>): void {
|
||||
super.updated(changedProperties);
|
||||
this._syncAriaInvalid();
|
||||
}
|
||||
|
||||
// wa-input never sets aria-invalid on its native input. Without it, WebKit
|
||||
// falls back to the native validity, so screen readers announce an empty
|
||||
// required field as invalid before anything was typed.
|
||||
private async _syncAriaInvalid(): Promise<void> {
|
||||
await this._input?.updateComplete;
|
||||
this._getNativeInput()?.setAttribute(
|
||||
"aria-invalid",
|
||||
this.invalid || this._invalid ? "true" : "false"
|
||||
);
|
||||
}
|
||||
|
||||
public override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener("focusin", this._syncFromNativeInput);
|
||||
@@ -423,7 +443,8 @@ export class HaInput extends WaInputMixin(LitElement) {
|
||||
|
||||
wa-input.label-raised::part(label),
|
||||
:host(:focus-within) wa-input::part(label),
|
||||
:host([type="date"]) wa-input::part(label) {
|
||||
:host([type="date"]) wa-input::part(label),
|
||||
:host([type="color"]) wa-input::part(label) {
|
||||
padding-top: var(--ha-space-3);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,33 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { LitElement, html, css } from "lit";
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { consumeEntityState } from "../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import "../ha-state-icon";
|
||||
|
||||
export const SELECTED_MARKER_SCALE = 1.25;
|
||||
// Floating: the frame hangs above a dot, a rotated-square tail between
|
||||
const FLOATING_TAIL_SIZE = 12;
|
||||
const FLOATING_TAIL_REACH = Math.round((FLOATING_TAIL_SIZE * Math.SQRT2) / 2);
|
||||
const FLOATING_GAP = 4;
|
||||
const FLOATING_DOT_SIZE = 10;
|
||||
export const FLOATING_LIFT = FLOATING_GAP + FLOATING_DOT_SIZE / 2;
|
||||
|
||||
export const floatingMarkerFootprint = (
|
||||
markerSize: number,
|
||||
selected: boolean
|
||||
): { size: [number, number]; anchor: [number, number] } => {
|
||||
const frame = selected
|
||||
? Math.round(markerSize * SELECTED_MARKER_SCALE)
|
||||
: markerSize;
|
||||
const height = frame + FLOATING_TAIL_REACH + FLOATING_GAP + FLOATING_DOT_SIZE;
|
||||
return {
|
||||
size: [frame, height],
|
||||
anchor: [frame / 2, height - FLOATING_DOT_SIZE / 2],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @csspart marker - The framed avatar.
|
||||
* @csspart picture - The entity picture inside the frame.
|
||||
@@ -30,13 +52,14 @@ class HaEntityMarker extends LitElement {
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public selected = false;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public floating = false;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<div
|
||||
part="marker"
|
||||
class="marker ${this.entityPicture ? "picture" : ""}"
|
||||
style=${styleMap({ "--ha-marker-selected-color": this.entityColor })}
|
||||
@click=${this._badgeTap}
|
||||
>
|
||||
${
|
||||
this.entityPicture
|
||||
@@ -63,16 +86,24 @@ class HaEntityMarker extends LitElement {
|
||||
`
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this.floating
|
||||
? html`<div class="tail"></div>
|
||||
<div class="dot"></div>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.addEventListener("click", this._badgeTap);
|
||||
this.addEventListener("keydown", this._handleKeydown);
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener("click", this._badgeTap);
|
||||
this.removeEventListener("keydown", this._handleKeydown);
|
||||
}
|
||||
|
||||
@@ -84,14 +115,35 @@ class HaEntityMarker extends LitElement {
|
||||
}
|
||||
};
|
||||
|
||||
private _badgeTap(ev: Event) {
|
||||
private _badgeTap = (ev: Event) => {
|
||||
ev.stopPropagation();
|
||||
if (this.entityId) {
|
||||
fireEvent(this, "hass-more-info", { entityId: this.entityId });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
static styles = css`
|
||||
:host([floating]) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
/* One shadow for the whole pin, so the frame casts none onto the tail */
|
||||
filter: var(
|
||||
--ha-cluster-shadow,
|
||||
drop-shadow(0 1px 3px rgba(0, 0, 0, 0.12))
|
||||
);
|
||||
}
|
||||
:host([floating]) {
|
||||
pointer-events: none;
|
||||
}
|
||||
:host([floating]) .marker,
|
||||
:host([floating]) .tail {
|
||||
pointer-events: auto;
|
||||
}
|
||||
:host([floating]) .marker {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.marker {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -104,7 +156,6 @@ class HaEntityMarker extends LitElement {
|
||||
border-radius: var(--ha-marker-border-radius, 50%);
|
||||
border: var(--ha-marker-border-width, 3px) solid
|
||||
var(--ha-marker-color, var(--card-background-color, #fff));
|
||||
box-shadow: var(--ha-marker-shadow, var(--ha-box-shadow-s));
|
||||
color: var(--primary-text-color);
|
||||
background-color: var(
|
||||
--ha-marker-background,
|
||||
@@ -114,10 +165,39 @@ class HaEntityMarker extends LitElement {
|
||||
.marker.picture {
|
||||
overflow: hidden;
|
||||
}
|
||||
/* A ring in the entity color outside the frame marks the selected marker */
|
||||
:host([selected]) .marker {
|
||||
width: calc(
|
||||
var(--ha-marker-size, 48px) *
|
||||
var(--ha-marker-selected-scale, ${SELECTED_MARKER_SCALE})
|
||||
);
|
||||
height: calc(
|
||||
var(--ha-marker-size, 48px) *
|
||||
var(--ha-marker-selected-scale, ${SELECTED_MARKER_SCALE})
|
||||
);
|
||||
}
|
||||
/* Floating, the accuracy circle carries the color instead of a ring */
|
||||
:host([selected]:not([floating])) .marker {
|
||||
outline: 3px solid var(--ha-marker-selected-color, var(--primary-color));
|
||||
}
|
||||
.tail {
|
||||
width: ${FLOATING_TAIL_SIZE}px;
|
||||
height: ${FLOATING_TAIL_SIZE}px;
|
||||
margin-top: ${-FLOATING_TAIL_SIZE / 2}px;
|
||||
margin-bottom: ${FLOATING_TAIL_REACH - FLOATING_TAIL_SIZE / 2}px;
|
||||
border-radius: 2px;
|
||||
background: var(--ha-marker-color, var(--card-background-color, #fff));
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.dot {
|
||||
width: ${FLOATING_DOT_SIZE}px;
|
||||
height: ${FLOATING_DOT_SIZE}px;
|
||||
margin-top: ${FLOATING_GAP}px;
|
||||
border-radius: 50%;
|
||||
background: var(--ha-marker-color, var(--card-background-color, #fff));
|
||||
}
|
||||
:host(:not([selected])) .dot {
|
||||
visibility: hidden;
|
||||
}
|
||||
.entity-picture {
|
||||
background-size: cover;
|
||||
height: 100%;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { transform } from "../../common/decorators/transform";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { MAP_MAX_ZOOM } from "../../common/map/base-layer";
|
||||
import type { MapLatLng } from "../../common/map/map-engine";
|
||||
import type { MapLatLng, MapView } from "../../common/map/map-engine";
|
||||
import { circleBoundsPoints } from "../../common/map/map-engine";
|
||||
import { internationalizationContext, uiContext } from "../../data/context";
|
||||
import type { Themes } from "../../data/ws-themes";
|
||||
@@ -47,6 +47,8 @@ export interface MarkerLocation {
|
||||
radius_editable?: boolean;
|
||||
/** Clicking or activating the marker fires marker-clicked */
|
||||
clickable?: boolean;
|
||||
/** Counts toward the map fit; defaults to true */
|
||||
fit?: boolean;
|
||||
}
|
||||
|
||||
const ICON_SIZE = 24;
|
||||
@@ -92,6 +94,16 @@ export class HaLocationsEditor extends LitElement {
|
||||
this.map.fitMap(options);
|
||||
}
|
||||
|
||||
public getView(): MapView | undefined {
|
||||
const view = this.map.getView();
|
||||
return (
|
||||
view && {
|
||||
...view,
|
||||
center: [view.center[0], this._normalizeLongitude(view.center[1])],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public fitBounds(
|
||||
boundingbox: MapLatLng[],
|
||||
options?: { zoom?: number; pad?: number }
|
||||
@@ -186,6 +198,7 @@ export class HaLocationsEditor extends LitElement {
|
||||
locationEditable: location.location_editable,
|
||||
radiusEditable: location.radius_editable,
|
||||
activatable: location.clickable,
|
||||
fit: location.fit,
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
+268
-89
@@ -21,6 +21,7 @@ import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { getEntityLocation } from "../../common/entity/get_entity_location";
|
||||
import { supportsVectorMaps } from "../../common/map/base-layer";
|
||||
import type {
|
||||
MapCircleHandle,
|
||||
MapClusterIcon,
|
||||
MapControlPosition,
|
||||
MapEngine,
|
||||
@@ -29,11 +30,13 @@ import type {
|
||||
MapLatLng,
|
||||
MapMarkerHandle,
|
||||
MapPath,
|
||||
MapPathHandle,
|
||||
MapPathMarker,
|
||||
MapPathSegment,
|
||||
MapEditableCircleHandle,
|
||||
MapEditableMarkerHandle,
|
||||
MapEditingSupport,
|
||||
MapView,
|
||||
} from "../../common/map/map-engine";
|
||||
import {
|
||||
circleBoundsPoints,
|
||||
@@ -66,6 +69,7 @@ import {
|
||||
} from "../../data/context";
|
||||
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
import { ensureMapTilesToken } from "../../data/map_tiles";
|
||||
import "../ha-tooltip";
|
||||
import type {
|
||||
HomeAssistantConfig,
|
||||
HomeAssistantConnection,
|
||||
@@ -74,7 +78,7 @@ import type {
|
||||
HomeAssistantUI,
|
||||
ThemeMode,
|
||||
} from "../../types";
|
||||
import "./ha-entity-marker";
|
||||
import { FLOATING_LIFT, floatingMarkerFootprint } from "./ha-entity-marker";
|
||||
|
||||
declare global {
|
||||
// for fire event
|
||||
@@ -85,6 +89,9 @@ declare global {
|
||||
|
||||
const PROGRAMMITIC_FIT_DELAY = 250;
|
||||
|
||||
// An engine that never reports a drawn frame must not leave an empty card
|
||||
const DRAWN_FALLBACK = 3000;
|
||||
|
||||
const getEntityId = (entity: string | HaMapEntity): string =>
|
||||
typeof entity === "string" ? entity : entity.entity_id;
|
||||
|
||||
@@ -123,6 +130,8 @@ export interface HaMapEditableLocation {
|
||||
radiusEditable?: boolean;
|
||||
/** Activating the marker fires editable-location-clicked; otherwise it is not a button */
|
||||
activatable?: boolean;
|
||||
/** Counts toward the map fit; defaults to true */
|
||||
fit?: boolean;
|
||||
}
|
||||
|
||||
// Geometry is updated in place; a change to anything else rebuilds the marker
|
||||
@@ -194,6 +203,7 @@ const staticEditing = (engine: MapEngine): MapEditingSupport => ({
|
||||
size: options.centerSize ?? [16, 16],
|
||||
interactive: !!options.onClick,
|
||||
title: options.title,
|
||||
nativeTitle: options.nativeTitle,
|
||||
}),
|
||||
];
|
||||
};
|
||||
@@ -259,7 +269,8 @@ const CLUSTER_BUBBLE_PADDING = 6;
|
||||
const CLUSTER_BUBBLE_GAP = 4;
|
||||
// Space an opened bubble keeps from the map edges
|
||||
const CLUSTER_BUBBLE_MARGIN = 12;
|
||||
const CLUSTER_MAX_AVATARS = 3;
|
||||
// Beyond this the last slot becomes a count
|
||||
const CLUSTER_MAX_AVATARS = 4;
|
||||
const CLUSTER_MORE_WIDTH = 28;
|
||||
const CLUSTER_MORE_MAX = 99;
|
||||
const CLUSTER_TAIL_SIZE = 10;
|
||||
@@ -270,6 +281,10 @@ const CLUSTER_RADIUS = 40;
|
||||
// Same-zone markers share the zone's bubble while they span at most this many
|
||||
// pixels; further apart they show their actual positions
|
||||
const ZONE_GROUP_RADIUS = 160;
|
||||
// Circle radius (meters) for a selected marker without a reported accuracy
|
||||
const NOMINAL_ACCURACY = 2.5;
|
||||
// Fixes this good (meters) draw a soft disc without an outline
|
||||
const PRECISE_GPS_ACCURACY = 10;
|
||||
|
||||
type EntityMarkerElement = HTMLElementTagNameMap["ha-entity-marker"];
|
||||
|
||||
@@ -408,6 +423,8 @@ export class HaMap extends ReactiveElement {
|
||||
|
||||
private _entityHandles: MapMarkerHandle[] = [];
|
||||
|
||||
private _tooltipCount = 0;
|
||||
|
||||
// Marker elements survive redraws so unchanged entities keep their DOM
|
||||
private _entityMarkers = new Map<string, EntityMarkerElement>();
|
||||
|
||||
@@ -415,7 +432,9 @@ export class HaMap extends ReactiveElement {
|
||||
|
||||
private _zoneHandles: MapItemHandle[] = [];
|
||||
|
||||
private _pathHandles: MapItemHandle[] = [];
|
||||
private _zoneCircleHandles: MapCircleHandle[] = [];
|
||||
|
||||
private _pathHandles: MapPathHandle[] = [];
|
||||
|
||||
private _focusPoints: MapLatLng[] = [];
|
||||
|
||||
@@ -474,10 +493,12 @@ export class HaMap extends ReactiveElement {
|
||||
this._startingEngine?.destroy();
|
||||
this._startingEngine = undefined;
|
||||
this._loading = false;
|
||||
clearTimeout(this._drawnFallback);
|
||||
this._entityHandles = [];
|
||||
this._entityMarkers.clear();
|
||||
this._clusterAvatars.clear();
|
||||
this._zoneHandles = [];
|
||||
this._zoneCircleHandles = [];
|
||||
this._pathHandles = [];
|
||||
this._removeEditableLocations();
|
||||
this._focusPoints = [];
|
||||
@@ -646,14 +667,34 @@ export class HaMap extends ReactiveElement {
|
||||
// case and keeps the map on the style the build generated.
|
||||
this._readThemeColors();
|
||||
|
||||
const map = this._mapElement!;
|
||||
const map = this._mapElement;
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
map.classList.toggle("clickable", this.clickable);
|
||||
map.classList.toggle("dark", this._darkMode);
|
||||
map.classList.toggle("drawn", this._mapDrawn);
|
||||
// The sky belongs behind a drawn globe; on a blank canvas it is just a
|
||||
// gradient with a glow in it
|
||||
map.classList.toggle("space", this._vectorEngine && this._mapDrawn);
|
||||
map.classList.toggle("forced-dark", this.themeMode === "dark");
|
||||
map.classList.toggle("forced-light", this.themeMode === "light");
|
||||
this._engine?.setMapStyle(this._resolvedMapStyle);
|
||||
}
|
||||
|
||||
private _mapDrawn = false;
|
||||
|
||||
private _drawnFallback?: number;
|
||||
|
||||
private _markDrawn(attempt: number): void {
|
||||
if (attempt !== this._setupAttempt || this._mapDrawn) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(this._drawnFallback);
|
||||
this._mapDrawn = true;
|
||||
this._updateMapAppearance();
|
||||
}
|
||||
|
||||
private _loading = false;
|
||||
|
||||
private _forceLeaflet = false;
|
||||
@@ -663,9 +704,14 @@ export class HaMap extends ReactiveElement {
|
||||
|
||||
private _setupAttempt = 0;
|
||||
|
||||
/** Whether the next engine is the vector one: only it draws a globe */
|
||||
private get _vectorEngine(): boolean {
|
||||
return !this._forceLeaflet && supportsVectorMaps();
|
||||
}
|
||||
|
||||
// Each engine is its own chunk; a map only downloads the one it uses
|
||||
private async _createEngine(): Promise<MapEngine> {
|
||||
if (this._forceLeaflet || !supportsVectorMaps()) {
|
||||
if (!this._vectorEngine) {
|
||||
const leaflet =
|
||||
await import("../../common/map/engines/leaflet-map-engine");
|
||||
return new leaflet.LeafletMapEngine();
|
||||
@@ -699,8 +745,13 @@ export class HaMap extends ReactiveElement {
|
||||
this.shadowRoot!.getElementById("map")?.remove();
|
||||
const map = document.createElement("div");
|
||||
map.id = "map";
|
||||
// Which ground shows in the gap before the first frame; the rest of the
|
||||
// classes wait for the engine (_updateMapAppearance)
|
||||
map.classList.toggle("dark", this._darkMode);
|
||||
this.shadowRoot!.append(map);
|
||||
this._loading = true;
|
||||
this._mapDrawn = false;
|
||||
clearTimeout(this._drawnFallback);
|
||||
const attempt = ++this._setupAttempt;
|
||||
let engine: MapEngine | undefined;
|
||||
try {
|
||||
@@ -718,6 +769,12 @@ export class HaMap extends ReactiveElement {
|
||||
return;
|
||||
}
|
||||
this._startingEngine = engine;
|
||||
// Started here so the budget covers an engine that never reports a
|
||||
// frame, not the token and the chunk it waited for
|
||||
this._drawnFallback = window.setTimeout(
|
||||
() => this._markDrawn(attempt),
|
||||
DRAWN_FALLBACK
|
||||
);
|
||||
await engine.init(map, {
|
||||
center: [
|
||||
this._config?.latitude ?? 52.3731339,
|
||||
@@ -741,6 +798,7 @@ export class HaMap extends ReactiveElement {
|
||||
}
|
||||
},
|
||||
fatal: () => this._handleEngineFatal(),
|
||||
drawn: () => this._markDrawn(attempt),
|
||||
},
|
||||
});
|
||||
// Disconnected while the style was loading, or superseded by a newer setup
|
||||
@@ -785,6 +843,7 @@ export class HaMap extends ReactiveElement {
|
||||
this._engine = undefined;
|
||||
this._entityHandles = [];
|
||||
this._zoneHandles = [];
|
||||
this._zoneCircleHandles = [];
|
||||
this._pathHandles = [];
|
||||
this._removeEditableLocations();
|
||||
this._focusPoints = [];
|
||||
@@ -836,9 +895,12 @@ export class HaMap extends ReactiveElement {
|
||||
return;
|
||||
}
|
||||
|
||||
// Zones join the fit when asked, or when they are all there is
|
||||
const zonePoints =
|
||||
this.fitZones || !this._focusPoints.length ? this._focusZonePoints : [];
|
||||
if (
|
||||
!this._focusPoints.length &&
|
||||
!this._focusZonePoints.length &&
|
||||
!zonePoints.length &&
|
||||
!this.editableLocations?.length
|
||||
) {
|
||||
this._withProgrammaticFit(() => {
|
||||
@@ -851,10 +913,12 @@ export class HaMap extends ReactiveElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const points = [...this._focusPoints, ...this._focusZonePoints];
|
||||
const points = [...this._focusPoints, ...zonePoints];
|
||||
|
||||
// Editable locations contribute their bounds, radius included
|
||||
this.editableLocations?.forEach((editable) => {
|
||||
// Opted-out locations only count when nothing else would be fitted
|
||||
const editables = this.editableLocations ?? [];
|
||||
const fitted = editables.filter((editable) => editable.fit !== false);
|
||||
(fitted.length ? fitted : editables).forEach((editable) => {
|
||||
if (editable.radius) {
|
||||
points.push(...circleBoundsPoints(editable.location, editable.radius));
|
||||
} else {
|
||||
@@ -912,9 +976,18 @@ export class HaMap extends ReactiveElement {
|
||||
this._engine.setView(center, zoom);
|
||||
}
|
||||
|
||||
public getView(): MapView | undefined {
|
||||
return this._engine?.getView();
|
||||
}
|
||||
|
||||
public fitBounds(
|
||||
boundingbox: MapLatLng[],
|
||||
options?: { zoom?: number; pad?: number; padding?: MapFitPadding }
|
||||
options?: {
|
||||
zoom?: number;
|
||||
pad?: number;
|
||||
padding?: MapFitPadding;
|
||||
fly?: boolean;
|
||||
}
|
||||
) {
|
||||
// An explicit fit is user intent, even while it waits for the engine or
|
||||
// a size; an auto-fit must not take its place in the meantime
|
||||
@@ -933,6 +1006,7 @@ export class HaMap extends ReactiveElement {
|
||||
pad: options?.pad ?? 0.5,
|
||||
animate: this._hasFitted,
|
||||
padding: options?.padding,
|
||||
fly: options?.fly,
|
||||
});
|
||||
});
|
||||
this._hasFitted = true;
|
||||
@@ -966,6 +1040,7 @@ export class HaMap extends ReactiveElement {
|
||||
// Markers are buttons, so an unnamed location still gets a name
|
||||
const title =
|
||||
editable.title ?? this._i18n?.localize("ui.components.map.location");
|
||||
const tooltip = !!editable.element && !!editable.title;
|
||||
const existing = this._editableHandles.get(id);
|
||||
const kind = editable.radius ? "circle" : "marker";
|
||||
|
||||
@@ -990,30 +1065,35 @@ export class HaMap extends ReactiveElement {
|
||||
}
|
||||
|
||||
if (kind === "circle") {
|
||||
const handle = editing.addEditableCircle(editable.location, {
|
||||
radius: editable.radius!,
|
||||
color: editable.color || defaultColor,
|
||||
centerElement: editable.element,
|
||||
centerSize: editable.elementSize,
|
||||
title,
|
||||
nativeTitle: !tooltip,
|
||||
moveable: editable.locationEditable,
|
||||
resizable: editable.radiusEditable,
|
||||
resizeLabel: editable.title
|
||||
? this._i18n?.localize("ui.components.map.radius_of", {
|
||||
name: editable.title,
|
||||
})
|
||||
: this._i18n?.localize("ui.components.map.radius"),
|
||||
onMove: (location) =>
|
||||
fireEvent(this, "editable-location-moved", { id, location }),
|
||||
onResize: (radius) =>
|
||||
fireEvent(this, "editable-location-resized", { id, radius }),
|
||||
onClick: editable.activatable
|
||||
? () => fireEvent(this, "editable-location-clicked", { id })
|
||||
: undefined,
|
||||
});
|
||||
this._editableHandles.set(id, {
|
||||
kind,
|
||||
source: editable,
|
||||
handle: editing.addEditableCircle(editable.location, {
|
||||
radius: editable.radius!,
|
||||
color: editable.color || defaultColor,
|
||||
centerElement: editable.element,
|
||||
centerSize: editable.elementSize,
|
||||
title,
|
||||
moveable: editable.locationEditable,
|
||||
resizable: editable.radiusEditable,
|
||||
resizeLabel: editable.title
|
||||
? this._i18n?.localize("ui.components.map.radius_of", {
|
||||
name: editable.title,
|
||||
})
|
||||
: this._i18n?.localize("ui.components.map.radius"),
|
||||
onMove: (location) =>
|
||||
fireEvent(this, "editable-location-moved", { id, location }),
|
||||
onResize: (radius) =>
|
||||
fireEvent(this, "editable-location-resized", { id, radius }),
|
||||
onClick: editable.activatable
|
||||
? () => fireEvent(this, "editable-location-clicked", { id })
|
||||
: undefined,
|
||||
}),
|
||||
handle,
|
||||
cleanup: tooltip
|
||||
? this._attachTooltip(editable.element!, editable.title!)
|
||||
: undefined,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -1052,25 +1132,42 @@ export class HaMap extends ReactiveElement {
|
||||
}
|
||||
// A location that cannot be dragged is static on any engine
|
||||
const support = editable.locationEditable ? editing : staticSupport;
|
||||
const handle = support.addDraggableMarker(element, editable.location, {
|
||||
size: editable.elementSize ?? [16, 16],
|
||||
interactive: true,
|
||||
focusable: !!editable.activatable,
|
||||
title,
|
||||
nativeTitle: !tooltip,
|
||||
onDragEnd: (location) => {
|
||||
dragged = true;
|
||||
fireEvent(this, "editable-location-moved", { id, location });
|
||||
},
|
||||
});
|
||||
const removeTooltip = tooltip
|
||||
? this._attachTooltip(editable.element!, editable.title!)
|
||||
: undefined;
|
||||
this._editableHandles.set(id, {
|
||||
kind,
|
||||
source: editable,
|
||||
cleanup,
|
||||
handle: support.addDraggableMarker(element, editable.location, {
|
||||
size: editable.elementSize ?? [16, 16],
|
||||
interactive: true,
|
||||
focusable: !!editable.activatable,
|
||||
title,
|
||||
onDragEnd: (location) => {
|
||||
dragged = true;
|
||||
fireEvent(this, "editable-location-moved", { id, location });
|
||||
},
|
||||
}),
|
||||
cleanup: () => {
|
||||
cleanup?.();
|
||||
removeTooltip?.();
|
||||
},
|
||||
handle,
|
||||
});
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
private _attachTooltip(element: HTMLElement, title: string): () => void {
|
||||
element.id ||= `ha-map-editable-${this._tooltipCount++}`;
|
||||
const tooltip = document.createElement("ha-tooltip");
|
||||
tooltip.for = element.id;
|
||||
tooltip.textContent = title;
|
||||
this.shadowRoot!.append(tooltip);
|
||||
return () => tooltip.remove();
|
||||
}
|
||||
|
||||
// One by one, so the listeners on the caller's elements are detached too
|
||||
private _removeEditableLocations(): void {
|
||||
for (const entry of this._editableHandles.values()) {
|
||||
@@ -1108,11 +1205,9 @@ export class HaMap extends ReactiveElement {
|
||||
if (!this._i18n || !this._config || !this._engine) {
|
||||
return;
|
||||
}
|
||||
if (this._pathHandles.length) {
|
||||
this._pathHandles.forEach((handle) => handle.remove());
|
||||
this._pathHandles = [];
|
||||
}
|
||||
if (!this.paths) {
|
||||
const paths = this.paths ?? [];
|
||||
this._pathHandles.splice(paths.length).forEach((handle) => handle.remove());
|
||||
if (!paths.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1120,7 +1215,7 @@ export class HaMap extends ReactiveElement {
|
||||
"--dark-primary-color"
|
||||
);
|
||||
|
||||
this.paths.forEach((path) => {
|
||||
paths.forEach((path, index) => {
|
||||
let opacityStep: number;
|
||||
let baseOpacity: number;
|
||||
if (path.gradualOpacity) {
|
||||
@@ -1212,7 +1307,12 @@ export class HaMap extends ReactiveElement {
|
||||
segments,
|
||||
markers,
|
||||
};
|
||||
this._pathHandles.push(this._engine!.addPath(enginePath));
|
||||
const handle = this._pathHandles[index];
|
||||
if (handle) {
|
||||
handle.update(enginePath);
|
||||
} else {
|
||||
this._pathHandles.push(this._engine!.addPath(enginePath));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1231,8 +1331,10 @@ export class HaMap extends ReactiveElement {
|
||||
this._zoneHandles.forEach((handle) => handle.remove());
|
||||
this._zoneHandles = [];
|
||||
this._focusZonePoints = [];
|
||||
let zoneCircleCount = 0;
|
||||
|
||||
if (!this.entities) {
|
||||
this._zoneCircleHandles.splice(0).forEach((handle) => handle.remove());
|
||||
this._entityMarkers.clear();
|
||||
this._clusterAvatars.clear();
|
||||
engine.setClustering(null);
|
||||
@@ -1314,9 +1416,16 @@ export class HaMap extends ReactiveElement {
|
||||
);
|
||||
|
||||
if (!hideRadius && radius) {
|
||||
this._zoneHandles.push(
|
||||
engine.addCircle(position, { radius, color: markerColor })
|
||||
);
|
||||
const circleOptions = { radius, color: markerColor };
|
||||
const circle = this._zoneCircleHandles[zoneCircleCount];
|
||||
if (circle) {
|
||||
circle.update(position, circleOptions);
|
||||
} else {
|
||||
this._zoneCircleHandles.push(
|
||||
engine.addCircle(position, circleOptions)
|
||||
);
|
||||
}
|
||||
zoneCircleCount++;
|
||||
}
|
||||
|
||||
const circleEl = createZoneMarkerElement({
|
||||
@@ -1349,10 +1458,7 @@ export class HaMap extends ReactiveElement {
|
||||
})
|
||||
);
|
||||
|
||||
if (
|
||||
this.fitZones &&
|
||||
(typeof entity === "string" || entity.focus !== false)
|
||||
) {
|
||||
if (typeof entity === "string" || entity.focus !== false) {
|
||||
if (!hideRadius && radius) {
|
||||
this._focusZonePoints.push(...circleBoundsPoints(position, radius));
|
||||
} else {
|
||||
@@ -1410,6 +1516,7 @@ export class HaMap extends ReactiveElement {
|
||||
entityMarker.entityColor = entityColor;
|
||||
entityMarker.selected =
|
||||
typeof entity !== "string" && (entity.selected ?? false);
|
||||
entityMarker.floating = true;
|
||||
|
||||
const clusterData: ClusterData = {
|
||||
entityId,
|
||||
@@ -1427,18 +1534,28 @@ export class HaMap extends ReactiveElement {
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const accuracy =
|
||||
gpsAccuracy || (entityMarker.selected ? NOMINAL_ACCURACY : 0);
|
||||
const showAccuracy =
|
||||
!!gpsAccuracy && !(typeof entity !== "string" && entity.hide_accuracy);
|
||||
accuracy > 0 && !(typeof entity !== "string" && entity.hide_accuracy);
|
||||
|
||||
const markerSize = this._getMarkerSize(computedStyles);
|
||||
this._entityHandles.push(
|
||||
engine.addMarker(entityMarker, position, {
|
||||
size: [markerSize, markerSize],
|
||||
...floatingMarkerFootprint(
|
||||
this._getMarkerSize(computedStyles),
|
||||
entityMarker.selected
|
||||
),
|
||||
title,
|
||||
cluster: true,
|
||||
// Selected, it leaves its bubble
|
||||
cluster: !entityMarker.selected,
|
||||
raised: entityMarker.selected,
|
||||
clusterData,
|
||||
decoration: showAccuracy
|
||||
? { radius: gpsAccuracy!, color: entityColor }
|
||||
? {
|
||||
radius: accuracy,
|
||||
color: entityColor,
|
||||
outline: accuracy > PRECISE_GPS_ACCURACY,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
);
|
||||
@@ -1448,6 +1565,10 @@ export class HaMap extends ReactiveElement {
|
||||
}
|
||||
}
|
||||
|
||||
this._zoneCircleHandles
|
||||
.splice(zoneCircleCount)
|
||||
.forEach((handle) => handle.remove());
|
||||
|
||||
const shownIds = new Set(this.entities.map(getEntityId));
|
||||
for (const cache of [this._entityMarkers, this._clusterAvatars]) {
|
||||
for (const entityId of cache.keys()) {
|
||||
@@ -1493,7 +1614,10 @@ export class HaMap extends ReactiveElement {
|
||||
expanded = false
|
||||
): MapClusterIcon => {
|
||||
const data = members.map((member) => member.clusterData as ClusterData);
|
||||
const shown = expanded ? data : data.slice(0, CLUSTER_MAX_AVATARS);
|
||||
const shown =
|
||||
expanded || data.length <= CLUSTER_MAX_AVATARS
|
||||
? data
|
||||
: data.slice(0, CLUSTER_MAX_AVATARS - 1);
|
||||
const hidden = data.length - shown.length;
|
||||
|
||||
// With history trails shown, colored borders match avatars to trails
|
||||
@@ -1564,37 +1688,30 @@ export class HaMap extends ReactiveElement {
|
||||
width += CLUSTER_MORE_WIDTH + CLUSTER_BUBBLE_GAP;
|
||||
}
|
||||
|
||||
// A cluster of one zone's occupants attaches to that zone's marker
|
||||
const zonePosition = zoneId ? this._zonePositions[zoneId] : undefined;
|
||||
const atZone = !!zonePosition;
|
||||
|
||||
let height =
|
||||
const height =
|
||||
rows * CLUSTER_AVATAR_SIZE +
|
||||
(rows - 1) * CLUSTER_BUBBLE_GAP +
|
||||
2 * CLUSTER_BUBBLE_PADDING;
|
||||
let root: HTMLElement = bubble;
|
||||
if (atZone) {
|
||||
root = document.createElement("div");
|
||||
root.className = "cluster-marker";
|
||||
const tail = document.createElement("div");
|
||||
tail.className = "cluster-bubble-tail";
|
||||
root.append(bubble, tail);
|
||||
height += CLUSTER_TAIL_HEIGHT;
|
||||
}
|
||||
2 * CLUSTER_BUBBLE_PADDING +
|
||||
CLUSTER_TAIL_HEIGHT;
|
||||
const root = document.createElement("div");
|
||||
root.className = "cluster-marker";
|
||||
const tail = document.createElement("div");
|
||||
tail.className = "cluster-bubble-tail";
|
||||
root.append(bubble, tail);
|
||||
|
||||
// A cluster of one zone's occupants floats above that zone's circle
|
||||
const zonePosition = zoneId ? this._zonePositions[zoneId] : undefined;
|
||||
return {
|
||||
element: root,
|
||||
size: [width, height],
|
||||
// Float above the zone circle, tail pointing at it
|
||||
...(atZone && zonePosition
|
||||
? {
|
||||
location: zonePosition,
|
||||
anchor: [
|
||||
width / 2,
|
||||
height + ZONE_CIRCLE_SIZE / 2 + CLUSTER_ZONE_SPACING,
|
||||
] as [number, number],
|
||||
}
|
||||
: {}),
|
||||
location: zonePosition,
|
||||
anchor: [
|
||||
width / 2,
|
||||
height +
|
||||
(zonePosition
|
||||
? ZONE_CIRCLE_SIZE / 2 + CLUSTER_ZONE_SPACING
|
||||
: FLOATING_LIFT),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1630,12 +1747,24 @@ export class HaMap extends ReactiveElement {
|
||||
}
|
||||
#map {
|
||||
height: 100%;
|
||||
/* The map arrives in one piece: the container carries the cartography's
|
||||
own ground, and fades in with the markers once a frame is drawn */
|
||||
background-color: #f4efe6;
|
||||
/* Hidden rather than transparent: the controls and markers in here are
|
||||
not to be clicked or tabbed to before they are on screen */
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity var(--ha-animation-duration-fast, 150ms) ease-in;
|
||||
/* A cluster bubble and its tail cast a single shadow around their
|
||||
combined silhouette (drop-shadow on the wrapper), so no shadow seam
|
||||
appears between the bubble and its tail. */
|
||||
--ha-cluster-shadow: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.08))
|
||||
drop-shadow(0 1px 3px rgba(0, 0, 0, 0.12));
|
||||
}
|
||||
#map.drawn {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
#map.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1653,7 +1782,7 @@ export class HaMap extends ReactiveElement {
|
||||
transition-delay: var(--ha-animation-duration-fast);
|
||||
}
|
||||
#map.dark {
|
||||
background: #090909;
|
||||
background: #191b2c;
|
||||
--ha-cluster-shadow: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.4))
|
||||
drop-shadow(0 1px 3px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
@@ -1667,6 +1796,39 @@ export class HaMap extends ReactiveElement {
|
||||
color: #000000;
|
||||
--map-filter: invert(0);
|
||||
}
|
||||
/* Zoomed out the sky fades out and the globe is left on a transparent
|
||||
canvas, so what surrounds it is this element. The same sky in both
|
||||
themes, day or night: a glow where the sphere sits at the widest zoom
|
||||
out -- MapLibre's own atmosphere carries the edge from there in -- over
|
||||
a gradient that lightens towards the top. */
|
||||
#map.space {
|
||||
--ha-map-space-glow: rgba(255, 255, 255, 0.95);
|
||||
--ha-map-space-haze: rgba(255, 255, 255, 0.45);
|
||||
--ha-map-space-fade: rgba(255, 255, 255, 0);
|
||||
--ha-map-space-high: #dbe8f8;
|
||||
--ha-map-space-low: #7aa5d3;
|
||||
background-color: var(--ha-map-space-low);
|
||||
background-image:
|
||||
radial-gradient(
|
||||
circle at 50% 50%,
|
||||
var(--ha-map-space-glow) 18%,
|
||||
var(--ha-map-space-haze) 24%,
|
||||
var(--ha-map-space-fade) 34%
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
var(--ha-map-space-high) 0%,
|
||||
var(--ha-map-space-low) 100%
|
||||
);
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
#map.space.dark {
|
||||
--ha-map-space-glow: rgba(126, 158, 224, 0.5);
|
||||
--ha-map-space-haze: rgba(86, 110, 170, 0.22);
|
||||
--ha-map-space-fade: rgba(86, 110, 170, 0);
|
||||
--ha-map-space-high: #141a30;
|
||||
--ha-map-space-low: #05060f;
|
||||
}
|
||||
#map.clickable:active,
|
||||
#map:active {
|
||||
cursor: grabbing;
|
||||
@@ -1716,6 +1878,23 @@ export class HaMap extends ReactiveElement {
|
||||
.dark .maplibregl-ctrl button .maplibregl-ctrl-icon {
|
||||
filter: invert(1);
|
||||
}
|
||||
/* Not inherited from the page, which may be in the other mode */
|
||||
.maplibregl-ctrl.maplibregl-ctrl-attrib {
|
||||
color: #000000;
|
||||
}
|
||||
.dark .maplibregl-ctrl.maplibregl-ctrl-attrib {
|
||||
background-color: rgba(28, 28, 28, 0.6);
|
||||
color: #ffffff;
|
||||
}
|
||||
.dark .maplibregl-ctrl-attrib.maplibregl-compact {
|
||||
background-color: #1c1c1c;
|
||||
}
|
||||
.dark .maplibregl-ctrl-attrib a {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
.dark .maplibregl-ctrl-attrib-button {
|
||||
filter: invert(1);
|
||||
}
|
||||
/* MapLibre's stylesheet, linked into this root, wins on equal specificity */
|
||||
.maplibregl-popup-content {
|
||||
padding: 8px !important;
|
||||
@@ -1788,9 +1967,9 @@ export class HaMap extends ReactiveElement {
|
||||
border-radius: 14px;
|
||||
filter: var(--ha-cluster-shadow);
|
||||
--ha-marker-size: ${CLUSTER_AVATAR_SIZE}px;
|
||||
--ha-marker-selected-scale: 1;
|
||||
--ha-marker-color: transparent;
|
||||
--ha-marker-border-width: 1px;
|
||||
--ha-marker-shadow: none;
|
||||
--ha-marker-font-size: var(--ha-font-size-s);
|
||||
/* distinguish letter tiles from the bubble background */
|
||||
--ha-marker-background: var(--ha-color-fill-neutral-quiet-resting);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { ContextProvider } from "@lit/context";
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { dump } from "js-yaml";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { consume } from "../../common/decorators/consume";
|
||||
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
|
||||
import type { Trigger } from "../../data/automation";
|
||||
import type { Trigger, TriggerCondition } from "../../data/automation";
|
||||
import { migrateAutomationTrigger } from "../../data/automation";
|
||||
import { describeCondition, describeTrigger } from "../../data/automation_i18n";
|
||||
import type { ConditionDescriptions } from "../../data/condition";
|
||||
@@ -28,10 +29,16 @@ import type {
|
||||
} from "../../data/trace";
|
||||
import type { TargetSelector } from "../../data/selector";
|
||||
import { getDataFromPath, isTriggerPath } from "../../data/trace";
|
||||
import { getTraceTriggers } from "../../data/trace-tree";
|
||||
import type { TriggerDescriptions } from "../../data/trigger";
|
||||
import { getDeviceTarget } from "../../panels/config/automation/target/get_device_target";
|
||||
import { getEntityTarget } from "../../panels/config/automation/target/get_entity_target";
|
||||
import "../../panels/config/automation/target/ha-automation-row-targets";
|
||||
import {
|
||||
automationTriggerContext,
|
||||
getTriggerIdOptions,
|
||||
} from "../../panels/config/automation/trigger/automation-trigger-id";
|
||||
import "../../panels/config/automation/trigger/ha-automation-trigger-references";
|
||||
import "../../panels/logbook/ha-logbook-renderer";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-alert";
|
||||
@@ -96,6 +103,26 @@ export class HaTracePathDetails extends LitElement {
|
||||
@consume({ context: conditionDescriptionsContext, subscribe: true })
|
||||
private _conditionDescriptions?: ConditionDescriptions;
|
||||
|
||||
private _triggerProvider = new ContextProvider(this, {
|
||||
context: automationTriggerContext,
|
||||
initialValue: {
|
||||
options: [],
|
||||
showIndices: false,
|
||||
select: () => undefined,
|
||||
fixDuplicateIds: async () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
if (changedProps.has("trace")) {
|
||||
this._triggerProvider.setValue({
|
||||
...this._triggerProvider.value,
|
||||
options: getTriggerIdOptions(getTraceTriggers(this.trace.config)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<div class="padded-box trace-info">
|
||||
@@ -353,6 +380,7 @@ export class HaTracePathDetails extends LitElement {
|
||||
|
||||
return html`<div class="heading">
|
||||
<h2>${description}</h2>
|
||||
${this._renderTriggerReferences(currentDetail)}
|
||||
${this._renderTargets(currentDetail, selectedType)}
|
||||
</div>`;
|
||||
}
|
||||
@@ -366,10 +394,21 @@ export class HaTracePathDetails extends LitElement {
|
||||
|
||||
return html`<div class="nested-condition">
|
||||
${describeCondition(currentDetail, this.hass, this._entityReg)}
|
||||
${this._renderTriggerReferences(currentDetail)}
|
||||
${this._renderTargets(currentDetail, "condition", "s")}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _renderTriggerReferences(config: any) {
|
||||
if (config?.condition !== "trigger") {
|
||||
return nothing;
|
||||
}
|
||||
return html`<ha-automation-trigger-references
|
||||
.condition=${config as TriggerCondition}
|
||||
.hass=${this.hass}
|
||||
></ha-automation-trigger-references>`;
|
||||
}
|
||||
|
||||
private _renderTargets(
|
||||
config: any,
|
||||
type: NodeInfo["type"],
|
||||
|
||||
@@ -29,6 +29,8 @@ export class HatGraphBranch extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) short = false;
|
||||
|
||||
@property({ type: Boolean }) end = false;
|
||||
|
||||
@state() _branches: BranchConfig[] = [];
|
||||
|
||||
private _totalWidth = 0;
|
||||
@@ -144,10 +146,16 @@ export class HatGraphBranch extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
<div id="branches">
|
||||
<svg id="lines" width=${this._totalWidth} height=${this._maxHeight}>
|
||||
${this._branches.map((branch) => {
|
||||
if (branch.end) return "";
|
||||
return svg`
|
||||
${
|
||||
!this.end
|
||||
? html`<svg
|
||||
id="lines"
|
||||
width=${this._totalWidth}
|
||||
height=${this._maxHeight}
|
||||
>
|
||||
${this._branches.map((branch) => {
|
||||
if (branch.end) return "";
|
||||
return svg`
|
||||
<path
|
||||
class=${classMap({
|
||||
track: branch.trackEnd,
|
||||
@@ -157,13 +165,15 @@ export class HatGraphBranch extends LitElement {
|
||||
v ${this._maxHeight - branch.height}
|
||||
"/>
|
||||
`;
|
||||
})}
|
||||
</svg>
|
||||
})}
|
||||
</svg>`
|
||||
: nothing
|
||||
}
|
||||
<slot @slotchange=${this._updateBranches}></slot>
|
||||
</div>
|
||||
|
||||
${
|
||||
!this.short
|
||||
!this.short && !this.end
|
||||
? html`
|
||||
<svg id="bottom" width=${this._totalWidth}>
|
||||
${this._branches.map((branch) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing, svg } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { isSafari } from "../../util/is_safari";
|
||||
import "../ha-svg-icon";
|
||||
import {
|
||||
BUILDING_BLOCK_ICON_SIZE,
|
||||
BUILDING_BLOCK_RADIUS,
|
||||
@@ -66,17 +67,17 @@ export class HatGraphNode extends LitElement {
|
||||
const width = SPACING + NODE_SIZE;
|
||||
const size = this.buildingBlock ? BUILDING_BLOCK_SIZE : NODE_SIZE;
|
||||
const iconSize = this.buildingBlock ? BUILDING_BLOCK_ICON_SIZE : ICON_SIZE;
|
||||
// Distance from the top of the svg to the node center (viewBox origin).
|
||||
const centerY = this.graphStart
|
||||
? Math.ceil(height / 2)
|
||||
: Math.ceil((NODE_SIZE + SPACING * 2) / 2);
|
||||
// A rotated building block is wider than its side.
|
||||
return html`
|
||||
<svg
|
||||
class=${isSafari ? "safari" : ""}
|
||||
width="${width}px"
|
||||
height="${height}px"
|
||||
viewBox="-${Math.ceil(width / 2)} -${
|
||||
this.graphStart
|
||||
? Math.ceil(height / 2)
|
||||
: Math.ceil((NODE_SIZE + SPACING * 2) / 2)
|
||||
} ${width} ${height}"
|
||||
viewBox="-${Math.ceil(width / 2)} -${centerY} ${width} ${height}"
|
||||
>
|
||||
${
|
||||
this.graphStart
|
||||
@@ -134,19 +135,6 @@ export class HatGraphNode extends LitElement {
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<g
|
||||
class="icon-wrapper"
|
||||
style="pointer-events: none"
|
||||
transform="translate(-${iconSize / 2} -${iconSize / 2}) scale(${
|
||||
iconSize / ICON_SIZE
|
||||
})"
|
||||
>
|
||||
${
|
||||
this.iconPath
|
||||
? svg`<path class="icon" d=${this.iconPath}/>`
|
||||
: svg`<foreignObject><span class="icon"><slot name="icon"></slot></span></foreignObject>`
|
||||
}
|
||||
</g>
|
||||
${
|
||||
this.notEnabled
|
||||
? svg`
|
||||
@@ -162,6 +150,15 @@ export class HatGraphNode extends LitElement {
|
||||
}
|
||||
</g>
|
||||
</svg>
|
||||
<span class="icon" style="top: ${centerY}px">
|
||||
<slot name="icon">
|
||||
${
|
||||
this.iconPath
|
||||
? html`<ha-svg-icon .path=${this.iconPath}></ha-svg-icon>`
|
||||
: nothing
|
||||
}
|
||||
</slot>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -169,12 +166,12 @@ export class HatGraphNode extends LitElement {
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
min-width: calc(var(--hat-graph-node-size) + var(--hat-graph-spacing));
|
||||
height: calc(var(--hat-graph-node-size) + var(--hat-graph-spacing) + 1px);
|
||||
}
|
||||
/* The count badge overlaps the next node's connector. */
|
||||
:host([badge]) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
:host([graph-start]) {
|
||||
@@ -219,7 +216,7 @@ export class HatGraphNode extends LitElement {
|
||||
/* Not the whole node group: SVG composites group opacity as a unit, which
|
||||
would fade the error and count badges along with the rest. The body is
|
||||
left at full strength so the grey does not wash out. */
|
||||
:host([not-enabled]) .icon-wrapper,
|
||||
:host([not-enabled]) .icon,
|
||||
:host([not-enabled]) .strike {
|
||||
opacity: 0.6;
|
||||
}
|
||||
@@ -279,15 +276,17 @@ export class HatGraphNode extends LitElement {
|
||||
font-size: var(--ha-font-size-s);
|
||||
fill: var(--text-primary-color);
|
||||
}
|
||||
path.icon {
|
||||
fill: var(--icon-clr);
|
||||
}
|
||||
foreignObject {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
.icon {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
pointer-events: none;
|
||||
color: var(--icon-clr);
|
||||
--mdc-icon-size: 24px;
|
||||
}
|
||||
:host([building-block]) .icon {
|
||||
--mdc-icon-size: 20px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -139,22 +139,40 @@ export class HatScriptGraph extends LitElement {
|
||||
other: this._renderOtherNode,
|
||||
};
|
||||
|
||||
private _renderActionNode(node: TraceActionNode, graphStart = false) {
|
||||
private _renderActionNode(
|
||||
node: TraceActionNode,
|
||||
graphStart = false,
|
||||
graphEnd = false
|
||||
) {
|
||||
const type = node.actionType ?? "other";
|
||||
return (this._typeRenderers[type] ?? this._renderOtherNode).bind(this)(
|
||||
node,
|
||||
graphStart
|
||||
graphStart,
|
||||
graphEnd
|
||||
);
|
||||
}
|
||||
|
||||
// Only the last action of a branch in a block that ends the graph ends it.
|
||||
private _renderBranchActions(actions: TraceActionNode[], graphEnd: boolean) {
|
||||
return actions.map((action, i) =>
|
||||
this._renderActionNode(
|
||||
action,
|
||||
false,
|
||||
graphEnd && i === actions.length - 1
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private _renderChooseNode(
|
||||
node: TraceActionNode<ChooseAction>,
|
||||
graphStart = false
|
||||
graphStart = false,
|
||||
graphEnd = false
|
||||
) {
|
||||
const { config, path, track } = node;
|
||||
const defaultBranch = node.branches[node.branches.length - 1];
|
||||
return html`
|
||||
<hat-graph-branch
|
||||
.end=${graphEnd}
|
||||
tabindex=${node.hasTrace ? "0" : "-1"}
|
||||
@focus=${this._selectNode(config, path, "action")}
|
||||
?track=${track}
|
||||
@@ -203,31 +221,39 @@ export class HatScriptGraph extends LitElement {
|
||||
aria-label=${ifDefined(this.labels?.[branch.path])}
|
||||
aria-current=${ifDefined(this.selected === branch.path || undefined)}
|
||||
></hat-graph-node>
|
||||
${branch.children.map((action) => this._renderActionNode(action))}
|
||||
${this._renderBranchActions(branch.children, graphEnd)}
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
<div
|
||||
?track=${defaultBranch.hasTrace}
|
||||
?unfinished=${defaultBranch.unfinished}
|
||||
>
|
||||
<hat-graph-spacer
|
||||
aria-hidden="true"
|
||||
?track=${defaultBranch.hasTrace}
|
||||
></hat-graph-spacer>
|
||||
${defaultBranch.children.map((action) =>
|
||||
this._renderActionNode(action)
|
||||
)}
|
||||
</div>
|
||||
${
|
||||
// An empty default branch leads nowhere when nothing follows.
|
||||
graphEnd && !defaultBranch.children.length
|
||||
? nothing
|
||||
: html`<div
|
||||
?track=${defaultBranch.hasTrace}
|
||||
?unfinished=${defaultBranch.unfinished}
|
||||
>
|
||||
<hat-graph-spacer
|
||||
aria-hidden="true"
|
||||
?track=${defaultBranch.hasTrace}
|
||||
></hat-graph-spacer>
|
||||
${this._renderBranchActions(defaultBranch.children, graphEnd)}
|
||||
</div>`
|
||||
}
|
||||
</hat-graph-branch>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderIfNode(node: TraceActionNode<IfAction>, graphStart = false) {
|
||||
private _renderIfNode(
|
||||
node: TraceActionNode<IfAction>,
|
||||
graphStart = false,
|
||||
graphEnd = false
|
||||
) {
|
||||
const { config, path, track } = node;
|
||||
const [thenBranch, elseBranch] = node.branches;
|
||||
return html`
|
||||
<hat-graph-branch
|
||||
.end=${graphEnd}
|
||||
tabindex=${node.hasTrace ? "0" : "-1"}
|
||||
@focus=${this._selectNode(config, path, "action")}
|
||||
?track=${track}
|
||||
@@ -250,6 +276,21 @@ export class HatScriptGraph extends LitElement {
|
||||
nofocus
|
||||
aria-hidden="true"
|
||||
></hat-graph-node>
|
||||
<div
|
||||
class="graph-container"
|
||||
?track=${thenBranch.hasTrace}
|
||||
?unfinished=${thenBranch.unfinished}
|
||||
>
|
||||
<hat-graph-node
|
||||
.iconPath=${mdiCallReceived}
|
||||
?track=${thenBranch.hasTrace}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${thenBranch.disabled}
|
||||
nofocus
|
||||
aria-hidden="true"
|
||||
></hat-graph-node>
|
||||
${this._renderBranchActions(thenBranch.children, graphEnd)}
|
||||
</div>
|
||||
${
|
||||
config.else
|
||||
? html`<div
|
||||
@@ -265,30 +306,15 @@ export class HatScriptGraph extends LitElement {
|
||||
nofocus
|
||||
aria-hidden="true"
|
||||
></hat-graph-node
|
||||
>${elseBranch.children.map((action) =>
|
||||
this._renderActionNode(action)
|
||||
)}
|
||||
>${this._renderBranchActions(elseBranch.children, graphEnd)}
|
||||
</div>`
|
||||
: html`<hat-graph-spacer
|
||||
aria-hidden="true"
|
||||
?track=${elseBranch.hasTrace}
|
||||
></hat-graph-spacer>`
|
||||
: graphEnd
|
||||
? nothing
|
||||
: html`<hat-graph-spacer
|
||||
aria-hidden="true"
|
||||
?track=${elseBranch.hasTrace}
|
||||
></hat-graph-spacer>`
|
||||
}
|
||||
<div
|
||||
class="graph-container"
|
||||
?track=${thenBranch.hasTrace}
|
||||
?unfinished=${thenBranch.unfinished}
|
||||
>
|
||||
<hat-graph-node
|
||||
.iconPath=${mdiCallReceived}
|
||||
?track=${thenBranch.hasTrace}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${thenBranch.disabled}
|
||||
nofocus
|
||||
aria-hidden="true"
|
||||
></hat-graph-node>
|
||||
${thenBranch.children.map((action) => this._renderActionNode(action))}
|
||||
</div>
|
||||
</hat-graph-branch>
|
||||
`;
|
||||
}
|
||||
@@ -350,12 +376,14 @@ export class HatScriptGraph extends LitElement {
|
||||
|
||||
private _renderRepeatNode(
|
||||
model: TraceActionNode<RepeatAction>,
|
||||
graphStart = false
|
||||
graphStart = false,
|
||||
graphEnd = false
|
||||
) {
|
||||
const { config: node, path, track } = model;
|
||||
const [branch] = model.branches;
|
||||
return html`
|
||||
<hat-graph-branch
|
||||
.end=${graphEnd}
|
||||
tabindex=${model.hasTrace ? "0" : "-1"}
|
||||
@focus=${this._selectNode(node, path, "action")}
|
||||
?track=${track}
|
||||
@@ -384,7 +412,7 @@ export class HatScriptGraph extends LitElement {
|
||||
?track=${branch.hasTrace}
|
||||
?unfinished=${branch.unfinished}
|
||||
>
|
||||
${branch.children.map((action) => this._renderActionNode(action))}
|
||||
${this._renderBranchActions(branch.children, graphEnd)}
|
||||
</div>
|
||||
</hat-graph-branch>
|
||||
`;
|
||||
@@ -455,12 +483,14 @@ export class HatScriptGraph extends LitElement {
|
||||
|
||||
private _renderSequenceNode(
|
||||
model: TraceActionNode<SequenceAction>,
|
||||
graphStart = false
|
||||
graphStart = false,
|
||||
graphEnd = false
|
||||
) {
|
||||
const { config: node, path, track } = model;
|
||||
const [branch] = model.branches;
|
||||
return html`
|
||||
<hat-graph-branch
|
||||
.end=${graphEnd}
|
||||
tabindex=${model.hasTrace ? "0" : "-1"}
|
||||
@focus=${this._selectNode(node, path, "action")}
|
||||
?track=${track}
|
||||
@@ -488,7 +518,7 @@ export class HatScriptGraph extends LitElement {
|
||||
nofocus
|
||||
aria-hidden="true"
|
||||
></hat-graph-node>
|
||||
${branch.children.map((action) => this._renderActionNode(action))}
|
||||
${this._renderBranchActions(branch.children, graphEnd)}
|
||||
</div>
|
||||
</hat-graph-branch>
|
||||
`;
|
||||
@@ -496,11 +526,13 @@ export class HatScriptGraph extends LitElement {
|
||||
|
||||
private _renderParallelNode(
|
||||
model: TraceActionNode<ParallelAction>,
|
||||
graphStart = false
|
||||
graphStart = false,
|
||||
graphEnd = false
|
||||
) {
|
||||
const { config: node, path, track } = model;
|
||||
return html`
|
||||
<hat-graph-branch
|
||||
.end=${graphEnd}
|
||||
tabindex=${model.hasTrace ? "0" : "-1"}
|
||||
@focus=${this._selectNode(node, path, "action")}
|
||||
?track=${track}
|
||||
@@ -529,9 +561,7 @@ export class HatScriptGraph extends LitElement {
|
||||
?track=${branch.hasTrace}
|
||||
?unfinished=${branch.unfinished}
|
||||
>
|
||||
${branch.children.map((sAction) =>
|
||||
this._renderActionNode(sAction)
|
||||
)}
|
||||
${this._renderBranchActions(branch.children, graphEnd)}
|
||||
</div>`
|
||||
)}
|
||||
</hat-graph-branch>
|
||||
@@ -577,9 +607,19 @@ export class HatScriptGraph extends LitElement {
|
||||
: ""
|
||||
}
|
||||
${tree.conditions.map((node) => this._renderConditionNode(node))}
|
||||
${tree.actions.map((node) => this._renderActionNode(node))}
|
||||
${tree.actions.map((node, i) =>
|
||||
this._renderActionNode(
|
||||
node,
|
||||
false,
|
||||
!tree.sequence.length && i === tree.actions.length - 1
|
||||
)
|
||||
)}
|
||||
${tree.sequence.map((node, i) =>
|
||||
this._renderActionNode(node, i === 0)
|
||||
this._renderActionNode(
|
||||
node,
|
||||
i === 0,
|
||||
i === tree.sequence.length - 1
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+11
-1
@@ -362,11 +362,17 @@ export type ConditionWithShorthand =
|
||||
| ShorthandAndConditionList
|
||||
| ShorthandAndCondition
|
||||
| ShorthandOrCondition
|
||||
| ShorthandNotCondition;
|
||||
| ShorthandNotCondition
|
||||
| string;
|
||||
|
||||
export const expandConditionWithShorthand = (
|
||||
cond: ConditionWithShorthand
|
||||
): Condition => {
|
||||
// A bare template string is shorthand for a template condition
|
||||
if (typeof cond === "string") {
|
||||
return { condition: "template", value_template: cond };
|
||||
}
|
||||
|
||||
if ("condition" in cond && Array.isArray(cond.condition)) {
|
||||
return {
|
||||
condition: "and",
|
||||
@@ -573,6 +579,10 @@ export const flattenTriggers = (
|
||||
const flatTriggers: Trigger[] = [];
|
||||
|
||||
ensureArray(triggers).forEach((t) => {
|
||||
// Incomplete YAML can leave null entries in the trigger list
|
||||
if (!t || typeof t !== "object") {
|
||||
return;
|
||||
}
|
||||
if ("triggers" in t) {
|
||||
if (t.triggers) {
|
||||
flatTriggers.push(...flattenTriggers(t.triggers));
|
||||
|
||||
@@ -149,8 +149,6 @@ const formatNumericLimitValue = (
|
||||
export interface DescribeOptions {
|
||||
// Skip the user defined alias and describe the underlying config.
|
||||
ignoreAlias?: boolean;
|
||||
// Rows with trigger-reference chips render the IDs separately.
|
||||
hideTriggerIds?: boolean;
|
||||
}
|
||||
|
||||
export const describeTrigger = (
|
||||
@@ -925,7 +923,7 @@ const tryDescribeCondition = (
|
||||
return condition.alias;
|
||||
}
|
||||
|
||||
if (condition.condition === "trigger" && options?.hideTriggerIds) {
|
||||
if (condition.condition === "trigger") {
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.trigger.description.summary`
|
||||
);
|
||||
@@ -1305,17 +1303,5 @@ const describeLegacyCondition = (
|
||||
);
|
||||
}
|
||||
|
||||
if (condition.condition === "trigger" && condition.id != null) {
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.trigger.description.full`,
|
||||
{
|
||||
id: formatListWithOrs(
|
||||
hass.locale,
|
||||
ensureArray(condition.id).map((id) => id.toString())
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import { computeObjectId } from "../common/entity/compute_object_id";
|
||||
import type { LocalizeKeys } from "../common/translations/localize";
|
||||
import type { DataEntryFlowProgress } from "./data_entry_flow";
|
||||
import type { ESPHomeDeviceCapabilities, ESPHomeSerialProxy } from "./esphome";
|
||||
import type { ESPHomeFrontendUserData } from "./frontend";
|
||||
import type { SerialPortUsage } from "./usb";
|
||||
@@ -178,6 +179,7 @@ export const deriveESPHomeSetupStatus = (
|
||||
sendspinSupported?: boolean;
|
||||
sendspinEnabled?: boolean;
|
||||
serialConfigured?: boolean;
|
||||
zwaveFlowInProgress?: boolean;
|
||||
}
|
||||
): ESPHomeSetupStatus => {
|
||||
const status: ESPHomeSetupStatus = {};
|
||||
@@ -199,7 +201,10 @@ export const deriveESPHomeSetupStatus = (
|
||||
if (capabilities.zwave_proxy.supported) {
|
||||
if (capabilities.zwave_proxy.config_entry_id) {
|
||||
status.connectivity = "completed";
|
||||
} else if (capabilities.zwave_proxy.home_id !== 0) {
|
||||
} else if (
|
||||
capabilities.zwave_proxy.home_id !== 0 ||
|
||||
options.zwaveFlowInProgress
|
||||
) {
|
||||
status.connectivity = "detected";
|
||||
} else {
|
||||
status.connectivity = "not-started";
|
||||
@@ -217,6 +222,31 @@ export const deriveESPHomeSetupStatus = (
|
||||
return status;
|
||||
};
|
||||
|
||||
// Core stores the device MAC through format_mac; the discovery key keeps the raw value.
|
||||
const normalizeMac = (mac: string): string =>
|
||||
mac.replace(/[^0-9a-f]/gi, "").toLowerCase();
|
||||
|
||||
/** The zwave_js discovery flow ESPHome started for this device, keyed by its MAC. */
|
||||
export const findESPHomeZWaveFlow = (
|
||||
flows: readonly DataEntryFlowProgress[],
|
||||
macAddress: string | undefined
|
||||
): DataEntryFlowProgress | undefined => {
|
||||
const mac = macAddress ? normalizeMac(macAddress) : undefined;
|
||||
if (!mac) {
|
||||
return undefined;
|
||||
}
|
||||
return flows.find((flow) => {
|
||||
const discoveryKey = flow.context.discovery_key;
|
||||
return (
|
||||
flow.handler === "zwave_js" &&
|
||||
flow.context.source === "esphome" &&
|
||||
discoveryKey?.domain === "esphome" &&
|
||||
typeof discoveryKey.key === "string" &&
|
||||
normalizeMac(discoveryKey.key) === mac
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const getESPHomeSetupCapabilityIds = (
|
||||
status: ESPHomeSetupStatus
|
||||
): ESPHomeCapabilityId[] =>
|
||||
|
||||
@@ -2,8 +2,6 @@ import type { VisibilityCondition } from "../../../panels/lovelace/common/valida
|
||||
import type { LovelaceCardConfig } from "./card";
|
||||
import type { LovelaceStrategyConfig } from "./strategy";
|
||||
|
||||
export const DEFAULT_SECTION_COLUMN_SPAN = 1;
|
||||
|
||||
export const DEFAULT_SECTION_BACKGROUND_OPACITY = 50;
|
||||
|
||||
export interface LovelaceSectionBackgroundConfig {
|
||||
|
||||
+62
-24
@@ -5,8 +5,8 @@ import {
|
||||
mdiClipboardList,
|
||||
mdiFormatListBulletedType,
|
||||
mdiLightningBolt,
|
||||
mdiMap,
|
||||
mdiPlayBoxMultiple,
|
||||
mdiTooltipAccount,
|
||||
} from "@mdi/js";
|
||||
import type { LocalizeKeys } from "../common/translations/localize";
|
||||
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
|
||||
@@ -107,22 +107,48 @@ export const getConfigSubpageTitle = (
|
||||
path: string,
|
||||
configSections: Record<string, PageNavigation[]>
|
||||
): string | undefined => {
|
||||
// Search through all config section groups for a matching path
|
||||
for (const sectionGroup of Object.values(configSections)) {
|
||||
const pageNav = sectionGroup.find((nav) => path.startsWith(nav.path));
|
||||
if (pageNav) {
|
||||
if (pageNav.translationKey) {
|
||||
const localized = hass.localize(pageNav.translationKey as LocalizeKeys);
|
||||
if (localized) {
|
||||
return localized;
|
||||
}
|
||||
}
|
||||
const sections = Object.entries(configSections);
|
||||
|
||||
if (pageNav.name) {
|
||||
return pageNav.name;
|
||||
// Prefer pages with a full translation key or a name. The dashboard
|
||||
// sections also list pages like /config/integrations under a broader title.
|
||||
for (const [, pages] of sections) {
|
||||
const pageNav = pages.find((nav) => path.startsWith(nav.path));
|
||||
if (!pageNav) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pageNav.translationKey?.includes(".")) {
|
||||
const localized = hass.localize(pageNav.translationKey as LocalizeKeys);
|
||||
if (localized) {
|
||||
return localized;
|
||||
}
|
||||
}
|
||||
|
||||
if (pageNav.name) {
|
||||
return pageNav.name;
|
||||
}
|
||||
}
|
||||
|
||||
// Some pages are only listed with a short translation key. Expand it the
|
||||
// same way their navigation does.
|
||||
for (const [section, pages] of sections) {
|
||||
const shortKey = pages.find((nav) =>
|
||||
path.startsWith(nav.path)
|
||||
)?.translationKey;
|
||||
if (!shortKey || shortKey.includes(".")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const translationKey =
|
||||
section === "general"
|
||||
? `ui.panel.config.${shortKey}.caption`
|
||||
: `ui.panel.config.dashboard.${shortKey}.main`;
|
||||
const localized = hass.localize(translationKey as LocalizeKeys);
|
||||
if (localized) {
|
||||
return localized;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -137,19 +163,31 @@ export const getPanelIcon = (panel: PanelInfo): string | undefined => {
|
||||
return panel.icon || undefined;
|
||||
};
|
||||
|
||||
export const PANEL_ICON_PATHS = {
|
||||
calendar: mdiCalendar,
|
||||
energy: mdiLightningBolt,
|
||||
history: mdiChartBox,
|
||||
logbook: mdiFormatListBulletedType,
|
||||
map: mdiTooltipAccount,
|
||||
profile: mdiAccount,
|
||||
"media-browser": mdiPlayBoxMultiple,
|
||||
todo: mdiClipboardList,
|
||||
// Built-in panels render their stock icon from a bundled path, so the sidebar
|
||||
// does not wait for the icon set to load.
|
||||
const BUILT_IN_PANEL_ICONS: Record<string, { icon?: string; path: string }> = {
|
||||
calendar: { icon: "mdi:calendar", path: mdiCalendar },
|
||||
energy: { icon: "mdi:lightning-bolt", path: mdiLightningBolt },
|
||||
history: { icon: "mdi:chart-box", path: mdiChartBox },
|
||||
logbook: {
|
||||
icon: "mdi:format-list-bulleted-type",
|
||||
path: mdiFormatListBulletedType,
|
||||
},
|
||||
map: { icon: "mdi:map", path: mdiMap },
|
||||
profile: { path: mdiAccount },
|
||||
"media-browser": { icon: "mdi:play-box-multiple", path: mdiPlayBoxMultiple },
|
||||
todo: { icon: "mdi:clipboard-list", path: mdiClipboardList },
|
||||
};
|
||||
|
||||
export const getPanelIconPath = (panel: PanelInfo): string | undefined =>
|
||||
PANEL_ICON_PATHS[panel.url_path];
|
||||
export const getPanelIconPath = (panel: PanelInfo): string | undefined => {
|
||||
const builtIn = BUILT_IN_PANEL_ICONS[panel.url_path];
|
||||
if (!builtIn) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// An icon the user picked for the panel always wins.
|
||||
return !panel.icon || panel.icon === builtIn.icon ? builtIn.path : undefined;
|
||||
};
|
||||
|
||||
export const FIXED_PANELS = [PROFILE_PANEL, "config", NOT_FOUND_PANEL];
|
||||
|
||||
|
||||
+13
-7
@@ -73,6 +73,16 @@ const isDisabled = (config: unknown, parentDisabled: boolean): boolean =>
|
||||
"enabled" in config &&
|
||||
(config as { enabled?: boolean }).enabled === false);
|
||||
|
||||
/** The automation's triggers, flattened like the trace paths count them. */
|
||||
export const getTraceTriggers = (
|
||||
config: TraceExtended["config"]
|
||||
): Trigger[] | undefined => {
|
||||
const triggerKey = "triggers" in config ? "triggers" : "trigger";
|
||||
return triggerKey in config
|
||||
? flattenTriggers(ensureArray(config[triggerKey]))
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/** Configuration-shaped tree annotated with the execution tracked by Core. */
|
||||
export class TraceTree {
|
||||
public readonly triggers?: TraceNode<Trigger>[];
|
||||
@@ -97,15 +107,11 @@ export class TraceTree {
|
||||
|
||||
constructor(public readonly trace: TraceExtended) {
|
||||
const config = trace.config;
|
||||
const triggerKey = "triggers" in config ? "triggers" : "trigger";
|
||||
const conditionKey = "conditions" in config ? "conditions" : "condition";
|
||||
const actionKey = "actions" in config ? "actions" : "action";
|
||||
this.triggers =
|
||||
triggerKey in config
|
||||
? flattenTriggers(ensureArray(config[triggerKey])).map((trigger, i) =>
|
||||
this._triggerNode(trigger, `trigger/${i}`)
|
||||
)
|
||||
: undefined;
|
||||
this.triggers = getTraceTriggers(config)?.map((trigger, i) =>
|
||||
this._triggerNode(trigger, `trigger/${i}`)
|
||||
);
|
||||
this.conditions =
|
||||
conditionKey in config
|
||||
? ensureArray<Condition>(config[conditionKey] ?? []).map(
|
||||
|
||||
+4
-1
@@ -227,7 +227,10 @@ export const reconfigureNode = (
|
||||
{
|
||||
type: "zha/devices/reconfigure",
|
||||
ieee: ieeeAddress,
|
||||
}
|
||||
},
|
||||
// Subscribing starts the reconfiguration, so a reconnect must not
|
||||
// start it again.
|
||||
{ resubscribe: false }
|
||||
);
|
||||
|
||||
export const refreshTopology = (hass: HomeAssistant): Promise<void> =>
|
||||
|
||||
+8
-15
@@ -309,22 +309,15 @@ export interface ZWaveJSSetRawConfigParamData {
|
||||
value_format: number;
|
||||
}
|
||||
|
||||
export interface ZWaveJSSetConfigParamResult {
|
||||
value_id: string;
|
||||
status: number;
|
||||
export type ZWaveJSSetConfigParamStatus = "accepted" | "queued";
|
||||
|
||||
export interface ZWaveJSSetRawConfigParamResult {
|
||||
status: ZWaveJSSetConfigParamStatus;
|
||||
}
|
||||
|
||||
export type ZWaveJSSetConfigParamStatus = "accepted" | "queued" | "error";
|
||||
|
||||
// Maps the numeric SetValueStatus from zwave-js.
|
||||
export const computeSetConfigParamStatus = (
|
||||
status: number
|
||||
): ZWaveJSSetConfigParamStatus => {
|
||||
if (status === 254 || status === 255) {
|
||||
return "accepted";
|
||||
}
|
||||
return status === 1 ? "queued" : "error";
|
||||
};
|
||||
export interface ZWaveJSSetConfigParamResult extends ZWaveJSSetRawConfigParamResult {
|
||||
value_id: string;
|
||||
}
|
||||
|
||||
export interface ZwaveJSNodeConfigParameterUpdate {
|
||||
id: string;
|
||||
@@ -815,7 +808,7 @@ export const setZwaveNodeRawConfigParameter = (
|
||||
value: number,
|
||||
value_size: number,
|
||||
value_format: number
|
||||
): Promise<ZWaveJSSetConfigParamResult> => {
|
||||
): Promise<ZWaveJSSetRawConfigParamResult> => {
|
||||
const data: ZWaveJSSetRawConfigParamData = {
|
||||
type: "zwave_js/set_raw_config_parameter",
|
||||
device_id,
|
||||
|
||||
@@ -175,7 +175,7 @@ export const showConfigFlowDialog = (
|
||||
|
||||
renderExternalStepDescription(hass, step) {
|
||||
const description = hass.localize(
|
||||
`component.${step.translation_domain || step.handler}.config.${step.step_id}.description`,
|
||||
`component.${step.translation_domain || step.handler}.config.step.${step.step_id}.description`,
|
||||
step.description_placeholders
|
||||
);
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
configContext,
|
||||
configEntriesContext,
|
||||
connectionContext,
|
||||
devicesContext,
|
||||
entitiesContext,
|
||||
internationalizationContext,
|
||||
statesContext,
|
||||
@@ -46,6 +47,7 @@ import {
|
||||
ESPHOME_CAPABILITY_ACCENTS,
|
||||
ESPHOME_CAPABILITY_ICONS,
|
||||
ESPHOME_CAPABILITY_TITLE_KEYS,
|
||||
findESPHomeZWaveFlow,
|
||||
getESPHomeAudioControls,
|
||||
getESPHomeSetupCapabilityIds,
|
||||
isESPHomeSerialConfigured,
|
||||
@@ -65,7 +67,7 @@ import { extractApiErrorMessage } from "../../data/hassio/common";
|
||||
import { listSerialPortsWithUsage, type SerialPortUsage } from "../../data/usb";
|
||||
import { showAddIntegrationDialog } from "../../panels/config/integrations/show-add-integration-dialog";
|
||||
import { haStyle, haStyleDialog } from "../../resources/styles";
|
||||
import type { HomeAssistantUI } from "../../types";
|
||||
import type { HomeAssistant, HomeAssistantUI } from "../../types";
|
||||
import { documentationUrl } from "../../util/documentation-url";
|
||||
import { getWsErrorMessage } from "../../util/ws-error";
|
||||
import { showConfigFlowDialog } from "../config-flow/show-dialog-config-flow";
|
||||
@@ -159,6 +161,20 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
@consume({ context: configEntriesContext, subscribe: true })
|
||||
private _configEntries?: ContextType<typeof configEntriesContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: devicesContext, subscribe: true })
|
||||
@transform<HomeAssistant["devices"], string | undefined>({
|
||||
transformer: function (this: DialogESPHomeDeviceSetup, devices) {
|
||||
const deviceId = this.params?.deviceId;
|
||||
return deviceId
|
||||
? devices[deviceId]?.connections.find(([type]) => type === "mac")?.[1]
|
||||
: undefined;
|
||||
},
|
||||
})
|
||||
private _macAddress?: string;
|
||||
|
||||
@state() private _zwaveFlows: DataEntryFlowProgress[] = [];
|
||||
|
||||
@state() private _capabilities?: ESPHomeDeviceCapabilities;
|
||||
|
||||
/** Last successful USB usage scan. Undefined until a scan completes. */
|
||||
@@ -662,21 +678,6 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
return html`
|
||||
<p>${localize("ui.panel.config.devices.esphome.setup_adapters_intro")}</p>
|
||||
<ha-list-nav>
|
||||
<ha-list-item-button
|
||||
href=${documentationUrl(this._hassConfig!, "/connect/zbt-2/")}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
<span slot="headline">
|
||||
${localize("ui.panel.config.devices.esphome.setup_adapter_zbt2")}
|
||||
</span>
|
||||
<span slot="supporting-text">
|
||||
${localize(
|
||||
"ui.panel.config.devices.esphome.setup_adapter_zbt2_description"
|
||||
)}
|
||||
</span>
|
||||
<ha-svg-icon slot="end" .path=${mdiOpenInNew}></ha-svg-icon>
|
||||
</ha-list-item-button>
|
||||
<ha-list-item-button
|
||||
href=${documentationUrl(this._hassConfig!, "/connect/zwa-2/")}
|
||||
target="_blank"
|
||||
@@ -738,6 +739,8 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
this._capabilities.serial_proxies,
|
||||
this._serialPorts
|
||||
),
|
||||
zwaveFlowInProgress:
|
||||
findESPHomeZWaveFlow(this._zwaveFlows, this._macAddress) !== undefined,
|
||||
});
|
||||
// A failed scan left no usage data. Keep the row, but do not show
|
||||
// Configured or Set up from that missing result.
|
||||
@@ -766,6 +769,12 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
return;
|
||||
}
|
||||
this._capabilities = capabilities;
|
||||
if (capabilities.zwave_proxy.supported) {
|
||||
await this._refreshZWaveFlows();
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await this._refreshSerialPorts();
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
@@ -1098,12 +1107,25 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
});
|
||||
}
|
||||
|
||||
private async _refreshZWaveFlows() {
|
||||
if (!this._connection) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const flows = await fetchConfigFlowInProgress(
|
||||
this._connection.connection
|
||||
);
|
||||
this._zwaveFlows = flows.filter((flow) => flow.handler === "zwave_js");
|
||||
} catch {
|
||||
// Keep the last list.
|
||||
}
|
||||
}
|
||||
|
||||
private async _setupZWave(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
if (!this._connection || !this._capabilities || !this._i18n) {
|
||||
return;
|
||||
}
|
||||
const homeId = String(this._capabilities.zwave_proxy.home_id);
|
||||
let flows: DataEntryFlowProgress[];
|
||||
try {
|
||||
flows = await fetchConfigFlowInProgress(this._connection.connection);
|
||||
@@ -1118,12 +1140,7 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
const flow = flows.find(
|
||||
(item) =>
|
||||
item.handler === "zwave_js" &&
|
||||
item.context?.source === "esphome" &&
|
||||
item.context?.unique_id === homeId
|
||||
);
|
||||
const flow = findESPHomeZWaveFlow(flows, this._macAddress);
|
||||
if (!flow) {
|
||||
this._error = this._i18n.localize(
|
||||
"ui.panel.config.devices.esphome.setup_error_zwave"
|
||||
|
||||
@@ -90,7 +90,7 @@ class DialogBox extends DirtyStateProviderMixin<DialogBoxDirtyState>()(
|
||||
type=${confirmPrompt ? "alert" : "standard"}
|
||||
.preventScrimClose=${!!this._params.confirmation || this.isDirtyState}
|
||||
@closed=${this._dialogClosed}
|
||||
aria-labelledby="dialog-box-title"
|
||||
.accessibleLabel=${this._accessibleLabel(dialogTitle)}
|
||||
aria-describedby="dialog-box-description"
|
||||
>
|
||||
<ha-dialog-header slot="header">
|
||||
@@ -217,6 +217,17 @@ class DialogBox extends DirtyStateProviderMixin<DialogBoxDirtyState>()(
|
||||
}
|
||||
}
|
||||
|
||||
// Alerts often have no title. Their message is what a screen reader should
|
||||
// announce then, as long as it is plain text.
|
||||
private _accessibleLabel(dialogTitle: string | false | undefined) {
|
||||
if (dialogTitle) {
|
||||
return dialogTitle;
|
||||
}
|
||||
return typeof this._params?.text === "string"
|
||||
? this._params.text
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private _dismiss(): void {
|
||||
this._closeState = "canceled";
|
||||
this._cancel();
|
||||
|
||||
@@ -152,6 +152,11 @@ export class MoreInfoInfo extends LitElement {
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
|
||||
ha-more-info-history,
|
||||
ha-more-info-logbook {
|
||||
margin-inline: calc(var(--ha-space-6) * -1);
|
||||
}
|
||||
|
||||
ha-alert {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
} from "../../data/hassio/host";
|
||||
import { haStyle, haStyleDialog } from "../../resources/styles";
|
||||
import type { HomeAssistant, ServiceCallRequest } from "../../types";
|
||||
import { showToast } from "../../util/toast";
|
||||
import { HOST_ACTION_TOAST_ID, showToast } from "../../util/toast";
|
||||
import {
|
||||
showAlertDialog,
|
||||
showConfirmationDialog,
|
||||
@@ -315,6 +315,7 @@ class DialogRestart extends LitElement {
|
||||
private _hostAction =
|
||||
(toastMessage: string, action: "reboot" | "shutdown") => async () => {
|
||||
showToast(this, {
|
||||
id: HOST_ACTION_TOAST_ID,
|
||||
message: toastMessage,
|
||||
duration: -1,
|
||||
});
|
||||
@@ -328,6 +329,11 @@ class DialogRestart extends LitElement {
|
||||
} catch (err: any) {
|
||||
// Ignore connection errors, these are all expected
|
||||
if (this.hass.connection.connected && !ignoreSupervisorError(err)) {
|
||||
showToast(this, {
|
||||
id: HOST_ACTION_TOAST_ID,
|
||||
message: "",
|
||||
duration: 0,
|
||||
});
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(`ui.dialogs.restart.${action}.failed`),
|
||||
text: extractApiErrorMessage(err),
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
listAssistPipelines,
|
||||
} from "../../data/assist_pipeline";
|
||||
import type { AssistSatelliteConfiguration } from "../../data/assist_satellite";
|
||||
import type { CloudStatus } from "../../data/cloud";
|
||||
import { fetchCloudStatus } from "../../data/cloud";
|
||||
import type { LanguageScore, LanguageScores } from "../../data/conversation";
|
||||
import { getLanguageScores, listAgents } from "../../data/conversation";
|
||||
@@ -257,7 +258,14 @@ export class HaVoiceAssistantSetupStepPipeline extends LitElement {
|
||||
if (!isComponentLoaded(this.hass.config, "cloud")) {
|
||||
return false;
|
||||
}
|
||||
const cloudStatus = await fetchCloudStatus(this.hass);
|
||||
// Fall back to a local pipeline when the cloud status can't be fetched,
|
||||
// instead of leaving the step blank.
|
||||
let cloudStatus: CloudStatus;
|
||||
try {
|
||||
cloudStatus = await fetchCloudStatus(this.hass);
|
||||
} catch (_err: any) {
|
||||
return false;
|
||||
}
|
||||
if (!cloudStatus.logged_in || !cloudStatus.active_subscription) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Loaded 100 of 219 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user