mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-19 12:19:32 +00:00
Compare commits
94
Commits
fullcalendar-v7
...
rc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -177,6 +177,7 @@ gulp.task("copy-static-landing-page", async () => {
|
||||
|
||||
copyFonts(paths.landingPage_output_static);
|
||||
copyTranslations(paths.landingPage_output_static);
|
||||
copyLocaleData(paths.landingPage_output_static);
|
||||
});
|
||||
|
||||
gulp.task("copy-static-e2e-test-app", async () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
+39
-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,9 +36,11 @@ 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";
|
||||
import { zoneRegistryEntries } from "./stubs/map";
|
||||
import { mockMediaPlayer } from "./stubs/media_player";
|
||||
import { mockPersistentNotification } from "./stubs/persistent_notification";
|
||||
import { mockRecorder } from "./stubs/recorder";
|
||||
@@ -57,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",
|
||||
@@ -87,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,
|
||||
]),
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -102,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);
|
||||
@@ -137,6 +161,7 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
mockFloorRegistry(hass);
|
||||
mockLabelRegistry(hass);
|
||||
mockUsagePrediction(hass);
|
||||
mockEntityRegistryDisplay(hass);
|
||||
mockEntityRegistry(hass, [
|
||||
{
|
||||
config_entry_id: "co2signal",
|
||||
@@ -181,6 +206,7 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
modified_at: 0,
|
||||
},
|
||||
...connectivityEntityRegistryEntries,
|
||||
...zoneRegistryEntries,
|
||||
]);
|
||||
|
||||
hass.addEntities(energyEntities());
|
||||
@@ -191,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);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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,53 +1,3 @@
|
||||
export const mapEntities = () =>
|
||||
Object.values({
|
||||
"zone.home": {
|
||||
entity_id: "zone.home",
|
||||
state: "zoning",
|
||||
attributes: {
|
||||
hidden: true,
|
||||
latitude: 52.3631339,
|
||||
longitude: 4.8903147,
|
||||
radius: 200,
|
||||
friendly_name: "Home",
|
||||
icon: "hademo:home",
|
||||
},
|
||||
},
|
||||
"zone.uva": {
|
||||
entity_id: "zone.buckhead",
|
||||
state: "zoning",
|
||||
attributes: {
|
||||
hidden: true,
|
||||
radius: 400,
|
||||
friendly_name: "UvA",
|
||||
icon: "hademo:school",
|
||||
latitude: 52.3558182,
|
||||
longitude: 4.9535376,
|
||||
},
|
||||
},
|
||||
"person.arsaboo": {
|
||||
entity_id: "person.arsaboo",
|
||||
state: "not_home",
|
||||
attributes: {
|
||||
radius: 50,
|
||||
friendly_name: "Arsaboo",
|
||||
latitude: 52.3579946,
|
||||
longitude: 4.8664597,
|
||||
entity_picture: "/assets/arsaboo/images/arsaboo.jpg",
|
||||
},
|
||||
},
|
||||
"person.melody": {
|
||||
entity_id: "person.melody",
|
||||
state: "not_home",
|
||||
attributes: {
|
||||
radius: 50,
|
||||
friendly_name: "Melody",
|
||||
latitude: 52.3408927,
|
||||
longitude: 4.8711073,
|
||||
entity_picture: "/assets/arsaboo/images/melody.jpg",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const energyEntities = () =>
|
||||
Object.values({
|
||||
"sensor.grid_fossil_fuel_percentage": {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import "../custom-cards/cast-demo-row";
|
||||
import "../custom-cards/ha-demo-card";
|
||||
import "../custom-cards/ha-demo-next-card";
|
||||
import { mapEntities } from "./entities";
|
||||
import { mockMap } from "./map";
|
||||
|
||||
export const mockLovelace = (
|
||||
hass: MockHomeAssistant,
|
||||
@@ -17,7 +17,7 @@ export const mockLovelace = (
|
||||
) => {
|
||||
hass.mockWS("lovelace/config", ({ url_path }) => {
|
||||
if (url_path === "map") {
|
||||
hass.addEntities(mapEntities());
|
||||
mockMap(hass);
|
||||
return {
|
||||
strategy: {
|
||||
type: "map",
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
|
||||
import type { EntityHistoryState } from "../../../src/data/history";
|
||||
import type { Zone } from "../../../src/data/zone";
|
||||
import { demoConfig } from "../../../src/fake_data/demo_config";
|
||||
import type { EntityInput } from "../../../src/fake_data/entities/types";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const HOUR = 3600 * 1000;
|
||||
|
||||
type ZoneData = Omit<Zone, "id">;
|
||||
|
||||
const HOME: ZoneData = {
|
||||
name: "Home",
|
||||
icon: "mdi:home",
|
||||
latitude: demoConfig.latitude,
|
||||
longitude: demoConfig.longitude,
|
||||
radius: 100,
|
||||
passive: false,
|
||||
};
|
||||
|
||||
export const zones: Zone[] = [
|
||||
{
|
||||
id: "gym",
|
||||
name: "Gym",
|
||||
icon: "mdi:dumbbell",
|
||||
latitude: 52.358,
|
||||
longitude: 4.8686,
|
||||
radius: 150,
|
||||
passive: false,
|
||||
},
|
||||
{
|
||||
id: "school",
|
||||
name: "School",
|
||||
icon: "mdi:school",
|
||||
latitude: 52.3558182,
|
||||
longitude: 4.9535376,
|
||||
radius: 250,
|
||||
passive: false,
|
||||
},
|
||||
{
|
||||
id: "supermarket",
|
||||
name: "Supermarket",
|
||||
icon: "mdi:cart",
|
||||
latitude: 52.3793,
|
||||
longitude: 4.8994,
|
||||
radius: 100,
|
||||
passive: false,
|
||||
},
|
||||
{
|
||||
id: "work",
|
||||
name: "Work",
|
||||
icon: "mdi:briefcase",
|
||||
latitude: 52.3909184,
|
||||
longitude: 4.8530821,
|
||||
radius: 200,
|
||||
passive: false,
|
||||
},
|
||||
];
|
||||
|
||||
interface DemoPerson {
|
||||
id: string;
|
||||
name: string;
|
||||
picture: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
// Hours ago and the state entered then, oldest first. The first entry is
|
||||
// the state before the 24 hour activity window, the last one the current
|
||||
// state.
|
||||
timeline: [number, string][];
|
||||
}
|
||||
|
||||
const PEOPLE: DemoPerson[] = [
|
||||
{
|
||||
id: "arsaboo",
|
||||
name: "Arsaboo",
|
||||
picture: "/assets/arsaboo/images/arsaboo.jpg",
|
||||
latitude: 52.3732,
|
||||
longitude: 4.8904,
|
||||
timeline: [
|
||||
[30, "home"],
|
||||
[10, "not_home"],
|
||||
[9.5, "Work"],
|
||||
[5, "not_home"],
|
||||
[4.6, "Gym"],
|
||||
[3.4, "not_home"],
|
||||
[3.1, "Supermarket"],
|
||||
[2.8, "not_home"],
|
||||
[2.4, "home"],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "melody",
|
||||
name: "Melody",
|
||||
picture: "/assets/arsaboo/images/melody.jpg",
|
||||
latitude: 52.373,
|
||||
longitude: 4.8901,
|
||||
timeline: [
|
||||
[30, "home"],
|
||||
[9, "not_home"],
|
||||
[8.6, "School"],
|
||||
[4, "not_home"],
|
||||
[3.6, "home"],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "oscar",
|
||||
name: "Oscar",
|
||||
picture: "/assets/kernehed/oscar.jpg",
|
||||
latitude: 52.391,
|
||||
longitude: 4.8532,
|
||||
timeline: [
|
||||
[30, "home"],
|
||||
[9, "not_home"],
|
||||
[8.7, "Work"],
|
||||
[4.5, "not_home"],
|
||||
[4.2, "Supermarket"],
|
||||
[3.8, "not_home"],
|
||||
[3.5, "Work"],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "bella",
|
||||
name: "Bella",
|
||||
picture: "/assets/kernehed/bella.jpg",
|
||||
latitude: 52.365,
|
||||
longitude: 4.8795,
|
||||
timeline: [
|
||||
[30, "home"],
|
||||
[8, "not_home"],
|
||||
[7.6, "Work"],
|
||||
[1.2, "not_home"],
|
||||
[0.9, "Gym"],
|
||||
[0.3, "not_home"],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const CAR: EntityInput = {
|
||||
entity_id: "device_tracker.car",
|
||||
state: "not_home",
|
||||
attributes: {
|
||||
friendly_name: "Car",
|
||||
icon: "mdi:car",
|
||||
source_type: "gps",
|
||||
latitude: 52.3805,
|
||||
longitude: 4.8718,
|
||||
gps_accuracy: 10,
|
||||
},
|
||||
};
|
||||
|
||||
const lastMove = (person: DemoPerson) =>
|
||||
person.timeline[person.timeline.length - 1];
|
||||
|
||||
const zoneEntity = (entityId: string, zone: ZoneData): EntityInput => {
|
||||
// A person's state is "home" for the home zone, the zone name otherwise
|
||||
const personState = entityId === "zone.home" ? "home" : zone.name;
|
||||
const persons = PEOPLE.filter(
|
||||
(person) => lastMove(person)[1] === personState
|
||||
).map((person) => `person.${person.id}`);
|
||||
return {
|
||||
entity_id: entityId,
|
||||
state: String(persons.length),
|
||||
attributes: {
|
||||
latitude: zone.latitude,
|
||||
longitude: zone.longitude,
|
||||
radius: zone.radius,
|
||||
passive: zone.passive,
|
||||
persons,
|
||||
editable: true,
|
||||
icon: zone.icon,
|
||||
friendly_name: zone.name,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const zoneEntities = (): EntityInput[] => [
|
||||
zoneEntity("zone.home", HOME),
|
||||
...zones.map((zone) => zoneEntity(`zone.${zone.id}`, zone)),
|
||||
];
|
||||
|
||||
// UI zones are in the entity registry, so the zone editor does not also list
|
||||
// them as YAML zones
|
||||
export const zoneRegistryEntries: EntityRegistryEntry[] = zones.map((zone) => ({
|
||||
config_entry_id: null,
|
||||
config_subentry_id: null,
|
||||
device_id: null,
|
||||
area_id: null,
|
||||
disabled_by: null,
|
||||
entity_id: `zone.${zone.id}`,
|
||||
id: `zone.${zone.id}`,
|
||||
name: null,
|
||||
icon: null,
|
||||
labels: [],
|
||||
categories: {},
|
||||
platform: "zone",
|
||||
hidden_by: null,
|
||||
entity_category: null,
|
||||
has_entity_name: false,
|
||||
unique_id: zone.id,
|
||||
options: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
}));
|
||||
|
||||
const personEntity = (person: DemoPerson, now: number): EntityInput => {
|
||||
const [hoursAgo, state] = lastMove(person);
|
||||
return {
|
||||
entity_id: `person.${person.id}`,
|
||||
state,
|
||||
last_changed: new Date(now - hoursAgo * HOUR).toISOString(),
|
||||
attributes: {
|
||||
friendly_name: person.name,
|
||||
entity_picture: person.picture,
|
||||
latitude: person.latitude,
|
||||
longitude: person.longitude,
|
||||
gps_accuracy: 20,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const personHistory = (person: DemoPerson, now: number): EntityHistoryState[] =>
|
||||
person.timeline.map(([hoursAgo, state]) => {
|
||||
const time = (now - hoursAgo * HOUR) / 1000;
|
||||
return { s: state, a: {}, lu: time, lc: time };
|
||||
});
|
||||
|
||||
export const mockMap = (hass: MockHomeAssistant) => {
|
||||
const now = Date.now();
|
||||
hass.addEntities([
|
||||
...zoneEntities(),
|
||||
...PEOPLE.map((person) => personEntity(person, now)),
|
||||
CAR,
|
||||
]);
|
||||
hass.mockWS(
|
||||
"history/history_during_period",
|
||||
({ entity_ids }: { entity_ids?: string[] }) =>
|
||||
Object.fromEntries(
|
||||
PEOPLE.filter(
|
||||
(person) => !entity_ids || entity_ids.includes(`person.${person.id}`)
|
||||
).map((person) => [`person.${person.id}`, personHistory(person, now)])
|
||||
)
|
||||
);
|
||||
};
|
||||
+2
-22
@@ -1,27 +1,7 @@
|
||||
import type { Zone } from "../../../src/data/zone";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const zones: Zone[] = [
|
||||
{
|
||||
id: "home",
|
||||
name: "Home",
|
||||
icon: "mdi:home",
|
||||
latitude: 52.3731339,
|
||||
longitude: 4.8903147,
|
||||
radius: 100,
|
||||
passive: false,
|
||||
},
|
||||
{
|
||||
id: "work",
|
||||
name: "Work",
|
||||
icon: "mdi:briefcase",
|
||||
latitude: 52.3909184,
|
||||
longitude: 4.8530821,
|
||||
radius: 200,
|
||||
passive: false,
|
||||
},
|
||||
];
|
||||
import { zoneEntities, zones } from "./map";
|
||||
|
||||
export const mockZone = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("zone/list", () => zones);
|
||||
hass.addEntities(zoneEntities());
|
||||
};
|
||||
@@ -101,6 +101,7 @@ export default [
|
||||
"entity-button-card",
|
||||
"entity-filter-card",
|
||||
"glance-card",
|
||||
"sensor-card",
|
||||
"tile-card",
|
||||
"area-card",
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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",
|
||||
|
||||
Generated
+5
-5
@@ -226,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
|
||||
@@ -5054,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==}
|
||||
@@ -12770,7 +12770,7 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected].0: {}
|
||||
[email protected].1: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20260930.0"
|
||||
version = "20260930.4"
|
||||
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 */
|
||||
|
||||
@@ -8,46 +8,46 @@
|
||||
|
||||
/** Drawn on the light palette */
|
||||
export const HA_MAP_COLORS = {
|
||||
background: "#f4efe6",
|
||||
land: "#f4efe6",
|
||||
water: "#bcd9e8",
|
||||
labelWater: "#5b86a3",
|
||||
natureWood: "#cfe1bf",
|
||||
natureGrass: "#e2ecda",
|
||||
naturePark: "#cfe1bf",
|
||||
natureLeisure: "#e2ecda",
|
||||
natureAgriculture: "#e8eee2",
|
||||
natureWetland: "#e5ebe0",
|
||||
natureSand: "#f1eadf",
|
||||
natureRock: "#f1eadf",
|
||||
glacier: "#f9f6f1",
|
||||
areaResidential: "#efe8dc",
|
||||
areaCommercial: "#f4e1db",
|
||||
areaIndustrial: "#ede5ce",
|
||||
areaWaste: "#ece3d5",
|
||||
areaBurial: "#efe8dc",
|
||||
siteParking: "#ede6d8",
|
||||
siteSports: "#cfe1bf26",
|
||||
building: "#e7ddca",
|
||||
buildingBg: "#dcccb2",
|
||||
background: "#f5efe5",
|
||||
land: "#f5efe5",
|
||||
water: "#b8daec",
|
||||
labelWater: "#5487aa",
|
||||
natureWood: "#cfe4bc",
|
||||
natureGrass: "#e2eed8",
|
||||
naturePark: "#cfe4bc",
|
||||
natureLeisure: "#e2eed8",
|
||||
natureAgriculture: "#e8efe1",
|
||||
natureWetland: "#e5ecdf",
|
||||
natureSand: "#f3eadd",
|
||||
natureRock: "#f3eadd",
|
||||
glacier: "#faf6f0",
|
||||
areaResidential: "#f1e9da",
|
||||
areaCommercial: "#f7e0d8",
|
||||
areaIndustrial: "#f0e7cb",
|
||||
areaWaste: "#eee3d3",
|
||||
areaBurial: "#f1e9da",
|
||||
siteParking: "#efe7d6",
|
||||
siteSports: "#cfe4bc26",
|
||||
building: "#eadec7",
|
||||
buildingBg: "#e0cdae",
|
||||
roadStreet: "#fcfaf8",
|
||||
roadStreetBg: "#e6dac7",
|
||||
roadTrunk: "#fbe6c2",
|
||||
roadTrunkBg: "#e5cea5",
|
||||
roadMotorway: "#ebd4ab",
|
||||
roadMotorwayBg: "#deba78",
|
||||
transitRail: "#e9dfce",
|
||||
transitSubway: "#eae1d1",
|
||||
transitCycle: "#f2ede3",
|
||||
transitFoot: "#f2ede3",
|
||||
boundary: "#d6c3a4",
|
||||
boundaryDisputed: "#e2d6c0",
|
||||
label: "#4a463d",
|
||||
roadStreetBg: "#e9dbc4",
|
||||
roadTrunk: "#ffe7be",
|
||||
roadTrunkBg: "#ebd09f",
|
||||
roadMotorway: "#f1d6a5",
|
||||
roadMotorwayBg: "#e8bd6e",
|
||||
transitRail: "#ece0cb",
|
||||
transitSubway: "#ece2cf",
|
||||
transitCycle: "#f3eee2",
|
||||
transitFoot: "#f3eee2",
|
||||
boundary: "#dbc49f",
|
||||
boundaryDisputed: "#e5d7bd",
|
||||
label: "#4b473c",
|
||||
labelHalo: "#faf8f5cc",
|
||||
labelSymbol: "#8d8676",
|
||||
labelPoi: "#8d867666",
|
||||
labelShield: "#faf8f4",
|
||||
labelHousenumber: "#8d86764d",
|
||||
labelSymbol: "#8f8774",
|
||||
labelPoi: "#8f877466",
|
||||
labelShield: "#fbf8f3",
|
||||
labelHousenumber: "#8f87744d",
|
||||
};
|
||||
|
||||
/** Drawn on the dark palette, in full: a light value left standing here would
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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[] =>
|
||||
|
||||
+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> =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -77,22 +77,34 @@ const handleProxyRequest: RouteHandler = async ({ request }) => {
|
||||
return fetch(req);
|
||||
};
|
||||
|
||||
// The access token rotates, so keying on the full URL empties the cache every
|
||||
// rotation. Other params (such as brands' "placeholder") stay in the key.
|
||||
const ignoreTokenPlugin = {
|
||||
cacheKeyWillBeUsed: async ({ request }: { request: Request }) => {
|
||||
const url = new URL(request.url);
|
||||
url.searchParams.delete("token");
|
||||
return url.href;
|
||||
},
|
||||
};
|
||||
|
||||
const initRouting = () => {
|
||||
precacheAndRoute(__WB_MANIFEST__, {
|
||||
// Ignore all URL parameters.
|
||||
ignoreURLParametersMatching: [/.*/],
|
||||
});
|
||||
|
||||
// Cache static content (including translations) on first access.
|
||||
// Cache static content (including translations) on first access. Match on
|
||||
// the start of the path: custom integrations serve their own files from
|
||||
// paths like /api/<domain>/static/, and those must not be cached forever.
|
||||
registerRoute(
|
||||
/\/(static|frontend_latest|frontend_es5)\/.+/,
|
||||
({ url }) =>
|
||||
url.origin === location.origin &&
|
||||
/^\/(static|frontend_latest|frontend_es5)\/.+/.test(url.pathname),
|
||||
new CacheFirst({ matchOptions: { ignoreSearch: true } })
|
||||
);
|
||||
|
||||
// Cache any brand images used for 1 day
|
||||
// Brands are proxied via the local API with backend caching.
|
||||
// Strip the rotating access token from cache keys so token rotation
|
||||
// doesn't bust the cache, while preserving other params like "placeholder".
|
||||
registerRoute(
|
||||
({ url, request }) =>
|
||||
url.pathname.startsWith("/api/brands/") &&
|
||||
@@ -100,13 +112,7 @@ const initRouting = () => {
|
||||
new StaleWhileRevalidate({
|
||||
cacheName: "brands",
|
||||
plugins: [
|
||||
{
|
||||
cacheKeyWillBeUsed: async ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
url.searchParams.delete("token");
|
||||
return url.href;
|
||||
},
|
||||
},
|
||||
ignoreTokenPlugin,
|
||||
// Add 404 so we quickly respond to domains with missing images
|
||||
new CacheableResponsePlugin({ statuses: [0, 200, 404] }),
|
||||
new ExpirationPlugin({
|
||||
@@ -117,26 +123,37 @@ const initRouting = () => {
|
||||
})
|
||||
);
|
||||
|
||||
// Strip the rotating token from the cache key, or every rotation refetches
|
||||
// every tile. The TileJSON is deliberately not matched: it is the switching
|
||||
// point for the tile source, so it stays on the network.
|
||||
// A stale token gives a 403, and caching that would pin the failure.
|
||||
const cacheableTile = new CacheableResponsePlugin({ statuses: [0, 200] });
|
||||
|
||||
// A couple of dozen, pinned to an upstream release. Kept apart from the
|
||||
// tiles so panning cannot evict the fonts every label needs.
|
||||
registerRoute(
|
||||
({ url }) =>
|
||||
/^\/api\/map_tiles\/(vector|raster|fonts|sprites)\//.test(url.pathname),
|
||||
({ url }) => /^\/api\/map_tiles\/(fonts|sprites)\//.test(url.pathname),
|
||||
new CacheFirst({
|
||||
cacheName: "map-assets",
|
||||
plugins: [
|
||||
ignoreTokenPlugin,
|
||||
cacheableTile,
|
||||
new ExpirationPlugin({
|
||||
maxAgeSeconds: 60 * 60 * 24 * 30,
|
||||
purgeOnQuotaError: true,
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
// The working set is a tile pyramid over every zoom visited, so the ceiling
|
||||
// is generous; the quota purge is what actually bounds it.
|
||||
registerRoute(
|
||||
({ url }) => /^\/api\/map_tiles\/(vector|raster)\//.test(url.pathname),
|
||||
new CacheFirst({
|
||||
cacheName: "map-tiles",
|
||||
plugins: [
|
||||
{
|
||||
cacheKeyWillBeUsed: async ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
url.searchParams.delete("token");
|
||||
return url.href;
|
||||
},
|
||||
},
|
||||
// A stale token gives a 403; caching that would pin the failure.
|
||||
new CacheableResponsePlugin({ statuses: [0, 200] }),
|
||||
ignoreTokenPlugin,
|
||||
cacheableTile,
|
||||
new ExpirationPlugin({
|
||||
maxEntries: 500,
|
||||
maxEntries: 1000,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 7,
|
||||
purgeOnQuotaError: true,
|
||||
}),
|
||||
@@ -144,6 +161,23 @@ const initRouting = () => {
|
||||
})
|
||||
);
|
||||
|
||||
// Every map waits on this before its first tile, so serve it from cache -
|
||||
// revalidated behind that, since it is how a moved endpoint arrives.
|
||||
registerRoute(
|
||||
({ url }) => url.pathname === "/api/map_tiles/tilejson.json",
|
||||
new StaleWhileRevalidate({
|
||||
cacheName: "map-tilejson",
|
||||
plugins: [
|
||||
ignoreTokenPlugin,
|
||||
cacheableTile,
|
||||
new ExpirationPlugin({
|
||||
maxAgeSeconds: 60 * 60 * 24,
|
||||
purgeOnQuotaError: true,
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
// Short-circuit camera/image proxy requests with an expired signature or a
|
||||
// missing/undefined token so they don't hit core and get logged as invalid
|
||||
// login attempts. Registered before the generic /api route below so it wins.
|
||||
|
||||
+2228
-1047
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,36 @@
|
||||
import { MockToggleEntity } from "./toggle-entity";
|
||||
import type { EntityAttributes } from "./types";
|
||||
|
||||
const AUTOMATION_ATTRIBUTES = [
|
||||
"id",
|
||||
"last_triggered",
|
||||
"mode",
|
||||
"current",
|
||||
"max",
|
||||
];
|
||||
|
||||
export class MockAutomationEntity extends MockToggleEntity {
|
||||
public async handleService(
|
||||
domain: string,
|
||||
service: string,
|
||||
data: Record<string, any>
|
||||
): Promise<void> {
|
||||
if (domain === "automation" && service === "trigger") {
|
||||
this.update({
|
||||
attributes: { last_triggered: new Date().toISOString() },
|
||||
});
|
||||
return;
|
||||
}
|
||||
super.handleService(domain, service, data);
|
||||
}
|
||||
|
||||
protected _getStateAttributes(): EntityAttributes {
|
||||
const stateAttrs: EntityAttributes = {};
|
||||
for (const key of AUTOMATION_ATTRIBUTES) {
|
||||
if (key in this.attributes) {
|
||||
stateAttrs[key] = this.attributes[key];
|
||||
}
|
||||
}
|
||||
return stateAttrs;
|
||||
}
|
||||
}
|
||||
@@ -56,8 +56,8 @@ export class MockBaseEntity {
|
||||
this.areaId = input.area_id;
|
||||
this.deviceId = input.device_id;
|
||||
this.platform = input.platform;
|
||||
this.lastChanged = randomTime();
|
||||
this.lastUpdated = randomTime();
|
||||
this.lastChanged = input.last_changed ?? randomTime();
|
||||
this.lastUpdated = input.last_changed ?? randomTime();
|
||||
|
||||
const attributes: EntityAttributes = input.attributes || {};
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MockBaseEntity } from "./base-entity";
|
||||
import type { EntityAttributes } from "./types";
|
||||
|
||||
const LOCATION_ATTRIBUTES = [
|
||||
"latitude",
|
||||
"longitude",
|
||||
"gps_accuracy",
|
||||
"radius",
|
||||
"passive",
|
||||
"persons",
|
||||
"editable",
|
||||
"source",
|
||||
"source_type",
|
||||
"in_zones",
|
||||
];
|
||||
|
||||
export class MockLocationEntity extends MockBaseEntity {
|
||||
protected _getStateAttributes(): EntityAttributes {
|
||||
const stateAttrs: EntityAttributes = {};
|
||||
for (const key of LOCATION_ATTRIBUTES) {
|
||||
if (key in this.attributes) {
|
||||
stateAttrs[key] = this.attributes[key];
|
||||
}
|
||||
}
|
||||
return stateAttrs;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MockBaseEntity } from "./base-entity";
|
||||
import type { EntityInput } from "./types";
|
||||
import { MockAlarmControlPanelEntity } from "./alarm-control-panel-entity";
|
||||
import { MockAutomationEntity } from "./automation-entity";
|
||||
import { MockClimateEntity } from "./climate-entity";
|
||||
import { MockCoverEntity } from "./cover-entity";
|
||||
import { MockFanEntity } from "./fan-entity";
|
||||
@@ -11,6 +12,7 @@ import { MockLawnMowerEntity } from "./lawn-mower-entity";
|
||||
import { MockInputSelectEntity } from "./input-select-entity";
|
||||
import { MockInputTextEntity } from "./input-text-entity";
|
||||
import { MockLightEntity } from "./light-entity";
|
||||
import { MockLocationEntity } from "./location-entity";
|
||||
import { MockLockEntity } from "./lock-entity";
|
||||
import { MockMediaPlayerEntity } from "./media-player-entity";
|
||||
import { MockToggleEntity } from "./toggle-entity";
|
||||
@@ -21,10 +23,11 @@ import { MockWaterHeaterEntity } from "./water-heater-entity";
|
||||
type EntityConstructor = new (input: EntityInput) => MockBaseEntity;
|
||||
|
||||
const TYPES: Record<string, EntityConstructor> = {
|
||||
automation: MockToggleEntity,
|
||||
automation: MockAutomationEntity,
|
||||
alarm_control_panel: MockAlarmControlPanelEntity,
|
||||
climate: MockClimateEntity,
|
||||
cover: MockCoverEntity,
|
||||
device_tracker: MockLocationEntity,
|
||||
fan: MockFanEntity,
|
||||
group: MockGroupEntity,
|
||||
humidifier: MockHumidifierEntity,
|
||||
@@ -36,10 +39,12 @@ const TYPES: Record<string, EntityConstructor> = {
|
||||
light: MockLightEntity,
|
||||
lock: MockLockEntity,
|
||||
media_player: MockMediaPlayerEntity,
|
||||
person: MockLocationEntity,
|
||||
switch: MockToggleEntity,
|
||||
vacuum: MockVacuumEntity,
|
||||
valve: MockValveEntity,
|
||||
water_heater: MockWaterHeaterEntity,
|
||||
zone: MockLocationEntity,
|
||||
};
|
||||
|
||||
export const getEntity = (input: EntityInput): MockBaseEntity => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { MockBaseEntity } from "./base-entity";
|
||||
|
||||
/**
|
||||
* Generic toggle entity used for domains: automation, input_boolean, switch.
|
||||
* Generic toggle entity used for domains: input_boolean, switch.
|
||||
* Handles turn_on, turn_off, and toggle services via homeassistant or own domain.
|
||||
*/
|
||||
export class MockToggleEntity extends MockBaseEntity {
|
||||
|
||||
@@ -17,6 +17,8 @@ export type EntityInput = Pick<
|
||||
device_id?: string;
|
||||
/** Integration that provides the entity, defaults to "demo" */
|
||||
platform?: string;
|
||||
/** When the state last changed, a random recent time by default */
|
||||
last_changed?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -363,6 +363,11 @@ export class HAFullCalendar extends LitElement {
|
||||
|
||||
config.dateClick = (info) => this._handleDateClick(info);
|
||||
config.eventClick = (info) => this._handleEventClick(info);
|
||||
// fullcalendar sets the event colors only inline, where styles cannot mix them
|
||||
config.eventDidMount = ({ el, event }) => {
|
||||
el.style.setProperty("--event-color", event.borderColor);
|
||||
el.style.setProperty("--event-text-color", event.textColor);
|
||||
};
|
||||
|
||||
this.calendar = new Calendar(
|
||||
this.shadowRoot!.getElementById("calendar")!,
|
||||
@@ -768,30 +773,92 @@ export class HAFullCalendar extends LitElement {
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
.tentative {
|
||||
--tentative-tint: color-mix(
|
||||
in srgb,
|
||||
var(--event-color) 25%,
|
||||
transparent
|
||||
);
|
||||
/* In the text color, so the stripes show on every calendar color */
|
||||
--tentative-stripe: color-mix(
|
||||
in srgb,
|
||||
var(--primary-text-color) 12%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
/* A dot keeps its size and color and gets stripes in the color that
|
||||
contrasts with it, 1.5px wide every 4px */
|
||||
.tentative .fc-daygrid-event-dot,
|
||||
.tentative .fc-list-event-dot {
|
||||
--tentative-dot-stripe: color-mix(
|
||||
in srgb,
|
||||
var(--event-text-color, #fff) 80%,
|
||||
transparent
|
||||
);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background-color: var(--event-color);
|
||||
background-image: linear-gradient(
|
||||
45deg,
|
||||
var(--tentative-dot-stripe) 0 9.375%,
|
||||
transparent 9.375% 40.625%,
|
||||
var(--tentative-dot-stripe) 40.625% 59.375%,
|
||||
transparent 59.375% 90.625%,
|
||||
var(--tentative-dot-stripe) 90.625%
|
||||
);
|
||||
background-size: 4px 4px;
|
||||
/* Half a tile, so no stripe runs through the middle like a slash */
|
||||
background-position: 2px 0;
|
||||
}
|
||||
|
||||
.tentative .fc-daygrid-event-dot {
|
||||
/* A long title would otherwise squeeze the ring into an oval */
|
||||
/* A long title would otherwise squeeze the dot into an oval */
|
||||
flex-shrink: 0;
|
||||
border-width: 2px;
|
||||
width: calc(var(--fc-daygrid-event-dot-width, 8px) - 4px);
|
||||
height: calc(var(--fc-daygrid-event-dot-width, 8px) - 4px);
|
||||
width: var(--fc-daygrid-event-dot-width, 8px);
|
||||
height: var(--fc-daygrid-event-dot-width, 8px);
|
||||
}
|
||||
|
||||
.tentative .fc-list-event-dot {
|
||||
border-width: 2px;
|
||||
width: calc(var(--fc-list-event-dot-width, 10px) - 4px);
|
||||
height: calc(var(--fc-list-event-dot-width, 10px) - 4px);
|
||||
width: var(--fc-list-event-dot-width, 10px);
|
||||
height: var(--fc-list-event-dot-width, 10px);
|
||||
}
|
||||
|
||||
/* fullcalendar sets the colors as inline styles */
|
||||
.fc-h-event.tentative {
|
||||
background-color: transparent !important;
|
||||
border-style: dashed;
|
||||
background-color: var(--tentative-tint) !important;
|
||||
/* A tile of whole pixels puts every stripe on the same pixels,
|
||||
2.8px wide every 14px */
|
||||
background-image: linear-gradient(
|
||||
45deg,
|
||||
var(--tentative-stripe) 0 5%,
|
||||
transparent 5% 45%,
|
||||
var(--tentative-stripe) 45% 55%,
|
||||
transparent 55% 95%,
|
||||
var(--tentative-stripe) 95%
|
||||
);
|
||||
background-size: 14px 14px;
|
||||
}
|
||||
|
||||
.fc-h-event.tentative .fc-event-main {
|
||||
color: var(--primary-text-color) !important;
|
||||
}
|
||||
|
||||
/* Forced colors drop the stripes and the fill of a dot, so a tentative
|
||||
event falls back to its outline */
|
||||
@media (forced-colors: active) {
|
||||
.fc-h-event.tentative {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.tentative .fc-daygrid-event-dot,
|
||||
.tentative .fc-list-event-dot {
|
||||
box-sizing: border-box;
|
||||
border-style: solid;
|
||||
border-width: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.fc-day-past .fc-daygrid-day-events {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@@ -75,8 +75,8 @@ class DialogFloorDetail extends DirtyStateProviderMixin<FloorFormState>()(
|
||||
this._aliases = this._params.entry?.aliases || [];
|
||||
this._icon = this._params.entry?.icon || null;
|
||||
this._level = this._params.entry?.level ?? null;
|
||||
this._addedAreas.clear();
|
||||
this._removedAreas.clear();
|
||||
this._addedAreas = new Set();
|
||||
this._removedAreas = new Set();
|
||||
this._open = true;
|
||||
this._initDirtyTracking({ type: "deep" }, this._currentState());
|
||||
}
|
||||
@@ -99,8 +99,6 @@ class DialogFloorDetail extends DirtyStateProviderMixin<FloorFormState>()(
|
||||
private _dialogClosed(): void {
|
||||
this._error = "";
|
||||
this._params = undefined;
|
||||
this._addedAreas.clear();
|
||||
this._removedAreas.clear();
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { startOfYesterday } from "date-fns";
|
||||
import {
|
||||
mdiDelete,
|
||||
mdiDevices,
|
||||
@@ -12,22 +11,23 @@ import {
|
||||
mdiShape,
|
||||
mdiTools,
|
||||
} from "@mdi/js";
|
||||
import { startOfYesterday } from "date-fns";
|
||||
import type { HassEntity } from "home-assistant-js-websocket/dist/types";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consume } from "../../../common/decorators/consume";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { consume } from "../../../common/decorators/consume";
|
||||
import type { HASSDomCurrentTargetEvent } from "../../../common/dom/fire_event";
|
||||
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { computeStateName } from "../../../common/entity/compute_state_name";
|
||||
import { goBack, navigate } from "../../../common/navigate";
|
||||
import { createSearchParam } from "../../../common/url/search-params";
|
||||
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
|
||||
import { slugify } from "../../../common/string/slugify";
|
||||
import { createSearchParam } from "../../../common/url/search-params";
|
||||
import { groupBy } from "../../../common/util/group-by";
|
||||
import { afterNextRender } from "../../../common/util/render-status";
|
||||
import { createColumnsController } from "../../../common/util/responsive-columns";
|
||||
@@ -37,9 +37,10 @@ import "../../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-icon-next";
|
||||
import "../../../components/ha-icon-button-next";
|
||||
import "../../../components/ha-icon-next";
|
||||
import "../../../components/ha-list";
|
||||
import "../../../components/ha-list-item";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import "../../../components/ha-tooltip";
|
||||
import type { AreaRegistryEntry } from "../../../data/area/area_registry";
|
||||
@@ -67,8 +68,8 @@ import "../../../layouts/hass-error-screen";
|
||||
import "../../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { isHelperDomain } from "../helpers/const";
|
||||
import "../../logbook/ha-logbook";
|
||||
import { isHelperDomain } from "../helpers/const";
|
||||
import {
|
||||
loadAreaAddToDialog,
|
||||
showAreaAddToDialog,
|
||||
|
||||
@@ -363,7 +363,7 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
this._entityReg,
|
||||
this.action,
|
||||
undefined,
|
||||
{ hideTriggerIds: true },
|
||||
undefined,
|
||||
this._manifests
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -1619,10 +1619,6 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
background-color: var(--ha-color-fill-primary-normal-hover);
|
||||
}
|
||||
|
||||
wa-tree-item::part(base).tree-item-selected .item {
|
||||
background-color: yellow;
|
||||
}
|
||||
|
||||
ha-list-base {
|
||||
--ha-row-item-padding-inline: var(--ha-space-3);
|
||||
--ha-row-item-padding-block: var(--ha-space-1);
|
||||
|
||||
@@ -205,9 +205,7 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
<ha-automation-condition-summary
|
||||
slot="header"
|
||||
.label=${capitalizeFirstLetter(
|
||||
describeCondition(this.condition, this.hass, this._entityReg, {
|
||||
hideTriggerIds: true,
|
||||
})
|
||||
describeCondition(this.condition, this.hass, this._entityReg)
|
||||
)}
|
||||
.condition=${this.condition}
|
||||
.description=${this.conditionDescriptions[this.condition.condition]}
|
||||
|
||||
@@ -49,7 +49,7 @@ export class HaTriggerCondition extends LitElement {
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const selectedIds = ensureArray(this.condition.id).filter(Boolean);
|
||||
const selectedIds = (ensureArray(this.condition.id) ?? []).filter(Boolean);
|
||||
const triggerIdOptions = this._triggers?.options ?? [];
|
||||
const missingIds = this._missingIds(this.condition.id, triggerIdOptions);
|
||||
|
||||
@@ -174,7 +174,7 @@ export class HaTriggerCondition extends LitElement {
|
||||
};
|
||||
|
||||
private _selectId(id: string, checked: boolean) {
|
||||
const ids = ensureArray(this.condition.id).filter(Boolean);
|
||||
const ids = (ensureArray(this.condition.id) ?? []).filter(Boolean);
|
||||
const selectedIds = checked
|
||||
? ids.includes(id)
|
||||
? ids
|
||||
@@ -202,7 +202,9 @@ export class HaTriggerCondition extends LitElement {
|
||||
private _missingIds = memoizeOne(
|
||||
(ids: TriggerCondition["id"], triggerIdOptions: TriggerIdOption[]) => {
|
||||
const availableIds = new Set(triggerIdOptions.map((option) => option.id));
|
||||
return ensureArray(ids).filter((id) => id && !availableIds.has(id));
|
||||
return (ensureArray(ids) ?? []).filter(
|
||||
(id) => id && !availableIds.has(id)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -869,7 +869,9 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
super.updated(changedProps);
|
||||
if (changedProps.has("_entityReg")) {
|
||||
// The filtered list holds entity IDs, so also refresh it when automations
|
||||
// are added or removed
|
||||
if (changedProps.has("_entityReg") || changedProps.has("automations")) {
|
||||
this._applyFilters();
|
||||
}
|
||||
}
|
||||
|
||||
Loaded 100 of 174 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user