mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-13 01:50:44 +00:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3356d6247b | ||
|
|
e07a08e72f | ||
|
|
4b7d3a7e4f | ||
|
|
88be7adafa | ||
|
|
91a6d737b3 | ||
|
|
22c3a6fe67 | ||
|
|
bcc799970a | ||
|
|
31d4a37c15 | ||
|
|
49ea96e091 | ||
|
|
08b33ccbc1 | ||
|
|
048e754149 | ||
|
|
3a30ea5973 | ||
|
|
f7836fd3d5 | ||
|
|
03d8c092ce | ||
|
|
b3aa3c83d5 |
@@ -11,6 +11,9 @@ inputs:
|
||||
is-test:
|
||||
description: Set IS_TEST for the build (skips source maps and compression)
|
||||
default: "false"
|
||||
rspack-cache:
|
||||
description: rspack persistent cache mode ("readwrite", "readonly", or "" to disable)
|
||||
default: ""
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
@@ -21,3 +24,4 @@ runs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ inputs.github-token }}
|
||||
IS_TEST: ${{ inputs.is-test }}
|
||||
RSPACK_CACHE: ${{ inputs.rspack-cache }}
|
||||
|
||||
@@ -97,10 +97,12 @@ jobs:
|
||||
run: yarn run test
|
||||
build:
|
||||
name: Build frontend
|
||||
needs:
|
||||
- prepare-dependencies
|
||||
- lint
|
||||
- test
|
||||
# Runs alongside lint and test rather than after them: the build only needs
|
||||
# the dependency tree, and with the rspack cache it is no longer expensive
|
||||
# enough to be worth serialising behind the other checks. The
|
||||
# cancel-on-failure job below stops the run as soon as a check fails, so a
|
||||
# broken pull request does not finish building.
|
||||
needs: prepare-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
@@ -111,12 +113,26 @@ jobs:
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
# Read-only reuse of the rspack cache written by the nightly (see
|
||||
# nightly.yaml). rspack itself decides what is still valid (version +
|
||||
# buildDependencies + node_modules snapshot), so the GHA key just restores
|
||||
# the latest nightly cache; no fingerprint, and no save step (CI never
|
||||
# writes the shared cache).
|
||||
- name: Restore rspack cache
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: .rspack-cache
|
||||
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
rspack-cache-${{ runner.os }}-
|
||||
- name: Build Application
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-app
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
is-test: true
|
||||
rspack-cache: readonly
|
||||
- name: Upload bundle stats
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
@@ -132,3 +148,54 @@ jobs:
|
||||
path: hass_frontend/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
# Now that the checks run in parallel, a failing lint or test no longer stops
|
||||
# the build from finishing on its own, so this watches them and cancels the
|
||||
# whole run on the first failure.
|
||||
#
|
||||
# It is a separate job on purpose. Cancelling needs `actions: write`, and the
|
||||
# other jobs check out the pull request and run its build scripts — handing
|
||||
# them that scope would give PR-controlled code (or a compromised dependency)
|
||||
# write access to Actions. This job never checks out the repository, so the
|
||||
# elevated token stays away from PR code. It also cannot be a job that
|
||||
# `needs` the checks: that would only start once they have all finished, which
|
||||
# is exactly too late to cancel anything.
|
||||
cancel-on-failure:
|
||||
name: Cancel run on failure
|
||||
needs: prepare-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Cancel the run when a check fails
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
run: |
|
||||
watched='^(Lint and check format|Run tests|Build frontend)$'
|
||||
while :; do
|
||||
jobs=$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs?per_page=100" \
|
||||
--paginate --jq '.jobs[] | [.name, .status, (.conclusion // "")] | @tsv' \
|
||||
2>/dev/null || true)
|
||||
|
||||
failed=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" \
|
||||
'$1 ~ w && ($3 == "failure" || $3 == "timed_out") { print $1 }')
|
||||
if [ -n "$failed" ]; then
|
||||
echo "Cancelling the run, these checks failed:"
|
||||
printf '%s\n' "$failed"
|
||||
gh run cancel "$RUN_ID" --repo "$REPO" || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
found=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" '$1 ~ w' | wc -l)
|
||||
running=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" \
|
||||
'$1 ~ w && $2 != "completed" { print $1 }')
|
||||
if [ "$found" -ge 3 ] && [ -z "$running" ]; then
|
||||
echo "All checks finished without failure"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep 15
|
||||
done
|
||||
|
||||
@@ -38,6 +38,11 @@ jobs:
|
||||
run: ./script/translations_download
|
||||
env:
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
|
||||
# The wheel only builds the app (build-app), which does not merge
|
||||
# backend translations. Skipping the whole-project backend export (as
|
||||
# the release does) keeps this off the build's critical path; the full
|
||||
# translations artifact is produced in parallel by the job below.
|
||||
SKIP_BACKEND_TRANSLATIONS: "1"
|
||||
|
||||
- name: Bump version
|
||||
run: script/version_bump.js nightly
|
||||
@@ -53,9 +58,24 @@ jobs:
|
||||
restore-keys: |
|
||||
compress-cache-${{ runner.os }}-
|
||||
|
||||
# The nightly writes the rspack persistent cache; CI reads it read-only
|
||||
# (see ci.yaml). rspack invalidates internally (version + buildDependencies
|
||||
# + node_modules snapshot), so the cache rolls forward daily and a single
|
||||
# dependency bump keeps most of it warm instead of dropping the lineage.
|
||||
- name: Restore rspack cache
|
||||
id: rspack-cache
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: .rspack-cache
|
||||
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
restore-keys: |
|
||||
rspack-cache-${{ runner.os }}-
|
||||
|
||||
- name: Build nightly Python wheels
|
||||
env:
|
||||
COMPRESS_CACHE_DIR: ${{ github.workspace }}/.compress-cache
|
||||
RSPACK_CACHE: readwrite
|
||||
run: |
|
||||
pip install build
|
||||
yarn install
|
||||
@@ -64,7 +84,7 @@ jobs:
|
||||
rm -rf dist home_assistant_frontend.egg-info
|
||||
python3 -m build
|
||||
|
||||
# Not gated on the restore step: a transient restore failure (it is
|
||||
# Not gated on the restore steps: a transient restore failure (they are
|
||||
# continue-on-error) must not stop us persisting a freshly built cache.
|
||||
- name: Save compression cache
|
||||
if: success()
|
||||
@@ -74,8 +94,13 @@ jobs:
|
||||
path: .compress-cache
|
||||
key: compress-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- name: Archive translations
|
||||
run: tar -czvf translations.tar.gz translations
|
||||
- name: Save rspack cache
|
||||
if: success()
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: .rspack-cache
|
||||
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
@@ -84,6 +109,31 @@ jobs:
|
||||
path: dist/home_assistant_frontend*.whl
|
||||
if-no-files-found: error
|
||||
|
||||
# The full translations (including the slow backend/core export) are only
|
||||
# needed for the uploaded artifact, not the wheel, so they are downloaded in
|
||||
# parallel here instead of blocking the build above.
|
||||
translations:
|
||||
name: Translations
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
immutable: false
|
||||
|
||||
- name: Download translations
|
||||
run: ./script/translations_download
|
||||
env:
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
|
||||
|
||||
- name: Archive translations
|
||||
run: tar -czvf translations.tar.gz translations
|
||||
|
||||
- name: Upload translations
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
|
||||
@@ -7,6 +7,7 @@ dist/
|
||||
/hass_frontend/
|
||||
/translations/
|
||||
/.compress-cache/
|
||||
/.rspack-cache/
|
||||
# Composite action source, not build output
|
||||
!/.github/actions/build/
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
const { existsSync } = require("fs");
|
||||
const fs = require("fs");
|
||||
|
||||
const { existsSync } = fs;
|
||||
const path = require("path");
|
||||
const rspack = require("@rspack/core");
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
@@ -16,6 +18,61 @@ const SafeWebpackBar = require("./safe-webpackbar.cjs");
|
||||
const paths = require("./paths.cjs");
|
||||
const bundle = require("./bundle.cjs");
|
||||
|
||||
// Build-toolchain packages whose version changes the emitted bytes but which
|
||||
// are loader/compiler machinery, not modules in the build graph — so rspack's
|
||||
// node_modules snapshot cannot see them. Their versions are folded into the
|
||||
// persistent cache `version` so a toolchain upgrade invalidates the cache,
|
||||
// while ordinary runtime-dependency bumps (handled by the snapshot) do not.
|
||||
const TOOLCHAIN_PACKAGES = [
|
||||
"@rspack/core",
|
||||
"@babel/core",
|
||||
"@babel/preset-env",
|
||||
"babel-plugin-polyfill-corejs3",
|
||||
"@babel/plugin-transform-runtime",
|
||||
"@babel/plugin-transform-class-properties",
|
||||
"@babel/plugin-transform-private-methods",
|
||||
"@babel/runtime",
|
||||
"babel-loader",
|
||||
"core-js",
|
||||
"terser",
|
||||
"terser-webpack-plugin",
|
||||
"browserslist",
|
||||
"caniuse-lite",
|
||||
];
|
||||
|
||||
// Our own build logic — the config, loaders and babel plugins. Their contents
|
||||
// (not their paths) go into the cache version, so a change invalidates the
|
||||
// cache the same way `buildDependencies` would, but without tying validity to
|
||||
// absolute paths — rspack compares buildDependencies by path, which breaks a
|
||||
// cache reused on another machine/checkout (a different workspace path).
|
||||
const CONFIG_FILES = [
|
||||
__filename,
|
||||
path.join(__dirname, "bundle.cjs"),
|
||||
path.join(__dirname, "minify-template-literals-loader.cjs"),
|
||||
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"),
|
||||
];
|
||||
|
||||
// Content hash of the toolchain versions and our own build files, used as the
|
||||
// persistent cache `version`. Everything here is path-independent so the cache
|
||||
// stays valid when reused on a different machine or checkout path.
|
||||
const cacheVersion = () => {
|
||||
const parts = [
|
||||
...TOOLCHAIN_PACKAGES.map(
|
||||
(pkg) => `${pkg}@${require(`${pkg}/package.json`).version}`
|
||||
),
|
||||
...CONFIG_FILES.map(
|
||||
(file) => `${path.basename(file)}:${fs.readFileSync(file, "utf8")}`
|
||||
),
|
||||
];
|
||||
return require("crypto")
|
||||
.createHash("sha256")
|
||||
.update(parts.join("\n"))
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
};
|
||||
|
||||
class LogStartCompilePlugin {
|
||||
ignoredFirst = false;
|
||||
|
||||
@@ -376,6 +433,33 @@ const createRspackConfig = ({
|
||||
])
|
||||
),
|
||||
},
|
||||
// Persistent filesystem cache for production builds, opt-in per environment
|
||||
// via RSPACK_CACHE ("readwrite" writes it, "readonly" only reads a warm
|
||||
// cache — e.g. CI reusing the nightly-written one). Unset (releases, local,
|
||||
// tests) = no cache.
|
||||
...(isProdBuild && process.env.RSPACK_CACHE
|
||||
? {
|
||||
cache: {
|
||||
type: "persistent",
|
||||
// `name` is already unique per variant (frontend-modern/-legacy).
|
||||
name,
|
||||
// Content-based version (node major + toolchain versions + our own
|
||||
// build files). Everything is path-independent, so the cache stays
|
||||
// valid when reused on another machine/checkout. Runtime deps are
|
||||
// deliberately absent — rspack's node_modules snapshot invalidates
|
||||
// their modules per-package, so a single unrelated bump keeps the
|
||||
// rest warm. buildDependencies is intentionally not used: rspack
|
||||
// compares it by absolute path, which breaks cross-machine reuse.
|
||||
version: `node${process.versions.node.split(".")[0]}-${cacheVersion()}`,
|
||||
storage: {
|
||||
type: "filesystem",
|
||||
directory: path.resolve(paths.root_dir, ".rspack-cache"),
|
||||
},
|
||||
// CI reads the nightly-written cache but must not modify it.
|
||||
readonly: process.env.RSPACK_CACHE === "readonly",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
experiments: {
|
||||
outputModule: true,
|
||||
},
|
||||
|
||||
@@ -37,6 +37,16 @@ title: Button
|
||||
<ha-button size="s"> small </ha-button>
|
||||
```
|
||||
|
||||
### Icons in the `xs` size
|
||||
|
||||
Avoid icons in `xs` buttons. At 24px the label carries the meaning on its own, and a
|
||||
16px glyph next to it adds visual noise without adding information.
|
||||
|
||||
Use an icon only when the button needs to be recognized at a glance in a dense layout,
|
||||
and only when the glyph is a common one users can identify from its silhouette alone,
|
||||
such as close, add, or settings. A detailed or unfamiliar glyph is unreadable at this
|
||||
size and should be replaced by the label alone.
|
||||
|
||||
### API
|
||||
|
||||
This component is based on the webawesome button component.
|
||||
|
||||
@@ -56,6 +56,19 @@ export class DemoHaButton extends LitElement {
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
${appearances.map(
|
||||
(appearance) => html`
|
||||
<ha-button
|
||||
.appearance=${appearance}
|
||||
.variant=${variant}
|
||||
size="xs"
|
||||
>
|
||||
${titleCase(`${variant} ${appearance}`)}
|
||||
</ha-button>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
${appearances.map(
|
||||
(appearance) => html`
|
||||
|
||||
@@ -65,6 +65,21 @@ export class HaButton extends Button {
|
||||
box-shadow: var(--ha-button-box-shadow);
|
||||
}
|
||||
|
||||
:host([size="xs"]) .button {
|
||||
--wa-form-control-height: var(
|
||||
--ha-button-height,
|
||||
var(--button-height, 24px)
|
||||
);
|
||||
font-size: var(--ha-font-size-m);
|
||||
--wa-form-control-padding-inline: var(--ha-space-2);
|
||||
}
|
||||
|
||||
/* A default 24px icon would fill the whole xs button. */
|
||||
:host([size="xs"]) slot[name="start"]::slotted(*),
|
||||
:host([size="xs"]) slot[name="end"]::slotted(*) {
|
||||
--mdc-icon-size: 16px;
|
||||
}
|
||||
|
||||
:host([size="s"]) .button {
|
||||
--wa-form-control-height: var(
|
||||
--ha-button-height,
|
||||
|
||||
@@ -1,11 +1,89 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { animate } from "@lit-labs/motion";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
|
||||
const THUMB_SIZE = 40;
|
||||
|
||||
@customElement("ha-icon-button-group")
|
||||
export class HaIconButtonGroup extends LitElement {
|
||||
protected render(): TemplateResult {
|
||||
return html`<slot></slot>`;
|
||||
@state() private _thumbX = 0;
|
||||
|
||||
@state() private _thumbVisible = false;
|
||||
|
||||
@state() private _thumbBorderOnly = false;
|
||||
|
||||
// When the thumb appears, only fade it in at its new position instead of
|
||||
// also sliding it from wherever it was last visible.
|
||||
private _thumbAppearing = false;
|
||||
|
||||
private _observer = new MutationObserver(() => this._updateThumb());
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._observer.disconnect();
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<div
|
||||
class="thumb ${classMap({
|
||||
visible: this._thumbVisible,
|
||||
"border-only": this._thumbBorderOnly,
|
||||
})}"
|
||||
style=${styleMap({ left: `${this._thumbX}px` })}
|
||||
${animate(() => ({
|
||||
properties: this._thumbAppearing ? ["opacity"] : ["left", "opacity"],
|
||||
keyframeOptions: {
|
||||
duration: this._animationDuration(),
|
||||
easing: "ease-in-out",
|
||||
},
|
||||
skipInitial: true,
|
||||
}))}
|
||||
></div>
|
||||
<slot @slotchange=${this._handleSlotchange}></slot>
|
||||
`;
|
||||
}
|
||||
|
||||
protected updated() {
|
||||
this._thumbAppearing = false;
|
||||
}
|
||||
|
||||
private _animationDuration(): number {
|
||||
return (
|
||||
parseFloat(
|
||||
getComputedStyle(this).getPropertyValue("--ha-animation-duration-fast")
|
||||
) || 150
|
||||
);
|
||||
}
|
||||
|
||||
private _handleSlotchange(ev: Event) {
|
||||
this._observer.disconnect();
|
||||
const slot = ev.target as HTMLSlotElement;
|
||||
for (const el of slot.assignedElements()) {
|
||||
this._observer.observe(el, {
|
||||
attributes: true,
|
||||
attributeFilter: ["selected", "disabled"],
|
||||
});
|
||||
}
|
||||
// Positions are only valid once the slotted buttons are laid out.
|
||||
requestAnimationFrame(() => this._updateThumb());
|
||||
}
|
||||
|
||||
private _updateThumb() {
|
||||
const selected = this.querySelector<HTMLElement>(
|
||||
"ha-icon-button-toggle[selected]:not([disabled])"
|
||||
);
|
||||
if (!selected) {
|
||||
this._thumbVisible = false;
|
||||
return;
|
||||
}
|
||||
this._thumbAppearing = !this._thumbVisible;
|
||||
this._thumbBorderOnly = selected.hasAttribute("border-only");
|
||||
this._thumbX =
|
||||
selected.offsetLeft + (selected.offsetWidth - THUMB_SIZE) / 2;
|
||||
this._thumbVisible = true;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
@@ -21,6 +99,32 @@ export class HaIconButtonGroup extends LitElement {
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
/* The selected toggle's circle is drawn here so it can slide between
|
||||
toggles; their own circles are suppressed below. */
|
||||
.thumb {
|
||||
position: absolute;
|
||||
top: calc(50% - 20px);
|
||||
opacity: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
background-color: var(
|
||||
--ha-icon-button-group-thumb-color,
|
||||
var(--primary-text-color)
|
||||
);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.thumb.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
.thumb.border-only {
|
||||
background-color: transparent;
|
||||
border: 2px solid
|
||||
var(--ha-icon-button-group-thumb-color, var(--primary-text-color));
|
||||
}
|
||||
::slotted(ha-icon-button-toggle) {
|
||||
--ha-icon-button-toggle-thumb-opacity: 0;
|
||||
}
|
||||
::slotted(.separator) {
|
||||
background-color: rgba(var(--rgb-primary-text-color), 0.15);
|
||||
width: 1px;
|
||||
|
||||
@@ -44,8 +44,10 @@ export class HaIconButtonToggle extends HaIconButton {
|
||||
color: var(--primary-background-color);
|
||||
background-color: unset;
|
||||
}
|
||||
/* ha-icon-button-group zeroes this so its sliding thumb draws the
|
||||
circle instead. */
|
||||
:host([selected]:not([disabled])) ha-button::part(base)::before {
|
||||
opacity: 1;
|
||||
opacity: var(--ha-icon-button-toggle-thumb-opacity, 1);
|
||||
}
|
||||
::slotted(*) {
|
||||
display: block;
|
||||
|
||||
@@ -472,10 +472,9 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
const newValue = value?.trim();
|
||||
const newTab = ev.ctrlKey || ev.metaKey;
|
||||
|
||||
this._fireSelectedEvents(newValue, index, newTab);
|
||||
this._fireSelectedEvents(value, index, newTab);
|
||||
};
|
||||
|
||||
private _fireSelectedEvents(value: string, index: number, newTab = false) {
|
||||
|
||||
@@ -52,7 +52,6 @@ import {
|
||||
type TargetType,
|
||||
} from "../../data/target";
|
||||
import { showMoreInfoDialog } from "../../dialogs/more-info/show-ha-more-info-dialog";
|
||||
import { buttonLinkStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { brandsUrl } from "../../util/brands-url";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
|
||||
@@ -221,30 +220,28 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
? html`
|
||||
<div slot="end" class="summary">
|
||||
${
|
||||
showEntities &&
|
||||
!this.expand &&
|
||||
entries?.referenced_entities.length
|
||||
? html`<button
|
||||
class="main link"
|
||||
this.expand || !entries.referenced_entities.length
|
||||
? html`<span class="main">
|
||||
${this.hass.localize(
|
||||
"ui.components.target-picker.entities_count",
|
||||
{
|
||||
count: entries.referenced_entities.length,
|
||||
}
|
||||
)}
|
||||
</span>`
|
||||
: html`<ha-button
|
||||
appearance="filled"
|
||||
variant="brand"
|
||||
size="xs"
|
||||
@click=${this._openDetails}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.components.target-picker.entities_count",
|
||||
{
|
||||
count: entries?.referenced_entities.length,
|
||||
count: entries.referenced_entities.length,
|
||||
}
|
||||
)}
|
||||
</button>`
|
||||
: showEntities
|
||||
? html`<span class="main">
|
||||
${this.hass.localize(
|
||||
"ui.components.target-picker.entities_count",
|
||||
{
|
||||
count: entries?.referenced_entities.length,
|
||||
}
|
||||
)}
|
||||
</span>`
|
||||
: nothing
|
||||
</ha-button>`
|
||||
}
|
||||
</div>
|
||||
`
|
||||
@@ -812,7 +809,6 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
};
|
||||
|
||||
static styles = [
|
||||
buttonLinkStyle,
|
||||
css`
|
||||
:host {
|
||||
--md-list-item-top-space: 0;
|
||||
@@ -883,16 +879,6 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
button.link {
|
||||
text-decoration: none;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
button.link:hover,
|
||||
button.link:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.state {
|
||||
width: fit-content;
|
||||
font-size: var(--ha-font-size-s);
|
||||
|
||||
@@ -94,6 +94,29 @@ const localizeTimeString = (
|
||||
}
|
||||
};
|
||||
|
||||
// Seconds since midnight for a literal `HH:MM(:SS)` time, undefined for
|
||||
// anything else (entity ids contain a dot, and malformed input is ignored).
|
||||
const literalTimeToSeconds = (value: unknown): number | undefined => {
|
||||
if (typeof value !== "string" || value.includes(".")) {
|
||||
return undefined;
|
||||
}
|
||||
const chunks = value.split(":");
|
||||
if (chunks.length < 2 || chunks.length > 3) {
|
||||
return undefined;
|
||||
}
|
||||
const hours = Number(chunks[0]);
|
||||
const minutes = Number(chunks[1]);
|
||||
const seconds = chunks.length > 2 ? Number(chunks[2]) : 0;
|
||||
if (
|
||||
!Number.isFinite(hours) ||
|
||||
!Number.isFinite(minutes) ||
|
||||
!Number.isFinite(seconds)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return hours * 3600 + minutes * 60 + seconds;
|
||||
};
|
||||
|
||||
const formatNumericLimitValue = (
|
||||
hass: HomeAssistant,
|
||||
value?: number | string
|
||||
@@ -1232,12 +1255,16 @@ const describeLegacyCondition = (
|
||||
|
||||
let hasTime = "";
|
||||
if (after !== undefined && before !== undefined) {
|
||||
if (
|
||||
typeof condition.after === "string" &&
|
||||
!condition.after.includes(".") &&
|
||||
typeof condition.before === "string" &&
|
||||
!condition.before.includes(".") &&
|
||||
condition.after > condition.before
|
||||
const afterSeconds = literalTimeToSeconds(condition.after);
|
||||
const beforeSeconds = literalTimeToSeconds(condition.before);
|
||||
if (beforeSeconds === 0) {
|
||||
// A window ending at midnight runs to the end of the day, so the
|
||||
// "before" boundary adds nothing to the summary.
|
||||
hasTime = "after";
|
||||
} else if (
|
||||
afterSeconds !== undefined &&
|
||||
beforeSeconds !== undefined &&
|
||||
afterSeconds > beforeSeconds
|
||||
) {
|
||||
hasTime = "after_before_or";
|
||||
} else {
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
import type { DateRange } from "../common/datetime/calc_date_range";
|
||||
import { calcDateRange } from "../common/datetime/calc_date_range";
|
||||
import { formatTime24h } from "../common/datetime/format_time";
|
||||
import { DEFAULT_ENTITY_NAME } from "../common/entity/compute_entity_name_display";
|
||||
import { formatNumber } from "../common/number/format_number";
|
||||
import { normalizeValueBySIPrefix } from "../common/number/normalize-by-si-prefix";
|
||||
import { groupBy } from "../common/util/group-by";
|
||||
@@ -37,7 +36,6 @@ import type {
|
||||
import {
|
||||
fetchStatistics,
|
||||
getDisplayUnit,
|
||||
getStatisticLabel,
|
||||
getStatisticMetadata,
|
||||
VOLUME_UNITS,
|
||||
} from "./recorder";
|
||||
@@ -313,59 +311,6 @@ export interface EnergySourceByType {
|
||||
export const energySourcesByType = (prefs: EnergyPreferences) =>
|
||||
groupBy(prefs.energy_sources, (item) => item.type) as EnergySourceByType;
|
||||
|
||||
/**
|
||||
* Display name of a configured statistic. A name set by the user always wins;
|
||||
* otherwise the entity is named the same way the rest of the UI names
|
||||
* entities, so devices sharing an entity name stay distinguishable.
|
||||
* Statistics without an entity (external or removed) keep the statistic label.
|
||||
*/
|
||||
export const computeEnergyLabel = (
|
||||
hass: HomeAssistant,
|
||||
statisticId: string,
|
||||
statisticsMetaData?: StatisticsMetaData,
|
||||
customName?: string
|
||||
): string => {
|
||||
if (customName) {
|
||||
return customName;
|
||||
}
|
||||
|
||||
const stateObj = hass.states[statisticId];
|
||||
|
||||
if (stateObj) {
|
||||
return hass.formatEntityName(stateObj, DEFAULT_ENTITY_NAME);
|
||||
}
|
||||
|
||||
return getStatisticLabel(hass, statisticId, statisticsMetaData);
|
||||
};
|
||||
|
||||
/**
|
||||
* Device labels keyed by statistic id. Cards that show live power or flow
|
||||
* key their nodes by `stat_rate` instead of `stat_consumption`; devices
|
||||
* without the requested statistic are left out.
|
||||
*/
|
||||
export const computeEnergyDeviceLabels = (
|
||||
hass: HomeAssistant,
|
||||
devices: DeviceConsumptionEnergyPreference[],
|
||||
statsMetadata?: Record<string, StatisticsMetaData>,
|
||||
statisticKey: "stat_consumption" | "stat_rate" = "stat_consumption"
|
||||
): Record<string, string> => {
|
||||
const labels: Record<string, string> = {};
|
||||
|
||||
for (const device of devices) {
|
||||
const statisticId = device[statisticKey];
|
||||
if (statisticId) {
|
||||
labels[statisticId] = computeEnergyLabel(
|
||||
hass,
|
||||
statisticId,
|
||||
statsMetadata?.[statisticId],
|
||||
device.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return labels;
|
||||
};
|
||||
|
||||
export interface EnergyData {
|
||||
start: Date;
|
||||
end?: Date;
|
||||
|
||||
@@ -367,10 +367,10 @@ class MoreInfoLight extends LitElement {
|
||||
width: auto;
|
||||
}
|
||||
.wheel {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: none;
|
||||
border-radius: var(--ha-border-radius-xl);
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
}
|
||||
.wheel.color {
|
||||
background-image: url("/static/images/color_wheel.png");
|
||||
|
||||
@@ -622,6 +622,8 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
var(--ha-color-border-neutral-quiet);
|
||||
overflow: hidden;
|
||||
height: 32px;
|
||||
box-sizing: border-box;
|
||||
font: inherit;
|
||||
}
|
||||
.target.warning {
|
||||
background: var(--ha-color-fill-warning-normal-resting);
|
||||
|
||||
@@ -622,8 +622,7 @@ class HaBlueprintOverview extends LitElement {
|
||||
}
|
||||
),
|
||||
confirmText: this.hass!.localize(
|
||||
"ui.panel.config.blueprint.overview.blueprint_in_use_view",
|
||||
{ type }
|
||||
`ui.panel.config.blueprint.overview.blueprint_in_use_view_${blueprint.domain}`
|
||||
),
|
||||
});
|
||||
if (result) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
mdiRobot,
|
||||
mdiScriptText,
|
||||
mdiShapeOutline,
|
||||
mdiTextureBox,
|
||||
mdiTools,
|
||||
} from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
@@ -44,6 +45,7 @@ import "../../../components/ha-button";
|
||||
import "../../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
import "../../../components/ha-icon";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-icon-next";
|
||||
import "../../../components/item/ha-list-item-base";
|
||||
@@ -1036,12 +1038,27 @@ export class HaConfigDevicePage extends LitElement {
|
||||
${
|
||||
area
|
||||
? html`<div class="header-name">
|
||||
<a href="/config/areas/area/${area.area_id}"
|
||||
>${this.hass.localize(
|
||||
<ha-button
|
||||
href="/config/areas/area/${area.area_id}"
|
||||
size="s"
|
||||
appearance="plain"
|
||||
>
|
||||
${
|
||||
area.icon
|
||||
? html`<ha-icon
|
||||
slot="start"
|
||||
.icon=${area.icon}
|
||||
></ha-icon>`
|
||||
: html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiTextureBox}
|
||||
></ha-svg-icon>`
|
||||
}
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.integrations.config_entry.area",
|
||||
{ area: area.name || "Unnamed Area" }
|
||||
)}</a
|
||||
>
|
||||
)}
|
||||
</ha-button>
|
||||
</div>`
|
||||
: ""
|
||||
}
|
||||
@@ -1744,12 +1761,14 @@ export class HaConfigDevicePage extends LitElement {
|
||||
.header-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: var(--ha-space-2);
|
||||
padding-inline-start: var(--ha-space-2);
|
||||
padding-inline-end: initial;
|
||||
direction: var(--direction);
|
||||
}
|
||||
|
||||
.header-name ha-icon,
|
||||
.header-name ha-svg-icon {
|
||||
--mdc-icon-size: 18px;
|
||||
}
|
||||
|
||||
.column,
|
||||
.fullwidth {
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -11,8 +11,6 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { computeAreaName } from "../../../../common/entity/compute_area_name";
|
||||
import { getEntityAreaId } from "../../../../common/entity/context/get_entity_context";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-icon-button";
|
||||
@@ -25,11 +23,9 @@ import type {
|
||||
EnergyPreferencesValidation,
|
||||
EnergyValidationIssue,
|
||||
} from "../../../../data/energy";
|
||||
import {
|
||||
computeEnergyLabel,
|
||||
saveEnergyPreferences,
|
||||
} from "../../../../data/energy";
|
||||
import { saveEnergyPreferences } from "../../../../data/energy";
|
||||
import type { StatisticsMetaData } from "../../../../data/recorder";
|
||||
import { getStatisticLabel } from "../../../../data/recorder";
|
||||
import {
|
||||
showAlertDialog,
|
||||
showConfirmationDialog,
|
||||
@@ -108,7 +104,18 @@ export class EnergyDeviceSettingsWater extends LitElement {
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
></ha-svg-icon>
|
||||
</div>
|
||||
${this._renderName(device)}
|
||||
<span class="content"
|
||||
>${
|
||||
device.name ||
|
||||
getStatisticLabel(
|
||||
this.hass,
|
||||
device.stat_consumption,
|
||||
this.statsMetadata?.[
|
||||
device.stat_consumption
|
||||
]
|
||||
)
|
||||
}</span
|
||||
>
|
||||
${this._renderIssueIndicator(
|
||||
this.validationResult?.device_consumption_water[
|
||||
index
|
||||
@@ -148,32 +155,6 @@ export class EnergyDeviceSettingsWater extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderName(device: DeviceConsumptionEnergyPreference) {
|
||||
const name = computeEnergyLabel(
|
||||
this.hass,
|
||||
device.stat_consumption,
|
||||
this.statsMetadata?.[device.stat_consumption],
|
||||
device.name
|
||||
);
|
||||
const areaId = getEntityAreaId(
|
||||
device.stat_consumption,
|
||||
this.hass.entities,
|
||||
this.hass.devices
|
||||
);
|
||||
const area = areaId ? this.hass.areas[areaId] : undefined;
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
return html`
|
||||
<div class="content">
|
||||
<span class="label">${name}</span>
|
||||
${
|
||||
areaName
|
||||
? html`<span class="label secondary">${areaName}</span>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderIssueIndicator(
|
||||
issues: EnergyValidationIssue[] | undefined,
|
||||
index: number
|
||||
@@ -299,22 +280,6 @@ export class EnergyDeviceSettingsWater extends LitElement {
|
||||
haStyle,
|
||||
energyCardStyles,
|
||||
css`
|
||||
.row {
|
||||
height: 58px;
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.label.secondary {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.handle {
|
||||
cursor: move; /* fallback if grab cursor is unsupported */
|
||||
cursor: grab;
|
||||
|
||||
@@ -11,8 +11,6 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { computeAreaName } from "../../../../common/entity/compute_area_name";
|
||||
import { getEntityAreaId } from "../../../../common/entity/context/get_entity_context";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-icon-button";
|
||||
@@ -25,11 +23,9 @@ import type {
|
||||
EnergyPreferencesValidation,
|
||||
EnergyValidationIssue,
|
||||
} from "../../../../data/energy";
|
||||
import {
|
||||
computeEnergyLabel,
|
||||
saveEnergyPreferences,
|
||||
} from "../../../../data/energy";
|
||||
import { saveEnergyPreferences } from "../../../../data/energy";
|
||||
import type { StatisticsMetaData } from "../../../../data/recorder";
|
||||
import { getStatisticLabel } from "../../../../data/recorder";
|
||||
import {
|
||||
showAlertDialog,
|
||||
showConfirmationDialog,
|
||||
@@ -108,7 +104,18 @@ export class EnergyDeviceSettings extends LitElement {
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
></ha-svg-icon>
|
||||
</div>
|
||||
${this._renderName(device)}
|
||||
<span class="content"
|
||||
>${
|
||||
device.name ||
|
||||
getStatisticLabel(
|
||||
this.hass,
|
||||
device.stat_consumption,
|
||||
this.statsMetadata?.[
|
||||
device.stat_consumption
|
||||
]
|
||||
)
|
||||
}</span
|
||||
>
|
||||
${this._renderIssueIndicator(
|
||||
this.validationResult?.device_consumption[
|
||||
index
|
||||
@@ -148,32 +155,6 @@ export class EnergyDeviceSettings extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderName(device: DeviceConsumptionEnergyPreference) {
|
||||
const name = computeEnergyLabel(
|
||||
this.hass,
|
||||
device.stat_consumption,
|
||||
this.statsMetadata?.[device.stat_consumption],
|
||||
device.name
|
||||
);
|
||||
const areaId = getEntityAreaId(
|
||||
device.stat_consumption,
|
||||
this.hass.entities,
|
||||
this.hass.devices
|
||||
);
|
||||
const area = areaId ? this.hass.areas[areaId] : undefined;
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
return html`
|
||||
<div class="content">
|
||||
<span class="label">${name}</span>
|
||||
${
|
||||
areaName
|
||||
? html`<span class="label secondary">${areaName}</span>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderIssueIndicator(
|
||||
issues: EnergyValidationIssue[] | undefined,
|
||||
index: number
|
||||
@@ -295,22 +276,6 @@ export class EnergyDeviceSettings extends LitElement {
|
||||
haStyle,
|
||||
energyCardStyles,
|
||||
css`
|
||||
.row {
|
||||
height: 58px;
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.label.secondary {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.handle {
|
||||
cursor: move; /* fallback if grab cursor is unsupported */
|
||||
cursor: grab;
|
||||
|
||||
@@ -11,11 +11,9 @@ 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 { energyStatisticHelpUrl } from "../../../../data/energy";
|
||||
import {
|
||||
computeEnergyLabel,
|
||||
energyStatisticHelpUrl,
|
||||
} from "../../../../data/energy";
|
||||
import {
|
||||
getStatisticLabel,
|
||||
getStatisticMetadata,
|
||||
isExternalStatistic,
|
||||
} from "../../../../data/recorder";
|
||||
@@ -176,7 +174,7 @@ export class DialogEnergyDeviceSettingsWater
|
||||
.value=${this._device?.name || ""}
|
||||
.placeholder=${
|
||||
this._device
|
||||
? computeEnergyLabel(
|
||||
? getStatisticLabel(
|
||||
this.hass,
|
||||
this._device.stat_consumption,
|
||||
this._params?.statsMetadata?.[this._device.stat_consumption]
|
||||
|
||||
@@ -11,11 +11,9 @@ 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 { energyStatisticHelpUrl } from "../../../../data/energy";
|
||||
import {
|
||||
computeEnergyLabel,
|
||||
energyStatisticHelpUrl,
|
||||
} from "../../../../data/energy";
|
||||
import {
|
||||
getStatisticLabel,
|
||||
getStatisticMetadata,
|
||||
isExternalStatistic,
|
||||
} from "../../../../data/recorder";
|
||||
@@ -172,7 +170,7 @@ export class DialogEnergyDeviceSettings
|
||||
.value=${this._device?.name || ""}
|
||||
.placeholder=${
|
||||
this._device
|
||||
? computeEnergyLabel(
|
||||
? getStatisticLabel(
|
||||
this.hass,
|
||||
this._device.stat_consumption,
|
||||
this._params?.statsMetadata?.[this._device.stat_consumption]
|
||||
|
||||
@@ -7,6 +7,7 @@ import memoizeOne from "memoize-one";
|
||||
import { computeEntityNameList } from "../../../../common/entity/compute_entity_name_display";
|
||||
import { computeStateName } from "../../../../common/entity/compute_state_name";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { computeRTL } from "../../../../common/util/compute_rtl";
|
||||
import "../../../../components/entity/state-badge";
|
||||
import "../../../../components/ha-combo-box-item";
|
||||
import "../../../../components/ha-generic-picker";
|
||||
@@ -14,7 +15,6 @@ import type { PickerComboBoxItem } from "../../../../components/ha-picker-combo-
|
||||
import type { PickerValueRenderer } from "../../../../components/ha-picker-field";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type { DeviceConsumptionEnergyPreference } from "../../../../data/energy";
|
||||
import { computeEnergyLabel } from "../../../../data/energy";
|
||||
import { domainToName } from "../../../../data/integration";
|
||||
import {
|
||||
getStatisticLabel,
|
||||
@@ -73,18 +73,20 @@ export class HaEnergyUpstreamDevicePicker extends LitElement {
|
||||
this.hass.floors
|
||||
);
|
||||
|
||||
const isRTL = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
return {
|
||||
id: statisticId,
|
||||
// Match the label shown in the device list and the graphs.
|
||||
primary: computeEnergyLabel(
|
||||
this.hass,
|
||||
statisticId,
|
||||
this.statsMetadata?.[statisticId],
|
||||
name
|
||||
),
|
||||
secondary: areaName,
|
||||
primary: name || entityName || deviceName || statisticId,
|
||||
secondary,
|
||||
stateObj,
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
subscribeLabFeatures,
|
||||
} from "../../../data/labs";
|
||||
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||
import "../../../layouts/hass-loading-screen";
|
||||
import "../../../layouts/hass-subpage";
|
||||
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
@@ -38,7 +39,7 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@state() private _preview_features: LabPreviewFeature[] = [];
|
||||
@state() private _preview_features?: LabPreviewFeature[];
|
||||
|
||||
@state() private _highlightedPreviewFeature?: string;
|
||||
|
||||
@@ -98,6 +99,10 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (this._preview_features === undefined) {
|
||||
return html`<hass-loading-screen></hass-loading-screen>`;
|
||||
}
|
||||
|
||||
const sortedFeatures = this._sortedPreviewFeatures(
|
||||
this.hass.localize,
|
||||
this._preview_features
|
||||
|
||||
@@ -51,6 +51,8 @@ export class HaConfigLovelaceResources extends LitElement {
|
||||
|
||||
@state() private _resources: LovelaceResource[] = [];
|
||||
|
||||
@state() private _loaded = false;
|
||||
|
||||
@state() private _lovelaceInfo?: LovelaceInfo;
|
||||
|
||||
@state()
|
||||
@@ -134,7 +136,7 @@ export class HaConfigLovelaceResources extends LitElement {
|
||||
);
|
||||
|
||||
protected render(): TemplateResult {
|
||||
if (!this.hass || this._resources === undefined) {
|
||||
if (!this.hass || !this._loaded) {
|
||||
return html` <hass-loading-screen></hass-loading-screen> `;
|
||||
}
|
||||
|
||||
@@ -229,6 +231,7 @@ export class HaConfigLovelaceResources extends LitElement {
|
||||
]);
|
||||
this._resources = resources;
|
||||
this._lovelaceInfo = lovelaceInfo;
|
||||
this._loaded = true;
|
||||
}
|
||||
|
||||
private _editResource(ev: CustomEvent) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
subscribeRepairsIssueRegistry,
|
||||
} from "../../../data/repairs";
|
||||
import "../../../layouts/hass-subpage";
|
||||
import "../../../layouts/hass-loading-screen";
|
||||
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import "./ha-config-repairs";
|
||||
@@ -32,6 +33,8 @@ class HaConfigRepairsDashboard extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _repairsIssues: RepairsIssue[] = [];
|
||||
|
||||
@state() private _loaded = false;
|
||||
|
||||
@state() private _showIgnored = false;
|
||||
|
||||
private _getFilteredIssues = memoizeOne(
|
||||
@@ -58,6 +61,7 @@ class HaConfigRepairsDashboard extends SubscribeMixin(LitElement) {
|
||||
this._repairsIssues = repairs.issues.sort(
|
||||
(a, b) => severitySort[a.severity] - severitySort[b.severity]
|
||||
);
|
||||
this._loaded = true;
|
||||
const integrations = new Set<string>();
|
||||
for (const issue of this._repairsIssues) {
|
||||
integrations.add(issue.domain);
|
||||
@@ -68,6 +72,10 @@ class HaConfigRepairsDashboard extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
if (!this._loaded) {
|
||||
return html`<hass-loading-screen></hass-loading-screen>`;
|
||||
}
|
||||
|
||||
const issues = this._getFilteredIssues(
|
||||
this._showIgnored,
|
||||
this._repairsIssues
|
||||
|
||||
+6
-1
@@ -16,6 +16,7 @@ import {
|
||||
listAssistPipelines,
|
||||
} from "../../../../data/assist_pipeline";
|
||||
import "../../../../layouts/hass-subpage";
|
||||
import "../../../../layouts/hass-loading-screen";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
|
||||
interface AssistDeviceExtra extends AssistDevice {
|
||||
@@ -124,6 +125,10 @@ class AssistDevicesPage extends LitElement {
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this._devices) {
|
||||
return html`<hass-loading-screen></hass-loading-screen>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
@@ -144,7 +149,7 @@ class AssistDevicesPage extends LitElement {
|
||||
this.hass.states,
|
||||
this._pipelines,
|
||||
this._preferred,
|
||||
this._devices || []
|
||||
this._devices
|
||||
)}
|
||||
auto-height
|
||||
@row-click=${this._handleRowClicked}
|
||||
|
||||
@@ -8,13 +8,13 @@ import type {
|
||||
} from "../../../../data/energy";
|
||||
import {
|
||||
computeConsumptionData,
|
||||
computeEnergyDeviceLabels,
|
||||
getSuggestedPeriod,
|
||||
getSummedData,
|
||||
} from "../../../../data/energy";
|
||||
import type { Statistics } from "../../../../data/recorder";
|
||||
import type { Statistics, StatisticsMetaData } from "../../../../data/recorder";
|
||||
import {
|
||||
calculateStatisticSumGrowth,
|
||||
getStatisticLabel,
|
||||
isExternalStatistic,
|
||||
} from "../../../../data/recorder";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
@@ -69,13 +69,13 @@ interface ProcessContext {
|
||||
end: Date;
|
||||
compareStart?: Date;
|
||||
untrackedOrder: number;
|
||||
deviceLabels: Record<string, string>;
|
||||
}
|
||||
|
||||
function processDataSet(
|
||||
ctx: ProcessContext,
|
||||
computedStyle: CSSStyleDeclaration,
|
||||
statistics: Statistics,
|
||||
statisticsMetaData: Record<string, StatisticsMetaData>,
|
||||
devices: DeviceConsumptionEnergyPreference[],
|
||||
sorted_devices: string[],
|
||||
childMap: Record<string, string[]>,
|
||||
@@ -167,7 +167,12 @@ function processDataSet(
|
||||
}
|
||||
|
||||
const name =
|
||||
ctx.deviceLabels[source.stat_consumption] +
|
||||
(source.name ||
|
||||
getStatisticLabel(
|
||||
ctx.hass,
|
||||
source.stat_consumption,
|
||||
statisticsMetaData[source.stat_consumption]
|
||||
)) +
|
||||
(source.stat_consumption in childMap
|
||||
? ` (${ctx.hass.localize("ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked")})`
|
||||
: "");
|
||||
@@ -346,8 +351,6 @@ export function generateEnergyDevicesDetailGraphData(
|
||||
const data = energyData.stats;
|
||||
const compareData = energyData.statsCompare;
|
||||
|
||||
const devices = energyData.prefs.device_consumption;
|
||||
|
||||
const ctx: ProcessContext = {
|
||||
hass,
|
||||
config,
|
||||
@@ -355,13 +358,10 @@ export function generateEnergyDevicesDetailGraphData(
|
||||
end,
|
||||
compareStart,
|
||||
untrackedOrder,
|
||||
deviceLabels: computeEnergyDeviceLabels(
|
||||
hass,
|
||||
devices,
|
||||
energyData.statsMetadata
|
||||
),
|
||||
};
|
||||
|
||||
const devices = energyData.prefs.device_consumption;
|
||||
|
||||
const childMap: Record<string, string[]> = {};
|
||||
devices.forEach((d) => {
|
||||
if (d.included_in_stat) {
|
||||
@@ -425,6 +425,7 @@ export function generateEnergyDevicesDetailGraphData(
|
||||
ctx,
|
||||
computedStyles,
|
||||
compareData,
|
||||
energyData.statsMetadata,
|
||||
energyData.prefs.device_consumption,
|
||||
sorted_devices,
|
||||
childMap,
|
||||
@@ -467,6 +468,7 @@ export function generateEnergyDevicesDetailGraphData(
|
||||
ctx,
|
||||
computedStyles,
|
||||
data,
|
||||
energyData.statsMetadata,
|
||||
energyData.prefs.device_consumption,
|
||||
sorted_devices,
|
||||
childMap,
|
||||
|
||||
@@ -16,7 +16,6 @@ import "../../../../components/chart/ha-chart-tooltip-marker";
|
||||
import type { EnergyData } from "../../../../data/energy";
|
||||
import {
|
||||
computeConsumptionData,
|
||||
computeEnergyDeviceLabels,
|
||||
getEnergyDataCollection,
|
||||
getSummedData,
|
||||
validateEnergyCollectionKey,
|
||||
@@ -92,8 +91,6 @@ export class HuiEnergyDevicesGraphCard
|
||||
|
||||
private _compoundStats: string[] = [];
|
||||
|
||||
private _deviceLabels: Record<string, string> = {};
|
||||
|
||||
protected hassSubscribeRequiredHostProps = ["_config"];
|
||||
|
||||
public hassSubscribe(): UnsubscribeFunc[] {
|
||||
@@ -298,8 +295,9 @@ export class HuiEnergyDevicesGraphCard
|
||||
? ` (${this.hass.localize("ui.panel.lovelace.cards.energy.energy_devices_graph.untracked")})`
|
||||
: "";
|
||||
return (
|
||||
// The untracked slice is not a statistic, so it has no label.
|
||||
(this._deviceLabels[statisticId] ||
|
||||
(this._data?.prefs.device_consumption.find(
|
||||
(d) => d.stat_consumption === statisticId
|
||||
)?.name ||
|
||||
getStatisticLabel(
|
||||
this.hass,
|
||||
statisticId,
|
||||
@@ -379,12 +377,6 @@ export class HuiEnergyDevicesGraphCard
|
||||
.map((d) => d.included_in_stat)
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
this._deviceLabels = computeEnergyDeviceLabels(
|
||||
this.hass,
|
||||
energyData.prefs.device_consumption,
|
||||
energyData.statsMetadata
|
||||
);
|
||||
|
||||
const devices = energyData.prefs.device_consumption;
|
||||
const devicesTotals: Record<string, number> = {};
|
||||
devices.forEach((device) => {
|
||||
|
||||
@@ -8,7 +8,6 @@ import "../../../../components/ha-svg-icon";
|
||||
import type { EnergyData } from "../../../../data/energy";
|
||||
import {
|
||||
computeConsumptionData,
|
||||
computeEnergyDeviceLabels,
|
||||
energySourcesByType,
|
||||
getEnergyDataCollection,
|
||||
getSummedData,
|
||||
@@ -273,14 +272,8 @@ class HuiEnergySankeyCard
|
||||
? calculateStatisticSumGrowth(this._data!.stats[statConsumption]) || 0
|
||||
: 0;
|
||||
|
||||
const deviceLabels = computeEnergyDeviceLabels(
|
||||
this.hass,
|
||||
prefs.device_consumption,
|
||||
this._data.statsMetadata
|
||||
);
|
||||
|
||||
const deviceLabel = (statConsumption: string) =>
|
||||
deviceLabels[statConsumption] ||
|
||||
const deviceLabel = (statConsumption: string, name?: string) =>
|
||||
name ||
|
||||
getStatisticLabel(
|
||||
this.hass,
|
||||
statConsumption,
|
||||
|
||||
@@ -7,7 +7,6 @@ import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type { EnergyData, EnergyPreferences } from "../../../../data/energy";
|
||||
import {
|
||||
computeEnergyDeviceLabels,
|
||||
formatPowerShort,
|
||||
getEnergyDataCollection,
|
||||
getPowerFromState,
|
||||
@@ -279,13 +278,6 @@ class HuiPowerSankeyCard
|
||||
}
|
||||
}
|
||||
|
||||
const deviceLabels = computeEnergyDeviceLabels(
|
||||
this.hass,
|
||||
prefs.device_consumption,
|
||||
this._data.statsMetadata,
|
||||
"stat_rate"
|
||||
);
|
||||
|
||||
const {
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
@@ -302,7 +294,7 @@ class HuiPowerSankeyCard
|
||||
initialUntracked: homeNode.value,
|
||||
getId: (device) => device.stat_rate,
|
||||
getValue: (id) => this._getCurrentPower(id),
|
||||
getLabel: (id) => deviceLabels[id] || this._getEntityLabel(id),
|
||||
getLabel: (id, name) => name || this._getEntityLabel(id),
|
||||
getEntityId: (id) => id,
|
||||
});
|
||||
links.push(...deviceLinks);
|
||||
|
||||
@@ -6,7 +6,6 @@ import { classMap } from "lit/directives/class-map";
|
||||
import "../../../../components/ha-card";
|
||||
import type { EnergyData } from "../../../../data/energy";
|
||||
import {
|
||||
computeEnergyDeviceLabels,
|
||||
formatFlowRateShort,
|
||||
getEnergyDataCollection,
|
||||
getFlowRateFromState,
|
||||
@@ -242,13 +241,6 @@ class HuiWaterFlowSankeyCard
|
||||
}
|
||||
}
|
||||
|
||||
const deviceLabels = computeEnergyDeviceLabels(
|
||||
this.hass,
|
||||
prefs.device_consumption_water,
|
||||
this._data.statsMetadata,
|
||||
"stat_rate"
|
||||
);
|
||||
|
||||
const {
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
@@ -265,7 +257,7 @@ class HuiWaterFlowSankeyCard
|
||||
initialUntracked: effectiveTotalInflow,
|
||||
getId: (device) => device.stat_rate,
|
||||
getValue: (id) => this._getCurrentFlowRate(id),
|
||||
getLabel: (id) => deviceLabels[id] || this._getEntityLabel(id),
|
||||
getLabel: (id, name) => name || this._getEntityLabel(id),
|
||||
getEntityId: (id) => id,
|
||||
});
|
||||
links.push(...deviceLinks);
|
||||
|
||||
@@ -7,7 +7,6 @@ import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type { EnergyData } from "../../../../data/energy";
|
||||
import {
|
||||
computeEnergyDeviceLabels,
|
||||
getEnergyDataCollection,
|
||||
validateEnergyCollectionKey,
|
||||
} from "../../../../data/energy";
|
||||
@@ -216,14 +215,8 @@ class HuiWaterSankeyCard
|
||||
? calculateStatisticSumGrowth(this._data!.stats[statConsumption]) || 0
|
||||
: 0;
|
||||
|
||||
const deviceLabels = computeEnergyDeviceLabels(
|
||||
this.hass,
|
||||
prefs.device_consumption_water,
|
||||
this._data!.statsMetadata
|
||||
);
|
||||
|
||||
const deviceLabel = (statConsumption: string) =>
|
||||
deviceLabels[statConsumption] ||
|
||||
const deviceLabel = (statConsumption: string, name?: string) =>
|
||||
name ||
|
||||
getStatisticLabel(
|
||||
this.hass,
|
||||
statConsumption,
|
||||
|
||||
@@ -92,7 +92,7 @@ class HuiInputNumberEntityRow extends LitElement implements LovelaceRow {
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="flex state">
|
||||
<div class="flex box">
|
||||
<ha-input
|
||||
.disabled=${stateObj.state === UNAVAILABLE}
|
||||
pattern="[0-9]+([\\.][0-9]+)?"
|
||||
@@ -128,6 +128,10 @@ class HuiInputNumberEntityRow extends LitElement implements LovelaceRow {
|
||||
min-width: 45px;
|
||||
text-align: end;
|
||||
}
|
||||
.box {
|
||||
flex-grow: 0;
|
||||
min-width: 45px;
|
||||
}
|
||||
ha-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ class HuiNumberEntityRow extends LitElement implements LovelaceRow {
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="flex state">
|
||||
<div class="flex box">
|
||||
<ha-input
|
||||
auto-validate
|
||||
.disabled=${stateObj.state === UNAVAILABLE}
|
||||
@@ -136,6 +136,10 @@ class HuiNumberEntityRow extends LitElement implements LovelaceRow {
|
||||
min-width: 45px;
|
||||
text-align: end;
|
||||
}
|
||||
.box {
|
||||
flex-grow: 0;
|
||||
min-width: 45px;
|
||||
}
|
||||
ha-input::part(wa-input) {
|
||||
text-align: end;
|
||||
direction: ltr !important;
|
||||
|
||||
@@ -209,7 +209,6 @@ class HaRefreshTokens extends LitElement {
|
||||
<ha-button
|
||||
variant="danger"
|
||||
appearance="filled"
|
||||
size="s"
|
||||
@click=${this._deleteAllTokens}
|
||||
>
|
||||
${this.hass.localize(
|
||||
@@ -352,6 +351,10 @@ class HaRefreshTokens extends LitElement {
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
margin-right: 6px;
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -6131,7 +6131,8 @@
|
||||
"error": "{path} could not be loaded",
|
||||
"blueprint_in_use_title": "This blueprint is in use and cannot be deleted",
|
||||
"blueprint_in_use_text": "Please remove all below {type} that use this blueprint before deleting it. {list}",
|
||||
"blueprint_in_use_view": "view {type}",
|
||||
"blueprint_in_use_view_automation": "View automations",
|
||||
"blueprint_in_use_view_script": "View scripts",
|
||||
"confirm_delete_title": "Delete blueprint?",
|
||||
"confirm_delete_text": "{name} will be permanently deleted.",
|
||||
"add_blueprint": "Import blueprint",
|
||||
|
||||
@@ -13,19 +13,13 @@ import {
|
||||
} from "../../src/data/translation";
|
||||
import {
|
||||
computeConsumptionSingle,
|
||||
computeEnergyLabel,
|
||||
computeEnergyDeviceLabels,
|
||||
formatConsumptionShort,
|
||||
calculateSolarConsumedGauge,
|
||||
formatPowerShort,
|
||||
getNextEnergyPeriodStart,
|
||||
getEnergyDefaultPeriodStorageKey,
|
||||
} from "../../src/data/energy";
|
||||
import type { DeviceRegistryEntry } from "../../src/data/device/device_registry";
|
||||
import type { EntityRegistryDisplayEntry } from "../../src/data/entity/entity_registry";
|
||||
import type { StatisticsMetaData } from "../../src/data/recorder";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
import { createMockEntityState, createMockHass } from "../fixtures/hass";
|
||||
|
||||
const checkConsumptionResult = (
|
||||
input: {
|
||||
@@ -950,151 +944,3 @@ describe("getEnergyDefaultPeriodStorageKey", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeEnergyLabel", () => {
|
||||
const ENTITY_ID = "sensor.washer_energy";
|
||||
|
||||
const createEntry = (
|
||||
entry: Partial<EntityRegistryDisplayEntry>
|
||||
): EntityRegistryDisplayEntry =>
|
||||
({
|
||||
entity_id: ENTITY_ID,
|
||||
labels: [],
|
||||
...entry,
|
||||
}) as EntityRegistryDisplayEntry;
|
||||
|
||||
const createDevice = (
|
||||
device: Partial<DeviceRegistryEntry>
|
||||
): DeviceRegistryEntry =>
|
||||
({ id: "device1", name_by_user: null, ...device }) as DeviceRegistryEntry;
|
||||
|
||||
const createHass = (
|
||||
friendlyName: string,
|
||||
entry?: Partial<EntityRegistryDisplayEntry>,
|
||||
device?: Partial<DeviceRegistryEntry>
|
||||
) =>
|
||||
createMockHass(
|
||||
{
|
||||
[ENTITY_ID]: createMockEntityState(ENTITY_ID, "1", {
|
||||
friendly_name: friendlyName,
|
||||
}),
|
||||
},
|
||||
{
|
||||
entities: entry ? { [ENTITY_ID]: createEntry(entry) } : {},
|
||||
devices: device ? { device1: createDevice(device) } : {},
|
||||
}
|
||||
);
|
||||
|
||||
it("composes the device and entity name", () => {
|
||||
const hass = createHass(
|
||||
"Washer Energy",
|
||||
{ name: "Energy", device_id: "device1" },
|
||||
{ name: "Washer" }
|
||||
);
|
||||
|
||||
assert.equal(computeEnergyLabel(hass, ENTITY_ID), "Washer Energy");
|
||||
});
|
||||
|
||||
it("uses the device name alone when the entity has no name of its own", () => {
|
||||
const hass = createHass(
|
||||
"Washer",
|
||||
{ name: "Washer", device_id: "device1" },
|
||||
{ name: "Washer" }
|
||||
);
|
||||
|
||||
assert.equal(computeEnergyLabel(hass, ENTITY_ID), "Washer");
|
||||
});
|
||||
|
||||
it("distinguishes entities sharing a name by their device", () => {
|
||||
const hass = createHass(
|
||||
"Energy",
|
||||
{ name: "Energy", device_id: "device1" },
|
||||
{ name: "Dishwasher" }
|
||||
);
|
||||
|
||||
assert.equal(computeEnergyLabel(hass, ENTITY_ID), "Dishwasher Energy");
|
||||
});
|
||||
|
||||
it("keeps a name set by the user", () => {
|
||||
const hass = createHass(
|
||||
"Washer Energy",
|
||||
{ name: "Energy", device_id: "device1" },
|
||||
{ name: "Washer" }
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
computeEnergyLabel(hass, ENTITY_ID, undefined, "Laundry"),
|
||||
"Laundry"
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores an empty name", () => {
|
||||
const hass = createHass(
|
||||
"Washer Energy",
|
||||
{ name: "Energy", device_id: "device1" },
|
||||
{ name: "Washer" }
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
computeEnergyLabel(hass, ENTITY_ID, undefined, ""),
|
||||
"Washer Energy"
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the friendly name for an entity outside the registry", () => {
|
||||
const hass = createHass("Washer Energy");
|
||||
|
||||
assert.equal(computeEnergyLabel(hass, ENTITY_ID), "Washer Energy");
|
||||
});
|
||||
|
||||
it("uses the statistic metadata name when there is no entity", () => {
|
||||
const hass = createMockHass();
|
||||
|
||||
assert.equal(
|
||||
computeEnergyLabel(hass, "external:solar", {
|
||||
statistic_id: "external:solar",
|
||||
name: "Solar production",
|
||||
} as StatisticsMetaData),
|
||||
"Solar production"
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the statistic id when there is nothing to name it with", () => {
|
||||
const hass = createMockHass();
|
||||
|
||||
assert.equal(computeEnergyLabel(hass, "external:solar"), "external:solar");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeEnergyDeviceLabels", () => {
|
||||
const DEVICES = [
|
||||
{
|
||||
stat_consumption: "sensor.washer_energy",
|
||||
stat_rate: "sensor.washer_power",
|
||||
},
|
||||
{ stat_consumption: "sensor.heater_energy", name: "Heater" },
|
||||
];
|
||||
|
||||
const hass = createMockHass({
|
||||
"sensor.washer_energy": createMockEntityState("sensor.washer_energy", "1", {
|
||||
friendly_name: "Washer Energy",
|
||||
}),
|
||||
"sensor.washer_power": createMockEntityState("sensor.washer_power", "5", {
|
||||
friendly_name: "Washer Power",
|
||||
}),
|
||||
});
|
||||
|
||||
it("keys labels by the consumption statistic", () => {
|
||||
assert.deepEqual(computeEnergyDeviceLabels(hass, DEVICES), {
|
||||
"sensor.washer_energy": "Washer Energy",
|
||||
"sensor.heater_energy": "Heater",
|
||||
});
|
||||
});
|
||||
|
||||
it("keys labels by the rate statistic, skipping devices without one", () => {
|
||||
assert.deepEqual(
|
||||
computeEnergyDeviceLabels(hass, DEVICES, undefined, "stat_rate"),
|
||||
{ "sensor.washer_power": "Washer Power" }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { IntlMessageFormat } from "intl-messageformat";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describeCondition } from "../../src/data/automation_i18n";
|
||||
import {
|
||||
DateFormat,
|
||||
FirstWeekday,
|
||||
NumberFormat,
|
||||
TimeFormat,
|
||||
TimeZone,
|
||||
} from "../../src/data/translation";
|
||||
import en from "../../src/translations/en.json";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
|
||||
type TranslationNode = string | { [key: string]: TranslationNode };
|
||||
|
||||
const localize = (key: string, values?: Record<string, unknown>) => {
|
||||
const message = key
|
||||
.split(".")
|
||||
.reduce<TranslationNode | undefined>(
|
||||
(translations, part) =>
|
||||
typeof translations === "object" ? translations[part] : undefined,
|
||||
en as TranslationNode
|
||||
);
|
||||
return typeof message === "string"
|
||||
? (new IntlMessageFormat(message, "en").format(values) as string)
|
||||
: "";
|
||||
};
|
||||
|
||||
const hass = {
|
||||
localize,
|
||||
locale: {
|
||||
language: "en",
|
||||
number_format: NumberFormat.language,
|
||||
time_format: TimeFormat.twenty_four,
|
||||
date_format: DateFormat.language,
|
||||
first_weekday: FirstWeekday.language,
|
||||
time_zone: TimeZone.local,
|
||||
},
|
||||
config: { time_zone: "Etc/UTC" },
|
||||
states: {},
|
||||
} as unknown as HomeAssistant;
|
||||
|
||||
const describeTimeCondition = (after?: string, before?: string) =>
|
||||
describeCondition({ condition: "time", after, before }, hass, []);
|
||||
|
||||
describe("time condition description", () => {
|
||||
it("joins a window within one day with 'and'", () => {
|
||||
expect(describeTimeCondition("09:00:00", "17:00:00")).toBe(
|
||||
"If the time is after 09:00 and before 17:00"
|
||||
);
|
||||
});
|
||||
|
||||
it("joins a window crossing midnight with 'or'", () => {
|
||||
expect(describeTimeCondition("22:00:00", "06:00:00")).toBe(
|
||||
"If the time is after 22:00 or before 06:00"
|
||||
);
|
||||
});
|
||||
|
||||
it("omits a 'before' boundary of midnight, which ends the window at the end of the day", () => {
|
||||
expect(describeTimeCondition("10:00:00", "00:00:00")).toBe(
|
||||
"If the time is after 10:00"
|
||||
);
|
||||
});
|
||||
|
||||
it("compares times numerically, not lexicographically", () => {
|
||||
expect(describeTimeCondition("9:00:00", "10:00:00")).toBe(
|
||||
"If the time is after 09:00 and before 10:00"
|
||||
);
|
||||
});
|
||||
|
||||
it("does not compare entity references", () => {
|
||||
expect(describeTimeCondition("input_datetime.wake_up", "10:00:00")).toBe(
|
||||
"If the time is after entity input_datetime.wake_up and before 10:00"
|
||||
);
|
||||
});
|
||||
});
|
||||
Vendored
+9
-32
@@ -1,10 +1,9 @@
|
||||
/**
|
||||
* Deterministic `HomeAssistant` stub covering exactly what the chart data
|
||||
* transforms read: states, registries, locale, config, localize, and entity
|
||||
* state/name formatting. Everything is stable across runs.
|
||||
* transforms read: states, entities, locale, config, localize, and entity
|
||||
* state formatting. Everything is stable across runs.
|
||||
*/
|
||||
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
|
||||
import { computeEntityNameDisplay } from "../../src/common/entity/compute_entity_name_display";
|
||||
import type { LocalizeFunc } from "../../src/common/translations/localize";
|
||||
import {
|
||||
DateFormat,
|
||||
@@ -44,43 +43,21 @@ export const createMockEntityState = (
|
||||
context: { id: "fixture", parent_id: null, user_id: null },
|
||||
});
|
||||
|
||||
export const createMockHass = (
|
||||
states: HassEntities = {},
|
||||
registries: Partial<
|
||||
Pick<HomeAssistant, "entities" | "devices" | "areas" | "floors">
|
||||
> = {}
|
||||
): HomeAssistant => {
|
||||
const entities = registries.entities ?? {};
|
||||
const devices = registries.devices ?? {};
|
||||
const areas = registries.areas ?? {};
|
||||
const floors = registries.floors ?? {};
|
||||
|
||||
return {
|
||||
export const createMockHass = (states: HassEntities = {}): HomeAssistant =>
|
||||
({
|
||||
states,
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors,
|
||||
entities: {},
|
||||
devices: {},
|
||||
areas: {},
|
||||
floors: {},
|
||||
config: demoConfig,
|
||||
locale: mockLocale,
|
||||
language: "en",
|
||||
localize: mockLocalize,
|
||||
translationMetadata: { translations: {} },
|
||||
formatEntityState: (stateObj: HassEntity, state?: string) =>
|
||||
state ?? stateObj.state,
|
||||
formatEntityAttributeValue: (stateObj: HassEntity, attribute: string) =>
|
||||
String(stateObj.attributes[attribute]),
|
||||
formatEntityAttributeName: (_stateObj: HassEntity, attribute: string) =>
|
||||
attribute,
|
||||
formatEntityName: ((stateObj, name, options) =>
|
||||
computeEntityNameDisplay(
|
||||
stateObj,
|
||||
name,
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors,
|
||||
options
|
||||
)) satisfies HomeAssistant["formatEntityName"],
|
||||
} as unknown as HomeAssistant;
|
||||
};
|
||||
}) as unknown as HomeAssistant;
|
||||
|
||||
Reference in New Issue
Block a user