Compare commits

...

7 Commits

Author SHA1 Message Date
Bram Kragten e79cd0c5b2 Bumped version to 20260624.4 2026-07-03 12:29:13 +02:00
Petar Petrov 52379b39e0 Fix My link for adding an add-on repository not doing anything (#52945) 2026-07-03 12:28:54 +02:00
Franck Nijhof 656e1bea8e Refresh the template tool documentation panel (#52941)
The About templates panel still pointed at the upstream Jinja2 docs and a
single extensions page. Rewrite the intro and link to the current templating
documentation instead: the learning guide (introduction, working with states,
debugging) and the searchable template functions reference.
2026-07-03 12:28:53 +02:00
Franck Nijhof 4ef3ed2f02 Link the config pane help icon to the dedicated docs page (#52940)
For built-in triggers, conditions, and actions, the help icon in the editor
config pane (and Developer Tools) linked to the integration page. Point it at
the dedicated page for that specific trigger/condition/action instead, e.g.
/triggers/air_quality.co2_changed. Custom integrations keep their own
documentation URL.
2026-07-03 12:28:27 +02:00
Petar Petrov fbad0ba885 Fix double bar in energy devices detail graph at start of day (#52939) 2026-07-03 12:25:06 +02:00
Petar Petrov c892691344 Add My links for infrared and radio frequency config panels (#52931) 2026-07-03 12:25:05 +02:00
Petar Petrov 811b7c7d98 Allow negative number entry in number selector on iOS (#52925)
Allow entering negative numbers in number selector on iOS
2026-07-03 12:24:37 +02:00
16 changed files with 233 additions and 68 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260624.3"
version = "20260624.4"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
@@ -3,6 +3,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { NumberSelector } from "../../data/selector";
import { isSafari } from "../../util/is_safari";
import "../ha-input-helper-text";
import "../ha-slider";
import "../input/ha-input";
@@ -66,6 +67,16 @@ export class HaNumberSelector extends LitElement {
}
}
// On iOS/iPadOS the numeric and decimal on-screen keypads have no minus key,
// so negatives can only be typed with the full "text" keyboard. Other
// platforms include a minus on their number keypads, so restrict this
// workaround to Safari/WebKit and only when the selector allows negatives
// (e.g. numeric_state triggers/conditions).
const useTextInputMode =
isSafari &&
this.selector.number?.min !== undefined &&
this.selector.number.min < 0;
const translationKey = this.selector.number?.translation_key;
let unit = this.selector.number?.unit_of_measurement;
if (isBox && unit && this.localizeValue && translationKey) {
@@ -96,10 +107,12 @@ export class HaNumberSelector extends LitElement {
`
: nothing}
<ha-input
.inputMode=${this.selector.number?.step === "any" ||
(this.selector.number?.step ?? 1) % 1 !== 0
? "decimal"
: "numeric"}
.inputmode=${useTextInputMode
? "text"
: this.selector.number?.step === "any" ||
(this.selector.number?.step ?? 1) % 1 !== 0
? "decimal"
: "numeric"}
.label=${!isBox ? undefined : this.label}
.placeholder=${this.placeholder !== undefined
? this.placeholder.toString()
+2 -2
View File
@@ -521,10 +521,10 @@ export class HaServiceControl extends LitElement {
${description ? html`<p>${description}</p>` : ""}
${this._manifest
? html` <a
href=${this._manifest.is_built_in
href=${this._manifest.is_built_in && this._value?.action
? documentationUrl(
this.hass,
`/integrations/${this._manifest.domain}`
`/actions/${this._value.action}`
)
: this._manifest.documentation}
title=${this.hass.localize(
@@ -5,6 +5,7 @@ import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
import { extractSearchParam } from "../../../common/url/search-params";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
@@ -23,8 +24,14 @@ import type {
StoreAddon,
SupervisorStore,
} from "../../../data/supervisor/store";
import { fetchSupervisorStore } from "../../../data/supervisor/store";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import {
addStoreRepository,
fetchSupervisorStore,
} from "../../../data/supervisor/store";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-error-screen";
import "../../../layouts/hass-loading-screen";
import "../../../layouts/hass-subpage";
@@ -82,7 +89,15 @@ export class HaConfigAppsAvailable extends LitElement {
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this._loadData();
const repositoryUrl = extractSearchParam("repository_url");
if (repositoryUrl) {
navigate("/config/apps/available", { replace: true });
}
this._loadData().then(() => {
if (repositoryUrl) {
this._addRepository(repositoryUrl);
}
});
this.addEventListener("hass-api-called", (ev) => this._apiCalled(ev));
}
@@ -226,6 +241,40 @@ export class HaConfigAppsAvailable extends LitElement {
navigate("/config/apps/registries");
}
private async _addRepository(repositoryUrl: string): Promise<void> {
if (
!this._store ||
this._store.repositories.some((repo) => repo.source === repositoryUrl)
) {
return;
}
if (
!(await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.apps.my.add_repository_title"
),
text: this.hass.localize(
"ui.panel.config.apps.my.add_repository_store_description",
{ repository: repositoryUrl }
),
confirmText: this.hass.localize("ui.common.add"),
dismissText: this.hass.localize("ui.common.cancel"),
}))
) {
return;
}
try {
await addStoreRepository(this.hass, repositoryUrl);
await this._loadData();
} catch (err: any) {
showAlertDialog(this, {
text: extractApiErrorMessage(err),
});
}
}
private async _loadData(): Promise<void> {
try {
const [addon, store] = await Promise.all([
@@ -5,7 +5,6 @@ import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import { extractSearchParam } from "../../../common/url/search-params";
import "../../../components/data-table/ha-data-table";
import type { DataTableColumnContainer } from "../../../components/data-table/ha-data-table";
import "../../../components/ha-button";
@@ -56,12 +55,7 @@ export class HaConfigAppsRepositories extends LitElement {
@state() private _error?: string;
protected firstUpdated() {
this._loadData().then(() => {
const repositoryUrl = extractSearchParam("repository_url");
if (repositoryUrl) {
this._addRepository(repositoryUrl);
}
});
this._loadData();
}
private _columns = memoizeOne(
@@ -224,18 +218,6 @@ export class HaConfigAppsRepositories extends LitElement {
});
}
private async _addRepository(url: string) {
try {
await addStoreRepository(this.hass, url);
await this._loadData();
fireEvent(this, "apps-collection-refresh", { collection: "store" });
} catch (err: any) {
showAlertDialog(this, {
text: extractApiErrorMessage(err),
});
}
}
private _removeRepository = async (ev: Event) => {
const slug = (ev.currentTarget as any).slug;
const repo = this._repositories?.find((r) => r.slug === slug);
@@ -195,7 +195,7 @@ export class HaPlatformCondition extends LitElement {
href=${this._manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._manifest.domain}`
`/conditions/${this.condition.condition}`
)
: this._manifest.documentation}
title=${this.hass.localize(
@@ -189,7 +189,7 @@ export class HaPlatformTrigger extends LitElement {
href=${this._manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._manifest.domain}`
`/triggers/${this.trigger.trigger}`
)
: this._manifest.documentation}
title=${this.hass.localize(
@@ -4,6 +4,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import type { LocalizeKeys } from "../../../../common/translations/localize";
import { debounce } from "../../../../common/util/debounce";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
@@ -40,6 +41,15 @@ For loop example getting entity values in the weather domain:
{{ state.name | lower }} is {{state.state_with_unit}}
{%- endfor %}.`;
// key resolves the label/description translation keys; path is passed through
// documentationUrl().
const TEMPLATE_DOCS_LINKS: { key: string; path: string }[] = [
{ key: "docs_introduction", path: "/docs/templating/introduction/" },
{ key: "docs_states", path: "/docs/templating/states/" },
{ key: "docs_debugging", path: "/docs/templating/debugging/" },
{ key: "docs_functions", path: "/template-functions/" },
];
@customElement("developer-tools-template")
class HaPanelDevTemplate extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -120,31 +130,36 @@ class HaPanelDevTemplate extends LitElement {
"ui.panel.config.developer-tools.tabs.templates.description"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.developer-tools.tabs.templates.engine_info"
)}
</p>
<h3>
${this.hass.localize(
"ui.panel.config.developer-tools.tabs.templates.learn_more"
)}
</h3>
<ul>
<li>
<a
href="https://jinja.palletsprojects.com/en/latest/templates/"
target="_blank"
rel="noreferrer"
>${this.hass.localize(
"ui.panel.config.developer-tools.tabs.templates.jinja_documentation"
)}
</a>
</li>
<li>
<a
href=${documentationUrl(
this.hass,
"/docs/configuration/templating/"
)}
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.developer-tools.tabs.templates.template_extensions"
)}</a
>
</li>
${TEMPLATE_DOCS_LINKS.map(
(link) => html`
<li>
<a
href=${documentationUrl(this.hass, link.path)}
target="_blank"
rel="noreferrer"
>${this.hass.localize(
`ui.panel.config.developer-tools.tabs.templates.${link.key}` as LocalizeKeys
)}</a
>
<span class="link-description"
>${this.hass.localize(
`ui.panel.config.developer-tools.tabs.templates.${link.key}_description` as LocalizeKeys
)}</span
>
</li>
`
)}
</ul>
</div>
</ha-expansion-panel>
@@ -430,6 +445,17 @@ ${type === "object"
margin-block-start: var(--ha-space-1);
margin-block-end: var(--ha-space-1);
}
.description > h3 {
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
margin-block-end: var(--ha-space-1);
}
.description li {
margin-block-end: var(--ha-space-1);
}
.description .link-description {
color: var(--secondary-text-color);
}
.render-pane .card-content {
user-select: text;
@@ -369,6 +369,22 @@ export function computeStatMidpoint(
return (start + end) / 2;
}
const PERIOD_MS: Record<string, number> = {
"5minute": 5 * 60 * 1000,
hour: 60 * 60 * 1000,
};
/**
* Offset from a period's start to its midpoint, for centering sub-daily bars
* (and forecast lines) between axis ticks — 0 for daily+ periods, which sit at
* the start. Derived from the period, not from the data, so the first/only
* bucket centers identically to every other bucket. (Previously estimated from
* the gap between the first two entries, which collapsed to 0 with one bucket.)
*/
export function getPeriodMidpointOffset(period: string): number {
return (PERIOD_MS[period] ?? 0) / 2;
}
export interface UntrackedSplit {
/** Untracked consumption per timestamp, clamped to >= 0. */
positive: Record<number, number>;
@@ -24,6 +24,7 @@ import {
type EnergyDataPoint,
fillDataGapsAndRoundCaps,
getCompareTransform,
getPeriodMidpointOffset,
splitUntrackedConsumption,
} from "./common/energy-chart-options";
import { getEnergyColor } from "./common/color";
@@ -237,12 +238,15 @@ function processUntracked(
const sortedTimes = Object.keys(consumptionData.used_total).sort(
(a, b) => Number(a) - Number(b)
);
// Only start timestamps available here, so estimate midpoint from the gap
// between the first two entries. Assumes uniform period spacing.
// Only start timestamps available here, so center sub-daily bars using the
// gap between the first two entries. With a lone first-of-day bucket there is
// no gap to measure, so fall back to the nominal period midpoint — which
// matches the device bars' computeStatMidpoint instead of collapsing to the
// period start and splitting into a second stack.
const periodOffset =
(period === "hour" || period === "5minute") && sortedTimes.length >= 2
? (Number(sortedTimes[1]) - Number(sortedTimes[0])) / 2
: 0;
: getPeriodMidpointOffset(period);
sortedTimes.forEach((time) => {
const ts = Number(time);
const x = compare
@@ -14,6 +14,7 @@ import {
type EnergyDataPoint,
fillDataGapsAndRoundCaps,
getCompareTransform,
getPeriodMidpointOffset,
} from "./common/energy-chart-options";
export interface EnergySolarGraphDataParams {
@@ -323,7 +324,8 @@ function processForecast(
const solarForecastData: LineSeriesOption["data"] = [];
// Only center forecast points for sub-daily periods to align with bars.
// Only start timestamps available, so estimate midpoint from the gap
// between the first two entries. Assumes uniform spacing.
// between the first two entries; with a lone first bucket there is no
// gap to measure, so fall back to the nominal period midpoint.
let forecastOffset = 0;
if (period === "hour" || period === "5minute") {
const forecastTimes = Object.keys(forecastsData)
@@ -332,7 +334,7 @@ function processForecast(
forecastOffset =
forecastTimes.length >= 2
? (forecastTimes[1] - forecastTimes[0]) / 2
: 0;
: getPeriodMidpointOffset(period);
}
for (const [time, value] of Object.entries(forecastsData)) {
const kWh = value / 1000;
@@ -39,6 +39,7 @@ import {
fillDataGapsAndRoundCaps,
getCommonOptions,
getCompareTransform,
getPeriodMidpointOffset,
} from "./common/energy-chart-options";
import type { HaECOption } from "../../../../resources/echarts/echarts";
import type { CustomLegendOption } from "../../../../components/chart/ha-chart-base";
@@ -585,14 +586,15 @@ export class HuiEnergyUsageGraphCard
const uniqueKeys = summedData.timestamps;
// Only center bars for sub-daily periods (hour/5min).
// Only start timestamps available here, so estimate midpoint from the gap
// between the first two entries. Assumes uniform period spacing.
// Only center bars for sub-daily periods (hour/5min). Only start timestamps
// available here, so estimate midpoint from the gap between the first two
// entries; with a lone first-of-day bucket there is no gap to measure, so
// fall back to the nominal period midpoint so the bar stays centered.
const period = getSuggestedPeriod(this._start, this._end);
const periodOffset =
(period === "hour" || period === "5minute") && uniqueKeys.length >= 2
? (uniqueKeys[1] - uniqueKeys[0]) / 2
: 0;
: getPeriodMidpointOffset(period);
const compareTransform = getCompareTransform(
this._start,
+7
View File
@@ -149,6 +149,13 @@ export const getMyRedirects = (): Redirects => ({
component: "energy",
redirect: "/config/energy",
},
config_infrared: {
redirect: "/config/infrared",
},
config_radiofrequency: {
component: "radio_frequency",
redirect: "/config/radio-frequency",
},
config_ssdp: {
component: "ssdp",
redirect: "/config/ssdp",
+12 -3
View File
@@ -2853,6 +2853,7 @@
"my": {
"add_repository_title": "Add app repository?",
"add_repository_description": "This app requires a repository that is currently not known. Do you want to add the repository {repository}?",
"add_repository_store_description": "Do you want to add the app repository {repository}?",
"error_repository_not_found": "The repository for this app was not found"
},
"panel": {
@@ -3904,7 +3905,9 @@
},
"templates": {
"title": "Template",
"description": "Templates are rendered using the Jinja2 template engine with some Home Assistant specific extensions.",
"description": "Templates let you generate dynamic content from your Home Assistant data, such as a notification that lists which lights are on, or a sensor whose value is calculated from several other entities.",
"engine_info": "Home Assistant uses the Jinja templating engine, extended with functions for working with your entities, areas, devices, and more. Write a template in the editor below and its result updates live as your states change.",
"learn_more": "Learn more",
"about": "About templates",
"editor": "Template editor",
"result": "Result",
@@ -3912,8 +3915,14 @@
"confirm_reset": "Do you want to reset your current template back to the demo template?",
"confirm_clear": "Do you want to clear your current template?",
"result_type": "Result type",
"jinja_documentation": "Jinja2 template documentation",
"template_extensions": "Home Assistant template extensions",
"docs_introduction": "Introduction to templating",
"docs_introduction_description": "Start here for a step-by-step guide.",
"docs_states": "Working with states",
"docs_states_description": "Read entity states and attributes in templates.",
"docs_debugging": "Debugging templates",
"docs_debugging_description": "Find and fix problems in your templates.",
"docs_functions": "Template functions reference",
"docs_functions_description": "Search every available function, filter, and test.",
"unknown_error_template": "Unknown error rendering template",
"time": "This template updates at the start of each minute.",
"all_listeners": "This template listens for all state changed events.",
+5 -1
View File
@@ -89,6 +89,8 @@ export interface EnergyDataOptions {
period?: "5minute" | "hour" | "day";
compare?: boolean;
prefs?: EnergyPreferences;
/** Probability a period is missing (creates gaps); 0 for a dense dataset. */
gapChance?: number;
}
const statisticIdsForPrefs = (prefs: EnergyPreferences): string[] => {
@@ -115,7 +117,7 @@ export const generateEnergyData = (
seed: number,
options: EnergyDataOptions
): EnergyData => {
const { days, period = "hour", compare = false } = options;
const { days, period = "hour", compare = false, gapChance } = options;
const prefs = options.prefs ?? generateEnergyPreferences();
const ids = statisticIdsForPrefs(prefs);
const dayMs = 24 * 60 * 60 * 1000;
@@ -124,6 +126,7 @@ export const generateEnergyData = (
ids,
period,
days,
gapChance,
sumStatistics: true,
});
const statsCompare = compare
@@ -132,6 +135,7 @@ export const generateEnergyData = (
period,
days,
startMs: FIXED_EPOCH_MS - days * dayMs,
gapChance,
sumStatistics: true,
})
: ({} as EnergyData["statsCompare"]);
@@ -164,6 +164,57 @@ describe("generateEnergyDevicesDetailGraphData", () => {
).toMatchSnapshot();
});
// Regression test for #52937: at the start of the day only the first hour
// has data. The untracked/over-reported bars must center on the same period
// midpoint as the device bars so they stack as one bar instead of splitting
// into a second stack at the period start.
it("stacks untracked bars on the device bars for a lone first-of-day bucket", () => {
// Full-day range (so getSuggestedPeriod stays "hour") but keep only the
// first hourly bucket in every stat. gapChance: 0 makes the bucket dense.
const full = generateEnergyData(1, {
days: 1,
period: "hour",
gapChance: 0,
prefs: buildPrefs(false),
});
const firstStart = full.start.getTime();
const energyData = {
...full,
stats: Object.fromEntries(
Object.entries(full.stats).map(
([id, values]) =>
[id, values.filter((s) => s.start === firstStart)] as const
)
),
};
const result = generateEnergyDevicesDetailGraphData({
...baseParams,
energyData,
});
// Collect the display x of every bar across all series.
const xs = new Set<number>();
let nonEmptySeries = 0;
for (const series of result.chartData) {
const points = series.data ?? [];
if (points.length) {
nonEmptySeries++;
}
for (const point of points as any[]) {
const x = Array.isArray(point) ? point[0] : point?.value?.[0];
if (x != null) {
xs.add(Number(x));
}
}
}
// Device bars + at least one untracked series are present...
assert.isAtLeast(nonEmptySeries, 2);
// ...and they all share a single x, so they render as one full stack.
assert.equal(xs.size, 1);
});
// The seeded fixtures above all happen to produce fully-negative untracked
// (devices reference the source stats, so they consume all of used_total).
// These two cases pin the branches those snapshots can't reach.