mirror of
https://github.com/home-assistant/frontend.git
synced 2026-11-07 21:45:16 +00:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eebc57d89a | ||
|
|
63b474f43c | ||
|
|
d5cc973c22 | ||
|
|
00e76021ce | ||
|
|
fb88d66421 | ||
|
|
23b161d257 | ||
|
|
123a32dd81 | ||
|
|
4dc713abe6 | ||
|
|
b9182e3ea6 | ||
|
|
9bd57fa122 | ||
|
|
4af7503112 | ||
|
|
984e63dcd0 | ||
|
|
f90ed06080 | ||
|
|
b176b8226b | ||
|
|
3f3e9297f7 | ||
|
|
83028028e8 | ||
|
|
37d7c6144c | ||
|
|
f469f3eeae | ||
|
|
069e87c913 | ||
|
|
46bdbee8f3 | ||
|
|
68def911a6 | ||
|
|
a8fdb99317 | ||
|
|
3d1bf61683 | ||
|
|
5cfdff0e21 | ||
|
|
54cefeb816 | ||
|
|
4458205273 | ||
|
|
0c2de46f11 | ||
|
|
6bef900455 | ||
|
|
438bfa2710 | ||
|
|
96864e951a |
No files matched your search
@@ -10,6 +10,7 @@ Use this skill when editing CSS, layout, visual hierarchy, theme integration, re
|
||||
## Theme And Layout Basics
|
||||
|
||||
- Use Home Assistant CSS custom properties instead of hardcoded colors.
|
||||
- CSS in the static HTML templates under `src/html/` must give custom properties a fallback value (for example `var(--ha-space-4, 16px)`), because the global tokens only exist once the JS-appended global stylesheet from `append-ha-style` has loaded.
|
||||
- Use `--ha-space-*` spacing tokens instead of hardcoded spacing where possible.
|
||||
- Keep components mobile-first and enhance for desktop.
|
||||
- Keep layouts RTL-safe. Prefer logical properties when they fit.
|
||||
|
||||
@@ -16,6 +16,7 @@ runs:
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
packages/*/node_modules
|
||||
~/.local/share/pnpm/store
|
||||
key: >-
|
||||
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
@@ -35,6 +36,7 @@ runs:
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
packages/*/node_modules
|
||||
~/.local/share/pnpm/store
|
||||
key: >-
|
||||
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
|
||||
@@ -28,6 +28,7 @@ runs:
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
packages/*/node_modules
|
||||
~/.local/share/pnpm/store
|
||||
key: >-
|
||||
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* global module, require */
|
||||
// rspack/webpack loader that minifies the HTML, SVG, and CSS inside lit
|
||||
// tagged template literals using `minify-literals` (html-minifier-next +
|
||||
// lightningcss). Replaces the unmaintained babel-plugin-template-html-minifier.
|
||||
@@ -36,6 +35,14 @@ const initLoader = (browserslistEnv) => {
|
||||
return {
|
||||
minifyHTMLLiterals: minifyModule.minifyHTMLLiterals,
|
||||
lightningcssTargets,
|
||||
// With the legacy floor at Chrome 67, lightningcss rewrites rgba()
|
||||
// to 8-digit hex (#rrggbbaa). Some kiosk webviews served the legacy
|
||||
// build (e.g. Shelly Wall Display) can't parse that, so any
|
||||
// var(--divider-color) border falls back to currentColor. Always
|
||||
// compile hex alpha colors to rgba() in legacy builds.
|
||||
lightningcssInclude: browserslistEnv.startsWith("legacy")
|
||||
? lightningcssModule.Features.HexAlphaColors
|
||||
: 0,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -67,12 +74,13 @@ module.exports = function minifyTemplateLiteralsLoader(source, map, meta) {
|
||||
const { browserslistEnv } = this.getOptions();
|
||||
|
||||
initLoader(browserslistEnv)
|
||||
.then(({ minifyHTMLLiterals, lightningcssTargets }) =>
|
||||
.then(({ minifyHTMLLiterals, lightningcssTargets, lightningcssInclude }) =>
|
||||
minifyHTMLLiterals(source, {
|
||||
fileName: this.resourcePath,
|
||||
html: htmlOptions,
|
||||
css: {
|
||||
targets: lightningcssTargets,
|
||||
include: lightningcssInclude,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
@@ -68,8 +68,42 @@ const CONFIG_FILES = [
|
||||
path.join(__dirname, "lit-disable-dev-mode-loader.cjs"),
|
||||
path.join(__dirname, "babel-plugins", "custom-polyfill-plugin.js"),
|
||||
path.join(__dirname, "babel-plugins", "inline-constants-plugin.cjs"),
|
||||
// Its exports define the components source aliases below
|
||||
path.join(__dirname, "..", "packages", "components", "package.json"),
|
||||
];
|
||||
|
||||
// Resolve @home-assistant/components entry points to their sources, so the package
|
||||
// needs no build step during development. The entry points are read from the
|
||||
// "ha-source" export condition of the package; subpath patterns are expanded
|
||||
// for every element, i.e. every directory with a global.ts, like "card".
|
||||
const COMPONENTS_PACKAGE_DIR = path.join(
|
||||
paths.root_dir,
|
||||
"packages",
|
||||
"components"
|
||||
);
|
||||
const COMPONENTS_SRC_DIR = path.join(
|
||||
COMPONENTS_PACKAGE_DIR,
|
||||
"src",
|
||||
"components"
|
||||
);
|
||||
const componentElements = fs
|
||||
.readdirSync(COMPONENTS_SRC_DIR, { recursive: true })
|
||||
.filter((file) => path.basename(file) === "global.ts")
|
||||
.map((file) => path.dirname(file).split(path.sep).join("/"));
|
||||
const componentsSourceAliases = Object.fromEntries(
|
||||
Object.entries(require("../packages/components/package.json").exports)
|
||||
.filter(([, target]) => target["ha-source"])
|
||||
.flatMap(([subpath, target]) =>
|
||||
(subpath.includes("*") ? componentElements : [undefined]).map((name) => [
|
||||
`${path.posix.join("@home-assistant/components", name ? subpath.replaceAll("*", name) : subpath)}$`,
|
||||
path.join(
|
||||
COMPONENTS_PACKAGE_DIR,
|
||||
name ? target["ha-source"].replaceAll("*", name) : target["ha-source"]
|
||||
),
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
// Patches change a package's files but not its version, which is all the
|
||||
// node_modules snapshot checks, so their contents go into the version too.
|
||||
const PATCHES_DIR = path.join(paths.root_dir, "patches");
|
||||
@@ -244,6 +278,9 @@ const createRspackConfig = ({
|
||||
path: paths.root_dir,
|
||||
env: "legacy",
|
||||
}),
|
||||
// Keep rgba() instead of #rrggbbaa, see
|
||||
// minify-template-literals-loader.cjs
|
||||
include: { hexAlphaColors: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -428,6 +465,7 @@ const createRspackConfig = ({
|
||||
"src/resources/polyfills/bigdecimal.ts"
|
||||
),
|
||||
}),
|
||||
...componentsSourceAliases,
|
||||
"lit/static-html$": "lit/static-html.js",
|
||||
"lit/decorators$": "lit/decorators.js",
|
||||
"lit/directive$": "lit/directive.js",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Cache-Control: public, max-age: 0, s-maxage=3600, must-revalidate
|
||||
Cache-Control: public, max-age=0, s-maxage=3600, must-revalidate
|
||||
Content-Security-Policy: form-action https:
|
||||
Feature-Policy: vibrate 'none'; geolocation 'none'; midi 'none'; microphone 'none'; camera 'none'; magnetometer 'none'; gyroscope 'none'; speaker 'none'; vibrate 'none'; payment 'none'
|
||||
Referrer-Policy: no-referrer-when-downgrade
|
||||
@@ -8,13 +8,13 @@
|
||||
X-XSS-Protection: 1; mode=block
|
||||
|
||||
/images/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
|
||||
/manifest.json
|
||||
Cache-Control: public, max-age: 3600, s-maxage=3600
|
||||
Cache-Control: public, max-age=3600, s-maxage=3600
|
||||
|
||||
/frontend_es5/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
|
||||
/frontend_latest/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"background_color": "#FFFFFF",
|
||||
"description": "Show Home Assistant on your Chromecast or Google Assistant devices with a screen.",
|
||||
"description": "Show Home Assistant on your Chromecast or Google Home devices with a screen.",
|
||||
"dir": "ltr",
|
||||
"display": "standalone",
|
||||
"icons": [
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta property="og:type" content="website" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Show Home Assistant on your Chromecast or Google Assistant devices with a screen."
|
||||
content="Show Home Assistant on your Chromecast or Google Home devices with a screen."
|
||||
/>
|
||||
<meta
|
||||
property="og:image"
|
||||
@@ -16,7 +16,7 @@
|
||||
<meta name="twitter:title" content="Home Assistant Cast" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Show Home Assistant on your Chromecast or Google Assistant devices with a screen."
|
||||
content="Show Home Assistant on your Chromecast or Google Home devices with a screen."
|
||||
/>
|
||||
<meta
|
||||
name="twitter:image"
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
<p>
|
||||
Home Assistant Cast allows you to show your Home Assistant data on a
|
||||
Chromecast device and allows you to interact with Home Assistant on
|
||||
Google Assistant devices with a screen.
|
||||
Google Home devices with a screen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ const translateErr = (err) =>
|
||||
const INTRO = html`
|
||||
<p>
|
||||
Home Assistant Cast allows you to cast your Home Assistant installation to
|
||||
Chromecast video devices and to Google Assistant devices with a screen.
|
||||
Chromecast video devices and to Google Home devices with a screen.
|
||||
</p>
|
||||
<p>
|
||||
For more information, see the
|
||||
|
||||
@@ -6,7 +6,7 @@ const playerManager = castContext.getPlayerManager();
|
||||
|
||||
playerManager.setMessageInterceptor("LOAD", (loadRequestData) => {
|
||||
const media = loadRequestData.media;
|
||||
// Special handling if it came from Google Assistant
|
||||
// Special handling if it came from Google Home
|
||||
if (media.entity) {
|
||||
media.contentId = media.entity;
|
||||
media.streamType = "LIVE";
|
||||
|
||||
@@ -13,7 +13,7 @@ export const castDemoLovelace: () => LovelaceConfig = () => {
|
||||
{
|
||||
type: "markdown",
|
||||
title: "Home Assistant Cast",
|
||||
content: `With Home Assistant you can easily create interfaces (just like this one) which can be shown on Chromecast devices connected to TVs or Google Assistant devices with a screen.${
|
||||
content: `With Home Assistant you can easily create interfaces (just like this one) which can be shown on Chromecast devices connected to TVs or Google Home devices with a screen.${
|
||||
touchSupported
|
||||
? "\n\nYou are able to interact with this demo using the touch screen."
|
||||
: "\n\nOn a Google Nest Hub you are able to interact with Home Assistant Cast via the touch screen."
|
||||
|
||||
@@ -117,7 +117,7 @@ playerManager.setMessageInterceptor("LOAD", (loadRequestData) => {
|
||||
// We received a play media command, hide Lovelace and show media player
|
||||
showMediaPlayer();
|
||||
const media = loadRequestData.media;
|
||||
// Special handling if it came from Google Assistant
|
||||
// Special handling if it came from Google Home
|
||||
if (media.entity) {
|
||||
media.contentId = media.entity;
|
||||
media.streamType = "LIVE";
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
/*
|
||||
Cache-Control: public, max-age: 0, s-maxage=3600, must-revalidate
|
||||
Cache-Control: public, max-age=0, s-maxage=3600, must-revalidate
|
||||
Content-Security-Policy: form-action https:
|
||||
Referrer-Policy: no-referrer-when-downgrade
|
||||
X-Content-Type-Options: nosniff
|
||||
X-XSS-Protection: 1; mode=block
|
||||
|
||||
/api/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
|
||||
/assets/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
|
||||
/frontend_es5/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
|
||||
/frontend_latest/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
Access-Control-Allow-Origin: *
|
||||
|
||||
/embed.js
|
||||
|
||||
@@ -168,7 +168,8 @@ export class HaEmbedProvider extends LitElement {
|
||||
hass.mockWS("config/device_registry/list", (_msg, currentHass) =>
|
||||
Object.values(currentHass.devices)
|
||||
);
|
||||
// The registry of the entities, without settings such as calendar colors
|
||||
// The registry of the entities, with the options each entity sets, such
|
||||
// as timer presets. Other settings, such as calendar colors, are not set.
|
||||
const registryEntries = (currentHass: MockHomeAssistant) =>
|
||||
Object.values(currentHass.entities).map(
|
||||
(entity): EntityRegistryEntry => ({
|
||||
@@ -179,6 +180,9 @@ export class HaEmbedProvider extends LitElement {
|
||||
}),
|
||||
area_id: entity.area_id ?? null,
|
||||
device_id: entity.device_id ?? null,
|
||||
options:
|
||||
this.entities.find((item) => item.entity_id === entity.entity_id)
|
||||
?.options ?? null,
|
||||
})
|
||||
);
|
||||
hass.mockWS("config/entity_registry/list", (_msg, currentHass) =>
|
||||
|
||||
@@ -97,6 +97,14 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
|
||||
stat_consumption: "sensor.water_garden",
|
||||
},
|
||||
],
|
||||
device_consumption_gas: [
|
||||
{
|
||||
stat_consumption: "sensor.gas_kitchen",
|
||||
},
|
||||
{
|
||||
stat_consumption: "sensor.gas_heat_pump",
|
||||
},
|
||||
],
|
||||
}));
|
||||
hass.mockWS("energy/info", (): EnergyInfo => ({
|
||||
cost_sensors: {},
|
||||
@@ -106,6 +114,7 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
|
||||
energy_sources: Array.from({ length: 6 }, () => []),
|
||||
device_consumption: Array.from({ length: 6 }, () => []),
|
||||
device_consumption_water: Array.from({ length: 2 }, () => []),
|
||||
device_consumption_gas: Array.from({ length: 2 }, () => []),
|
||||
}));
|
||||
hass.mockWS(
|
||||
"energy/fossil_energy_consumption",
|
||||
|
||||
@@ -160,6 +160,26 @@ export const energyEntities = () =>
|
||||
unit_of_measurement: "L",
|
||||
},
|
||||
},
|
||||
"sensor.gas_kitchen": {
|
||||
entity_id: "sensor.gas_kitchen",
|
||||
state: "100",
|
||||
attributes: {
|
||||
friendly_name: "Gas kitchen",
|
||||
unit_of_measurement: "m³",
|
||||
device_class: "gas",
|
||||
state_class: "total_increasing",
|
||||
},
|
||||
},
|
||||
"sensor.gas_heat_pump": {
|
||||
entity_id: "sensor.gas_heat_pump",
|
||||
state: "500",
|
||||
attributes: {
|
||||
friendly_name: "Gas heat pump",
|
||||
unit_of_measurement: "m³",
|
||||
device_class: "gas",
|
||||
state_class: "total_increasing",
|
||||
},
|
||||
},
|
||||
"sensor.energy_car": {
|
||||
entity_id: "sensor.energy_car",
|
||||
state: "4",
|
||||
|
||||
@@ -158,6 +158,24 @@ const statisticsFunctions: Record<
|
||||
(bucketStart, bucketEnd) =>
|
||||
(20 * (bucketEnd.getTime() - bucketStart.getTime())) / 3600000
|
||||
),
|
||||
"sensor.gas_kitchen": (_id, start, end, period = "hour") =>
|
||||
generateSumStatistics(
|
||||
start,
|
||||
end,
|
||||
period,
|
||||
100,
|
||||
(bucketStart, bucketEnd) =>
|
||||
(0.1 * (bucketEnd.getTime() - bucketStart.getTime())) / 3600000
|
||||
),
|
||||
"sensor.gas_heat_pump": (_id, start, end, period = "hour") =>
|
||||
generateSumStatistics(
|
||||
start,
|
||||
end,
|
||||
period,
|
||||
500,
|
||||
(bucketStart, bucketEnd) =>
|
||||
(0.6 * (bucketEnd.getTime() - bucketStart.getTime())) / 3600000
|
||||
),
|
||||
"sensor.energy_consumption_tarif_1": (
|
||||
_id: string,
|
||||
start: Date,
|
||||
|
||||
@@ -301,6 +301,14 @@ export default tseslint.config(
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Published as native ESM, where package subpath imports such as
|
||||
// "lit/decorators.js" need their extension.
|
||||
files: ["packages/*/src/**/*.ts"],
|
||||
rules: {
|
||||
"import-x/extensions": ["error", "ignorePackages", { ts: "never" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
html,
|
||||
|
||||
@@ -4,7 +4,7 @@ title: "Our story"
|
||||
|
||||
## Open source home automation that puts local control and privacy first
|
||||
|
||||
Home Assistant is a free and open-source software for home automation that is designed to be the central control system for smart home devices with a focus on local control and privacy. It can be accessed via a web-based user interface, via apps for Android and iOS, or using voice commands via a supported virtual assistant like Google Assistant and Amazon Alexa.
|
||||
Home Assistant is a free and open-source software for home automation that is designed to be the central control system for smart home devices with a focus on local control and privacy. It can be accessed via a web-based user interface, via apps for Android and iOS, or using voice commands via a supported virtual assistant like Google Home and Amazon Alexa.
|
||||
|
||||
IoT devices and services are supported by modular support for controlling proprietary ecosystems if they provide public access via an Open API for third-party integrations and protocols like Bluetooth, MQTT, Zigbee, and Z-Wave, After the Home Assistant software application is installed as a computer appliance it will act as a central control system for home automation. Information from all entities it sees can be used and controlled from within scripts trigger automations using scheduling and "blueprint" subroutines, e.g. for controlling lighting, climate, entertainment systems, and appliances.
|
||||
|
||||
@@ -36,6 +36,6 @@ Durability for the Open Home means that devices are designed and built to keep w
|
||||
|
||||
The project was started as a Python application by Paulus Schoutsen in September 2013 and first published publicly on GitHub in November 2013. In July 2017, a managed operating system called Hass.io was initially introduced to make it easier use to use Home Assistant on single-board computers like the Raspberry Pi series. Its bundled "supervisor" management system allowed users to manage, backup, and update the local installation and introduced the option to extend the functionality of the software with add-ons.
|
||||
|
||||
An optional subscription service was introduced in December 2017 for $5/month to solve the complexities associated with secured remote access, as well as linking to Amazon Alexa and Google Assistant. Nabu Casa, Inc. was formed in September 2018 to take over the subscription service. The company's funding is based solely on revenue from the subscription service. It is used to finance the project's infrastructure and to pay for full-time employees contributing to the project.
|
||||
An optional subscription service was introduced in December 2017 for $5/month to solve the complexities associated with secured remote access, as well as linking to Amazon Alexa and Google Home. Nabu Casa, Inc. was formed in September 2018 to take over the subscription service. The company's funding is based solely on revenue from the subscription service. It is used to finance the project's infrastructure and to pay for full-time employees contributing to the project.
|
||||
|
||||
In January 2020, branding was adjusted to make it easier to refer to different parts of the project. The main piece of software was renamed to Home Assistant Core, while the full suite of software with the embedded operating system and bundled "supervisor" management system was renamed to Home Assistant.
|
||||
@@ -121,7 +121,7 @@ const brandEntries: ConfigEntryExtended[] = (
|
||||
["tuya", "Tuya", "setup_error"],
|
||||
["unifi", "UniFi Network", "not_loaded"],
|
||||
["met", "Meteorologisk institutt (Met.no)", "setup_error"],
|
||||
["google_assistant", "Google Assistant", "not_loaded"],
|
||||
["google_assistant", "Google Home", "not_loaded"],
|
||||
["homekit_controller", "HomeKit Device", "setup_retry"],
|
||||
] as [string, string, ConfigEntryExtended["state"]][]
|
||||
).map(([domain, name, entryState]) =>
|
||||
|
||||
+13
-12
@@ -13,7 +13,7 @@
|
||||
"lint:prettier": "prettier . --cache --check",
|
||||
"format:prettier": "prettier . --cache --write",
|
||||
"lint:types": "node ./node_modules/@typescript/native/bin/tsc && node ./node_modules/@typescript/native/bin/tsc -p .github/scripts",
|
||||
"lint:lit": "lit-analyzer \"{.,*}/src/**/*.ts\"",
|
||||
"lint:lit": "lit-analyzer \"{.,*,packages/*}/src/**/*.ts\"",
|
||||
"lint:element-imports": "eslint \"**/src/**/*.{js,ts,html}\" --ignore-pattern=.gitignore --rule '{\"ha/no-unused-element-import\": \"warn\"}'",
|
||||
"lint:licenses": "node --no-deprecation script/check-licenses",
|
||||
"lint": "pnpm run lint:eslint && pnpm run lint:prettier && pnpm run lint:types && pnpm run lint:lit",
|
||||
@@ -78,9 +78,9 @@
|
||||
"@mdi/svg": "7.4.47",
|
||||
"@replit/codemirror-indentation-markers": "6.5.3",
|
||||
"@swc/helpers": "0.5.23",
|
||||
"@tsparticles/engine": "4.5.0",
|
||||
"@tsparticles/preset-links": "4.5.0",
|
||||
"@versatiles/style": "8.0.0",
|
||||
"@tsparticles/engine": "4.5.1",
|
||||
"@tsparticles/preset-links": "4.5.1",
|
||||
"@versatiles/style": "8.1.1",
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@vvo/tzdb": "6.202.0",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
@@ -98,7 +98,7 @@
|
||||
"echarts": "6.1.0",
|
||||
"echarts-extension-chart2music": "0.1.1",
|
||||
"element-internals-polyfill": "3.0.2",
|
||||
"fullcalendar": "7.1.3",
|
||||
"fullcalendar": "7.1.4",
|
||||
"fuse.js": "7.5.0",
|
||||
"hls.js": "1.7.4",
|
||||
"home-assistant-js-websocket": "9.8.1",
|
||||
@@ -110,7 +110,7 @@
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"maplibre-gl": "6.16.0",
|
||||
"marked": "18.1.2",
|
||||
"marked": "18.1.3",
|
||||
"memoize-one": "6.0.0",
|
||||
"nanoid": "6.0.2",
|
||||
"node-vibrant": "4.0.4",
|
||||
@@ -123,7 +123,7 @@
|
||||
"sortablejs": "1.15.7",
|
||||
"stacktrace-js": "2.0.2",
|
||||
"superstruct": "2.0.2",
|
||||
"temporal-polyfill": "1.0.6",
|
||||
"temporal-polyfill": "1.0.7",
|
||||
"tinykeys": "4.0.1",
|
||||
"weekstart": "2.0.0",
|
||||
"workbox-cacheable-response": "7.4.1",
|
||||
@@ -144,16 +144,17 @@
|
||||
"@babel/plugin-transform-private-methods": "8.0.1",
|
||||
"@babel/plugin-transform-runtime": "8.0.6",
|
||||
"@babel/preset-env": "8.0.7",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.23.0",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.23.1",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@gfx/zopfli": "1.0.15",
|
||||
"@home-assistant/components": "workspace:*",
|
||||
"@html-eslint/eslint-plugin": "0.67.0",
|
||||
"@lokalise/node-api": "17.0.1",
|
||||
"@octokit/auth-oauth-device": "8.0.5",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.64.1",
|
||||
"@rsdoctor/rspack-plugin": "1.6.5",
|
||||
"@rspack/core": "2.3.0",
|
||||
"@rspack/core": "2.3.1",
|
||||
"@rspack/dev-server": "2.2.2",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "7.0.1",
|
||||
@@ -170,7 +171,7 @@
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
"@typescript/native": "npm:[email protected]",
|
||||
"@vitest/coverage-v8": "5.0.5",
|
||||
"@vitest/coverage-v8": "5.1.0",
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-polyfill-corejs3": "1.0.0",
|
||||
"browserslist": "4.29.5",
|
||||
@@ -217,12 +218,12 @@
|
||||
"typescript-eslint": "8.72.1",
|
||||
"vite": "8.3.5",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "5.0.5",
|
||||
"vitest": "5.1.0",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
"webpackbar": "7.0.0",
|
||||
"workbox-build": "7.4.1"
|
||||
},
|
||||
"packageManager": "[email protected]6.1",
|
||||
"packageManager": "[email protected]7.0",
|
||||
"volta": {
|
||||
"node": "24.22.2"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
# @home-assistant/components
|
||||
|
||||
Home Assistant Components (`hac-*`): framework-agnostic web components built
|
||||
with [Lit](https://lit.dev).
|
||||
|
||||
> Work in progress, not published yet.
|
||||
|
||||
## Registration
|
||||
|
||||
Every element is imported on its own, in one of two ways:
|
||||
|
||||
| Import | Registers in | For |
|
||||
| --------------------------------------------- | -------------------- | ----------------------------------------------------------- |
|
||||
| `@home-assistant/components/<element>` | Global registry | Home Assistant itself |
|
||||
| `@home-assistant/components/<element>/scoped` | Your scoped registry | Custom cards and other code that runs inside Home Assistant |
|
||||
|
||||
`<element>` is the tag name without the `hac-` prefix, like `card` for
|
||||
`hac-card`.
|
||||
|
||||
Both use the copy bundled with your code. A scoped copy is isolated from the
|
||||
one Home Assistant registers globally, so a custom card keeps the version it
|
||||
was built with.
|
||||
|
||||
Global registration skips tag names that are already registered, so the first
|
||||
registration wins and nothing throws.
|
||||
|
||||
### Global
|
||||
|
||||
```ts
|
||||
import "@home-assistant/components/card";
|
||||
```
|
||||
|
||||
### Scoped
|
||||
|
||||
Pass the component's elements to `ScopedElementsMixin` for Lit, or to
|
||||
`createScopedElementRegistry` for plain custom elements. Forgetting the
|
||||
elements is a TypeScript error, and a runtime error in plain JavaScript.
|
||||
|
||||
Lit:
|
||||
|
||||
```ts
|
||||
import { LitElement, html } from "lit";
|
||||
import { ScopedElementsMixin } from "@home-assistant/components/scoped";
|
||||
import { hacCard } from "@home-assistant/components/card/scoped";
|
||||
|
||||
class MyCard extends ScopedElementsMixin(LitElement, [hacCard]) {
|
||||
render() {
|
||||
return html`<hac-card>…</hac-card>`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Plain custom elements:
|
||||
|
||||
```js
|
||||
import {
|
||||
attachScopedShadow,
|
||||
createScopedElement,
|
||||
createScopedElementRegistry,
|
||||
} from "@home-assistant/components/scoped";
|
||||
import { hacCard } from "@home-assistant/components/card/scoped";
|
||||
|
||||
// Create the registry once, outside the class: all instances of the card share it
|
||||
const registry = createScopedElementRegistry([hacCard]);
|
||||
|
||||
class MyCard extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
const root = attachScopedShadow(this, { mode: "open" }, registry);
|
||||
root.innerHTML = "<hac-card>…</hac-card>";
|
||||
// or: root.append(createScopedElement(root, "hac-card"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`innerHTML`, `insertAdjacentHTML` and `createScopedElement` use the scoped
|
||||
registry. `document.createElement` uses the global one, so avoid it for
|
||||
scoped elements.
|
||||
|
||||
Scoped registries need native support (Chrome 146+, Safari 26) or
|
||||
`@webcomponents/scoped-custom-element-registry`, which Home Assistant loads.
|
||||
Without either, elements are registered globally instead, and an already
|
||||
registered tag name keeps its existing class.
|
||||
|
||||
Only elements rendered inside the shadow root use the scoped registry. Light
|
||||
DOM content, such as slotted elements, uses the registry of the tree it is
|
||||
written in.
|
||||
|
||||
`lit` is a peer dependency.
|
||||
|
||||
### TypeScript
|
||||
|
||||
Each element's entry points declare its tag in `HTMLElementTagNameMap`, so
|
||||
`document.querySelector("hac-card")` and templates are typed once the element
|
||||
is imported. For the class itself, import its type from the `scoped` entry
|
||||
point; `import type` loads nothing at runtime:
|
||||
|
||||
```ts
|
||||
import type { HacCard } from "@home-assistant/components/card/scoped";
|
||||
```
|
||||
|
||||
The package has no root entry point: each element and the scoped helpers
|
||||
(`@home-assistant/components/scoped`) have their own.
|
||||
|
||||
## Theming
|
||||
|
||||
Each component exposes its own `--hac-*` custom properties, documented in its
|
||||
source, which take precedence. Next come the theme variables of the existing
|
||||
Home Assistant element it replaces, so current themes keep working: `hac-card`
|
||||
reads the variables of `ha-card` (`--ha-card-background`,
|
||||
`--card-background-color`, `--ha-card-border-radius`, …). Last come the
|
||||
semantic color tokens (`--ha-color-*`, like `--ha-color-surface-default`) and
|
||||
their light values, so the components also render outside Home Assistant.
|
||||
|
||||
The legacy theme variables are a temporary bridge for this experiment, not the
|
||||
target design. Home Assistant always defines some of them, like
|
||||
`--card-background-color`, so inside Home Assistant they currently win over
|
||||
the semantic tokens. Once themes move to the semantic tokens, the legacy
|
||||
variables will be removed from the fallbacks.
|
||||
|
||||
## Structure
|
||||
|
||||
Each element has its own directory, named like its import path:
|
||||
|
||||
```
|
||||
src/components/card/ <hac-card> -> @home-assistant/components/card
|
||||
hac-card.ts class, does not register itself
|
||||
info.ts tag name and version
|
||||
global.ts registers globally
|
||||
scoped.ts element for a scoped registry -> …/card/scoped
|
||||
```
|
||||
|
||||
Shared code lives in `src/utils/`: `registration/` for defining elements
|
||||
(global and scoped) and `element/` for helpers that components use
|
||||
themselves, like slot checks.
|
||||
|
||||
Every element has its own version in `info.ts`.
|
||||
|
||||
Entry points are mapped with subpath patterns in `package.json`, so a new
|
||||
element directory needs no `package.json` change. Only `global.ts` files have
|
||||
side effects, which `sideEffects` relies on for tree shaking.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm --filter @home-assistant/components build # ESM, type declarations and custom-elements.json to dist/
|
||||
pnpm --filter @home-assistant/components dev # rebuild on change
|
||||
```
|
||||
|
||||
Inside this repository the app uses the sources directly through
|
||||
the `ha-source` export condition, so no build is needed during development.
|
||||
The package is built with [Rslib](https://rslib.rs) in bundleless mode: every
|
||||
source file becomes its own ES module. The build then generates a
|
||||
[custom elements manifest](https://custom-elements-manifest.open-wc.org) from
|
||||
the JSDoc of each element, so document new slots, parts, CSS properties and
|
||||
events there.
|
||||
@@ -0,0 +1,84 @@
|
||||
// Generates dist/custom-elements.json from the JSDoc of the hac elements, for
|
||||
// editors and documentation tools. Only hac-*.ts files declare elements; the
|
||||
// entry points just re-export them.
|
||||
|
||||
const forEachDeclaration = (customElementsManifest, callback) => {
|
||||
for (const module of customElementsManifest.modules) {
|
||||
for (const declaration of module.declarations ?? []) {
|
||||
callback(declaration);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// The analyzer leaves the readme empty. Paths in the manifest are relative to
|
||||
// the package root, like the module paths.
|
||||
const packageReadme = () => ({
|
||||
name: "package-readme",
|
||||
packageLinkPhase({ customElementsManifest }) {
|
||||
customElementsManifest.readme = "README.md";
|
||||
},
|
||||
});
|
||||
|
||||
// Elements are documented with @summary, which the analyzer only puts into
|
||||
// `summary`. Most tools show `description`, so fall back to the summary.
|
||||
const summaryAsDescription = () => ({
|
||||
name: "summary-as-description",
|
||||
packageLinkPhase({ customElementsManifest }) {
|
||||
forEachDeclaration(customElementsManifest, (declaration) => {
|
||||
if (!declaration.description && declaration.summary) {
|
||||
declaration.description = declaration.summary;
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Private and protected members are internal, so keep them out of the
|
||||
// documented API.
|
||||
const publicApiOnly = () => ({
|
||||
name: "public-api-only",
|
||||
packageLinkPhase({ customElementsManifest }) {
|
||||
forEachDeclaration(customElementsManifest, (declaration) => {
|
||||
declaration.members = declaration.members?.filter(
|
||||
(member) => !member.privacy || member.privacy === "public"
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const distPaths = () => {
|
||||
const toDist = (value) => value.replace(/^src\/(.+)\.ts$/, "dist/$1.js");
|
||||
const rewrite = (node) => {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(rewrite);
|
||||
return;
|
||||
}
|
||||
if (!node || typeof node !== "object") {
|
||||
return;
|
||||
}
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if ((key === "path" || key === "module") && typeof value === "string") {
|
||||
node[key] = toDist(value);
|
||||
} else {
|
||||
rewrite(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
return {
|
||||
name: "dist-paths",
|
||||
packageLinkPhase({ customElementsManifest }) {
|
||||
rewrite(customElementsManifest.modules);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default {
|
||||
globs: ["src/components/**/hac-*.ts"],
|
||||
outdir: "dist",
|
||||
litelement: true,
|
||||
plugins: [
|
||||
packageReadme(),
|
||||
summaryAsDescription(),
|
||||
publicApiOnly(),
|
||||
distPaths(),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "@home-assistant/components",
|
||||
"version": "0.0.0",
|
||||
"description": "Home Assistant web components (hac-* elements) built with Lit",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/home-assistant/frontend.git",
|
||||
"directory": "packages/components"
|
||||
},
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
"./dist/components/**/global.js",
|
||||
"./src/components/**/global.ts"
|
||||
],
|
||||
"customElements": "dist/custom-elements.json",
|
||||
"exports": {
|
||||
"./scoped": {
|
||||
"ha-source": "./src/scoped.ts",
|
||||
"types": "./dist/scoped.d.ts",
|
||||
"import": "./dist/scoped.js"
|
||||
},
|
||||
"./package.json": "./package.json",
|
||||
"./*/scoped": {
|
||||
"ha-source": "./src/components/*/scoped.ts",
|
||||
"types": "./dist/components/*/scoped.d.ts",
|
||||
"import": "./dist/components/*/scoped.js"
|
||||
},
|
||||
"./*/info": {
|
||||
"ha-source": "./src/components/*/info.ts",
|
||||
"types": "./dist/components/*/info.d.ts",
|
||||
"import": "./dist/components/*/info.js"
|
||||
},
|
||||
"./*": {
|
||||
"ha-source": "./src/components/*/global.ts",
|
||||
"types": "./dist/components/*/global.d.ts",
|
||||
"import": "./dist/components/*/global.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "rslib build && cem analyze",
|
||||
"dev": "rslib build --watch",
|
||||
"prepublishOnly": "pnpm run build"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"lit": "^3.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@custom-elements-manifest/analyzer": "0.11.0",
|
||||
"@rslib/core": "1.0.5",
|
||||
"lit": "3.3.3",
|
||||
"typescript": "6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from "@rslib/core";
|
||||
|
||||
// Bundleless build: every source file becomes its own ESM file, so consumers'
|
||||
// bundlers only include the entry points they import. Only global.ts files
|
||||
// register elements; everything else is free of side effects.
|
||||
export default defineConfig({
|
||||
source: {
|
||||
entry: { index: ["./src/**/*.ts"] },
|
||||
decorators: { version: "legacy" },
|
||||
tsconfigPath: "./tsconfig.json",
|
||||
},
|
||||
tools: {
|
||||
swc: { jsc: { transform: { decoratorMetadata: false } } },
|
||||
},
|
||||
lib: [
|
||||
{
|
||||
format: "esm",
|
||||
bundle: false,
|
||||
syntax: "es2021",
|
||||
dts: true,
|
||||
output: { target: "web", distPath: { root: "./dist" } },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { define } from "../../utils/registration/define";
|
||||
import { HacCard } from "./hac-card";
|
||||
import { TAG_NAME } from "./info";
|
||||
|
||||
// Registers <hac-card> in the global registry, as used by Home Assistant
|
||||
define(TAG_NAME, HacCard);
|
||||
|
||||
export { HacCard };
|
||||
@@ -0,0 +1,165 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import {
|
||||
hasAssignedContent,
|
||||
hasAssignedElements,
|
||||
} from "../../utils/element/slot";
|
||||
|
||||
/**
|
||||
* @element hac-card
|
||||
*
|
||||
* @summary
|
||||
* Card that groups related content, with an optional header and footer.
|
||||
*
|
||||
* @slot header - Header of the card. Its spacing replaces the top padding of
|
||||
* the content.
|
||||
* @slot - Content of the card. Its padding is omitted when empty.
|
||||
* @slot footer - Footer of the card. Hidden when empty.
|
||||
*
|
||||
* @csspart header - Wrapper of the header slot.
|
||||
* @csspart content - Wrapper of the default slot.
|
||||
* @csspart footer - Wrapper of the footer slot.
|
||||
*
|
||||
* @cssprop --hac-card-background - Background of the card.
|
||||
* @cssprop --hac-card-color - Text color of the card.
|
||||
* @cssprop --hac-card-border-color - Border color of the card and the footer divider.
|
||||
* @cssprop --hac-card-border-width - Border width of the card.
|
||||
* @cssprop --hac-card-border-radius - Border radius of the card.
|
||||
* @cssprop --hac-card-box-shadow - Box shadow of the card.
|
||||
* @cssprop --hac-card-content-padding - Padding of the content.
|
||||
* @cssprop --hac-card-footer-padding - Padding of the footer.
|
||||
*/
|
||||
export class HacCard extends LitElement {
|
||||
/**
|
||||
* Visual style of the card. `outlined` draws a border without a shadow.
|
||||
* `raised` hides the border and lifts the card with a shadow instead.
|
||||
*/
|
||||
@property({ reflect: true }) public appearance: "outlined" | "raised" =
|
||||
"outlined";
|
||||
|
||||
@state() private _hasHeader = false;
|
||||
|
||||
@state() private _hasFooter = false;
|
||||
|
||||
@state() private _hasContent = false;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<div class="header" part="header" ?hidden=${!this._hasHeader}>
|
||||
<slot name="header" @slotchange=${this._headerChanged}></slot>
|
||||
</div>
|
||||
<div
|
||||
class="content ${this._hasHeader ? "below-header" : ""}"
|
||||
part="content"
|
||||
?hidden=${!this._hasContent}
|
||||
>
|
||||
<slot @slotchange=${this._contentChanged}></slot>
|
||||
</div>
|
||||
<div class="footer" part="footer" ?hidden=${!this._hasFooter}>
|
||||
<slot name="footer" @slotchange=${this._footerChanged}></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _headerChanged(ev: Event) {
|
||||
this._hasHeader = hasAssignedElements(ev);
|
||||
}
|
||||
|
||||
private _contentChanged(ev: Event) {
|
||||
this._hasContent = hasAssignedContent(ev);
|
||||
}
|
||||
|
||||
private _footerChanged(ev: Event) {
|
||||
this._hasFooter = hasAssignedElements(ev);
|
||||
}
|
||||
|
||||
/*
|
||||
* Fallback order: --hac-card-*, then the theme variables of ha-card
|
||||
* (--ha-card-*, --card-background-color, --divider-color,
|
||||
* --primary-text-color) so existing themes keep working, then the semantic
|
||||
* tokens and their light values for use outside Home Assistant.
|
||||
* The ha-card variables are temporary, see "Theming" in the README.
|
||||
*/
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
background: var(
|
||||
--hac-card-background,
|
||||
var(
|
||||
--ha-card-background,
|
||||
var(--card-background-color, var(--ha-color-surface-default, #fff))
|
||||
)
|
||||
);
|
||||
backdrop-filter: var(--ha-card-backdrop-filter, none);
|
||||
color: var(
|
||||
--hac-card-color,
|
||||
var(--primary-text-color, var(--ha-color-on-surface-default, #141414))
|
||||
);
|
||||
border: var(--hac-card-border-width, var(--ha-card-border-width, 1px))
|
||||
solid
|
||||
var(
|
||||
--hac-card-border-color,
|
||||
var(
|
||||
--ha-card-border-color,
|
||||
var(--divider-color, var(--ha-color-border-neutral-quiet, #e6e6e6))
|
||||
)
|
||||
);
|
||||
border-radius: var(
|
||||
--hac-card-border-radius,
|
||||
var(--ha-card-border-radius, var(--ha-border-radius-lg, 12px))
|
||||
);
|
||||
box-shadow: var(--hac-card-box-shadow, var(--ha-card-box-shadow, none));
|
||||
}
|
||||
|
||||
:host([appearance="raised"]) {
|
||||
border-color: transparent;
|
||||
box-shadow: var(
|
||||
--hac-card-box-shadow,
|
||||
var(
|
||||
--ha-card-box-shadow,
|
||||
var(
|
||||
--ha-box-shadow-s,
|
||||
0 1px 2px 0 rgba(0, 0, 0, 0.08),
|
||||
0 1px 3px 0 rgba(0, 0, 0, 0.12)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: var(--hac-card-content-padding, var(--ha-space-4, 16px));
|
||||
}
|
||||
|
||||
/* The header already provides the spacing above the content */
|
||||
.content.below-header {
|
||||
padding-block-start: 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2, 8px);
|
||||
padding: var(--hac-card-footer-padding, var(--ha-space-2, 8px));
|
||||
border-block-start: 1px solid
|
||||
var(
|
||||
--hac-card-border-color,
|
||||
var(--divider-color, var(--ha-color-border-neutral-quiet, #e6e6e6))
|
||||
);
|
||||
}
|
||||
|
||||
.header[hidden],
|
||||
.content[hidden],
|
||||
.footer[hidden] {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hac-card": HacCard;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Tag name of the element. */
|
||||
export const TAG_NAME = "hac-card";
|
||||
|
||||
/** Version of <hac-card>. */
|
||||
export const VERSION = "0.1.0";
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ElementDefinition } from "../../utils/registration/definition";
|
||||
import { HacCard } from "./hac-card";
|
||||
import { TAG_NAME } from "./info";
|
||||
|
||||
export { HacCard };
|
||||
|
||||
/**
|
||||
* The bundled <hac-card> for a scoped registry:
|
||||
* `ScopedElementsMixin(LitElement, [hacCard])` or
|
||||
* `createScopedElementRegistry([hacCard])`.
|
||||
*/
|
||||
export const hacCard: ElementDefinition<typeof HacCard> = {
|
||||
tagName: TAG_NAME,
|
||||
element: HacCard,
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { LitElement } from "lit";
|
||||
import { define } from "./utils/registration/define";
|
||||
import type { ElementDefinition } from "./utils/registration/definition";
|
||||
import {
|
||||
createScopedRegistry,
|
||||
scopedCreationScope,
|
||||
scopedRegistrySupport,
|
||||
scopedShadowRootInit,
|
||||
} from "./utils/registration/scoped-registry";
|
||||
|
||||
export type { ElementDefinition } from "./utils/registration/definition";
|
||||
|
||||
/**
|
||||
* Creates a registry with the given elements, for `attachScopedShadow`:
|
||||
* definitions from `scoped` entry points, e.g. `[hacCard]`.
|
||||
* Create it once, outside the class, so all instances of a card share it.
|
||||
*
|
||||
* Without scoped registry support (native or polyfill), the elements are
|
||||
* registered in the global registry instead, unless their tag name is already
|
||||
* taken there, and the global registry is returned.
|
||||
*/
|
||||
export const createScopedElementRegistry = (
|
||||
elements: readonly ElementDefinition[]
|
||||
): CustomElementRegistry => {
|
||||
// Checked as unknown: Array.isArray would narrow the list to any[]
|
||||
if (!Array.isArray(elements as unknown)) {
|
||||
throw new TypeError(
|
||||
"@home-assistant/components: pass the elements to register, e.g. [hacCard]"
|
||||
);
|
||||
}
|
||||
const registry = createScopedRegistry() ?? customElements;
|
||||
for (const { tagName, element } of elements) {
|
||||
define(tagName, element, registry);
|
||||
}
|
||||
return registry;
|
||||
};
|
||||
|
||||
const shadowRootInit = (
|
||||
init: ShadowRootInit,
|
||||
registry: CustomElementRegistry
|
||||
): ShadowRootInit =>
|
||||
registry === customElements ? init : scopedShadowRootInit(init, registry);
|
||||
|
||||
const registries = new WeakMap<ShadowRoot, CustomElementRegistry>();
|
||||
|
||||
/**
|
||||
* Attaches a shadow root that uses `registry` for custom elements in it.
|
||||
*
|
||||
* Content added with `innerHTML` or `insertAdjacentHTML` uses the registry.
|
||||
* Create single elements with `createScopedElement`; `document.createElement`
|
||||
* would use the global registry.
|
||||
*
|
||||
* Light DOM content, like slotted elements, keeps using the registry of the
|
||||
* tree it is written in.
|
||||
*/
|
||||
export const attachScopedShadow = (
|
||||
host: HTMLElement,
|
||||
init: ShadowRootInit,
|
||||
registry: CustomElementRegistry
|
||||
): ShadowRoot => {
|
||||
const root = host.attachShadow(shadowRootInit(init, registry));
|
||||
registries.set(root, registry);
|
||||
return root;
|
||||
};
|
||||
|
||||
/** Creates an element from the registry of a shadow root. */
|
||||
export const createScopedElement = <K extends keyof HTMLElementTagNameMap>(
|
||||
root: ShadowRoot,
|
||||
tagName: K
|
||||
): HTMLElementTagNameMap[K] => {
|
||||
const registry = registries.get(root);
|
||||
if (!registry || registry === customElements) {
|
||||
return document.createElement(tagName);
|
||||
}
|
||||
if (scopedRegistrySupport() === "polyfill") {
|
||||
// The polyfill adds createElement to shadow roots
|
||||
return (
|
||||
root as unknown as {
|
||||
createElement(tagName: K): HTMLElementTagNameMap[K];
|
||||
}
|
||||
).createElement(tagName);
|
||||
}
|
||||
return (
|
||||
document.createElement as (
|
||||
tagName: K,
|
||||
options: { customElementRegistry: CustomElementRegistry }
|
||||
) => HTMLElementTagNameMap[K]
|
||||
)(tagName, { customElementRegistry: registry });
|
||||
};
|
||||
|
||||
type LitElementConstructor = abstract new (...args: any[]) => LitElement;
|
||||
|
||||
/**
|
||||
* Renders a Lit element into a shadow root with its own custom element
|
||||
* registry, containing `elements`:
|
||||
*
|
||||
* ```ts
|
||||
* class MyCard extends ScopedElementsMixin(LitElement, [hacCard]) {}
|
||||
* ```
|
||||
*
|
||||
* See `createScopedElementRegistry` and `attachScopedShadow` for the details.
|
||||
*/
|
||||
export const ScopedElementsMixin = <T extends LitElementConstructor>(
|
||||
superClass: T,
|
||||
elements: readonly ElementDefinition[]
|
||||
) => {
|
||||
// Created on first use, so defining the class has no side effects
|
||||
let registry: CustomElementRegistry | undefined;
|
||||
const getRegistry = () => {
|
||||
registry ??= createScopedElementRegistry(elements);
|
||||
return registry;
|
||||
};
|
||||
|
||||
abstract class ScopedElementsHost extends superClass {
|
||||
public attachShadow(init: ShadowRootInit): ShadowRoot {
|
||||
const root = super.attachShadow(shadowRootInit(init, getRegistry()));
|
||||
registries.set(root, getRegistry());
|
||||
return root;
|
||||
}
|
||||
|
||||
protected createRenderRoot() {
|
||||
const root = super.createRenderRoot();
|
||||
const scoped = getRegistry();
|
||||
if (scoped !== customElements && root instanceof ShadowRoot) {
|
||||
this.renderOptions.creationScope = scopedCreationScope(root, scoped);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
}
|
||||
// Typed as the base class: the overrides add no public API
|
||||
return ScopedElementsHost as unknown as T;
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Whether the slot of a `slotchange` event has elements assigned. */
|
||||
export const hasAssignedElements = (ev: Event): boolean =>
|
||||
(ev.target as HTMLSlotElement).assignedElements().length > 0;
|
||||
|
||||
/**
|
||||
* Whether the slot of a `slotchange` event has content assigned: elements or
|
||||
* text. Whitespace between tags is not content.
|
||||
*/
|
||||
export const hasAssignedContent = (ev: Event): boolean =>
|
||||
(ev.target as HTMLSlotElement)
|
||||
.assignedNodes()
|
||||
.some(
|
||||
(node) =>
|
||||
node.nodeType === Node.ELEMENT_NODE || Boolean(node.textContent?.trim())
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Registers a custom element unless the tag name is already taken in the
|
||||
* registry.
|
||||
*
|
||||
* Home Assistant and custom cards can each ship a copy of this library, so
|
||||
* registering the same tag name twice must not throw. The first registration
|
||||
* wins.
|
||||
*/
|
||||
export const define = (
|
||||
tagName: string,
|
||||
elementClass: CustomElementConstructor,
|
||||
registry: CustomElementRegistry = customElements
|
||||
): void => {
|
||||
if (!registry.get(tagName)) {
|
||||
registry.define(tagName, elementClass);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* An element to register: its tag name and its class. The `scoped` entry
|
||||
* point of an element exports one, e.g. `hacCard`.
|
||||
*/
|
||||
export interface ElementDefinition<
|
||||
E extends CustomElementConstructor = CustomElementConstructor,
|
||||
> {
|
||||
readonly tagName: string;
|
||||
readonly element: E;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Scoped custom element registries come in two flavors: the standard API
|
||||
* (Chrome 146+, Safari 26) and the older API of
|
||||
* `@webcomponents/scoped-custom-element-registry`, which Home Assistant loads
|
||||
* and which replaces the native implementation when present.
|
||||
*/
|
||||
export type ScopedRegistrySupport = "polyfill" | "native" | "none";
|
||||
|
||||
let support: ScopedRegistrySupport | undefined;
|
||||
|
||||
export const scopedRegistrySupport = (): ScopedRegistrySupport => {
|
||||
if (support) {
|
||||
return support;
|
||||
}
|
||||
if ("CustomElementRegistryPolyfill" in globalThis) {
|
||||
support = "polyfill";
|
||||
} else {
|
||||
try {
|
||||
new CustomElementRegistry();
|
||||
support = "native";
|
||||
} catch (_err) {
|
||||
support = "none";
|
||||
}
|
||||
}
|
||||
return support;
|
||||
};
|
||||
|
||||
/** A new scoped registry, or `undefined` if the browser has no support. */
|
||||
export const createScopedRegistry = (): CustomElementRegistry | undefined =>
|
||||
scopedRegistrySupport() === "none" ? undefined : new CustomElementRegistry();
|
||||
|
||||
/** Shadow root options that make the shadow root use `registry`. */
|
||||
export const scopedShadowRootInit = (
|
||||
init: ShadowRootInit,
|
||||
registry: CustomElementRegistry
|
||||
): ShadowRootInit =>
|
||||
scopedRegistrySupport() === "polyfill"
|
||||
? ({ ...init, registry } as ShadowRootInit)
|
||||
: ({ ...init, customElementRegistry: registry } as ShadowRootInit);
|
||||
|
||||
interface CreationScope {
|
||||
importNode(node: Node, deep?: boolean): Node;
|
||||
}
|
||||
|
||||
/**
|
||||
* The object Lit should create template content with, so custom elements in
|
||||
* it are created from the scoped registry (Lit's `creationScope`).
|
||||
*/
|
||||
export const scopedCreationScope = (
|
||||
root: ShadowRoot,
|
||||
registry: CustomElementRegistry
|
||||
): CreationScope => {
|
||||
if (scopedRegistrySupport() === "polyfill") {
|
||||
// The polyfill adds importNode to shadow roots
|
||||
return root as unknown as CreationScope;
|
||||
}
|
||||
// Standard API: the registry is passed to importNode
|
||||
return {
|
||||
importNode: (node, deep) =>
|
||||
(
|
||||
document.importNode as (
|
||||
node: Node,
|
||||
options: { selfOnly: boolean; customElementRegistry: unknown }
|
||||
) => Node
|
||||
)(node, { selfOnly: !deep, customElementRegistry: registry }),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { define } from "../src/utils/registration/define";
|
||||
|
||||
// Home Assistant and custom cards can each ship a copy of the library, so a
|
||||
// second registration of the same tag must be ignored instead of throwing.
|
||||
describe("define", () => {
|
||||
it("registers an element that is not registered yet", () => {
|
||||
class First extends HTMLElement {}
|
||||
define("hac-test-first", First);
|
||||
expect(customElements.get("hac-test-first")).toBe(First);
|
||||
});
|
||||
|
||||
it("keeps the first registration of a tag name", () => {
|
||||
class Original extends HTMLElement {}
|
||||
class Duplicate extends HTMLElement {}
|
||||
define("hac-test-duplicate", Original);
|
||||
expect(() => define("hac-test-duplicate", Duplicate)).not.toThrow();
|
||||
expect(customElements.get("hac-test-duplicate")).toBe(Original);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
hasAssignedContent,
|
||||
hasAssignedElements,
|
||||
} from "../src/utils/element/slot";
|
||||
|
||||
// Components hide wrappers of empty slots, e.g. the padding of an empty card.
|
||||
// Whitespace between tags must not count as content.
|
||||
const slotEvent = (light: string, name?: string) => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = light;
|
||||
const slot = document.createElement("slot");
|
||||
if (name) {
|
||||
slot.name = name;
|
||||
}
|
||||
host.attachShadow({ mode: "open" }).append(slot);
|
||||
return { target: slot } as unknown as Event;
|
||||
};
|
||||
|
||||
describe("hasAssignedContent", () => {
|
||||
it("ignores whitespace", () => {
|
||||
expect(hasAssignedContent(slotEvent("\n \n"))).toBe(false);
|
||||
});
|
||||
|
||||
it("counts text", () => {
|
||||
expect(hasAssignedContent(slotEvent(" Hello "))).toBe(true);
|
||||
});
|
||||
|
||||
it("counts elements", () => {
|
||||
expect(hasAssignedContent(slotEvent("\n <p></p>\n"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasAssignedElements", () => {
|
||||
it("only counts elements assigned to the slot", () => {
|
||||
const light = '<span slot="icon"></span> text';
|
||||
expect(hasAssignedElements(slotEvent(light, "icon"))).toBe(true);
|
||||
expect(hasAssignedElements(slotEvent(light, "actions"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Generated
+1010
-351
File diff suppressed because it is too large.
Load diff
@@ -1,3 +1,7 @@
|
||||
packages:
|
||||
- "."
|
||||
- "packages/*"
|
||||
|
||||
savePrefix: ""
|
||||
autoInstallPeers: false
|
||||
|
||||
@@ -19,6 +23,7 @@ trustPolicyExclude:
|
||||
allowBuilds:
|
||||
"@tsparticles/engine": false
|
||||
core-js: false
|
||||
rs-module-lexer: false
|
||||
unrs-resolver: false
|
||||
|
||||
overrides:
|
||||
|
||||
@@ -3,6 +3,12 @@ import { ref } from "lit/directives/ref";
|
||||
|
||||
const MAX_ROWS = 2;
|
||||
|
||||
// A narrow list, as on a phone or a half width card, fits about one item per
|
||||
// row, so two rows hide most of the legend. 450px is about the widest list a
|
||||
// phone viewport gives.
|
||||
const NARROW_MAX_ROWS = 3;
|
||||
const NARROW_WIDTH = 450;
|
||||
|
||||
// How many items fit in maxRows of a wrapping row, leaving room for the
|
||||
// "more" item after them when not all of them fit.
|
||||
export const fitInRows = (
|
||||
@@ -34,9 +40,10 @@ export const fitInRows = (
|
||||
return widths.length;
|
||||
};
|
||||
|
||||
// Fits the items of a wrapping legend list into two rows. The list keeps its
|
||||
// "more" item rendered with the legend-more class, and items past the fit
|
||||
// stay rendered out of the flow, so both can always be measured.
|
||||
// Fits the items of a wrapping legend list into two rows, three when it is
|
||||
// narrow. The list keeps its "more" item rendered with the legend-more class,
|
||||
// and items past the fit stay rendered out of the flow, so both can always be
|
||||
// measured.
|
||||
export class LegendFitController {
|
||||
// Items to show while collapsed, undefined until the list is measured
|
||||
public fit?: number;
|
||||
@@ -118,7 +125,7 @@ export class LegendFitController {
|
||||
more?.getBoundingClientRect().width ?? 0,
|
||||
rowWidth,
|
||||
parseFloat(getComputedStyle(list).columnGap) || 0,
|
||||
MAX_ROWS
|
||||
rowWidth < NARROW_WIDTH ? NARROW_MAX_ROWS : MAX_ROWS
|
||||
);
|
||||
if (fit !== this.fit) {
|
||||
this.fit = fit;
|
||||
|
||||
@@ -163,21 +163,9 @@ export const findEntities = (
|
||||
return results;
|
||||
};
|
||||
|
||||
export const excludeEntities = (
|
||||
entityIds: string[],
|
||||
excludedEntityIds?: string[]
|
||||
): string[] => {
|
||||
if (!excludedEntityIds?.length) {
|
||||
return entityIds;
|
||||
}
|
||||
const excluded = new Set(excludedEntityIds);
|
||||
return entityIds.filter((entityId) => !excluded.has(entityId));
|
||||
};
|
||||
|
||||
export const findFilteredEntities = (
|
||||
sources: EntityFilterSources,
|
||||
filters: EntityFilter[],
|
||||
excludedEntityIds?: string[]
|
||||
filters: EntityFilter[]
|
||||
): string[] => {
|
||||
const filterFuncs = filters.map((filter) =>
|
||||
generateEntityFilter(
|
||||
@@ -189,8 +177,5 @@ export const findFilteredEntities = (
|
||||
filter
|
||||
)
|
||||
);
|
||||
return excludeEntities(
|
||||
findEntities(Object.keys(sources.states), filterFuncs),
|
||||
excludedEntityIds
|
||||
);
|
||||
return findEntities(Object.keys(sources.states), filterFuncs);
|
||||
};
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { join } from "lit/directives/join";
|
||||
import { keyed } from "lit/directives/keyed";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consume } from "../../common/decorators/consume";
|
||||
@@ -124,16 +125,16 @@ export interface DataTableRowData {
|
||||
|
||||
export type SortableColumnContainer = Record<string, ClonedDataTableColumnData>;
|
||||
|
||||
// Slot for the actions at the end of a group's header, they don't toggle the
|
||||
// group. Put `ha-icon-button`s with a label in it. They get the rows' icon
|
||||
// Renders the actions at the end of a group's header, they don't toggle the
|
||||
// group. Return `ha-icon-button`s with a label. They get the rows' icon
|
||||
// colour and size by default, which `--data-table-group-actions-color`,
|
||||
// `--data-table-group-actions-button-size` and
|
||||
// `--data-table-group-actions-icon-size` change for all of them; style a
|
||||
// button itself for its own colour or size.
|
||||
export const GROUP_ACTIONS_SLOT_PREFIX = "group-actions-";
|
||||
|
||||
export const groupActionsSlot = (group: string) =>
|
||||
`${GROUP_ACTIONS_SLOT_PREFIX}${group}`;
|
||||
// button itself for its own colour or size. Not a slot per group, as WebKit
|
||||
// is slow to add and remove slots while the rows scroll.
|
||||
export type DataTableGroupActions = (
|
||||
group: string
|
||||
) => TemplateResult | typeof nothing;
|
||||
|
||||
const UNDEFINED_GROUP_KEY = "zzzzz_undefined";
|
||||
|
||||
@@ -197,6 +198,9 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public groupOrder?: string[];
|
||||
|
||||
@property({ attribute: false })
|
||||
public renderGroupActions?: DataTableGroupActions;
|
||||
|
||||
@property({ attribute: false }) public sortColumn?: string;
|
||||
|
||||
@property({ attribute: false }) public sortDirection: SortingDirection = null;
|
||||
@@ -506,7 +510,8 @@ export class HaDataTable extends LitElement {
|
||||
this.groupOrder,
|
||||
this._collapsedGroups,
|
||||
this.sortColumn,
|
||||
this.sortDirection
|
||||
this.sortDirection,
|
||||
this.renderGroupActions
|
||||
)
|
||||
: [];
|
||||
|
||||
@@ -759,8 +764,6 @@ export class HaDataTable extends LitElement {
|
||||
`
|
||||
: html`
|
||||
${
|
||||
// Rendered before the rows, so the group's actions slot
|
||||
// shows here and not in its scrolled out header row
|
||||
this.groupColumn &&
|
||||
!this.autoHeight &&
|
||||
this._stickyGroup !== undefined
|
||||
@@ -775,11 +778,15 @@ export class HaDataTable extends LitElement {
|
||||
.group=${this._stickyGroup}
|
||||
@click=${this._collapseStickyGroup}
|
||||
>
|
||||
${this._renderGroupHeader(
|
||||
${keyed(
|
||||
this._stickyGroup,
|
||||
this._collapsedGroups.includes(this._stickyGroup),
|
||||
this._i18n?.localize,
|
||||
true
|
||||
this._renderGroupHeader(
|
||||
this._stickyGroup,
|
||||
this._collapsedGroups.includes(this._stickyGroup),
|
||||
this._i18n?.localize,
|
||||
this.renderGroupActions,
|
||||
true
|
||||
)
|
||||
)}
|
||||
</div>`
|
||||
: nothing
|
||||
@@ -803,7 +810,14 @@ export class HaDataTable extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _keyFunction = (row: DataTableRowData) => row?.[this.id] || row;
|
||||
// Group headers are keyed by their group, so they keep their actions' state
|
||||
// when the data changes. The prefixes keep them apart from row ids.
|
||||
private _keyFunction = (row: DataTableRowData) =>
|
||||
row?.[this.id]
|
||||
? `row-${row[this.id]}`
|
||||
: row?.groupHeader !== undefined
|
||||
? `group-${row.groupHeader}`
|
||||
: row;
|
||||
|
||||
private _renderMainValue(value: unknown) {
|
||||
return this.clickable
|
||||
@@ -1063,7 +1077,8 @@ export class HaDataTable extends LitElement {
|
||||
groupOrder: string[] | undefined,
|
||||
collapsedGroups: string[],
|
||||
sortColumn: string | undefined,
|
||||
sortDirection: SortingDirection
|
||||
sortDirection: SortingDirection,
|
||||
renderGroupActions: DataTableGroupActions | undefined
|
||||
) => {
|
||||
if (appendRow || groupColumn) {
|
||||
let items = [...data];
|
||||
@@ -1122,7 +1137,8 @@ export class HaDataTable extends LitElement {
|
||||
content: this._renderGroupHeader(
|
||||
groupName,
|
||||
collapsedGroups.includes(groupName),
|
||||
localize
|
||||
localize,
|
||||
renderGroupActions
|
||||
),
|
||||
});
|
||||
if (!collapsedGroups.includes(groupName)) {
|
||||
@@ -1148,6 +1164,7 @@ export class HaDataTable extends LitElement {
|
||||
groupName: string,
|
||||
collapsed: boolean,
|
||||
localize: LocalizeFunc | undefined,
|
||||
renderGroupActions: DataTableGroupActions | undefined,
|
||||
sticky = false
|
||||
) {
|
||||
// The sticky copy's button and name are hidden from assistive technology,
|
||||
@@ -1183,7 +1200,7 @@ export class HaDataTable extends LitElement {
|
||||
}
|
||||
</span>
|
||||
<div class="group-actions" @click=${this._stopGroupToggle}>
|
||||
<slot name=${groupActionsSlot(groupName)}></slot>
|
||||
${renderGroupActions?.(groupName)}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -1264,7 +1281,8 @@ export class HaDataTable extends LitElement {
|
||||
this.groupOrder,
|
||||
this._collapsedGroups,
|
||||
this.sortColumn,
|
||||
this.sortDirection
|
||||
this.sortDirection,
|
||||
this.renderGroupActions
|
||||
);
|
||||
|
||||
if (
|
||||
@@ -1961,7 +1979,7 @@ export class HaDataTable extends LitElement {
|
||||
align-self: stretch;
|
||||
margin-top: -12px;
|
||||
margin-inline-start: auto;
|
||||
/* The rows' icon button size, inherited by the slotted ones */
|
||||
/* The rows' icon button size, inherited by the actions */
|
||||
--_button-size: var(--data-table-group-actions-button-size, 48px);
|
||||
--ha-icon-button-size: var(--_button-size);
|
||||
--mdc-icon-size: var(--data-table-group-actions-icon-size, 24px);
|
||||
@@ -1974,9 +1992,9 @@ export class HaDataTable extends LitElement {
|
||||
position: sticky;
|
||||
inset-inline-end: 0;
|
||||
cursor: default;
|
||||
/* Don't pass the group name's weight on to slotted menus */
|
||||
/* Don't pass the group name's weight on to the actions */
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
/* Inherited by the slotted icon buttons, like the rows' icon cells */
|
||||
/* Inherited by the actions, like the rows' icon cells */
|
||||
color: var(
|
||||
--data-table-group-actions-color,
|
||||
var(--secondary-text-color)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import type {
|
||||
HaFormCondition,
|
||||
HaFormDataContainer,
|
||||
@@ -87,26 +88,38 @@ export const combineVisible = (
|
||||
return conditions;
|
||||
};
|
||||
|
||||
const getConditionFields = (visible: HaFormVisible | undefined): string[] =>
|
||||
visible === undefined || typeof visible === "boolean"
|
||||
? []
|
||||
: ensureArray(visible).flatMap((condition) =>
|
||||
"condition" in condition
|
||||
? condition.conditions.flatMap((sub) => getConditionFields(sub))
|
||||
: [condition.field]
|
||||
);
|
||||
|
||||
// Hiding a field drops its value, which can flip another field's condition, so
|
||||
// resolve the set to a fixpoint. Fields are given in order as name and visible.
|
||||
// a field is evaluated after the fields its condition depends on.
|
||||
export const getHiddenFields = (
|
||||
fields: readonly (readonly [string, HaFormVisible | undefined])[],
|
||||
data: HaFormDataContainer | undefined
|
||||
): Set<string> => {
|
||||
const hidden = new Set<string>();
|
||||
const conditions = new Map(fields);
|
||||
const evalData: HaFormDataContainer = { ...(data ?? {}) };
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const [name, visible] of fields) {
|
||||
if (hidden.has(name) || isVisible(visible, evalData)) {
|
||||
continue;
|
||||
}
|
||||
const hidden = new Set<string>();
|
||||
const resolved = new Set<string>();
|
||||
const resolve = (name: string) => {
|
||||
if (resolved.has(name) || !conditions.has(name)) {
|
||||
return;
|
||||
}
|
||||
resolved.add(name);
|
||||
const visible = conditions.get(name);
|
||||
getConditionFields(visible).forEach(resolve);
|
||||
if (!isVisible(visible, evalData)) {
|
||||
hidden.add(name);
|
||||
delete evalData[name];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
fields.forEach(([name]) => resolve(name));
|
||||
return hidden;
|
||||
};
|
||||
|
||||
|
||||
@@ -219,6 +219,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
if (this._searchFieldElement) {
|
||||
this._searchFieldElement.value = value;
|
||||
}
|
||||
this._applySearch(value);
|
||||
}
|
||||
|
||||
protected get scrollableElement(): HTMLElement | null {
|
||||
@@ -691,7 +692,11 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
|
||||
private _filterChanged = (ev: InputEvent) => {
|
||||
const textfield = ev.target as HaInputSearch;
|
||||
const searchString = (textfield.value ?? "").trim();
|
||||
this._applySearch(textfield.value ?? "");
|
||||
};
|
||||
|
||||
private _applySearch(value: string) {
|
||||
const searchString = value.trim();
|
||||
this._search = searchString;
|
||||
|
||||
if (this.sections?.length) {
|
||||
@@ -736,7 +741,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
|
||||
this._resetSelectedItem();
|
||||
this._resetListScroll();
|
||||
};
|
||||
}
|
||||
|
||||
// Keep focus in the search field, so a focus change doesn't scroll the
|
||||
// list to the active row and make the click miss.
|
||||
|
||||
+102
-18
@@ -252,12 +252,14 @@ export interface EnergyPreferences {
|
||||
energy_sources: EnergySource[];
|
||||
device_consumption: DeviceConsumptionEnergyPreference[];
|
||||
device_consumption_water: DeviceConsumptionEnergyPreference[];
|
||||
device_consumption_gas: DeviceConsumptionEnergyPreference[];
|
||||
}
|
||||
|
||||
export const EMPTY_PREFERENCES: EnergyPreferences = {
|
||||
energy_sources: [],
|
||||
device_consumption: [],
|
||||
device_consumption_water: [],
|
||||
device_consumption_gas: [],
|
||||
};
|
||||
|
||||
export interface EnergyInfo {
|
||||
@@ -275,6 +277,7 @@ export interface EnergyPreferencesValidation {
|
||||
energy_sources: EnergyValidationIssue[][];
|
||||
device_consumption: EnergyValidationIssue[][];
|
||||
device_consumption_water: EnergyValidationIssue[][];
|
||||
device_consumption_gas: EnergyValidationIssue[][];
|
||||
}
|
||||
|
||||
export const getEnergyInfo = (callWS: HomeAssistant["callWS"]) =>
|
||||
@@ -507,6 +510,11 @@ export const getReferencedStatisticIds = (
|
||||
...prefs.device_consumption_water.map((d) => d.stat_consumption)
|
||||
);
|
||||
}
|
||||
if (!(includeTypes && !includeTypes.includes("gas"))) {
|
||||
statIDs.push(
|
||||
...prefs.device_consumption_gas.map((d) => d.stat_consumption)
|
||||
);
|
||||
}
|
||||
|
||||
return statIDs;
|
||||
};
|
||||
@@ -543,6 +551,7 @@ export const getReferencedStatisticIdsPower = (
|
||||
}
|
||||
statIDs.push(...prefs.device_consumption.map((d) => d.stat_rate));
|
||||
statIDs.push(...prefs.device_consumption_water.map((d) => d.stat_rate));
|
||||
statIDs.push(...prefs.device_consumption_gas.map((d) => d.stat_rate));
|
||||
|
||||
return statIDs.filter(Boolean) as string[];
|
||||
};
|
||||
@@ -651,9 +660,16 @@ const getEnergyData = async (
|
||||
const gasIsOtherEnergy =
|
||||
ENERGY_UNITS.includes(gasUnit as any) && gasUnit !== "kWh";
|
||||
const gasEnergyStatIds = gasIsOtherEnergy
|
||||
? (energySourcesByType(prefs).gas ?? []).map(
|
||||
(source) => source.stat_energy_from
|
||||
)
|
||||
? [
|
||||
...new Set([
|
||||
...(energySourcesByType(prefs).gas ?? []).map(
|
||||
(source) => source.stat_energy_from
|
||||
),
|
||||
...prefs.device_consumption_gas.map(
|
||||
(device) => device.stat_consumption
|
||||
),
|
||||
]),
|
||||
]
|
||||
: [];
|
||||
const gasEnergyUnits: StatisticsUnitConfiguration = {
|
||||
energy: gasIsOtherEnergy
|
||||
@@ -1474,13 +1490,18 @@ export type EnergyGasUnitClass = (typeof energyGasUnitClass)[number];
|
||||
export const getEnergyGasUnitClass = (
|
||||
prefs: EnergyPreferences,
|
||||
excludeSource?: string,
|
||||
statisticsMetaData: Record<string, StatisticsMetaData> = {}
|
||||
statisticsMetaData: Record<string, StatisticsMetaData> = {},
|
||||
excludeFrom?: "source" | "device"
|
||||
): EnergyGasUnitClass | undefined => {
|
||||
for (const source of prefs.energy_sources) {
|
||||
if (source.type !== "gas") {
|
||||
continue;
|
||||
}
|
||||
if (excludeSource && excludeSource === source.stat_energy_from) {
|
||||
if (
|
||||
excludeSource &&
|
||||
excludeSource === source.stat_energy_from &&
|
||||
excludeFrom !== "device"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const statisticIdWithMeta = statisticsMetaData[source.stat_energy_from];
|
||||
@@ -1492,6 +1513,19 @@ export const getEnergyGasUnitClass = (
|
||||
return statisticIdWithMeta.unit_class as EnergyGasUnitClass;
|
||||
}
|
||||
}
|
||||
for (const device of prefs.device_consumption_gas) {
|
||||
if (
|
||||
excludeSource &&
|
||||
excludeSource === device.stat_consumption &&
|
||||
excludeFrom !== "source"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const unitClass = statisticsMetaData[device.stat_consumption]?.unit_class;
|
||||
if (energyGasUnitClass.includes(unitClass as EnergyGasUnitClass)) {
|
||||
return unitClass as EnergyGasUnitClass;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -1501,15 +1535,15 @@ const getEnergyGasUnit = (
|
||||
prefs: EnergyPreferences,
|
||||
statisticsMetaData: Record<string, StatisticsMetaData> = {}
|
||||
): string => {
|
||||
const units = prefs.energy_sources
|
||||
.filter((s) => s.type === "gas")
|
||||
.map((s) =>
|
||||
getDisplayUnit(
|
||||
states,
|
||||
s.stat_energy_from,
|
||||
statisticsMetaData[s.stat_energy_from]
|
||||
)
|
||||
);
|
||||
const gasStatIds = [
|
||||
...prefs.energy_sources.flatMap((s) =>
|
||||
s.type === "gas" ? [s.stat_energy_from] : []
|
||||
),
|
||||
...prefs.device_consumption_gas.map((d) => d.stat_consumption),
|
||||
];
|
||||
const units = gasStatIds.map((id) =>
|
||||
getDisplayUnit(states, id, statisticsMetaData[id])
|
||||
);
|
||||
|
||||
const unitClass = getEnergyGasUnitClass(prefs, undefined, statisticsMetaData);
|
||||
if (unitClass === "energy") {
|
||||
@@ -1535,10 +1569,7 @@ const getEnergyGasUnit = (
|
||||
return [];
|
||||
}),
|
||||
]);
|
||||
const isSharedWithKWh = prefs.energy_sources.some(
|
||||
(source) =>
|
||||
source.type === "gas" && kWhStatIds.has(source.stat_energy_from)
|
||||
);
|
||||
const isSharedWithKWh = gasStatIds.some((id) => kWhStatIds.has(id));
|
||||
const first = units[0];
|
||||
if (
|
||||
!isSharedWithKWh &&
|
||||
@@ -2200,6 +2231,48 @@ export const formatFlowRateShort = (
|
||||
});
|
||||
};
|
||||
|
||||
export type WaterGasResource = "water" | "gas";
|
||||
|
||||
export const getWaterGasDevicesKey = (resource: WaterGasResource) =>
|
||||
`device_consumption_${resource}` as const;
|
||||
|
||||
export const getWaterGasDevices = (
|
||||
prefs: EnergyPreferences,
|
||||
resource: WaterGasResource
|
||||
): DeviceConsumptionEnergyPreference[] =>
|
||||
prefs[getWaterGasDevicesKey(resource)];
|
||||
|
||||
export const getFlowRateUnit = (
|
||||
resource: WaterGasResource,
|
||||
prefs: EnergyPreferences,
|
||||
states: HomeAssistant["states"]
|
||||
): string | undefined => {
|
||||
const ids = [
|
||||
...prefs.energy_sources.flatMap((s) =>
|
||||
s.type === resource && s.stat_rate ? [s.stat_rate] : []
|
||||
),
|
||||
...getWaterGasDevices(prefs, resource).flatMap((d) =>
|
||||
d.stat_rate ? [d.stat_rate] : []
|
||||
),
|
||||
];
|
||||
for (const id of ids) {
|
||||
const unit = states[id]?.attributes.unit_of_measurement;
|
||||
if (unit) {
|
||||
return unit;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getFlowRateInUnit = (
|
||||
stateObj: HassEntity | undefined,
|
||||
unit: string
|
||||
): number | undefined => {
|
||||
const value = getFlowRateFromState(stateObj);
|
||||
const factor = FLOW_RATE_TO_LMIN[unit];
|
||||
return value === undefined || factor === undefined ? value : value / factor;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current power value from entity state, normalized to watts (W)
|
||||
* @param stateObj - The entity state object to get power value from
|
||||
@@ -2280,6 +2353,7 @@ export const downloadEnergyData = (
|
||||
const energy_sources = energyData.prefs.energy_sources;
|
||||
const device_consumption = energyData.prefs.device_consumption;
|
||||
const device_consumption_water = energyData.prefs.device_consumption_water;
|
||||
const device_consumption_gas = energyData.prefs.device_consumption_gas;
|
||||
const stats = state.stats;
|
||||
|
||||
interface CsvRow {
|
||||
@@ -2460,6 +2534,16 @@ export const downloadEnergyData = (
|
||||
printCategory("device_consumption_water", waterDevices, state.waterUnit);
|
||||
}
|
||||
|
||||
if (device_consumption_gas) {
|
||||
const gasDevices: string[] = [];
|
||||
device_consumption_gas.forEach((source) => {
|
||||
source = source as DeviceConsumptionEnergyPreference;
|
||||
gasDevices.push(source.stat_consumption);
|
||||
});
|
||||
|
||||
printCategory("device_consumption_gas", gasDevices, state.gasUnit);
|
||||
}
|
||||
|
||||
const { summedData } = getSummedData(state);
|
||||
const { consumption } = computeConsumptionData(summedData, undefined);
|
||||
|
||||
|
||||
+2
-1
@@ -19,7 +19,8 @@ export interface ESPHomeZWaveProxyCapabilities {
|
||||
export interface ESPHomeSerialProxy {
|
||||
name: string;
|
||||
port_type: ESPHomeSerialPortType | null;
|
||||
url: string;
|
||||
// `null` when no device is present (e.g. unpopulated USB port)
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export interface ESPHomeDeviceCapabilities {
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ export const voiceAssistants = {
|
||||
},
|
||||
"cloud.google_assistant": {
|
||||
domain: "google_assistant",
|
||||
name: "Google Assistant",
|
||||
name: "Google Home",
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -51,14 +51,6 @@ export interface SecurityFrontendSystemData {
|
||||
favorite_entities?: string[];
|
||||
}
|
||||
|
||||
export interface MaintenanceFrontendSystemData {
|
||||
hidden_entities?: string[];
|
||||
}
|
||||
|
||||
export interface ClimateFrontendSystemData {
|
||||
hidden_entities?: string[];
|
||||
}
|
||||
|
||||
export interface EnergyFrontendSystemData {
|
||||
// Stable "<view>.<card-type>" keys of energy dashboard cards the user has
|
||||
// hidden. An absent key or array means nothing is hidden (all cards visible),
|
||||
@@ -77,8 +69,6 @@ declare global {
|
||||
home: HomeFrontendSystemData;
|
||||
energy: EnergyFrontendSystemData;
|
||||
security: SecurityFrontendSystemData;
|
||||
maintenance: MaintenanceFrontendSystemData;
|
||||
climate: ClimateFrontendSystemData;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,32 +145,6 @@ export const saveFrontendSystemData = async <
|
||||
value,
|
||||
});
|
||||
|
||||
const pendingSystemDataSaves = new Map<ValidSystemDataKey, Promise<void>>();
|
||||
|
||||
export const queueFrontendSystemDataSave = <
|
||||
SystemDataKey extends ValidSystemDataKey,
|
||||
>(
|
||||
conn: Connection,
|
||||
key: SystemDataKey,
|
||||
value: FrontendSystemData[SystemDataKey]
|
||||
): Promise<void> => {
|
||||
const save = (pendingSystemDataSaves.get(key) ?? Promise.resolve())
|
||||
.catch(() => undefined)
|
||||
.then(() => saveFrontendSystemData(conn, key, value));
|
||||
pendingSystemDataSaves.set(key, save);
|
||||
return save;
|
||||
};
|
||||
|
||||
export const fetchFrontendSystemDataAfterSaves = async <
|
||||
SystemDataKey extends ValidSystemDataKey,
|
||||
>(
|
||||
conn: Connection,
|
||||
key: SystemDataKey
|
||||
): Promise<FrontendSystemData[SystemDataKey] | null> => {
|
||||
await pendingSystemDataSaves.get(key)?.catch(() => undefined);
|
||||
return fetchFrontendSystemData(conn, key);
|
||||
};
|
||||
|
||||
export const subscribeFrontendSystemData = <
|
||||
SystemDataKey extends ValidSystemDataKey,
|
||||
>(
|
||||
|
||||
@@ -839,6 +839,9 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
|
||||
return [
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-dialog {
|
||||
--ha-dialog-header-title-height: auto;
|
||||
}
|
||||
.dialog-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -690,7 +690,7 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
* is always in the scan, so a missing or absent port means it is offline.
|
||||
* Without the usb integration nothing is scanned, so a missing port says nothing.
|
||||
*/
|
||||
private _serialPortUsage(url: string) {
|
||||
private _serialPortUsage(url: string | null) {
|
||||
if (!this._serialPorts) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -375,21 +375,16 @@ export class HaVoiceAssistantSetupStepSuccess extends LitElement {
|
||||
margin-top: 24px;
|
||||
}
|
||||
.rows {
|
||||
gap: var(--ha-space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--ha-space-4) var(--ha-space-1);
|
||||
align-items: center;
|
||||
}
|
||||
.row > *:first-child {
|
||||
flex: 1;
|
||||
margin-right: 4px;
|
||||
.row {
|
||||
display: contents;
|
||||
}
|
||||
.row ha-button {
|
||||
width: 82px;
|
||||
.row > :only-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
ha-select {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
HassEntity,
|
||||
HassEntityAttributeBase,
|
||||
} from "home-assistant-js-websocket";
|
||||
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
|
||||
/** Base attributes type that all entities share */
|
||||
export type EntityAttributes = HassEntityAttributeBase & Record<string, any>;
|
||||
@@ -17,6 +18,11 @@ export type EntityInput = Pick<
|
||||
device_id?: string;
|
||||
/** Integration that provides the entity, defaults to "demo" */
|
||||
platform?: string;
|
||||
/**
|
||||
* Options of the entity in the mocked entity registry, such as timer
|
||||
* presets or favorite positions
|
||||
*/
|
||||
options?: EntityRegistryEntry["options"];
|
||||
/** When the state last changed, a random recent time by default */
|
||||
last_changed?: string;
|
||||
};
|
||||
|
||||
@@ -27,9 +27,10 @@ import {
|
||||
dataTableQueryStateFromUrl,
|
||||
} from "../common/url/data-table-query-params";
|
||||
import "../components/chips/ha-assist-chip";
|
||||
import { GROUP_ACTIONS_SLOT_PREFIX } from "../components/data-table/ha-data-table";
|
||||
import "../components/data-table/ha-data-table";
|
||||
import type {
|
||||
DataTableColumnContainer,
|
||||
DataTableGroupActions,
|
||||
DataTableRowData,
|
||||
HaDataTable,
|
||||
SortingDirection,
|
||||
@@ -216,6 +217,9 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
|
||||
@property({ attribute: false }) public groupOrder?: string[];
|
||||
|
||||
@property({ attribute: false })
|
||||
public renderGroupActions?: DataTableGroupActions;
|
||||
|
||||
@property({ attribute: false }) public columnOrder?: string[];
|
||||
|
||||
@property({ attribute: false }) public hiddenColumns?: string[];
|
||||
@@ -627,13 +631,11 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
.sortDirection=${this._sortDirection}
|
||||
.groupColumn=${this._groupColumn}
|
||||
.groupOrder=${this.groupOrder}
|
||||
.renderGroupActions=${this.renderGroupActions}
|
||||
.initialCollapsedGroups=${this.initialCollapsedGroups}
|
||||
.columnOrder=${this.columnOrder}
|
||||
.hiddenColumns=${this.hiddenColumns}
|
||||
>
|
||||
${this._groupActionsSlots.map(
|
||||
(slot) => html`<slot name=${slot} slot=${slot}></slot>`
|
||||
)}
|
||||
${
|
||||
!this.narrow
|
||||
? html`
|
||||
@@ -849,46 +851,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
this._setGroupColumn(group);
|
||||
}
|
||||
|
||||
// Slots of the group actions the page supplies, passed through to the table
|
||||
@state() private _groupActionsSlots: string[] = [];
|
||||
|
||||
private _groupActionsObserver = new MutationObserver(() =>
|
||||
this._updateGroupActionsSlots()
|
||||
);
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._updateGroupActionsSlots();
|
||||
// Subtree, so slot changes on the page's children are seen too
|
||||
this._groupActionsObserver.observe(this, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributeFilter: ["slot"],
|
||||
});
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._groupActionsObserver.disconnect();
|
||||
}
|
||||
|
||||
private _updateGroupActionsSlots() {
|
||||
const slots = [
|
||||
...new Set(
|
||||
Array.from(this.children, (child) => child.slot).filter((slot) =>
|
||||
slot.startsWith(GROUP_ACTIONS_SLOT_PREFIX)
|
||||
)
|
||||
),
|
||||
];
|
||||
|
||||
if (
|
||||
slots.length !== this._groupActionsSlots.length ||
|
||||
slots.some((slot, index) => slot !== this._groupActionsSlots[index])
|
||||
) {
|
||||
this._groupActionsSlots = slots;
|
||||
}
|
||||
}
|
||||
|
||||
private _setGroupColumn(columnId: string, link = false) {
|
||||
this._groupColumn = columnId;
|
||||
fireEvent(this, "grouping-changed", { value: columnId, link });
|
||||
|
||||
@@ -1,28 +1,18 @@
|
||||
import { mdiPencil } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-icon-button";
|
||||
import "../../components/ha-top-app-bar-fixed";
|
||||
import {
|
||||
fetchFrontendSystemDataAfterSaves,
|
||||
queueFrontendSystemDataSave,
|
||||
type ClimateFrontendSystemData,
|
||||
} from "../../data/frontend";
|
||||
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
|
||||
import { ChildPanelReady } from "../../layouts/panel-ready";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { showToast } from "../../util/toast";
|
||||
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
|
||||
import type { Lovelace } from "../lovelace/types";
|
||||
import "../lovelace/views/hui-view";
|
||||
import "../lovelace/views/hui-view-background";
|
||||
import "../lovelace/views/hui-view-container";
|
||||
import "../../components/ha-top-app-bar-fixed";
|
||||
|
||||
const CLIMATE_LOVELACE_VIEW_CONFIG: LovelaceStrategyViewConfig = {
|
||||
strategy: {
|
||||
@@ -40,20 +30,10 @@ class PanelClimate extends LitElement {
|
||||
|
||||
@state() private _lovelace?: Lovelace;
|
||||
|
||||
@state() private _config?: ClimateFrontendSystemData;
|
||||
|
||||
@state() private _editVisibility = false;
|
||||
|
||||
@state() private _searchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
private _childPanelReady?: ChildPanelReady;
|
||||
|
||||
private _loadConfigPromise?: Promise<void>;
|
||||
|
||||
private _loadConfigRevision = 0;
|
||||
|
||||
private _setLovelaceRevision = 0;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
// Initial setup
|
||||
@@ -67,22 +47,13 @@ class PanelClimate extends LitElement {
|
||||
}
|
||||
|
||||
const oldHass = changedProps.get("hass") as this["hass"];
|
||||
if (
|
||||
oldHass &&
|
||||
this.hass.config.state === "RUNNING" &&
|
||||
oldHass.config.state !== "RUNNING"
|
||||
) {
|
||||
this._setup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (oldHass && oldHass.localize !== this.hass.localize) {
|
||||
this._setLovelace();
|
||||
return;
|
||||
}
|
||||
|
||||
if (oldHass && this.hass) {
|
||||
// Refresh the generated view when registries or panels change.
|
||||
// If the entity registry changed, ask the user if they want to refresh the config
|
||||
if (
|
||||
oldHass.entities !== this.hass.entities ||
|
||||
oldHass.devices !== this.hass.devices ||
|
||||
@@ -92,46 +63,24 @@ class PanelClimate extends LitElement {
|
||||
) {
|
||||
if (this.hass.config.state === "RUNNING") {
|
||||
this._debounceRegistriesChanged();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// If ha started, refresh the config
|
||||
if (
|
||||
this.hass.config.state === "RUNNING" &&
|
||||
oldHass.config.state !== "RUNNING"
|
||||
) {
|
||||
this._setLovelace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _setup() {
|
||||
this._loadConfigPromise = this._loadConfig();
|
||||
await Promise.all([
|
||||
this.hass.loadFragmentTranslation("lovelace"),
|
||||
this._loadConfigPromise,
|
||||
]);
|
||||
await this.hass.loadFragmentTranslation("lovelace");
|
||||
this._setLovelace();
|
||||
}
|
||||
|
||||
private async _loadConfig() {
|
||||
const revision = ++this._loadConfigRevision;
|
||||
this._config = undefined;
|
||||
try {
|
||||
const data = await fetchFrontendSystemDataAfterSaves(
|
||||
this.hass.connection,
|
||||
"climate"
|
||||
);
|
||||
if (revision !== this._loadConfigRevision) {
|
||||
return;
|
||||
}
|
||||
this._config = data || {};
|
||||
} catch (err) {
|
||||
if (revision !== this._loadConfigRevision) {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to load climate configuration:", err);
|
||||
showToast(this, {
|
||||
message: this.hass.localize("ui.panel.climate.editor.load_failed"),
|
||||
duration: 0,
|
||||
dismissable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _debounceRegistriesChanged = debounce(
|
||||
() => this._registriesChanged(),
|
||||
200
|
||||
@@ -148,27 +97,6 @@ class PanelClimate extends LitElement {
|
||||
.backButton=${this._searchParams.has("historyBack")}
|
||||
>
|
||||
<div slot="title">${this.hass.localize("panel.climate")}</div>
|
||||
${
|
||||
!this.hass.user?.is_admin
|
||||
? nothing
|
||||
: this._editVisibility
|
||||
? html`<ha-button
|
||||
slot="actionItems"
|
||||
appearance="filled"
|
||||
size="s"
|
||||
@click=${this._stopEditVisibility}
|
||||
>
|
||||
${this.hass.localize("ui.panel.lovelace.menu.exit_edit_mode")}
|
||||
</ha-button>`
|
||||
: this._config
|
||||
? html`<ha-icon-button
|
||||
slot="actionItems"
|
||||
.path=${mdiPencil}
|
||||
.label=${this.hass.localize("ui.panel.climate.editor.title")}
|
||||
@click=${this._startEditVisibility}
|
||||
></ha-icon-button>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._lovelace
|
||||
? html`
|
||||
@@ -180,7 +108,6 @@ class PanelClimate extends LitElement {
|
||||
.narrow=${this.narrow}
|
||||
.lovelace=${this._lovelace}
|
||||
.index=${this._viewIndex}
|
||||
@entity-visibility-changed=${this._visibilityChanged}
|
||||
></hui-view
|
||||
></hui-view-container>
|
||||
`
|
||||
@@ -190,31 +117,16 @@ class PanelClimate extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private async _setLovelace(force = false) {
|
||||
const revision = ++this._setLovelaceRevision;
|
||||
if (this._loadConfigPromise) {
|
||||
await this._loadConfigPromise;
|
||||
}
|
||||
|
||||
private async _setLovelace() {
|
||||
const viewConfig = await generateLovelaceViewStrategy(
|
||||
{
|
||||
strategy: {
|
||||
...CLIMATE_LOVELACE_VIEW_CONFIG.strategy,
|
||||
hidden_entities: this._config?.hidden_entities,
|
||||
edit_visibility: this._editVisibility || undefined,
|
||||
},
|
||||
},
|
||||
CLIMATE_LOVELACE_VIEW_CONFIG,
|
||||
this.hass
|
||||
);
|
||||
|
||||
if (revision !== this._setLovelaceRevision) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = { views: [viewConfig] };
|
||||
const rawConfig = { views: [CLIMATE_LOVELACE_VIEW_CONFIG] };
|
||||
|
||||
if (!force && deepEqual(config, this._lovelace?.config)) {
|
||||
if (deepEqual(config, this._lovelace?.config)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -234,60 +146,6 @@ class PanelClimate extends LitElement {
|
||||
};
|
||||
}
|
||||
|
||||
private _startEditVisibility = () => {
|
||||
this._editVisibility = true;
|
||||
this._setLovelace();
|
||||
};
|
||||
|
||||
private _stopEditVisibility = () => {
|
||||
this._editVisibility = false;
|
||||
if (!this._config) {
|
||||
this._loadConfigPromise = this._loadConfig();
|
||||
}
|
||||
this._setLovelace();
|
||||
};
|
||||
|
||||
private _visibilityChanged = (
|
||||
ev: HASSDomEvent<HASSDomEvents["entity-visibility-changed"]>
|
||||
) => {
|
||||
if (!this._config) {
|
||||
return;
|
||||
}
|
||||
const { entity, hidden } = ev.detail;
|
||||
const hiddenEntities = (this._config.hidden_entities ?? []).filter(
|
||||
(entityId) => entityId !== entity
|
||||
);
|
||||
if (hidden) {
|
||||
hiddenEntities.push(entity);
|
||||
}
|
||||
this._saveConfig({
|
||||
...this._config,
|
||||
hidden_entities: hiddenEntities.length ? hiddenEntities : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
private _saveConfig(config: ClimateFrontendSystemData): Promise<void> {
|
||||
this._config = config;
|
||||
return queueFrontendSystemDataSave(
|
||||
this.hass.connection,
|
||||
"climate",
|
||||
config
|
||||
).catch(async (err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to save climate configuration:", err);
|
||||
showToast(this, {
|
||||
message: this.hass.localize("ui.panel.climate.editor.save_failed"),
|
||||
duration: 0,
|
||||
dismissable: true,
|
||||
});
|
||||
if (config !== this._config) {
|
||||
return;
|
||||
}
|
||||
await this._loadConfig();
|
||||
await this._setLovelace(true);
|
||||
});
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
|
||||
@@ -2,10 +2,9 @@ import { ReactiveElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { getAreasFloorHierarchy } from "../../../common/areas/areas-floor-hierarchy";
|
||||
import {
|
||||
findFilteredEntities,
|
||||
findEntities,
|
||||
generateEntityFilter,
|
||||
type EntityFilter,
|
||||
type EntityFilterSources,
|
||||
} from "../../../common/entity/entity_filter";
|
||||
import { floorDefaultIcon } from "../../../components/ha-floor-icon";
|
||||
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
|
||||
@@ -13,15 +12,9 @@ import type { LovelaceSectionRawConfig } from "../../../data/lovelace/config/sec
|
||||
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { computeAreaTileCardConfig } from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
|
||||
import {
|
||||
wrapEntityVisibility,
|
||||
type ComputeEntityCard,
|
||||
} from "../../lovelace/strategies/helpers/entity-visibility";
|
||||
|
||||
export interface ClimateViewStrategyConfig {
|
||||
type: "climate";
|
||||
hidden_entities?: string[];
|
||||
edit_visibility?: boolean;
|
||||
}
|
||||
|
||||
export const climateEntityFilters: EntityFilter[] = [
|
||||
@@ -49,53 +42,40 @@ export const climateEntityFilters: EntityFilter[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const findClimateEntities = (
|
||||
sources: EntityFilterSources,
|
||||
hiddenEntities?: string[]
|
||||
): string[] =>
|
||||
findFilteredEntities(sources, climateEntityFilters, hiddenEntities);
|
||||
export const hasClimateEntities = (hass: HomeAssistant): boolean => {
|
||||
const hasAreaSensor = Object.values(hass.areas).some(
|
||||
(area) =>
|
||||
(area.temperature_entity_id && hass.states[area.temperature_entity_id]) ||
|
||||
(area.humidity_entity_id && hass.states[area.humidity_entity_id])
|
||||
);
|
||||
|
||||
type ClimateAreaSensorKind = "temperature" | "humidity";
|
||||
|
||||
export const findClimateAreaSensors = (
|
||||
sources: EntityFilterSources,
|
||||
hiddenEntities?: string[]
|
||||
): Map<string, ClimateAreaSensorKind> => {
|
||||
const hidden = new Set(hiddenEntities);
|
||||
const sensors = new Map<string, ClimateAreaSensorKind>();
|
||||
for (const area of Object.values(sources.areas)) {
|
||||
const slots = [
|
||||
[area.temperature_entity_id, "temperature"],
|
||||
[area.humidity_entity_id, "humidity"],
|
||||
] as const;
|
||||
for (const [entityId, kind] of slots) {
|
||||
if (entityId && entityId in sources.states && !hidden.has(entityId)) {
|
||||
sensors.set(entityId, kind);
|
||||
}
|
||||
}
|
||||
if (hasAreaSensor) {
|
||||
return true;
|
||||
}
|
||||
return sensors;
|
||||
|
||||
const entityIds = Object.keys(hass.states);
|
||||
|
||||
return climateEntityFilters.some((filter) =>
|
||||
entityIds.some(
|
||||
generateEntityFilter(
|
||||
hass.states,
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors,
|
||||
filter
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const hasClimateEntities = (
|
||||
hass: HomeAssistant,
|
||||
hiddenEntities?: string[]
|
||||
): boolean =>
|
||||
findClimateAreaSensors(hass, hiddenEntities).size > 0 ||
|
||||
findClimateEntities(hass, hiddenEntities).length > 0;
|
||||
|
||||
interface ClimateCardOptions {
|
||||
entities: string[];
|
||||
areaSensors: Map<string, ClimateAreaSensorKind>;
|
||||
computeCard: ComputeEntityCard;
|
||||
}
|
||||
|
||||
const processAreasForClimate = (
|
||||
areaIds: string[],
|
||||
hass: HomeAssistant,
|
||||
{ entities, areaSensors, computeCard }: ClimateCardOptions
|
||||
entities: string[]
|
||||
): LovelaceCardConfig[] => {
|
||||
const cards: LovelaceCardConfig[] = [];
|
||||
const computeTileCard = computeAreaTileCardConfig(hass, true);
|
||||
|
||||
for (const areaId of areaIds) {
|
||||
const area = hass.areas[areaId];
|
||||
@@ -116,13 +96,25 @@ const processAreasForClimate = (
|
||||
|
||||
// Add temperature and humidity sensors with trend graphs for areas
|
||||
const temperatureEntityId = area.temperature_entity_id;
|
||||
if (temperatureEntityId && areaSensors.has(temperatureEntityId)) {
|
||||
areaCards.push(computeCard(temperatureEntityId));
|
||||
if (temperatureEntityId && hass.states[temperatureEntityId]) {
|
||||
areaCards.push({
|
||||
...computeTileCard(temperatureEntityId),
|
||||
name:
|
||||
hass.localize("component.sensor.entity_component.temperature.name") ||
|
||||
"Temperature",
|
||||
features: [{ type: "trend-graph" }],
|
||||
});
|
||||
}
|
||||
|
||||
const humidityEntityId = area.humidity_entity_id;
|
||||
if (humidityEntityId && areaSensors.has(humidityEntityId)) {
|
||||
areaCards.push(computeCard(humidityEntityId));
|
||||
if (humidityEntityId && hass.states[humidityEntityId]) {
|
||||
areaCards.push({
|
||||
...computeTileCard(humidityEntityId),
|
||||
name:
|
||||
hass.localize("component.sensor.entity_component.humidity.name") ||
|
||||
"Humidity",
|
||||
features: [{ type: "trend-graph" }],
|
||||
});
|
||||
}
|
||||
|
||||
// Add other climate entities
|
||||
@@ -132,7 +124,18 @@ const processAreasForClimate = (
|
||||
continue;
|
||||
}
|
||||
|
||||
areaCards.push(computeCard(entityId));
|
||||
const state = hass.states[entityId];
|
||||
if (
|
||||
state?.attributes.device_class === "temperature" ||
|
||||
state?.attributes.device_class === "humidity"
|
||||
) {
|
||||
areaCards.push({
|
||||
...computeTileCard(entityId),
|
||||
features: [{ type: "trend-graph" }],
|
||||
});
|
||||
} else {
|
||||
areaCards.push(computeTileCard(entityId));
|
||||
}
|
||||
}
|
||||
|
||||
if (areaCards.length > 0) {
|
||||
@@ -156,7 +159,7 @@ const processAreasForClimate = (
|
||||
|
||||
const processUnassignedEntities = (
|
||||
hass: HomeAssistant,
|
||||
{ entities, computeCard }: ClimateCardOptions
|
||||
entities: string[]
|
||||
): LovelaceCardConfig[] => {
|
||||
const unassignedFilter = generateEntityFilter(
|
||||
hass.states,
|
||||
@@ -170,9 +173,10 @@ const processUnassignedEntities = (
|
||||
);
|
||||
const unassignedEntities = entities.filter(unassignedFilter);
|
||||
const areaCards: LovelaceCardConfig[] = [];
|
||||
const computeTileCard = computeAreaTileCardConfig(hass, true);
|
||||
|
||||
for (const entityId of unassignedEntities) {
|
||||
areaCards.push(computeCard(entityId));
|
||||
areaCards.push(computeTileCard(entityId));
|
||||
}
|
||||
|
||||
return areaCards;
|
||||
@@ -181,7 +185,7 @@ const processUnassignedEntities = (
|
||||
@customElement("climate-view-strategy")
|
||||
export class ClimateViewStrategy extends ReactiveElement {
|
||||
static async generate(
|
||||
config: ClimateViewStrategyConfig,
|
||||
_config: ClimateViewStrategyConfig,
|
||||
hass: HomeAssistant
|
||||
): Promise<LovelaceViewConfig> {
|
||||
const areas = Object.values(hass.areas);
|
||||
@@ -190,40 +194,20 @@ export class ClimateViewStrategy extends ReactiveElement {
|
||||
|
||||
const sections: LovelaceSectionRawConfig[] = [];
|
||||
|
||||
const hiddenEntities = new Set(config.hidden_entities);
|
||||
const editVisibility = Boolean(config.edit_visibility);
|
||||
const entities = findClimateEntities(
|
||||
hass,
|
||||
editVisibility ? undefined : config.hidden_entities
|
||||
const allEntities = Object.keys(hass.states);
|
||||
|
||||
const climateFilters = climateEntityFilters.map((filter) =>
|
||||
generateEntityFilter(
|
||||
hass.states,
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors,
|
||||
filter
|
||||
)
|
||||
);
|
||||
|
||||
const areaSensors = findClimateAreaSensors(
|
||||
hass,
|
||||
editVisibility ? undefined : config.hidden_entities
|
||||
);
|
||||
const computeTileCard = computeAreaTileCardConfig(hass, true);
|
||||
const computeClimateCard = (entityId: string): LovelaceCardConfig => {
|
||||
const tileCard = computeTileCard(entityId);
|
||||
const areaSensor = areaSensors.get(entityId);
|
||||
if (!areaSensor) {
|
||||
return tileCard;
|
||||
}
|
||||
return {
|
||||
...tileCard,
|
||||
name:
|
||||
hass.localize(
|
||||
`component.sensor.entity_component.${areaSensor}.name`
|
||||
) || undefined,
|
||||
features: [{ type: "trend-graph" }],
|
||||
};
|
||||
};
|
||||
const cardOptions: ClimateCardOptions = {
|
||||
entities,
|
||||
areaSensors,
|
||||
computeCard: editVisibility
|
||||
? wrapEntityVisibility(hiddenEntities, computeClimateCard)
|
||||
: computeClimateCard,
|
||||
};
|
||||
const entities = findEntities(allEntities, climateFilters);
|
||||
|
||||
const floorCount =
|
||||
hierarchy.floors.length + (hierarchy.areas.length ? 1 : 0);
|
||||
@@ -249,7 +233,7 @@ export class ClimateViewStrategy extends ReactiveElement {
|
||||
],
|
||||
};
|
||||
|
||||
const areaCards = processAreasForClimate(areaIds, hass, cardOptions);
|
||||
const areaCards = processAreasForClimate(areaIds, hass, entities);
|
||||
|
||||
if (areaCards.length > 0) {
|
||||
section.cards!.push(...areaCards);
|
||||
@@ -273,11 +257,7 @@ export class ClimateViewStrategy extends ReactiveElement {
|
||||
],
|
||||
};
|
||||
|
||||
const areaCards = processAreasForClimate(
|
||||
hierarchy.areas,
|
||||
hass,
|
||||
cardOptions
|
||||
);
|
||||
const areaCards = processAreasForClimate(hierarchy.areas, hass, entities);
|
||||
|
||||
if (areaCards.length > 0) {
|
||||
section.cards!.push(...areaCards);
|
||||
@@ -286,7 +266,7 @@ export class ClimateViewStrategy extends ReactiveElement {
|
||||
}
|
||||
|
||||
// Process unassigned entities
|
||||
const unassignedCards = processUnassignedEntities(hass, cardOptions);
|
||||
const unassignedCards = processUnassignedEntities(hass, entities);
|
||||
|
||||
if (unassignedCards.length > 0) {
|
||||
const section: LovelaceSectionRawConfig = {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
mdiScriptText,
|
||||
mdiShape,
|
||||
mdiTools,
|
||||
mdiTransitConnectionVariant,
|
||||
} from "@mdi/js";
|
||||
import { startOfYesterday } from "date-fns";
|
||||
import type { HassEntity } from "home-assistant-js-websocket/dist/types";
|
||||
@@ -19,6 +20,7 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { consume } from "../../../common/decorators/consume";
|
||||
import { transform } from "../../../common/decorators/transform";
|
||||
import type { HASSDomCurrentTargetEvent } from "../../../common/dom/fire_event";
|
||||
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
@@ -34,9 +36,11 @@ import "../../../components/ha-card";
|
||||
import "../../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
import "../../../components/ha-domain-icon";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-icon-button-next";
|
||||
import "../../../components/ha-icon-next";
|
||||
import "../../../components/ha-state-icon";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import "../../../components/ha-tooltip";
|
||||
import "../../../components/item/ha-list-item-base";
|
||||
@@ -49,7 +53,13 @@ import {
|
||||
updateAreaRegistryEntry,
|
||||
} from "../../../data/area/area_registry";
|
||||
import type { AutomationEntity } from "../../../data/automation";
|
||||
import { fireRelatedContext, fullEntitiesContext } from "../../../data/context";
|
||||
import type { ConfigEntry } from "../../../data/config_entries";
|
||||
import {
|
||||
brandsAccessTokenContext,
|
||||
configEntriesContext,
|
||||
fireRelatedContext,
|
||||
fullEntitiesContext,
|
||||
} from "../../../data/context";
|
||||
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
|
||||
import { sortDeviceRegistryByName } from "../../../data/device/device_registry";
|
||||
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
|
||||
@@ -68,6 +78,7 @@ import "../../../layouts/hass-error-screen";
|
||||
import "../../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { brandsUrl } from "../../../util/brands-url";
|
||||
import "../../logbook/ha-logbook";
|
||||
import { isHelperDomain } from "../helpers/const";
|
||||
import {
|
||||
@@ -149,6 +160,24 @@ class HaConfigAreaPage extends LitElement {
|
||||
@consume({ context: fullEntitiesContext, subscribe: true })
|
||||
_entityReg: EntityRegistryEntry[] = [];
|
||||
|
||||
// Integration domain per config entry ID, to show the brand icon of a device
|
||||
@state()
|
||||
@consume({ context: configEntriesContext, subscribe: true })
|
||||
@transform<ConfigEntry[], Record<string, string>>({
|
||||
transformer: (entries) =>
|
||||
entries
|
||||
? Object.fromEntries(
|
||||
entries.map((entry) => [entry.entry_id, entry.domain])
|
||||
)
|
||||
: undefined,
|
||||
})
|
||||
private _configEntryDomains?: Record<string, string>;
|
||||
|
||||
@state()
|
||||
@consume({ context: brandsAccessTokenContext, subscribe: true })
|
||||
// @ts-ignore re-render trigger only, its value is never read
|
||||
private _brandsAccessToken?: string;
|
||||
|
||||
@state() private _related?: RelatedResult;
|
||||
|
||||
private _logbookTime = { recent: 86400 };
|
||||
@@ -387,6 +416,7 @@ class HaConfigAreaPage extends LitElement {
|
||||
.href=${`/config/devices/device/${device.id}`}
|
||||
.headline=${device.name}
|
||||
>
|
||||
${this._renderDeviceIcon(device)}
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-list-item-button>
|
||||
`
|
||||
@@ -419,6 +449,7 @@ class HaConfigAreaPage extends LitElement {
|
||||
.entity=${entity}
|
||||
.headline=${entity.name ?? undefined}
|
||||
>
|
||||
${this._renderEntityIcon(entity)}
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-list-item-button>
|
||||
`
|
||||
@@ -794,14 +825,73 @@ class HaConfigAreaPage extends LitElement {
|
||||
return { groupedEntities, relatedEntities };
|
||||
}
|
||||
|
||||
private _renderDeviceIcon(device: DeviceRegistryEntry) {
|
||||
const entryIds = device.primary_config_entry
|
||||
? [device.primary_config_entry, ...device.config_entries]
|
||||
: device.config_entries;
|
||||
const domain = entryIds
|
||||
.map((entryId) => this._configEntryDomains?.[entryId])
|
||||
.find(Boolean);
|
||||
|
||||
if (!domain) {
|
||||
return html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${
|
||||
device.entry_type === "service"
|
||||
? mdiTransitConnectionVariant
|
||||
: mdiDevices
|
||||
}
|
||||
></ha-svg-icon>`;
|
||||
}
|
||||
|
||||
return html`<img
|
||||
slot="start"
|
||||
alt=""
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${brandsUrl(
|
||||
{
|
||||
domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
@error=${this._onImageError}
|
||||
@load=${this._onImageLoad}
|
||||
/>`;
|
||||
}
|
||||
|
||||
private _renderEntityIcon(entity: EntityRegistryEntry) {
|
||||
const stateObj = this.hass.states[entity.entity_id];
|
||||
return stateObj
|
||||
? html`<ha-state-icon slot="start" .stateObj=${stateObj}></ha-state-icon>`
|
||||
: html`<ha-domain-icon
|
||||
slot="start"
|
||||
.domain=${computeDomain(entity.entity_id)}
|
||||
.icon=${entity.icon ?? undefined}
|
||||
></ha-domain-icon>`;
|
||||
}
|
||||
|
||||
// Keep the space of a brand icon that failed to load so the rows stay aligned
|
||||
private _onImageError(ev: HASSDomCurrentTargetEvent<HTMLImageElement>) {
|
||||
ev.currentTarget.style.visibility = "hidden";
|
||||
}
|
||||
|
||||
private _onImageLoad(ev: HASSDomCurrentTargetEvent<HTMLImageElement>) {
|
||||
ev.currentTarget.style.visibility = "";
|
||||
}
|
||||
|
||||
private _renderScene(name: string, entityState: SceneEntity) {
|
||||
return entityState.attributes.id
|
||||
? this._renderLink(
|
||||
name,
|
||||
entityState,
|
||||
`/config/scene/edit/${entityState.attributes.id}`
|
||||
)
|
||||
: this._renderNotEditable(
|
||||
name,
|
||||
entityState,
|
||||
`scene-${slugify(entityState.entity_id)}`
|
||||
);
|
||||
}
|
||||
@@ -810,22 +900,30 @@ class HaConfigAreaPage extends LitElement {
|
||||
return entityState.attributes.id
|
||||
? this._renderLink(
|
||||
name,
|
||||
entityState,
|
||||
`/config/automation/edit/${encodeURIComponent(entityState.attributes.id)}`
|
||||
)
|
||||
: this._renderNotEditable(
|
||||
name,
|
||||
entityState,
|
||||
`automation-${slugify(entityState.entity_id)}`
|
||||
);
|
||||
}
|
||||
|
||||
private _renderLink(name: string, href: string) {
|
||||
private _renderLink(name: string, entityState: HassEntity, href: string) {
|
||||
return html`<ha-list-item-button .href=${href} .headline=${name}>
|
||||
<ha-state-icon slot="start" .stateObj=${entityState}></ha-state-icon>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-list-item-button>`;
|
||||
}
|
||||
|
||||
private _renderNotEditable(name: string, id: string) {
|
||||
private _renderNotEditable(
|
||||
name: string,
|
||||
entityState: HassEntity,
|
||||
id: string
|
||||
) {
|
||||
return html`<ha-list-item-base id=${id} .headline=${name}>
|
||||
<ha-state-icon slot="start" .stateObj=${entityState}></ha-state-icon>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-list-item-base>
|
||||
<ha-tooltip .for=${id} .distance=${-4}>
|
||||
@@ -841,7 +939,7 @@ class HaConfigAreaPage extends LitElement {
|
||||
if (entry) {
|
||||
url = `/config/script/edit/${entry.unique_id}`;
|
||||
}
|
||||
return this._renderLink(name, url);
|
||||
return this._renderLink(name, entityState, url);
|
||||
}
|
||||
|
||||
private async _findRelated() {
|
||||
@@ -1017,6 +1115,11 @@ class HaConfigAreaPage extends LitElement {
|
||||
ha-list-item-button ha-icon-next {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
img[slot="start"] {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
object-fit: contain;
|
||||
}
|
||||
ha-card > *:first-child {
|
||||
margin-top: -16px;
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ import { documentationUrl } from "../../../util/documentation-url";
|
||||
import { turnOnOffEntity } from "../../lovelace/common/entity/turn-on-off-entity";
|
||||
import { showAreaRegistryDetailDialog } from "../areas/show-dialog-area-registry-detail";
|
||||
import { showAssignCategoryDialog } from "../category/show-dialog-assign-category";
|
||||
import { renderCategoryGroupActions } from "../category/render-category-group-actions";
|
||||
import { categoryGroupActions } from "../category/category-group-actions";
|
||||
import { showCategoryRegistryDetailDialog } from "../category/show-dialog-category-registry-detail";
|
||||
import {
|
||||
getAreaTableColumn,
|
||||
@@ -537,6 +537,12 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
.columns=${this._columns(this.narrow, this.hass.localize, automations)}
|
||||
.initialGroupColumn=${this._tableSettings.grouping}
|
||||
.renderGroupActions=${this._groupActions(
|
||||
"automation",
|
||||
this._categories,
|
||||
this._tableSettings.grouping,
|
||||
this._categoryDeleted
|
||||
)}
|
||||
.initialCollapsedGroups=${this._activeCollapsed}
|
||||
.initialSorting=${this._tableSettings.sorting}
|
||||
.columnOrder=${this._activeColumnOrder}
|
||||
@@ -565,12 +571,6 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
.path=${mdiHelpCircleOutline}
|
||||
@click=${this._showHelp}
|
||||
></ha-icon-button>
|
||||
${renderCategoryGroupActions(
|
||||
"automation",
|
||||
this._categories,
|
||||
this._tableSettings.grouping,
|
||||
this._categoryDeleted
|
||||
)}
|
||||
<ha-filter-floor-areas
|
||||
.type=${"automation"}
|
||||
.value=${this._filters["ha-filter-floor-areas"]?.value}
|
||||
@@ -941,6 +941,8 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
});
|
||||
};
|
||||
|
||||
private _groupActions = memoizeOne(categoryGroupActions);
|
||||
|
||||
private _categoryDeleted = (categoryId: string) => {
|
||||
const value = this._filters["ha-filter-categories"]?.value;
|
||||
|
||||
|
||||
+14
-16
@@ -1,6 +1,5 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { groupActionsSlot } from "../../../components/data-table/ha-data-table";
|
||||
import type { DataTableGroupActions } from "../../../components/data-table/ha-data-table";
|
||||
import type { CategoryRegistryEntry } from "../../../data/category_registry";
|
||||
import "./ha-category-group-actions";
|
||||
|
||||
@@ -8,22 +7,21 @@ import "./ha-category-group-actions";
|
||||
// group headers of a table grouped by category, which names its groups after
|
||||
// the categories. Other groupings can have groups with the same names.
|
||||
// onDeleted lets the page take a deleted category out of its filter.
|
||||
export const renderCategoryGroupActions = (
|
||||
export const categoryGroupActions = (
|
||||
scope: string,
|
||||
categories: CategoryRegistryEntry[] | undefined,
|
||||
grouping: string | undefined,
|
||||
onDeleted: (categoryId: string) => void
|
||||
) =>
|
||||
): DataTableGroupActions | undefined =>
|
||||
grouping === "category"
|
||||
? repeat(
|
||||
categories ?? [],
|
||||
(category) => category.category_id,
|
||||
(category) =>
|
||||
html`<ha-category-group-actions
|
||||
slot=${groupActionsSlot(category.name)}
|
||||
.category=${category}
|
||||
.scope=${scope}
|
||||
.onDeleted=${onDeleted}
|
||||
></ha-category-group-actions>`
|
||||
)
|
||||
: nothing;
|
||||
? (group) => {
|
||||
const category = categories?.find((cat) => cat.name === group);
|
||||
return category
|
||||
? html`<ha-category-group-actions
|
||||
.category=${category}
|
||||
.scope=${scope}
|
||||
.onDeleted=${onDeleted}
|
||||
></ha-category-group-actions>`
|
||||
: nothing;
|
||||
}
|
||||
: undefined;
|
||||
@@ -95,6 +95,7 @@ import {
|
||||
showAlertDialog,
|
||||
showConfirmationDialog,
|
||||
} from "../../../dialogs/generic/show-dialog-box";
|
||||
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
|
||||
import { showVoiceAssistantSetupDialog } from "../../../dialogs/voice-assistant-setup/show-voice-assistant-setup-dialog";
|
||||
import "../../../layouts/hass-error-screen";
|
||||
import "../../../layouts/hass-subpage";
|
||||
@@ -994,7 +995,13 @@ export class HaConfigDevicePage extends LitElement {
|
||||
(batteryDomain === "binary_sensor" ||
|
||||
!Number.isNaN(Number(battery.state)))
|
||||
? html`
|
||||
<div class="battery">
|
||||
<button
|
||||
class="battery"
|
||||
aria-label=${`${this.hass.formatEntityName(
|
||||
battery
|
||||
)} ${this.hass.formatEntityState(battery)}`}
|
||||
@click=${this._showBatteryMoreInfo}
|
||||
>
|
||||
${
|
||||
batteryDomain === "sensor"
|
||||
? this.hass.formatEntityState(battery)
|
||||
@@ -1004,7 +1011,7 @@ export class HaConfigDevicePage extends LitElement {
|
||||
.batteryStateObj=${battery}
|
||||
.batteryChargingStateObj=${batteryChargingState}
|
||||
></ha-battery-icon>
|
||||
</div>
|
||||
</button>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
@@ -1387,6 +1394,15 @@ export class HaConfigDevicePage extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _showBatteryMoreInfo() {
|
||||
const batteryEntity = this._batteryEntity(
|
||||
this._entities(this.deviceId, this._entityReg, this.hass.devices)
|
||||
);
|
||||
if (batteryEntity) {
|
||||
showMoreInfoDialog(this, { entityId: batteryEntity.entity_id });
|
||||
}
|
||||
}
|
||||
|
||||
private _computeEntityName(
|
||||
entity: EntityRegistryEntry,
|
||||
devices: HomeAssistant["devices"]
|
||||
@@ -1756,6 +1772,19 @@ export class HaConfigDevicePage extends LitElement {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
}
|
||||
|
||||
.battery:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.column > *:not(:first-child) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { consume } from "../../../common/decorators/consume";
|
||||
import { storage } from "../../../common/decorators/storage";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { computeAreaName } from "../../../common/entity/compute_area_name";
|
||||
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
|
||||
import { computeFloorName } from "../../../common/entity/compute_floor_name";
|
||||
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
|
||||
@@ -28,13 +29,13 @@ import {
|
||||
} from "../../../common/integrations/protocolIntegrationPicked";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import { deepEqual } from "../../../common/util/deep-equal";
|
||||
import {
|
||||
devicesFilterQueryParamConfig,
|
||||
devicesFilterQueryParams,
|
||||
devicesFilterQueryString,
|
||||
devicesLinkFromUrl,
|
||||
} from "../../../common/url/devices-query-params";
|
||||
import { deepEqual } from "../../../common/util/deep-equal";
|
||||
import {
|
||||
hasRejectedItems,
|
||||
rejectedItems,
|
||||
@@ -47,8 +48,6 @@ import type {
|
||||
import "../../../components/data-table/ha-data-table-labels";
|
||||
import "../../../components/entity/ha-battery-icon";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/skeleton/ha-skeleton-icon";
|
||||
import "../../../components/skeleton/ha-skeleton-text";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
@@ -58,6 +57,8 @@ import "../../../components/ha-filter-integrations";
|
||||
import "../../../components/ha-filter-labels";
|
||||
import "../../../components/ha-filter-states";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/skeleton/ha-skeleton-icon";
|
||||
import "../../../components/skeleton/ha-skeleton-text";
|
||||
import { createAreaRegistryEntry } from "../../../data/area/area_registry";
|
||||
import type { ConfigEntry, SubEntry } from "../../../data/config_entries";
|
||||
import { getSubEntries, sortConfigEntries } from "../../../data/config_entries";
|
||||
@@ -67,7 +68,6 @@ import {
|
||||
deserializeFilters,
|
||||
serializeFilters,
|
||||
} from "../../../data/data_table_filters";
|
||||
import { computeDeviceAreaLabel } from "../../../data/device/device_picker";
|
||||
import type {
|
||||
DeviceEntityLookup,
|
||||
DeviceRegistryEntry,
|
||||
@@ -451,31 +451,19 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
const parentDevice = device.parent_device_id
|
||||
? this.hass.devices[device.parent_device_id]
|
||||
: undefined;
|
||||
const viaDevice = device.via_device_id
|
||||
? this.hass.devices[device.via_device_id]
|
||||
: undefined;
|
||||
// The device that identifies this device's family: its parent for a
|
||||
// child device, itself for a device that has children.
|
||||
const familyParentDevice =
|
||||
parentDevice ??
|
||||
(deviceIdsWithChildren.has(device.id) ? device : undefined);
|
||||
|
||||
const { areaName } = computeDeviceAreaLabel(
|
||||
device,
|
||||
this.hass.areas,
|
||||
this.hass.devices,
|
||||
this.hass.localize,
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata
|
||||
);
|
||||
const area = getDeviceArea(device, areas, this.hass.devices);
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
|
||||
const floorArea =
|
||||
getDeviceArea(device, areas, this.hass.devices) ??
|
||||
(device.via_device_id && this.hass.devices[device.via_device_id]
|
||||
? getDeviceArea(
|
||||
this.hass.devices[device.via_device_id],
|
||||
areas,
|
||||
this.hass.devices
|
||||
)
|
||||
: undefined);
|
||||
const floorId = floorArea?.floor_id;
|
||||
const floorId = area?.floor_id;
|
||||
const floorName =
|
||||
floorId && this.hass.floors?.[floorId]
|
||||
? computeFloorName(this.hass.floors[floorId])
|
||||
@@ -511,6 +499,9 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
parent_device_name: parentDevice
|
||||
? computeDeviceNameDisplay(parentDevice, this.hass.localize)
|
||||
: "",
|
||||
via_device_name: viaDevice
|
||||
? computeDeviceNameDisplay(viaDevice, this.hass.localize)
|
||||
: undefined,
|
||||
// Grouping key that keeps a device with its family: children group
|
||||
// under their parent's name, a parent groups under its own name, and
|
||||
// standalone devices stay ungrouped. The name is always computed from
|
||||
@@ -634,6 +625,15 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
minWidth: "120px",
|
||||
template: (device) => device.parent_device_name || "",
|
||||
},
|
||||
via_device_name: {
|
||||
title: localize("ui.panel.config.devices.data_table.connected_via"),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
groupable: true,
|
||||
urlName: "via",
|
||||
defaultHidden: true,
|
||||
minWidth: "120px",
|
||||
},
|
||||
manufacturer: {
|
||||
title: localize("ui.panel.config.devices.data_table.manufacturer"),
|
||||
sortable: true,
|
||||
|
||||
+95
-67
@@ -1,10 +1,11 @@
|
||||
import {
|
||||
mdiAlertCircle,
|
||||
mdiDelete,
|
||||
mdiWater,
|
||||
mdiDragHorizontalVariant,
|
||||
mdiFire,
|
||||
mdiPencil,
|
||||
mdiPlus,
|
||||
mdiWater,
|
||||
} from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
@@ -24,9 +25,13 @@ import type {
|
||||
EnergyPreferences,
|
||||
EnergyPreferencesValidation,
|
||||
EnergyValidationIssue,
|
||||
WaterGasResource,
|
||||
} from "../../../../data/energy";
|
||||
import {
|
||||
computeEnergyLabel,
|
||||
getEnergyGasUnitClass,
|
||||
getWaterGasDevices,
|
||||
getWaterGasDevicesKey,
|
||||
saveEnergyPreferences,
|
||||
} from "../../../../data/energy";
|
||||
import type { StatisticsMetaData } from "../../../../data/recorder";
|
||||
@@ -37,14 +42,22 @@ import {
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { documentationUrl } from "../../../../util/documentation-url";
|
||||
import { showEnergySettingsDeviceWaterDialog } from "../dialogs/show-dialogs-energy";
|
||||
import { showEnergySettingsDeviceWaterGasDialog } from "../dialogs/show-dialogs-energy";
|
||||
import "./ha-energy-validation-result";
|
||||
import { energyCardStyles } from "./styles";
|
||||
import type { LocalizeKeys } from "../../../../common/translations/localize";
|
||||
|
||||
@customElement("ha-energy-device-settings-water")
|
||||
export class EnergyDeviceSettingsWater extends LitElement {
|
||||
const ICONS: Record<WaterGasResource, string> = {
|
||||
water: mdiWater,
|
||||
gas: mdiFire,
|
||||
};
|
||||
|
||||
@customElement("ha-energy-device-settings-water-gas")
|
||||
export class EnergyDeviceSettingsWaterGas extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public resource!: WaterGasResource;
|
||||
|
||||
@property({ attribute: false })
|
||||
public preferences!: EnergyPreferences;
|
||||
|
||||
@@ -54,34 +67,51 @@ export class EnergyDeviceSettingsWater extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public validationResult?: EnergyPreferencesValidation;
|
||||
|
||||
private get _devices(): DeviceConsumptionEnergyPreference[] {
|
||||
return getWaterGasDevices(this.preferences, this.resource);
|
||||
}
|
||||
|
||||
private _withDevices(
|
||||
devices: DeviceConsumptionEnergyPreference[]
|
||||
): EnergyPreferences {
|
||||
return {
|
||||
...this.preferences,
|
||||
[getWaterGasDevicesKey(this.resource)]: devices,
|
||||
} as EnergyPreferences;
|
||||
}
|
||||
|
||||
private _t(key: string): string {
|
||||
return this.hass.localize(
|
||||
`ui.panel.config.energy.device_consumption_${this.resource}.${key}` as LocalizeKeys
|
||||
);
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const devices = this._devices;
|
||||
const validation =
|
||||
this.validationResult?.[getWaterGasDevicesKey(this.resource)];
|
||||
|
||||
return html`
|
||||
<ha-card>
|
||||
<h1 class="card-header">
|
||||
<ha-svg-icon .path=${mdiWater}></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption_water.title"
|
||||
)}
|
||||
<ha-svg-icon .path=${ICONS[this.resource]}></ha-svg-icon>
|
||||
${this._t("title")}
|
||||
</h1>
|
||||
|
||||
<div class="card-content">
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption_water.sub"
|
||||
)}
|
||||
${this._t("sub")}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href=${documentationUrl(
|
||||
this.hass,
|
||||
"/docs/energy/water/#individual-devices"
|
||||
`/docs/energy/${this.resource}/#individual-${this.resource}-devices`
|
||||
)}
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption_water.learn_more"
|
||||
)}</a
|
||||
>${this._t("learn_more")}</a
|
||||
>
|
||||
</p>
|
||||
${this.validationResult?.device_consumption_water.map(
|
||||
${validation?.map(
|
||||
(result) => html`
|
||||
<ha-energy-validation-result
|
||||
.hass=${this.hass}
|
||||
@@ -90,7 +120,7 @@ export class EnergyDeviceSettingsWater extends LitElement {
|
||||
`
|
||||
)}
|
||||
${
|
||||
this.preferences.device_consumption_water.length > 0
|
||||
devices.length > 0
|
||||
? html`
|
||||
<div class="items-container">
|
||||
<ha-sortable
|
||||
@@ -99,7 +129,7 @@ export class EnergyDeviceSettingsWater extends LitElement {
|
||||
>
|
||||
<div class="devices">
|
||||
${repeat(
|
||||
this.preferences.device_consumption_water,
|
||||
devices,
|
||||
(device) => device.stat_consumption,
|
||||
(device, index) => html`
|
||||
<div class="row" .device=${device}>
|
||||
@@ -110,9 +140,7 @@ export class EnergyDeviceSettingsWater extends LitElement {
|
||||
</div>
|
||||
${this._renderName(device)}
|
||||
${this._renderIssueIndicator(
|
||||
this.validationResult?.device_consumption_water[
|
||||
index
|
||||
],
|
||||
validation?.[index],
|
||||
index
|
||||
)}
|
||||
<ha-icon-button
|
||||
@@ -138,9 +166,7 @@ export class EnergyDeviceSettingsWater extends LitElement {
|
||||
<div class="row">
|
||||
<ha-button @click=${this._addDevice} appearance="filled" size="s">
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption_water.add_device"
|
||||
)}</ha-button
|
||||
>${this._t("add_device")}</ha-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -203,64 +229,69 @@ export class EnergyDeviceSettingsWater extends LitElement {
|
||||
private _itemMoved(ev: CustomEvent): void {
|
||||
ev.stopPropagation();
|
||||
const { oldIndex, newIndex } = ev.detail;
|
||||
const devices = this.preferences.device_consumption_water.concat();
|
||||
const devices = this._devices.concat();
|
||||
const device = devices.splice(oldIndex, 1)[0];
|
||||
devices.splice(newIndex, 0, device);
|
||||
|
||||
const newPrefs = {
|
||||
...this.preferences,
|
||||
device_consumption_water: devices,
|
||||
};
|
||||
const newPrefs = this._withDevices(devices);
|
||||
fireEvent(this, "value-changed", { value: newPrefs });
|
||||
this._savePreferences(newPrefs);
|
||||
}
|
||||
|
||||
private _allowedUnitClass(excludeSource?: string) {
|
||||
return this.resource === "gas"
|
||||
? getEnergyGasUnitClass(
|
||||
this.preferences,
|
||||
excludeSource,
|
||||
this.statsMetadata,
|
||||
"device"
|
||||
)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private _editDevice(ev) {
|
||||
const origDevice: DeviceConsumptionEnergyPreference =
|
||||
ev.currentTarget.closest(".row").device;
|
||||
showEnergySettingsDeviceWaterDialog(this, {
|
||||
showEnergySettingsDeviceWaterGasDialog(this, {
|
||||
resource: this.resource,
|
||||
statsMetadata: this.statsMetadata,
|
||||
device: { ...origDevice },
|
||||
device_consumptions: this.preferences
|
||||
.device_consumption_water as DeviceConsumptionEnergyPreference[],
|
||||
allowedUnitClass: this._allowedUnitClass(origDevice.stat_consumption),
|
||||
device_consumptions: this._devices,
|
||||
saveCallback: async (newDevice) => {
|
||||
const newPrefs = {
|
||||
...this.preferences,
|
||||
device_consumption_water:
|
||||
this.preferences.device_consumption_water.map((d) =>
|
||||
d === origDevice ? newDevice : d
|
||||
),
|
||||
};
|
||||
this._sanitizeParents(newPrefs);
|
||||
await this._savePreferences(newPrefs);
|
||||
const newPrefs = this._withDevices(
|
||||
this._devices.map((d) => (d === origDevice ? newDevice : d))
|
||||
);
|
||||
await this._savePreferences(this._sanitizeParents(newPrefs));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _addDevice() {
|
||||
showEnergySettingsDeviceWaterDialog(this, {
|
||||
showEnergySettingsDeviceWaterGasDialog(this, {
|
||||
resource: this.resource,
|
||||
statsMetadata: this.statsMetadata,
|
||||
device_consumptions: this.preferences
|
||||
.device_consumption_water as DeviceConsumptionEnergyPreference[],
|
||||
allowedUnitClass: this._allowedUnitClass(),
|
||||
device_consumptions: this._devices,
|
||||
saveCallback: async (device) => {
|
||||
await this._savePreferences({
|
||||
...this.preferences,
|
||||
device_consumption_water:
|
||||
this.preferences.device_consumption_water.concat(device),
|
||||
});
|
||||
await this._savePreferences(
|
||||
this._withDevices(this._devices.concat(device))
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _sanitizeParents(prefs: EnergyPreferences) {
|
||||
const statIds = prefs.device_consumption_water.map(
|
||||
(d) => d.stat_consumption
|
||||
);
|
||||
prefs.device_consumption_water.forEach((d) => {
|
||||
if (d.included_in_stat && !statIds.includes(d.included_in_stat)) {
|
||||
delete d.included_in_stat;
|
||||
}
|
||||
});
|
||||
private _sanitizeParents(prefs: EnergyPreferences): EnergyPreferences {
|
||||
const devices = getWaterGasDevices(prefs, this.resource);
|
||||
const statIds = devices.map((d) => d.stat_consumption);
|
||||
return {
|
||||
...prefs,
|
||||
[getWaterGasDevicesKey(this.resource)]: devices.map((d) =>
|
||||
d.included_in_stat && !statIds.includes(d.included_in_stat)
|
||||
? { ...d, included_in_stat: undefined }
|
||||
: d
|
||||
),
|
||||
} as EnergyPreferences;
|
||||
}
|
||||
|
||||
private async _deleteDevice(ev) {
|
||||
@@ -276,14 +307,11 @@ export class EnergyDeviceSettingsWater extends LitElement {
|
||||
}
|
||||
|
||||
try {
|
||||
const newPrefs = {
|
||||
...this.preferences,
|
||||
device_consumption_water:
|
||||
this.preferences.device_consumption_water.filter(
|
||||
(device) => device !== deviceToDelete
|
||||
),
|
||||
};
|
||||
this._sanitizeParents(newPrefs);
|
||||
const newPrefs = this._sanitizeParents(
|
||||
this._withDevices(
|
||||
this._devices.filter((device) => device !== deviceToDelete)
|
||||
)
|
||||
);
|
||||
await this._savePreferences(newPrefs);
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, { title: `Failed to save config: ${err.message}` });
|
||||
@@ -330,6 +358,6 @@ export class EnergyDeviceSettingsWater extends LitElement {
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-energy-device-settings-water": EnergyDeviceSettingsWater;
|
||||
"ha-energy-device-settings-water-gas": EnergyDeviceSettingsWaterGas;
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,8 @@ export class EnergyGasSettings extends LitElement {
|
||||
allowedGasUnitClass: getEnergyGasUnitClass(
|
||||
this.preferences,
|
||||
undefined,
|
||||
this.statsMetadata
|
||||
this.statsMetadata,
|
||||
"source"
|
||||
),
|
||||
gas_sources: this.preferences.energy_sources.filter(
|
||||
(src) => src.type === "gas"
|
||||
@@ -175,7 +176,8 @@ export class EnergyGasSettings extends LitElement {
|
||||
allowedGasUnitClass: getEnergyGasUnitClass(
|
||||
this.preferences,
|
||||
origSource.stat_energy_from,
|
||||
this.statsMetadata
|
||||
this.statsMetadata,
|
||||
"source"
|
||||
),
|
||||
gas_sources: this.preferences.energy_sources.filter(
|
||||
(src) => src.type === "gas"
|
||||
|
||||
+93
-55
@@ -10,7 +10,10 @@ import "../../../../components/ha-dialog-footer";
|
||||
import "../../../../components/input/ha-input";
|
||||
import "./ha-energy-upstream-device-picker";
|
||||
import type { HaInput } from "../../../../components/input/ha-input";
|
||||
import type { DeviceConsumptionEnergyPreference } from "../../../../data/energy";
|
||||
import type {
|
||||
DeviceConsumptionEnergyPreference,
|
||||
WaterGasResource,
|
||||
} from "../../../../data/energy";
|
||||
import {
|
||||
computeEnergyLabel,
|
||||
energyStatisticHelpUrl,
|
||||
@@ -26,22 +29,22 @@ import { haStyleDialog } from "../../../../resources/styles";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
|
||||
import type { IntegralUnits } from "./energy-integral";
|
||||
import { confirmIntegrals, integralUnits } from "./energy-integral";
|
||||
import type { EnergySettingsDeviceWaterDialogParams } from "./show-dialogs-energy";
|
||||
import type { EnergySettingsDeviceWaterGasDialogParams } from "./show-dialogs-energy";
|
||||
import type { LocalizeKeys } from "../../../../common/translations/localize";
|
||||
|
||||
const volumeUnitClasses = ["volume"];
|
||||
const flowRateUnitClasses = ["volume_flow_rate"];
|
||||
|
||||
// Without a water sensor, saving offers to create one from the flow rate sensor
|
||||
// Without a consumption sensor, saving offers to create one from the flow rate sensor
|
||||
type DeviceConsumptionDraft = Partial<DeviceConsumptionEnergyPreference>;
|
||||
|
||||
@customElement("dialog-energy-device-settings-water")
|
||||
export class DialogEnergyDeviceSettingsWater
|
||||
@customElement("dialog-energy-device-settings-water-gas")
|
||||
export class DialogEnergyDeviceSettingsWaterGas
|
||||
extends DirtyStateProviderMixin<DeviceConsumptionDraft | null>()(LitElement)
|
||||
implements HassDialog<EnergySettingsDeviceWaterDialogParams>
|
||||
implements HassDialog<EnergySettingsDeviceWaterGasDialogParams>
|
||||
{
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _params?: EnergySettingsDeviceWaterDialogParams;
|
||||
@state() private _params?: EnergySettingsDeviceWaterGasDialogParams;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@@ -49,6 +52,8 @@ export class DialogEnergyDeviceSettingsWater
|
||||
|
||||
@state() private _volume_units?: string[];
|
||||
|
||||
@state() private _energy_units?: string[];
|
||||
|
||||
@state() private _flow_rate_units?: string[];
|
||||
|
||||
@state() private _error?: string;
|
||||
@@ -62,7 +67,7 @@ export class DialogEnergyDeviceSettingsWater
|
||||
private _possibleParents: DeviceConsumptionEnergyPreference[] = [];
|
||||
|
||||
public async showDialog(
|
||||
params: EnergySettingsDeviceWaterDialogParams
|
||||
params: EnergySettingsDeviceWaterGasDialogParams
|
||||
): Promise<void> {
|
||||
this._params = params;
|
||||
this._device = this._params.device;
|
||||
@@ -70,8 +75,13 @@ export class DialogEnergyDeviceSettingsWater
|
||||
this._saving = false;
|
||||
this._computePossibleParents();
|
||||
this._volume_units = (
|
||||
await getSensorDeviceClassConvertibleUnits(this.hass, "water")
|
||||
await getSensorDeviceClassConvertibleUnits(this.hass, params.resource)
|
||||
).units;
|
||||
this._energy_units =
|
||||
params.resource === "gas"
|
||||
? (await getSensorDeviceClassConvertibleUnits(this.hass, "energy"))
|
||||
.units
|
||||
: [];
|
||||
this._flow_rate_units = (
|
||||
await getSensorDeviceClassConvertibleUnits(this.hass, "volume_flow_rate")
|
||||
).units;
|
||||
@@ -86,6 +96,38 @@ export class DialogEnergyDeviceSettingsWater
|
||||
this._initDirtyTracking({ type: "deep" }, this._device ?? null);
|
||||
}
|
||||
|
||||
private get _resource(): WaterGasResource {
|
||||
return this._params!.resource;
|
||||
}
|
||||
|
||||
private _t(key: string, params?: Record<string, string>): string {
|
||||
return this.hass.localize(
|
||||
`ui.panel.config.energy.device_consumption_${this._resource}.dialog.${key}` as LocalizeKeys,
|
||||
params as any
|
||||
);
|
||||
}
|
||||
|
||||
private get _unitClasses(): string[] {
|
||||
if (this._params?.allowedUnitClass) {
|
||||
return [this._params.allowedUnitClass];
|
||||
}
|
||||
return this._resource === "gas" ? ["volume", "energy"] : ["volume"];
|
||||
}
|
||||
|
||||
private get _deviceClasses(): string[] | undefined {
|
||||
if (this._resource === "water") {
|
||||
return undefined;
|
||||
}
|
||||
switch (this._params?.allowedUnitClass) {
|
||||
case "volume":
|
||||
return ["gas"];
|
||||
case "energy":
|
||||
return ["energy"];
|
||||
default:
|
||||
return ["gas", "energy"];
|
||||
}
|
||||
}
|
||||
|
||||
private _computePossibleParents() {
|
||||
if (!this._device || !this._params) {
|
||||
this._possibleParents = [];
|
||||
@@ -101,7 +143,7 @@ export class DialogEnergyDeviceSettingsWater
|
||||
}
|
||||
});
|
||||
}
|
||||
// Saving without a water sensor can reuse the original one
|
||||
// Saving without a consumption sensor can reuse the original one
|
||||
const stat =
|
||||
this._device.stat_consumption ?? this._params.device?.stat_consumption;
|
||||
if (stat) {
|
||||
@@ -134,7 +176,15 @@ export class DialogEnergyDeviceSettingsWater
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const pickableUnit = this._volume_units?.join(", ") || "";
|
||||
const resource = this._resource;
|
||||
const pickableUnit = [
|
||||
...(this._params.allowedUnitClass === "energy"
|
||||
? []
|
||||
: this._volume_units || []),
|
||||
...(this._params.allowedUnitClass === "volume"
|
||||
? []
|
||||
: this._energy_units || []),
|
||||
].join(", ");
|
||||
|
||||
const labelStatistic =
|
||||
this._device?.stat_consumption ?? this._device?.stat_rate;
|
||||
@@ -142,9 +192,7 @@ export class DialogEnergyDeviceSettingsWater
|
||||
return html`
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
header-title=${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption_water.dialog.header"
|
||||
)}
|
||||
header-title=${this._t("header")}
|
||||
.preventScrimClose=${this.isDirtyState}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
@@ -152,42 +200,33 @@ export class DialogEnergyDeviceSettingsWater
|
||||
|
||||
<ha-statistic-picker
|
||||
.helpMissingEntityUrl=${energyStatisticHelpUrl}
|
||||
.includeUnitClass=${volumeUnitClasses}
|
||||
.includeUnitClass=${this._unitClasses}
|
||||
.includeDeviceClass=${this._deviceClasses}
|
||||
.value=${this._device?.stat_consumption}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption_water.dialog.device_consumption_water"
|
||||
)}
|
||||
.label=${this._t(`device_consumption_${resource}`)}
|
||||
.excludeStatistics=${this._excludeList}
|
||||
@value-changed=${this._statisticChanged}
|
||||
.disabled=${this._saving}
|
||||
.helper=${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption_water.dialog.selected_stat_intro",
|
||||
{ unit: pickableUnit }
|
||||
)}
|
||||
.helper=${this._t("selected_stat_intro", { unit: pickableUnit })}
|
||||
autofocus
|
||||
></ha-statistic-picker>
|
||||
|
||||
<ha-statistic-picker
|
||||
.includeUnitClass=${flowRateUnitClasses}
|
||||
.value=${this._device?.stat_rate}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption_water.dialog.device_consumption_water_flow_rate"
|
||||
)}
|
||||
.label=${this._t(`device_consumption_${resource}_flow_rate`)}
|
||||
.excludeStatistics=${this._excludeListFlowRate}
|
||||
@value-changed=${this._flowRateStatisticChanged}
|
||||
.disabled=${this._saving}
|
||||
.invalid=${!!this._flowRateError()}
|
||||
.errorMessage=${this._flowRateError()}
|
||||
.helper=${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption_water.dialog.selected_stat_intro",
|
||||
{ unit: this._flow_rate_units?.join(", ") || "" }
|
||||
)}
|
||||
.helper=${this._t("selected_stat_intro", {
|
||||
unit: this._flow_rate_units?.join(", ") || "",
|
||||
})}
|
||||
></ha-statistic-picker>
|
||||
|
||||
<ha-input
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption_water.dialog.display_name"
|
||||
)}
|
||||
.label=${this._t("display_name")}
|
||||
type="text"
|
||||
.disabled=${!this._device || this._saving}
|
||||
.value=${this._device?.name || ""}
|
||||
@@ -207,18 +246,12 @@ export class DialogEnergyDeviceSettingsWater
|
||||
|
||||
<ha-energy-upstream-device-picker
|
||||
.hass=${this.hass}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption_water.dialog.included_in_device"
|
||||
)}
|
||||
.helper=${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption_water.dialog.included_in_device_helper"
|
||||
)}
|
||||
.label=${this._t("included_in_device")}
|
||||
.helper=${this._t("included_in_device_helper")}
|
||||
.value=${this._device?.included_in_stat}
|
||||
.possibleParents=${this._possibleParents}
|
||||
.statsMetadata=${this._params.statsMetadata}
|
||||
.emptyLabel=${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption_water.dialog.no_upstream_devices"
|
||||
)}
|
||||
.emptyLabel=${this._t("no_upstream_devices")}
|
||||
.disabled=${!this._device || this._saving}
|
||||
@value-changed=${this._parentChanged}
|
||||
></ha-energy-upstream-device-picker>
|
||||
@@ -258,7 +291,7 @@ export class DialogEnergyDeviceSettingsWater
|
||||
this._device =
|
||||
device.stat_consumption || device.stat_rate ? device : undefined;
|
||||
this._computePossibleParents();
|
||||
// The new water sensor can rule out the picked upstream device
|
||||
// The new consumption sensor can rule out the picked upstream device
|
||||
if (
|
||||
device.included_in_stat &&
|
||||
!this._possibleParents.some(
|
||||
@@ -332,14 +365,20 @@ export class DialogEnergyDeviceSettingsWater
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption_water.dialog.flow_rate_no_volume"
|
||||
return this._t(
|
||||
this._params?.allowedUnitClass === "energy"
|
||||
? "flow_rate_energy_sources"
|
||||
: "flow_rate_no_volume"
|
||||
);
|
||||
}
|
||||
|
||||
private _integralUnits(): IntegralUnits | undefined {
|
||||
const statRate = this._device?.stat_rate;
|
||||
if (!statRate || !this._volume_units) {
|
||||
if (
|
||||
!statRate ||
|
||||
!this._volume_units ||
|
||||
this._params?.allowedUnitClass === "energy"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return integralUnits(
|
||||
@@ -358,7 +397,7 @@ export class DialogEnergyDeviceSettingsWater
|
||||
this._saving = true;
|
||||
try {
|
||||
const statConsumption =
|
||||
draft.stat_consumption ?? (await this._waterFromFlowRate(draft));
|
||||
draft.stat_consumption ?? (await this._volumeFromFlowRate(draft));
|
||||
if (!statConsumption) {
|
||||
return;
|
||||
}
|
||||
@@ -383,7 +422,7 @@ export class DialogEnergyDeviceSettingsWater
|
||||
}
|
||||
}
|
||||
|
||||
private async _waterFromFlowRate(
|
||||
private async _volumeFromFlowRate(
|
||||
draft: DeviceConsumptionDraft
|
||||
): Promise<string | undefined> {
|
||||
const statRate = draft.stat_rate!;
|
||||
@@ -391,8 +430,9 @@ export class DialogEnergyDeviceSettingsWater
|
||||
if (!units) {
|
||||
return undefined;
|
||||
}
|
||||
const resource = this._resource;
|
||||
const sensors = await confirmIntegrals(this, this.hass, {
|
||||
deviceClass: "water",
|
||||
deviceClass: resource,
|
||||
states: this.hass.states,
|
||||
entities: this.hass.entities,
|
||||
requests: [
|
||||
@@ -400,7 +440,7 @@ export class DialogEnergyDeviceSettingsWater
|
||||
rate: statRate,
|
||||
units,
|
||||
name: this.hass.localize(
|
||||
"ui.panel.config.energy.integral.water.sensor_name",
|
||||
`ui.panel.config.energy.integral.${resource}.sensor_name` as LocalizeKeys,
|
||||
{
|
||||
name: computeEnergyLabel(
|
||||
this.hass.states,
|
||||
@@ -409,13 +449,11 @@ export class DialogEnergyDeviceSettingsWater
|
||||
this._params?.statsMetadata?.[statRate],
|
||||
draft.name
|
||||
),
|
||||
}
|
||||
} as any
|
||||
),
|
||||
},
|
||||
],
|
||||
intro: this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption_water.dialog.water_sensor_needed"
|
||||
),
|
||||
intro: this._t(`${resource}_sensor_needed`),
|
||||
exclude: this._excludeList,
|
||||
statsMetadata: this._params?.statsMetadata,
|
||||
});
|
||||
@@ -448,6 +486,6 @@ export class DialogEnergyDeviceSettingsWater
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"dialog-energy-device-settings-water": DialogEnergyDeviceSettingsWater;
|
||||
"dialog-energy-device-settings-water-gas": DialogEnergyDeviceSettingsWaterGas;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
SolarSourceTypeEnergyPreference,
|
||||
WaterSourceTypeEnergyPreference,
|
||||
HomeSourceTypeEnergyPreference,
|
||||
WaterGasResource,
|
||||
} from "../../../../data/energy";
|
||||
import type { StatisticsMetaData } from "../../../../data/recorder";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
@@ -63,10 +64,12 @@ export interface EnergySettingsDeviceDialogParams {
|
||||
saveCallback: (device: DeviceConsumptionEnergyPreference) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface EnergySettingsDeviceWaterDialogParams {
|
||||
export interface EnergySettingsDeviceWaterGasDialogParams {
|
||||
resource: WaterGasResource;
|
||||
device?: DeviceConsumptionEnergyPreference;
|
||||
device_consumptions: DeviceConsumptionEnergyPreference[];
|
||||
statsMetadata?: Record<string, StatisticsMetaData>;
|
||||
allowedUnitClass?: EnergyGasUnitClass;
|
||||
saveCallback: (device: DeviceConsumptionEnergyPreference) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -136,14 +139,14 @@ export const showEnergySettingsWaterDialog = (
|
||||
});
|
||||
};
|
||||
|
||||
export const showEnergySettingsDeviceWaterDialog = (
|
||||
export const showEnergySettingsDeviceWaterGasDialog = (
|
||||
element: HTMLElement,
|
||||
dialogParams: EnergySettingsDeviceWaterDialogParams
|
||||
dialogParams: EnergySettingsDeviceWaterGasDialogParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "dialog-energy-device-settings-water",
|
||||
dialogImport: () => import("./dialog-energy-device-settings-water"),
|
||||
dialogParams: dialogParams,
|
||||
dialogTag: "dialog-energy-device-settings-water-gas",
|
||||
dialogImport: () => import("./dialog-energy-device-settings-water-gas"),
|
||||
dialogParams,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant, Route } from "../../../types";
|
||||
import "../../../components/ha-alert";
|
||||
import "./components/ha-energy-device-settings";
|
||||
import "./components/ha-energy-device-settings-water";
|
||||
import "./components/ha-energy-device-settings-water-gas";
|
||||
import "./components/ha-energy-grid-settings";
|
||||
import "./components/ha-energy-solar-settings";
|
||||
import "./components/ha-energy-home-settings";
|
||||
@@ -46,6 +46,7 @@ const INITIAL_CONFIG: EnergyPreferences = {
|
||||
energy_sources: [],
|
||||
device_consumption: [],
|
||||
device_consumption_water: [],
|
||||
device_consumption_gas: [],
|
||||
};
|
||||
|
||||
const TABS: PageNavigation[] = [
|
||||
@@ -202,6 +203,14 @@ class HaConfigEnergy extends LitElement {
|
||||
.validationResult=${this._validationResult}
|
||||
@value-changed=${this._prefsChanged}
|
||||
></ha-energy-gas-settings>
|
||||
<ha-energy-device-settings-water-gas
|
||||
.hass=${this.hass}
|
||||
.resource=${"gas"}
|
||||
.preferences=${this._preferences!}
|
||||
.statsMetadata=${this._statsMetadata}
|
||||
.validationResult=${this._validationResult}
|
||||
@value-changed=${this._prefsChanged}
|
||||
></ha-energy-device-settings-water-gas>
|
||||
`;
|
||||
case "water":
|
||||
return html`
|
||||
@@ -212,13 +221,14 @@ class HaConfigEnergy extends LitElement {
|
||||
.validationResult=${this._validationResult}
|
||||
@value-changed=${this._prefsChanged}
|
||||
></ha-energy-water-settings>
|
||||
<ha-energy-device-settings-water
|
||||
<ha-energy-device-settings-water-gas
|
||||
.hass=${this.hass}
|
||||
.resource=${"water"}
|
||||
.preferences=${this._preferences!}
|
||||
.statsMetadata=${this._statsMetadata}
|
||||
.validationResult=${this._validationResult}
|
||||
@value-changed=${this._prefsChanged}
|
||||
></ha-energy-device-settings-water>
|
||||
></ha-energy-device-settings-water-gas>
|
||||
`;
|
||||
default:
|
||||
return nothing;
|
||||
@@ -343,7 +353,7 @@ class HaConfigEnergy extends LitElement {
|
||||
ha-energy-gas-settings,
|
||||
ha-energy-water-settings,
|
||||
ha-energy-device-settings,
|
||||
ha-energy-device-settings-water {
|
||||
ha-energy-device-settings-water-gas {
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@@ -1742,7 +1742,7 @@ ${rejected
|
||||
}
|
||||
}
|
||||
|
||||
// Alexa and Google Assistant can't be exposed to from the UI when they
|
||||
// Alexa and Google Home can't be exposed to from the UI when they
|
||||
// are configured manually in YAML, same as on the voice assistants expose page
|
||||
private _exposableAssistants = memoize(
|
||||
(cloudStatus: CloudStatus | undefined, hass: HomeAssistant) =>
|
||||
|
||||
@@ -38,3 +38,54 @@ export type Helper =
|
||||
type HelperWithoutId<T> = T extends { id: string } ? Omit<T, "id"> : never;
|
||||
|
||||
export type HelperFormItem = HelperWithoutId<Helper>;
|
||||
|
||||
export const HELPER_CATEGORIES = [
|
||||
"input",
|
||||
"time",
|
||||
"calculation",
|
||||
"other",
|
||||
] as const;
|
||||
|
||||
export type HelperCategory = (typeof HELPER_CATEGORIES)[number];
|
||||
|
||||
// Helper integrations have no category or short description in their
|
||||
// manifest, so the ones shipped with Home Assistant are listed here. Any other
|
||||
// helper falls into "other" and has no description.
|
||||
const HELPER_DOMAIN_CATEGORIES = {
|
||||
input_boolean: "input",
|
||||
input_button: "input",
|
||||
input_datetime: "input",
|
||||
input_number: "input",
|
||||
input_select: "input",
|
||||
input_text: "input",
|
||||
counter: "input",
|
||||
schedule: "time",
|
||||
timer: "time",
|
||||
tod: "time",
|
||||
history_stats: "time",
|
||||
derivative: "calculation",
|
||||
filter: "calculation",
|
||||
integration: "calculation",
|
||||
min_max: "calculation",
|
||||
mold_indicator: "calculation",
|
||||
random: "calculation",
|
||||
statistics: "calculation",
|
||||
threshold: "calculation",
|
||||
trend: "calculation",
|
||||
template: "calculation",
|
||||
utility_meter: "calculation",
|
||||
generic_hygrostat: "other",
|
||||
generic_thermostat: "other",
|
||||
group: "other",
|
||||
otp: "other",
|
||||
switch_as_x: "other",
|
||||
} as const satisfies Record<string, HelperCategory>;
|
||||
|
||||
export type KnownHelperDomain = keyof typeof HELPER_DOMAIN_CATEGORIES;
|
||||
|
||||
export const isKnownHelperDomain = (
|
||||
domain: string
|
||||
): domain is KnownHelperDomain => domain in HELPER_DOMAIN_CATEGORIES;
|
||||
|
||||
export const getHelperCategory = (domain: string): HelperCategory =>
|
||||
isKnownHelperDomain(domain) ? HELPER_DOMAIN_CATEGORIES[domain] : "other";
|
||||
@@ -1,32 +1,33 @@
|
||||
import { mdiAlertOutline } from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import Fuse from "fuse.js";
|
||||
import type { HassConfig } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { dynamicElement } from "../../../common/dom/dynamic-element-directive";
|
||||
import type {
|
||||
HASSDomCurrentTargetEvent,
|
||||
HASSDomEvent,
|
||||
HASSDomTargetEvent,
|
||||
} from "../../../common/dom/fire_event";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { stopPropagation } from "../../../common/dom/stop_propagation";
|
||||
import { stringCompare } from "../../../common/string/compare";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import "../../../components/ha-area-picker";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-combo-box-item";
|
||||
import "../../../components/ha-dialog";
|
||||
import "../../../components/ha-dialog-footer";
|
||||
import "../../../components/ha-domain-icon";
|
||||
import "../../../components/ha-icon-next";
|
||||
import "../../../components/ha-picker-combo-box";
|
||||
import type {
|
||||
HaPickerComboBox,
|
||||
PickerComboBoxIndexSelectedDetail,
|
||||
PickerComboBoxItem,
|
||||
} from "../../../components/ha-picker-combo-box";
|
||||
import "../../../components/ha-spinner";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import "../../../components/ha-tooltip";
|
||||
import "../../../components/input/ha-input-search";
|
||||
import type { HaInputSearch } from "../../../components/input/ha-input-search";
|
||||
import "../../../components/item/ha-list-item-option";
|
||||
import type { HaListItemOption } from "../../../components/item/ha-list-item-option";
|
||||
import "../../../components/list/ha-list-selectable";
|
||||
import type { HaListSelectable } from "../../../components/list/ha-list-selectable";
|
||||
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
|
||||
import { getConfigFlowHandlers } from "../../../data/config_flow";
|
||||
import { createCounter } from "../../../data/counter";
|
||||
import {
|
||||
@@ -51,11 +52,25 @@ import { createTimer } from "../../../data/timer";
|
||||
import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow";
|
||||
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
|
||||
import { haStyleDialog, haStyleScrollbar } from "../../../resources/styles";
|
||||
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
|
||||
import {
|
||||
multiTermSortedSearch,
|
||||
type FuseWeightedKey,
|
||||
} from "../../../resources/fuseMultiTerm";
|
||||
import { haStyleDialog } from "../../../resources/styles";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
|
||||
import { brandsUrl } from "../../../util/brands-url";
|
||||
import type { Helper, HelperDomain, HelperFormItem } from "./const";
|
||||
import { isHelperDomain } from "./const";
|
||||
import type {
|
||||
Helper,
|
||||
HelperCategory,
|
||||
HelperDomain,
|
||||
HelperFormItem,
|
||||
} from "./const";
|
||||
import {
|
||||
getHelperCategory,
|
||||
HELPER_CATEGORIES,
|
||||
isHelperDomain,
|
||||
isKnownHelperDomain,
|
||||
} from "./const";
|
||||
import type { ShowDialogHelperDetailParams } from "./show-dialog-helper-detail";
|
||||
|
||||
type HelperCreators = Record<
|
||||
@@ -116,6 +131,18 @@ const HELPERS: HelperCreators = {
|
||||
},
|
||||
};
|
||||
|
||||
interface HelperPickerItem extends PickerComboBoxItem {
|
||||
category: HelperCategory;
|
||||
loaded: boolean;
|
||||
}
|
||||
|
||||
const HELPER_SEARCH_KEYS: FuseWeightedKey[] = [
|
||||
{ name: "primary", weight: 10 },
|
||||
{ name: "search_labels.aliases", weight: 8 },
|
||||
{ name: "id", weight: 4 },
|
||||
{ name: "secondary", weight: 2 },
|
||||
];
|
||||
|
||||
@customElement("dialog-helper-detail")
|
||||
export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
{ item?: HelperFormItem; area?: string } | undefined
|
||||
@@ -128,6 +155,8 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@state() private _shown = false;
|
||||
|
||||
@state() private _domain?: string;
|
||||
|
||||
@state() private _error?: string;
|
||||
@@ -138,10 +167,11 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
|
||||
@state() private _loading = false;
|
||||
|
||||
@state() private _filter?: string;
|
||||
private _category?: HelperCategory;
|
||||
|
||||
@query("ha-input-search")
|
||||
private _searchInput?: HTMLElement & { updateComplete?: Promise<unknown> };
|
||||
private _search?: string;
|
||||
|
||||
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
|
||||
|
||||
private _pendingConfigFlow?: {
|
||||
startFlowHandler: string;
|
||||
@@ -157,6 +187,8 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
this._domain = params.domain;
|
||||
this._item = params.item;
|
||||
this._area = undefined;
|
||||
this._category = undefined;
|
||||
this._search = undefined;
|
||||
if (this._domain && this._domain in HELPERS) {
|
||||
this._loading = true;
|
||||
try {
|
||||
@@ -178,19 +210,41 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
this._helperFlows = flows;
|
||||
|
||||
await this.updateComplete;
|
||||
await this._focusSearchInput();
|
||||
this._comboBox?.refreshItems();
|
||||
this._focusPicker();
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>): void {
|
||||
super.updated(changedProps);
|
||||
const oldHass = changedProps.get("hass");
|
||||
if (
|
||||
oldHass &&
|
||||
(oldHass.localize !== this.hass.localize ||
|
||||
oldHass.config !== this.hass.config)
|
||||
) {
|
||||
this._comboBox?.refreshItems();
|
||||
}
|
||||
}
|
||||
|
||||
public closeDialog(): void {
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
private _dialogShown(ev: Event): void {
|
||||
if (ev.eventPhase !== Event.AT_TARGET) {
|
||||
return;
|
||||
}
|
||||
this._shown = true;
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._open = false;
|
||||
this._shown = false;
|
||||
this._error = undefined;
|
||||
this._domain = undefined;
|
||||
this._params = undefined;
|
||||
this._filter = undefined;
|
||||
this._category = undefined;
|
||||
this._search = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
|
||||
if (this._pendingConfigFlow) {
|
||||
@@ -211,6 +265,7 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
}
|
||||
let content: TemplateResult;
|
||||
let footer: TemplateResult | typeof nothing = nothing;
|
||||
let showPicker = false;
|
||||
|
||||
if (this._domain && !this._loading) {
|
||||
content = html`
|
||||
@@ -255,78 +310,34 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
} else if (this._loading || this._helperFlows === undefined) {
|
||||
content = html`<ha-spinner></ha-spinner>`;
|
||||
} else {
|
||||
const items = this._filterHelpers(
|
||||
HELPERS,
|
||||
this._helperFlows,
|
||||
this._filter
|
||||
);
|
||||
|
||||
showPicker = true;
|
||||
content = html`
|
||||
<ha-input-search
|
||||
appearance="outlined"
|
||||
<ha-picker-combo-box
|
||||
autofocus
|
||||
.value=${this._filter}
|
||||
@input=${this._filterChanged}
|
||||
.placeholder=${this.hass.localize(
|
||||
mode="dialog"
|
||||
.shown=${this._shown}
|
||||
no-sort
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.integrations.search_helper"
|
||||
)}
|
||||
></ha-input-search>
|
||||
<ha-list-selectable
|
||||
controlled
|
||||
aria-label=${this.hass.localize(
|
||||
"ui.panel.config.helpers.dialog.create_helper"
|
||||
.getItems=${this._getItems}
|
||||
.rowRenderer=${this._renderHelperRow}
|
||||
.sections=${this._getSections(
|
||||
this._helperFlows,
|
||||
this.hass.localize,
|
||||
this.hass.config,
|
||||
this.hass.locale.language
|
||||
)}
|
||||
@ha-list-item-selected=${this._domainPicked}
|
||||
>
|
||||
${items.map(([domain, label]) => {
|
||||
// Only OG helpers need to be loaded prior adding one
|
||||
const isLoaded =
|
||||
!(domain in HELPERS) ||
|
||||
isComponentLoaded(this.hass.config, domain);
|
||||
|
||||
return html`
|
||||
<ha-list-item-option appearance="line" .value=${domain}>
|
||||
<img
|
||||
slot="start"
|
||||
loading="lazy"
|
||||
alt=""
|
||||
src=${brandsUrl(
|
||||
{
|
||||
domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<span slot="headline">${label}</span>
|
||||
${
|
||||
isLoaded
|
||||
? html`<ha-icon-next slot="end"></ha-icon-next>`
|
||||
: html`<ha-svg-icon
|
||||
slot="end"
|
||||
.id="icon-${domain}"
|
||||
path=${mdiAlertOutline}
|
||||
@click=${stopPropagation}
|
||||
></ha-svg-icon>
|
||||
<ha-tooltip slot="end" .for="icon-${domain}">
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.helper_settings.platform_not_loaded",
|
||||
{ platform: domain }
|
||||
)}
|
||||
</ha-tooltip>`
|
||||
}
|
||||
</ha-list-item-option>
|
||||
`;
|
||||
})}
|
||||
</ha-list-selectable>
|
||||
.selectedSection=${this._category}
|
||||
@index-selected=${this._helperPicked}
|
||||
></ha-picker-combo-box>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
class=${showPicker ? "picker" : ""}
|
||||
.flexContent=${showPicker}
|
||||
.open=${this._open}
|
||||
.preventScrimClose=${this.isDirtyState}
|
||||
header-title=${
|
||||
@@ -346,6 +357,7 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
)
|
||||
: this.hass.localize("ui.panel.config.helpers.dialog.create_helper")
|
||||
}
|
||||
@after-show=${this._dialogShown}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
${content} ${footer}
|
||||
@@ -353,51 +365,148 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
`;
|
||||
}
|
||||
|
||||
private _filterHelpers = memoizeOne(
|
||||
private _getHelperItems = memoizeOne(
|
||||
(
|
||||
predefinedHelpers: HelperCreators,
|
||||
flowHelpers?: string[],
|
||||
filter?: string
|
||||
) => {
|
||||
const items: [string, string][] = [];
|
||||
|
||||
for (const helper of Object.keys(
|
||||
predefinedHelpers
|
||||
) as (keyof typeof predefinedHelpers)[]) {
|
||||
items.push([
|
||||
helper,
|
||||
this.hass.localize(`ui.panel.config.helpers.types.${helper}`) ||
|
||||
helper,
|
||||
]);
|
||||
}
|
||||
|
||||
if (flowHelpers) {
|
||||
for (const domain of flowHelpers) {
|
||||
items.push([domain, domainToName(this.hass.localize, domain)]);
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
.filter(([domain, label]) => {
|
||||
if (filter) {
|
||||
const lowerFilter = filter.toLowerCase();
|
||||
return (
|
||||
label.toLowerCase().includes(lowerFilter) ||
|
||||
domain.toLowerCase().includes(lowerFilter) ||
|
||||
(predefinedHelpers[domain as HelperDomain]?.alias || []).some(
|
||||
(alias) => alias.toLowerCase().includes(lowerFilter)
|
||||
)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
flowHelpers: string[],
|
||||
localize: LocalizeFunc,
|
||||
config: HassConfig,
|
||||
language: string
|
||||
): HelperPickerItem[] => {
|
||||
const domains = [
|
||||
...(Object.keys(HELPERS) as HelperDomain[]),
|
||||
...flowHelpers,
|
||||
];
|
||||
return domains
|
||||
.map((domain) => {
|
||||
const helperDomain = isHelperDomain(domain) ? domain : undefined;
|
||||
return {
|
||||
id: domain,
|
||||
primary:
|
||||
(helperDomain &&
|
||||
localize(`ui.panel.config.helpers.types.${helperDomain}`)) ||
|
||||
domainToName(localize, domain),
|
||||
secondary: isKnownHelperDomain(domain)
|
||||
? localize(
|
||||
`ui.panel.config.helpers.dialog.descriptions.${domain}`
|
||||
)
|
||||
: undefined,
|
||||
category: getHelperCategory(domain),
|
||||
// Only OG helpers need to be loaded prior adding one
|
||||
loaded: !helperDomain || isComponentLoaded(config, domain),
|
||||
search_labels: {
|
||||
aliases: helperDomain
|
||||
? (HELPERS[helperDomain].alias?.join(" ") ?? null)
|
||||
: null,
|
||||
},
|
||||
};
|
||||
})
|
||||
.sort((a, b) => stringCompare(a[1], b[1], this.hass.locale.language));
|
||||
.sort((a, b) => stringCompare(a.primary, b.primary, language));
|
||||
}
|
||||
);
|
||||
|
||||
private async _filterChanged(e: HASSDomTargetEvent<HaInputSearch>) {
|
||||
this._filter = e.target.value;
|
||||
}
|
||||
// Only offer the categories that have helpers.
|
||||
private _getSections = memoizeOne(
|
||||
(
|
||||
flowHelpers: string[],
|
||||
localize: LocalizeFunc,
|
||||
config: HassConfig,
|
||||
language: string
|
||||
) => {
|
||||
const items = this._getHelperItems(
|
||||
flowHelpers,
|
||||
localize,
|
||||
config,
|
||||
language
|
||||
);
|
||||
return HELPER_CATEGORIES.filter((category) =>
|
||||
items.some((item) => item.category === category)
|
||||
).map((category) => ({
|
||||
id: category,
|
||||
label: localize(
|
||||
`ui.panel.config.helpers.dialog.categories.${category}`
|
||||
),
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
private _fuseIndex = memoizeOne((items: HelperPickerItem[]) =>
|
||||
Fuse.createIndex(HELPER_SEARCH_KEYS, items)
|
||||
);
|
||||
|
||||
private _getItems = (
|
||||
search?: string,
|
||||
section?: string
|
||||
): (HelperPickerItem | string)[] => {
|
||||
this._category = section as HelperCategory | undefined;
|
||||
this._search = search;
|
||||
const allItems = this._getHelperItems(
|
||||
this._helperFlows ?? [],
|
||||
this.hass.localize,
|
||||
this.hass.config,
|
||||
this.hass.locale.language
|
||||
);
|
||||
const items = search
|
||||
? multiTermSortedSearch(
|
||||
allItems,
|
||||
search,
|
||||
(item) => item.id,
|
||||
this._fuseIndex(allItems)
|
||||
)
|
||||
: allItems;
|
||||
|
||||
if (this._category) {
|
||||
return items.filter((item) => item.category === this._category);
|
||||
}
|
||||
|
||||
// Group by category, keeping the search order within each one.
|
||||
return HELPER_CATEGORIES.flatMap((category) => {
|
||||
const categoryItems = items.filter((item) => item.category === category);
|
||||
return categoryItems.length
|
||||
? [
|
||||
this.hass.localize(
|
||||
`ui.panel.config.helpers.dialog.categories.${category}`
|
||||
),
|
||||
...categoryItems,
|
||||
]
|
||||
: [];
|
||||
});
|
||||
};
|
||||
|
||||
private _renderHelperRow = (item: PickerComboBoxItem) => {
|
||||
const { id: domain, primary, secondary, loaded } = item as HelperPickerItem;
|
||||
return html`
|
||||
<ha-combo-box-item>
|
||||
<ha-domain-icon
|
||||
slot="start"
|
||||
style="margin: var(--ha-space-1);"
|
||||
.domain=${domain}
|
||||
brand-fallback
|
||||
></ha-domain-icon>
|
||||
<span slot="headline">${primary}</span>
|
||||
${
|
||||
secondary
|
||||
? html`<span slot="supporting-text">${secondary}</span>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
loaded
|
||||
? html`<ha-icon-next slot="end"></ha-icon-next>`
|
||||
: html`<ha-svg-icon
|
||||
slot="end"
|
||||
.id="icon-${domain}"
|
||||
.path=${mdiAlertOutline}
|
||||
@click=${stopPropagation}
|
||||
></ha-svg-icon>
|
||||
<ha-tooltip slot="end" .for="icon-${domain}">
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.helper_settings.platform_not_loaded",
|
||||
{ platform: domain }
|
||||
)}
|
||||
</ha-tooltip>`
|
||||
}
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
};
|
||||
|
||||
private _valueChanged(ev: CustomEvent): void {
|
||||
this._item = ev.detail.value;
|
||||
@@ -498,18 +607,12 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
this._updateDirtyState({ item: this._item, area: this._area });
|
||||
}
|
||||
|
||||
private async _domainPicked(
|
||||
ev: HASSDomEvent<number> & HASSDomCurrentTargetEvent<HaListSelectable>
|
||||
private async _helperPicked(
|
||||
ev: HASSDomEvent<PickerComboBoxIndexSelectedDetail>
|
||||
): Promise<void> {
|
||||
const domain = (
|
||||
ev.currentTarget.items[ev.detail] as HaListItemOption | undefined
|
||||
)?.value;
|
||||
if (!domain) {
|
||||
return;
|
||||
}
|
||||
const isLoaded =
|
||||
!(domain in HELPERS) || isComponentLoaded(this.hass.config, domain);
|
||||
if (!isLoaded) {
|
||||
ev.stopPropagation();
|
||||
const { id: domain, loaded } = ev.detail.item as HelperPickerItem;
|
||||
if (!loaded) {
|
||||
showAlertDialog(this, {
|
||||
text: this.hass.localize(
|
||||
"ui.dialogs.helper_settings.platform_not_loaded",
|
||||
@@ -543,43 +646,35 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
}
|
||||
|
||||
private async _goBack() {
|
||||
const search = this._search;
|
||||
this._domain = undefined;
|
||||
this._item = undefined;
|
||||
this._area = undefined;
|
||||
this._error = undefined;
|
||||
await this.updateComplete;
|
||||
await this._focusSearchInput();
|
||||
await this._comboBox?.updateComplete;
|
||||
if (search) {
|
||||
this._comboBox?.setFieldValue(search);
|
||||
}
|
||||
this._focusPicker();
|
||||
}
|
||||
|
||||
private async _focusSearchInput() {
|
||||
const searchInput = this._searchInput;
|
||||
|
||||
if (!searchInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
await searchInput.updateComplete;
|
||||
searchInput.focus();
|
||||
private async _focusPicker() {
|
||||
await this._comboBox?.updateComplete;
|
||||
this._comboBox?.focus();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-dialog {
|
||||
--dialog-content-padding: 0;
|
||||
}
|
||||
ha-icon-next {
|
||||
width: var(--ha-space-6);
|
||||
}
|
||||
ha-list-item-option img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
object-fit: contain;
|
||||
}
|
||||
ha-tooltip {
|
||||
pointer-events: auto;
|
||||
@media all and (min-width: 451px) and (min-height: 501px) {
|
||||
ha-dialog.picker {
|
||||
--ha-dialog-min-height: min(620px, 90vh);
|
||||
}
|
||||
}
|
||||
.form {
|
||||
padding: var(--ha-space-6);
|
||||
@@ -588,23 +683,6 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
display: block;
|
||||
margin-top: var(--ha-space-4);
|
||||
}
|
||||
ha-input-search {
|
||||
margin: 0 var(--ha-space-4) var(--ha-space-3);
|
||||
}
|
||||
ha-list-selectable::part(base) {
|
||||
height: calc(60vh - 184px);
|
||||
}
|
||||
@media all and (max-width: 450px), all and (max-height: 500px) {
|
||||
ha-list-selectable::part(base) {
|
||||
height: calc(
|
||||
100vh -
|
||||
184px - var(--safe-area-inset-top, 0px) - var(
|
||||
--safe-area-inset-bottom,
|
||||
0px
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant, Route } from "../../../types";
|
||||
import { fileDownload } from "../../../util/file_download";
|
||||
import { showAssignCategoryDialog } from "../category/show-dialog-assign-category";
|
||||
import { renderCategoryGroupActions } from "../category/render-category-group-actions";
|
||||
import { categoryGroupActions } from "../category/category-group-actions";
|
||||
import { showCategoryRegistryDetailDialog } from "../category/show-dialog-category-registry-detail";
|
||||
import {
|
||||
getAreaTableColumn,
|
||||
@@ -718,6 +718,12 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
.columns=${this._columns(this.hass.localize, helpers)}
|
||||
.data=${helpers}
|
||||
.initialGroupColumn=${this._tableSettings.grouping}
|
||||
.renderGroupActions=${this._groupActions(
|
||||
"helpers",
|
||||
this._categories,
|
||||
this._tableSettings.grouping,
|
||||
this._categoryDeleted
|
||||
)}
|
||||
.initialCollapsedGroups=${this._activeCollapsed}
|
||||
.initialSorting=${this._tableSettings.sorting}
|
||||
.columnOrder=${this._activeColumnOrder}
|
||||
@@ -880,12 +886,6 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
.hass=${this.hass}
|
||||
slot="toolbar-icon"
|
||||
></ha-integration-overflow-menu>
|
||||
${renderCategoryGroupActions(
|
||||
"helpers",
|
||||
this._categories,
|
||||
this._tableSettings.grouping,
|
||||
this._categoryDeleted
|
||||
)}
|
||||
<ha-button slot="fab" size="l" @click=${this._createHelper}>
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize("ui.panel.config.helpers.picker.create_helper")}
|
||||
@@ -931,6 +931,8 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
this._applyFilters();
|
||||
}
|
||||
|
||||
private _groupActions = memoizeOne(categoryGroupActions);
|
||||
|
||||
private _categoryDeleted = (categoryId: string) => {
|
||||
const value = this._filters["ha-filter-categories"];
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ import { documentationUrl } from "../../../util/documentation-url";
|
||||
import { showToast } from "../../../util/toast";
|
||||
import { showAreaRegistryDetailDialog } from "../areas/show-dialog-area-registry-detail";
|
||||
import { showAssignCategoryDialog } from "../category/show-dialog-assign-category";
|
||||
import { renderCategoryGroupActions } from "../category/render-category-group-actions";
|
||||
import { categoryGroupActions } from "../category/category-group-actions";
|
||||
import { showCategoryRegistryDetailDialog } from "../category/show-dialog-category-registry-detail";
|
||||
import {
|
||||
getAreaTableColumn,
|
||||
@@ -535,6 +535,12 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
|
||||
.columns=${this._columns(this.hass.localize, scenes)}
|
||||
id="entity_id"
|
||||
.initialGroupColumn=${this._tableSettings.grouping}
|
||||
.renderGroupActions=${this._groupActions(
|
||||
"scene",
|
||||
this._categories,
|
||||
this._tableSettings.grouping,
|
||||
this._categoryDeleted
|
||||
)}
|
||||
.initialCollapsedGroups=${this._activeCollapsed}
|
||||
.initialSorting=${this._tableSettings.sorting}
|
||||
.columnOrder=${this._activeColumnOrder}
|
||||
@@ -563,12 +569,6 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
|
||||
.label=${this.hass.localize("ui.common.help")}
|
||||
.path=${mdiHelpCircleOutline}
|
||||
></ha-icon-button>
|
||||
${renderCategoryGroupActions(
|
||||
"scene",
|
||||
this._categories,
|
||||
this._tableSettings.grouping,
|
||||
this._categoryDeleted
|
||||
)}
|
||||
|
||||
<ha-filter-floor-areas
|
||||
.type=${"scene"}
|
||||
@@ -795,6 +795,8 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
|
||||
});
|
||||
};
|
||||
|
||||
private _groupActions = memoizeOne(categoryGroupActions);
|
||||
|
||||
private _categoryDeleted = (categoryId: string) => {
|
||||
const value = this._filters["ha-filter-categories"]?.value;
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ import { showToast } from "../../../util/toast";
|
||||
import { showAreaRegistryDetailDialog } from "../areas/show-dialog-area-registry-detail";
|
||||
import { showNewAutomationDialog } from "../automation/show-dialog-new-automation";
|
||||
import { showAssignCategoryDialog } from "../category/show-dialog-assign-category";
|
||||
import { renderCategoryGroupActions } from "../category/render-category-group-actions";
|
||||
import { categoryGroupActions } from "../category/category-group-actions";
|
||||
import { showCategoryRegistryDetailDialog } from "../category/show-dialog-category-registry-detail";
|
||||
import {
|
||||
getAreaTableColumn,
|
||||
@@ -453,6 +453,12 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
|
||||
)}
|
||||
has-filters
|
||||
.initialGroupColumn=${this._tableSettings.grouping}
|
||||
.renderGroupActions=${this._groupActions(
|
||||
"script",
|
||||
this._categories,
|
||||
this._tableSettings.grouping,
|
||||
this._categoryDeleted
|
||||
)}
|
||||
.initialCollapsedGroups=${this._activeCollapsed}
|
||||
.initialSorting=${this._tableSettings.sorting}
|
||||
.columnOrder=${this._activeColumnOrder}
|
||||
@@ -497,12 +503,6 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
|
||||
.path=${mdiHelpCircleOutline}
|
||||
@click=${this._showHelp}
|
||||
></ha-icon-button>
|
||||
${renderCategoryGroupActions(
|
||||
"script",
|
||||
this._categories,
|
||||
this._tableSettings.grouping,
|
||||
this._categoryDeleted
|
||||
)}
|
||||
<ha-filter-floor-areas
|
||||
.type=${"script"}
|
||||
.value=${this._filters["ha-filter-floor-areas"]?.value}
|
||||
@@ -739,6 +739,8 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
|
||||
});
|
||||
};
|
||||
|
||||
private _groupActions = memoizeOne(categoryGroupActions);
|
||||
|
||||
private _categoryDeleted = (categoryId: string) => {
|
||||
const value = this._filters["ha-filter-categories"]?.value;
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ export class EnergySetupWizard extends LitElement implements LovelaceCard {
|
||||
energy_sources: [],
|
||||
device_consumption: [],
|
||||
device_consumption_water: [],
|
||||
device_consumption_gas: [],
|
||||
};
|
||||
|
||||
public getCardSize() {
|
||||
|
||||
@@ -58,6 +58,9 @@ export const hasWaterSource = (prefs: EnergyPreferences): boolean =>
|
||||
export const hasWaterDevices = (prefs: EnergyPreferences): boolean =>
|
||||
(prefs.device_consumption_water?.length ?? 0) > 0;
|
||||
|
||||
export const hasGasDevices = (prefs: EnergyPreferences): boolean =>
|
||||
(prefs.device_consumption_gas?.length ?? 0) > 0;
|
||||
|
||||
export const hasDeviceConsumption = (prefs: EnergyPreferences): boolean =>
|
||||
prefs.device_consumption.length > 0;
|
||||
|
||||
@@ -78,6 +81,9 @@ export const hasPowerDevices = (prefs: EnergyPreferences): boolean =>
|
||||
export const hasWaterRateDevices = (prefs: EnergyPreferences): boolean =>
|
||||
(prefs.device_consumption_water ?? []).some((device) => device.stat_rate);
|
||||
|
||||
export const hasGasRateDevices = (prefs: EnergyPreferences): boolean =>
|
||||
prefs.device_consumption_gas.some((device) => device.stat_rate);
|
||||
|
||||
/** A water source exposing a live flow-rate statistic. */
|
||||
export const hasWaterRateSource = (prefs: EnergyPreferences): boolean =>
|
||||
prefs.energy_sources.some(
|
||||
@@ -96,7 +102,8 @@ export const hasNowViewContent = (prefs: EnergyPreferences): boolean =>
|
||||
hasPowerDevices(prefs) ||
|
||||
hasWaterRateSource(prefs) ||
|
||||
hasWaterRateDevices(prefs) ||
|
||||
hasGasRateSource(prefs);
|
||||
hasGasRateSource(prefs) ||
|
||||
hasGasRateDevices(prefs);
|
||||
|
||||
// --- Card catalog ----------------------------------------------------------
|
||||
|
||||
@@ -155,7 +162,7 @@ export const ENERGY_CARD_CATALOG: readonly EnergyCardCatalogEntry[] = [
|
||||
"overview",
|
||||
"energy-gas-graph",
|
||||
"ui.panel.energy.cards.energy_gas_graph_title",
|
||||
(p) => hasGasSource(p)
|
||||
(p) => hasGasSource(p) || hasGasDevices(p)
|
||||
),
|
||||
// One toggle gates the water row, which renders energy-water-graph (sources)
|
||||
// or, with only water devices, water-sankey.
|
||||
@@ -253,6 +260,9 @@ export const ENERGY_CARD_CATALOG: readonly EnergyCardCatalogEntry[] = [
|
||||
"ui.panel.energy.cards.energy_sources_table_title",
|
||||
(p) => hasGasSource(p)
|
||||
),
|
||||
entry("gas", "gas-sankey", "ui.panel.energy.cards.gas_sankey_title", (p) =>
|
||||
hasGasDevices(p)
|
||||
),
|
||||
|
||||
// --- Water ---
|
||||
entry(
|
||||
@@ -293,6 +303,12 @@ export const ENERGY_CARD_CATALOG: readonly EnergyCardCatalogEntry[] = [
|
||||
"ui.panel.energy.cards.water_flow_sankey_title",
|
||||
(p) => hasWaterRateDevices(p)
|
||||
),
|
||||
entry(
|
||||
"now",
|
||||
"gas-flow-sankey",
|
||||
"ui.panel.energy.cards.gas_flow_sankey_title",
|
||||
(p) => hasGasRateDevices(p)
|
||||
),
|
||||
];
|
||||
|
||||
// --- Lookup helpers --------------------------------------------------------
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { EnergyViewPath } from "./energy-cards";
|
||||
import {
|
||||
hasDeviceConsumption,
|
||||
hasEnergySource,
|
||||
hasGasDevices,
|
||||
hasGasSource,
|
||||
hasNowViewContent,
|
||||
hasPowerSources,
|
||||
@@ -91,7 +92,8 @@ export class EnergyDashboardStrategy extends ReactiveElement {
|
||||
!prefs ||
|
||||
(prefs.device_consumption.length === 0 &&
|
||||
prefs.energy_sources.length === 0 &&
|
||||
!hasWaterDevices(prefs))
|
||||
!hasWaterDevices(prefs) &&
|
||||
!hasGasDevices(prefs))
|
||||
) {
|
||||
await import("../cards/energy-setup-wizard-card");
|
||||
return {
|
||||
@@ -102,7 +104,7 @@ export class EnergyDashboardStrategy extends ReactiveElement {
|
||||
const hasEnergy = hasEnergySource(prefs);
|
||||
const hasPowerSource = hasPowerSources(prefs);
|
||||
const hasWater = hasWaterSource(prefs) || hasWaterDevices(prefs);
|
||||
const hasGas = hasGasSource(prefs);
|
||||
const hasGas = hasGasSource(prefs) || hasGasDevices(prefs);
|
||||
const hasDevices = hasDeviceConsumption(prefs);
|
||||
|
||||
const hidden = _config.hidden_cards;
|
||||
|
||||
@@ -8,7 +8,12 @@ import type { HomeAssistant } from "../../../types";
|
||||
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
|
||||
import type { LovelaceStrategyDependency } from "../../lovelace/strategies/types";
|
||||
import type { EnergyViewStrategyConfig } from "./energy-cards";
|
||||
import { hasWaterSource, isEnergyCardVisible } from "./energy-cards";
|
||||
import {
|
||||
hasGasDevices,
|
||||
hasGasSource,
|
||||
hasWaterSource,
|
||||
isEnergyCardVisible,
|
||||
} from "./energy-cards";
|
||||
|
||||
@customElement("energy-overview-view-strategy")
|
||||
export class EnergyOverviewViewStrategy extends ReactiveElement {
|
||||
@@ -55,7 +60,8 @@ export class EnergyOverviewViewStrategy extends ReactiveElement {
|
||||
if (
|
||||
!prefs ||
|
||||
(prefs.device_consumption.length === 0 &&
|
||||
prefs.energy_sources.length === 0)
|
||||
prefs.energy_sources.length === 0 &&
|
||||
!hasGasDevices(prefs))
|
||||
) {
|
||||
return view;
|
||||
}
|
||||
@@ -142,14 +148,20 @@ export class EnergyOverviewViewStrategy extends ReactiveElement {
|
||||
view.sections!.push({
|
||||
type: "grid",
|
||||
cards: [
|
||||
{
|
||||
title: hass.localize(
|
||||
"ui.panel.energy.cards.energy_gas_graph_title"
|
||||
),
|
||||
type: "energy-gas-graph",
|
||||
collection_key: collectionKey,
|
||||
combine_sources: true,
|
||||
},
|
||||
hasGasSource(prefs)
|
||||
? {
|
||||
title: hass.localize(
|
||||
"ui.panel.energy.cards.energy_gas_graph_title"
|
||||
),
|
||||
type: "energy-gas-graph",
|
||||
collection_key: collectionKey,
|
||||
combine_sources: true,
|
||||
}
|
||||
: {
|
||||
title: hass.localize("ui.panel.energy.cards.gas_sankey_title"),
|
||||
type: "gas-sankey",
|
||||
collection_key: collectionKey,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,9 +8,14 @@ import {
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
|
||||
import type { EnergyViewStrategyConfig } from "./energy-cards";
|
||||
import { hasGasSource, isEnergyCardVisible } from "./energy-cards";
|
||||
import {
|
||||
hasGasDevices,
|
||||
hasGasSource,
|
||||
isEnergyCardVisible,
|
||||
} from "./energy-cards";
|
||||
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
|
||||
import type { LovelaceStrategyDependency } from "../../lovelace/strategies/types";
|
||||
import { shouldShowFloorsAndAreas } from "./show-floors-and-areas";
|
||||
|
||||
@customElement("gas-view-strategy")
|
||||
export class GasViewStrategy extends ReactiveElement {
|
||||
@@ -52,8 +57,8 @@ export class GasViewStrategy extends ReactiveElement {
|
||||
}
|
||||
const prefs = energyCollection.prefs;
|
||||
|
||||
// No gas sources available
|
||||
if (!prefs || !hasGasSource(prefs)) {
|
||||
// No gas sources or devices available
|
||||
if (!prefs || (!hasGasSource(prefs) && !hasGasDevices(prefs))) {
|
||||
return view;
|
||||
}
|
||||
|
||||
@@ -97,6 +102,24 @@ export class GasViewStrategy extends ReactiveElement {
|
||||
});
|
||||
}
|
||||
|
||||
if (isEnergyCardVisible("gas", "gas-sankey", prefs, hidden)) {
|
||||
const showFloorsAndAreas = shouldShowFloorsAndAreas(
|
||||
prefs.device_consumption_gas,
|
||||
hass,
|
||||
(d) => d.stat_consumption
|
||||
);
|
||||
section.cards!.push({
|
||||
title: hass.localize("ui.panel.energy.cards.gas_sankey_title"),
|
||||
type: "gas-sankey",
|
||||
collection_key: collectionKey,
|
||||
group_by_floor: showFloorsAndAreas,
|
||||
group_by_area: showFloorsAndAreas,
|
||||
grid_options: {
|
||||
columns: 24,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return view;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +151,24 @@ export class PowerViewStrategy extends ReactiveElement {
|
||||
});
|
||||
}
|
||||
|
||||
if (isEnergyCardVisible("now", "gas-flow-sankey", prefs, hidden)) {
|
||||
const showFloorsAndAreas = shouldShowFloorsAndAreas(
|
||||
prefs.device_consumption_gas,
|
||||
hass,
|
||||
(d) => d.stat_rate
|
||||
);
|
||||
chartsSection.cards!.push({
|
||||
title: hass.localize("ui.panel.energy.cards.gas_flow_sankey_title"),
|
||||
type: "gas-flow-sankey",
|
||||
collection_key: collectionKey,
|
||||
group_by_floor: showFloorsAndAreas,
|
||||
group_by_area: showFloorsAndAreas,
|
||||
grid_options: {
|
||||
columns: 36,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (badges.length) {
|
||||
view.badges = badges;
|
||||
}
|
||||
|
||||
@@ -16,9 +16,7 @@ import {
|
||||
fetchFrontendSystemData,
|
||||
saveFrontendSystemData,
|
||||
subscribeFrontendSystemData,
|
||||
type ClimateFrontendSystemData,
|
||||
type HomeFrontendSystemData,
|
||||
type MaintenanceFrontendSystemData,
|
||||
type SecurityFrontendSystemData,
|
||||
} from "../../data/frontend";
|
||||
import type { LovelaceDashboardStrategyConfig } from "../../data/lovelace/config/types";
|
||||
@@ -56,10 +54,6 @@ class PanelHome extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _securityConfig: SecurityFrontendSystemData = {};
|
||||
|
||||
@state() private _maintenanceConfig: MaintenanceFrontendSystemData = {};
|
||||
|
||||
@state() private _climateConfig: ClimateFrontendSystemData = {};
|
||||
|
||||
@state() private _extraActionItems?: ExtraActionItem[];
|
||||
|
||||
@query(".banner") private _banner?: HTMLElement;
|
||||
@@ -68,10 +62,6 @@ class PanelHome extends SubscribeMixin(LitElement) {
|
||||
|
||||
private _securityConfigRevision = 0;
|
||||
|
||||
private _maintenanceConfigRevision = 0;
|
||||
|
||||
private _climateConfigRevision = 0;
|
||||
|
||||
public hassSubscribe() {
|
||||
return [
|
||||
subscribeFrontendSystemData(
|
||||
@@ -89,36 +79,6 @@ class PanelHome extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
}
|
||||
),
|
||||
subscribeFrontendSystemData(
|
||||
this.hass.connection,
|
||||
"maintenance",
|
||||
({ value }) => {
|
||||
this._maintenanceConfigRevision++;
|
||||
const config = value || {};
|
||||
if (deepEqual(config, this._maintenanceConfig)) {
|
||||
return;
|
||||
}
|
||||
this._maintenanceConfig = config;
|
||||
if (this.hasUpdated) {
|
||||
this._setLovelace();
|
||||
}
|
||||
}
|
||||
),
|
||||
subscribeFrontendSystemData(
|
||||
this.hass.connection,
|
||||
"climate",
|
||||
({ value }) => {
|
||||
this._climateConfigRevision++;
|
||||
const config = value || {};
|
||||
if (deepEqual(config, this._climateConfig)) {
|
||||
return;
|
||||
}
|
||||
this._climateConfig = config;
|
||||
if (this.hasUpdated) {
|
||||
this._setLovelace();
|
||||
}
|
||||
}
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -219,21 +179,12 @@ class PanelHome extends SubscribeMixin(LitElement) {
|
||||
|
||||
private async _loadConfig() {
|
||||
const securityConfigRevision = this._securityConfigRevision;
|
||||
const maintenanceConfigRevision = this._maintenanceConfigRevision;
|
||||
const climateConfigRevision = this._climateConfigRevision;
|
||||
const [
|
||||
translationsResult,
|
||||
homeResult,
|
||||
securityResult,
|
||||
maintenanceResult,
|
||||
climateResult,
|
||||
] = await Promise.allSettled([
|
||||
this.hass.loadFragmentTranslation("lovelace"),
|
||||
fetchFrontendSystemData(this.hass.connection, "home"),
|
||||
fetchFrontendSystemData(this.hass.connection, "security"),
|
||||
fetchFrontendSystemData(this.hass.connection, "maintenance"),
|
||||
fetchFrontendSystemData(this.hass.connection, "climate"),
|
||||
]);
|
||||
const [translationsResult, homeResult, securityResult] =
|
||||
await Promise.allSettled([
|
||||
this.hass.loadFragmentTranslation("lovelace"),
|
||||
fetchFrontendSystemData(this.hass.connection, "home"),
|
||||
fetchFrontendSystemData(this.hass.connection, "security"),
|
||||
]);
|
||||
|
||||
if (translationsResult.status === "rejected") {
|
||||
// eslint-disable-next-line no-console
|
||||
@@ -263,32 +214,6 @@ class PanelHome extends SubscribeMixin(LitElement) {
|
||||
} else if (securityConfigRevision === this._securityConfigRevision) {
|
||||
this._securityConfig = securityResult.value || {};
|
||||
}
|
||||
|
||||
if (maintenanceResult.status === "rejected") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
"Failed to load maintenance configuration:",
|
||||
maintenanceResult.reason
|
||||
);
|
||||
if (maintenanceConfigRevision === this._maintenanceConfigRevision) {
|
||||
this._maintenanceConfig = {};
|
||||
}
|
||||
} else if (maintenanceConfigRevision === this._maintenanceConfigRevision) {
|
||||
this._maintenanceConfig = maintenanceResult.value || {};
|
||||
}
|
||||
|
||||
if (climateResult.status === "rejected") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
"Failed to load climate configuration:",
|
||||
climateResult.reason
|
||||
);
|
||||
if (climateConfigRevision === this._climateConfigRevision) {
|
||||
this._climateConfig = {};
|
||||
}
|
||||
} else if (climateConfigRevision === this._climateConfigRevision) {
|
||||
this._climateConfig = climateResult.value || {};
|
||||
}
|
||||
}
|
||||
|
||||
private _debounceRegenerateStrategy = debounce(
|
||||
@@ -475,8 +400,6 @@ class PanelHome extends SubscribeMixin(LitElement) {
|
||||
strategy: {
|
||||
type: "home",
|
||||
alert_entities: this._securityConfig.alert_entities,
|
||||
maintenance_hidden_entities: this._maintenanceConfig.hidden_entities,
|
||||
climate_hidden_entities: this._climateConfig.hidden_entities,
|
||||
favorite_entities: this._config?.favorite_entities,
|
||||
home_panel: true,
|
||||
hide_welcome_message: this._config?.hide_welcome_message,
|
||||
|
||||
@@ -141,6 +141,36 @@ export const findDevicesOverCap = ({
|
||||
return grouped;
|
||||
};
|
||||
|
||||
/**
|
||||
* Re-parent each device to its nearest ancestor that has data, so a device
|
||||
* whose parent has no usable value is not orphaned or double-counted.
|
||||
*/
|
||||
export const resolveSankeyParents = (
|
||||
devices: DeviceConsumptionEnergyPreference[],
|
||||
isUsable: (device: DeviceConsumptionEnergyPreference) => boolean
|
||||
): DeviceConsumptionEnergyPreference[] => {
|
||||
const usable = new Set(
|
||||
devices.filter(isUsable).map((d) => d.stat_consumption)
|
||||
);
|
||||
const parentByStat = new Map(
|
||||
devices.map((d) => [d.stat_consumption, d.included_in_stat])
|
||||
);
|
||||
return devices.map((d) => {
|
||||
const seen = new Set([d.stat_consumption]);
|
||||
let parent = d.included_in_stat;
|
||||
while (parent && !usable.has(parent) && !seen.has(parent)) {
|
||||
seen.add(parent);
|
||||
parent = parentByStat.get(parent);
|
||||
}
|
||||
if (parent && !usable.has(parent)) {
|
||||
parent = undefined;
|
||||
}
|
||||
return parent === d.included_in_stat
|
||||
? d
|
||||
: { ...d, included_in_stat: parent };
|
||||
});
|
||||
};
|
||||
|
||||
export interface BuildSankeyDeviceNodesOptions {
|
||||
devices: DeviceConsumptionEnergyPreference[];
|
||||
computedStyle: CSSStyleDeclaration;
|
||||
|
||||
@@ -181,6 +181,7 @@ export class HuiDistributionCard extends LitElement implements LovelaceCard {
|
||||
|
||||
public setConfig(config: DistributionCardConfig): void {
|
||||
this._config = config;
|
||||
this._expandLegend = config.expand_legend ?? false;
|
||||
|
||||
// Handle empty entities gracefully
|
||||
if (!config.entities || config.entities.length === 0) {
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
import { mdiEye, mdiEyeOff } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import "./hui-card";
|
||||
import type { HuiCard } from "./hui-card";
|
||||
import type { LovelaceCard, LovelaceGridOptions } from "../types";
|
||||
import type { EntityVisibilityCardConfig } from "./types";
|
||||
|
||||
@customElement("hui-entity-visibility-card")
|
||||
export class HuiEntityVisibilityCard
|
||||
extends LitElement
|
||||
implements LovelaceCard
|
||||
{
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public preview = false;
|
||||
|
||||
@state() private _config?: EntityVisibilityCardConfig;
|
||||
|
||||
private _card?: HuiCard;
|
||||
|
||||
public setConfig(config: EntityVisibilityCardConfig): void {
|
||||
if (!config.entity) {
|
||||
throw new Error("No entity configured");
|
||||
}
|
||||
if (!config.card) {
|
||||
throw new Error("No card configured");
|
||||
}
|
||||
this._config = config;
|
||||
this._card = document.createElement("hui-card");
|
||||
this._card.hass = this.hass;
|
||||
this._card.preview = this.preview;
|
||||
this._card.config = config.card;
|
||||
this._card.inert = true;
|
||||
this._card.load();
|
||||
}
|
||||
|
||||
public getCardSize(): number | Promise<number> {
|
||||
return this._card?.getCardSize() ?? 1;
|
||||
}
|
||||
|
||||
public getGridOptions(): LovelaceGridOptions {
|
||||
return this._card?.getGridOptions() ?? {};
|
||||
}
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
window.addEventListener(
|
||||
"entity-visibility-changed",
|
||||
this._entityVisibilityChanged
|
||||
);
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener(
|
||||
"entity-visibility-changed",
|
||||
this._entityVisibilityChanged
|
||||
);
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>): void {
|
||||
super.willUpdate(changedProps);
|
||||
if (!this._card) {
|
||||
return;
|
||||
}
|
||||
if (changedProps.has("hass")) {
|
||||
this._card.hass = this.hass;
|
||||
}
|
||||
if (changedProps.has("preview")) {
|
||||
this._card.preview = this.preview;
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._config || !this.hass || !this._card) {
|
||||
return nothing;
|
||||
}
|
||||
const hidden = Boolean(this._config.hidden);
|
||||
const stateObj = this.hass.states[this._config.entity];
|
||||
const name = stateObj
|
||||
? this.hass.formatEntityName(stateObj)
|
||||
: this._config.entity;
|
||||
return html`
|
||||
<div class="wrapper" ?hidden-entity=${hidden}>
|
||||
${this._card}
|
||||
<button
|
||||
class="control"
|
||||
aria-label=${this.hass.localize(
|
||||
`ui.components.items-display-editor.${hidden ? "show" : "hide"}`,
|
||||
{ label: name }
|
||||
)}
|
||||
@click=${this._toggle}
|
||||
>
|
||||
<ha-svg-icon .path=${hidden ? mdiEyeOff : mdiEye}></ha-svg-icon>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _entityVisibilityChanged = (
|
||||
ev: HASSDomEvent<HASSDomEvents["entity-visibility-changed"]>
|
||||
) => {
|
||||
if (
|
||||
this._config?.hidden === undefined ||
|
||||
ev.detail.entity !== this._config.entity ||
|
||||
ev.detail.hidden === this._config.hidden
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this._config = { ...this._config, hidden: ev.detail.hidden };
|
||||
};
|
||||
|
||||
private _toggle(): void {
|
||||
if (!this._config) {
|
||||
return;
|
||||
}
|
||||
const hidden = !this._config.hidden;
|
||||
this._config = { ...this._config, hidden };
|
||||
fireEvent(this, "entity-visibility-changed", {
|
||||
entity: this._config.entity,
|
||||
hidden,
|
||||
});
|
||||
}
|
||||
|
||||
static styles: CSSResultGroup = css`
|
||||
.wrapper {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
|
||||
}
|
||||
hui-card {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
.wrapper[hidden-entity] hui-card {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.wrapper[hidden-entity]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 2px dotted var(--secondary-text-color);
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
}
|
||||
.control {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
margin: 0;
|
||||
padding: var(--ha-space-2);
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: inherit;
|
||||
cursor: pointer;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.control:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
}
|
||||
.control ha-svg-icon {
|
||||
display: flex;
|
||||
padding: var(--ha-space-1);
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
background: var(--secondary-background-color);
|
||||
box-shadow: var(--ha-box-shadow-s);
|
||||
--mdc-icon-size: 20px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-entity-visibility-card": HuiEntityVisibilityCard;
|
||||
}
|
||||
interface GlobalEventHandlersEventMap {
|
||||
"entity-visibility-changed": HASSDomEvent<
|
||||
HASSDomEvents["entity-visibility-changed"]
|
||||
>;
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
"entity-visibility-changed": { entity: string; hidden: boolean };
|
||||
}
|
||||
}
|
||||
@@ -191,16 +191,12 @@ export class HuiHomeSummaryCard extends LitElement implements LovelaceCard {
|
||||
}
|
||||
case "climate": {
|
||||
// Min/Max temperature of the areas
|
||||
const hiddenEntities = new Set(this._config.hidden_entities);
|
||||
const areaSensors = Object.values(areas)
|
||||
.map((area) => area.temperature_entity_id)
|
||||
.filter(
|
||||
(entityId): entityId is string =>
|
||||
!!entityId && !hiddenEntities.has(entityId)
|
||||
);
|
||||
.filter(Boolean);
|
||||
|
||||
const sensorsValues = areaSensors
|
||||
.map((entityId) => parseFloat(states[entityId]?.state) || NaN)
|
||||
.map((entityId) => parseFloat(states[entityId!]?.state) || NaN)
|
||||
.filter((value) => !isNaN(value));
|
||||
|
||||
if (sensorsValues.length === 0) {
|
||||
@@ -326,10 +322,13 @@ export class HuiHomeSummaryCard extends LitElement implements LovelaceCard {
|
||||
: localize("ui.card.home-summary.no_media_playing");
|
||||
}
|
||||
case "maintenance": {
|
||||
const maintenanceEntities = findMaintenanceEntities(
|
||||
{ states, entities, devices, areas, floors },
|
||||
this._config.hidden_entities
|
||||
);
|
||||
const maintenanceEntities = findMaintenanceEntities({
|
||||
states,
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors,
|
||||
});
|
||||
|
||||
const lowBatteryEntities = filterLowBatteryEntities(
|
||||
states,
|
||||
|
||||
@@ -71,12 +71,6 @@ export interface ConditionalCardConfig extends LovelaceCardConfig {
|
||||
conditions: VisibilityCondition[];
|
||||
}
|
||||
|
||||
export interface EntityVisibilityCardConfig extends LovelaceCardConfig {
|
||||
entity: string;
|
||||
hidden?: boolean;
|
||||
card: LovelaceCardConfig;
|
||||
}
|
||||
|
||||
export interface EmptyStateButtonConfig {
|
||||
text: string;
|
||||
icon?: string;
|
||||
@@ -226,6 +220,14 @@ export interface EnergyGasGraphCardConfig extends EnergyCardConfig {
|
||||
combine_sources?: boolean;
|
||||
}
|
||||
|
||||
export interface GasSankeyCardConfig extends EnergyCardSankeyConfig {
|
||||
type: "gas-sankey";
|
||||
}
|
||||
|
||||
export interface GasFlowSankeyCardConfig extends EnergyCardSankeyConfig {
|
||||
type: "gas-flow-sankey";
|
||||
}
|
||||
|
||||
export interface EnergyWaterGraphCardConfig extends EnergyCardConfig {
|
||||
type: "energy-water-graph";
|
||||
combine_sources?: boolean;
|
||||
@@ -299,7 +301,9 @@ export type SankeyCardConfig =
|
||||
| EnergySankeyCardConfig
|
||||
| PowerSankeyCardConfig
|
||||
| WaterSankeyCardConfig
|
||||
| WaterFlowSankeyCardConfig;
|
||||
| WaterFlowSankeyCardConfig
|
||||
| GasSankeyCardConfig
|
||||
| GasFlowSankeyCardConfig;
|
||||
|
||||
export interface EntityFilterCardConfig extends LovelaceCardConfig {
|
||||
type: "entity-filter";
|
||||
@@ -757,7 +761,6 @@ export interface HeadingCardConfig extends LovelaceCardConfig {
|
||||
export interface HomeSummaryCard extends LovelaceCardConfig {
|
||||
summary: HomeSummary;
|
||||
alert_entities?: SecurityAlertEntityConfig[];
|
||||
hidden_entities?: string[];
|
||||
vertical?: boolean;
|
||||
tap_action?: ActionConfig;
|
||||
hold_action?: ActionConfig;
|
||||
@@ -805,6 +808,7 @@ export interface DistributionCardConfig extends LovelaceCardConfig {
|
||||
type: "distribution";
|
||||
title?: string;
|
||||
entities: (string | DistributionEntityConfig)[];
|
||||
expand_legend?: boolean;
|
||||
}
|
||||
|
||||
export interface DiscoveredDevicesCardConfig extends LovelaceCardConfig {
|
||||
|
||||
+134
-82
@@ -13,25 +13,32 @@ import {
|
||||
registriesContext,
|
||||
statesContext,
|
||||
} from "../../../../data/context";
|
||||
import type { EnergyData } from "../../../../data/energy";
|
||||
import type { EnergyData, WaterGasResource } from "../../../../data/energy";
|
||||
import {
|
||||
computeEnergyDeviceLabels,
|
||||
formatFlowRateShort,
|
||||
getFlowRateFromState,
|
||||
getFlowRateInUnit,
|
||||
getFlowRateUnit,
|
||||
getWaterGasDevices,
|
||||
validateEnergyCollectionKey,
|
||||
} from "../../../../data/energy";
|
||||
import { EnergyCollectionController } from "../../../../data/energy-collection-controller";
|
||||
import type {
|
||||
HomeAssistant,
|
||||
HomeAssistantConfig,
|
||||
HomeAssistantFormatters,
|
||||
HomeAssistantInternationalization,
|
||||
HomeAssistantRegistries,
|
||||
} from "../../../../types";
|
||||
import type { LovelaceCard, LovelaceGridOptions } from "../../types";
|
||||
import type { WaterFlowSankeyCardConfig } from "../types";
|
||||
import type {
|
||||
EnergyCardSankeyConfig,
|
||||
GasFlowSankeyCardConfig,
|
||||
WaterFlowSankeyCardConfig,
|
||||
} from "../types";
|
||||
import "../../../../components/chart/ha-sankey-chart";
|
||||
import type { Link, Node } from "../../../../components/chart/ha-sankey-chart";
|
||||
import { formatNumberWithUnit } from "../../../../common/number/format_number";
|
||||
import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
|
||||
import {
|
||||
buildSankeyDeviceNodes,
|
||||
@@ -39,18 +46,21 @@ import {
|
||||
DEFAULT_MAX_SANKEY_DEVICES,
|
||||
fireSankeyNodeMoreInfo,
|
||||
MIN_SANKEY_THRESHOLD_FACTOR,
|
||||
resolveSankeyParents,
|
||||
} from "../energy/common/sankey";
|
||||
import type { LocalizeKeys } from "../../../../common/translations/localize";
|
||||
|
||||
const DEFAULT_CONFIG: Partial<WaterFlowSankeyCardConfig> = {
|
||||
const DEFAULT_CONFIG = {
|
||||
group_by_floor: true,
|
||||
group_by_area: true,
|
||||
};
|
||||
|
||||
@customElement("hui-water-flow-sankey-card")
|
||||
class HuiWaterFlowSankeyCard
|
||||
abstract class HuiWaterGasFlowSankeyCard
|
||||
extends MobileAwareMixin(LitElement)
|
||||
implements LovelaceCard
|
||||
{
|
||||
protected abstract readonly resource: WaterGasResource;
|
||||
|
||||
public static async getConfigElement() {
|
||||
await import("../../editor/config-elements/hui-energy-sankey-card-editor");
|
||||
return document.createElement("hui-energy-sankey-card-editor");
|
||||
@@ -58,34 +68,24 @@ class HuiWaterFlowSankeyCard
|
||||
|
||||
@property({ attribute: false }) public layout?: string;
|
||||
|
||||
@state() private _config?: WaterFlowSankeyCardConfig;
|
||||
|
||||
public static getStubConfig(
|
||||
_hass: HomeAssistant,
|
||||
_entities: string[],
|
||||
_entitiesFill: string[]
|
||||
): WaterFlowSankeyCardConfig {
|
||||
return {
|
||||
type: "water-flow-sankey",
|
||||
layout: "auto",
|
||||
...DEFAULT_CONFIG,
|
||||
};
|
||||
}
|
||||
@state() private _config?: EnergyCardSankeyConfig;
|
||||
|
||||
@state() private _data?: EnergyData;
|
||||
|
||||
@state()
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
@transform<HassEntities, HassEntities>({
|
||||
transformer: function (this: HuiWaterFlowSankeyCard, states) {
|
||||
transformer: function (this: HuiWaterGasFlowSankeyCard, states) {
|
||||
const tracked: HassEntities = {};
|
||||
const prefs = this._data?.prefs;
|
||||
if (states && prefs) {
|
||||
[
|
||||
...prefs.energy_sources.map((source) =>
|
||||
source.type === "water" ? source.stat_rate : undefined
|
||||
source.type === this.resource ? source.stat_rate : undefined
|
||||
),
|
||||
...getWaterGasDevices(prefs, this.resource).map(
|
||||
(device) => device.stat_rate
|
||||
),
|
||||
...prefs.device_consumption_water.map((device) => device.stat_rate),
|
||||
].forEach((entityId) => {
|
||||
if (entityId && states[entityId]) {
|
||||
tracked[entityId] = states[entityId];
|
||||
@@ -133,7 +133,7 @@ class HuiWaterFlowSankeyCard
|
||||
});
|
||||
}
|
||||
|
||||
public setConfig(config: WaterFlowSankeyCardConfig): void {
|
||||
public setConfig(config: EnergyCardSankeyConfig): void {
|
||||
if (config.collection_key) {
|
||||
validateEnergyCollectionKey(config.collection_key);
|
||||
}
|
||||
@@ -153,6 +153,12 @@ class HuiWaterFlowSankeyCard
|
||||
};
|
||||
}
|
||||
|
||||
private _getRateUnit(): string | undefined {
|
||||
return this.resource === "water" || !this._data
|
||||
? undefined
|
||||
: getFlowRateUnit(this.resource, this._data.prefs, this._states);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._config) {
|
||||
return nothing;
|
||||
@@ -162,18 +168,35 @@ class HuiWaterFlowSankeyCard
|
||||
return html`${this._i18n.localize("ui.panel.lovelace.cards.energy.loading")}`;
|
||||
}
|
||||
|
||||
const resource = this.resource;
|
||||
const isWater = resource === "water";
|
||||
const prefs = this._data.prefs;
|
||||
// Gas shows the unit of its rate sensors (as the gas badge does); water keeps
|
||||
// the fixed L/min or gal/min. Set at the start of every render.
|
||||
const rateUnit = this._getRateUnit();
|
||||
const computedStyle = getComputedStyle(this);
|
||||
|
||||
// Collect water sources with stat_rate
|
||||
const waterSources = prefs.energy_sources.filter(
|
||||
(source) => source.type === "water" && source.stat_rate
|
||||
const prefDevices = getWaterGasDevices(prefs, resource);
|
||||
|
||||
// Collect sources with a live stat_rate
|
||||
const sources = prefs.energy_sources.filter(
|
||||
(source) =>
|
||||
source.type === resource &&
|
||||
source.stat_rate &&
|
||||
getFlowRateFromState(this._states[source.stat_rate]) !== undefined
|
||||
);
|
||||
|
||||
// Devices whose parent has no flow move up to the nearest ancestor with flow
|
||||
const devices = resolveSankeyParents(
|
||||
prefDevices,
|
||||
(d) =>
|
||||
!!d.stat_rate && this._getCurrentFlowRate(d.stat_rate, rateUnit) > 0
|
||||
);
|
||||
|
||||
let totalInflow = 0;
|
||||
waterSources.forEach((source) => {
|
||||
if (source.type === "water" && source.stat_rate) {
|
||||
const value = this._getCurrentFlowRate(source.stat_rate);
|
||||
sources.forEach((source) => {
|
||||
if (source.type === resource && source.stat_rate) {
|
||||
const value = this._getCurrentFlowRate(source.stat_rate, rateUnit);
|
||||
if (value > 0) totalInflow += value;
|
||||
}
|
||||
});
|
||||
@@ -184,58 +207,62 @@ class HuiWaterFlowSankeyCard
|
||||
// while its children have positive values. Skip sub-trackers so the
|
||||
// total only reflects top-level devices and we don't double-count.
|
||||
let totalDeviceFlow = 0;
|
||||
if (waterSources.length === 0) {
|
||||
prefs.device_consumption_water.forEach((device) => {
|
||||
if (device.included_in_stat) {
|
||||
return;
|
||||
}
|
||||
if (device.stat_rate) {
|
||||
totalDeviceFlow += this._getCurrentFlowRate(device.stat_rate);
|
||||
if (sources.length === 0) {
|
||||
devices.forEach((d) => {
|
||||
if (!d.included_in_stat && d.stat_rate) {
|
||||
totalDeviceFlow += Math.max(
|
||||
0,
|
||||
this._getCurrentFlowRate(d.stat_rate, rateUnit)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
const effectiveTotalInflow =
|
||||
waterSources.length === 0 ? totalDeviceFlow : totalInflow;
|
||||
sources.length === 0 ? totalDeviceFlow : totalInflow;
|
||||
|
||||
// Calculate dynamic threshold
|
||||
const minFlowThreshold = effectiveTotalInflow * MIN_SANKEY_THRESHOLD_FACTOR;
|
||||
|
||||
// Fixed floor and ceil only suit integer L/min; gas values in m³/h or
|
||||
// similar are often below 1
|
||||
const untrackedFloor = isWater ? 1 : minFlowThreshold;
|
||||
|
||||
const nodes: Node[] = [];
|
||||
const links: Link[] = [];
|
||||
const waterColor = computedStyle
|
||||
.getPropertyValue("--energy-water-color")
|
||||
const resourceColor = computedStyle
|
||||
.getPropertyValue(`--energy-${resource}-color`)
|
||||
.trim();
|
||||
const primaryColor = computedStyle
|
||||
.getPropertyValue("--primary-color")
|
||||
.trim();
|
||||
|
||||
const defaultSourceLabel = this._i18n.localize(
|
||||
`ui.panel.lovelace.cards.energy.energy_distribution.${resource}` as LocalizeKeys
|
||||
);
|
||||
|
||||
// Determine the "root" node for device links.
|
||||
// - 0 sources: home node (value = sum of device values, computed later)
|
||||
// - 1 source: that source node is the root (no home node)
|
||||
// - >1 sources: home node aggregates all sources
|
||||
// - combined sources: one summed source node is the root (no home node)
|
||||
const combineSources =
|
||||
waterSources.length > 1 &&
|
||||
sources.length > 1 &&
|
||||
(this._config.combine_sources ?? this._data.combineSources ?? false);
|
||||
const showHomeNode = !combineSources && waterSources.length !== 1;
|
||||
const showHomeNode = !combineSources && sources.length !== 1;
|
||||
let rootNodeId: string;
|
||||
|
||||
if (showHomeNode) {
|
||||
// Add source nodes and link to home
|
||||
waterSources.forEach((source) => {
|
||||
if (source.type !== "water" || !source.stat_rate) return;
|
||||
const value = this._getCurrentFlowRate(source.stat_rate);
|
||||
sources.forEach((source) => {
|
||||
if (source.type !== resource || !source.stat_rate) return;
|
||||
const value = this._getCurrentFlowRate(source.stat_rate, rateUnit);
|
||||
if (value <= 0) return;
|
||||
const sourceNodeId = `water_source_${source.stat_rate}`;
|
||||
const sourceNodeId = `${resource}_source_${source.stat_rate}`;
|
||||
nodes.push({
|
||||
id: sourceNodeId,
|
||||
label:
|
||||
this._getEntityLabel(source.stat_rate) ||
|
||||
this._i18n.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_distribution.water"
|
||||
),
|
||||
label: this._getEntityLabel(source.stat_rate) || defaultSourceLabel,
|
||||
value,
|
||||
color: waterColor,
|
||||
color: resourceColor,
|
||||
index: 0,
|
||||
entityId: source.stat_rate,
|
||||
});
|
||||
@@ -253,35 +280,27 @@ class HuiWaterFlowSankeyCard
|
||||
rootNodeId = "home";
|
||||
} else if (combineSources) {
|
||||
nodes.push({
|
||||
id: "water_source",
|
||||
label: this._i18n.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_distribution.water"
|
||||
),
|
||||
id: `${resource}_source`,
|
||||
label: defaultSourceLabel,
|
||||
value: totalInflow,
|
||||
color: waterColor,
|
||||
color: resourceColor,
|
||||
index: 0,
|
||||
});
|
||||
rootNodeId = "water_source";
|
||||
rootNodeId = `${resource}_source`;
|
||||
} else {
|
||||
// Single source: that source IS the root, no home node
|
||||
const source = waterSources[0];
|
||||
if (source.type === "water" && source.stat_rate) {
|
||||
const value = this._getCurrentFlowRate(source.stat_rate);
|
||||
const source = sources[0];
|
||||
if (source.type === resource && source.stat_rate) {
|
||||
const value = this._getCurrentFlowRate(source.stat_rate, rateUnit);
|
||||
nodes.push({
|
||||
id: source.stat_rate,
|
||||
label:
|
||||
this._getEntityLabel(source.stat_rate) ||
|
||||
this._i18n.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_distribution.water"
|
||||
),
|
||||
label: this._getEntityLabel(source.stat_rate) || defaultSourceLabel,
|
||||
value: Math.max(0, value),
|
||||
color: waterColor,
|
||||
color: resourceColor,
|
||||
index: 0,
|
||||
entityId: source.stat_rate,
|
||||
});
|
||||
rootNodeId = source.stat_rate;
|
||||
} else {
|
||||
// Fallback (shouldn't happen)
|
||||
rootNodeId = "home";
|
||||
}
|
||||
}
|
||||
@@ -289,7 +308,7 @@ class HuiWaterFlowSankeyCard
|
||||
const deviceLabels = computeEnergyDeviceLabels(
|
||||
this._states,
|
||||
this._formatEntityName,
|
||||
prefs.device_consumption_water,
|
||||
prefDevices,
|
||||
this._data.statsMetadata,
|
||||
"stat_rate"
|
||||
);
|
||||
@@ -300,17 +319,17 @@ class HuiWaterFlowSankeyCard
|
||||
links: deviceLinks,
|
||||
untrackedConsumption,
|
||||
} = buildSankeyDeviceNodes({
|
||||
devices: prefs.device_consumption_water,
|
||||
devices,
|
||||
computedStyle,
|
||||
localize: this._i18n.localize,
|
||||
rootNodeId,
|
||||
minThreshold: minFlowThreshold,
|
||||
maxDevices: this._config.max_devices ?? DEFAULT_MAX_SANKEY_DEVICES,
|
||||
untrackedFloor: 1,
|
||||
ceilOtherValue: true,
|
||||
untrackedFloor,
|
||||
ceilOtherValue: isWater,
|
||||
initialUntracked: effectiveTotalInflow,
|
||||
getId: (device) => device.stat_rate,
|
||||
getValue: (id) => this._getCurrentFlowRate(id),
|
||||
getValue: (id) => this._getCurrentFlowRate(id, rateUnit),
|
||||
getLabel: (id) => deviceLabels[id] || this._getEntityLabel(id),
|
||||
getEntityId: (id) => id,
|
||||
});
|
||||
@@ -328,7 +347,7 @@ class HuiWaterFlowSankeyCard
|
||||
groupByFloor: !!group_by_floor,
|
||||
groupByArea: !!group_by_area,
|
||||
untrackedConsumption,
|
||||
untrackedFloor: 1,
|
||||
untrackedFloor,
|
||||
});
|
||||
nodes.push(...sankeyLayout.nodes);
|
||||
links.push(...sankeyLayout.links);
|
||||
@@ -366,19 +385,33 @@ class HuiWaterFlowSankeyCard
|
||||
`;
|
||||
}
|
||||
|
||||
private _valueFormatter = (value: number) =>
|
||||
formatFlowRateShort(
|
||||
this._i18n.locale,
|
||||
this._hassConfig.unit_system.length,
|
||||
value
|
||||
);
|
||||
private _valueFormatter = (value: number) => {
|
||||
const rateUnit = this._getRateUnit();
|
||||
return rateUnit
|
||||
? formatNumberWithUnit(
|
||||
value,
|
||||
rateUnit,
|
||||
this._i18n.locale,
|
||||
value < 0.1 ? { maximumFractionDigits: 3 } : undefined
|
||||
)
|
||||
: formatFlowRateShort(
|
||||
this._i18n.locale,
|
||||
this._hassConfig.unit_system.length,
|
||||
value
|
||||
);
|
||||
};
|
||||
|
||||
private _handleNodeClick(ev: CustomEvent<{ node: Node }>) {
|
||||
fireSankeyNodeMoreInfo(this, ev.detail.node);
|
||||
}
|
||||
|
||||
private _getCurrentFlowRate(entityId: string): number {
|
||||
return getFlowRateFromState(this._states[entityId]) ?? 0;
|
||||
private _getCurrentFlowRate(entityId: string, rateUnit?: string): number {
|
||||
const stateObj = this._states[entityId];
|
||||
return (
|
||||
(rateUnit
|
||||
? getFlowRateInUnit(stateObj, rateUnit)
|
||||
: getFlowRateFromState(stateObj)) ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
private _getEntityLabel(entityId: string): string {
|
||||
@@ -408,8 +441,27 @@ class HuiWaterFlowSankeyCard
|
||||
`;
|
||||
}
|
||||
|
||||
@customElement("hui-water-flow-sankey-card")
|
||||
class HuiWaterFlowSankeyCard extends HuiWaterGasFlowSankeyCard {
|
||||
protected readonly resource = "water";
|
||||
|
||||
public static getStubConfig(): WaterFlowSankeyCardConfig {
|
||||
return { type: "water-flow-sankey", layout: "auto", ...DEFAULT_CONFIG };
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("hui-gas-flow-sankey-card")
|
||||
class HuiGasFlowSankeyCard extends HuiWaterGasFlowSankeyCard {
|
||||
protected readonly resource = "gas";
|
||||
|
||||
public static getStubConfig(): GasFlowSankeyCardConfig {
|
||||
return { type: "gas-flow-sankey", layout: "auto", ...DEFAULT_CONFIG };
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-water-flow-sankey-card": HuiWaterFlowSankeyCard;
|
||||
"hui-gas-flow-sankey-card": HuiGasFlowSankeyCard;
|
||||
}
|
||||
}
|
||||
+85
-79
@@ -13,9 +13,10 @@ import {
|
||||
registriesContext,
|
||||
statesContext,
|
||||
} from "../../../../data/context";
|
||||
import type { EnergyData } from "../../../../data/energy";
|
||||
import type { EnergyData, WaterGasResource } from "../../../../data/energy";
|
||||
import {
|
||||
computeEnergyDeviceLabels,
|
||||
getWaterGasDevices,
|
||||
validateEnergyCollectionKey,
|
||||
} from "../../../../data/energy";
|
||||
import { EnergyCollectionController } from "../../../../data/energy-collection-controller";
|
||||
@@ -25,14 +26,17 @@ import {
|
||||
isExternalStatistic,
|
||||
} from "../../../../data/recorder";
|
||||
import type {
|
||||
HomeAssistant,
|
||||
HomeAssistantConfig,
|
||||
HomeAssistantFormatters,
|
||||
HomeAssistantInternationalization,
|
||||
HomeAssistantRegistries,
|
||||
} from "../../../../types";
|
||||
import type { LovelaceCard, LovelaceGridOptions } from "../../types";
|
||||
import type { WaterSankeyCardConfig } from "../types";
|
||||
import type {
|
||||
EnergyCardSankeyConfig,
|
||||
GasSankeyCardConfig,
|
||||
WaterSankeyCardConfig,
|
||||
} from "../types";
|
||||
import "../../../../components/chart/ha-sankey-chart";
|
||||
import type { Link, Node } from "../../../../components/chart/ha-sankey-chart";
|
||||
import { formatNumberWithUnit } from "../../../../common/number/format_number";
|
||||
@@ -43,18 +47,21 @@ import {
|
||||
DEFAULT_MAX_SANKEY_DEVICES,
|
||||
fireSankeyNodeMoreInfo,
|
||||
MIN_SANKEY_THRESHOLD_FACTOR,
|
||||
resolveSankeyParents,
|
||||
} from "../energy/common/sankey";
|
||||
import type { LocalizeKeys } from "../../../../common/translations/localize";
|
||||
|
||||
const DEFAULT_CONFIG: Partial<WaterSankeyCardConfig> = {
|
||||
const DEFAULT_CONFIG = {
|
||||
group_by_floor: true,
|
||||
group_by_area: true,
|
||||
};
|
||||
|
||||
@customElement("hui-water-sankey-card")
|
||||
class HuiWaterSankeyCard
|
||||
abstract class HuiWaterGasSankeyCard
|
||||
extends MobileAwareMixin(LitElement)
|
||||
implements LovelaceCard
|
||||
{
|
||||
protected abstract readonly resource: WaterGasResource;
|
||||
|
||||
public static async getConfigElement() {
|
||||
await import("../../editor/config-elements/hui-energy-sankey-card-editor");
|
||||
return document.createElement("hui-energy-sankey-card-editor");
|
||||
@@ -62,19 +69,7 @@ class HuiWaterSankeyCard
|
||||
|
||||
@property({ attribute: false }) public layout?: string;
|
||||
|
||||
@state() private _config?: WaterSankeyCardConfig;
|
||||
|
||||
public static getStubConfig(
|
||||
_hass: HomeAssistant,
|
||||
_entities: string[],
|
||||
_entitiesFill: string[]
|
||||
): WaterSankeyCardConfig {
|
||||
return {
|
||||
type: "water-sankey",
|
||||
layout: "auto",
|
||||
...DEFAULT_CONFIG,
|
||||
};
|
||||
}
|
||||
@state() private _config?: EnergyCardSankeyConfig;
|
||||
|
||||
@state() private _data?: EnergyData;
|
||||
|
||||
@@ -117,7 +112,7 @@ class HuiWaterSankeyCard
|
||||
});
|
||||
}
|
||||
|
||||
public setConfig(config: WaterSankeyCardConfig): void {
|
||||
public setConfig(config: EnergyCardSankeyConfig): void {
|
||||
if (config.collection_key) {
|
||||
validateEnergyCollectionKey(config.collection_key);
|
||||
}
|
||||
@@ -146,44 +141,46 @@ class HuiWaterSankeyCard
|
||||
return html`${this._i18n.localize("ui.panel.lovelace.cards.energy.loading")}`;
|
||||
}
|
||||
|
||||
const prefs = this._data.prefs;
|
||||
const waterSources = prefs.energy_sources.filter(
|
||||
(source) => source.type === "water"
|
||||
const data = this._data;
|
||||
const resource = this.resource;
|
||||
const prefs = data.prefs;
|
||||
const sources = prefs.energy_sources.filter(
|
||||
(source) => source.type === resource
|
||||
);
|
||||
const prefDevices = getWaterGasDevices(prefs, resource);
|
||||
|
||||
const computedStyle = getComputedStyle(this);
|
||||
|
||||
const nodes: Node[] = [];
|
||||
const links: Link[] = [];
|
||||
|
||||
const statValue = (statId: string) =>
|
||||
statId in data.stats
|
||||
? calculateStatisticSumGrowth(data.stats[statId]) || 0
|
||||
: 0;
|
||||
|
||||
// Devices whose parent has no data move up to the nearest ancestor with data
|
||||
const devices = resolveSankeyParents(
|
||||
prefDevices,
|
||||
(d) => statValue(d.stat_consumption) > 0
|
||||
);
|
||||
|
||||
// Sum only top-level devices. Devices with `included_in_stat` are already
|
||||
// counted inside their parent stat; adding them again would double-count
|
||||
// and push the home total above the source meter.
|
||||
const totalDownstreamConsumption = prefs.device_consumption_water.reduce(
|
||||
(total, device) => {
|
||||
if (device.included_in_stat) {
|
||||
return total;
|
||||
}
|
||||
const value =
|
||||
device.stat_consumption in this._data!.stats
|
||||
? calculateStatisticSumGrowth(
|
||||
this._data!.stats[device.stat_consumption]
|
||||
) || 0
|
||||
: 0;
|
||||
return total + value;
|
||||
},
|
||||
const totalDownstreamConsumption = devices.reduce(
|
||||
(total, d) =>
|
||||
d.included_in_stat ? total : total + statValue(d.stat_consumption),
|
||||
0
|
||||
);
|
||||
const totalSourceSupply = waterSources.reduce((total, source) => {
|
||||
const value =
|
||||
source.stat_energy_from in this._data!.stats
|
||||
? calculateStatisticSumGrowth(
|
||||
this._data!.stats[source.stat_energy_from]
|
||||
) || 0
|
||||
: 0;
|
||||
return total + value;
|
||||
}, 0);
|
||||
const totalWaterConsumption = Math.max(
|
||||
const totalSourceSupply = sources.reduce(
|
||||
(total, source) =>
|
||||
source.type === resource
|
||||
? total + statValue(source.stat_energy_from)
|
||||
: total,
|
||||
0
|
||||
);
|
||||
const totalConsumption = Math.max(
|
||||
totalDownstreamConsumption,
|
||||
totalSourceSupply
|
||||
);
|
||||
@@ -192,47 +189,42 @@ class HuiWaterSankeyCard
|
||||
const homeNode: Node = {
|
||||
id: "home",
|
||||
label: this._hassConfig.location_name,
|
||||
value: Math.max(0, totalWaterConsumption),
|
||||
value: Math.max(0, totalConsumption),
|
||||
color: computedStyle.getPropertyValue("--primary-color").trim(),
|
||||
index: 1,
|
||||
};
|
||||
nodes.push(homeNode);
|
||||
|
||||
const minWaterThreshold = homeNode.value * MIN_SANKEY_THRESHOLD_FACTOR;
|
||||
const minThreshold = homeNode.value * MIN_SANKEY_THRESHOLD_FACTOR;
|
||||
|
||||
// Add water source nodes
|
||||
const waterColor = computedStyle
|
||||
.getPropertyValue("--energy-water-color")
|
||||
// Add source nodes
|
||||
const resourceColor = computedStyle
|
||||
.getPropertyValue(`--energy-${resource}-color`)
|
||||
.trim();
|
||||
const combineSources =
|
||||
waterSources.length > 1 &&
|
||||
(this._config.combine_sources ?? this._data.combineSources ?? false);
|
||||
sources.length > 1 &&
|
||||
(this._config.combine_sources ?? data.combineSources ?? false);
|
||||
if (combineSources && totalSourceSupply > 0) {
|
||||
nodes.push({
|
||||
id: "source-water",
|
||||
id: `source-${resource}`,
|
||||
label: this._i18n.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_distribution.water"
|
||||
`ui.panel.lovelace.cards.energy.energy_distribution.${resource}` as LocalizeKeys
|
||||
),
|
||||
value: totalSourceSupply,
|
||||
color: waterColor,
|
||||
color: resourceColor,
|
||||
index: 0,
|
||||
});
|
||||
links.push({
|
||||
source: "source-water",
|
||||
source: `source-${resource}`,
|
||||
target: "home",
|
||||
value: totalSourceSupply,
|
||||
});
|
||||
}
|
||||
waterSources.forEach((source) => {
|
||||
if (combineSources || source.type !== "water") {
|
||||
sources.forEach((source) => {
|
||||
if (combineSources || source.type !== resource) {
|
||||
return;
|
||||
}
|
||||
const value =
|
||||
source.stat_energy_from in this._data!.stats
|
||||
? calculateStatisticSumGrowth(
|
||||
this._data!.stats[source.stat_energy_from]
|
||||
) || 0
|
||||
: 0;
|
||||
const value = statValue(source.stat_energy_from);
|
||||
|
||||
if (value <= 0) {
|
||||
return;
|
||||
@@ -246,10 +238,10 @@ class HuiWaterSankeyCard
|
||||
this._states,
|
||||
this._formatEntityName,
|
||||
source.stat_energy_from,
|
||||
this._data!.statsMetadata[source.stat_energy_from]
|
||||
data.statsMetadata[source.stat_energy_from]
|
||||
),
|
||||
value,
|
||||
color: waterColor,
|
||||
color: resourceColor,
|
||||
index: 0,
|
||||
});
|
||||
|
||||
@@ -260,16 +252,11 @@ class HuiWaterSankeyCard
|
||||
});
|
||||
});
|
||||
|
||||
const deviceValue = (statConsumption: string) =>
|
||||
statConsumption in this._data!.stats
|
||||
? calculateStatisticSumGrowth(this._data!.stats[statConsumption]) || 0
|
||||
: 0;
|
||||
|
||||
const deviceLabels = computeEnergyDeviceLabels(
|
||||
this._states,
|
||||
this._formatEntityName,
|
||||
prefs.device_consumption_water,
|
||||
this._data!.statsMetadata
|
||||
prefDevices,
|
||||
data.statsMetadata
|
||||
);
|
||||
|
||||
const deviceLabel = (statConsumption: string) =>
|
||||
@@ -278,7 +265,7 @@ class HuiWaterSankeyCard
|
||||
this._states,
|
||||
this._formatEntityName,
|
||||
statConsumption,
|
||||
this._data!.statsMetadata[statConsumption]
|
||||
data.statsMetadata[statConsumption]
|
||||
);
|
||||
|
||||
const {
|
||||
@@ -287,17 +274,17 @@ class HuiWaterSankeyCard
|
||||
links: deviceLinks,
|
||||
untrackedConsumption,
|
||||
} = buildSankeyDeviceNodes({
|
||||
devices: prefs.device_consumption_water,
|
||||
devices,
|
||||
computedStyle,
|
||||
localize: this._i18n.localize,
|
||||
rootNodeId: "home",
|
||||
minThreshold: minWaterThreshold,
|
||||
minThreshold,
|
||||
maxDevices: this._config.max_devices ?? DEFAULT_MAX_SANKEY_DEVICES,
|
||||
untrackedFloor: 0,
|
||||
ceilOtherValue: false,
|
||||
initialUntracked: homeNode.value,
|
||||
getId: (device) => device.stat_consumption,
|
||||
getValue: deviceValue,
|
||||
getValue: statValue,
|
||||
getLabel: deviceLabel,
|
||||
getEntityId: (id) => (isExternalStatistic(id) ? undefined : id),
|
||||
});
|
||||
@@ -359,7 +346,7 @@ class HuiWaterSankeyCard
|
||||
value < 0.1 ? { maximumFractionDigits: 3 } : undefined;
|
||||
return formatNumberWithUnit(
|
||||
value,
|
||||
this._data!.waterUnit,
|
||||
this.resource === "gas" ? this._data!.gasUnit : this._data!.waterUnit,
|
||||
this._i18n.locale,
|
||||
formatOptions
|
||||
);
|
||||
@@ -390,8 +377,27 @@ class HuiWaterSankeyCard
|
||||
`;
|
||||
}
|
||||
|
||||
@customElement("hui-water-sankey-card")
|
||||
class HuiWaterSankeyCard extends HuiWaterGasSankeyCard {
|
||||
protected readonly resource = "water";
|
||||
|
||||
public static getStubConfig(): WaterSankeyCardConfig {
|
||||
return { type: "water-sankey", layout: "auto", ...DEFAULT_CONFIG };
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("hui-gas-sankey-card")
|
||||
class HuiGasSankeyCard extends HuiWaterGasSankeyCard {
|
||||
protected readonly resource = "gas";
|
||||
|
||||
public static getStubConfig(): GasSankeyCardConfig {
|
||||
return { type: "gas-sankey", layout: "auto", ...DEFAULT_CONFIG };
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-water-sankey-card": HuiWaterSankeyCard;
|
||||
"hui-gas-sankey-card": HuiGasSankeyCard;
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,6 @@ const LAZY_LOAD_TYPES = {
|
||||
area: () => import("../cards/hui-area-card"),
|
||||
calendar: () => import("../cards/hui-calendar-card"),
|
||||
conditional: () => import("../cards/hui-conditional-card"),
|
||||
"entity-visibility": () => import("../cards/hui-entity-visibility-card"),
|
||||
"empty-state": () => import("../cards/hui-empty-state-card"),
|
||||
"energy-compare": () => import("../cards/energy/hui-energy-compare-card"),
|
||||
"energy-carbon-consumed-gauge": () =>
|
||||
@@ -52,6 +51,9 @@ const LAZY_LOAD_TYPES = {
|
||||
"energy-distribution": () =>
|
||||
import("../cards/energy/hui-energy-distribution-card"),
|
||||
"energy-gas-graph": () => import("../cards/energy/hui-energy-gas-graph-card"),
|
||||
"gas-sankey": () => import("../cards/water-gas/hui-water-gas-sankey-card"),
|
||||
"gas-flow-sankey": () =>
|
||||
import("../cards/water-gas/hui-water-gas-flow-sankey-card"),
|
||||
"energy-water-graph": () =>
|
||||
import("../cards/energy/hui-energy-water-graph-card"),
|
||||
"energy-grid-neutrality-gauge": () =>
|
||||
@@ -69,9 +71,9 @@ const LAZY_LOAD_TYPES = {
|
||||
"energy-usage-graph": () =>
|
||||
import("../cards/energy/hui-energy-usage-graph-card"),
|
||||
"energy-sankey": () => import("../cards/energy/hui-energy-sankey-card"),
|
||||
"water-sankey": () => import("../cards/water/hui-water-sankey-card"),
|
||||
"water-sankey": () => import("../cards/water-gas/hui-water-gas-sankey-card"),
|
||||
"water-flow-sankey": () =>
|
||||
import("../cards/water/hui-water-flow-sankey-card"),
|
||||
import("../cards/water-gas/hui-water-gas-flow-sankey-card"),
|
||||
"power-sources-graph": () =>
|
||||
import("../cards/energy/hui-power-sources-graph-card"),
|
||||
"power-sankey": () => import("../cards/energy/hui-power-sankey-card"),
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
array,
|
||||
assert,
|
||||
assign,
|
||||
boolean,
|
||||
object,
|
||||
optional,
|
||||
string,
|
||||
@@ -38,6 +39,7 @@ const cardConfigStruct = assign(
|
||||
object({
|
||||
title: optional(string()),
|
||||
entities: array(union([string(), distributionEntityConfigStruct])),
|
||||
expand_legend: optional(boolean()),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -58,7 +60,10 @@ const SUB_FORM = {
|
||||
] as const,
|
||||
};
|
||||
|
||||
const SCHEMA = [{ name: "title", selector: { text: {} } }] as const;
|
||||
const SCHEMA = [
|
||||
{ name: "title", selector: { text: {} } },
|
||||
{ name: "expand_legend", required: false, selector: { boolean: {} } },
|
||||
] as const;
|
||||
|
||||
@customElement("hui-distribution-card-editor")
|
||||
export class HuiDistributionCardEditor
|
||||
@@ -207,6 +212,10 @@ export class HuiDistributionCardEditor
|
||||
)} (${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.card.config.optional"
|
||||
)})`;
|
||||
case "expand_legend":
|
||||
return this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.card.generic.expand_legend"
|
||||
);
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -27,8 +27,10 @@ const cardConfigStruct = assign(
|
||||
type: union([
|
||||
literal("power-sankey"),
|
||||
literal("water-sankey"),
|
||||
literal("gas-sankey"),
|
||||
literal("energy-sankey"),
|
||||
literal("water-flow-sankey"),
|
||||
literal("gas-flow-sankey"),
|
||||
]),
|
||||
title: optional(string()),
|
||||
collection_key: optional(string()),
|
||||
|
||||
@@ -28,6 +28,8 @@ const NON_STANDARD_CARD_URLS = {
|
||||
"power-sankey": "energy/#power-flow-sankey-graph",
|
||||
"water-sankey": "energy/#water-sankey-graph",
|
||||
"water-flow-sankey": "energy/#water-flow-sankey-graph",
|
||||
"gas-sankey": "energy/#gas-sankey-graph",
|
||||
"gas-flow-sankey": "energy/#gas-flow-sankey-graph",
|
||||
"power-sources-graph": "energy/#power-sources-graph",
|
||||
};
|
||||
|
||||
|
||||
@@ -208,6 +208,14 @@ export const energyCards: Card[] = [
|
||||
type: "water-flow-sankey",
|
||||
showElement: false,
|
||||
},
|
||||
{
|
||||
type: "gas-sankey",
|
||||
showElement: false,
|
||||
},
|
||||
{
|
||||
type: "gas-flow-sankey",
|
||||
showElement: false,
|
||||
},
|
||||
{
|
||||
type: "energy-solar-consumed-gauge",
|
||||
showElement: false,
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
|
||||
import type { EntityVisibilityCardConfig } from "../../cards/types";
|
||||
|
||||
export type ComputeEntityCard = (entityId: string) => LovelaceCardConfig;
|
||||
|
||||
export const wrapEntityVisibility =
|
||||
(hiddenEntities: Set<string>, computeCard: ComputeEntityCard) =>
|
||||
(entityId: string): EntityVisibilityCardConfig => ({
|
||||
type: "entity-visibility",
|
||||
entity: entityId,
|
||||
hidden: hiddenEntities.has(entityId),
|
||||
card: computeCard(entityId),
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import { customElement } from "lit/decorators";
|
||||
import { computeDeviceName } from "../../../../common/entity/compute_device_name";
|
||||
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
|
||||
import {
|
||||
excludeEntities,
|
||||
findEntities,
|
||||
generateEntityFilter,
|
||||
} from "../../../../common/entity/entity_filter";
|
||||
@@ -30,7 +29,6 @@ import {
|
||||
export interface HomeAreaViewStrategyConfig {
|
||||
type: "home-area";
|
||||
area?: string;
|
||||
climate_hidden_entities?: string[];
|
||||
home_panel?: boolean;
|
||||
}
|
||||
|
||||
@@ -93,10 +91,6 @@ export class HomeAreaViewStrategy extends ReactiveElement {
|
||||
const allEntities = Object.keys(hass.states);
|
||||
const areaEntities = allEntities.filter(areaFilter);
|
||||
|
||||
const hiddenEntities: Partial<Record<HomeSummary, string[]>> = {
|
||||
climate: config.climate_hidden_entities,
|
||||
};
|
||||
|
||||
const entitiesBySummary = HOME_SUMMARIES.reduce(
|
||||
(acc, summary) => {
|
||||
const summariesFilters = HOME_SUMMARIES_FILTERS[summary];
|
||||
@@ -110,10 +104,7 @@ export class HomeAreaViewStrategy extends ReactiveElement {
|
||||
filter
|
||||
)
|
||||
);
|
||||
acc[summary] = excludeEntities(
|
||||
findEntities(areaEntities, filterFunctions),
|
||||
hiddenEntities[summary]
|
||||
);
|
||||
acc[summary] = findEntities(areaEntities, filterFunctions);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<HomeSummary, string[]>
|
||||
|
||||
@@ -21,8 +21,6 @@ import type { HomeOverviewViewStrategyConfig } from "./home-overview-view-strate
|
||||
export interface HomeDashboardStrategyConfig {
|
||||
type: "home";
|
||||
alert_entities?: SecurityAlertEntityConfig[];
|
||||
maintenance_hidden_entities?: string[];
|
||||
climate_hidden_entities?: string[];
|
||||
favorite_entities?: string[];
|
||||
home_panel?: boolean;
|
||||
hide_welcome_message?: boolean;
|
||||
@@ -74,7 +72,6 @@ export class HomeDashboardStrategy extends ReactiveElement {
|
||||
strategy: {
|
||||
type: "home-area",
|
||||
area: area.area_id,
|
||||
climate_hidden_entities: config.climate_hidden_entities,
|
||||
home_panel: config.home_panel,
|
||||
} satisfies HomeAreaViewStrategyConfig,
|
||||
};
|
||||
@@ -109,8 +106,6 @@ export class HomeDashboardStrategy extends ReactiveElement {
|
||||
strategy: {
|
||||
type: "home-overview",
|
||||
alert_entities: config.alert_entities,
|
||||
maintenance_hidden_entities: config.maintenance_hidden_entities,
|
||||
climate_hidden_entities: config.climate_hidden_entities,
|
||||
favorite_entities: config.favorite_entities,
|
||||
home_panel: config.home_panel,
|
||||
hide_welcome_message: config.hide_welcome_message,
|
||||
|
||||
@@ -57,8 +57,6 @@ import { OTHER_DEVICES_FILTERS } from "./helpers/other-devices-filters";
|
||||
export interface HomeOverviewViewStrategyConfig {
|
||||
type: "home-overview";
|
||||
alert_entities?: SecurityAlertEntityConfig[];
|
||||
maintenance_hidden_entities?: string[];
|
||||
climate_hidden_entities?: string[];
|
||||
favorite_entities?: string[];
|
||||
home_panel?: boolean;
|
||||
hide_welcome_message?: boolean;
|
||||
@@ -362,15 +360,11 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
hass.panels.light && findEntities(allEntities, lightsFilters).length > 0;
|
||||
const hasMediaPlayers =
|
||||
findEntities(allEntities, mediaPlayerFilter).length > 0;
|
||||
const hasClimate =
|
||||
hass.panels.climate &&
|
||||
hasClimateEntities(hass, config.climate_hidden_entities);
|
||||
const hasClimate = hass.panels.climate && hasClimateEntities(hass);
|
||||
const hasSecurity =
|
||||
hass.panels.security && findSecurityEntities(hass).length > 0;
|
||||
const hasMaintenance =
|
||||
hass.panels.maintenance &&
|
||||
findMaintenanceEntities(hass, config.maintenance_hidden_entities).length >
|
||||
0;
|
||||
hass.panels.maintenance && findMaintenanceEntities(hass).length > 0;
|
||||
|
||||
const alertEntities = config.alert_entities ?? [];
|
||||
const alertSeverityEntities = filterSecurityAlertEntities(
|
||||
@@ -429,9 +423,6 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
? ({
|
||||
type: "home-summary",
|
||||
summary: "climate",
|
||||
hidden_entities: config.climate_hidden_entities?.length
|
||||
? config.climate_hidden_entities
|
||||
: undefined,
|
||||
tap_action: {
|
||||
action: "navigate",
|
||||
navigation_path: "/climate?historyBack=1",
|
||||
@@ -471,9 +462,6 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
? ({
|
||||
type: "home-summary",
|
||||
summary: "maintenance",
|
||||
hidden_entities: config.maintenance_hidden_entities?.length
|
||||
? config.maintenance_hidden_entities
|
||||
: undefined,
|
||||
tap_action: {
|
||||
action: "navigate",
|
||||
navigation_path: config.home_panel
|
||||
|
||||
@@ -1,23 +1,13 @@
|
||||
import { mdiPencil } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-icon-button";
|
||||
import "../../components/ha-top-app-bar-fixed";
|
||||
import {
|
||||
fetchFrontendSystemDataAfterSaves,
|
||||
queueFrontendSystemDataSave,
|
||||
type MaintenanceFrontendSystemData,
|
||||
} from "../../data/frontend";
|
||||
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
|
||||
import { ChildPanelReady } from "../../layouts/panel-ready";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { showToast } from "../../util/toast";
|
||||
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
|
||||
import type { Lovelace } from "../lovelace/types";
|
||||
import "../lovelace/views/hui-view";
|
||||
@@ -40,20 +30,10 @@ class PanelMaintenance extends LitElement {
|
||||
|
||||
@state() private _lovelace?: Lovelace;
|
||||
|
||||
@state() private _config?: MaintenanceFrontendSystemData;
|
||||
|
||||
@state() private _editVisibility = false;
|
||||
|
||||
@state() private _searchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
private _childPanelReady?: ChildPanelReady;
|
||||
|
||||
private _loadConfigPromise?: Promise<void>;
|
||||
|
||||
private _loadConfigRevision = 0;
|
||||
|
||||
private _setLovelaceRevision = 0;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
// Initial setup
|
||||
@@ -67,22 +47,13 @@ class PanelMaintenance extends LitElement {
|
||||
}
|
||||
|
||||
const oldHass = changedProps.get("hass") as this["hass"];
|
||||
if (
|
||||
oldHass &&
|
||||
this.hass.config.state === "RUNNING" &&
|
||||
oldHass.config.state !== "RUNNING"
|
||||
) {
|
||||
this._setup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (oldHass && oldHass.localize !== this.hass.localize) {
|
||||
this._setLovelace();
|
||||
return;
|
||||
}
|
||||
|
||||
if (oldHass && this.hass) {
|
||||
// Refresh the generated view when registries or panels change.
|
||||
// If the entity registry changed, ask the user if they want to refresh the config
|
||||
if (
|
||||
oldHass.entities !== this.hass.entities ||
|
||||
oldHass.devices !== this.hass.devices ||
|
||||
@@ -92,47 +63,24 @@ class PanelMaintenance extends LitElement {
|
||||
) {
|
||||
if (this.hass.config.state === "RUNNING") {
|
||||
this._debounceRegistriesChanged();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// If ha started, refresh the config
|
||||
if (
|
||||
this.hass.config.state === "RUNNING" &&
|
||||
oldHass.config.state !== "RUNNING"
|
||||
) {
|
||||
this._setLovelace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _setup() {
|
||||
this._loadConfigPromise = this._loadConfig();
|
||||
await Promise.all([
|
||||
this.hass.loadFragmentTranslation("lovelace"),
|
||||
this._loadConfigPromise,
|
||||
]);
|
||||
await this.hass.loadFragmentTranslation("lovelace");
|
||||
this._setLovelace();
|
||||
}
|
||||
|
||||
private async _loadConfig() {
|
||||
const revision = ++this._loadConfigRevision;
|
||||
this._config = undefined;
|
||||
try {
|
||||
const data = await fetchFrontendSystemDataAfterSaves(
|
||||
this.hass.connection,
|
||||
"maintenance"
|
||||
);
|
||||
if (revision !== this._loadConfigRevision) {
|
||||
return;
|
||||
}
|
||||
this._config = data || {};
|
||||
} catch (err) {
|
||||
if (revision !== this._loadConfigRevision) {
|
||||
return;
|
||||
}
|
||||
this._editVisibility = false;
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to load maintenance configuration:", err);
|
||||
showToast(this, {
|
||||
message: this.hass.localize("ui.panel.maintenance.editor.load_failed"),
|
||||
duration: 0,
|
||||
dismissable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _debounceRegistriesChanged = debounce(
|
||||
() => this._registriesChanged(),
|
||||
200
|
||||
@@ -149,27 +97,6 @@ class PanelMaintenance extends LitElement {
|
||||
.backButton=${this._searchParams.has("historyBack")}
|
||||
>
|
||||
<div slot="title">${this.hass.localize("panel.maintenance")}</div>
|
||||
${
|
||||
!this.hass.user?.is_admin
|
||||
? nothing
|
||||
: this._editVisibility
|
||||
? html`<ha-button
|
||||
slot="actionItems"
|
||||
appearance="filled"
|
||||
size="s"
|
||||
@click=${this._stopEditVisibility}
|
||||
>
|
||||
${this.hass.localize("ui.panel.lovelace.menu.exit_edit_mode")}
|
||||
</ha-button>`
|
||||
: this._config
|
||||
? html`<ha-icon-button
|
||||
slot="actionItems"
|
||||
.path=${mdiPencil}
|
||||
.label=${this.hass.localize("ui.panel.maintenance.editor.title")}
|
||||
@click=${this._startEditVisibility}
|
||||
></ha-icon-button>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._lovelace
|
||||
? html`
|
||||
@@ -181,7 +108,6 @@ class PanelMaintenance extends LitElement {
|
||||
.narrow=${this.narrow}
|
||||
.lovelace=${this._lovelace}
|
||||
.index=${this._viewIndex}
|
||||
@entity-visibility-changed=${this._visibilityChanged}
|
||||
></hui-view>
|
||||
</hui-view-container>
|
||||
`
|
||||
@@ -191,31 +117,16 @@ class PanelMaintenance extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private async _setLovelace(force = false) {
|
||||
const revision = ++this._setLovelaceRevision;
|
||||
if (this._loadConfigPromise) {
|
||||
await this._loadConfigPromise;
|
||||
}
|
||||
|
||||
private async _setLovelace() {
|
||||
const viewConfig = await generateLovelaceViewStrategy(
|
||||
{
|
||||
strategy: {
|
||||
...MAINTENANCE_LOVELACE_VIEW_CONFIG.strategy,
|
||||
hidden_entities: this._config?.hidden_entities,
|
||||
edit_visibility: this._editVisibility || undefined,
|
||||
},
|
||||
},
|
||||
MAINTENANCE_LOVELACE_VIEW_CONFIG,
|
||||
this.hass
|
||||
);
|
||||
|
||||
if (revision !== this._setLovelaceRevision) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = { views: [viewConfig] };
|
||||
const rawConfig = { views: [MAINTENANCE_LOVELACE_VIEW_CONFIG] };
|
||||
|
||||
if (!force && deepEqual(config, this._lovelace?.config)) {
|
||||
if (deepEqual(config, this._lovelace?.config)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -235,60 +146,6 @@ class PanelMaintenance extends LitElement {
|
||||
};
|
||||
}
|
||||
|
||||
private _startEditVisibility = () => {
|
||||
this._editVisibility = true;
|
||||
this._setLovelace();
|
||||
};
|
||||
|
||||
private _stopEditVisibility = () => {
|
||||
this._editVisibility = false;
|
||||
if (!this._config) {
|
||||
this._loadConfigPromise = this._loadConfig();
|
||||
}
|
||||
this._setLovelace();
|
||||
};
|
||||
|
||||
private _visibilityChanged = (
|
||||
ev: HASSDomEvent<HASSDomEvents["entity-visibility-changed"]>
|
||||
) => {
|
||||
if (!this._config) {
|
||||
return;
|
||||
}
|
||||
const { entity, hidden } = ev.detail;
|
||||
const hiddenEntities = (this._config.hidden_entities ?? []).filter(
|
||||
(entityId) => entityId !== entity
|
||||
);
|
||||
if (hidden) {
|
||||
hiddenEntities.push(entity);
|
||||
}
|
||||
this._saveConfig({
|
||||
...this._config,
|
||||
hidden_entities: hiddenEntities.length ? hiddenEntities : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
private _saveConfig(config: MaintenanceFrontendSystemData): Promise<void> {
|
||||
this._config = config;
|
||||
return queueFrontendSystemDataSave(
|
||||
this.hass.connection,
|
||||
"maintenance",
|
||||
config
|
||||
).catch(async (err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to save maintenance configuration:", err);
|
||||
showToast(this, {
|
||||
message: this.hass.localize("ui.panel.maintenance.editor.save_failed"),
|
||||
duration: 0,
|
||||
dismissable: true,
|
||||
});
|
||||
if (config !== this._config) {
|
||||
return;
|
||||
}
|
||||
await this._loadConfig();
|
||||
await this._setLovelace(true);
|
||||
});
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
|
||||
@@ -14,10 +14,6 @@ import type { LovelaceSectionRawConfig } from "../../../data/lovelace/config/sec
|
||||
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { TileCardConfig } from "../../lovelace/cards/types";
|
||||
import {
|
||||
wrapEntityVisibility,
|
||||
type ComputeEntityCard,
|
||||
} from "../../lovelace/strategies/helpers/entity-visibility";
|
||||
import { BINARY_STATE_ON } from "../../../common/const";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { getDeviceEntityDisplayLookup } from "../../../data/device/device_registry";
|
||||
@@ -25,8 +21,6 @@ import { findBatteryChargingEntity } from "../../../data/entity/entity_registry"
|
||||
|
||||
export interface MaintenanceViewStrategyConfig {
|
||||
type: "maintenance";
|
||||
hidden_entities?: string[];
|
||||
edit_visibility?: boolean;
|
||||
}
|
||||
|
||||
export const maintenanceEntityFilters: EntityFilter[] = [
|
||||
@@ -41,10 +35,8 @@ export const maintenanceEntityFilters: EntityFilter[] = [
|
||||
];
|
||||
|
||||
export const findMaintenanceEntities = (
|
||||
sources: EntityFilterSources,
|
||||
hiddenEntities?: string[]
|
||||
): string[] =>
|
||||
findFilteredEntities(sources, maintenanceEntityFilters, hiddenEntities);
|
||||
sources: EntityFilterSources
|
||||
): string[] => findFilteredEntities(sources, maintenanceEntityFilters);
|
||||
|
||||
const LOW_BATTERY_THRESHOLD = 20;
|
||||
|
||||
@@ -127,8 +119,7 @@ const computeMaintenanceTileCard = (
|
||||
const processAreasForBattery = (
|
||||
areaIds: string[],
|
||||
hass: HomeAssistant,
|
||||
entities: string[],
|
||||
computeCard: ComputeEntityCard
|
||||
entities: string[]
|
||||
): LovelaceCardConfig[] => {
|
||||
const cards: LovelaceCardConfig[] = [];
|
||||
|
||||
@@ -150,7 +141,9 @@ const processAreasForBattery = (
|
||||
const areaCards: LovelaceCardConfig[] = [];
|
||||
|
||||
for (const entityId of areaBatteryEntities) {
|
||||
areaCards.push(computeCard(entityId));
|
||||
areaCards.push(
|
||||
computeMaintenanceTileCard(hass.states, hass.entities, entityId)
|
||||
);
|
||||
}
|
||||
|
||||
if (areaCards.length > 0) {
|
||||
@@ -174,8 +167,7 @@ const processAreasForBattery = (
|
||||
|
||||
const processUnassignedEntities = (
|
||||
hass: HomeAssistant,
|
||||
entities: string[],
|
||||
computeCard: ComputeEntityCard
|
||||
entities: string[]
|
||||
): LovelaceCardConfig[] => {
|
||||
const unassignedFilter = generateEntityFilter(
|
||||
hass.states,
|
||||
@@ -191,7 +183,9 @@ const processUnassignedEntities = (
|
||||
const cards: LovelaceCardConfig[] = [];
|
||||
|
||||
for (const entityId of unassignedEntities) {
|
||||
cards.push(computeCard(entityId));
|
||||
cards.push(
|
||||
computeMaintenanceTileCard(hass.states, hass.entities, entityId)
|
||||
);
|
||||
}
|
||||
|
||||
return cards;
|
||||
@@ -200,7 +194,7 @@ const processUnassignedEntities = (
|
||||
@customElement("maintenance-view-strategy")
|
||||
export class MaintenanceViewStrategy extends ReactiveElement {
|
||||
static async generate(
|
||||
config: MaintenanceViewStrategyConfig,
|
||||
_config: MaintenanceViewStrategyConfig,
|
||||
hass: HomeAssistant
|
||||
): Promise<LovelaceViewConfig> {
|
||||
const areas = Object.values(hass.areas);
|
||||
@@ -209,16 +203,7 @@ export class MaintenanceViewStrategy extends ReactiveElement {
|
||||
|
||||
const sections: LovelaceSectionRawConfig[] = [];
|
||||
|
||||
const entities = findMaintenanceEntities(
|
||||
hass,
|
||||
config.edit_visibility ? undefined : config.hidden_entities
|
||||
);
|
||||
|
||||
const computeTileCard = (entityId: string) =>
|
||||
computeMaintenanceTileCard(hass.states, hass.entities, entityId);
|
||||
const computeCard: ComputeEntityCard = config.edit_visibility
|
||||
? wrapEntityVisibility(new Set(config.hidden_entities), computeTileCard)
|
||||
: computeTileCard;
|
||||
const entities = findMaintenanceEntities(hass);
|
||||
|
||||
const floorCount =
|
||||
hierarchy.floors.length + (hierarchy.areas.length ? 1 : 0);
|
||||
@@ -244,12 +229,7 @@ export class MaintenanceViewStrategy extends ReactiveElement {
|
||||
],
|
||||
};
|
||||
|
||||
const areaCards = processAreasForBattery(
|
||||
areaIds,
|
||||
hass,
|
||||
entities,
|
||||
computeCard
|
||||
);
|
||||
const areaCards = processAreasForBattery(areaIds, hass, entities);
|
||||
|
||||
if (areaCards.length > 0) {
|
||||
section.cards!.push(...areaCards);
|
||||
@@ -275,12 +255,7 @@ export class MaintenanceViewStrategy extends ReactiveElement {
|
||||
],
|
||||
};
|
||||
|
||||
const areaCards = processAreasForBattery(
|
||||
hierarchy.areas,
|
||||
hass,
|
||||
entities,
|
||||
computeCard
|
||||
);
|
||||
const areaCards = processAreasForBattery(hierarchy.areas, hass, entities);
|
||||
|
||||
if (areaCards.length > 0) {
|
||||
section.cards!.push(...areaCards);
|
||||
@@ -289,11 +264,7 @@ export class MaintenanceViewStrategy extends ReactiveElement {
|
||||
}
|
||||
|
||||
// Process unassigned entities
|
||||
const unassignedCards = processUnassignedEntities(
|
||||
hass,
|
||||
entities,
|
||||
computeCard
|
||||
);
|
||||
const unassignedCards = processUnassignedEntities(hass, entities);
|
||||
|
||||
if (unassignedCards.length > 0) {
|
||||
const section: LovelaceSectionRawConfig = {
|
||||
|
||||
@@ -35,9 +35,9 @@ import {
|
||||
sortedMarketplaceFilterQueryString,
|
||||
} from "../../../common/url/marketplace-query-params";
|
||||
import { shallowEqual } from "../../../common/util/shallow-equal";
|
||||
import { groupActionsSlot } from "../../../components/data-table/ha-data-table";
|
||||
import type {
|
||||
DataTableColumnContainer,
|
||||
DataTableGroupActions,
|
||||
SelectionChangedEvent,
|
||||
} from "../../../components/data-table/ha-data-table";
|
||||
import "../../../layouts/hass-tabs-subpage";
|
||||
@@ -400,6 +400,7 @@ export class HaMarketplaceDashboard extends LitElement {
|
||||
this._tableSettings.grouping,
|
||||
this._i18n.localize
|
||||
)}
|
||||
.renderGroupActions=${this._renderGroupActions}
|
||||
.columnOrder=${this._orderTableColumns}
|
||||
.hiddenColumns=${this._hiddenTableColumns}
|
||||
@columns-changed=${this._handleColumnsChanged}
|
||||
@@ -414,14 +415,6 @@ export class HaMarketplaceDashboard extends LitElement {
|
||||
@sorting-changed=${this._tableSettings.sortingChanged}
|
||||
>
|
||||
${this._renderToolbar()}
|
||||
<ha-icon-button
|
||||
slot=${groupActionsSlot(
|
||||
this._i18n.localize("ui.panel.marketplace.repository_status.new")
|
||||
)}
|
||||
.label=${this._i18n.localize("ui.panel.marketplace.menu.dismiss")}
|
||||
.path=${mdiCheckAll}
|
||||
@click=${this._dismissNew}
|
||||
></ha-icon-button>
|
||||
${
|
||||
this.tab === "installed"
|
||||
? html`<ha-assist-chip
|
||||
@@ -984,7 +977,16 @@ export class HaMarketplaceDashboard extends LitElement {
|
||||
showMarketplaceAddFromLink(this, this._i18n.localize, this.marketplace);
|
||||
}
|
||||
|
||||
private _dismissNew() {
|
||||
private _renderGroupActions: DataTableGroupActions = (group) =>
|
||||
group === this._i18n.localize("ui.panel.marketplace.repository_status.new")
|
||||
? html`<ha-icon-button
|
||||
.label=${this._i18n.localize("ui.panel.marketplace.menu.dismiss")}
|
||||
.path=${mdiCheckAll}
|
||||
@click=${this._dismissNew}
|
||||
></ha-icon-button>`
|
||||
: nothing;
|
||||
|
||||
private _dismissNew = () => {
|
||||
showConfirmationDialog(this, {
|
||||
title: this._i18n.localize(
|
||||
"ui.panel.marketplace.dialog.dismiss_new.title"
|
||||
@@ -1021,7 +1023,7 @@ export class HaMarketplaceDashboard extends LitElement {
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private _statusStates = memoize(
|
||||
(localize: LocalizeFunc, tab: MarketplaceTab) =>
|
||||
|
||||
@@ -211,6 +211,10 @@ export const getMyRedirects = (): Redirects => ({
|
||||
tools_states: {
|
||||
redirect: "/config/tools/state",
|
||||
},
|
||||
tools_action_history: {
|
||||
component: "logbook",
|
||||
redirect: "/config/tools/action-history",
|
||||
},
|
||||
tools_actions: {
|
||||
redirect: "/config/tools/action",
|
||||
},
|
||||
|
||||
@@ -2,12 +2,12 @@ import { mdiDelete } from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "@home-assistant/components/card";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { relativeTime } from "../../common/datetime/relative_time";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { HASSDomCurrentTargetEvent } from "../../common/dom/fire_event";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-card";
|
||||
import "../../components/ha-icon-button";
|
||||
import type { HaIconButton } from "../../components/ha-icon-button";
|
||||
import "../../components/ha-settings-row";
|
||||
@@ -37,83 +37,79 @@ class HaLongLivedTokens extends LitElement {
|
||||
const accessTokens = this._accessTokens(this.refreshTokens);
|
||||
|
||||
return html`
|
||||
<ha-card
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.profile.long_lived_access_tokens.header"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
<hac-card>
|
||||
<h2 slot="header" class="header">
|
||||
${this.hass.localize(
|
||||
"ui.panel.profile.long_lived_access_tokens.description"
|
||||
"ui.panel.profile.long_lived_access_tokens.header"
|
||||
)}
|
||||
</h2>
|
||||
${this.hass.localize(
|
||||
"ui.panel.profile.long_lived_access_tokens.description"
|
||||
)}
|
||||
|
||||
<a
|
||||
href="https://contributors.home-assistant.io/core/internals/auth-api/#make-authenticated-requests"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.profile.long_lived_access_tokens.learn_auth_requests"
|
||||
)}
|
||||
</a>
|
||||
${
|
||||
!accessTokens.length
|
||||
? html`<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.profile.long_lived_access_tokens.empty_state"
|
||||
)}
|
||||
</p>`
|
||||
: accessTokens.map(
|
||||
(token) =>
|
||||
html`<ha-settings-row two-line>
|
||||
<span slot="heading">${token.client_name}</span>
|
||||
<div slot="description">
|
||||
${this.hass.localize(
|
||||
"ui.panel.profile.long_lived_access_tokens.created",
|
||||
{
|
||||
date: relativeTime(
|
||||
new Date(token.created_at),
|
||||
this.hass.locale
|
||||
),
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
${
|
||||
this._hasBeenUsed(token)
|
||||
? html`<div slot="description">
|
||||
${this.hass.localize(
|
||||
"ui.panel.profile.long_lived_access_tokens.last_used",
|
||||
{
|
||||
date: relativeTime(
|
||||
new Date(token.last_used_at),
|
||||
this.hass.locale
|
||||
),
|
||||
location: token.last_used_ip,
|
||||
}
|
||||
)}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
<ha-icon-button
|
||||
.token=${token}
|
||||
.disabled=${token.is_current}
|
||||
.label=${this.hass.localize("ui.common.delete")}
|
||||
.path=${mdiDelete}
|
||||
@click=${this._deleteToken}
|
||||
></ha-icon-button>
|
||||
</ha-settings-row>`
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<ha-button @click=${this._createToken}>
|
||||
${this.hass.localize(
|
||||
"ui.panel.profile.long_lived_access_tokens.create"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
<a
|
||||
href="https://contributors.home-assistant.io/core/internals/auth-api/#make-authenticated-requests"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.profile.long_lived_access_tokens.learn_auth_requests"
|
||||
)}
|
||||
</a>
|
||||
${
|
||||
!accessTokens.length
|
||||
? html`<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.profile.long_lived_access_tokens.empty_state"
|
||||
)}
|
||||
</p>`
|
||||
: accessTokens.map(
|
||||
(token) =>
|
||||
html`<ha-settings-row two-line>
|
||||
<span slot="heading">${token.client_name}</span>
|
||||
<div slot="description">
|
||||
${this.hass.localize(
|
||||
"ui.panel.profile.long_lived_access_tokens.created",
|
||||
{
|
||||
date: relativeTime(
|
||||
new Date(token.created_at),
|
||||
this.hass.locale
|
||||
),
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
${
|
||||
this._hasBeenUsed(token)
|
||||
? html`<div slot="description">
|
||||
${this.hass.localize(
|
||||
"ui.panel.profile.long_lived_access_tokens.last_used",
|
||||
{
|
||||
date: relativeTime(
|
||||
new Date(token.last_used_at),
|
||||
this.hass.locale
|
||||
),
|
||||
location: token.last_used_ip,
|
||||
}
|
||||
)}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
<ha-icon-button
|
||||
.token=${token}
|
||||
.disabled=${token.is_current}
|
||||
.label=${this.hass.localize("ui.common.delete")}
|
||||
.path=${mdiDelete}
|
||||
@click=${this._deleteToken}
|
||||
></ha-icon-button>
|
||||
</ha-settings-row>`
|
||||
)
|
||||
}
|
||||
<ha-button slot="footer" @click=${this._createToken}>
|
||||
${this.hass.localize(
|
||||
"ui.panel.profile.long_lived_access_tokens.create"
|
||||
)}
|
||||
</ha-button>
|
||||
</hac-card>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -174,6 +170,9 @@ class HaLongLivedTokens extends LitElement {
|
||||
return [
|
||||
haStyle,
|
||||
css`
|
||||
hac-card::part(footer) {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
ha-settings-row {
|
||||
padding: 0;
|
||||
}
|
||||
@@ -183,9 +182,18 @@ class HaLongLivedTokens extends LitElement {
|
||||
ha-icon-button {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
.header {
|
||||
margin: 0;
|
||||
padding: var(--ha-space-5) var(--ha-space-4) var(--ha-space-4);
|
||||
color: var(--ha-card-header-color, var(--primary-text-color));
|
||||
font-family: var(--ha-card-header-font-family, inherit);
|
||||
font-size: var(--ha-card-header-font-size, var(--ha-font-size-2xl));
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
letter-spacing: -0.012em;
|
||||
/* haStyle truncates h2 to one line, long titles should wrap */
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user