Compare commits

..
Author SHA1 Message Date
renovate[bot]andGitHub 14f9edcb27 Update Node.js to v24.20.0 2026-08-29 01:06:47 +00:00
136cf0c8bf Revamp user settings page & navigation (#52070)
* refactor(profile): split general section into subpages with dashboard navigation

Replace the two-tab profile layout (general/security) with a dashboard-first
navigation structure. The general section is split into three focused subpages
(preferences, localization, browser), each wrapping the original row components
unchanged. A new dashboard page provides a user card (name, owner badge, logout)
and a navigation list linking to all four subpages.

Add ha-navigation-list as a reusable component for page navigation lists.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* refactor: remove orphan translation keys after general section split

Drop tabs.general and current_user, which were only used in the now-deleted
ha-profile-section-general.ts and the old profileSections tab config.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* refactor(profile): align dashboard layout with Bluetooth panel pattern

Replace .content with .container (padding on wrapper, max-width/margin on
cards) to match the layout convention used in protocol panels like Bluetooth.
Always show the chevron in ha-navigation-list, removing the narrow condition.
Drop the unused narrow prop from ha-navigation-list.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* refactor: move ha-config-navigation-list to src/components

Makes the component available outside the config panel. Update import
paths in the two existing config consumers and use it in the profile
dashboard. Remove the now-unused ha-navigation-list (which relied on
ha-md-list/ha-md-list-item).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix: security subpage width and card spacing

Use .container > * instead of ha-card to apply max-width and margin,
since the security cards are custom elements whose inner ha-card lives
in their own shadow DOM and is unreachable from the parent stylesheet.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* refactor(profile): move language setting to localization subpage

Language is a regional/cultural preference, consistent with the other
settings in localization (time zone, date and time formats).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* refactor(profile): rename profile subpage labels

- "User preferences" -> "Appearance"
- "Browser settings" -> "This browser"
- "Mobile app settings" -> "This mobile app"

* feat(profile): surface theme picker on dashboard page

Move ha-pick-theme-row from the Appearance subpage to the profile
dashboard, between the user card and the navigation list. Theme and
dark mode are now accessible directly without navigating to a subpage.

Update Appearance description to reflect its new scope.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* refactor(profile): restructure theme row layout and labels

Split dark mode and color pickers into separate ha-settings-row elements
with headings. Move reset button outside the color-row to keep the label
vertically stable when the button appears. Add padding to reset row.
Rename "Reset" to "Reset colors" for clarity.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* add title to theme settings card

* refactor(profile): apply theme row layout improvements to ha-theme-settings

Migrate the dark mode and color pickers layout from ha-pick-theme-row into
ha-theme-settings: use separate ha-settings-row elements with headings for
dark mode and custom colors, move reset button outside as a sibling element.
Add colors label to ThemeSettingsLabels interface.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* refactor(profile): migrate theme settings to ha-list-item-base layout

Replace ha-settings-row elements in ha-theme-settings with ha-list-item-base
inside ha-list-base for a more compact appearance. Split color pickers into
individual rows (one per color). Reduce vertical padding to --ha-space-2.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* refactor(profile): combine color pickers into single row with label

Merge primary and accent color pickers into one ha-list-item-base row
under a "Custom colors" headline. Restore labels on individual pickers
and increase their min-width to 150px.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix(profile): stack theme settings rows vertically in narrow mode

Reflect narrow prop on host and use ::part(base/end) to stack all
ha-list-item-base rows in column layout on narrow screens, preventing
headline truncation from oversized end slot controls.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* Fix test:e2e:demo

* fix broken links

* Add my links

* Use s instead of smal for ha-button size

Co-authored-by: Maarten Lakerveld <[email protected]>

---------

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
Co-authored-by: Maarten Lakerveld <[email protected]>
2026-08-28 21:57:59 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
920c858056 Update dependency @lokalise/node-api to v16.4.0 (#53865)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-28 21:57:46 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
3d2a4bfa04 Update dependency generate-license-file to v4.2.3 (#53864)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-28 21:57:43 +02:00
Paul BotteinandGitHub e2c9b2cb79 Always allow saving vacuum segment mapping (#53862) 2026-08-28 17:21:14 +02:00
Maarten LakerveldandGitHub d38ee185bf Allow negative number entry on iOS when the number selector has no min (#53852)
The iOS workaround from #52925 only applied when the selector had an
explicit negative min. The numeric threshold selector used by the power
triggers passes a number selector without a min, so the digit-only keypad
(without a minus key) was still shown. Treat a missing min as allowing
negatives too.

Also leave inputmode unset instead of forcing "text": on a number input
iOS then shows the Numbers and Punctuation keyboard, which has a minus
key and fits numeric entry better than the full QWERTY keyboard.

Fixes #53747
2026-08-28 16:50:01 +02:00
7e10f50419 Prefer neighbor relationship over raw LQI in ZHA network graph fallback (#53764)
* Prefer neighbor relationship over raw LQI in ZHA network graph fallback

* Create zha-network-data.test.ts

* Fix relative import paths and test connectivity via BFS instead of exact edges

* Add test coverage for extended relationship priority

* Apply suggestions from code review

Co-authored-by: Petar Petrov <[email protected]>

* Just capitalizing relationshipPriority → RELATIONSHIP_PRIORITY in those two spots — nothing else changes.

* Update zha-network-data.ts

* Update zha-network-data.test.ts

* Update zha-network-data.ts

* Update zha-network-data.ts

* Update zha-network-data.test.ts

---------

Co-authored-by: Petar Petrov <[email protected]>
2026-08-28 15:47:00 +03:00
noksideandGitHub 070fd96d6c Fix and centralize selector initial value handling (#53729)
* Fix and centralize selector initial value handling

* Fix unsupported selector initial values

* Omit undefined choose child values

* Fix device class and object selector initial values
2026-08-28 15:45:57 +03:00
Maarten LakerveldandGitHub 156840b6ff Fix RTL map labels and add English names to non-Latin ones (#53856)
* Load MapLibre RTL text plugin so Hebrew and Arabic map labels read correctly

The vector base map never registered MapLibre's RTL text plugin, so
right-to-left scripts were shaped left to right and every label came out
reversed. Ship @mapbox/mapbox-gl-rtl-text from /static/map/ alongside the
glyphs (no third-party host) and register it once, lazily, before the
first vector layer is created.

Fixes #53851

* Add the English name to map labels in non-Latin scripts

Before the move to vector tiles the CARTO basemap showed English names
everywhere. The OSM style shows local names, which most users cannot
read in Cyrillic, Arabic, Hebrew or CJK regions. Keep the local name and
add the English one from the tiles under it - in parentheses for street
labels, which cannot break lines. Latin-script names are left alone, so
Köln stays Köln.
2026-08-28 14:44:43 +03:00
5ff7a05ec5 feat: option to show add event button in calendar card (#53055)
* feat: option to show add event button in calendar card

* fix: remove translation file

* refactor: rename show_addfab to show_add_event

* fix: css calendar height

* feat: add event styling options

* Delete translations/backend/en.json

* fix: translation file

* fix: translations file

* fix: no box shadow when button below calendar

* fix: inconsistent narrow mode calculation

* fix: screen reader compability

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

* fix: margin-inline-start for rtl layout support

* fix: use literal union instead of string in editor

* fix: use sentence case

* Apply suggestions from code review

Co-authored-by: Petar Petrov <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>

* fix: remove unused class

* feat: use visible parameter to toggle editor elements

* refactor: split add-fab into mulitple attributes for ha-full-calendar

* fix: lint type error

* fix: editor defaults

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: Petar Petrov <[email protected]>
2026-08-28 14:39:17 +03:00
c3643555ab Respect gas entity display precision in energy dashboard (#53712)
* Respect gas display precision in gas graph

The gas graph previously formatted the total gas consumption using the generic number formatter, which could ignore the display precision  configured on the selected gas entity.

Use the configured gas entity's display precision for both the visible total chip and the total consumption tooltip.

This keeps the gas graph consistent with the entity configuration. For example, a gas entity configured with 3 decimal places will display 0.009 m³ instead of being rounded to 0.01 m³.

* Use configured gas entity display precision in energy distribution card

The energy distribution card previously formatted gas usage with the generic
energy formatter, which could ignore the display precision configured on the
selected gas entity.

Use the display precision of the configured gas consumption entity when
formatting the gas value in the energy distribution card.

This keeps the displayed value consistent with the entity configuration. For
example, a gas entity configured with 3 decimal places will now display
0.009 m³ instead of being rounded to 0.01 m³.

* Respect gas display precision in energy sources table

The energy sources table previously formatted gas values using the generic number formatter, which could ignore the display precision configured on the selected gas entity.

Use the configured gas entity's display precision for both individual gas source rows and the gas total row.

This keeps gas values consistent with the entity configuration. For example, a gas entity configured with 3 decimal places will display 0.009 m³ instead of being rounded to 0.01 m³.

* Use highest gas display precision for energy distribution

The energy distribution card previously used the display precision of the first configured gas source for the aggregated gas value.

Use the highest configured display precision across all gas sources instead of special-casing the first source.

This avoids making source 0 authoritative for an aggregate value and preserves the greatest configured precision when multiple gas sources are present.

* Use highest gas display precision for energy distribution

The energy distribution card previously used the display precision of the first configured gas source for the aggregated gas value.

Use the highest configured display precision across all gas sources instead of special-casing the first source.

This avoids making source 0 authoritative for an aggregate value and preserves the greatest configured precision when multiple gas sources are present.

* Use highest gas display precision in gas graph

The gas graph previously used the display precision of the first configured gas source for its aggregated total.

Use the highest configured display precision across all gas sources instead of special-casing source 0.

This keeps the graph total and tooltip consistent with the greatest configured precision when multiple gas sources are present.

* Use display_precision for entity precision

Replace dp with display_precision when reading precision from hass.entities, matching the EntityRegistryDisplayEntry type used by the frontend.

* Use display_precision for entity precision

Replace dp with display_precision when reading precision from hass.entities, matching the EntityRegistryDisplayEntry type used by the frontend.

* Use display_precision for entity precision

Replace dp with display_precision when reading precision from hass.entities, matching the EntityRegistryDisplayEntry type used by the frontend.

* Use display_precision for entity precision

Replace dp with display_precision when reading precision from hass.entities, matching the EntityRegistryDisplayEntry type used by the frontend.

* Use display_precision for entity precision

Replace dp with display_precision when reading precision from hass.entities, matching the EntityRegistryDisplayEntry type used by the frontend.

* Use display_precision for entity precision

Replace dp with display_precision when reading precision from hass.entities, matching the EntityRegistryDisplayEntry type used by the frontend.

* Restore consumption formatting in energy distribution card

* Respect gas display precision in energy sources table

* Respect gas display precision in energy graph

* Respect display precision in formatConsumptionShort

* Pass gas display precision to consumption formatter

Add logic to calculate gas display precision based on available sources.

* Use gas display precision for graph y-axis

* Update distribution card on gas precision changes

* Update sources table on gas precision changes

* Update gas graph when display precision changes

Refresh the gas graph when a gas entity's display precision or unit of measurement changes, ensuring the configured precision is applied without waiting for new energy data.

* Update distribution card on gas unit changes

Refactor gas source display precision check and update unit of measurement comparison.

* Derive gas display precision with a getter

* Fix hass change handling in energy sources table

* Fix hass change handling in gas graph

* Fix hass change handling in energy distribution

* Fix gas unit narrowing in energy distribution

* Fix formatting in gas graph card

Refactor gasDisplayPrecisions assignment for better readability.

* Fix formatting in energy sources table

* Fix Prettier formatting in energy gas cards

---------

Co-authored-by: Petar Petrov <[email protected]>
2026-08-28 11:15:12 +00:00
Paul BotteinandGitHub ae2c296272 Remove vacuum battery_level attribute unit (#53855) 2026-08-28 12:42:38 +02:00
1db1c8024e Improve low battery entity lookup (#53850)
* Improve low battery entity lookup

* Apply suggestions from code review

Co-authored-by: Maarten Lakerveld <[email protected]>

* Fix formatting

---------

Co-authored-by: Maarten Lakerveld <[email protected]>
2026-08-28 10:47:44 +02:00
renovate[bot]andGitHub 2f003ca999 Update dependency js-yaml to v5.4.0 (#53853) 2026-08-28 08:58:36 +01:00
Maarten LakerveldandGitHub b1d8a21aaa Don't close drawer when a nested tooltip hides (#53809)
wa-after-hide from a tooltip inside the drawer (e.g. the exact time
tooltip on a persistent notification) bubbled to the wa-drawer listener
and was treated as the drawer closing. Only handle the drawer's own event.

Fixes #52901
2026-08-28 08:45:28 +01:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
cde2e66229 Update dependency typescript-eslint to v8.68.0 (#53848)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-28 06:45:08 +02:00
Paulus SchoutsenandGitHub 71d5500ed8 Render camera favorites as camera tiles (#53814) 2026-08-28 00:05:02 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
462321a3c8 Update dependency eslint to v10.9.1 (#53846)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-27 22:02:51 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
afc0c36dd9 Update dependency marked to v18.0.11 (#53847)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-27 22:02:43 +02:00
62 changed files with 3028 additions and 1484 deletions
+2
View File
@@ -75,3 +75,5 @@ test/e2e/app/dist/
test/benchmarks/results/
# Downloaded map glyph and sprite archives
.map-assets/
+1 -1
View File
@@ -1 +1 @@
24.19.0
24.20.0
+4
View File
@@ -108,6 +108,10 @@ async function copyMapPanel(staticDir) {
// Style, glyphs and sprites for the vector base map
await ensureMapAssets();
fs.copySync(mapAssetsDir, staticPath("map/"));
copyFileDir(
npmPath("@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js"),
staticPath("map/")
);
}
function copyZXingWasm(staticDir) {
+153 -20
View File
@@ -1,25 +1,137 @@
// Generates the MapLibre styles for the vector base map.
// Assembles the static assets of the vector base map - style, SDF glyphs and
// icon sprites - into /static/map/. vector.openstreetmap.org sets CORS headers
// on its tiles only, so these cannot be loaded from there by a browser.
//
// Only the styles. Glyphs, sprites and tiles are served by core's proxy, which
// is what lets them be requested with an application User-Agent and without a
// referrer. The styles stay here because they come from @versatiles/style and
// core has no node toolchain to regenerate them with.
// Glyphs and sprites come from pinned VersaTiles releases, cached locally and
// verified against a digest.
import { writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { colorful, eclipse } from "@versatiles/style";
import fs from "fs-extra";
import gulp from "gulp";
import { extract } from "tar";
import paths from "../paths.cjs";
import { addLatinLabels } from "./map-labels.js";
const PROXY_PATH = "/api/map_tiles";
const TILEJSON_URL = `${PROXY_PATH}/tilejson.json`;
// The tile URL is deliberately never named here: the OSMF asks consumers to
// resolve it through the TileJSON so they can move the tiles without every
// client needing a release. https://operations.osmfoundation.org/policies/vector/
const TILEJSON_URL =
"https://vector.openstreetmap.org/shortbread_v1/tilejson.json";
const ASSET_PATH = "/static/map";
const ARCHIVES = {
fonts: {
url: "https://github.com/versatiles-org/versatiles-fonts/releases/download/v2.2.0/noto_sans.tar.gz",
sha256: "a2dac39f4096722bc420367ffd4a36687cce7229e8aa760bc12cf657072eea6b",
},
sprites: {
url: "https://github.com/versatiles-org/versatiles-style/releases/download/v5.13.1/sprites.tar.gz",
sha256: "efffd0ee4cb9591bd52f16ff5b269d9618c7dd1db159cd6511943965560ddea5",
},
};
// Rendered with a device font through `localIdeographFontFamily`, so these are
// never downloaded - and they are 90% of the Noto Sans SDF set.
const LOCAL_IDEOGRAPH_BLOCKS = [
[0x2e80, 0x9fff], // CJK radicals through CJK Unified Ideographs, incl. kana
[0xac00, 0xd7ff], // Hangul syllables
[0xf900, 0xfaff], // CJK compatibility ideographs
[0xfe30, 0xfe4f], // CJK compatibility forms
];
// Our styles use bold only for motorway shields, so Latin, Greek and Cyrillic
// cover every ref and the rest of bold - half the glyph set - is dropped.
// `assertBoldStaysOnRefs` guards the assumption.
const BOLD_MAX_CODEPOINT = 0x04ff;
const BOLD_TEXT_FIELD = "{ref}";
const cacheDir = path.resolve(paths.root_dir, ".map-assets");
const outputDir = path.resolve(paths.build_dir, "map");
const sha256 = (buffer) => createHash("sha256").update(buffer).digest("hex");
// Downloads an archive into the cache, or reuses it when the digest matches.
const cachedArchive = async (name, { url, sha256: expected }) => {
const file = path.join(cacheDir, `${name}.tar.gz`);
if (await fs.pathExists(file)) {
if (sha256(await readFile(file)) === expected) {
return file;
}
console.warn("Cached map %s archive is stale, downloading again", name);
}
console.log("Downloading map %s from %s", name, url);
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to download map ${name}: ${response.status} ${response.statusText}`
);
}
const body = Buffer.from(await response.arrayBuffer());
const digest = sha256(body);
if (digest !== expected) {
throw new Error(
`Digest mismatch for map ${name}: expected ${expected}, got ${digest}`
);
}
await fs.outputFile(file, body);
return file;
};
const glyphRange = (entryPath) => {
const match = /^(?<font>[^/]+)\/(?<start>\d+)-(?<end>\d+)\.pbf$/.exec(
entryPath
);
return match
? { font: match.groups.font, start: Number(match.groups.start) }
: undefined;
};
const keepGlyph = (entryPath) => {
const range = glyphRange(entryPath);
if (!range) {
return false;
}
if (range.font.endsWith("_bold") && range.start > BOLD_MAX_CODEPOINT) {
return false;
}
return !LOCAL_IDEOGRAPH_BLOCKS.some(
([from, to]) => range.start >= from && range.start <= to
);
};
const keepSprite = (entryPath) =>
/^basics\/sprites(@2x)?\.(json|png)$/.test(entryPath);
// `neutrino`, for one, sets country and state labels in bold - names in any
// script, which would turn to tofu. Fail the build rather than ship that.
const assertBoldStaysOnRefs = (name, style) => {
const offenders = style.layers
.filter((layer) =>
(layer.layout?.["text-font"] ?? []).some((font) => font.endsWith("_bold"))
)
.filter((layer) => layer.layout["text-field"] !== BOLD_TEXT_FIELD)
.map((layer) => layer.id);
if (offenders.length) {
throw new Error(
`Style "${name}" uses bold for ${offenders.join(", ")}, which can hold ` +
`names in any script. Raise BOLD_MAX_CODEPOINT to cover the full set ` +
`before shipping this style.`
);
}
};
// MapLibre extends the fetched TileJSON with the style's source options, so
// anything left here wins and freezes at build time. Dropping them is what lets
// the proxy move the attribution and zoom range too, not just the URLs.
// a remote switch move the attribution and zoom range too, not just the URLs.
const TILEJSON_FIELDS = [
"tiles",
"attribution",
@@ -52,25 +164,46 @@ const useTileJson = (name, style) => {
const styleOptions = {
// Keeps the generated URLs origin relative.
baseUrl: "",
glyphs: `${PROXY_PATH}/fonts/{fontstack}/{range}.pbf`,
sprite: [{ id: "basics", url: `${PROXY_PATH}/sprites/basics/sprites` }],
glyphs: `${ASSET_PATH}/fonts/{fontstack}/{range}.pbf`,
sprite: [{ id: "basics", url: `${ASSET_PATH}/sprites/basics/sprites` }],
};
const buildMapAssets = async () => {
await fs.emptyDir(outputDir);
const [fontArchive, spriteArchive] = await Promise.all([
cachedArchive("fonts", ARCHIVES.fonts),
cachedArchive("sprites", ARCHIVES.sprites),
]);
await Promise.all(
await fs.emptyDir(outputDir);
await Promise.all([
fs.ensureDir(path.join(outputDir, "fonts")),
fs.ensureDir(path.join(outputDir, "sprites")),
]);
await Promise.all([
extract({
file: fontArchive,
cwd: path.join(outputDir, "fonts"),
filter: keepGlyph,
}),
extract({
file: spriteArchive,
cwd: path.join(outputDir, "sprites"),
filter: keepSprite,
}),
// Both themes up front: dark is a real cartography, not an inverted raster.
[
...[
["light", colorful],
["dark", eclipse],
].map(([name, builder]) =>
writeFile(
].map(([name, builder]) => {
const style = addLatinLabels(useTileJson(name, builder(styleOptions)));
assertBoldStaysOnRefs(name, style);
return writeFile(
path.join(outputDir, `${name}.json`),
JSON.stringify(useTileJson(name, builder(styleOptions)))
)
)
);
JSON.stringify(style)
);
}),
]);
};
// Shared so it does not have to be wired into every pipeline separately.
+42
View File
@@ -0,0 +1,42 @@
// Adds the English name to labels whose local name is not in Latin script.
// Shortbread tiles carry `name`, `name_en` and `name_de` only.
const NAME = ["get", "name"];
const NAME_EN = ["get", "name_en"];
// Strings compare by code point: anything from Basic Latin up to Latin
// Extended-B, digits and punctuation included.
const IS_LATIN = ["<", NAME, "ɐ"];
const ENGLISH_SCALE = 0.8;
// Streets are line-placed and cannot break lines.
const withEnglish = (placement) =>
placement === "line"
? ["concat", NAME, " (", NAME_EN, ")"]
: ["format", NAME, {}, "\n", {}, NAME_EN, { "font-scale": ENGLISH_SCALE }];
const isNameLabel = (layer) =>
JSON.stringify(layer.layout?.["text-field"]) === JSON.stringify(NAME);
export const addLatinLabels = (style) => ({
...style,
layers: style.layers.map((layer) =>
isNameLabel(layer)
? {
...layer,
layout: {
...layer.layout,
"text-field": [
"case",
IS_LATIN,
NAME,
["!", ["has", "name_en"]],
NAME,
withEnglish(layer.layout["symbol-placement"]),
],
},
}
: layer
),
});
+8 -7
View File
@@ -75,6 +75,7 @@
"@lit/context": "1.1.6",
"@lit/reactive-element": "2.1.2",
"@lit/task": "1.0.3",
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
"@maplibre/maplibre-gl-leaflet": "0.1.4",
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
@@ -109,7 +110,7 @@
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.14",
"js-yaml": "5.3.0",
"js-yaml": "5.4.0",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
@@ -117,7 +118,7 @@
"lit-html": "3.3.3",
"luxon": "3.7.2",
"maplibre-gl": "5.24.0",
"marked": "18.0.10",
"marked": "18.0.11",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -149,7 +150,7 @@
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@html-eslint/eslint-plugin": "0.65.0",
"@lokalise/node-api": "16.3.0",
"@lokalise/node-api": "16.4.0",
"@octokit/auth-oauth-device": "8.0.4",
"@octokit/plugin-retry": "8.1.1",
"@octokit/rest": "22.0.1",
@@ -179,7 +180,7 @@
"browserslist": "4.28.8",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.9.0",
"eslint": "10.9.1",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
@@ -189,7 +190,7 @@
"eslint-plugin-wc": "3.1.0",
"fancy-log": "2.0.0",
"fs-extra": "11.4.0",
"generate-license-file": "4.2.1",
"generate-license-file": "4.2.3",
"glob": "13.0.6",
"globals": "17.11.0",
"gulp": "5.0.1",
@@ -216,7 +217,7 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.67.0",
"typescript-eslint": "8.68.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.11",
"webpack-stats-plugin": "1.1.3",
@@ -234,6 +235,6 @@
},
"packageManager": "[email protected]",
"volta": {
"node": "24.19.0"
"node": "24.20.0"
}
}
+1 -3
View File
@@ -19,7 +19,6 @@ export const setupLeafletMap = async (
longitude: number;
zoom?: number;
darkMode?: boolean;
token?: string;
}
): Promise<LeafletMapSetup> => {
if (!mapElement.parentNode) {
@@ -61,8 +60,7 @@ export const setupLeafletMap = async (
const baseLayer = await createBaseLayer(
Leaflet,
map,
initialView?.darkMode ?? false,
initialView?.token
initialView?.darkMode ?? false
);
return { map, leaflet: Leaflet, baseLayer };
+91 -134
View File
@@ -1,40 +1,42 @@
import type { maplibreGL } from "@maplibre/maplibre-gl-leaflet";
import type { Map as LeafletMap, TileLayerOptions } from "leaflet";
import type { StyleSpecification } from "maplibre-gl";
import type { Map as LeafletMap } from "leaflet";
import type { setRTLTextPlugin, StyleSpecification } from "maplibre-gl";
import type { LeafletModuleType } from "../dom/setup-leaflet-map";
import {
MAP_TILES_PATH,
refreshMapTilesToken,
subscribeMapTilesToken,
withMapTilesToken,
} from "../../data/map_tiles";
// Generated by build-scripts/gulp/map-assets.js. The attribution comes from the
// TileJSON, deliberately: it follows whoever serves the tiles.
// Shortbread vector tiles from the OpenStreetMap Foundation. Only their tile
// endpoint sends CORS headers, so the style, glyphs and sprites are ours to
// serve - see build-scripts/gulp/map-assets.js. The credit comes from the
// TileJSON rather than from here, deliberately: it follows whoever serves the
// tiles.
const VECTOR_STYLES = {
light: "/static/map/light.json",
dark: "/static/map/dark.json",
} as const;
// MapLibre needs WebGL2 even for raster, so the fallback stays a Leaflet layer.
// OSM serves no @2x variant.
const RASTER_TILE_URL = `${MAP_TILES_PATH}/raster/{z}/{x}/{y}.png?token={token}`;
const OSM_ATTRIBUTION =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
// Without it Arabic and Hebrew labels render reversed. Loaded by MapLibre's
// worker, hence a URL rather than an import.
const RTL_TEXT_PLUGIN_URL = "/static/map/mapbox-gl-rtl-text.js";
// Browsers keep about 16 live WebGL contexts and drop the oldest, which a
// dashboard full of map cards hits. A transient loss is restored, hence a grace.
// Fallback for browsers without WebGL2, which MapLibre needs even for raster,
// so it stays a Leaflet tile layer. Still CARTO, and temporarily so: OSM's
// raster blocks a browser that sends no Referer, and the only referrer a browser
// can send is its origin, which identifies a Nabu Casa installation.
const RASTER_TILE_URL = "https://basemaps.cartocdn.com/rastertiles/voyager";
const CARTO_ATTRIBUTION =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, ' +
'&copy; <a href="https://carto.com/attributions">CARTO</a>';
// Browsers keep about 16 live WebGL contexts and drop the oldest, so a dashboard
// full of map cards loses its first ones for good - nothing frees a slot for
// MapLibre to reclaim. A transient loss does get restored, hence the grace.
const CONTEXT_RESTORE_GRACE = 2000;
const RECOVERY_THROTTLE = 30000;
// On the map, not the layer: marker clustering throws without a maximum. The
// floor is 1 because at Leaflet zoom 0 the adapter drives MapLibre to -1.
// On the map, not the layer: only Leaflet tile layers report their own limits,
// and marker clustering throws without a maximum. The floor is 1 because at
// Leaflet zoom 0 the adapter drives MapLibre to -1, outside its range.
export const MAP_MIN_ZOOM = 1;
export const MAP_MAX_ZOOM = 20;
// Leaflet substitutes any option into the URL template; its types do not.
type TokenTileLayerOptions = TileLayerOptions & { token?: string };
export interface MapBaseLayer {
// A no-op for raster, which has no dark variant and is inverted in CSS.
setDarkMode: (darkMode: boolean) => void;
@@ -42,7 +44,7 @@ export interface MapBaseLayer {
let webGL2Supported: boolean | undefined;
// Rules out iOS below 15, older Android tablets and blocklisted drivers.
// Rules out iOS below 15, older Android tablets, and blocklisted drivers.
const supportsWebGL2 = (): boolean => {
if (webGL2Supported === undefined) {
try {
@@ -57,35 +59,11 @@ const supportsWebGL2 = (): boolean => {
return webGL2Supported;
};
// MapLibre rejects a relative sprite URL. Not the glyph URL: encoding would
// mangle its {fontstack} and {range} placeholders.
// The demo has no core to proxy through. OSM sets CORS on its tiles but not on
// its glyphs and sprites, which is why those come from VersaTiles.
const DEMO_UPSTREAM = {
tilejson: "https://vector.openstreetmap.org/shortbread_v1/tilejson.json",
assets: "https://tiles.versatiles.org/assets",
};
const useDemoUpstream = (style: StyleSpecification): StyleSpecification => {
style.glyphs = `${DEMO_UPSTREAM.assets}/glyphs/{fontstack}/{range}.pbf`;
style.sprite = [
{ id: "basics", url: `${DEMO_UPSTREAM.assets}/sprites/basics/sprites` },
];
Object.values(style.sources).forEach((source) => {
if ("url" in source) {
source.url = DEMO_UPSTREAM.tilejson;
}
});
return style;
};
// Asset URLs are stored origin relative so they follow the instance's host, but
// MapLibre rejects a relative sprite URL. The glyph URL is left alone: URL
// encoding would mangle its {fontstack} and {range} placeholders.
const loadStyle = async (url: string): Promise<StyleSpecification> => {
const style: StyleSpecification = await (await fetch(url)).json();
if (__DEMO__) {
return useDemoUpstream(style);
}
if (typeof style.sprite === "string") {
style.sprite = new URL(style.sprite, location.href).href;
} else if (Array.isArray(style.sprite)) {
@@ -97,28 +75,39 @@ const loadStyle = async (url: string): Promise<StyleSpecification> => {
return style;
};
// Global to MapLibre, and it throws when set twice.
let rtlTextPluginRequested = false;
const ensureRTLTextPlugin = (setPlugin: typeof setRTLTextPlugin) => {
if (rtlTextPluginRequested) {
return;
}
rtlTextPluginRequested = true;
setPlugin(new URL(RTL_TEXT_PLUGIN_URL, location.href).href, true).catch(
() => {
// RTL labels stay reversed; everything else still renders.
}
);
};
const createVectorLayer = async (
createLayer: typeof maplibreGL,
leaflet: LeafletModuleType,
map: LeafletMap,
darkMode: boolean,
token: string | undefined
darkMode: boolean
): Promise<MapBaseLayer | undefined> => {
let layer: ReturnType<typeof maplibreGL> | undefined;
try {
layer = createLayer({
style: await loadStyle(VECTOR_STYLES[darkMode ? "dark" : "light"]),
// Absolute, or the worker fetching tiles cannot resolve them.
transformRequest: (url) => ({
url: withMapTilesToken(url),
// OSM asks a website for a referrer, and the demo has no instance
// hostname to leak.
referrerPolicy: __DEMO__ ? "origin" : undefined,
}),
// Draws CJK, kana and hangul with a device font, which is why we ship a
// tenth of the glyph set. No referrer is set: the endpoint serves without
// one, and the only one a browser can send identifies the installation.
localIdeographFontFamily: "sans-serif",
});
// The plugin builds the MapLibre map in `onAdd`, so a refused context or a
// blocked worker throws here. Keep it guarded or those lose the fallback.
// The plugin builds the MapLibre map in `onAdd`, so a refused context, a
// blocked worker or a rejected blob URL throws here. Keep it inside the
// guard or those lose the raster fallback.
layer.addTo(map);
} catch {
if (layer) {
@@ -131,14 +120,14 @@ const createVectorLayer = async (
return undefined;
}
// Tracked apart so a failed request rolls back to what is displayed, not to
// the opposite of what it asked - which with several in flight differs.
// Tracked apart because a failed request must roll back to what is displayed,
// not to the opposite of what it asked for - with several in flight those are
// different, and guessing wrong makes the next toggle a permanent no-op.
let appliedDarkMode = darkMode;
let requestedDarkMode = darkMode;
// Styles are fetched, so only the newest request may touch the map.
let latestRequest = 0;
let vector = true;
let refused = false;
const glMap = layer.getMaplibreMap();
let fallbackTimeout: number | undefined;
@@ -159,12 +148,13 @@ const createVectorLayer = async (
} catch {
// Nothing left to detach.
}
createRasterLayer(leaflet, map, token);
createRasterLayer(leaflet, map);
};
const scheduleSwap = () => {
clearTimeout(fallbackTimeout);
// Backgrounding drops it too, and there it comes back on return.
// Backgrounding also drops the context, and there it comes back on return.
// Running the clock then would make switching apps enough to lose vector.
if (!vector || document.hidden) {
return;
}
@@ -187,101 +177,68 @@ const createVectorLayer = async (
document.removeEventListener("visibilitychange", handleVisibilityChange);
});
const applyStyle = (newDarkMode: boolean) => {
const request = ++latestRequest;
loadStyle(VECTOR_STYLES[newDarkMode ? "dark" : "light"])
.then((style) => {
if (request === latestRequest) {
appliedDarkMode = newDarkMode;
refused = false;
layer.getMaplibreMap()?.setStyle(style);
}
})
.catch(() => {
if (request === latestRequest) {
requestedDarkMode = appliedDarkMode;
}
});
};
// A refused request leaves the source dead: the TileJSON is fetched once and
// is never retried, so the style has to be applied again once there is a new
// token. Throttled, or a proxy refusing for another reason loops.
let lastRecovery = 0;
glMap.on("error", (event) => {
if ((event.error as { status?: number } | undefined)?.status !== 403) {
return;
}
if (Date.now() - lastRecovery < RECOVERY_THROTTLE) {
return;
}
lastRecovery = Date.now();
refused = true;
refreshMapTilesToken();
});
const unsubscribeToken = subscribeMapTilesToken(() => {
if (vector && refused) {
applyStyle(requestedDarkMode);
}
});
map.on("unload", unsubscribeToken);
return {
setDarkMode: (newDarkMode: boolean) => {
if (!vector || newDarkMode === requestedDarkMode) {
return;
}
requestedDarkMode = newDarkMode;
applyStyle(newDarkMode);
const request = ++latestRequest;
loadStyle(VECTOR_STYLES[newDarkMode ? "dark" : "light"])
.then((style) => {
if (request === latestRequest) {
appliedDarkMode = newDarkMode;
layer.getMaplibreMap()?.setStyle(style);
}
})
.catch(() => {
if (request === latestRequest) {
requestedDarkMode = appliedDarkMode;
}
});
},
};
};
const createRasterLayer = (
leaflet: LeafletModuleType,
map: LeafletMap,
token: string | undefined
map: LeafletMap
): MapBaseLayer => {
const layer = leaflet
.tileLayer(RASTER_TILE_URL, {
attribution: OSM_ATTRIBUTION,
maxZoom: MAP_MAX_ZOOM,
// Leaflet throws on an undefined template variable, so no token means an
// empty one: the tiles 403 and the markers still draw.
token: token ?? "",
} as TokenTileLayerOptions)
leaflet
.tileLayer(
// These are the old retina tablets, and CARTO serves @2x - sharp tiles at
// the same request count, where `detectRetina` would fetch a zoom deeper
// at four times as many.
`${RASTER_TILE_URL}/{z}/{x}/{y}${leaflet.Browser.retina ? "@2x" : ""}.png`,
{
attribution: CARTO_ATTRIBUTION,
maxZoom: MAP_MAX_ZOOM,
}
)
.addTo(map);
// Substituted per request, so a refreshed token needs no new layer.
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();
});
map.on("unload", unsubscribe);
return { setDarkMode: () => undefined };
};
export const createBaseLayer = async (
leaflet: LeafletModuleType,
map: LeafletMap,
darkMode: boolean,
token: string | undefined
darkMode: boolean
): Promise<MapBaseLayer> => {
if (supportsWebGL2()) {
let vectorLayer: MapBaseLayer | undefined;
try {
const { maplibreGL: createLayer } =
await import("@maplibre/maplibre-gl-leaflet");
const [{ maplibreGL: createLayer }, maplibre] = await Promise.all([
import("@maplibre/maplibre-gl-leaflet"),
import("maplibre-gl"),
]);
ensureRTLTextPlugin(maplibre.setRTLTextPlugin);
vectorLayer = await createVectorLayer(
createLayer,
leaflet,
map,
darkMode,
token
darkMode
);
} catch {
// No chunk, no vector map - but still a map.
@@ -290,5 +247,5 @@ export const createBaseLayer = async (
return vectorLayer;
}
}
return createRasterLayer(leaflet, map, token);
return createRasterLayer(leaflet, map);
};
@@ -1,12 +1,12 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-icon-next";
import "../../../components/ha-svg-icon";
import "../../../components/item/ha-list-item-button";
import "../../../components/list/ha-list-nav";
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
import type { HomeAssistant } from "../../../types";
import "./ha-icon-next";
import "./ha-svg-icon";
import "./item/ha-list-item-button";
import "./list/ha-list-nav";
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
import type { HomeAssistant } from "../types";
@customElement("ha-config-navigation-list")
class HaConfigNavigationList extends LitElement {
+4
View File
@@ -95,6 +95,10 @@ export class HaDrawer extends LitElement {
}
private _handleAfterHide(ev: Event) {
// Ignore wa-after-hide from nested Web Awesome components (e.g. tooltips)
if (ev.target !== ev.currentTarget) {
return;
}
ev.stopPropagation();
this.open = false;
fireEvent(this, "hass-drawer-closed");
@@ -1,6 +1,13 @@
import type { Selector } from "../../data/selector";
import {
getSelectorInitialValue,
getSelectorInitialValueOrUndefined,
} from "./get-selector-initial-value";
import type { HaFormData, HaFormSchema } from "./types";
interface ComputeInitialHaFormDataOptions {
skipUnsupportedSelectors?: boolean;
}
const setDefaultValue = (
field: HaFormSchema,
value: HaFormData | undefined
@@ -18,7 +25,8 @@ const setDefaultValue = (
};
export const computeInitialHaFormData = (
schema: HaFormSchema[] | readonly HaFormSchema[]
schema: HaFormSchema[] | readonly HaFormSchema[],
options?: ComputeInitialHaFormDataOptions
): Record<string, any> => {
const data = {};
schema.forEach((field) => {
@@ -33,7 +41,7 @@ export const computeInitialHaFormData = (
} else if ("default" in field) {
data[field.name] = setDefaultValue(field, field.default);
} else if (field.type === "expandable") {
const expandableData = computeInitialHaFormData(field.schema);
const expandableData = computeInitialHaFormData(field.schema, options);
if (field.required || Object.keys(expandableData).length) {
// Only add expandable data if it's required or any of its children have initial values.
data[field.name] = expandableData;
@@ -62,104 +70,11 @@ export const computeInitialHaFormData = (
seconds: 0,
};
} else if ("selector" in field) {
const selector: Selector = field.selector;
if ("device" in selector) {
data[field.name] = selector.device?.multiple ? [] : "";
} else if ("entity" in selector) {
data[field.name] = selector.entity?.multiple ? [] : "";
} else if ("area" in selector) {
data[field.name] = selector.area?.multiple ? [] : "";
} else if ("label" in selector) {
data[field.name] = selector.label?.multiple ? [] : "";
} else if ("boolean" in selector) {
data[field.name] = false;
} else if (
"addon" in selector ||
"attribute" in selector ||
"file" in selector ||
"icon" in selector ||
"serial_port" in selector ||
"template" in selector ||
"text" in selector ||
"theme" in selector ||
"object" in selector
) {
data[field.name] = "";
} else if ("number" in selector) {
data[field.name] = selector.number?.min ?? 0;
} else if ("select" in selector) {
if (selector.select?.options.length) {
const firstOption = selector.select.options[0];
const val =
typeof firstOption === "string" ? firstOption : firstOption.value;
data[field.name] = selector.select.multiple ? [val] : val;
}
} else if ("country" in selector) {
if (selector.country?.countries?.length) {
data[field.name] = selector.country.countries[0];
}
} else if ("language" in selector) {
if (selector.language?.languages?.length) {
data[field.name] = selector.language.languages[0];
}
} else if ("duration" in selector) {
data[field.name] = {
hours: 0,
minutes: 0,
seconds: 0,
};
} else if ("time" in selector) {
data[field.name] = "00:00:00";
} else if ("date" in selector || "datetime" in selector) {
const now = new Date().toISOString().slice(0, 10);
data[field.name] = `${now}T00:00:00`;
} else if ("color_rgb" in selector) {
data[field.name] = [0, 0, 0];
} else if ("color_temp" in selector) {
data[field.name] = selector.color_temp?.min_mireds ?? 153;
} else if (
"action" in selector ||
"trigger" in selector ||
"condition" in selector
) {
data[field.name] = [];
} else if ("media" in selector || "target" in selector) {
data[field.name] = {};
} else if ("state" in selector) {
data[field.name] = selector.state?.multiple ? [] : "";
} else if ("choose" in selector) {
const firstChoice = Object.keys(selector.choose.choices)[0];
if (!firstChoice) {
data[field.name] = {};
} else {
data[field.name] = {
active_choice: firstChoice,
[firstChoice]: computeInitialHaFormData([
{
name: firstChoice,
selector: selector.choose.choices[firstChoice].selector,
},
])[firstChoice],
};
}
} else if ("numeric_threshold" in selector) {
const mode = selector.numeric_threshold?.mode ?? "crossed";
const type = mode === "changed" ? "any" : "above";
data[field.name] =
type === "any"
? { type }
: {
type,
value: {
number: selector.numeric_threshold?.number?.min ?? 0,
active_choice: "number",
},
};
} else {
throw new Error(
`Selector ${Object.keys(selector)[0]} not supported in initial form data`
);
const initialValue = options?.skipUnsupportedSelectors
? getSelectorInitialValueOrUndefined(field.selector)
: getSelectorInitialValue(field.selector);
if (initialValue !== undefined) {
data[field.name] = initialValue;
}
}
});
@@ -1,25 +1,89 @@
import { DEFAULT_MIN_KELVIN } from "../../common/color/convert-light-color";
import type { Selector } from "../../data/selector";
import type {
Selector,
SelectorForType,
SelectorType,
} from "../../data/selector";
type SelectorFallbackValues = {
[T in SelectorType]: ((selector: SelectorForType<T>) => unknown) | undefined;
};
const SELECTOR_FALLBACK_VALUES = {
action: undefined,
addon: undefined,
automation_behavior: undefined,
app: undefined,
area: undefined,
areas_display: undefined,
attribute: undefined,
assist_pipeline: undefined,
boolean: () => false,
choose: undefined,
color_rgb: undefined,
condition: undefined,
config_entry: undefined,
conversation_agent: undefined,
constant: (selector) => selector.constant?.value,
country: undefined,
date: undefined,
datetime: undefined,
device: undefined,
device_class: undefined,
duration: undefined,
entity: undefined,
entity_name: undefined,
statistic: undefined,
file: undefined,
floor: undefined,
label: undefined,
language: undefined,
navigation: undefined,
number: (selector) => selector.number?.min ?? 0,
numeric_threshold: undefined,
object: undefined,
period: undefined,
qr_code: undefined,
select: undefined,
selector: undefined,
serial_port: undefined,
state: undefined,
backup_location: undefined,
stt: undefined,
target: undefined,
template: undefined,
text: undefined,
time: undefined,
icon: undefined,
media: undefined,
theme: undefined,
timezone: undefined,
button_toggle: undefined,
trigger: undefined,
tts: undefined,
tts_voice: undefined,
location: undefined,
color_temp: (selector) => {
if (selector.color_temp?.unit === "kelvin") {
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
}
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
},
ui_action: undefined,
ui_clock_date_format: undefined,
ui_color: undefined,
ui_state_content: undefined,
ui_time_format: undefined,
} satisfies SelectorFallbackValues;
/**
* Value a selector already displays when no field value is set.
* Used when enabling an optional service/trigger/condition field.
*/
export const getSelectorFallbackValue = (selector: Selector): unknown => {
if ("constant" in selector) {
return selector.constant?.value;
}
if ("boolean" in selector) {
return false;
}
if ("number" in selector) {
return selector.number?.min ?? 0;
}
if ("color_temp" in selector) {
if (selector.color_temp?.unit === "kelvin") {
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
}
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
}
return undefined;
const type = Object.keys(selector)[0] as SelectorType;
const fallbackValue = SELECTOR_FALLBACK_VALUES[type];
return fallbackValue?.(selector as never);
};
@@ -0,0 +1,133 @@
import type {
Selector,
SelectorForType,
SelectorType,
} from "../../data/selector";
type SelectorInitialValues = {
[T in SelectorType]: ((selector: SelectorForType<T>) => unknown) | undefined;
};
const SELECTOR_INITIAL_VALUES = {
action: () => [],
addon: () => "",
automation_behavior: undefined,
app: undefined,
area: (selector) => (selector.area?.multiple ? [] : ""),
areas_display: undefined,
attribute: () => "",
assist_pipeline: undefined,
boolean: () => false,
choose: (selector) => {
const firstChoice = Object.keys(selector.choose.choices)[0];
if (!firstChoice) {
return {};
}
const childValue = getSelectorInitialValueOrUndefined(
selector.choose.choices[firstChoice].selector
);
return childValue === undefined
? { active_choice: firstChoice }
: { active_choice: firstChoice, [firstChoice]: childValue };
},
color_rgb: () => [0, 0, 0],
condition: () => [],
config_entry: undefined,
conversation_agent: undefined,
constant: (selector) => selector.constant?.value,
country: (selector) => selector.country?.countries?.[0],
date: () => `${new Date().toISOString().slice(0, 10)}T00:00:00`,
datetime: () => `${new Date().toISOString().slice(0, 10)}T00:00:00`,
device: (selector) => (selector.device?.multiple ? [] : ""),
device_class: (selector) =>
selector.device_class?.multiple ? [] : undefined,
duration: () => ({
hours: 0,
minutes: 0,
seconds: 0,
}),
entity: (selector) => (selector.entity?.multiple ? [] : ""),
entity_name: undefined,
statistic: undefined,
file: () => "",
floor: undefined,
label: (selector) => (selector.label?.multiple ? [] : ""),
language: (selector) => selector.language?.languages?.[0],
navigation: undefined,
number: (selector) => selector.number?.min ?? 0,
numeric_threshold: (selector) => {
const mode = selector.numeric_threshold?.mode ?? "crossed";
const type = mode === "changed" ? "any" : "above";
return type === "any"
? { type }
: {
type,
value: {
number: selector.numeric_threshold?.number?.min ?? 0,
active_choice: "number",
},
};
},
object: (selector) => (selector.object?.multiple ? [] : ""),
period: undefined,
qr_code: undefined,
select: (selector) => {
const select = selector.select;
if (!select?.options.length) {
return undefined;
}
const firstOption = select.options[0];
const value =
typeof firstOption === "string" ? firstOption : firstOption.value;
return select.multiple ? [value] : value;
},
selector: undefined,
serial_port: () => "",
state: (selector) => (selector.state?.multiple ? [] : ""),
backup_location: undefined,
stt: undefined,
target: () => ({}),
template: () => "",
text: (selector) => (selector.text?.multiple ? [] : ""),
time: () => "00:00:00",
icon: () => "",
media: () => ({}),
theme: () => "",
timezone: undefined,
button_toggle: undefined,
trigger: () => [],
tts: undefined,
tts_voice: undefined,
location: undefined,
color_temp: (selector) => selector.color_temp?.min_mireds ?? 153,
ui_action: undefined,
ui_clock_date_format: undefined,
ui_color: undefined,
ui_state_content: undefined,
ui_time_format: undefined,
} satisfies SelectorInitialValues;
export const getSelectorInitialValueOrUndefined = (
selector: Selector
): unknown => {
const type = Object.keys(selector)[0] as SelectorType;
return SELECTOR_INITIAL_VALUES[type]?.(selector as never);
};
export const getSelectorInitialValue = (selector: Selector): unknown => {
const type = Object.keys(selector)[0] as SelectorType;
const initialValue = SELECTOR_INITIAL_VALUES[type];
if (!initialValue) {
throw new Error(`Selector ${type} not supported in initial form data`);
}
return initialValue(selector as never);
};
@@ -67,15 +67,16 @@ export class HaNumberSelector extends LitElement {
}
}
// On iOS/iPadOS the numeric and decimal on-screen keypads have no minus key,
// so negatives can only be typed with the full "text" keyboard. Other
// platforms include a minus on their number keypads, so restrict this
// workaround to Safari/WebKit and only when the selector allows negatives
// (e.g. numeric_state triggers/conditions).
const useTextInputMode =
// On iOS/iPadOS the numeric and decimal on-screen keypads have no minus key.
// Leaving inputmode unset on a number input gives the "Numbers and
// Punctuation" keyboard there, which does include a minus. Other platforms
// include a minus on their number keypads, so restrict this workaround to
// Safari/WebKit and only when the selector allows negatives: either an
// explicit negative min, or no min at all (e.g. the numeric threshold
// selector used by the power triggers).
const useSafariNegativeKeyboard =
isSafari &&
this.selector.number?.min !== undefined &&
this.selector.number.min < 0;
(this.selector.number?.min === undefined || this.selector.number.min < 0);
const translationKey = this.selector.number?.translation_key;
let unit = this.selector.number?.unit_of_measurement;
@@ -112,8 +113,8 @@ export class HaNumberSelector extends LitElement {
}
<ha-input
.inputmode=${
useTextInputMode
? "text"
useSafariNegativeKeyboard
? undefined
: this.selector.number?.step === "any" ||
(this.selector.number?.step ?? 1) % 1 !== 0
? "decimal"
@@ -13,6 +13,7 @@ import type { ObjectSelector } from "../../data/selector";
import { formatSelectorValue } from "../../data/selector/format_selector_value";
import { showFormDialog } from "../../dialogs/form/show-form-dialog";
import type { HomeAssistant } from "../../types";
import { computeInitialHaFormData } from "../ha-form/compute-initial-ha-form-data";
import type { HaFormSchema } from "../ha-form/types";
import "../ha-input-helper-text";
import "../ha-md-list";
@@ -237,10 +238,14 @@ export class HaObjectSelector extends LitElement {
private async _addItem(ev) {
ev.stopPropagation();
const schema = this._schema(this.selector);
const newItem = await showFormDialog(this, {
title: this.hass.localize("ui.common.add"),
schema: this._schema(this.selector),
data: {},
schema,
data: computeInitialHaFormData(schema, {
skipUnsupportedSelectors: true,
}),
computeLabel: this._computeLabel,
computeHelper: this._computeHelper,
submitText: this.hass.localize("ui.common.add"),
@@ -293,7 +298,7 @@ export class HaObjectSelector extends LitElement {
const index = ev.currentTarget.index;
if (!this.selector.object!.multiple) {
fireEvent(this, "value-changed", { value: undefined });
fireEvent(this, "value-changed", { value: "" });
return;
}
+2 -2
View File
@@ -3,7 +3,7 @@ import { html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
import type { Selector } from "../../data/selector";
import type { Selector, SelectorType } from "../../data/selector";
import {
handleLegacyDeviceSelector,
handleLegacyEntitySelector,
@@ -70,7 +70,7 @@ const LOAD_ELEMENTS = {
ui_color: () => import("./ha-selector-ui-color"),
ui_state_content: () => import("./ha-selector-ui-state-content"),
ui_time_format: () => import("./ha-selector-ui-time-format"),
};
} satisfies Record<SelectorType, () => Promise<unknown>>;
const LEGACY_UI_SELECTORS = new Set(["ui-action", "ui-color"]);
+133 -105
View File
@@ -9,7 +9,8 @@ import {
} from "../resources/theme/color/color.globals";
import type { HomeAssistant, ThemeSettings, ValueChangedEvent } from "../types";
import "./ha-button";
import "./ha-settings-row";
import "./item/ha-list-item-base";
import "./list/ha-list-base";
import "./ha-theme-picker";
import "./input/ha-input";
import "./radio/ha-radio-group";
@@ -25,6 +26,7 @@ export interface ThemeSettingsLabels {
autoMode?: string;
lightMode?: string;
darkMode?: string;
colors?: string;
primaryColor?: string;
accentColor?: string;
reset?: string;
@@ -42,7 +44,7 @@ export class HaThemeSettings extends LitElement {
@property() public heading?: string;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ attribute: "include-default", type: Boolean })
public includeDefault = false;
@@ -62,98 +64,121 @@ export class HaThemeSettings extends LitElement {
? this.hass.themes.default_dark_theme || this.hass.themes.default_theme
: this.hass.themes.default_theme;
const showDarkMode =
curTheme === HOME_ASSISTANT_THEME ||
(curThemeIsUseDefault &&
this.hass.themes.default_dark_theme &&
this.hass.themes.default_theme) ||
this._supportsModeSelection(curTheme);
return html`
<ha-settings-row .narrow=${this.narrow} ?empty=${!this.showThemePicker}>
${
this.heading
? html`<span slot="heading">${this.heading}</span>`
: nothing
}
${
this.description
? html`<span slot="description">${this.description}</span>`
: nothing
}
<ha-list-base>
${
this.showThemePicker
? html`
<ha-theme-picker
.label=${this.labels?.theme}
.noThemeLabel=${this.labels?.noTheme}
.value=${themeSettings?.theme || undefined}
.disabled=${this.themePickerDisabled}
?include-default=${this.includeDefault}
@value-changed=${this._handleThemeSelection}
></ha-theme-picker>
<ha-list-item-base>
${
this.heading
? html`<span slot="headline">${this.heading}</span>`
: nothing
}
${
this.description
? html`<span slot="supporting-text"
>${this.description}</span
>`
: nothing
}
<ha-theme-picker
slot="end"
.label=${this.labels?.theme}
.noThemeLabel=${this.labels?.noTheme}
.value=${themeSettings?.theme || undefined}
.disabled=${this.themePickerDisabled}
?include-default=${this.includeDefault}
@value-changed=${this._handleThemeSelection}
></ha-theme-picker>
</ha-list-item-base>
`
: nothing
}
</ha-settings-row>
${
curTheme === HOME_ASSISTANT_THEME ||
(curThemeIsUseDefault &&
this.hass.themes.default_dark_theme &&
this.hass.themes.default_theme) ||
this._supportsModeSelection(curTheme)
? html`<div class="inputs">
<ha-radio-group
@change=${this._handleDarkMode}
name="dark_mode"
.ariaLabel=${this.labels?.mode ?? "Theme mode"}
.value=${
themeSettings?.dark === undefined
? "auto"
: themeSettings.dark
? "dark"
: "light"
${
showDarkMode
? html`
<ha-list-item-base>
<span slot="headline"
>${this.labels?.mode ?? "Theme mode"}</span
>
<ha-radio-group
slot="end"
@change=${this._handleDarkMode}
name="dark_mode"
.value=${
themeSettings?.dark === undefined
? "auto"
: themeSettings.dark
? "dark"
: "light"
}
orientation="horizontal"
>
<ha-radio-option value="auto">
${this.labels?.autoMode ?? "Auto"}
</ha-radio-option>
<ha-radio-option value="light">
${this.labels?.lightMode ?? "Light"}
</ha-radio-option>
<ha-radio-option value="dark">
${this.labels?.darkMode ?? "Dark"}
</ha-radio-option>
</ha-radio-group>
</ha-list-item-base>
`
: nothing
}
${
curTheme === HOME_ASSISTANT_THEME
? html`
<ha-list-item-base>
<span slot="headline"
>${this.labels?.colors ?? "Custom colors"}</span
>
<div slot="end" class="color-pickers">
<ha-input
.value=${themeSettings?.primaryColor || DefaultPrimaryColor}
type="color"
.label=${this.labels?.primaryColor ?? "Primary color"}
.name=${"primaryColor"}
@change=${this._handleColorChange}
></ha-input>
<ha-input
.value=${themeSettings?.accentColor || DefaultAccentColor}
type="color"
.label=${this.labels?.accentColor ?? "Accent color"}
.name=${"accentColor"}
@change=${this._handleColorChange}
></ha-input>
</div>
</ha-list-item-base>
${
themeSettings?.primaryColor || themeSettings?.accentColor
? html`
<div class="reset-row">
<ha-button
appearance="plain"
size="s"
@click=${this._resetColors}
>
${this.labels?.reset ?? "Reset"}
</ha-button>
</div>
`
: nothing
}
orientation="horizontal"
>
<ha-radio-option value="auto">
${this.labels?.autoMode ?? "Auto"}
</ha-radio-option>
<ha-radio-option value="light">
${this.labels?.lightMode ?? "Light"}
</ha-radio-option>
<ha-radio-option value="dark">
${this.labels?.darkMode ?? "Dark"}
</ha-radio-option>
</ha-radio-group>
${
curTheme === HOME_ASSISTANT_THEME
? html`<div class="color-pickers">
<ha-input
.value=${themeSettings?.primaryColor || DefaultPrimaryColor}
type="color"
.label=${this.labels?.primaryColor ?? "Primary color"}
.name=${"primaryColor"}
@change=${this._handleColorChange}
></ha-input>
<ha-input
.value=${themeSettings?.accentColor || DefaultAccentColor}
type="color"
.label=${this.labels?.accentColor ?? "Accent color"}
.name=${"accentColor"}
@change=${this._handleColorChange}
></ha-input>
${
themeSettings?.primaryColor ||
themeSettings?.accentColor
? html` <ha-button
appearance="plain"
size="s"
@click=${this._resetColors}
>
${this.labels?.reset ?? "Reset"}
</ha-button>`
: nothing
}
</div>`
: nothing
}
</div>`
: nothing
}
`
: nothing
}
</ha-list-base>
`;
}
@@ -231,33 +256,36 @@ export class HaThemeSettings extends LitElement {
a {
color: var(--primary-color);
}
.inputs {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
margin: 0 var(--ha-space-3);
ha-list-base {
--ha-row-item-padding-block: var(--ha-space-2);
}
ha-radio-group {
display: flex;
justify-content: center;
margin-inline-end: var(--ha-space-3);
ha-theme-picker {
min-width: 150px;
}
.color-pickers {
display: flex;
justify-content: flex-end;
align-items: center;
flex-grow: 1;
gap: var(--ha-space-2);
}
ha-input {
min-width: 75px;
flex-grow: 1;
margin: 0 var(--ha-space-1);
.color-pickers ha-input {
min-width: 150px;
flex: 1;
}
ha-theme-picker {
display: block;
:host([narrow]) ha-list-item-base::part(base) {
flex-direction: column;
align-items: flex-start;
}
:host([narrow]) ha-list-item-base::part(end) {
width: 100%;
}
:host([narrow]) .color-pickers {
width: 100%;
}
.reset-row {
display: flex;
justify-content: flex-end;
padding-inline-end: var(--ha-space-4);
padding-bottom: var(--ha-space-4);
}
`;
}
-9
View File
@@ -29,7 +29,6 @@ import type { MapBaseLayer } from "../../common/map/base-layer";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { getEntityLocation } from "../../common/entity/get_entity_location";
import { ensureMapTilesToken } from "../../data/map_tiles";
import { DecoratedMarker } from "../../common/map/decorated_marker";
import { filterXSS } from "../../common/util/xss";
import {
@@ -328,19 +327,11 @@ export class HaMap extends ReactiveElement {
}
this._loading = true;
try {
// The tiles are proxied by core behind a token, so nothing loads without
// one. A host that provides no connection, or a backend without the
// proxy, leaves the map without tiles rather than failing to set up.
const token = this._connection
? await ensureMapTilesToken(this._connection.connection)
: undefined;
const setup = await setupLeafletMap(map, {
latitude: this._config?.latitude ?? 52.3731339,
longitude: this._config?.longitude ?? 4.8903147,
zoom: this.zoom,
darkMode: this._darkMode,
token,
});
// Setting up fetches a style, so the element can be gone by now.
// `disconnectedCallback` had no map to tear down, and keeping this one
+15 -5
View File
@@ -1553,7 +1553,8 @@ export const formatConsumptionShort = (
hass: HomeAssistant,
consumption: number | null,
unit: string,
targetUnit?: string
targetUnit?: string,
displayPrecision?: number
): string => {
const units = ["Wh", "kWh", "MWh", "GWh", "TWh"];
let pickedUnit = unit;
@@ -1583,10 +1584,19 @@ export const formatConsumptionShort = (
pickedUnit = units[unitIndex];
}
return (
formatNumber(val, hass.locale, {
maximumFractionDigits:
Math.abs(val) < 10 ? 2 : Math.abs(val) < 100 ? 1 : 0,
}) +
formatNumber(
val,
hass.locale,
displayPrecision !== undefined && pickedUnit === unit
? {
minimumFractionDigits: displayPrecision,
maximumFractionDigits: displayPrecision,
}
: {
maximumFractionDigits:
Math.abs(val) < 10 ? 2 : Math.abs(val) < 100 ? 1 : 0,
}
) +
" " +
pickedUnit
);
-3
View File
@@ -50,9 +50,6 @@ export const DOMAIN_ATTRIBUTES_UNITS = {
azimuth: "°",
elevation: "°",
},
vacuum: {
battery_level: "%",
},
valve: {
current_position: "%",
},
-136
View File
@@ -1,136 +0,0 @@
import type { Connection } from "home-assistant-js-websocket";
import { waitForMs } from "../common/util/wait";
export const MAP_TILES_PATH = "/api/map_tiles";
// Well inside the token's lifetime, as util/brands-url.ts does.
const TOKEN_REFRESH_MS = 30 * 60 * 1000;
// Nothing loads without a token, so the first attempts are awaited - briefly,
// or a backend without the proxy would hold the map hostage. The rest retry in
// the background, for the window after a restart where the WebSocket is up but
// the handler is not registered yet.
const BLOCKING_DELAYS_MS = [0, 400, 1000];
const BACKGROUND_DELAYS_MS = [2000, 5000, 10000, 15000];
let token: string | undefined;
let refreshInterval: ReturnType<typeof setInterval> | undefined;
let watchingConnection = false;
let activeConnection: Connection | undefined;
let refreshing: Promise<void> | undefined;
const listeners = new Set<(token: string) => void>();
const fetchToken = async (connection: Connection): Promise<void> => {
const result = await connection.sendMessagePromise<{ token: string }>({
type: "map_tiles/access_token",
});
if (result.token !== token) {
token = result.token;
listeners.forEach((listener) => listener(token!));
}
};
const attempt = async (connection: Connection, delays: number[]) => {
/* eslint-disable no-await-in-loop -- retries are intentionally sequential */
for (const delay of delays) {
if (token) {
return;
}
if (delay) {
await waitForMs(delay);
}
try {
await fetchToken(connection);
return;
} catch {
// try next delay
}
}
/* eslint-enable no-await-in-loop */
};
export const ensureMapTilesToken = async (
connection: Connection
): Promise<string | undefined> => {
activeConnection = connection;
if (!token) {
await attempt(connection, BLOCKING_DELAYS_MS);
}
if (!token) {
attempt(connection, BACKGROUND_DELAYS_MS).then(() =>
scheduleRefresh(connection)
);
return undefined;
}
scheduleRefresh(connection);
return token;
};
const scheduleRefresh = (connection: Connection) => {
if (token && !refreshInterval) {
refreshInterval = setInterval(() => {
fetchToken(connection).catch(() => {
// Keep the current token; the next interval retries.
});
}, TOKEN_REFRESH_MS);
}
if (!watchingConnection) {
watchingConnection = true;
// The interval does not fire while the process is suspended, so the token
// can be stale before it comes round; reconnecting is the reliable signal.
connection.addEventListener("ready", () => {
fetchToken(connection).catch(() => {
// Nothing to do; the interval keeps trying.
});
});
}
};
/**
* Forces a new token, for when the current one is refused. Deduplicated: a
* refused map produces one of these per tile.
*/
export const refreshMapTilesToken = (): Promise<void> => {
if (!activeConnection) {
return Promise.resolve();
}
refreshing ??= fetchToken(activeConnection)
.catch(() => {
// Leave the old token in place; a reconnect or the interval retries.
})
.finally(() => {
refreshing = undefined;
});
return refreshing;
};
/** Leaflet bakes its URL template at layer creation, so it needs telling. */
export const subscribeMapTilesToken = (
listener: (token: string) => void
): (() => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
/**
* MapLibre hands tile URLs to a worker, which has no document to resolve a
* relative URL against, so the result has to be absolute.
*/
export const withMapTilesToken = (url: string): string => {
let parsed: URL;
try {
parsed = new URL(url, location.href);
} catch {
return url;
}
if (token && parsed.pathname.startsWith(`${MAP_TILES_PATH}/`)) {
parsed.searchParams.set("token", token);
}
return parsed.href;
};
+14
View File
@@ -88,6 +88,20 @@ export type Selector =
| UiTimeFormatSelector
| BackupLocationSelector;
type KeysOfUnion<T> = T extends T ? keyof T : never;
export type SelectorType = KeysOfUnion<Selector>;
type UnionMemberWithKey<U, K extends PropertyKey> = U extends unknown
? K extends keyof U
? U
: never
: never;
export type SelectorForType<T extends SelectorType> = UnionMemberWithKey<
Selector,
T
>;
export interface ActionSelector {
action: {
optionsInSidebar?: boolean;
@@ -116,10 +116,7 @@ export class HaMoreInfoViewVacuumSegmentMapping extends LitElement {
></ha-vacuum-segment-area-mapper>
<div class="footer">
<ha-button
@click=${this._save}
.disabled=${!this._dirtyState?.isDirty || this._submitting}
>
<ha-button @click=${this._save} .disabled=${this._submitting}>
${this.hass.localize("ui.common.save")}
</ha-button>
</div>
+1 -1
View File
@@ -235,7 +235,7 @@ class DialogShortcuts extends DialogMixin(LitElement) {
<ha-alert slot="footer">
${this._i18n.localize("ui.dialogs.shortcuts.enable_shortcuts_hint", {
user_profile: html`<a href="/profile/general#shortcuts"
user_profile: html`<a href="/profile/browser#shortcuts"
>${this._i18n.localize(
"ui.dialogs.shortcuts.enable_shortcuts_hint_user_profile"
)}</a
-27
View File
@@ -117,33 +117,6 @@ 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.
registerRoute(
({ url }) =>
/^\/api\/map_tiles\/(vector|raster|fonts|sprites)\//.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] }),
new ExpirationPlugin({
maxEntries: 500,
maxAgeSeconds: 60 * 60 * 24 * 7,
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.
+81 -16
View File
@@ -16,6 +16,7 @@ import {
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoize from "memoize-one";
import { firstWeekdayIndex } from "../../common/datetime/first_weekday";
import { resolveTimeZone } from "../../common/datetime/resolve-time-zone";
@@ -77,6 +78,10 @@ export class HAFullCalendar extends LitElement {
@property({ attribute: "add-fab", type: Boolean }) public addFab = false;
@property({ attribute: "add-fab-size" }) public addFabSize = "large";
@property({ attribute: "add-fab-style" }) public addFabStyle = "on_top";
@property({ attribute: false }) public events: CalendarEvent[] = [];
@property({ attribute: false }) public calendars: CalendarData[] = [];
@@ -172,13 +177,34 @@ export class HAFullCalendar extends LitElement {
</ha-icon-button-next>
</div>
<h1>${this.calendar.view.title}</h1>
<ha-button-toggle-group
.buttons=${viewToggleButtons}
.active=${this._activeView}
size="s"
no-wrap
@value-changed=${this._handleView}
></ha-button-toggle-group>
<div>
<ha-button-toggle-group
.buttons=${viewToggleButtons}
.active=${this._activeView}
size="s"
no-wrap
@value-changed=${this._handleView}
></ha-button-toggle-group>
${
this.addFab &&
this._hasMutableCalendars &&
this.addFabStyle === "header"
? html`<ha-button
size="s"
class="fab-header"
aria-label=${this.hass.localize(
"ui.components.calendar.event.add"
)}
@click=${this._createEvent}
>
<ha-svg-icon
slot=""
.path=${mdiPlus}
></ha-svg-icon>
</ha-button>`
: nothing
}
</div>
`
: html`
<div class="controls">
@@ -208,13 +234,34 @@ export class HAFullCalendar extends LitElement {
"ui.components.calendar.today"
)}</ha-button
>
<ha-button-toggle-group
.buttons=${viewToggleButtons}
.active=${this._activeView}
size="s"
no-wrap
@value-changed=${this._handleView}
></ha-button-toggle-group>
<div>
<ha-button-toggle-group
.buttons=${viewToggleButtons}
.active=${this._activeView}
size="s"
no-wrap
@value-changed=${this._handleView}
></ha-button-toggle-group>
${
this.addFab &&
this._hasMutableCalendars &&
this.addFabStyle === "header"
? html`<ha-button
size="s"
class="fab-header"
aria-label=${this.hass.localize(
"ui.components.calendar.event.add"
)}
@click=${this._createEvent}
>
<ha-svg-icon
slot=""
.path=${mdiPlus}
></ha-svg-icon>
</ha-button>`
: nothing
}
</div>
</div>
`
}
@@ -225,8 +272,15 @@ export class HAFullCalendar extends LitElement {
<div id="calendar"></div>
${
this.addFab && this._hasMutableCalendars
? html`<ha-button size="l" slot="fab" @click=${this._createEvent}>
this.addFab &&
this._hasMutableCalendars &&
this.addFabStyle !== "header"
? html`<ha-button
size=${this.addFabSize.charAt(0)}
class=${classMap({ below: this.addFabStyle === "below" })}
slot="fab"
@click=${this._createEvent}
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.components.calendar.event.add")}
</ha-button>`
@@ -574,6 +628,17 @@ export class HAFullCalendar extends LitElement {
--ha-button-box-shadow: var(--ha-box-shadow-l);
}
ha-button.below[slot="fab"] {
position: relative;
margin-inline-start: auto;
padding-top: var(--ha-space-2);
left: 0;
right: 0;
bottom: 0;
top: 0;
--ha-button-box-shadow: none;
}
#calendar {
flex-grow: 1;
background-color: var(
@@ -28,7 +28,7 @@ import { showRestartDialog } from "../../../dialogs/restart/show-dialog-restart"
import "../../../layouts/hass-subpage";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import "../components/ha-config-navigation-list";
import "../../../components/ha-config-navigation-list";
import "../ha-config-section";
import { configSections } from "../config-sections";
@@ -14,7 +14,7 @@ import {
type RegisterChildPanelReady,
} from "../../../layouts/panel-ready";
import type { HomeAssistant } from "../../../types";
import "../components/ha-config-navigation-list";
import "../../../components/ha-config-navigation-list";
@customElement("ha-config-navigation")
class HaConfigNavigation extends LitElement {
@@ -201,7 +201,7 @@ export class DialogVacuumSegmentMapping
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${this._submitting || !this.isDirtyState}
.disabled=${this._submitting}
>
${this.hass.localize("ui.common.save")}
</ha-button>
@@ -12,6 +12,15 @@ function getLQIWidth(lqi: number): number {
return lqi > 200 ? 3 : lqi > 100 ? 2 : 1;
}
const RELATIONSHIP_PRIORITY: Record<string, number> = {
Parent: 0,
Sibling: 1,
NoneOfTheAbove: 2,
Child: 3,
PreviousChild: 5,
};
const UNKNOWN_RELATIONSHIP_PRIORITY = 4;
export function createZHANetworkChartData(
devices: ZHADevice[],
hass: HomeAssistant,
@@ -159,40 +168,80 @@ export function createZHANetworkChartData(
existingLinks.push(link);
}
});
} else if (existingLinks.length === 0) {
// If there are no links, create a link to the closest neighbor
const neighbors: { ieee: string; lqi: string }[] = device.neighbors ?? [];
if (neighbors.length === 0) {
// If there are no neighbors, look for links from other devices
devices.forEach((d) => {
if (d.neighbors && d.neighbors.length > 0) {
const neighbor = d.neighbors.find((n) => n.ieee === device.ieee);
if (neighbor) {
neighbors.push({ ieee: d.ieee, lqi: neighbor.lqi });
}
}
});
}
const closestNeighbor = neighbors.sort(
(a, b) => parseInt(b.lqi) - parseInt(a.lqi)
)[0];
if (closestNeighbor) {
links.push({
source: device.ieee,
target: closestNeighbor.ieee,
value: parseInt(closestNeighbor.lqi),
symbolSize: 5,
lineStyle: {
width: 1,
color: style.getPropertyValue("--dark-primary-color"),
type: "dotted",
},
ignoreForceLayout: true,
});
}
}
});
// For every device whose routing table was empty (so it got no link
// above), independently compute its preferred fallback neighbor before
// creating any of these links. Doing this device-by-device while
// creating links (as before) meant whichever device the backend happens
// to list first "claims" the connection, and a sibling router listed
// later never gets to contribute its own — often better — choice,
// splitting the graph into islands depending on backend device order.
const fallbackTargets = new Map<string, { ieee: string; lqi: string }>();
devices.forEach((device) => {
const hasLink = links.some(
(link) => link.source === device.ieee || link.target === device.ieee
);
if (hasLink) {
return;
}
const neighbors: { ieee: string; lqi: string; relationship?: string }[] =
device.neighbors ?? [];
if (neighbors.length === 0) {
// If there are no neighbors, look for links from other devices
devices.forEach((d) => {
if (d.neighbors && d.neighbors.length > 0) {
const neighbor = d.neighbors.find((n) => n.ieee === device.ieee);
if (neighbor) {
neighbors.push({ ieee: d.ieee, lqi: neighbor.lqi });
}
}
});
}
// Prefer a neighbor the device's own Zigbee stack reports as its
// parent: picking by raw LQI alone regularly favors a nearby child end
// device (e.g. a plug sitting right next to its router) over the
// router's actual uplink, which severs it from the rest of the mesh in
// the visualization even though the real network is connected.
const closestNeighbor = neighbors.sort((a, b) => {
const aPriority =
RELATIONSHIP_PRIORITY[a.relationship ?? ""] ??
UNKNOWN_RELATIONSHIP_PRIORITY;
const bPriority =
RELATIONSHIP_PRIORITY[b.relationship ?? ""] ??
UNKNOWN_RELATIONSHIP_PRIORITY;
const priorityDiff = aPriority - bPriority;
return priorityDiff !== 0
? priorityDiff
: parseInt(b.lqi) - parseInt(a.lqi);
})[0];
if (closestNeighbor) {
fallbackTargets.set(device.ieee, closestNeighbor);
}
});
const addedFallbackPairs = new Set<string>();
fallbackTargets.forEach((target, ieee) => {
const pairKey = [ieee, target.ieee].sort().join("|");
if (addedFallbackPairs.has(pairKey)) {
return;
}
addedFallbackPairs.add(pairKey);
links.push({
source: ieee,
target: target.ieee,
value: parseInt(target.lqi),
symbolSize: 5,
lineStyle: {
width: 1,
color: style.getPropertyValue("--dark-primary-color"),
type: "dotted",
},
ignoreForceLayout: true,
});
});
// Now set ignoreForceLayout to false for the best connection of each device
// Except for the coordinator which can have multiple strong connections
devices.forEach((device) => {
@@ -112,7 +112,11 @@ class HuiEnergyDistrubutionCard
) {
return true;
}
const oldStates = changedProps.get("hass").states;
const oldHass = changedProps.get("hass");
if (!oldHass) {
return true;
}
const oldStates = oldHass.states;
if (
this._data?.co2SignalEntity &&
this.hass.states[this._data.co2SignalEntity] !==
@@ -120,6 +124,20 @@ class HuiEnergyDistrubutionCard
) {
return true;
}
if (
this._data &&
energySourcesByType(this._data.prefs).gas?.some((source) => {
const statId = source.stat_energy_from;
return (
this.hass.entities[statId]?.display_precision !==
oldHass.entities[statId]?.display_precision ||
this.hass.states[statId]?.attributes.unit_of_measurement !==
oldHass.states[statId]?.attributes.unit_of_measurement
);
})
) {
return true;
}
if (this._data && periodIncludesNow(this._data)) {
const batteries = energySourcesByType(this._data.prefs).battery;
if (
@@ -162,6 +180,21 @@ class HuiEnergyDistrubutionCard
const hasBattery = types.battery !== undefined;
const hasGas = types.gas !== undefined;
const hasWater = types.water !== undefined;
const gasUnit = this._data.gasUnit;
const gasDisplayPrecisions = types.gas
?.filter(
(source) =>
this.hass.states[source.stat_energy_from]?.attributes
.unit_of_measurement === gasUnit
)
.map(
(source) =>
this.hass.entities[source.stat_energy_from]?.display_precision
)
.filter((precision): precision is number => precision !== undefined);
const gasDisplayPrecision = gasDisplayPrecisions?.length
? Math.max(...gasDisplayPrecisions)
: undefined;
const hasReturnToGrid =
types.grid?.some((source) => source.stat_energy_to) ?? false;
@@ -437,7 +470,9 @@ class HuiEnergyDistrubutionCard
${formatConsumptionShort(
this.hass,
gasUsage,
this._data.gasUnit
this._data.gasUnit,
undefined,
gasDisplayPrecision
)}
</div>
<svg width="80" height="30">
@@ -11,6 +11,7 @@ import "../../../../components/chart/ha-chart-base";
import "../../../../components/ha-card";
import type { EnergyData } from "../../../../data/energy";
import {
energySourcesByType,
getEnergyDataCollection,
validateEnergyCollectionKey,
} from "../../../../data/energy";
@@ -66,6 +67,8 @@ export class HuiEnergyGasGraphCard
@state() private _total?: number;
private _energyData?: EnergyData;
protected hassSubscribeRequiredHostProps = ["_config"];
public hassSubscribe(): UnsubscribeFunc[] {
@@ -88,11 +91,68 @@ export class HuiEnergyGasGraphCard
}
protected shouldUpdate(changedProps: PropertyValues<this>): boolean {
return (
if (
hasConfigChanged(this, changedProps) ||
changedProps.size > 1 ||
!changedProps.has("hass")
);
) {
return true;
}
const oldHass = changedProps.get("hass");
if (!oldHass) {
return true;
}
if (
this._energyData &&
energySourcesByType(this._energyData.prefs).gas?.some((source) => {
const statId = source.stat_energy_from;
return (
this.hass.entities[statId]?.display_precision !==
oldHass.entities[statId]?.display_precision ||
this.hass.states[statId]?.attributes.unit_of_measurement !==
oldHass.states[statId]?.attributes.unit_of_measurement
);
})
) {
return true;
}
return false;
}
private get _displayPrecision(): number | undefined {
if (!this._energyData) {
return undefined;
}
const gasDisplayPrecisions = energySourcesByType(this._energyData.prefs)
.gas?.filter(
(source) =>
this.hass.states[source.stat_energy_from]?.attributes
.unit_of_measurement === this._unit
)
.map(
(source) =>
this.hass.entities[source.stat_energy_from]?.display_precision
)
.filter((precision): precision is number => precision !== undefined);
return gasDisplayPrecisions?.length
? Math.max(...gasDisplayPrecisions)
: undefined;
}
private get _gasFormatOptions(): Intl.NumberFormatOptions | undefined {
const displayPrecision = this._displayPrecision;
return displayPrecision !== undefined
? {
minimumFractionDigits: displayPrecision,
maximumFractionDigits: displayPrecision,
}
: undefined;
}
protected render() {
@@ -111,7 +171,11 @@ export class HuiEnergyGasGraphCard
? html`<hui-energy-graph-chip
.tooltip=${this._formatTotal(this._total)}
>
${formatNumber(this._total, this.hass.locale)}
${formatNumber(
this._total,
this.hass.locale,
this._gasFormatOptions
)}
${this._unit}
</hui-energy-graph-chip>`
: nothing
@@ -135,7 +199,7 @@ export class HuiEnergyGasGraphCard
this._unit,
this._compareStart,
this._compareEnd,
this._yAxisFractionDigits
this._displayPrecision ?? this._yAxisFractionDigits
)}
chart-type="bar"
></ha-chart-base>
@@ -162,7 +226,10 @@ export class HuiEnergyGasGraphCard
private _formatTotal = (total: number) =>
this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_gas_graph.total_consumed",
{ num: formatNumber(total, this.hass.locale), unit: this._unit }
{
num: formatNumber(total, this.hass.locale, this._gasFormatOptions),
unit: this._unit,
}
);
private _createOptions = memoizeOne(
@@ -191,6 +258,8 @@ export class HuiEnergyGasGraphCard
);
private async _getStatistics(energyData: EnergyData): Promise<void> {
this._energyData = energyData;
const result = generateEnergyGasGraphData({
hass: this.hass,
energyData,
@@ -87,11 +87,35 @@ export class HuiEnergySourcesTableCard
}
protected shouldUpdate(changedProps: PropertyValues<this>): boolean {
return (
if (
hasConfigChanged(this, changedProps) ||
changedProps.size > 1 ||
!changedProps.has("hass")
);
) {
return true;
}
const oldHass = changedProps.get("hass");
if (!oldHass) {
return true;
}
if (
this._data &&
energySourcesByType(this._data.prefs).gas?.some((source) => {
const statId = source.stat_energy_from;
return (
this.hass.entities[statId]?.display_precision !==
oldHass.entities[statId]?.display_precision ||
this.hass.states[statId]?.attributes.unit_of_measurement !==
oldHass.states[statId]?.attributes.unit_of_measurement
);
})
) {
return true;
}
return false;
}
protected _renderRow(
@@ -108,6 +132,20 @@ export class HuiEnergySourcesTableCard
compare: boolean,
name?: string
) {
const displayPrecision =
type === "gas" &&
this.hass.states[statId]?.attributes.unit_of_measurement === energyUnit
? this.hass.entities[statId]?.display_precision
: undefined;
const formatOptions =
displayPrecision !== undefined
? {
minimumFractionDigits: displayPrecision,
maximumFractionDigits: displayPrecision,
}
: undefined;
return html`<tr
class="mdc-data-table__row ${classMap({
clickable: !isExternalStatistic(statId),
@@ -151,7 +189,8 @@ export class HuiEnergySourcesTableCard
${
compare
? html`<td class="mdc-data-table__cell mdc-data-table__cell--numeric">
${formatNumber(compareEnergy, this.hass.locale)} ${energyUnit}
${formatNumber(compareEnergy, this.hass.locale, formatOptions)}
${energyUnit}
</td>
${
showCosts
@@ -172,7 +211,7 @@ export class HuiEnergySourcesTableCard
: ""
}
<td class="mdc-data-table__cell mdc-data-table__cell--numeric">
${formatNumber(energy, this.hass.locale)} ${energyUnit}
${formatNumber(energy, this.hass.locale, formatOptions)} ${energyUnit}
</td>
${
showCosts
@@ -203,7 +242,8 @@ export class HuiEnergySourcesTableCard
showCosts: boolean,
compare: boolean,
bulletColor?: { border: string; background: string },
isFinalTotal?: boolean
isFinalTotal?: boolean,
formatOptions?: Intl.NumberFormatOptions
) {
return html` <tr
class="mdc-data-table__row ${bulletColor && !isFinalTotal ? "" : "total"}"
@@ -228,7 +268,11 @@ export class HuiEnergySourcesTableCard
${
compareEnergy === null
? ""
: `${formatNumber(compareEnergy, this.hass.locale)} ${energyUnit}`
: `${formatNumber(
compareEnergy,
this.hass.locale,
formatOptions
)} ${energyUnit}`
}
</td>
${
@@ -253,7 +297,11 @@ export class HuiEnergySourcesTableCard
${
energy === null
? ""
: `${formatNumber(energy, this.hass.locale)} ${energyUnit}`
: `${formatNumber(
energy,
this.hass.locale,
formatOptions
)} ${energyUnit}`
}
</td>
${
@@ -358,6 +406,30 @@ export class HuiEnergySourcesTableCard
water: this._data.waterUnit,
};
const gasDisplayPrecisions = types.gas
?.filter(
(source) =>
this.hass.states[source.stat_energy_from]?.attributes
.unit_of_measurement === units.gas
)
.map(
(source) =>
this.hass.entities[source.stat_energy_from]?.display_precision
)
.filter((precision): precision is number => precision !== undefined);
const gasDisplayPrecision = gasDisplayPrecisions?.length
? Math.max(...gasDisplayPrecisions)
: undefined;
const gasFormatOptions =
gasDisplayPrecision !== undefined
? {
minimumFractionDigits: gasDisplayPrecision,
maximumFractionDigits: gasDisplayPrecision,
}
: undefined;
const compare = this._data.statsCompare !== undefined;
const _extractStatData = (
@@ -481,7 +553,9 @@ export class HuiEnergySourcesTableCard
0
),
}
: undefined
: undefined,
false,
type === "gas" ? gasFormatOptions : undefined
)
: ""
}`;
+12 -13
View File
@@ -222,6 +222,9 @@ export class HuiCalendarCard
"has-title": !!this._config.title,
loading: loading,
})}
?add-fab=${this._config.show_add_event}
add-fab-style=${this._config.show_add_event ? (this._config.add_event_style ?? "below") : nothing}
add-fab-size=${this._config.show_add_event && this._config.add_event_style !== "header" ? (this._config.add_event_size ?? "small") : nothing}
.narrow=${this._narrow}
.events=${this._events}
.calendars=${this._calendars}
@@ -370,11 +373,7 @@ export class HuiCalendarCard
}
private _measureCard() {
const card = this.shadowRoot!.querySelector("ha-card");
if (!card) {
return;
}
this._narrow = card.offsetWidth < 870;
this._narrow = this.offsetWidth < 870;
}
private async _attachObserver(): Promise<void> {
@@ -383,12 +382,7 @@ export class HuiCalendarCard
debounce(() => this._measureCard(), 250, false)
);
}
const card = this.shadowRoot!.querySelector("ha-card");
// If we show an error or warning there is no ha-card
if (!card) {
return;
}
this._resizeObserver.observe(card);
this._resizeObserver.observe(this);
}
static styles = css`
@@ -414,9 +408,9 @@ export class HuiCalendarCard
ha-full-calendar {
--calendar-height: 400px;
display: block;
display: flex;
width: 100%;
height: var(--calendar-height);
height: 100%;
min-height: var(--calendar-height);
}
@@ -431,9 +425,14 @@ export class HuiCalendarCard
ha-full-calendar.is-grid.has-title,
ha-full-calendar.is-panel.has-title {
--header-height: calc(
var(--ha-card-header-font-size, var(--ha-font-size-2xl)) *
var(--ha-line-height-condensed) + 16px
);
--calendar-height: calc(
100% - var(--ha-card-header-font-size, var(--ha-font-size-2xl)) - 22px
);
height: calc(100% - var(--header-height));
}
.loading {
+3
View File
@@ -52,6 +52,9 @@ export interface CalendarCardConfig extends LovelaceCardConfig {
initial_view?: FullCalendarView;
title?: string;
theme?: string;
show_add_event?: boolean;
add_event_style?: "below" | "on_top" | "header";
add_event_size?: "small" | "medium" | "large";
}
export interface ConditionalCardConfig extends LovelaceCardConfig {
@@ -10,12 +10,16 @@ import {
optional,
string,
union,
literal,
} from "superstruct";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/entity/ha-entities-picker";
import "../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types";
import type { CalendarCardConfig } from "../../cards/types";
import type { LovelaceCardEditor } from "../../types";
@@ -27,6 +31,13 @@ const cardConfigStruct = assign(
title: optional(union([string(), boolean()])),
initial_view: optional(string()),
theme: optional(string()),
show_add_event: optional(boolean()),
add_event_style: optional(
union([literal("below"), literal("on_top"), literal("header")])
),
add_event_size: optional(
union([literal("small"), literal("medium"), literal("large")])
),
entities: array(string()),
})
);
@@ -72,7 +83,83 @@ export class HuiCalendarCardEditor
],
},
{ name: "theme", required: false, selector: { theme: {} } },
] as const
{ name: "show_add_event", required: false, selector: { boolean: {} } },
{
name: "",
type: "grid",
schema: [
{
name: "add_event_style",
default: "below",
visible: { field: "show_add_event", operator: "eq", value: true },
required: false,
selector: {
select: {
options: [
{
value: "below",
label: localize(
"ui.panel.lovelace.editor.card.calendar.add_event.style.below"
),
},
{
value: "on_top",
label: localize(
"ui.panel.lovelace.editor.card.calendar.add_event.style.on_top"
),
},
{
value: "header",
label: localize(
"ui.panel.lovelace.editor.card.calendar.add_event.style.header"
),
},
],
mode: "dropdown",
},
},
},
{
name: "add_event_size",
default: "small",
visible: [
{ field: "show_add_event", operator: "eq", value: true },
{
field: "add_event_style",
operator: "not_eq",
value: "header",
},
],
required: false,
selector: {
select: {
options: [
{
value: "small",
label: localize(
"ui.panel.lovelace.editor.card.calendar.add_event.size.s"
),
},
{
value: "medium",
label: localize(
"ui.panel.lovelace.editor.card.calendar.add_event.size.m"
),
},
{
value: "large",
label: localize(
"ui.panel.lovelace.editor.card.calendar.add_event.size.l"
),
},
],
mode: "dropdown",
},
},
},
],
},
] as const satisfies readonly HaFormSchema[]
);
protected render() {
@@ -0,0 +1,32 @@
import { computeDomain } from "../../../../common/entity/compute_domain";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type {
PictureEntityCardConfig,
TileCardConfig,
} from "../../cards/types";
export const computeFavoriteCardConfig = (
entityId: string
): LovelaceCardConfig => {
// A camera tile would only show a thumbnail, so give the picture the room.
if (computeDomain(entityId) === "camera") {
return {
type: "picture-entity",
entity: entityId,
show_name: false,
show_state: false,
grid_options: {
columns: 6,
rows: 2,
},
} satisfies PictureEntityCardConfig;
}
return {
type: "tile",
entity: entityId,
// Favorites come from all over the home, so name the area.
state_content: ["state", "area_name"],
show_entity_picture: true,
} satisfies TileCardConfig;
};
@@ -34,6 +34,7 @@ import type {
TileCardConfig,
UpdatesCardConfig,
} from "../../cards/types";
import { computeFavoriteCardConfig } from "../helpers/favorite-cards";
import {
LARGE_SCREEN_CONDITION,
SMALL_SCREEN_CONDITION,
@@ -271,15 +272,7 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
column_span: maxColumns,
cards: [
favoritesHeadingCard,
...favoriteEntities.map(
(entityId) =>
({
type: "tile",
entity: entityId,
state_content: ["state", "area_name"],
show_entity_picture: true,
}) satisfies TileCardConfig
),
...favoriteEntities.map(computeFavoriteCardConfig),
],
};
}
@@ -4,8 +4,9 @@ import { isComponentLoaded } from "../../../../common/config/is_component_loaded
import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
import { getCommonControlsUsagePrediction } from "../../../../data/usage_prediction";
import type { HomeAssistant } from "../../../../types";
import type { HeadingCardConfig, TileCardConfig } from "../../cards/types";
import type { HeadingCardConfig } from "../../cards/types";
import type { Condition } from "../../common/validate-condition";
import { computeFavoriteCardConfig } from "../helpers/favorite-cards";
import type { LovelaceStrategyDependency } from "../types";
const DEFAULT_LIMIT = 8;
@@ -25,13 +26,6 @@ export interface CommonControlsSectionStrategyConfig {
title_visibilty?: Condition[];
}
const toTileCard = (entity: string): TileCardConfig => ({
type: "tile",
entity,
state_content: ["state", "area_name"],
show_entity_picture: true,
});
@customElement("common-controls-section-strategy")
export class CommonControlsSectionStrategy extends ReactiveElement {
static registryDependencies: readonly LovelaceStrategyDependency[] = [];
@@ -63,7 +57,9 @@ export class CommonControlsSectionStrategy extends ReactiveElement {
// Pinned entities already fill the section, skip the prediction call.
if (includedEntities.length >= limit) {
section.cards!.push(...includedEntities.slice(0, limit).map(toTileCard));
section.cards!.push(
...includedEntities.slice(0, limit).map(computeFavoriteCardConfig)
);
return section;
}
@@ -110,7 +106,7 @@ export class CommonControlsSectionStrategy extends ReactiveElement {
return section;
}
section.cards!.push(...entities.map(toTileCard));
section.cards!.push(...entities.map(computeFavoriteCardConfig));
return section;
}
}
@@ -15,10 +15,8 @@ import type { HomeAssistant } from "../../../types";
import type { TileCardConfig } from "../../lovelace/cards/types";
import { BINARY_STATE_ON } from "../../../common/const";
import { computeDomain } from "../../../common/entity/compute_domain";
import {
type EntityRegistryDisplayEntry,
findBatteryChargingEntity,
} from "../../../data/entity/entity_registry";
import { getDeviceEntityDisplayLookup } from "../../../data/device/device_registry";
import { findBatteryChargingEntity } from "../../../data/entity/entity_registry";
export interface MaintenanceViewStrategyConfig {
type: "maintenance";
@@ -37,44 +35,42 @@ export const maintenanceEntityFilters: EntityFilter[] = [
const LOW_BATTERY_THRESHOLD = 20;
const _deviceEntities = memoizeOne(
(
deviceId: string,
entities: HomeAssistant["entities"]
): EntityRegistryDisplayEntry[] => {
const entries = Object.values(entities);
return entries.filter((entity) => entity.device_id === deviceId);
}
const _deviceEntityLookup = memoizeOne((entities: HomeAssistant["entities"]) =>
getDeviceEntityDisplayLookup(Object.values(entities))
);
export const filterLowBatteryEntities = (
hass: HomeAssistant,
entityIds: string[]
): string[] =>
entityIds.filter((entityId) => {
): string[] => {
return entityIds.filter((entityId) => {
const state = hass.states[entityId]?.state ?? "";
if (computeDomain(entityId) === "binary_sensor") {
return state === BINARY_STATE_ON;
}
const deviceId = hass.entities[entityId]?.device_id;
const entities = deviceId ? _deviceEntities(deviceId, hass.entities) : [];
const batteryChargingEntity = findBatteryChargingEntity(
hass.states,
entities
);
const batteryCharging = batteryChargingEntity
? hass.states[batteryChargingEntity?.entity_id]
: undefined;
if (batteryCharging && batteryCharging.state === "on") {
const stateValue = parseFloat(state);
if (isNaN(stateValue) || stateValue > LOW_BATTERY_THRESHOLD) {
return false;
}
const stateValue = parseFloat(state);
return !isNaN(stateValue) && stateValue <= LOW_BATTERY_THRESHOLD;
const deviceId = hass.entities[entityId]?.device_id;
if (!deviceId) {
return true;
}
const batteryChargingEntity = findBatteryChargingEntity(
hass.states,
_deviceEntityLookup(hass.entities)[deviceId] ?? []
);
const batteryCharging = batteryChargingEntity
? hass.states[batteryChargingEntity.entity_id]
: undefined;
return batteryCharging?.state !== "on";
});
};
export const filterUnavailableBatteryEntities = (
hass: HomeAssistant,
+9
View File
@@ -322,6 +322,15 @@ export const getMyRedirects = (): Redirects => ({
profile_security: {
redirect: "/profile/security",
},
profile_preferences: {
redirect: "/profile/preferences",
},
profile_localization: {
redirect: "/profile/localization",
},
profile_browser: {
redirect: "/profile/browser",
},
profile: {
redirect: "/profile",
},
+1 -1
View File
@@ -77,7 +77,7 @@ class HaPanelNotFound extends LitElement {
),
tap_action: {
action: "navigate",
navigation_path: "/profile/general",
navigation_path: "/profile/dashboard",
},
},
],
+17 -21
View File
@@ -1,38 +1,34 @@
import { customElement, property } from "lit/decorators";
import { mdiAccount, mdiLock } from "@mdi/js";
import type { PropertyValues } from "lit";
import type { RouterOptions } from "../../layouts/hass-router-page";
import { HassRouterPage } from "../../layouts/hass-router-page";
import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../types";
export const profileSections: PageNavigation[] = [
{
path: "/profile/general",
translationKey: "ui.panel.profile.tabs.general",
iconPath: mdiAccount,
},
{
path: "/profile/security",
translationKey: "ui.panel.profile.tabs.security",
iconPath: mdiLock,
},
];
@customElement("ha-panel-profile")
class HaPanelProfile extends SubscribeMixin(HassRouterPage) {
class HaPanelProfile extends HassRouterPage {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
protected routerOptions: RouterOptions = {
defaultPage: "general",
defaultPage: "dashboard",
routes: {
general: {
tag: "ha-profile-section-general",
load: () => import("./ha-profile-section-general"),
dashboard: {
tag: "ha-profile-dashboard",
load: () => import("./ha-profile-dashboard"),
},
preferences: {
tag: "ha-profile-section-preferences",
load: () => import("./ha-profile-section-preferences"),
},
localization: {
tag: "ha-profile-section-localization",
load: () => import("./ha-profile-section-localization"),
},
browser: {
tag: "ha-profile-section-browser",
load: () => import("./ha-profile-section-browser"),
},
security: {
tag: "ha-profile-section-security",
+1
View File
@@ -81,6 +81,7 @@ export class HaPickThemeRow extends SubscribeMixin(LitElement) {
darkMode: this.hass.localize(
"ui.panel.profile.themes.dark_mode.dark"
),
colors: this.hass.localize("ui.panel.profile.themes.colors"),
primaryColor: this.hass.localize(
"ui.panel.profile.themes.primary_color"
),
+220
View File
@@ -0,0 +1,220 @@
import {
mdiAccount,
mdiCellphone,
mdiEarth,
mdiLock,
mdiMonitor,
mdiTune,
} from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-button";
import "../../components/ha-card";
import "../../components/ha-config-navigation-list";
import "../../components/ha-svg-icon";
import "./ha-pick-theme-row";
import { isExternal } from "../../data/external";
import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box";
import "../../layouts/hass-subpage";
import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant, Route } from "../../types";
@customElement("ha-profile-dashboard")
class HaProfileDashboard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public route!: Route;
protected render(): TemplateResult {
const pages: PageNavigation[] = [
{
path: "/profile/preferences",
name: this.hass.localize("ui.panel.profile.user_preferences_header"),
description: this.hass.localize(
"ui.panel.profile.user_preferences_description"
),
iconPath: mdiTune,
},
{
path: "/profile/localization",
name: this.hass.localize("ui.panel.profile.localization_header"),
description: this.hass.localize(
"ui.panel.profile.localization_description"
),
iconPath: mdiEarth,
},
{
path: "/profile/browser",
name: this.hass.localize(
isExternal
? "ui.panel.profile.mobile_app_settings"
: "ui.panel.profile.browser_settings"
),
description: this.hass.localize("ui.panel.profile.browser_description"),
iconPath: isExternal ? mdiCellphone : mdiMonitor,
},
{
path: "/profile/security",
name: this.hass.localize("ui.panel.profile.tabs.security"),
description: this.hass.localize(
"ui.panel.profile.security_description"
),
iconPath: mdiLock,
},
];
return html`
<hass-subpage
main-page
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("panel.profile")}
>
<div class="container">
<ha-card>
<div class="card-content">
<div class="heading">
<div class="icon">
<ha-svg-icon .path=${mdiAccount}></ha-svg-icon>
</div>
<div class="details">
${this.hass.user!.name}
${
this.hass.user!.is_owner
? html`<br /><small
>${this.hass.localize(
"ui.panel.profile.is_owner"
)}</small
>`
: ""
}
</div>
<ha-button
variant="danger"
appearance="plain"
@click=${this._handleLogOut}
>
${this.hass.localize("ui.panel.profile.logout")}
</ha-button>
</div>
</div>
</ha-card>
<ha-card
.header=${this.hass.localize("ui.panel.profile.themes.header")}
>
<ha-pick-theme-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-theme-row>
</ha-card>
<ha-card outlined>
<ha-config-navigation-list
.hass=${this.hass}
.pages=${pages}
has-secondary
.label=${this.hass.localize("panel.profile")}
></ha-config-navigation-list>
</ha-card>
</div>
</hass-subpage>
`;
}
private _handleLogOut() {
showConfirmationDialog(this, {
title: this.hass.localize("ui.panel.profile.logout_title"),
text: this.hass.localize("ui.panel.profile.logout_text"),
confirmText: this.hass.localize("ui.panel.profile.logout"),
confirm: () => fireEvent(this, "hass-logout"),
destructive: true,
});
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
}
.container {
padding: var(--ha-space-2) var(--ha-space-4)
calc(var(--ha-space-4) + var(--safe-area-inset-bottom));
}
ha-card {
margin: 0 auto var(--ha-space-4);
max-width: 600px;
}
ha-card[outlined] {
overflow: hidden;
}
.heading {
display: flex;
align-items: center;
column-gap: var(--ha-space-4);
}
.icon {
position: relative;
border-radius: var(--ha-border-radius-2xl);
width: var(--ha-space-10);
height: var(--ha-space-10);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
}
.icon::before {
display: block;
content: "";
position: absolute;
inset: 0;
background-color: var(--primary-color);
opacity: 0.2;
}
.icon ha-svg-icon {
color: var(--primary-color);
width: var(--ha-space-6);
height: var(--ha-space-6);
}
.details {
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed);
color: var(--primary-text-color);
}
.details small {
font-size: var(--ha-font-size-m);
color: var(--secondary-text-color);
}
ha-button {
margin-inline-start: auto;
flex-shrink: 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-profile-dashboard": HaProfileDashboard;
}
}
@@ -0,0 +1,130 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import "../../components/ha-card";
import "../../components/ha-md-list";
import { isExternal } from "../../data/external";
import "../../layouts/hass-subpage";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant, Route } from "../../types";
import { isMobileClient } from "../../util/is_mobile";
import "./ha-enable-shortcuts-row";
import "./ha-force-narrow-row";
import "./ha-push-notifications-row";
import "./ha-set-suspend-row";
import "./ha-set-vibrate-row";
@customElement("ha-profile-section-browser")
class HaProfileSectionBrowser extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public route!: Route;
protected render(): TemplateResult {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/profile"
.header=${this.hass.localize(
isExternal
? "ui.panel.profile.mobile_app_settings"
: "ui.panel.profile.browser_settings"
)}
>
<div class="container">
<ha-card
.header=${this.hass.localize(
isExternal
? "ui.panel.profile.mobile_app_settings"
: "ui.panel.profile.browser_settings"
)}
>
<div class="card-content">
${this.hass.localize("ui.panel.profile.client_settings_detail")}
</div>
<ha-md-list>
${
this.hass.dockedSidebar !== "auto" || !this.narrow
? html`
<ha-force-narrow-row
.hass=${this.hass}
></ha-force-narrow-row>
`
: ""
}
${
"vibrate" in navigator
? html`
<ha-set-vibrate-row
.hass=${this.hass}
></ha-set-vibrate-row>
`
: ""
}
${
!isExternal &&
isComponentLoaded(this.hass.config, "html5.notify")
? html`
<ha-push-notifications-row
.hass=${this.hass}
></ha-push-notifications-row>
`
: ""
}
<ha-set-suspend-row .hass=${this.hass}></ha-set-suspend-row>
${
!isMobileClient
? html`
<ha-enable-shortcuts-row
id="shortcuts"
.hass=${this.hass}
></ha-enable-shortcuts-row>
`
: ""
}
</ha-md-list>
</ha-card>
</div>
</hass-subpage>
`;
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
}
.container {
padding: var(--ha-space-2) var(--ha-space-4)
calc(var(--ha-space-4) + var(--safe-area-inset-bottom));
}
ha-card {
margin: 0 auto var(--ha-space-4);
max-width: 600px;
}
ha-md-list {
background: none;
padding-top: 0;
padding-bottom: 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-profile-section-browser": HaProfileSectionBrowser;
}
}
@@ -1,305 +0,0 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { fireEvent } from "../../common/dom/fire_event";
import { replaceCurrentUrl } from "../../common/navigate";
import { nextRender } from "../../common/util/render-status";
import "../../components/ha-button";
import "../../components/ha-card";
import "../../components/item/ha-row-item";
import { isExternal } from "../../data/external";
import type { CoreFrontendUserData } from "../../data/frontend";
import { subscribeFrontendUserData } from "../../data/frontend";
import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box";
import { showEditSidebarDialog } from "../../dialogs/sidebar/show-dialog-edit-sidebar";
import "../../layouts/hass-tabs-subpage";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant, Route } from "../../types";
import { isMobileClient } from "../../util/is_mobile";
import "./ha-enable-shortcuts-row";
import "./ha-entity-id-picker-row";
import "./ha-force-narrow-row";
import { profileSections } from "./ha-panel-profile";
import "./ha-pick-dashboard-row";
import "./ha-pick-date-format-row";
import "./ha-pick-first-weekday-row";
import "./ha-pick-language-row";
import "./ha-pick-number-format-row";
import "./ha-pick-theme-row";
import "./ha-pick-time-format-row";
import "./ha-pick-time-zone-row";
import "./ha-push-notifications-row";
import "./ha-set-suspend-row";
import "./ha-set-vibrate-row";
@customElement("ha-profile-section-general")
class HaProfileSectionGeneral extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@state() private _coreUserData?: CoreFrontendUserData | null;
@property({ attribute: false }) public route!: Route;
private _unsubCoreData?: Promise<UnsubscribeFunc>;
private _getCoreData() {
this._unsubCoreData = subscribeFrontendUserData(
this.hass.connection,
"core",
({ value }) => {
this._coreUserData = value;
}
);
}
public connectedCallback() {
super.connectedCallback();
if (this.hass) {
this._getCoreData();
}
this._scrollToHash();
}
public firstUpdated() {
if (!this._unsubCoreData) {
this._getCoreData();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
if (this._unsubCoreData) {
this._unsubCoreData.then((unsub) => unsub());
this._unsubCoreData = undefined;
}
}
private async _scrollToHash() {
await nextRender();
const hash = window.location.hash.substring(1);
if (hash) {
const element = this.shadowRoot!.getElementById(hash);
element?.scrollIntoView();
this._clearHash();
}
}
private _clearHash() {
replaceCurrentUrl(window.location.pathname);
}
protected render(): TemplateResult {
return html`
<hass-tabs-subpage
main-page
.hass=${this.hass}
.tabs=${profileSections}
.route=${this.route}
>
<div slot="title">${this.hass.localize("panel.profile")}</div>
<div class="content">
<ha-card .header=${this.hass.user!.name}>
<div class="card-content">
${this.hass.localize("ui.panel.profile.current_user", {
fullName: this.hass.user!.name,
})}
${
this.hass.user!.is_owner
? this.hass.localize("ui.panel.profile.is_owner")
: ""
}
</div>
<div class="card-actions">
<ha-button
variant="danger"
appearance="plain"
@click=${this._handleLogOut}
>
${this.hass.localize("ui.panel.profile.logout")}
</ha-button>
</div>
</ha-card>
<ha-card
.header=${this.hass.localize(
"ui.panel.profile.user_preferences_header"
)}
>
<div class="card-content">
${this.hass.localize("ui.panel.profile.user_preferences_detail")}
</div>
<ha-pick-language-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-language-row>
<ha-pick-theme-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-theme-row>
<ha-pick-dashboard-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-dashboard-row>
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.profile.customize_sidebar.header"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.profile.customize_sidebar.description"
)}</span
>
<ha-button
slot="end"
appearance="plain"
size="s"
@click=${this._customizeSidebar}
>
${this.hass.localize(
"ui.panel.profile.customize_sidebar.button"
)}
</ha-button>
</ha-row-item>
${
this.hass.user!.is_admin
? html`
<ha-entity-id-picker-row
.hass=${this.hass}
.coreUserData=${this._coreUserData}
></ha-entity-id-picker-row>
`
: nothing
}
</ha-card>
<ha-card
.header=${this.hass.localize(
"ui.panel.profile.localization_header"
)}
>
<div class="card-content">
${this.hass.localize("ui.panel.profile.localization_detail")}
</div>
<ha-pick-time-zone-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-time-zone-row>
<ha-pick-number-format-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-number-format-row>
<ha-pick-time-format-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-time-format-row>
<ha-pick-date-format-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-date-format-row>
<ha-pick-first-weekday-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-first-weekday-row>
</ha-card>
<ha-card
.header=${this.hass.localize(
isExternal
? "ui.panel.profile.mobile_app_settings"
: "ui.panel.profile.browser_settings"
)}
>
<div class="card-content">
${this.hass.localize("ui.panel.profile.client_settings_detail")}
</div>
${
this.hass.dockedSidebar !== "auto" || !this.narrow
? html`
<ha-force-narrow-row
.hass=${this.hass}
></ha-force-narrow-row>
`
: nothing
}
${
"vibrate" in navigator
? html`
<ha-set-vibrate-row .hass=${this.hass}></ha-set-vibrate-row>
`
: nothing
}
${
!isExternal && isComponentLoaded(this.hass.config, "html5.notify")
? html`
<ha-push-notifications-row
.hass=${this.hass}
></ha-push-notifications-row>
`
: nothing
}
<ha-set-suspend-row .hass=${this.hass}></ha-set-suspend-row>
${
!isMobileClient
? html`
<ha-enable-shortcuts-row
id="shortcuts"
.hass=${this.hass}
></ha-enable-shortcuts-row>
`
: nothing
}
</ha-card>
</div>
</hass-tabs-subpage>
`;
}
private _customizeSidebar() {
showEditSidebarDialog(this);
}
private _handleLogOut() {
showConfirmationDialog(this, {
title: this.hass.localize("ui.panel.profile.logout_title"),
text: this.hass.localize("ui.panel.profile.logout_text"),
confirmText: this.hass.localize("ui.panel.profile.logout"),
confirm: () => fireEvent(this, "hass-logout"),
destructive: true,
});
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
}
.content {
display: block;
max-width: 600px;
margin: 0 auto;
padding-bottom: var(--safe-area-inset-bottom);
}
.content > * {
display: block;
margin: 24px 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-profile-section-general": HaProfileSectionGeneral;
}
}
@@ -0,0 +1,98 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../components/ha-card";
import "../../layouts/hass-subpage";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant, Route } from "../../types";
import "./ha-pick-date-format-row";
import "./ha-pick-first-weekday-row";
import "./ha-pick-language-row";
import "./ha-pick-number-format-row";
import "./ha-pick-time-format-row";
import "./ha-pick-time-zone-row";
@customElement("ha-profile-section-localization")
class HaProfileSectionLocalization extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public route!: Route;
protected render(): TemplateResult {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/profile"
.header=${this.hass.localize("ui.panel.profile.localization_header")}
>
<div class="container">
<ha-card
.header=${this.hass.localize(
"ui.panel.profile.localization_header"
)}
>
<div class="card-content">
${this.hass.localize("ui.panel.profile.localization_detail")}
</div>
<ha-pick-language-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-language-row>
<ha-pick-time-zone-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-time-zone-row>
<ha-pick-number-format-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-number-format-row>
<ha-pick-time-format-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-time-format-row>
<ha-pick-date-format-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-date-format-row>
<ha-pick-first-weekday-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-first-weekday-row>
</ha-card>
</div>
</hass-subpage>
`;
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
}
.container {
padding: var(--ha-space-2) var(--ha-space-4)
calc(var(--ha-space-4) + var(--safe-area-inset-bottom));
}
ha-card {
margin: 0 auto var(--ha-space-4);
max-width: 600px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-profile-section-localization": HaProfileSectionLocalization;
}
}
@@ -0,0 +1,162 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../components/ha-button";
import "../../components/ha-card";
import "../../components/ha-md-list";
import "../../components/ha-md-list-item";
import type { CoreFrontendUserData } from "../../data/frontend";
import { subscribeFrontendUserData } from "../../data/frontend";
import { showEditSidebarDialog } from "../../dialogs/sidebar/show-dialog-edit-sidebar";
import "../../layouts/hass-subpage";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant, Route } from "../../types";
import "./ha-entity-id-picker-row";
import "./ha-pick-dashboard-row";
@customElement("ha-profile-section-preferences")
class HaProfileSectionPreferences extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public route!: Route;
@state() private _coreUserData?: CoreFrontendUserData | null;
private _unsubCoreData?: Promise<UnsubscribeFunc>;
private _getCoreData() {
this._unsubCoreData = subscribeFrontendUserData(
this.hass.connection,
"core",
({ value }) => {
this._coreUserData = value;
}
);
}
public connectedCallback() {
super.connectedCallback();
if (this.hass) {
this._getCoreData();
}
}
public firstUpdated() {
if (!this._unsubCoreData) {
this._getCoreData();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
if (this._unsubCoreData) {
this._unsubCoreData.then((unsub) => unsub());
this._unsubCoreData = undefined;
}
}
protected render(): TemplateResult {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/profile"
.header=${this.hass.localize(
"ui.panel.profile.user_preferences_header"
)}
>
<div class="container">
<ha-card
.header=${this.hass.localize(
"ui.panel.profile.user_preferences_header"
)}
>
<div class="card-content">
${this.hass.localize("ui.panel.profile.user_preferences_detail")}
</div>
<ha-pick-dashboard-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-dashboard-row>
<ha-md-list>
<ha-md-list-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.profile.customize_sidebar.header"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.profile.customize_sidebar.description"
)}</span
>
<ha-button
slot="end"
appearance="plain"
size="s"
@click=${this._customizeSidebar}
>
${this.hass.localize(
"ui.panel.profile.customize_sidebar.button"
)}
</ha-button>
</ha-md-list-item>
${
this.hass.user!.is_admin
? html`
<ha-entity-id-picker-row
.hass=${this.hass}
.coreUserData=${this._coreUserData}
></ha-entity-id-picker-row>
`
: ""
}
</ha-md-list>
</ha-card>
</div>
</hass-subpage>
`;
}
private _customizeSidebar() {
showEditSidebarDialog(this);
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
}
.container {
padding: var(--ha-space-2) var(--ha-space-4)
calc(var(--ha-space-4) + var(--safe-area-inset-bottom));
}
ha-card {
margin: 0 auto var(--ha-space-4);
max-width: 600px;
}
ha-md-list {
background: none;
padding-top: 0;
padding-bottom: 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-profile-section-preferences": HaProfileSectionPreferences;
}
}
@@ -1,8 +1,7 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../layouts/hass-tabs-subpage";
import { profileSections } from "./ha-panel-profile";
import "../../layouts/hass-subpage";
import type { RefreshToken } from "../../data/refresh_token";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant, Route } from "../../types";
@@ -32,14 +31,12 @@ class HaProfileSectionSecurity extends LitElement {
protected render(): TemplateResult {
return html`
<hass-tabs-subpage
main-page
<hass-subpage
.hass=${this.hass}
.tabs=${profileSections}
.route=${this.route}
back-path="/profile"
.header=${this.hass.localize("ui.panel.profile.tabs.security")}
>
<div slot="title">${this.hass.localize("panel.profile")}</div>
<div class="content">
<div class="container">
${
this.hass.user!.credentials.some(
(cred) => cred.auth_provider_type === "homeassistant"
@@ -70,7 +67,7 @@ class HaProfileSectionSecurity extends LitElement {
@hass-refresh-tokens=${this._refreshRefreshTokens}
></ha-long-lived-access-tokens-card>
</div>
</hass-tabs-subpage>
</hass-subpage>
`;
}
@@ -93,21 +90,15 @@ class HaProfileSectionSecurity extends LitElement {
-moz-user-select: initial;
}
.content {
.container {
padding: var(--ha-space-2) var(--ha-space-4)
calc(var(--ha-space-4) + var(--safe-area-inset-bottom));
}
.container > * {
display: block;
margin: 0 auto var(--ha-space-4);
max-width: 600px;
margin: 0 auto;
padding-bottom: var(--safe-area-inset-bottom);
}
.content > * {
display: block;
margin: 24px 0;
}
.promo-advanced {
text-align: center;
color: var(--secondary-text-color);
}
`,
];
@@ -18,11 +18,9 @@ import type {
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { SecurityAlertEntityConfig } from "../../../data/frontend";
import type { HomeAssistant } from "../../../types";
import type {
LogbookCardConfig,
TileCardConfig,
} from "../../lovelace/cards/types";
import type { LogbookCardConfig } from "../../lovelace/cards/types";
import { computeAreaTileCardConfig } from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
import { computeFavoriteCardConfig } from "../../lovelace/strategies/helpers/favorite-cards";
import { computeSecurityAlertCardConfig } from "./security-alerts";
export interface SecurityViewStrategyConfig {
@@ -186,15 +184,7 @@ export class SecurityViewStrategy extends ReactiveElement {
),
heading_style: "title",
},
...favoriteEntities.map(
(entityId) =>
({
type: "tile",
entity: entityId,
state_content: ["state", "area_name"],
show_entity_picture: true,
}) satisfies TileCardConfig
),
...favoriteEntities.map(computeFavoriteCardConfig),
],
});
}
+24 -6
View File
@@ -9924,6 +9924,21 @@
"dayGridWeek": "Week",
"dayGridDay": "Day",
"listWeek": "List (7 days)"
},
"show_add_event": "Show add event button",
"add_event_style": "Add event button style",
"add_event_size": "Add event button size",
"add_event": {
"style": {
"header": "Header",
"below": "Below",
"on_top": "On top"
},
"size": {
"s": "Small",
"m": "Medium",
"l": "Large"
}
}
},
"conditional": {
@@ -11180,17 +11195,19 @@
},
"profile": {
"tabs": {
"general": "General",
"security": "Security"
},
"current_user": "You are currently logged in as {fullName}.",
"is_owner": "You are an owner.",
"user_preferences_header": "User preferences",
"user_preferences_header": "Appearance",
"user_preferences_description": "Default dashboard and sidebar",
"user_preferences_detail": "The following settings are tied to your account and will persist across all sessions and devices.",
"localization_header": "Localization",
"localization_description": "Language, time zone, date, time, and number formats",
"localization_detail": "These settings are tied to your account and control how dates, times, and numbers are displayed.",
"mobile_app_settings": "Mobile app settings",
"browser_settings": "Browser settings",
"mobile_app_settings": "This mobile app",
"browser_settings": "This browser",
"browser_description": "Notifications, display, and shortcuts",
"security_description": "Password, multi-factor authentication, and access tokens",
"client_settings_detail": "The following settings are local to this client only, and may reset to defaults on logout or when local data is cleared.",
"logout": "Log out",
"logout_title": "Log out?",
@@ -11305,6 +11322,7 @@
"migrate_success": "Theme settings migrated.",
"migrate_failed": "Failed to migrate theme settings.",
"theme_mode": "Theme mode",
"colors": "Custom colors",
"dark_mode": {
"auto": "Auto",
"light": "Light",
@@ -11312,7 +11330,7 @@
},
"primary_color": "Primary color",
"accent_color": "Accent color",
"reset": "Reset",
"reset": "Reset colors",
"use_default": "Use default theme"
},
"dashboard": {
+106
View File
@@ -0,0 +1,106 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from "vitest";
import { addLatinLabels } from "../../build-scripts/gulp/map-labels.js";
// The rewrite keys on the exact `text-field` @versatiles/style emits; a bump
// that changed it would silently ship local-only names again.
const layer = (id, layout) => ({ id, type: "symbol", layout });
const STYLE = {
layers: [
layer("label-place-city", { "text-field": ["get", "name"] }),
layer("label-street-primary", {
"symbol-placement": "line",
"text-field": ["get", "name"],
}),
layer("label-motorway-shield", { "text-field": "{ref}" }),
{ id: "water", type: "fill" },
],
};
// Just enough of the expression language for the expressions built here.
const evaluate = (expression, properties) => {
if (!Array.isArray(expression)) {
return expression;
}
const [op, ...args] = expression;
switch (op) {
case "get":
return properties[args[0]];
case "has":
return args[0] in properties;
case "!":
return !evaluate(args[0], properties);
case "<":
return evaluate(args[0], properties) < evaluate(args[1], properties);
case "concat":
return args.map((arg) => evaluate(arg, properties)).join("");
case "format":
return args
.filter((_, i) => i % 2 === 0)
.map((arg) => evaluate(arg, properties))
.join("");
case "case":
for (let i = 0; i < args.length - 1; i += 2) {
if (evaluate(args[i], properties)) {
return evaluate(args[i + 1], properties);
}
}
return evaluate(args[args.length - 1], properties);
default:
throw new Error(`Unexpected operator ${op}`);
}
};
const textField = (style, id) =>
style.layers.find((l) => l.id === id).layout["text-field"];
describe("addLatinLabels", () => {
const style = addLatinLabels(STYLE);
const city = textField(style, "label-place-city");
const street = textField(style, "label-street-primary");
it("leaves Latin names alone", () => {
expect(evaluate(city, { name: "Köln", name_en: "Cologne" })).toBe("Köln");
expect(evaluate(city, { name: "1er arrondissement" })).toBe(
"1er arrondissement"
);
});
it("adds the English name under a non-Latin one", () => {
expect(evaluate(city, { name: "Москва", name_en: "Moscow" })).toBe(
"Москва\nMoscow"
);
expect(evaluate(city, { name: "بيروت", name_en: "Beirut" })).toBe(
"بيروت\nBeirut"
);
expect(evaluate(city, { name: "東京", name_en: "Tokyo" })).toBe(
"東京\nTokyo"
);
});
it("sets the English line smaller", () => {
expect(city.at(-1)).toEqual(
expect.arrayContaining([["get", "name_en"], { "font-scale": 0.8 }])
);
});
it("falls back to the local name without an English one", () => {
expect(evaluate(city, { name: "בני ברק" })).toBe("בני ברק");
});
it("keeps street labels on one line", () => {
expect(
evaluate(street, { name: "شارع الحمرا", name_en: "Hamra Street" })
).toBe("شارع الحمرا (Hamra Street)");
});
it("does not touch other layers", () => {
expect(textField(style, "label-motorway-shield")).toBe("{ref}");
expect(style.layers[3]).toEqual(STYLE.layers[3]);
});
});
+55 -108
View File
@@ -18,21 +18,9 @@ const maplibreGL = vi.hoisted(() => vi.fn(() => maplibreLayer));
vi.mock("@maplibre/maplibre-gl-leaflet", () => ({ maplibreGL }));
// The token module is driven directly here, so a stale token and the one that
// replaces it can be played out without a WebSocket.
const tokenListeners = vi.hoisted(() => new Set<(token: string) => void>());
const refreshMapTilesToken = vi.hoisted(() => vi.fn());
vi.mock("../../../src/data/map_tiles", () => ({
MAP_TILES_PATH: "/api/map_tiles",
refreshMapTilesToken,
subscribeMapTilesToken: (listener: (token: string) => void) => {
tokenListeners.add(listener);
return () => tokenListeners.delete(listener);
},
withMapTilesToken: (url: string) => new URL(url, location.href).href,
}));
const emitToken = (token: string) =>
tokenListeners.forEach((listener) => listener(token));
const setRTLTextPlugin = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("maplibre-gl", () => ({ setRTLTextPlugin }));
const STYLE = {
version: 8,
@@ -41,23 +29,19 @@ const STYLE = {
sprite: [{ id: "basics", url: "/static/map/sprites/basics/sprites" }],
};
const rasterLayer = {
// Leaflet returns the layer from addTo, and the source chains off it.
addTo: vi.fn(() => rasterLayer),
redraw: vi.fn(),
options: {} as Record<string, unknown>,
};
const rasterLayer = { addTo: vi.fn() };
// Kept as a local so tests can flip it: @types/leaflet has it readonly.
const browser = { retina: false };
const leaflet = {
tileLayer: vi.fn(() => rasterLayer),
Browser: browser,
} as unknown as LeafletModuleType;
const TOKEN = "test-token";
// `createVectorLayer` listens on both the MapLibre map and the Leaflet map.
const glHandlers: Record<string, (event?: unknown) => void> = {};
const glHandlers: Record<string, () => void> = {};
const glMap = {
setStyle: vi.fn(),
on: vi.fn((event: string, handler: (event?: unknown) => void) => {
on: vi.fn((event: string, handler: () => void) => {
glHandlers[event] = handler;
}),
};
@@ -77,7 +61,6 @@ const isRaster = () => vi.mocked(leaflet.tileLayer).mock.calls.length === 1;
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
tokenListeners.clear();
maplibreLayer.options = {};
maplibreGL.mockReturnValue(maplibreLayer);
maplibreLayer.addTo.mockImplementation(() => maplibreLayer);
@@ -100,28 +83,52 @@ describe("createBaseLayer", () => {
it("falls back to raster tiles without WebGL2", async () => {
const createBaseLayer = await setWebGL2(false);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, false);
expect(isRaster()).toBe(true);
expect(maplibreGL).not.toHaveBeenCalled();
expect(rasterLayer.addTo).toHaveBeenCalledWith(map);
const [url, options = {}] = vi.mocked(leaflet.tileLayer).mock.calls[0];
// Through core's proxy, which is what identifies Home Assistant upstream -
// a browser can set neither a User-Agent nor a Referer.
expect(url).toContain("/api/map_tiles/raster/{z}/{x}/{y}.png");
// Leaflet substitutes options into the template on every tile request, so
// the token can be refreshed without recreating the layer.
expect(url).toContain("token={token}");
expect(options).toMatchObject({ token: TOKEN });
// No referrer: the only one a browser can send is its origin, which
// identifies a Nabu Casa installation. The fallback source is chosen so it
// does not need one.
expect(options).not.toHaveProperty("referrerPolicy");
// The vector layer takes its credit from the style's source instead, so the
// raster layer is the only one carrying attribution itself.
// raster layer is the only one carrying attribution itself - and it credits
// both the data and whoever rendered it.
expect(options.attribution).toContain("openstreetmap.org/copyright");
expect(options.attribution).toContain("carto.com/attributions");
expect(url).toMatch(/\{z\}\/\{x\}\/\{y\}/);
});
// The devices on the fallback are the old retina tablets, and the source
// serves @2x, so they get sharp tiles without quadrupling the requests.
it("asks for @2x raster tiles on a retina screen", async () => {
const createBaseLayer = await setWebGL2(false);
browser.retina = true;
await createBaseLayer(leaflet, map, false);
expect(vi.mocked(leaflet.tileLayer).mock.calls[0][0]).toContain("@2x.png");
browser.retina = false;
});
it("registers the RTL text plugin once, lazily, from our own host", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false);
await createBaseLayer(leaflet, map, false);
expect(setRTLTextPlugin).toHaveBeenCalledOnce();
expect(setRTLTextPlugin).toHaveBeenCalledWith(
`${location.origin}/static/map/mapbox-gl-rtl-text.js`,
true
);
});
it("uses vector tiles when WebGL2 is available", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, false);
expect(maplibreGL).toHaveBeenCalledOnce();
expect(maplibreLayer.addTo).toHaveBeenCalledWith(map);
@@ -137,7 +144,7 @@ describe("createBaseLayer", () => {
})
);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, false);
expect(isRaster()).toBe(true);
});
@@ -150,7 +157,7 @@ describe("createBaseLayer", () => {
throw new Error("Failed to initialize WebGL");
});
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, false);
expect(isRaster()).toBe(true);
expect(maplibreLayer.remove).toHaveBeenCalled();
@@ -165,7 +172,7 @@ describe("createBaseLayer", () => {
throw new Error("nothing to remove");
});
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, false);
expect(isRaster()).toBe(true);
});
@@ -178,7 +185,7 @@ describe("setDarkMode", () => {
it("swaps the style, and ignores a repeat of the current mode", async () => {
const createBaseLayer = await setWebGL2(true);
const baseLayer = await createBaseLayer(leaflet, map, false, TOKEN);
const baseLayer = await createBaseLayer(leaflet, map, false);
baseLayer.setDarkMode(true);
await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledOnce());
@@ -192,7 +199,7 @@ describe("setDarkMode", () => {
// the tracked mode, and the next toggle to that mode would do nothing.
it("can retry a mode whose request failed while another was in flight", async () => {
const createBaseLayer = await setWebGL2(true);
const baseLayer = await createBaseLayer(leaflet, map, false, TOKEN);
const baseLayer = await createBaseLayer(leaflet, map, false);
const failing = vi.fn(async () => {
throw new Error("offline");
@@ -239,7 +246,7 @@ describe("setDarkMode", () => {
// The layer only settles once its first style resolves, so let that one
// through before the map exists to switch.
const pending = createBaseLayer(leaflet, map, false, TOKEN);
const pending = createBaseLayer(leaflet, map, false);
await vi.waitFor(() => expect(resolvers).toHaveLength(1));
resolvers.shift()!(styleResponse("light"));
const baseLayer = await pending;
@@ -271,7 +278,7 @@ describe("WebGL context loss", () => {
it("falls back to raster tiles when the context stays lost", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, false);
expect(leaflet.tileLayer).not.toHaveBeenCalled();
glHandlers.webglcontextlost();
@@ -283,7 +290,7 @@ describe("WebGL context loss", () => {
it("keeps the vector layer when the context comes back", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, false);
glHandlers.webglcontextlost();
glHandlers.webglcontextrestored();
@@ -299,7 +306,7 @@ describe("WebGL context loss", () => {
it("waits for the page to be visible before falling back", async () => {
const hidden = vi.spyOn(document, "hidden", "get").mockReturnValue(true);
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, false);
glHandlers.webglcontextlost();
vi.runAllTimers();
@@ -315,7 +322,7 @@ describe("WebGL context loss", () => {
it("keeps the vector layer when a hidden page gets its context back", async () => {
const hidden = vi.spyOn(document, "hidden", "get").mockReturnValue(true);
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, false);
glHandlers.webglcontextlost();
glHandlers.webglcontextrestored();
@@ -330,7 +337,7 @@ describe("WebGL context loss", () => {
it("stops listening for visibility once it has fallen back", async () => {
const remove = vi.spyOn(document, "removeEventListener");
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, false);
glHandlers.webglcontextlost();
vi.runAllTimers();
@@ -347,7 +354,7 @@ describe("WebGL context loss", () => {
it("stops answering theme changes once it has fallen back", async () => {
const createBaseLayer = await setWebGL2(true);
const baseLayer = await createBaseLayer(leaflet, map, false, TOKEN);
const baseLayer = await createBaseLayer(leaflet, map, false);
glHandlers.webglcontextlost();
vi.runAllTimers();
@@ -356,63 +363,3 @@ describe("WebGL context loss", () => {
expect(glMap.setStyle).not.toHaveBeenCalled();
});
});
// A refused request leaves the source dead: the TileJSON is fetched once and
// MapLibre never retries it, so the map stays blank until the style is
// applied again.
describe("recovering from a refused token", () => {
it("asks for a new token and re-applies the style once it arrives", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
glMap.setStyle.mockClear();
glHandlers.error({ error: { status: 403 } });
expect(refreshMapTilesToken).toHaveBeenCalled();
emitToken("fresh-token");
await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledOnce());
});
it("does not keep asking while the proxy refuses for another reason", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
for (let i = 0; i < 5; i++) {
glHandlers.error({ error: { status: 403 } });
}
expect(refreshMapTilesToken).toHaveBeenCalledOnce();
});
it("ignores errors that are not a refusal", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
glHandlers.error({ error: { status: 500 } });
expect(refreshMapTilesToken).not.toHaveBeenCalled();
});
it("leaves a working map alone when the token is merely refreshed", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
glMap.setStyle.mockClear();
emitToken("fresh-token");
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
expect(glMap.setStyle).not.toHaveBeenCalled();
});
it("redraws the raster layer so refused tiles are asked for again", async () => {
const createBaseLayer = await setWebGL2(false);
await createBaseLayer(leaflet, map, false, TOKEN);
emitToken("fresh-token");
expect(rasterLayer.options.token).toBe("fresh-token");
expect(rasterLayer.redraw).toHaveBeenCalled();
});
});
@@ -0,0 +1,256 @@
import { describe, expect, it } from "vitest";
import { computeInitialHaFormData } from "../../../src/components/ha-form/compute-initial-ha-form-data";
import type { Selector } from "../../../src/data/selector";
import type { HaFormSchema } from "../../../src/components/ha-form/types";
const requiredField = (selector: Selector): HaFormSchema => ({
name: "value",
required: true,
selector,
});
const optionalField = (selector: Selector): HaFormSchema => ({
name: "value",
required: false,
selector,
});
describe("computeInitialHaFormData", () => {
it("initializes required text selector with an empty string", () => {
expect(computeInitialHaFormData([requiredField({ text: {} })])).toEqual({
value: "",
});
});
it("initializes required multiple text selector with an empty array", () => {
expect(
computeInitialHaFormData([
requiredField({
text: {
multiple: true,
},
}),
])
).toEqual({
value: [],
});
});
it("initializes required object selector with an empty string", () => {
expect(computeInitialHaFormData([requiredField({ object: {} })])).toEqual({
value: "",
});
});
it("initializes required multiple object selector with an empty array", () => {
expect(
computeInitialHaFormData([
requiredField({
object: {
multiple: true,
},
}),
])
).toEqual({
value: [],
});
});
it("leaves a required single device class selector unset", () => {
expect(
computeInitialHaFormData([
requiredField({
device_class: {
domain: "sensor",
},
}),
])
).toEqual({});
});
it("initializes a required multiple device class selector with an empty array", () => {
expect(
computeInitialHaFormData([
requiredField({
device_class: {
domain: "sensor",
multiple: true,
},
}),
])
).toEqual({
value: [],
});
});
it("does not initialize optional text selectors", () => {
expect(
computeInitialHaFormData([
optionalField({ text: {} }),
{
...optionalField({
text: {
multiple: true,
},
}),
name: "multiple",
},
])
).toEqual({});
});
it("does not initialize optional object selectors", () => {
expect(
computeInitialHaFormData([
optionalField({ object: {} }),
{
...optionalField({
object: {
multiple: true,
},
}),
name: "multiple",
},
])
).toEqual({});
});
it("initializes a required constant selector without losing falsy values", () => {
expect(
computeInitialHaFormData([
requiredField({
constant: {
value: false,
},
}),
])
).toEqual({
value: false,
});
expect(
computeInitialHaFormData([
requiredField({
constant: {
value: 0,
},
}),
])
).toEqual({
value: 0,
});
});
it("initializes a required choose selector from a constant first choice", () => {
const schema = [
{
name: "match",
required: true,
selector: {
choose: {
choices: {
Disabled: {
selector: {
constant: {
value: "",
},
},
},
Enabled: {
selector: {
number: {},
},
},
},
},
},
},
] as const;
expect(computeInitialHaFormData(schema)).toEqual({
match: {
active_choice: "Disabled",
Disabled: "",
},
});
});
it("initializes a required choose selector from its child selector", () => {
const schema = [
{
name: "mode",
required: true,
selector: {
choose: {
choices: {
First: {
selector: {
text: {
multiple: true,
},
},
},
Second: {
selector: {
text: {},
},
},
},
},
},
},
] as const;
expect(computeInitialHaFormData(schema)).toEqual({
mode: {
active_choice: "First",
First: [],
},
});
});
it("throws for an unsupported required selector", () => {
expect(() =>
computeInitialHaFormData([
requiredField({
ui_action: {
default_action: "none",
},
}),
])
).toThrow("Selector ui_action not supported in initial form data");
});
it("omits a first choose child without an initial value", () => {
const schema = [
{
name: "mode",
required: true,
selector: {
choose: {
choices: {
First: {
selector: {
ui_action: {
default_action: "none",
},
},
},
Second: {
selector: {
text: {},
},
},
},
},
},
},
] as const;
expect(computeInitialHaFormData(schema)).toStrictEqual({
mode: {
active_choice: "First",
},
});
});
});
@@ -1,7 +1,11 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ObjectSelector } from "../../../src/data/selector";
import type { HomeAssistant } from "../../../src/types";
import type { HaObjectSelector } from "../../../src/components/ha-selector/ha-selector-object";
import type { FormDialogParams } from "../../../src/dialogs/form/show-form-dialog";
import type {
FormDialogData,
FormDialogParams,
} from "../../../src/dialogs/form/show-form-dialog";
import "../../../src/components/ha-selector/ha-selector-object";
vi.mock("../../../src/components/ha-input-helper-text", () => {
@@ -51,12 +55,23 @@ const selectorConfig = {
const getInternals = (selector: HaObjectSelector) =>
selector as unknown as Record<string, unknown>;
const mountSelector = async (value: Record<string, string>[]) => {
interface ItemActionEvent {
stopPropagation: () => void;
currentTarget: {
item?: FormDialogData;
index?: number;
};
}
const mountSelector = async (
value: FormDialogData | FormDialogData[] | "",
config: ObjectSelector = selectorConfig
) => {
const selector = document.createElement(
"ha-selector-object"
) as HaObjectSelector;
selector.hass = hass;
selector.selector = selectorConfig;
selector.selector = config;
selector.value = value;
document.body.append(selector);
await selector.updateComplete;
@@ -66,8 +81,8 @@ const mountSelector = async (value: Record<string, string>[]) => {
const resolveFormDialog = async (
selector: HaObjectSelector,
action: "_addItem" | "_editItem",
result: Record<string, string> | null,
item?: Record<string, string>,
result: FormDialogData | null,
item?: FormDialogData,
index?: number
) => {
let params: FormDialogParams | undefined;
@@ -87,15 +102,14 @@ const resolveFormDialog = async (
);
});
const event = {
const event: ItemActionEvent = {
stopPropagation: vi.fn(),
currentTarget: { item, index },
};
const operation = (
getInternals(selector)[action] as (ev: typeof event) => Promise<void>
getInternals(selector)[action] as (event: ItemActionEvent) => Promise<void>
)(event);
await dialogShown;
await operation;
await Promise.all([dialogShown, operation]);
return params!;
};
@@ -105,6 +119,105 @@ afterEach(() => {
});
describe("ha-selector-object form dialog flow", () => {
it("initializes Add dialog data from the object field schema", async () => {
const selector = await mountSelector([], {
object: {
multiple: true,
fields: {
name: {
required: true,
selector: { text: {} },
},
states: {
required: true,
selector: {
text: {
multiple: true,
},
},
},
},
},
});
const params = await resolveFormDialog(selector, "_addItem", {
name: "A",
states: ["on"],
});
expect(params.data).toEqual({
name: "",
states: [],
});
});
it("leaves unsupported required fields unset in Add dialog data", async () => {
const selector = await mountSelector([], {
object: {
multiple: true,
fields: {
name: {
required: true,
selector: { text: {} },
},
tap_action: {
required: true,
selector: {
ui_action: {
default_action: "none",
},
},
},
},
},
});
const params = await resolveFormDialog(selector, "_addItem", {
name: "A",
tap_action: {
action: "none",
},
});
expect(params.data).toEqual({
name: "",
});
});
it("restores the initialized empty value after an add-delete round trip", async () => {
const selector = await mountSelector("", {
object: {
fields: {
name: {
selector: { text: {} },
},
},
},
});
const valueChanged = vi.fn();
selector.addEventListener("value-changed", valueChanged);
await resolveFormDialog(selector, "_addItem", { name: "A" });
expect(valueChanged.mock.calls[0][0].detail.value).toEqual({ name: "A" });
selector.value = valueChanged.mock.calls[0][0].detail.value;
await selector.updateComplete;
const event: ItemActionEvent = {
stopPropagation: vi.fn(),
currentTarget: {
index: 0,
},
};
(getInternals(selector)._deleteItem as (event: ItemActionEvent) => void)(
event
);
expect(valueChanged.mock.calls[1][0].detail.value).toBe("");
});
it("appends an item through the real Add flow", async () => {
const first = { name: "A" };
const selector = await mountSelector([first]);
-165
View File
@@ -1,165 +0,0 @@
import type { Connection } from "home-assistant-js-websocket";
import { beforeEach, describe, expect, it, vi } from "vitest";
// MapLibre hands tile URLs to a worker, which has no document to resolve a
// relative URL against. Measured: without absolute URLs nothing loads at all -
// not the TileJSON, not the glyphs, not a tile - and MapLibre reports no error,
// so this is worth pinning down.
const connectionWith = (...tokens: string[]) => {
let call = 0;
const listeners: Record<string, () => void> = {};
return {
sendMessagePromise: vi.fn(async () => ({
token: tokens[Math.min(call++, tokens.length - 1)],
})),
addEventListener: vi.fn((event: string, cb: () => void) => {
listeners[event] = cb;
}),
listeners,
} as unknown as Connection & { listeners: Record<string, () => void> };
};
const load = async () => {
vi.resetModules();
return import("../../src/data/map_tiles");
};
describe("withMapTilesToken", () => {
beforeEach(() => {
vi.resetModules();
});
it("makes relative URLs absolute", async () => {
const { withMapTilesToken } = await load();
expect(withMapTilesToken("/api/map_tiles/tilejson.json")).toBe(
`${location.origin}/api/map_tiles/tilejson.json`
);
});
it("adds the token to proxy URLs once there is one", async () => {
const { ensureMapTilesToken, withMapTilesToken } = await load();
await ensureMapTilesToken(connectionWith("abc123"));
const url = new URL(withMapTilesToken("/api/map_tiles/vector/1/0/0.mvt"));
expect(url.searchParams.get("token")).toBe("abc123");
expect(url.pathname).toBe("/api/map_tiles/vector/1/0/0.mvt");
});
it("leaves anything outside the proxy alone", async () => {
const { ensureMapTilesToken, withMapTilesToken } = await load();
await ensureMapTilesToken(connectionWith("abc123"));
const url = new URL(
withMapTilesToken("https://example.com/tiles/1/0/0.png")
);
expect(url.searchParams.get("token")).toBeNull();
expect(url.href).toBe("https://example.com/tiles/1/0/0.png");
});
it("still returns an absolute URL when there is no token", async () => {
const { withMapTilesToken } = await load();
expect(
withMapTilesToken("/api/map_tiles/fonts/noto_sans_regular/0-255.pbf")
).toBe(
`${location.origin}/api/map_tiles/fonts/noto_sans_regular/0-255.pbf`
);
});
});
describe("ensureMapTilesToken", () => {
beforeEach(() => {
vi.resetModules();
});
it("asks the backend once and reuses the answer", async () => {
const { ensureMapTilesToken } = await load();
const connection = connectionWith("abc123");
expect(await ensureMapTilesToken(connection)).toBe("abc123");
expect(await ensureMapTilesToken(connection)).toBe("abc123");
expect(connection.sendMessagePromise).toHaveBeenCalledTimes(1);
});
// A backend without the proxy must not hold the map hostage while the
// retries run; the map falls back to loading nothing rather than to nothing
// being drawn at all.
it("gives up quickly on a backend that does not answer", async () => {
const { ensureMapTilesToken } = await load();
const connection = {
sendMessagePromise: vi.fn(async () => {
throw new Error("unknown command");
}),
} as unknown as Connection;
const started = Date.now();
expect(await ensureMapTilesToken(connection)).toBeUndefined();
expect(Date.now() - started).toBeLessThan(3000);
});
it("tells subscribers when a token arrives, for Leaflet's baked template", async () => {
const { ensureMapTilesToken, subscribeMapTilesToken } = await load();
const listener = vi.fn();
subscribeMapTilesToken(listener);
await ensureMapTilesToken(connectionWith("abc123"));
expect(listener).toHaveBeenCalledWith("abc123");
});
});
// A token can expire before the refresh interval comes round, so recovery
// hangs on the reconnect and on retrying a refused request.
describe("recovering a stale token", () => {
beforeEach(() => {
vi.resetModules();
});
it("fetches a new token when the connection comes back", async () => {
const { ensureMapTilesToken, subscribeMapTilesToken } = await load();
const connection = connectionWith("first", "second") as Connection & {
listeners: Record<string, () => void>;
};
await ensureMapTilesToken(connection);
const listener = vi.fn();
subscribeMapTilesToken(listener);
connection.listeners.ready();
await vi.waitFor(() => expect(listener).toHaveBeenCalledWith("second"));
});
it("asks once for a refresh however many tiles were refused", async () => {
const { ensureMapTilesToken, refreshMapTilesToken } = await load();
const connection = connectionWith("first", "second");
await ensureMapTilesToken(connection);
expect(connection.sendMessagePromise).toHaveBeenCalledTimes(1);
await Promise.all([
refreshMapTilesToken(),
refreshMapTilesToken(),
refreshMapTilesToken(),
]);
expect(connection.sendMessagePromise).toHaveBeenCalledTimes(2);
});
it("keeps the old token when the refresh fails", async () => {
const { ensureMapTilesToken, refreshMapTilesToken, withMapTilesToken } =
await load();
const connection = connectionWith("first");
await ensureMapTilesToken(connection);
vi.mocked(connection.sendMessagePromise).mockRejectedValueOnce(
new Error("disconnected")
);
await refreshMapTilesToken();
expect(
new URL(
withMapTilesToken("/api/map_tiles/vector/1/0/0.mvt")
).searchParams.get("token")
).toBe("first");
});
});
-8
View File
@@ -100,7 +100,6 @@ const commandResults: Record<string, unknown> = {
},
"lovelace/info": { resource_mode: "storage" },
"lovelace/resources": [],
"map_tiles/access_token": { token: "map-tiles-token" },
"recorder/info": {
migration_in_progress: false,
migration_is_live: false,
@@ -178,13 +177,6 @@ export async function setupOnboardingMocks(
): Promise<OnboardingCalls> {
const calls: OnboardingCalls = { tokenRequests: [] };
// The location step shows a map, which asks core's tile proxy for tiles core
// is not running here. Answer them so the outcome does not depend on what the
// dev server does with an unknown /api path.
await page.route("**/api/map_tiles/**", (route) =>
route.fulfill({ status: 404, body: "" })
);
await page.route("**/api/onboarding**", async (route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
+2 -2
View File
@@ -101,7 +101,7 @@ test.describe("Home Assistant Demo", () => {
);
}, DEMO_THEME_STORAGE_KEY);
await loadDemo(page, "/#/profile/general");
await loadDemo(page, "/#/profile/dashboard");
const themeRow = page.locator("ha-pick-theme-row");
await expect(themeRow).toBeVisible({ timeout: PANEL_TIMEOUT });
@@ -121,7 +121,7 @@ test.describe("Home Assistant Demo", () => {
await expectStoredDemoTheme(page, { theme: "default", dark: true });
await expectDemoDarkMode(page, true);
await loadDemo(page, "/#/profile/general");
await loadDemo(page, "/#/profile/dashboard");
await expect(themeRow).toBeVisible({ timeout: PANEL_TIMEOUT });
await expect(
themeRow.locator('ha-radio-option[value="dark"]')
@@ -0,0 +1,295 @@
import { describe, expect, it } from "vitest";
import { createZHANetworkChartData } from "../../../../../../src/panels/config/integrations/integration-panels/zha/zha-network-data";
import type { ZHADevice } from "../../../../../../src/data/zha";
import type { HomeAssistant } from "../../../../../../src/types";
const hass = {
devices: {},
areas: {},
localize: () => "",
} as unknown as HomeAssistant;
const device = (partial: Partial<ZHADevice>): ZHADevice =>
({
available: true,
name: partial.ieee!,
lqi: 0,
rssi: "",
last_seen: "",
manufacturer: "",
model: "",
quirk_applied: false,
quirk_class: "",
entities: [],
manufacturer_code: 0,
device_reg_id: partial.ieee!,
active_coordinator: false,
signature: {},
routes: [],
neighbors: [],
...partial,
}) as ZHADevice;
describe("createZHANetworkChartData", () => {
it("links a router to its upstream router instead of a nearby child device when routing tables are empty", () => {
// Regression test: many Zigbee radios (e.g. TI CC2652 via zigpy-znp)
// never populate Mgmt_Rtg routes, so the chart has to fall back to
// picking a device's strongest RF neighbor. A router's own child end
// device (e.g. a plug sitting right next to it) commonly reports a
// stronger LQI than the router's real uplink, which must not be
// allowed to hide the backbone connection.
const coordinator = device({
ieee: "coordinator",
device_type: "Coordinator",
nwk: 0,
});
const upstreamRouter = device({
ieee: "upstream-router",
device_type: "Router",
nwk: 1,
neighbors: [
{
ieee: "coordinator",
nwk: "0x0000",
lqi: "200",
depth: "0",
relationship: "Parent",
},
],
});
const downstreamRouter = device({
ieee: "downstream-router",
device_type: "Router",
nwk: 2,
neighbors: [
// Real backbone link: weaker signal, correctly flagged as a sibling router
{
ieee: "upstream-router",
nwk: "0x0001",
lqi: "80",
depth: "1",
relationship: "Sibling",
},
// Nearby child end device with a much stronger signal than the real uplink
{
ieee: "child-end-device",
nwk: "0x0003",
lqi: "250",
depth: "2",
relationship: "Child",
},
],
});
const childEndDevice = device({
ieee: "child-end-device",
device_type: "EndDevice",
nwk: 3,
neighbors: [
{
ieee: "downstream-router",
nwk: "0x0002",
lqi: "250",
depth: "2",
relationship: "Parent",
},
],
});
const { links } = createZHANetworkChartData(
[coordinator, upstreamRouter, downstreamRouter, childEndDevice],
hass,
document.createElement("div")
);
const adjacency = new Map<string, string[]>();
for (const link of links) {
adjacency.set(link.source, [
...(adjacency.get(link.source) ?? []),
link.target,
]);
adjacency.set(link.target, [
...(adjacency.get(link.target) ?? []),
link.source,
]);
}
// Every device should be reachable from the coordinator. Before the
// fix, downstream-router picked its child as its only link, leaving
// it (and its children) as a disconnected island in the chart even
// though the real network is fully connected.
const reachable = new Set(["coordinator"]);
const queue = ["coordinator"];
while (queue.length) {
const current = queue.shift()!;
for (const neighbor of adjacency.get(current) ?? []) {
if (!reachable.has(neighbor)) {
reachable.add(neighbor);
queue.push(neighbor);
}
}
}
expect(reachable.has("upstream-router")).toBe(true);
expect(reachable.has("downstream-router")).toBe(true);
expect(reachable.has("child-end-device")).toBe(true);
});
it("ranks NoneOfTheAbove above Child, and treats unrecognized relationships as better than PreviousChild", () => {
const router = device({
ieee: "router",
device_type: "Router",
nwk: 1,
neighbors: [
{
ieee: "n-child",
nwk: "0x0001",
lqi: "100",
depth: "1",
relationship: "Child",
},
{
ieee: "n-none-of-the-above",
nwk: "0x0002",
lqi: "100",
depth: "1",
relationship: "NoneOfTheAbove",
},
],
});
const unknownRelationship = device({
ieee: "router-2",
device_type: "Router",
nwk: 2,
neighbors: [
{
ieee: "n-unrecognized",
nwk: "0x0003",
lqi: "100",
depth: "1",
relationship: "SomeFutureValue",
},
{
ieee: "n-previous-child",
nwk: "0x0004",
lqi: "100",
depth: "1",
relationship: "PreviousChild",
},
],
});
const { links } = createZHANetworkChartData(
[router, unknownRelationship],
hass,
document.createElement("div")
);
const otherEndOf = (ieee: string) => {
const link = links.find((l) => l.source === ieee || l.target === ieee);
return link?.source === ieee ? link.target : link?.source;
};
expect(otherEndOf("router")).toBe("n-none-of-the-above");
expect(otherEndOf("router-2")).toBe("n-unrecognized");
});
it("connects every device regardless of backend device order (child listed before its parent router)", () => {
// Regression test for a subtler variant of the same bug: the fallback
// link is only computed for a device if it doesn't already have a
// link. If the backend lists a child end device before its parent
// router, the child claims the link first, and the router - now
// appearing to "already have a link" - never gets to evaluate its own
// (better) neighbor choice, splitting the graph exactly as before but
// triggered by device order instead of by LQI.
const coordinator = device({
ieee: "coordinator",
device_type: "Coordinator",
nwk: 0,
});
const upstreamRouter = device({
ieee: "upstream-router",
device_type: "Router",
nwk: 1,
neighbors: [
{
ieee: "coordinator",
nwk: "0x0000",
lqi: "200",
depth: "0",
relationship: "Parent",
},
],
});
const downstreamRouter = device({
ieee: "downstream-router",
device_type: "Router",
nwk: 2,
neighbors: [
{
ieee: "upstream-router",
nwk: "0x0001",
lqi: "80",
depth: "1",
relationship: "Sibling",
},
{
ieee: "child-end-device",
nwk: "0x0003",
lqi: "250",
depth: "2",
relationship: "Child",
},
],
});
const childEndDevice = device({
ieee: "child-end-device",
device_type: "EndDevice",
nwk: 3,
neighbors: [
{
ieee: "downstream-router",
nwk: "0x0002",
lqi: "250",
depth: "2",
relationship: "Parent",
},
],
});
// Same topology as the first test, but the child is listed before its
// parent router this time.
const { links } = createZHANetworkChartData(
[coordinator, upstreamRouter, childEndDevice, downstreamRouter],
hass,
document.createElement("div")
);
const adjacency = new Map<string, string[]>();
for (const link of links) {
adjacency.set(link.source, [
...(adjacency.get(link.source) ?? []),
link.target,
]);
adjacency.set(link.target, [
...(adjacency.get(link.target) ?? []),
link.source,
]);
}
const reachable = new Set(["coordinator"]);
const queue = ["coordinator"];
while (queue.length) {
const current = queue.shift()!;
for (const neighbor of adjacency.get(current) ?? []) {
if (!reachable.has(neighbor)) {
reachable.add(neighbor);
queue.push(neighbor);
}
}
}
expect(reachable.has("upstream-router")).toBe(true);
expect(reachable.has("downstream-router")).toBe(true);
expect(reachable.has("child-end-device")).toBe(true);
});
});
@@ -0,0 +1,145 @@
import { describe, expect, it } from "vitest";
import { filterLowBatteryEntities } from "../../../../src/panels/maintenance/strategies/maintenance-view-strategy";
import { mockEntity } from "../../../common/entity/context/context-mock";
import { createMockEntityState, createMockHass } from "../../../fixtures/hass";
describe("filterLowBatteryEntities", () => {
it("filters numeric battery entities by the low battery threshold", () => {
const hass = createMockHass({
"sensor.low_battery": createMockEntityState("sensor.low_battery", "20", {
device_class: "battery",
}),
"sensor.ok_battery": createMockEntityState("sensor.ok_battery", "21", {
device_class: "battery",
}),
});
expect(
filterLowBatteryEntities(hass, [
"sensor.low_battery",
"sensor.ok_battery",
])
).toEqual(["sensor.low_battery"]);
});
it("excludes a low battery when its device is charging", () => {
const entities = {
"sensor.device_1_battery": mockEntity({
entity_id: "sensor.device_1_battery",
device_id: "device_1",
}),
"binary_sensor.device_1_battery_charging": mockEntity({
entity_id: "binary_sensor.device_1_battery_charging",
device_id: "device_1",
}),
"sensor.device_2_battery": mockEntity({
entity_id: "sensor.device_2_battery",
device_id: "device_2",
}),
"binary_sensor.device_2_battery_charging": mockEntity({
entity_id: "binary_sensor.device_2_battery_charging",
device_id: "device_2",
}),
};
const hass = createMockHass(
{
"sensor.device_1_battery": createMockEntityState(
"sensor.device_1_battery",
"10",
{ device_class: "battery" }
),
"binary_sensor.device_1_battery_charging": createMockEntityState(
"binary_sensor.device_1_battery_charging",
"on",
{ device_class: "battery_charging" }
),
"sensor.device_2_battery": createMockEntityState(
"sensor.device_2_battery",
"10",
{ device_class: "battery" }
),
"binary_sensor.device_2_battery_charging": createMockEntityState(
"binary_sensor.device_2_battery_charging",
"off",
{ device_class: "battery_charging" }
),
},
{ entities }
);
expect(
filterLowBatteryEntities(hass, [
"sensor.device_1_battery",
"sensor.device_2_battery",
])
).toEqual(["sensor.device_2_battery"]);
});
it("keeps binary battery sensor behavior unchanged", () => {
const hass = createMockHass({
"binary_sensor.low_battery": createMockEntityState(
"binary_sensor.low_battery",
"on",
{ device_class: "battery" }
),
"binary_sensor.ok_battery": createMockEntityState(
"binary_sensor.ok_battery",
"off",
{ device_class: "battery" }
),
});
expect(
filterLowBatteryEntities(hass, [
"binary_sensor.low_battery",
"binary_sensor.ok_battery",
])
).toEqual(["binary_sensor.low_battery"]);
});
it("updates the device lookup when the entity registry changes", () => {
const states = {
"sensor.battery": createMockEntityState("sensor.battery", "10", {
device_class: "battery",
}),
"binary_sensor.battery_charging": createMockEntityState(
"binary_sensor.battery_charging",
"on",
{ device_class: "battery_charging" }
),
};
const firstHass = createMockHass(states, {
entities: {
"sensor.battery": mockEntity({
entity_id: "sensor.battery",
device_id: "device_1",
}),
"binary_sensor.battery_charging": mockEntity({
entity_id: "binary_sensor.battery_charging",
device_id: "device_1",
}),
},
});
expect(filterLowBatteryEntities(firstHass, ["sensor.battery"])).toEqual([]);
const secondHass = createMockHass(states, {
entities: {
"sensor.battery": mockEntity({
entity_id: "sensor.battery",
device_id: "device_1",
}),
"binary_sensor.battery_charging": mockEntity({
entity_id: "binary_sensor.battery_charging",
device_id: "device_2",
}),
},
});
expect(filterLowBatteryEntities(secondHass, ["sensor.battery"])).toEqual([
"sensor.battery",
]);
});
});
+117 -103
View File
@@ -3488,10 +3488,10 @@ __metadata:
languageName: node
linkType: hard
"@lokalise/node-api@npm:16.3.0":
version: 16.3.0
resolution: "@lokalise/node-api@npm:16.3.0"
checksum: 10/9902033459cd3abb6dd0ad2846a4c464144caf890433456c2b966500201b6b692f751e9e23e925f968f9501f431ea6bbc07fdfb59f367fd6f3351d6bb7ae67bd
"@lokalise/node-api@npm:16.4.0":
version: 16.4.0
resolution: "@lokalise/node-api@npm:16.4.0"
checksum: 10/68bc43e4cdeec93b44ddbfc10999acda68938ef889c9f595a2849da7289e5a6b33e34f5c4d7234e12a730348eb3adff164145acfeba610785c6adc509b7e4cf4
languageName: node
linkType: hard
@@ -3502,6 +3502,13 @@ __metadata:
languageName: node
linkType: hard
"@mapbox/mapbox-gl-rtl-text@npm:0.4.0":
version: 0.4.0
resolution: "@mapbox/mapbox-gl-rtl-text@npm:0.4.0"
checksum: 10/a678240e4cc6f589726ef2f35fac64a7eb073451a8686c09db58041d3fb351d2e72100bb12607591efc298d8ac5e19691c593b3c213d121b2a0e84978254e1a8
languageName: node
linkType: hard
"@mapbox/point-geometry@npm:^1.1.0, @mapbox/point-geometry@npm:~1.1.0":
version: 1.1.0
resolution: "@mapbox/point-geometry@npm:1.1.0"
@@ -5951,105 +5958,105 @@ __metadata:
languageName: node
linkType: hard
"@typescript-eslint/eslint-plugin@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/eslint-plugin@npm:8.67.0"
"@typescript-eslint/eslint-plugin@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/eslint-plugin@npm:8.68.0"
dependencies:
"@eslint-community/regexpp": "npm:^4.12.2"
"@typescript-eslint/scope-manager": "npm:8.67.0"
"@typescript-eslint/type-utils": "npm:8.67.0"
"@typescript-eslint/utils": "npm:8.67.0"
"@typescript-eslint/visitor-keys": "npm:8.67.0"
"@typescript-eslint/scope-manager": "npm:8.68.0"
"@typescript-eslint/type-utils": "npm:8.68.0"
"@typescript-eslint/utils": "npm:8.68.0"
"@typescript-eslint/visitor-keys": "npm:8.68.0"
ignore: "npm:^7.0.5"
natural-compare: "npm:^1.4.0"
ts-api-utils: "npm:^2.5.0"
peerDependencies:
"@typescript-eslint/parser": ^8.67.0
"@typescript-eslint/parser": ^8.68.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/d53f9e51c6be98ff4cdcb94de24e2951f5e2b7a1dfc56c4f8a2d81e61346c511c0478117626d9cfd5d5d63f5e7a0e0653dc1abe1e7d926a3f9806aaf1fa11bd2
checksum: 10/8776fd8ffde8eceae3b1aeca57e528e8ee61a683223c3bef455ca3c5e28e52275e6cd6ebf8ee284a46883e6220bbf297312084e1ee85e00cef017a78c169a674
languageName: node
linkType: hard
"@typescript-eslint/parser@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/parser@npm:8.67.0"
"@typescript-eslint/parser@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/parser@npm:8.68.0"
dependencies:
"@typescript-eslint/scope-manager": "npm:8.67.0"
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/typescript-estree": "npm:8.67.0"
"@typescript-eslint/visitor-keys": "npm:8.67.0"
"@typescript-eslint/scope-manager": "npm:8.68.0"
"@typescript-eslint/types": "npm:8.68.0"
"@typescript-eslint/typescript-estree": "npm:8.68.0"
"@typescript-eslint/visitor-keys": "npm:8.68.0"
debug: "npm:^4.4.3"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/fa156ade066d0886ea0daa0f872940cdb450a2b5dbd1687e0df64ec19aac86711dde51e17e6b20e72387aa38daec811736821e9db950e8557affa7faea63d65c
checksum: 10/979ae0d2c6a391fd5b9b0218189fda5112ff0b5ab2538eef1da4cd77ac8ae46f9b590db9779f2614bd193105b40cf12635b565c840f4cdbbb4a683e24cf5ec4b
languageName: node
linkType: hard
"@typescript-eslint/project-service@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/project-service@npm:8.67.0"
"@typescript-eslint/project-service@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/project-service@npm:8.68.0"
dependencies:
"@typescript-eslint/tsconfig-utils": "npm:^8.67.0"
"@typescript-eslint/types": "npm:^8.67.0"
"@typescript-eslint/tsconfig-utils": "npm:^8.68.0"
"@typescript-eslint/types": "npm:^8.68.0"
debug: "npm:^4.4.3"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/3beccdee1e74060eac4e6c5e7edd14a800c8c98e3313dbfe5838a357e83babc22c41c3ebe637c331c4b93db2da50f5479ccf4677db7b6b1a760f3cd27795b36f
checksum: 10/37521df3731f9069c7df55362342dd0027f43947241de33c2a1c029b4553fe092e2ac69a534153aaf08c862e6b2390d864cb6c88a0714fc276395390df276fb6
languageName: node
linkType: hard
"@typescript-eslint/scope-manager@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/scope-manager@npm:8.67.0"
"@typescript-eslint/scope-manager@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/scope-manager@npm:8.68.0"
dependencies:
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/visitor-keys": "npm:8.67.0"
checksum: 10/a0ca8d52e6c5b2538f7df860acd5a36437cce8e6d5bcededdfde04c02489f4b523cb178b502c266d138cff6758ea375ebe311b2b6af373b127ded4876a697ffb
"@typescript-eslint/types": "npm:8.68.0"
"@typescript-eslint/visitor-keys": "npm:8.68.0"
checksum: 10/494d499315b4f3ab8eaa114aa53a4b2b9f533b82eb007cd5b5617cececfd8ad6938edb74f734479ae6e40a4086f510b6b984de8e66d06c518cb47817549db780
languageName: node
linkType: hard
"@typescript-eslint/tsconfig-utils@npm:8.67.0, @typescript-eslint/tsconfig-utils@npm:^8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/tsconfig-utils@npm:8.67.0"
"@typescript-eslint/tsconfig-utils@npm:8.68.0, @typescript-eslint/tsconfig-utils@npm:^8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/tsconfig-utils@npm:8.68.0"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/736d0ab8a273b4e4e17d412b390386eec0171cd3f7ba451bacd92c4f931051bba2049e040fdae84603ea0e07430afb05b49c6d8b6d429f9012d3c6b03eb69f54
checksum: 10/b527a7c5deaad40bb72f78eec1bfea5997a7e792e64666984605e9a167246cf856c8d40290a7b3d39cefb3a242538e22a3a4fc136132e4669a314887e2de5f08
languageName: node
linkType: hard
"@typescript-eslint/type-utils@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/type-utils@npm:8.67.0"
"@typescript-eslint/type-utils@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/type-utils@npm:8.68.0"
dependencies:
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/typescript-estree": "npm:8.67.0"
"@typescript-eslint/utils": "npm:8.67.0"
"@typescript-eslint/types": "npm:8.68.0"
"@typescript-eslint/typescript-estree": "npm:8.68.0"
"@typescript-eslint/utils": "npm:8.68.0"
debug: "npm:^4.4.3"
ts-api-utils: "npm:^2.5.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/a0b943e5a8f3c2e8490ec28599eb94561d7ad029fcf42f2e9a0e8d2685ce625898371cd0a7d3a906fe8b4077d72fee8329cad512a5b0521bc2167f7d88a984fb
checksum: 10/ed6b6fe74eee017a79c9dbfa72b3a08748dac8de5702314494d8849feee516c20e2907ad730fd82e50be81c5984c575b29e35eb8b5c99091445b4bc5102ce836
languageName: node
linkType: hard
"@typescript-eslint/types@npm:8.67.0, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/types@npm:8.67.0"
checksum: 10/8edc1a14a52c7566409c19b34fb72ba3a62a3b409c98b1d496de9fcd83179fb128c8df2fde391c199316a54cdc5d314ba70ce7b440161dd850b73a44f3e54e6d
"@typescript-eslint/types@npm:8.68.0, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/types@npm:8.68.0"
checksum: 10/f9337e0263d95e6edca6e364f9b1f70d5840b9648b5aa0d02065bcee5809a50c92d024ba17b458182ec4e7fffe5efd05584f1667b1e43ff3e7121b218283a207
languageName: node
linkType: hard
"@typescript-eslint/typescript-estree@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/typescript-estree@npm:8.67.0"
"@typescript-eslint/typescript-estree@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/typescript-estree@npm:8.68.0"
dependencies:
"@typescript-eslint/project-service": "npm:8.67.0"
"@typescript-eslint/tsconfig-utils": "npm:8.67.0"
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/visitor-keys": "npm:8.67.0"
"@typescript-eslint/project-service": "npm:8.68.0"
"@typescript-eslint/tsconfig-utils": "npm:8.68.0"
"@typescript-eslint/types": "npm:8.68.0"
"@typescript-eslint/visitor-keys": "npm:8.68.0"
debug: "npm:^4.4.3"
minimatch: "npm:^10.2.2"
semver: "npm:^7.7.3"
@@ -6057,32 +6064,32 @@ __metadata:
ts-api-utils: "npm:^2.5.0"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/31c5be812f67c997b9d1e9324c4289b6268a12e9948492403fdd9c9b5df12dcc00f845ae93ae37c78f717583519060175f2147f165b65112eef1e40acf44ad1d
checksum: 10/47d75cbc9c10842aeb3c1cc8d0625bd578b3b43b75d3dc86d3543a739b2fc8d0b524fd86b5af1f1493a3f0ae1b9e65c3200bf01a6bde0162efe6414708eac553
languageName: node
linkType: hard
"@typescript-eslint/utils@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/utils@npm:8.67.0"
"@typescript-eslint/utils@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/utils@npm:8.68.0"
dependencies:
"@eslint-community/eslint-utils": "npm:^4.9.1"
"@typescript-eslint/scope-manager": "npm:8.67.0"
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/typescript-estree": "npm:8.67.0"
"@typescript-eslint/scope-manager": "npm:8.68.0"
"@typescript-eslint/types": "npm:8.68.0"
"@typescript-eslint/typescript-estree": "npm:8.68.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/8703468cdea3630275c6faacc031e7e4bd7fb8f8988e80ab987811ae3c63a9a9677496cee2b4811f49cb9bc9e6f6c0bdb91cb213353019ce31df0a2b351cea51
checksum: 10/6581a1e7ced23cce82279df001d643844c97408bc84826da77ec77d52b77047004b202ff8cfcfbc0b0fef15bf2cafb5ae21bafbda24c9b58102ce634f66c2af9
languageName: node
linkType: hard
"@typescript-eslint/visitor-keys@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/visitor-keys@npm:8.67.0"
"@typescript-eslint/visitor-keys@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/visitor-keys@npm:8.68.0"
dependencies:
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/types": "npm:8.68.0"
eslint-visitor-keys: "npm:^5.0.0"
checksum: 10/c92c9b202105a8f398ab2ad4be58c8c8fd9af6b5ddd28cdcd57d75f7f04f69561a56a05aac9a008668c64faa7222ebb3fefe19097923a18eb2b9f7a90809a5be
checksum: 10/d802bc2c4569495f3d83f9bcf169e3a432ac1c6672e243399886409dbc55d7fa30375d9d0b2cf0d9466b81fc2b01b991483eb56e8cdb8141721009978d3b078b
languageName: node
linkType: hard
@@ -7750,13 +7757,20 @@ __metadata:
languageName: node
linkType: hard
"cli-spinners@npm:^2.5.0, cli-spinners@npm:^2.6.0":
"cli-spinners@npm:^2.5.0":
version: 2.9.2
resolution: "cli-spinners@npm:2.9.2"
checksum: 10/a0a863f442df35ed7294424f5491fa1756bd8d2e4ff0c8736531d886cec0ece4d85e8663b77a5afaf1d296e3cbbebff92e2e99f52bbea89b667cbe789b994794
languageName: node
linkType: hard
"cli-spinners@npm:^3.4.0":
version: 3.4.0
resolution: "cli-spinners@npm:3.4.0"
checksum: 10/6a4021c1999011fc34ae714f055dcdafb56309abc1f8fb021ea7d9370dfc524485fe8684226015e5fe6053dd30544e74270184ff7edc3fa4d37043b8efd0a054
languageName: node
linkType: hard
"clipboardy@npm:3.0.0":
version: 3.0.0
resolution: "clipboardy@npm:3.0.0"
@@ -8688,7 +8702,7 @@ __metadata:
languageName: node
linkType: hard
"enquirer@npm:^2.3.6":
"enquirer@npm:^2.4.1":
version: 2.4.1
resolution: "enquirer@npm:2.4.1"
dependencies:
@@ -9097,9 +9111,9 @@ __metadata:
languageName: node
linkType: hard
"eslint@npm:10.9.0":
version: 10.9.0
resolution: "eslint@npm:10.9.0"
"eslint@npm:10.9.1":
version: 10.9.1
resolution: "eslint@npm:10.9.1"
dependencies:
"@eslint-community/eslint-utils": "npm:^4.8.0"
"@eslint-community/regexpp": "npm:^4.12.2"
@@ -9138,7 +9152,7 @@ __metadata:
optional: true
bin:
eslint: bin/eslint.js
checksum: 10/1cd63bbf4f2c003ed759ab6ae9e2030be0abafdcd7b9ffb5be9e9c3aa3ea6142e6af0a337aac21044957750807fde06c9d052e2c9734cca6792dc2d095f9c180
checksum: 10/4cee237c7d2c445a05330e595b18521d7452fd3f419241bf1ccf81c769a6761c10ca5a06988197482d4842ed1a26b1dcb51b9526408990cee84559e6c08cb3ab
languageName: node
linkType: hard
@@ -9675,24 +9689,23 @@ __metadata:
languageName: node
linkType: hard
"generate-license-file@npm:4.2.1":
version: 4.2.1
resolution: "generate-license-file@npm:4.2.1"
"generate-license-file@npm:4.2.3":
version: 4.2.3
resolution: "generate-license-file@npm:4.2.3"
dependencies:
"@commander-js/extra-typings": "npm:^14.0.0"
"@npmcli/arborist": "npm:^9.0.0"
cli-spinners: "npm:^2.6.0"
cli-spinners: "npm:^3.4.0"
commander: "npm:^14.0.2"
cosmiconfig: "npm:^9.0.0"
enquirer: "npm:^2.3.6"
enquirer: "npm:^2.4.1"
glob: "npm:^13.0.0"
json5: "npm:^2.2.3"
ora: "npm:^5.4.1"
tslib: "npm:^2.3.0"
zod: "npm:^4.0.0"
bin:
generate-license-file: bin/generate-license-file
checksum: 10/03876abeba28efea39a0fbd4d048e2c0fe1f59477c14e3a5e9f7022235f9197a3f451b7f26124dc09da5cc63140376f53b4ee9167f238eee58241ab954e62efd
checksum: 10/bdfd899c04b247f75115d9710b53124b6f202d85d4cacf3e9d2d46260cc7d75e9f93ebf2204105ecb49b051c5acfac6c05d1a9711763b7daed073bffd986ac69
languageName: node
linkType: hard
@@ -10147,7 +10160,8 @@ __metadata:
"@lit/context": "npm:1.1.6"
"@lit/reactive-element": "npm:2.1.2"
"@lit/task": "npm:1.0.3"
"@lokalise/node-api": "npm:16.3.0"
"@lokalise/node-api": "npm:16.4.0"
"@mapbox/mapbox-gl-rtl-text": "npm:0.4.0"
"@maplibre/maplibre-gl-leaflet": "npm:0.1.4"
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch"
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
@@ -10206,7 +10220,7 @@ __metadata:
echarts: "npm:6.1.0"
echarts-extension-chart2music: "npm:0.1.1"
element-internals-polyfill: "npm:3.0.2"
eslint: "npm:10.9.0"
eslint: "npm:10.9.1"
eslint-config-prettier: "npm:10.1.8"
eslint-import-resolver-webpack: "npm:0.13.11"
eslint-plugin-import-x: "npm:4.17.1"
@@ -10217,7 +10231,7 @@ __metadata:
fancy-log: "npm:2.0.0"
fs-extra: "npm:11.4.0"
fuse.js: "npm:7.5.0"
generate-license-file: "npm:4.2.1"
generate-license-file: "npm:4.2.3"
glob: "npm:13.0.6"
globals: "npm:17.11.0"
gulp: "npm:5.0.1"
@@ -10229,7 +10243,7 @@ __metadata:
husky: "npm:9.1.7"
idb-keyval: "npm:6.3.0"
intl-messageformat: "npm:11.2.14"
js-yaml: "npm:5.3.0"
js-yaml: "npm:5.4.0"
jsdom: "npm:30.0.1"
jszip: "npm:3.10.1"
leaflet: "npm:1.9.4"
@@ -10246,7 +10260,7 @@ __metadata:
luxon: "npm:3.7.2"
map-stream: "npm:0.0.7"
maplibre-gl: "npm:5.24.0"
marked: "npm:18.0.10"
marked: "npm:18.0.11"
memoize-one: "npm:6.0.0"
minify-literals: "npm:2.2.0"
node-vibrant: "npm:4.0.4"
@@ -10269,7 +10283,7 @@ __metadata:
tinykeys: "patch:tinykeys@npm%3A4.0.0#~/.yarn/patches/tinykeys-npm-4.0.0-a6ca3fd771.patch"
ts-lit-plugin: "npm:2.0.2"
typescript: "npm:6.0.3"
typescript-eslint: "npm:8.67.0"
typescript-eslint: "npm:8.68.0"
vite-tsconfig-paths: "npm:6.1.1"
vitest: "npm:4.1.11"
webpack-stats-plugin: "npm:1.1.3"
@@ -11177,14 +11191,14 @@ __metadata:
languageName: node
linkType: hard
"js-yaml@npm:5.3.0":
version: 5.3.0
resolution: "js-yaml@npm:5.3.0"
"js-yaml@npm:5.4.0":
version: 5.4.0
resolution: "js-yaml@npm:5.4.0"
dependencies:
argparse: "npm:^2.0.1"
bin:
js-yaml: bin/js-yaml.mjs
checksum: 10/6c7e8ea8dd5643d9df73f4aa796f3572537284e9b819b74ef919467452bfa4624f8f7477cfc775c91db7e2f32387133b1b6c4e95aef9702be6bb042ae554884a
checksum: 10/d8216083212772b203a1ee16be2ea96a1638c6d7f0ff769ba4da274b5ad29e858bd26d96761feb8937fce1aa47e8acfa79b2cd21a8ced724cc5555c11b4d056c
languageName: node
linkType: hard
@@ -11944,12 +11958,12 @@ __metadata:
languageName: node
linkType: hard
"marked@npm:18.0.10":
version: 18.0.10
resolution: "marked@npm:18.0.10"
"marked@npm:18.0.11":
version: 18.0.11
resolution: "marked@npm:18.0.11"
bin:
marked: bin/marked.js
checksum: 10/0d4b560e0773fd6ba30a4e7560ba7ae1f05d47b23926ad8337d9f80c598dfdbffdf2f81e773a1789e8bc9d9c2c2d3d0c9143a54d32581c336a3bbb1bad80461c
checksum: 10/6da69f17820c7442358d751e902d3e0e06fef957e1178d634db938cf339c4504399c9f11a953b0a116b1e5bebc1c7fb52726777cc6c351f2c9f39e4a8276e21a
languageName: node
linkType: hard
@@ -15232,18 +15246,18 @@ __metadata:
languageName: node
linkType: hard
"typescript-eslint@npm:8.67.0":
version: 8.67.0
resolution: "typescript-eslint@npm:8.67.0"
"typescript-eslint@npm:8.68.0":
version: 8.68.0
resolution: "typescript-eslint@npm:8.68.0"
dependencies:
"@typescript-eslint/eslint-plugin": "npm:8.67.0"
"@typescript-eslint/parser": "npm:8.67.0"
"@typescript-eslint/typescript-estree": "npm:8.67.0"
"@typescript-eslint/utils": "npm:8.67.0"
"@typescript-eslint/eslint-plugin": "npm:8.68.0"
"@typescript-eslint/parser": "npm:8.68.0"
"@typescript-eslint/typescript-estree": "npm:8.68.0"
"@typescript-eslint/utils": "npm:8.68.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/4efc2cea5b067f65d58e010fda3888ec94f2e67ebfa289be78c3a02458787b7125cf8f6095776b912005fb69e8748ad4018777b16436a0658957c163ef213bf9
checksum: 10/451c64296ac5f48e4621a03324f5aaef4a26f1dd9d4df63bd3cb07f136e8379185a800731f83634eb5d909c696fad820ba74d31ab3d7cf263328504275d1305b
languageName: node
linkType: hard