Compare commits

..
Author SHA1 Message Date
Bram KragtenandClaude Opus 4.8 3356d6247b Keep the Actions write scope away from pull request code
The cancellation needed `actions: write`, and granting it at workflow scope
handed it to every job — including the ones that check out the pull request
and pass GITHUB_TOKEN into the gulp build, so PR-controlled code (or a
compromised dependency) would have had write access to Actions.

Move the cancellation into its own job that holds `actions: write` on its own
and never checks out the repository, so the elevated token is never exposed to
PR code. It cannot simply `needs` the checks — a dependent job only starts once
they have all finished, which is too late to cancel anything — so it polls the
run's job statuses and cancels on the first failure.

Costs one extra (idle) runner slot for the duration of the run.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-13 00:47:01 +02:00
Bram KragtenandClaude Opus 4.8 e07a08e72f Run the frontend build in parallel with lint and tests
The build job waited for lint and test because a full build was expensive
enough that we did not want to spend it on a PR that fails its checks. With
the rspack persistent cache it now takes ~3 min instead of ~5, and it is the
longest job in the run, so serialising it behind the others dominates CI
wall-clock: 8s + max(lint 86s, test 123s) + build 194s.

Depend only on prepare-dependencies so all three run together, which brings a
successful run down from ~5.5 min to ~3.5 min — the build itself becomes the
floor.

To avoid finishing an expensive build for a PR that is already broken, each of
the three jobs cancels the whole run when it fails. The cancel step needs
`actions: write`; on pull requests from forks the token stays read-only, so it
is a no-op there and the jobs just run to completion.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-13 00:20:04 +02:00
4b7d3a7e4f Add rspack persistent cache (nightly writes, CI reads) (#53611)
Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-12 22:30:42 +02:00
Petar PetrovandGitHub 88be7adafa Stop number box rows from reserving unused space (#53614) 2026-08-12 14:42:09 +01:00
Paul BotteinandGitHub 91a6d737b3 Fix row target badge height and font (#53612) 2026-08-12 10:17:08 +01:00
Petar PetrovandGitHub 22c3a6fe67 Don't trim the selected value in pickers (#53613) 2026-08-12 10:16:39 +01:00
Petar PetrovandGitHub bcc799970a Fix lowercase view button in blueprint in-use dialog (#53609) 2026-08-12 09:55:34 +01:00
Petar PetrovandGitHub 31d4a37c15 Fix time condition summary when before is midnight (#53608) 2026-08-12 09:51:51 +01:00
Paul BotteinandGitHub 49ea96e091 Add icon button group animation (#53597)
* Animate the selected toggle circle in ha-icon-button-group

* Round the light color wheels to fit the selected ring

* Use lit motion
2026-08-12 10:19:07 +03:00
08b33ccbc1 Decouple translations artifact from the nightly build (#53602)
* Skip backend translations download in nightly build

The nightly only builds the app (build-app), which does not merge backend
translations — the shipped app fetches those from core at runtime. The
backend Lokalise export is a whole-project download across all languages
and the slowest part of the translations step. Skipping it, as the release
already does, cuts several minutes off every nightly.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Decouple translations artifact into a parallel job

The full translations (including the slow Lokalise backend/core export) are
only needed for the uploaded `translations` artifact, not the wheel:
build-app does not merge backend translations. Move that download and the
artifact upload into a separate `translations` job that runs in parallel
with the build, so the backend export no longer sits on the build's
critical path. Both jobs run in the same workflow run, so consumers still
find both the `wheels` and `translations` artifacts.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-11 20:48:38 +02:00
Aidan TimsonandGitHub 048e754149 Match tokens card actions position and size of button with others (#53605) 2026-08-11 18:22:19 +02:00
Aidan TimsonandGitHub 3a30ea5973 Fix loading states for async config pages (#53604) 2026-08-11 15:32:53 +02:00
Aidan TimsonandGitHub f7836fd3d5 Show loading screen while Labs features load (#53601) 2026-08-11 16:11:04 +03:00
Paul BotteinandGitHub 03d8c092ce Render the target picker entities count as a button (#53598)
* Implement the xs button size

* Render the target picker entities count as a button
2026-08-11 15:40:14 +03:00
Aidan TimsonandGitHub b3aa3c83d5 Change area navigation to icon button in device page (#53600)
* Add area navigation button to device page

* Remove area button tooltip
2026-08-11 15:00:00 +03:00
40 changed files with 636 additions and 485 deletions
+4
View File
@@ -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 }}
+71 -4
View File
@@ -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
+53 -3
View File
@@ -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:
+1
View File
@@ -7,6 +7,7 @@ dist/
/hass_frontend/
/translations/
/.compress-cache/
/.rspack-cache/
# Composite action source, not build output
!/.github/actions/build/
+85 -1
View File
@@ -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.
+13
View File
@@ -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`
+15
View File
@@ -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,
+108 -4
View File
@@ -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;
+3 -1
View File
@@ -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;
+1 -2
View File
@@ -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);
+33 -6
View File
@@ -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 {
-55
View File
@@ -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,
+6 -1
View File
@@ -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
@@ -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;
+4 -1
View File
@@ -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;
}
`,
];
}
+2 -1
View File
@@ -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",
-154
View File
@@ -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"
);
});
});
+9 -32
View File
@@ -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;