Compare commits

...

29 Commits

Author SHA1 Message Date
Bram Kragten
c4f4cbd323 Bumped version to 20260429.3 2026-05-06 11:18:01 +02:00
Paul Bottein
2e0df00f0f Fix name for battery entities without device (#51879) 2026-05-06 11:17:09 +02:00
Wendelin
ce02f8072d Reduce progress bar default height (#51878)
reduce progress bar default height to 12px
2026-05-06 11:17:08 +02:00
Paul Bottein
c973aa7516 Fix media controls in media player more info dialog (#51877) 2026-05-06 11:17:07 +02:00
Paul Bottein
1e2328707c Fix switch clipping in view visibility editor (#51876) 2026-05-06 11:17:06 +02:00
Wendelin
56368b88cd Remove duplicate definition in semantic colors (#51875)
* Remove duplicate definition in semantic colors

* rearrange surface tokens
2026-05-06 11:17:05 +02:00
Aidan Timson
fcd4f177c1 Fix Safari 14 legacy bundle require errors (#51868)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 11:17:04 +02:00
Wendelin
7423ae7316 Fix integration search shrink on mobile (#51867) 2026-05-06 11:17:03 +02:00
Marcin Bauer
4427c581f1 Fix automation row right padding and soften chip highlight animation (#51865)
Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 11:17:02 +02:00
Paul Bottein
cf86bb9821 Use ha-switch instead of ha-control-switch in entity toggle (#51852) 2026-05-06 11:17:01 +02:00
karwosts
897802dc16 Change display for uptime sensors (#51830) 2026-05-06 11:17:00 +02:00
Paul Bottein
dd65173c5a Bumped version to 20260429.2 2026-05-04 17:04:06 +02:00
Paul Bottein
cf26753f7d Remove daily and hourly forecast card features (#51854) 2026-05-04 17:03:54 +02:00
Paul Bottein
d6ab8ffb16 Resolve service name and icon for shortcut card and badge (#51850) 2026-05-04 17:03:53 +02:00
Wendelin
2dc4b16eac Fix automation row target width (#51848) 2026-05-04 17:03:52 +02:00
Paul Bottein
1eba765bc2 Group areas floor vacuum clean (#51847) 2026-05-04 17:03:51 +02:00
Wendelin
398479ddd7 Use ha-switch in ha-automation-picker (#51846)
use ha-switch in ha-automation-picker
2026-05-04 17:03:50 +02:00
Paul Bottein
c4fd7bb3e1 Fix entity toggle switch size (#51845) 2026-05-04 17:03:49 +02:00
Isaac (Kwangjin Ko)
4cfc67a95e ha-humidifier-state: fix incorrect translation key for 'Currently' (#51843) 2026-05-04 17:03:48 +02:00
Brooke Hatton
e38d1964ca Remove battery chargers from maintenance dashboard (#51835) 2026-05-04 17:03:47 +02:00
Paul Bottein
ec8b5c77bd Add min touch size for control switch (#51826) 2026-05-04 17:03:46 +02:00
Simon Lamon
425f2775e2 Missing toggle in switch group (#51825)
Missing toggle
2026-05-04 17:03:45 +02:00
Brooke Hatton
3a3d8191a3 Adjust Copy for maintenance summary card and include unavailable device count (#51815)
* Adjust Copy For summary card

* Further tweak copy and include unavailable devices
2026-05-04 17:03:44 +02:00
Aidan Timson
04fca68549 Add gap between hui editors and previews on mobile (#51811) 2026-05-04 17:03:43 +02:00
Paul Bottein
35601a0900 Bumped version to 20260429.1 2026-04-30 20:32:28 +02:00
Wendelin
e7016c15af Fix ha-select undefined value (#51800)
Fix ha-select undefined

Co-authored-by: Copilot <copilot@github.com>
2026-04-30 20:32:08 +02:00
Wendelin
624521e30b Hide tooltip on mobile clients in ha-sidebar component (#51799) 2026-04-30 20:32:08 +02:00
Bram Kragten
4876bfa639 Add tooltips for Jinja editors (#51792)
* Add descriptions to Jinja2 tags, filters, expressions, tests and variables

All standard Jinja2 tags, filters, and expression completions now carry
info and detail strings so the autocomplete info popover shows meaningful
documentation when users browse them — not just HA-specific functions.

* Add keyboard shortcut tip to the template developer tool

A ha-tip below the editor card now shows users that Ctrl+Space triggers
autocomplete, Ctrl+F opens the search panel, and F11 toggles fullscreen,
making the editor's built-in features more discoverable.

* Add hover tooltips for Jinja2 functions, filters and expressions

Hovering over a function, filter, tag, test, or variable name inside a
Jinja2 template shows a tooltip with its signature and description.
Non-tag completions also get a help-circle icon linking to the
corresponding Home Assistant template-functions documentation page.

The tooltip is rendered as a custom Lit element (ha-code-editor-jinja-hover)
that takes the Completion object and an optional docUrl as properties.

The tooltip source (haJinjaHoverSource) is wired into ha-code-editor
via CodeMirror's hoverTooltip extension. The documentationUrl() helper
is used so the link points to the correct subdomain (www / rc / next)
based on the running HA version.

* Add hover tooltips for Jinja2 hover + arg value tooltips for entity/device/area

Wire haJinjaHoverSource into ha-code-editor via CodeMirror hoverTooltip.
Two types of hover are now shown in jinja2/yaml mode:

- Hovering a function/filter/tag/expression name shows its signature,
  description, and a doc-link icon (non-tags only).
- Hovering a string-literal argument of a known HA Jinja function (e.g.
  states(), device_name(), area_entities()) shows the friendly name,
  current state, device, and area for entity_id arguments; the device
  name and area for device_id arguments; and the area name for area_id
  arguments. The same applies to states["entity_id"] subscripts.

The arg-value tooltip reuses CompletionItem / ha-code-editor-completion-items
(the same component used for autocomplete info popovers) via a new
ha-code-editor-jinja-arg-hover element. HA registry data is passed from
ha-code-editor via a HassArgHoverContext interface to keep jinja_ha_completions.ts
free of HomeAssistant type imports.

* only add tip for autocomplete

* review
2026-04-30 20:32:07 +02:00
AlCalzone
5dea0764b2 Expose Z-Wave exclusion instructions when removing device (#51788)
* Expose Z-Wave exclusion instructions when removing device

* text tweaks

* Apply suggestion from @MindFreeze

* Apply suggestions from code review

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* bring back comment

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-04-30 20:32:05 +02:00
64 changed files with 2516 additions and 1782 deletions

View File

@@ -1,3 +1,4 @@
/* global require, module, __dirname, process */
const path = require("path");
const env = require("./env.cjs");
const paths = require("./paths.cjs");
@@ -176,11 +177,14 @@ module.exports.babelOptions = ({
{
// Use unambiguous for dependencies so that require() is correctly injected into CommonJS files
// Exclusions are needed in some cases where ES modules have no static imports or exports, such as polyfills
// (otherwise babel-plugin-polyfill-corejs3 injects bare require("core-js/modules/...") calls
// that rspack does not transform, causing ReferenceError in browsers like Safari 14).
sourceType: "unambiguous",
include: /\/node_modules\//,
exclude: [
"element-internals-polyfill",
"@?lit(?:-labs|-element|-html)?",
"@formatjs/(?:ecma402-abstract|intl-\\w+)",
].map((p) => new RegExp(`/node_modules/${p}/`)),
},
],

View File

@@ -0,0 +1,12 @@
/* global module */
// Browser-only replacement for core-js/internals/get-built-in-node-module.
// The original helper evaluates `Function('return require("...")')()`
// when it detects a Node environment, which causes a runtime
// ReferenceError on browsers (notably Safari 14) if environment
// detection mis-classifies the page. Since browser bundles never need to
// access Node built-in modules, return undefined unconditionally.
//
// Wired up via rspack `NormalModuleReplacementPlugin` in build-scripts/rspack.cjs.
module.exports = function () {
return undefined;
};

View File

@@ -1,3 +1,4 @@
/* global require, module, __dirname */
const { existsSync } = require("fs");
const path = require("path");
const rspack = require("@rspack/core");
@@ -173,6 +174,16 @@ const createRspackConfig = ({
path.resolve(paths.root_dir, "src/util/empty.js")
)
: false,
// core-js ships a Node-only helper that evaluates
// `Function('return require("...")')()` when its runtime environment
// detection mis-classifies the page as Node. That produces a
// ReferenceError on browsers (observed on Safari 14). Since browser
// bundles never need to access Node built-in modules, replace it with
// a CommonJS no-op stub matching the helper's API (returns undefined).
new rspack.NormalModuleReplacementPlugin(
/core-js[\\/]internals[\\/]get-built-in-node-module(?:\.js)?$/,
path.resolve(__dirname, "get-built-in-node-module-shim.cjs")
),
!isProdBuild && new LogStartCompilePlugin(),
isProdBuild &&
new StatsWriterPlugin({

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260429.0"
version = "20260429.3"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"

View File

@@ -17,6 +17,7 @@ import {
import { blankBeforeUnit } from "../translations/blank_before_unit";
import type { LocalizeFunc } from "../translations/localize";
import { computeDomain } from "./compute_domain";
import { SENSOR_TIMESTAMP_DEVICE_CLASSES } from "../../data/sensor";
export const computeStateDisplay = (
localize: LocalizeFunc,
@@ -267,8 +268,7 @@ const computeStateToPartsFromEntityAttributes = (
"datetime",
].includes(domain) ||
(domain === "sensor" &&
(attributes.device_class === "timestamp" ||
attributes.device_class === "uptime"))
SENSOR_TIMESTAMP_DEVICE_CLASSES.includes(attributes.device_class))
) {
try {
return [

View File

@@ -53,7 +53,7 @@ export class HaAutomationRowEventChip extends LitElement {
return keyed(
this._highlight,
html`
<wa-animation fill="both" .iterations=${1} name="tada" play
<wa-animation fill="both" .iterations=${1} name="headShake" play
>${base}</wa-animation
>
`

View File

@@ -127,7 +127,7 @@ export class HaAutomationRow extends LitElement {
}
.row {
display: flex;
padding: 0 var(--ha-space-3);
padding: 0 0 0 var(--ha-space-3);
min-height: 48px;
align-items: flex-start;
cursor: pointer;

View File

@@ -13,9 +13,9 @@ import {
} from "../../data/entity/entity";
import { forwardHaptic } from "../../data/haptics";
import type { HomeAssistant } from "../../types";
import "../ha-control-switch";
import "../ha-formfield";
import "../ha-icon-button";
import "../ha-switch";
const isOn = (stateObj?: HassEntity) =>
stateObj !== undefined &&
@@ -35,7 +35,7 @@ export class HaEntityToggle extends LitElement {
protected render(): TemplateResult {
if (!this.stateObj) {
return html`<ha-control-switch disabled></ha-control-switch> `;
return html`<ha-switch disabled></ha-switch> `;
}
if (
@@ -62,14 +62,14 @@ export class HaEntityToggle extends LitElement {
`;
}
const switchTemplate = html`<ha-control-switch
const switchTemplate = html`<ha-switch
aria-label=${`Toggle ${computeStateName(this.stateObj)} ${
this._isOn ? "off" : "on"
}`}
.checked=${this._isOn}
.disabled=${this.stateObj.state === UNAVAILABLE}
@change=${this._toggleChanged}
></ha-control-switch>`;
></ha-switch>`;
if (!this.label) {
return switchTemplate;
@@ -160,12 +160,14 @@ export class HaEntityToggle extends LitElement {
static styles = css`
:host {
display: flex;
align-items: center;
white-space: nowrap;
min-width: 38px;
}
ha-control-switch {
--control-switch-thickness: 20px;
--control-switch-off-color: var(--state-inactive-color);
ha-switch {
--ha-switch-width: 38px;
--ha-switch-size: 20px;
--ha-switch-thumb-size: 14px;
}
ha-icon-button {
--ha-icon-button-size: 40px;

View File

@@ -0,0 +1,42 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type { CompletionItem } from "./ha-code-editor-completion-items";
import "./ha-code-editor-completion-items";
@customElement("ha-code-editor-jinja-arg-hover")
export class HaCodeEditorJinjaArgHover extends LitElement {
/** Bold heading shown above the items grid (e.g. entity/device/area name). */
@property({ attribute: false }) public heading?: string;
@property({ attribute: false }) public items: CompletionItem[] = [];
render() {
return html`
${this.heading
? html`<div class="heading">${this.heading}</div>`
: nothing}
<ha-code-editor-completion-items
.items=${this.items}
></ha-code-editor-completion-items>
`;
}
static styles = css`
:host {
display: block;
padding: 6px 10px;
max-width: 360px;
}
.heading {
font-weight: var(--ha-font-weight-bold);
margin-bottom: 4px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-code-editor-jinja-arg-hover": HaCodeEditorJinjaArgHover;
}
}

View File

@@ -0,0 +1,101 @@
import type { Completion } from "@codemirror/autocomplete";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { mdiHelpCircleOutline } from "@mdi/js";
import "./ha-svg-icon";
@customElement("ha-code-editor-jinja-hover")
export class HaCodeEditorJinjaHover extends LitElement {
@property({ attribute: false }) public completion!: Completion;
@property({ attribute: false }) public docUrl?: string;
@property({ attribute: false }) public openDocumentation =
"Open documentation";
render() {
const info =
typeof this.completion.info === "string"
? this.completion.info
: undefined;
return html`
<div class="header">
<div class="sig">
<strong>${this.completion.label}</strong>
${this.completion.detail
? html`<span class="detail">(${this.completion.detail})</span>`
: nothing}
</div>
${this.docUrl
? html`<a
class="doc-link"
href=${this.docUrl}
target="_blank"
rel="noreferrer"
title=${this.openDocumentation}
><ha-svg-icon .path=${mdiHelpCircleOutline}></ha-svg-icon
></a>`
: nothing}
</div>
${info ? html`<div class="desc">${info}</div>` : nothing}
`;
}
static styles = css`
:host {
display: block;
padding: 6px 10px;
max-width: 360px;
line-height: 1.5;
}
.header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.sig {
font-family: var(--ha-font-family-code);
font-size: 0.9em;
flex: 1;
min-width: 0;
}
.detail {
color: var(--secondary-text-color);
}
.doc-link {
flex-shrink: 0;
display: inline-flex;
align-items: center;
color: var(--secondary-text-color);
opacity: 0.7;
line-height: 1;
}
.doc-link:hover {
opacity: 1;
color: var(--primary-color);
}
.doc-link ha-svg-icon {
width: 16px;
height: 16px;
}
.desc {
font-size: 0.9em;
color: var(--secondary-text-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-code-editor-jinja-hover": HaCodeEditorJinjaHover;
}
}

View File

@@ -36,9 +36,13 @@ import { computeAreaName } from "../common/entity/compute_area_name";
import { computeFloorName } from "../common/entity/compute_floor_name";
import { copyToClipboard } from "../common/util/copy-clipboard";
import { haStyleScrollbar } from "../resources/styles";
import type { JinjaArgType } from "../resources/jinja_ha_completions";
import type {
JinjaArgType,
HassArgHoverContext,
} from "../resources/jinja_ha_completions";
import type { HomeAssistant } from "../types";
import { showToast } from "../util/toast";
import { documentationUrl } from "../util/documentation-url";
import { labelsContext } from "../data/context";
import type { LabelRegistryEntry } from "../data/label/label_registry";
import "./ha-code-editor-completion-items";
@@ -326,6 +330,16 @@ export class HaCodeEditor extends ReactiveElement {
this._loadedCodeMirror.tooltips({
position: "absolute",
}),
this._loadedCodeMirror.hoverTooltip(
(view, pos) =>
this._loadedCodeMirror!.haJinjaHoverSource(
view,
pos,
this.hass ? documentationUrl(this.hass, "") : undefined,
this.hass ? this._hassArgHoverContext() : undefined
),
{ hoverTime: 300 }
),
...(this.placeholder ? [placeholder(this.placeholder)] : []),
];
@@ -575,6 +589,48 @@ export class HaCodeEditor extends ReactiveElement {
}
};
/**
* Builds a HassArgHoverContext from the current hass object so that
* haJinjaHoverSource can resolve entity / device / area friendly names
* without importing the full HomeAssistant type into the resource file.
*/
private _hassArgHoverContext(): HassArgHoverContext {
const hass = this.hass!;
const labelMap: Record<
string,
{ name: string; description?: string | null }
> = {};
for (const label of this._labels ?? []) {
labelMap[label.label_id] = {
name: label.name,
description: label.description,
};
}
return {
states: hass.states as HassArgHoverContext["states"],
devices: hass.devices as HassArgHoverContext["devices"],
areas: hass.areas as HassArgHoverContext["areas"],
floors: hass.floors as HassArgHoverContext["floors"],
entities: hass.entities as HassArgHoverContext["entities"],
labels: labelMap,
formatEntityState: (entityId) =>
hass.formatEntityState(hass.states[entityId]),
formatEntityName: (entityId) => {
const stateObj = hass.states[entityId];
return (
(stateObj?.attributes.friendly_name as string | undefined) ??
hass.entities[entityId]?.name ??
undefined
);
},
formatAttributeName: (entityId, attribute) =>
hass.formatEntityAttributeName(hass.states[entityId], attribute),
formatAttributeValue: (entityId, attribute) =>
hass.formatEntityAttributeValue(hass.states[entityId], attribute),
localize: (key) => hass.localize(key as never),
};
}
private _renderInfo = (completion: Completion): CompletionInfo => {
const key =
typeof completion.apply === "string"

View File

@@ -55,7 +55,11 @@ export class HaConditionIcon extends LitElement {
return this._renderFallback();
}
const icon = conditionIcon(this.hass, this.condition).then((icn) => {
const icon = conditionIcon(
this.hass.connection,
this.hass.config,
this.condition
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}

View File

@@ -196,6 +196,7 @@ export class HaControlSwitch extends LitElement {
--control-switch-background-opacity: 0.2;
--control-switch-hover-background-opacity: 0.4;
--control-switch-thickness: 40px;
--control-switch-min-touch-size: 40px;
--control-switch-border-radius: var(--ha-border-radius-lg);
--control-switch-padding: 4px;
--mdc-icon-size: 20px;
@@ -219,21 +220,35 @@ export class HaControlSwitch extends LitElement {
width: 100%;
border-radius: var(--control-switch-border-radius);
outline: none;
overflow: hidden;
padding: var(--control-switch-padding);
display: flex;
cursor: pointer;
}
.switch::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
height: 100%;
min-width: var(--control-switch-min-touch-size);
min-height: var(--control-switch-min-touch-size);
}
.switch[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
.switch[disabled]::before {
pointer-events: none;
}
.switch .background {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
border-radius: inherit;
background-color: var(--control-switch-off-color);
transition: background-color 180ms ease-in-out;
opacity: var(--control-switch-background-opacity);

View File

@@ -32,7 +32,7 @@ class HaHumidifierState extends LitElement {
${currentStatus && !isUnavailableState(this.stateObj.state)
? html`<div class="current">
${this.hass.localize("ui.card.climate.currently")}:
${this.hass.localize("ui.card.humidifier.currently")}:
<div class="unit">${currentStatus}</div>
</div>`
: ""}`;

View File

@@ -59,7 +59,7 @@ export class HaSelect extends LitElement {
value: string | number | undefined
) => {
// just in case value is a number, convert it to string to avoid falsy value
const valueStr = String(value);
const valueStr = value !== undefined ? String(value) : undefined;
if (!options || !valueStr) {
return valueStr;
}

View File

@@ -32,7 +32,11 @@ export class HaServiceIcon extends LitElement {
return this._renderFallback();
}
const icon = serviceIcon(this.hass, this.service).then((icn) => {
const icon = serviceIcon(
this.hass.connection,
this.hass.config,
this.service
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}

View File

@@ -54,7 +54,7 @@ class HaServicePicker extends LitElement {
protected firstUpdated(props: PropertyValues<this>) {
super.firstUpdated(props);
this.hass.loadBackendTranslation("services");
getServiceIcons(this.hass);
getServiceIcons(this.hass.connection, this.hass.config);
}
private _rowRenderer: RenderItemFunction<ServiceComboBoxItem> = (

View File

@@ -29,14 +29,17 @@ export class HaServiceSectionIcon extends LitElement {
return this._renderFallback();
}
const icon = serviceSectionIcon(this.hass, this.service, this.section).then(
(icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
const icon = serviceSectionIcon(
this.hass.connection,
this.hass.config,
this.service,
this.section
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
);
return this._renderFallback();
});
return html`${until(icon)}`;
}

View File

@@ -36,6 +36,7 @@ import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { actionHandler } from "../panels/lovelace/common/directives/action-handler-directive";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant, PanelInfo, Route } from "../types";
import { isMobileClient } from "../util/is_mobile";
import "./animation/ha-fade-in";
import "./ha-icon";
import "./ha-icon-button";
@@ -579,6 +580,10 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
}
private _renderToolTip(id: string, text: string) {
if (isMobileClient) {
return nothing;
}
return html`<ha-tooltip
for=${id}
show-delay="0"

View File

@@ -33,6 +33,7 @@ import { forwardHaptic } from "../data/haptics";
* @cssprop --ha-switch-checked-thumb-border-color-hover - Border color of the checked thumb on hover.
* @cssprop --ha-switch-thumb-box-shadow - The box shadow of the thumb. Defaults to `var(--ha-box-shadow-s)`.
* @cssprop --ha-switch-disabled-opacity - Opacity of the switch when disabled. Defaults to `0.2`.
* @cssprop --ha-switch-min-touch-size - Minimum touch target size around the switch. Defaults to `40px`.
* @cssprop --ha-switch-required-marker - The marker shown after the label for required fields. Defaults to `"*"`.
* @cssprop --ha-switch-required-marker-offset - Offset of the required marker. Defaults to `0.1rem`.
*
@@ -89,8 +90,23 @@ export class HaSwitch extends Switch {
}
label {
position: relative;
height: max(var(--thumb-size), var(--wa-form-control-toggle-size));
}
label::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
height: 100%;
min-width: var(--ha-switch-min-touch-size, 40px);
min-height: var(--ha-switch-min-touch-size, 40px);
}
label.disabled::before {
pointer-events: none;
}
.switch {
background-color: var(

View File

@@ -69,7 +69,11 @@ export class HaTriggerIcon extends LitElement {
return this._renderFallback();
}
const icon = triggerIcon(this.hass, this.trigger).then((icn) => {
const icon = triggerIcon(
this.hass.connection,
this.hass.config,
this.trigger
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}

View File

@@ -44,7 +44,7 @@ export class HaProgressBar extends ProgressBar {
--ha-progress-bar-track-color,
var(--ha-color-fill-neutral-normal-hover)
);
--track-height: var(--ha-progress-bar-track-height, 16px);
--track-height: var(--ha-progress-bar-track-height, 12px);
--wa-transition-slow: var(--ha-animation-duration-slow);
position: relative;
}

View File

@@ -0,0 +1,34 @@
import { computeDomain } from "../common/entity/compute_domain";
import { computeObjectId } from "../common/entity/compute_object_id";
import type { LocalizeFunc } from "../common/translations/localize";
import { DEFAULT_SERVICE_ICON } from "./icons";
import type { HomeAssistant } from "../types";
export interface ServiceInfo {
label: string;
icon?: string;
iconPath: string;
}
export const DEFAULT_SERVICE_INFO: ServiceInfo = {
label: "",
iconPath: DEFAULT_SERVICE_ICON,
};
export const computeServiceLabel = (
localize: LocalizeFunc,
services: HomeAssistant["services"],
service: string
): string => {
const domain = computeDomain(service);
const serviceName = computeObjectId(service);
const serviceDef = services[domain]?.[serviceName];
return (
localize(
`component.${domain}.services.${serviceName}.name`,
serviceDef?.description_placeholders
) ||
serviceDef?.name ||
service
);
};

View File

@@ -323,7 +323,8 @@ export const getComponentIcons = async (
export const getCategoryIcons = async <
T extends Exclude<IconCategory, "entity" | "entity_component">,
>(
hass: HomeAssistant,
connection: Connection,
hassConfig: HomeAssistant["config"],
category: T,
domain?: string,
force = false
@@ -334,12 +335,10 @@ export const getCategoryIcons = async <
Record<string, CategoryType[T]>
>;
}
resources[category].all = getHassIcons(hass.connection, category).then(
(res) => {
resources[category].domains = res.resources as any;
return res?.resources as Record<string, CategoryType[T]>;
}
) as any;
resources[category].all = getHassIcons(connection, category).then((res) => {
resources[category].domains = res.resources as any;
return res?.resources as Record<string, CategoryType[T]>;
}) as any;
return resources[category].all as Promise<Record<string, CategoryType[T]>>;
}
if (!force && domain in resources[category].domains) {
@@ -351,10 +350,10 @@ export const getCategoryIcons = async <
return resources[category].domains[domain] as Promise<CategoryType[T]>;
}
}
if (!isComponentLoaded(hass.config, domain)) {
if (!isComponentLoaded(hassConfig, domain)) {
return undefined;
}
const result = getHassIcons(hass.connection, category, domain);
const result = getHassIcons(connection, category, domain);
resources[category].domains[domain] = result.then(
(res) => res?.resources[domain]
) as any;
@@ -362,25 +361,28 @@ export const getCategoryIcons = async <
};
export const getServiceIcons = async (
hass: HomeAssistant,
connection: Connection,
hassConfig: HomeAssistant["config"],
domain?: string,
force = false
): Promise<ServiceIcons | Record<string, ServiceIcons> | undefined> =>
getCategoryIcons(hass, "services", domain, force);
getCategoryIcons(connection, hassConfig, "services", domain, force);
export const getTriggerIcons = async (
hass: HomeAssistant,
connection: Connection,
hassConfig: HomeAssistant["config"],
domain?: string,
force = false
): Promise<TriggerIcons | Record<string, TriggerIcons> | undefined> =>
getCategoryIcons(hass, "triggers", domain, force);
getCategoryIcons(connection, hassConfig, "triggers", domain, force);
export const getConditionIcons = async (
hass: HomeAssistant,
connection: Connection,
hassConfig: HomeAssistant["config"],
domain?: string,
force = false
): Promise<ConditionIcons | Record<string, ConditionIcons> | undefined> =>
getCategoryIcons(hass, "conditions", domain, force);
getCategoryIcons(connection, hassConfig, "conditions", domain, force);
// Cache for sorted range keys
const sortedRangeCache = new WeakMap<Record<string, string>, number[]>();
@@ -578,7 +580,8 @@ export const attributeIcon = async (
};
export const triggerIcon = async (
hass: HomeAssistant,
connection: Connection,
hassConfig: HomeAssistant["config"],
trigger: string
): Promise<string | undefined> => {
let icon: string | undefined;
@@ -586,62 +589,69 @@ export const triggerIcon = async (
const domain = getTriggerDomain(trigger);
const triggerName = getTriggerObjectId(trigger);
const triggerIcons = await getTriggerIcons(hass, domain);
const triggerIcons = await getTriggerIcons(connection, hassConfig, domain);
if (triggerIcons) {
const trgrIcon = triggerIcons[triggerName] as TriggerIcons[string];
icon = trgrIcon?.trigger;
}
if (!icon) {
icon = await domainIcon(hass.connection, hass.config, domain);
icon = await domainIcon(connection, hassConfig, domain);
}
return icon;
};
export const conditionIcon = async (
hass: HomeAssistant,
connection: Connection,
hassConfig: HomeAssistant["config"],
condition: string
): Promise<string | undefined> => {
let icon: string | undefined;
const domain = getConditionDomain(condition);
const conditionIcons = await getConditionIcons(hass, domain);
const conditionIcons = await getConditionIcons(
connection,
hassConfig,
domain
);
if (conditionIcons) {
const conditionName = getConditionObjectId(condition);
const condIcon = conditionIcons[conditionName] as ConditionIcons[string];
icon = condIcon?.condition;
}
if (!icon) {
icon = await domainIcon(hass.connection, hass.config, domain);
icon = await domainIcon(connection, hassConfig, domain);
}
return icon;
};
export const serviceIcon = async (
hass: HomeAssistant,
connection: Connection,
hassConfig: HomeAssistant["config"],
service: string
): Promise<string | undefined> => {
let icon: string | undefined;
const domain = computeDomain(service);
const serviceName = computeObjectId(service);
const serviceIcons = await getServiceIcons(hass, domain);
const serviceIcons = await getServiceIcons(connection, hassConfig, domain);
if (serviceIcons) {
const srvceIcon = serviceIcons[serviceName] as ServiceIcons[string];
icon = srvceIcon?.service;
}
if (!icon) {
icon = await domainIcon(hass.connection, hass.config, domain);
icon = await domainIcon(connection, hassConfig, domain);
}
return icon;
};
export const serviceSectionIcon = async (
hass: HomeAssistant,
connection: Connection,
hassConfig: HomeAssistant["config"],
service: string,
section: string
): Promise<string | undefined> => {
const domain = computeDomain(service);
const serviceName = computeObjectId(service);
const serviceIcons = await getServiceIcons(hass, domain);
const serviceIcons = await getServiceIcons(connection, hassConfig, domain);
if (serviceIcons) {
const srvceIcon = serviceIcons[serviceName] as ServiceIcons[string];
return srvceIcon?.sections?.[section];

View File

@@ -4,6 +4,12 @@ export const SENSOR_DEVICE_CLASS_BATTERY = "battery";
export const SENSOR_DEVICE_CLASS_TIMESTAMP = "timestamp";
export const SENSOR_DEVICE_CLASS_TEMPERATURE = "temperature";
export const SENSOR_DEVICE_CLASS_HUMIDITY = "humidity";
export const SENSOR_DEVICE_CLASS_UPTIME = "uptime";
export const SENSOR_TIMESTAMP_DEVICE_CLASSES: (string | undefined)[] = [
"timestamp",
"uptime",
];
export interface SensorDeviceClassUnits {
units: string[];

View File

@@ -0,0 +1,157 @@
import { ContextConsumer, type Context } from "@lit/context";
import type { Connection, HassConfig } from "home-assistant-js-websocket";
import type { ReactiveController, ReactiveControllerHost } from "lit";
import { computeDomain } from "../common/entity/compute_domain";
import {
computeServiceLabel,
DEFAULT_SERVICE_INFO,
type ServiceInfo,
} from "./compute-service-info";
import {
configContext,
connectionContext,
internationalizationContext,
servicesContext,
} from "./context";
import {
DEFAULT_SERVICE_ICON,
FALLBACK_DOMAIN_ICONS,
serviceIcon,
} from "./icons";
import type {
HomeAssistant,
HomeAssistantInternationalization,
} from "../types";
type ServiceInfoHost = ReactiveControllerHost & HTMLElement;
/**
* Reactive controller that prepares display data for a service action
* (e.g. `light.turn_on`): loads service translations, resolves the localized
* service name, and resolves the service icon (with a synchronous domain
* fallback that upgrades once the full icon is loaded).
*
* Pulls connection, config, services, and i18n from Lit context, so the
* caller only needs to feed in the service ID via `updateService()`.
*/
export class ServiceInfoController implements ReactiveController {
private _host: ServiceInfoHost;
private _connection?: Connection;
private _config?: HassConfig;
private _services?: HomeAssistant["services"];
private _i18n?: HomeAssistantInternationalization;
private _service?: string;
private _resolvedService?: string;
private _resolvedLanguage?: string;
private _info: ServiceInfo = DEFAULT_SERVICE_INFO;
constructor(host: ServiceInfoHost) {
this._host = host;
host.addController(this);
this._consume(connectionContext, (value) => {
this._connection = value.connection;
});
this._consume(configContext, (value) => {
this._config = value.config;
});
this._consume(servicesContext, (value) => {
this._services = value;
});
this._consume(internationalizationContext, (value) => {
this._i18n = value;
});
}
private _consume<T>(
context: Context<unknown, T>,
assign: (value: T) => void
): void {
new ContextConsumer(this._host, {
context,
subscribe: true,
callback: (value) => {
assign(value);
this._resolve();
},
});
}
get info(): ServiceInfo {
return this._info;
}
hostConnected(): void {
this._resolve();
}
updateService(service: string | undefined): void {
if (service === this._service) return;
this._service = service;
this._resolve();
}
private _resolve(): void {
if (!this._connection || !this._config || !this._services || !this._i18n) {
return;
}
const service = this._service;
const language = this._i18n.language;
const serviceChanged = service !== this._resolvedService;
const languageChanged = language !== this._resolvedLanguage;
if (!serviceChanged && !languageChanged) return;
this._resolvedService = service;
this._resolvedLanguage = language;
if (!service) {
this._info = DEFAULT_SERVICE_INFO;
this._host.requestUpdate();
return;
}
const domain = computeDomain(service);
this._info = {
label: computeServiceLabel(this._i18n.localize, this._services, service),
iconPath: serviceChanged
? FALLBACK_DOMAIN_ICONS[domain] || DEFAULT_SERVICE_ICON
: this._info.iconPath,
icon: serviceChanged ? undefined : this._info.icon,
};
this._host.requestUpdate();
this._i18n.loadBackendTranslation("services", domain).then((localize) => {
if (
this._resolvedService !== service ||
this._resolvedLanguage !== language ||
!this._services
) {
return;
}
this._info = {
...this._info,
label: computeServiceLabel(localize, this._services, service),
};
this._host.requestUpdate();
});
if (serviceChanged) {
serviceIcon(this._connection, this._config, service).then((icon) => {
if (this._resolvedService !== service) return;
this._info = { ...this._info, icon };
this._host.requestUpdate();
});
}
}
}

View File

@@ -21,9 +21,12 @@ import { isTiltOnly } from "../../../data/cover";
import { isUnavailableState } from "../../../data/entity/entity";
import type { ImageEntity } from "../../../data/image";
import { computeImageUrl } from "../../../data/image";
import { SENSOR_DEVICE_CLASS_TIMESTAMP } from "../../../data/sensor";
import "../../../panels/lovelace/components/hui-timestamp-display";
import type { HomeAssistant } from "../../../types";
import {
SENSOR_DEVICE_CLASS_UPTIME,
SENSOR_TIMESTAMP_DEVICE_CLASSES,
} from "../../../data/sensor";
@customElement("entity-preview-row")
class EntityPreviewRow extends LitElement {
@@ -312,14 +315,19 @@ class EntityPreviewRow extends LitElement {
if (domain === "sensor") {
const showSensor =
stateObj.attributes.device_class === SENSOR_DEVICE_CLASS_TIMESTAMP &&
!isUnavailableState(stateObj.state);
SENSOR_TIMESTAMP_DEVICE_CLASSES.includes(
stateObj.attributes.device_class
) && !isUnavailableState(stateObj.state);
return html`
${showSensor
? html`
<hui-timestamp-display
.hass=${this.hass}
.ts=${new Date(stateObj.state)}
.format=${stateObj.attributes.device_class ===
SENSOR_DEVICE_CLASS_UPTIME
? "total"
: undefined}
capitalize
></hui-timestamp-display>
`

View File

@@ -2,10 +2,17 @@ import { mdiCogOutline, mdiTextureBox } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import {
getAreasFloorHierarchy,
type AreasFloorHierarchy,
} from "../../../../common/areas/areas-floor-hierarchy";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeAreaName } from "../../../../common/entity/compute_area_name";
import { computeFloorName } from "../../../../common/entity/compute_floor_name";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-floor-icon";
import "../../../../components/ha-icon";
import "../../../../components/ha-spinner";
import "../../../../components/ha-svg-icon";
@@ -17,13 +24,18 @@ import {
import type { HomeAssistant } from "../../../../types";
import { showVacuumSegmentMappingView } from "./show-view-vacuum-segment-mapping";
interface MappedSection {
floorId: string | null;
areaIds: string[];
}
@customElement("ha-more-info-view-vacuum-clean-areas")
export class HaMoreInfoViewVacuumCleanAreas extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public params!: { entityId: string };
@state() private _mappedAreaIds?: string[];
@state() private _mappedHierarchy?: AreasFloorHierarchy;
@state() private _selectedAreaIds: string[] = [];
@@ -47,8 +59,13 @@ export class HaMoreInfoViewVacuumCleanAreas extends LitElement {
await getExtendedEntityRegistryEntry(this.hass, this.params.entityId);
const areaMapping = entry?.options?.vacuum?.area_mapping || {};
this._mappedAreaIds = Object.keys(areaMapping).filter(
(areaId) => this.hass.areas[areaId]
const mappedAreaIds = new Set(Object.keys(areaMapping));
const mappedAreas = Object.values(this.hass.areas).filter((area) =>
mappedAreaIds.has(area.area_id)
);
this._mappedHierarchy = getAreasFloorHierarchy(
Object.values(this.hass.floors),
mappedAreas
);
} catch (err: any) {
this._error = err.message || "Failed to load areas";
@@ -95,6 +112,42 @@ export class HaMoreInfoViewVacuumCleanAreas extends LitElement {
);
}
private _getMappedSections(): MappedSection[] {
if (!this._mappedHierarchy) return [];
const sections: MappedSection[] = this._mappedHierarchy.floors
.filter((floor) => floor.areas.length > 0)
.map((floor) => ({ floorId: floor.id, areaIds: floor.areas }));
if (this._mappedHierarchy.areas.length > 0) {
sections.push({ floorId: null, areaIds: this._mappedHierarchy.areas });
}
return sections;
}
private _renderSection(section: MappedSection, showLabel: boolean) {
const floor = section.floorId ? this.hass.floors[section.floorId] : null;
const label = showLabel
? floor
? computeFloorName(floor)
: this.hass.localize("ui.dialogs.more_info_control.vacuum.other_areas")
: null;
return html`
<div class="section">
${label
? html`<div class="section-header">
${floor
? html`<ha-floor-icon .floor=${floor}></ha-floor-icon>`
: nothing}
<span class="section-name">${label}</span>
</div>`
: nothing}
<div class="area-grid">
${section.areaIds.map((areaId) => this._renderAreaCard(areaId))}
</div>
</div>
`;
}
private _renderAreaCard(areaId: string) {
const area: AreaRegistryEntry | undefined = this.hass.areas[areaId];
if (!area) return nothing;
@@ -116,7 +169,7 @@ export class HaMoreInfoViewVacuumCleanAreas extends LitElement {
? html`<ha-icon .icon=${area.icon}></ha-icon>`
: html`<ha-svg-icon .path=${mdiTextureBox}></ha-svg-icon>`}
</div>
<div class="area-name">${area.name}</div>
<div class="area-name">${computeAreaName(area)}</div>
</div>
`;
}
@@ -138,7 +191,9 @@ export class HaMoreInfoViewVacuumCleanAreas extends LitElement {
`;
}
if (!this._mappedAreaIds || this._mappedAreaIds.length === 0) {
const sections = this._getMappedSections();
if (sections.length === 0) {
return html`
<div class="content empty-content">
<div class="empty">
@@ -177,11 +232,13 @@ export class HaMoreInfoViewVacuumCleanAreas extends LitElement {
`;
}
const showFloorLabels = sections.length > 1;
return html`
<div class="content">
<div class="area-grid">
${this._mappedAreaIds.map((areaId) => this._renderAreaCard(areaId))}
</div>
${sections.map((section) =>
this._renderSection(section, showFloorLabels)
)}
<p class="hint">
${this.hass.localize(
"ui.dialogs.more_info_control.vacuum.clean_areas_order_hint"
@@ -223,6 +280,9 @@ export class HaMoreInfoViewVacuumCleanAreas extends LitElement {
flex: 1;
overflow-y: auto;
padding: var(--ha-space-4);
display: flex;
flex-direction: column;
gap: var(--ha-space-4);
}
.empty-content {
@@ -257,6 +317,20 @@ export class HaMoreInfoViewVacuumCleanAreas extends LitElement {
margin: 0 0 var(--ha-space-4);
}
.section-header {
display: flex;
align-items: center;
gap: var(--ha-space-2);
margin-bottom: var(--ha-space-2);
color: var(--secondary-text-color);
--mdc-icon-size: 20px;
}
.section-name {
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
}
.area-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
@@ -343,7 +417,7 @@ export class HaMoreInfoViewVacuumCleanAreas extends LitElement {
}
.hint {
margin: var(--ha-space-3) 0 0;
margin: 0;
text-align: center;
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);

View File

@@ -29,7 +29,6 @@ import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import type { HaIconButton } from "../../../components/ha-icon-button";
import "../../../components/ha-list-item";
import "../../../components/ha-marquee-text";
import "../../../components/ha-select";
@@ -507,7 +506,7 @@ class MoreInfoMediaPlayer extends LitElement {
<ha-icon-button
.id=${`media-control-row-button-${idSuffix}`}
hide-title
.action=${action}
action=${ifDefined(action)}
@click=${clickHandler}
.label=${title}
.path=${icon}
@@ -709,7 +708,7 @@ class MoreInfoMediaPlayer extends LitElement {
handleMediaControlClick(
this.hass!,
this.stateObj!,
(e.currentTarget as HaIconButton & { action: string }).action!
(e.currentTarget as HTMLElement).getAttribute("action")!
);
}

View File

@@ -374,6 +374,7 @@ export class HassTabsSubpage extends LitElement {
}
.main-title {
min-width: 0;
flex: 1;
max-height: var(--header-height);
line-height: var(--ha-line-height-normal);

View File

@@ -327,14 +327,14 @@ class DialogAddAutomationElement
if (this._params?.type === "action") {
this.hass.loadBackendTranslation("services");
getServiceIcons(this.hass);
getServiceIcons(this.hass.connection, this.hass.config);
} else if (this._params?.type === "trigger") {
this.hass.loadBackendTranslation("triggers");
getTriggerIcons(this.hass);
getTriggerIcons(this.hass.connection, this.hass.config);
this._subscribeDescriptions();
} else if (this._params?.type === "condition") {
this.hass.loadBackendTranslation("conditions");
getConditionIcons(this.hass);
getConditionIcons(this.hass.connection, this.hass.config);
this._subscribeDescriptions();
}

View File

@@ -29,6 +29,7 @@ import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { fireEvent } from "../../../common/dom/fire_event";
import { stopPropagation } from "../../../common/dom/stop_propagation";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { navigate } from "../../../common/navigate";
import type { LocalizeFunc } from "../../../common/translations/localize";
@@ -44,7 +45,6 @@ import type {
SortingChangedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/data-table/ha-data-table-labels";
import "../../../components/entity/ha-entity-toggle";
import "../../../components/ha-button";
import "../../../components/ha-checkbox";
import "../../../components/ha-dropdown";
@@ -63,6 +63,8 @@ import "../../../components/ha-filter-voice-assistants";
import "../../../components/ha-icon-button";
import "../../../components/ha-sub-menu";
import "../../../components/ha-svg-icon";
import "../../../components/ha-switch";
import type { HaSwitch } from "../../../components/ha-switch";
import "../../../components/ha-tooltip";
import { createAreaRegistryEntry } from "../../../data/area/area_registry";
import type { AutomationEntity } from "../../../data/automation";
@@ -336,10 +338,12 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
type: "overflow",
title: this.hass.localize("ui.panel.config.automation.picker.state"),
template: (automation) => html`
<ha-entity-toggle
.stateObj=${automation}
.hass=${this.hass}
></ha-entity-toggle>
<ha-switch
@click=${stopPropagation}
@change=${this._handleSwitchToggle}
.automation=${automation}
.checked=${automation.state === "on"}
></ha-switch>
`,
},
actions: {
@@ -974,6 +978,13 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
);
};
private _handleSwitchToggle = (ev: Event) => {
const automation = (
ev.currentTarget as HaSwitch & { automation: AutomationItem }
).automation;
this._toggle(automation);
};
private _toggle = async (automation: AutomationItem): Promise<void> => {
const service = automation.state === "off" ? "turn_on" : "turn_off";
await this.hass.callService("automation", service, {

View File

@@ -21,6 +21,7 @@ export const rowStyles = css`
gap: var(--ha-space-2);
padding: var(--ha-space-2) 0;
min-height: 32px;
max-width: 100%;
}
ha-card {

View File

@@ -213,6 +213,7 @@ export class HaAutomationRowTargets extends LitElement {
display: inline-flex;
align-items: flex-end;
gap: var(--ha-space-1);
max-width: 100%;
}
.target {
display: inline-flex;

View File

@@ -9,6 +9,7 @@ import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-code-editor";
import "../../../../components/ha-spinner";
import "../../../../components/ha-tip";
import type { RenderTemplateResult } from "../../../../data/ws-templates";
import { subscribeRenderTemplate } from "../../../../data/ws-templates";
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
@@ -158,6 +159,14 @@ class HaPanelDevTemplate extends LitElement {
${this.hass.localize("ui.common.clear")}
</ha-button>
</div>
<ha-tip .hass=${this.hass}>
${this.hass.localize(
"ui.panel.config.developer-tools.tabs.templates.keyboard_tip",
{
autocomplete: html`<kbd>Ctrl</kbd>+<kbd>Space</kbd>`,
}
)}
</ha-tip>
</ha-card>
<ha-card
@@ -361,6 +370,22 @@ ${type === "object"
color: var(--warning-color);
}
ha-tip {
padding: 0 var(--ha-space-4) var(--ha-space-4);
display: block;
}
kbd {
display: inline-block;
font-family: var(--ha-font-family-code);
font-size: 0.85em;
padding: 1px 5px;
border: 1px solid var(--divider-color);
border-radius: var(--ha-border-radius-xs);
background-color: var(--secondary-background-color);
white-space: nowrap;
}
@media all and (max-width: 870px) {
.content ha-card {
max-width: 100%;

View File

@@ -8,6 +8,7 @@ import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { deepActiveElement } from "../../../common/dom/deep-active-element";
import {
PROTOCOL_INTEGRATIONS,
protocolIntegrationPicked,
@@ -16,7 +17,6 @@ import { navigate } from "../../../common/navigate";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import { extractSearchParam } from "../../../common/url/search-params";
import { nextRender } from "../../../common/util/render-status";
import { deepActiveElement } from "../../../common/dom/deep-active-element";
import "../../../components/ha-button";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
@@ -1073,9 +1073,11 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
}
ha-input-search {
flex: 1;
min-width: 0;
}
.header {
display: flex;
min-width: 0;
}
.search {
display: flex;

View File

@@ -8,15 +8,18 @@ import "../../../../../components/ha-alert";
import "../../../../../components/ha-button";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-list-item";
import "../../../../../components/ha-markdown";
import "../../../../../components/ha-spinner";
import "../../../../../components/ha-dialog-footer";
import "../../../../../components/ha-dialog";
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
import {
fetchZwaveNodeMetadata,
fetchZwaveNodeStatus,
NodeStatus,
removeFailedZwaveNode,
} from "../../../../../data/zwave_js";
import type { ZwaveJSNodeMetadata } from "../../../../../data/zwave_js";
import { haStyleDialog } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import type { ZWaveJSRemoveNodeDialogParams } from "./show-dialog-zwave_js-remove-node";
@@ -51,6 +54,8 @@ class DialogZWaveJSRemoveNode extends LitElement {
@state() private _node?: ZWaveJSRemovedNode;
@state() private _metadata?: ZwaveJSNodeMetadata;
@state() private _onClose?: () => void;
private _removeNodeTimeoutHandle?: number;
@@ -72,12 +77,26 @@ class DialogZWaveJSRemoveNode extends LitElement {
this._entryId = params.entryId;
this._deviceId = params.deviceId;
this._onClose = params.onClose;
this._metadata = undefined;
this._open = true;
if (this._deviceId) {
const nodeStatus = await fetchZwaveNodeStatus(this.hass, this._deviceId!);
this._device = this.hass.devices[this._deviceId];
this._step =
nodeStatus.status === NodeStatus.Dead ? "start_removal" : "start";
if (nodeStatus.status !== NodeStatus.Dead) {
const requestedDeviceId = this._deviceId;
fetchZwaveNodeMetadata(this.hass, requestedDeviceId).then(
(metadata) => {
if (this._deviceId === requestedDeviceId) {
this._metadata = metadata;
}
},
() => {
// instructions are supplemental — ignore fetch errors
}
);
}
} else if (params.skipConfirmation) {
this._startExclusion();
} else {
@@ -170,13 +189,39 @@ class DialogZWaveJSRemoveNode extends LitElement {
`;
}
if (["exclusion", "remove"].includes(this._step)) {
if (this._step === "exclusion") {
const exclusion = this._metadata?.exclusion?.trim();
return html`
<ha-spinner></ha-spinner>
<div>
<p>
<b
>${this.hass.localize(
"ui.panel.config.zwave_js.remove_node.ready_to_remove"
)}</b
>
${this.hass.localize(
"ui.panel.config.zwave_js.remove_node.trigger_device_exclusion"
)}
</p>
${exclusion
? html`<ha-markdown breaks .content=${exclusion}></ha-markdown>`
: html`<p>
${this.hass.localize(
"ui.panel.config.zwave_js.remove_node.follow_device_instructions"
)}
</p>`}
</div>
`;
}
if (this._step === "remove") {
return html`
<ha-spinner></ha-spinner>
<div>
<p>
${this.hass.localize(
`ui.panel.config.zwave_js.remove_node.${this._step === "exclusion" ? "follow_device_instructions" : "removing_device"}`
"ui.panel.config.zwave_js.remove_node.removing_device"
)}
</p>
</div>
@@ -343,6 +388,7 @@ class DialogZWaveJSRemoveNode extends LitElement {
public handleDialogClosed(): void {
this._unsubscribe();
this._entryId = undefined;
this._metadata = undefined;
this._step = "start";
this._open = false;
if (this._onClose) {
@@ -371,6 +417,10 @@ class DialogZWaveJSRemoveNode extends LitElement {
color: var(--secondary-text-color);
}
.content ha-markdown {
color: var(--secondary-text-color);
}
ha-alert {
width: 100%;
}

View File

@@ -7,6 +7,7 @@ import "../../../components/ha-icon";
import "../../../components/ha-svg-icon";
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import { NavigationPathInfoController } from "../../../data/navigation-path-controller";
import { ServiceInfoController } from "../../../data/service-info-controller";
import type { HomeAssistant } from "../../../types";
import { getShortcutCardDefaults } from "../cards/hui-shortcut-card-defaults";
import { actionHandler } from "../common/directives/action-handler-directive";
@@ -38,6 +39,8 @@ export class HuiShortcutBadge extends LitElement implements LovelaceBadge {
private _navInfo = new NavigationPathInfoController(this);
private _serviceInfo = new ServiceInfoController(this);
public setConfig(config: ShortcutBadgeConfig): void {
this._config = {
tap_action: {
@@ -57,6 +60,11 @@ export class HuiShortcutBadge extends LitElement implements LovelaceBadge {
this.hass,
action?.action === "navigate" ? action.navigation_path : undefined
);
this._serviceInfo.updateService(
action?.action === "perform-action" || action?.action === "call-service"
? action.perform_action || action.service
: undefined
);
}
}
@@ -80,7 +88,8 @@ export class HuiShortcutBadge extends LitElement implements LovelaceBadge {
const defaults = getShortcutCardDefaults(
this.hass,
this._config.tap_action,
this._navInfo.info
this._navInfo.info,
this._serviceInfo.info
);
const text = (this._config.text || defaults.label).trim();
const icon = this._config.icon || defaults.icon;

View File

@@ -1,469 +0,0 @@
import { consume } from "@lit/context";
import type {
Connection,
HassEntity,
UnsubscribeFunc,
} from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing, svg } from "lit";
import { customElement, property, state } from "lit/decorators";
import { computeCssColor } from "../../../common/color/compute-color";
import { consumeEntityState } from "../../../common/decorators/consume-context-entry";
import { transform } from "../../../common/decorators/transform";
import { computeDomain } from "../../../common/entity/compute_domain";
import { supportsFeature } from "../../../common/entity/supports-feature";
import { slugify } from "../../../common/string/slugify";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-spinner";
import {
connectionContext,
internationalizationContext,
} from "../../../data/context";
import type { ForecastAttribute, ForecastEvent } from "../../../data/weather";
import {
getForecastPrecipitation,
subscribeForecast,
WeatherEntityFeature,
} from "../../../data/weather";
import type {
HomeAssistantConnection,
HomeAssistantInternationalization,
} from "../../../types";
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
import type {
DailyForecastCardFeatureConfig,
LovelaceCardFeatureContext,
} from "./types";
export const DEFAULT_DAYS_TO_SHOW = 7;
const MAX_BAR_WIDTH = 8;
export type DailyForecastType = "daily" | "twice_daily";
export const supportsDailyForecastCardFeature = (
stateObj: HassEntity | undefined
) => {
if (!stateObj) return false;
const domain = computeDomain(stateObj.entity_id);
return (
domain === "weather" &&
(supportsFeature(stateObj, WeatherEntityFeature.FORECAST_DAILY) ||
supportsFeature(stateObj, WeatherEntityFeature.FORECAST_TWICE_DAILY))
);
};
export const resolveDailyForecastType = (
stateObj: HassEntity | undefined,
configured?: DailyForecastType
): DailyForecastType | undefined => {
if (!stateObj) return undefined;
const supportsDaily = supportsFeature(
stateObj,
WeatherEntityFeature.FORECAST_DAILY
);
const supportsTwiceDaily = supportsFeature(
stateObj,
WeatherEntityFeature.FORECAST_TWICE_DAILY
);
if (configured === "daily" && supportsDaily) return "daily";
if (configured === "twice_daily" && supportsTwiceDaily) return "twice_daily";
if (supportsDaily) return "daily";
if (supportsTwiceDaily) return "twice_daily";
return undefined;
};
@customElement("hui-daily-forecast-card-feature")
class HuiDailyForecastCardFeature
extends LitElement
implements LovelaceCardFeature
{
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state()
@consumeEntityState({ entityIdPath: ["context", "entity_id"] })
private _stateObj?: HassEntity;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, LocalizeFunc>({
transformer: ({ localize }) => localize,
})
private _localize!: LocalizeFunc;
@state()
@consume({ context: connectionContext, subscribe: true })
@transform<HomeAssistantConnection, Connection>({
transformer: ({ connection }) => connection,
})
private _connection!: Connection;
@state() private _config?: DailyForecastCardFeatureConfig;
@state() private _forecast?: ForecastAttribute[];
@state() private _error?: string;
private _subscribed?: Promise<UnsubscribeFunc | undefined>;
private _subscribedType?: DailyForecastType;
static getStubConfig(): DailyForecastCardFeatureConfig {
return {
type: "daily-forecast",
};
}
public static async getConfigElement(): Promise<LovelaceCardFeatureEditor> {
await import("../editor/config-elements/hui-daily-forecast-card-feature-editor");
return document.createElement(
"hui-daily-forecast-card-feature-editor"
) as LovelaceCardFeatureEditor;
}
public setConfig(config: DailyForecastCardFeatureConfig): void {
if (!config) {
throw new Error("Invalid configuration");
}
this._config = config;
}
public connectedCallback() {
super.connectedCallback();
if (this.hasUpdated) {
this._subscribeForecast();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribeForecast();
}
protected firstUpdated() {
this._subscribeForecast();
}
protected updated(changedProps: PropertyValues) {
const resolvedType = this._resolvedForecastType();
const contextChanged =
changedProps.has("context") &&
(changedProps.get("context") as LovelaceCardFeatureContext | undefined)
?.entity_id !== this.context?.entity_id;
const configTypeChanged =
changedProps.has("_config") && resolvedType !== this._subscribedType;
if (contextChanged || configTypeChanged) {
this._unsubscribeForecast();
this._subscribeForecast();
}
}
private _resolvedForecastType(): DailyForecastType | undefined {
return resolveDailyForecastType(
this._stateObj,
this._config?.forecast_type
);
}
protected render() {
if (
!this._config ||
!this.context ||
!supportsDailyForecastCardFeature(this._stateObj)
) {
return nothing;
}
if (this._error) {
return html`
<div class="container">
<div class="info">${this._error}</div>
</div>
`;
}
if (!this._forecast) {
return html`
<div class="container loading">
<ha-spinner size="small"></ha-spinner>
</div>
`;
}
const showTemperature = this._config.show_temperature ?? true;
const showPrecipitation = this._config.show_precipitation ?? false;
const daysToShow = this._config.days_to_show ?? DEFAULT_DAYS_TO_SHOW;
const entriesPerDay = this._subscribedType === "twice_daily" ? 2 : 1;
const entries = this._forecast
.filter((entry) => {
if (showTemperature) {
return (
Number.isFinite(entry.temperature) && Number.isFinite(entry.templow)
);
}
return showPrecipitation;
})
.slice(0, daysToShow * entriesPerDay);
if (!entries.length) {
return html`
<div class="container">
<div class="info">
${this._localize(
"ui.panel.lovelace.editor.features.types.daily-forecast.no_forecast"
)}
</div>
</div>
`;
}
return html` <div class="container">${this._renderChart(entries)}</div> `;
}
private _renderChart(entries: ForecastAttribute[]): TemplateResult {
const width = this.clientWidth || 300;
const height = this.clientHeight || 42;
const padding = 4;
const minGap = 4;
const slotWidth = width / entries.length;
const barWidth = Math.max(1, Math.min(MAX_BAR_WIDTH, slotWidth - minGap));
const drawableHeight = height - padding * 2;
const showTemperature = this._config!.show_temperature ?? true;
const showCurrentTemperature =
this._config!.show_current_temperature ?? true;
const showPrecipitation = this._config!.show_precipitation ?? false;
const precipitationType = this._config!.precipitation_type ?? "amount";
const customColor = this._config!.color
? computeCssColor(this._config!.color)
: undefined;
const currentTemp = Number(this._stateObj?.attributes?.temperature);
const hasCurrentTemp = Number.isFinite(currentTemp);
let yFor: ((value: number) => number) | undefined;
if (showTemperature) {
let tempMin = Infinity;
let tempMax = -Infinity;
for (const entry of entries) {
if (
Number.isFinite(entry.templow) &&
Number.isFinite(entry.temperature)
) {
tempMin = Math.min(tempMin, entry.templow!);
tempMax = Math.max(tempMax, entry.temperature);
}
}
if (hasCurrentTemp) {
tempMin = Math.min(tempMin, currentTemp);
tempMax = Math.max(tempMax, currentTemp);
}
if (tempMin === tempMax) {
tempMin -= 1;
tempMax += 1;
}
yFor = (value: number) =>
padding +
drawableHeight -
((value - tempMin) / (tempMax - tempMin)) * drawableHeight;
}
let maxPrecipitation = 0;
if (showPrecipitation) {
if (precipitationType === "probability") {
maxPrecipitation = 100;
} else {
for (const entry of entries) {
const value = getForecastPrecipitation(entry, precipitationType);
if (Number.isFinite(value)) {
maxPrecipitation = Math.max(maxPrecipitation, value!);
}
}
}
}
const rainBarWidth = Math.max(
barWidth,
Math.min(barWidth + 8, slotWidth - 2)
);
const precipitationBars =
showPrecipitation && maxPrecipitation > 0
? entries.map((entry, i) => {
const value = getForecastPrecipitation(entry, precipitationType);
if (!Number.isFinite(value) || value! <= 0) {
return nothing;
}
const x = slotWidth * i + (slotWidth - rainBarWidth) / 2;
const barHeight = Math.max(
1,
(value! / maxPrecipitation) * drawableHeight
);
const y = padding + drawableHeight - barHeight;
return svg`<rect
x=${x}
y=${y}
width=${rainBarWidth}
height=${barHeight}
fill="var(--state-weather-rainy-color)"
opacity="0.4"
></rect>`;
})
: nothing;
const bars =
showTemperature && yFor
? entries.map((entry, i) => {
if (
!Number.isFinite(entry.temperature) ||
!Number.isFinite(entry.templow)
) {
return nothing;
}
const x = slotWidth * i + (slotWidth - barWidth) / 2;
const yHigh = yFor(entry.temperature);
const yLow = yFor(entry.templow!);
const barHeight = Math.max(1, yLow - yHigh);
const rx = Math.min(barWidth / 2, barHeight / 2);
const fill =
customColor ??
(entry.condition
? `var(--state-weather-${slugify(entry.condition, "_")}-color, var(--feature-color))`
: "var(--feature-color)");
return svg`<rect
x=${x}
y=${yHigh}
width=${barWidth}
height=${barHeight}
rx=${rx}
ry=${rx}
fill=${fill}
></rect>`;
})
: nothing;
const currentTempLine =
showTemperature && showCurrentTemperature && yFor && hasCurrentTemp
? svg`<line
x1="0"
x2=${width}
y1=${yFor(currentTemp)}
y2=${yFor(currentTemp)}
stroke=${customColor ?? "var(--feature-color)"}
stroke-width="1"
stroke-opacity="0.5"
vector-effect="non-scaling-stroke"
></line>`
: nothing;
const dotRadius = 1.5;
const dots = !showTemperature
? entries.map((entry, i) => {
const value = getForecastPrecipitation(entry, precipitationType);
if (Number.isFinite(value) && value! > 0) {
return nothing;
}
const cx = slotWidth * i + slotWidth / 2;
const cy = padding + drawableHeight - dotRadius;
return svg`<circle
cx=${cx}
cy=${cy}
r=${dotRadius}
fill="var(--state-weather-rainy-color)"
opacity="0.4"
></circle>`;
})
: nothing;
return html`
<svg
width="100%"
height="100%"
viewBox="0 0 ${width} ${height}"
preserveAspectRatio="none"
>
${dots}${precipitationBars}${bars}${currentTempLine}
</svg>
`;
}
private _unsubscribeForecast() {
if (this._subscribed) {
this._subscribed.then((unsub) => unsub?.()).catch(() => undefined);
this._subscribed = undefined;
}
this._subscribedType = undefined;
}
private async _subscribeForecast() {
if (
!this.context?.entity_id ||
!this._config ||
!this._connection ||
this._subscribed
) {
return;
}
const forecastType = this._resolvedForecastType();
if (!forecastType) {
return;
}
const entityId = this.context.entity_id;
this._forecast = undefined;
this._error = undefined;
this._subscribedType = forecastType;
this._subscribed = subscribeForecast(
this._connection,
entityId,
forecastType,
(forecastEvent: ForecastEvent) => {
this._forecast = forecastEvent.forecast ?? [];
}
).catch((err) => {
this._subscribed = undefined;
this._subscribedType = undefined;
this._error = err.message || err.code;
return undefined;
});
}
static styles = css`
:host {
display: flex;
width: 100%;
height: var(--feature-height);
flex-direction: column;
justify-content: flex-end;
align-items: stretch;
pointer-events: none !important;
}
.container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
border-bottom-right-radius: 8px;
border-bottom-left-radius: 8px;
overflow: hidden;
}
.info {
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
}
svg {
display: block;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-daily-forecast-card-feature": HuiDailyForecastCardFeature;
}
}

View File

@@ -1,436 +0,0 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing, svg } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { computeCssColor } from "../../../common/color/compute-color";
import { computeDomain } from "../../../common/entity/compute_domain";
import { supportsFeature } from "../../../common/entity/supports-feature";
import "../../../components/ha-spinner";
import type { ForecastAttribute, ForecastEvent } from "../../../data/weather";
import {
getForecastPrecipitation,
subscribeForecast,
WeatherEntityFeature,
} from "../../../data/weather";
import type { HomeAssistant } from "../../../types";
import { coordinates } from "../common/graph/coordinates";
import "../components/hui-graph-base";
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
import type {
HourlyForecastCardFeatureConfig,
LovelaceCardFeatureContext,
} from "./types";
export const DEFAULT_HOURS_TO_SHOW = 24;
const MS_PER_HOUR = 60 * 60 * 1000;
const MAX_RAIN_BAR_WIDTH = 16;
export const supportsHourlyForecastCardFeature = (
hass: HomeAssistant,
context: LovelaceCardFeatureContext
) => {
const stateObj = context.entity_id
? hass.states[context.entity_id]
: undefined;
if (!stateObj) return false;
const domain = computeDomain(stateObj.entity_id);
return (
domain === "weather" &&
supportsFeature(stateObj, WeatherEntityFeature.FORECAST_HOURLY)
);
};
@customElement("hui-hourly-forecast-card-feature")
class HuiHourlyForecastCardFeature
extends LitElement
implements LovelaceCardFeature
{
@property({ attribute: false, hasChanged: () => false })
public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state() private _config?: HourlyForecastCardFeatureConfig;
@state() private _forecast?: ForecastAttribute[];
@state() private _coordinates?: [number, number][];
@state() private _yAxisOrigin?: number;
@state() private _error?: string;
private _subscribed?: Promise<UnsubscribeFunc | undefined>;
static getStubConfig(): HourlyForecastCardFeatureConfig {
return {
type: "hourly-forecast",
};
}
public static async getConfigElement(): Promise<LovelaceCardFeatureEditor> {
await import("../editor/config-elements/hui-hourly-forecast-card-feature-editor");
return document.createElement(
"hui-hourly-forecast-card-feature-editor"
) as LovelaceCardFeatureEditor;
}
public setConfig(config: HourlyForecastCardFeatureConfig): void {
if (!config) {
throw new Error("Invalid configuration");
}
this._config = config;
}
public connectedCallback() {
super.connectedCallback();
if (this.hasUpdated) {
this._subscribeForecast();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribeForecast();
}
protected firstUpdated() {
this._subscribeForecast();
}
protected updated(changedProps: PropertyValues<this>) {
if (changedProps.has("context")) {
const oldContext = changedProps.get("context") as
| LovelaceCardFeatureContext
| undefined;
if (oldContext?.entity_id !== this.context?.entity_id) {
this._unsubscribeForecast();
this._subscribeForecast();
}
}
}
protected render() {
if (
!this._config ||
!this.hass ||
!this.context ||
!supportsHourlyForecastCardFeature(this.hass, this.context)
) {
return nothing;
}
if (this._error) {
return html`
<div class="container">
<div class="info">${this._error}</div>
</div>
`;
}
if (!this._forecast || !this._coordinates) {
return html`
<div class="container loading">
<ha-spinner size="small"></ha-spinner>
</div>
`;
}
const showTemperature = this._config.show_temperature ?? true;
const showPrecipitation = this._config.show_precipitation ?? false;
const showDots = !showTemperature && showPrecipitation;
const layer =
showPrecipitation || showDots
? this._renderForecastLayer(showPrecipitation, showDots)
: nothing;
const hasGraphData = this._coordinates.length > 0;
const showGraph = showTemperature && hasGraphData;
if (!showGraph && layer === nothing) {
return html`
<div class="container">
<div class="info">
${this.hass.localize(
"ui.panel.lovelace.editor.features.types.hourly-forecast.no_forecast"
)}
</div>
</div>
`;
}
const customColor = this._config.color
? computeCssColor(this._config.color)
: undefined;
const graphStyle = customColor
? styleMap({ "--feature-color": customColor })
: nothing;
return html`
<div class="layers">
${layer}
${showGraph
? html`
<hui-graph-base
.coordinates=${this._coordinates}
.yAxisOrigin=${this._yAxisOrigin}
style=${graphStyle}
></hui-graph-base>
`
: nothing}
</div>
`;
}
private _renderForecastLayer(showRain: boolean, showDots: boolean) {
if (!this._forecast?.length) {
return nothing;
}
const width = this.clientWidth || 300;
const height = this.clientHeight || 42;
// No bottom padding so bars and dots line up with the line graph baseline.
const topPadding = 4;
const drawableHeight = height - topPadding;
const now = Date.now();
const hoursToShow = this._config!.hours_to_show ?? DEFAULT_HOURS_TO_SHOW;
const maxTime =
Math.floor((now + hoursToShow * MS_PER_HOUR) / MS_PER_HOUR) * MS_PER_HOUR;
const timeRange = maxTime - now;
if (timeRange <= 0) {
return nothing;
}
const precipitationType = this._config!.precipitation_type ?? "amount";
const inRange: { entry: ForecastAttribute; t: number }[] = [];
for (const entry of this._forecast) {
const t = new Date(entry.datetime).getTime();
if (t >= now && t <= maxTime) {
inRange.push({ entry, t });
}
}
if (!inRange.length) {
return nothing;
}
const rainRects: TemplateResult[] = [];
if (showRain) {
const rainEntries = inRange.filter(({ entry }) => {
const value = getForecastPrecipitation(entry, precipitationType);
return Number.isFinite(value) && value! > 0;
});
let maxPrecipitation = 0;
if (precipitationType === "probability") {
maxPrecipitation = 100;
} else {
for (const { entry } of rainEntries) {
maxPrecipitation = Math.max(
maxPrecipitation,
getForecastPrecipitation(entry, precipitationType)!
);
}
}
if (maxPrecipitation > 0 && rainEntries.length) {
const slotWidth = width / hoursToShow;
const barWidth = Math.max(
1,
Math.min(MAX_RAIN_BAR_WIDTH, slotWidth - 2)
);
for (const { entry, t } of rainEntries) {
const value = getForecastPrecipitation(entry, precipitationType)!;
const xCenter = ((t - now) / timeRange) * width;
const x = xCenter - barWidth / 2;
const barHeight = Math.max(
1,
(value / maxPrecipitation) * drawableHeight
);
const y = height - barHeight;
rainRects.push(svg`<rect
x=${x}
y=${y}
width=${barWidth}
height=${barHeight}
fill="var(--state-weather-rainy-color)"
opacity="0.4"
></rect>`);
}
}
}
const dots: TemplateResult[] = [];
if (showDots) {
const dotRadius = 1.5;
const cy = height - dotRadius;
for (const { entry, t } of inRange) {
const value = getForecastPrecipitation(entry, precipitationType);
if (Number.isFinite(value) && value! > 0) {
continue;
}
const cx = ((t - now) / timeRange) * width;
dots.push(svg`<circle
cx=${cx}
cy=${cy}
r=${dotRadius}
fill="var(--state-weather-rainy-color)"
opacity="0.4"
></circle>`);
}
}
if (!rainRects.length && !dots.length) {
return nothing;
}
return html`
<svg
class="rain"
width="100%"
height="100%"
viewBox="0 0 ${width} ${height}"
preserveAspectRatio="none"
>
${dots}${rainRects}
</svg>
`;
}
private _unsubscribeForecast() {
if (this._subscribed) {
this._subscribed.then((unsub) => unsub?.()).catch(() => undefined);
this._subscribed = undefined;
}
}
private _computeCoordinates(forecastEvent: ForecastEvent) {
const entityId = this.context!.entity_id!;
const stateObj = this.hass!.states[entityId];
if (!forecastEvent.forecast?.length) {
this._coordinates = [];
return;
}
const data: [number, number][] = [];
const now = Date.now();
const hoursToShow = this._config!.hours_to_show ?? DEFAULT_HOURS_TO_SHOW;
// Round down to the nearest hour so the axis aligns with forecast data points
const maxTime =
Math.floor((now + hoursToShow * MS_PER_HOUR) / MS_PER_HOUR) * MS_PER_HOUR;
// Start with current temperature
const currentTemp = stateObj?.attributes?.temperature;
if (currentTemp != null && !Number.isNaN(Number(currentTemp))) {
data.push([now, Number(currentTemp)]);
}
// Add forecast data points for the next 24 hours
for (const entry of forecastEvent.forecast) {
if (entry.temperature != null && !Number.isNaN(entry.temperature)) {
const time = new Date(entry.datetime).getTime();
if (time > maxTime) break;
if (time < now) continue;
data.push([time, entry.temperature]);
}
}
if (!data.length) {
this._coordinates = [];
return;
}
const { points, yAxisOrigin } = coordinates(
data,
this.clientWidth,
this.clientHeight,
data.length,
{ minX: now, maxX: maxTime }
);
// Remove the trailing flat extension point added by calcPoints
points.pop();
this._coordinates = points;
this._yAxisOrigin = yAxisOrigin;
}
private async _subscribeForecast() {
if (
!this.context?.entity_id ||
!this._config ||
!this.hass ||
this._subscribed
) {
return;
}
const entityId = this.context.entity_id;
this._subscribed = subscribeForecast(
this.hass.connection,
entityId,
"hourly",
(forecastEvent) => {
this._forecast = forecastEvent.forecast ?? [];
this._computeCoordinates(forecastEvent);
}
).catch((err) => {
this._subscribed = undefined;
this._error = err.message || err.code;
return undefined;
});
}
static styles = css`
:host {
display: flex;
width: 100%;
height: var(--feature-height);
flex-direction: column;
justify-content: flex-end;
align-items: stretch;
pointer-events: none !important;
}
.container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.info {
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
}
.layers {
position: relative;
width: 100%;
height: 100%;
border-bottom-right-radius: 8px;
border-bottom-left-radius: 8px;
overflow: hidden;
}
.rain {
position: absolute;
inset: 0;
display: block;
}
hui-graph-base {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
--accent-color: var(--feature-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-hourly-forecast-card-feature": HuiHourlyForecastCardFeature;
}
}

View File

@@ -1,9 +1,6 @@
import type { AlarmMode } from "../../../data/alarm_control_panel";
import type { HvacMode } from "../../../data/climate";
import type { OperationMode } from "../../../data/water_heater";
import type { ForecastPrecipitationType } from "../../../data/weather";
export type { ForecastPrecipitationType };
export type ButtonCardData = Record<string, any>;
@@ -244,26 +241,6 @@ export interface TrendGraphCardFeatureConfig {
detail?: boolean;
}
export interface HourlyForecastCardFeatureConfig {
type: "hourly-forecast";
hours_to_show?: number;
show_temperature?: boolean;
show_precipitation?: boolean;
precipitation_type?: ForecastPrecipitationType;
color?: string;
}
export interface DailyForecastCardFeatureConfig {
type: "daily-forecast";
forecast_type?: "daily" | "twice_daily";
days_to_show?: number;
show_temperature?: boolean;
show_current_temperature?: boolean;
show_precipitation?: boolean;
precipitation_type?: ForecastPrecipitationType;
color?: string;
}
export const AREA_CONTROL_DOMAINS = [
"light",
"fan",
@@ -318,8 +295,6 @@ export type LovelaceCardFeatureConfig =
| FanPresetModesCardFeatureConfig
| FanSpeedCardFeatureConfig
| TrendGraphCardFeatureConfig
| HourlyForecastCardFeatureConfig
| DailyForecastCardFeatureConfig
| HumidifierToggleCardFeatureConfig
| HumidifierModesCardFeatureConfig
| LawnMowerCommandsCardFeatureConfig

View File

@@ -15,7 +15,10 @@ import type {
CallServiceActionConfig,
MoreInfoActionConfig,
} from "../../../data/lovelace/config/action";
import { SENSOR_DEVICE_CLASS_TIMESTAMP } from "../../../data/sensor";
import {
SENSOR_DEVICE_CLASS_UPTIME,
SENSOR_TIMESTAMP_DEVICE_CLASSES,
} from "../../../data/sensor";
import type { HomeAssistant } from "../../../types";
import { actionHandler } from "../common/directives/action-handler-directive";
import { findEntities } from "../common/find-entities";
@@ -287,14 +290,19 @@ export class HuiGlanceCard extends LitElement implements LovelaceCard {
? html`
<div>
${computeDomain(entityConf.entity) === "sensor" &&
stateObj.attributes.device_class ===
SENSOR_DEVICE_CLASS_TIMESTAMP &&
SENSOR_TIMESTAMP_DEVICE_CLASSES.includes(
stateObj.attributes.device_class
) &&
!isUnavailableState(stateObj.state)
? html`
<hui-timestamp-display
.hass=${this.hass}
.ts=${new Date(stateObj.state)}
.format=${entityConf.format}
.format=${entityConf.format ??
(stateObj.attributes.device_class ===
SENSOR_DEVICE_CLASS_UPTIME
? "total"
: undefined)}
capitalize
></hui-timestamp-display>
`

View File

@@ -35,7 +35,10 @@ import {
HOME_SUMMARIES_ICONS,
type HomeSummary,
} from "../strategies/home/helpers/home-summaries";
import { filterNeedsAttentionEntities } from "../../maintenance/strategies/maintenance-view-strategy";
import {
filterLowBatteryEntities,
filterUnavailableBatteryEntities,
} from "../../maintenance/strategies/maintenance-view-strategy";
import type { LovelaceCard, LovelaceGridOptions } from "../types";
import { tileCardStyle } from "./tile/tile-card-style";
import type { HomeSummaryCard } from "./types";
@@ -258,19 +261,48 @@ export class HuiHomeSummaryCard
maintenanceFilters
);
const needsAttentionEntities = filterNeedsAttentionEntities(
const lowBatteryEntities = filterLowBatteryEntities(
this.hass!,
maintenanceEntities
);
if (needsAttentionEntities.length > 0) {
return this.hass.localize(
"ui.card.home-summary.count_maintenance_issues",
{
count: needsAttentionEntities.length,
}
);
const unavailableBatteryEntities = filterUnavailableBatteryEntities(
this.hass!,
maintenanceEntities
);
const lowBatteryText =
lowBatteryEntities.length > 0
? this.hass.localize(
"ui.card.home-summary.count_maintenance_low_battery_issues",
{
count: lowBatteryEntities.length,
}
)
: undefined;
const unavailableText =
unavailableBatteryEntities.length > 0
? this.hass.localize(
"ui.card.home-summary.count_maintenance_issues_unavailable_battery_entities",
{
count: unavailableBatteryEntities.length,
}
)
: undefined;
if (lowBatteryText && unavailableText) {
return `${lowBatteryText}, ${unavailableText}`;
}
if (lowBatteryText) {
return lowBatteryText;
}
if (unavailableText) {
return unavailableText;
}
return this.hass.localize("ui.card.home-summary.all_maintenance_good");
}
case "energy": {

View File

@@ -1,6 +1,7 @@
import { mdiLink, mdiMicrophone, mdiOpenInNew, mdiRoomService } from "@mdi/js";
import { mdiLink, mdiMicrophone, mdiOpenInNew } from "@mdi/js";
import type { NavigationPathInfo } from "../../../data/compute-navigation-path-info";
import type { ActionConfig } from "../../../data/lovelace/config/action";
import type { ServiceInfo } from "../../../data/compute-service-info";
import type { HomeAssistant } from "../../../types";
const DEFAULT: NavigationPathInfo = { label: "", iconPath: mdiLink };
@@ -8,7 +9,8 @@ const DEFAULT: NavigationPathInfo = { label: "", iconPath: mdiLink };
export const getShortcutCardDefaults = (
hass: HomeAssistant,
action: ActionConfig | undefined,
navInfo: NavigationPathInfo
navInfo: NavigationPathInfo,
serviceInfo: ServiceInfo
): NavigationPathInfo => {
switch (action?.action) {
case "navigate":
@@ -22,10 +24,7 @@ export const getShortcutCardDefaults = (
};
case "call-service":
case "perform-action":
return {
label: action.perform_action || "",
iconPath: mdiRoomService,
};
return serviceInfo;
case "url":
return {
label: action.url_path || "",

View File

@@ -8,6 +8,7 @@ import "../../../components/tile/ha-tile-icon";
import "../../../components/tile/ha-tile-info";
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import { NavigationPathInfoController } from "../../../data/navigation-path-controller";
import { ServiceInfoController } from "../../../data/service-info-controller";
import type { HomeAssistant } from "../../../types";
import { handleAction } from "../common/handle-action";
import { hasAction } from "../common/has-action";
@@ -43,6 +44,8 @@ export class HuiShortcutCard extends LitElement implements LovelaceCard {
private _navInfo = new NavigationPathInfoController(this);
private _serviceInfo = new ServiceInfoController(this);
public setConfig(config: ShortcutCardConfig): void {
this._config = {
tap_action: {
@@ -83,6 +86,11 @@ export class HuiShortcutCard extends LitElement implements LovelaceCard {
this.hass,
action?.action === "navigate" ? action.navigation_path : undefined
);
this._serviceInfo.updateService(
action?.action === "perform-action" || action?.action === "call-service"
? action.perform_action || action.service
: undefined
);
}
}
@@ -106,7 +114,8 @@ export class HuiShortcutCard extends LitElement implements LovelaceCard {
const defaults = getShortcutCardDefaults(
this.hass,
this._config.tap_action,
this._navInfo.info
this._navInfo.info,
this._serviceInfo.info
);
const label = this._config.label || defaults.label;
const description = this._config.description;

View File

@@ -2,7 +2,7 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { DOMAINS_TOGGLE } from "../../../common/const";
import "../../../components/ha-control-switch";
import "../../../components/ha-switch";
import type { HaSwitch } from "../../../components/ha-switch";
import { forwardHaptic } from "../../../data/haptics";
import type { HomeAssistant } from "../../../types";
@@ -33,7 +33,7 @@ class HuiEntitiesToggle extends LitElement {
}
return html`
<ha-control-switch
<ha-switch
aria-label=${this.hass!.localize(
"ui.panel.lovelace.card.entities.toggle"
)}
@@ -42,19 +42,19 @@ class HuiEntitiesToggle extends LitElement {
return stateObj && stateObj.state === "on";
})}
@change=${this._callService}
></ha-control-switch>
></ha-switch>
`;
}
static styles = css`
:host {
width: 38px;
display: flex;
align-items: center;
}
ha-control-switch {
--control-switch-thickness: 20px;
--control-switch-off-color: var(--state-inactive-color);
ha-switch {
--ha-switch-width: 38px;
--ha-switch-size: 20px;
--ha-switch-thumb-size: 14px;
}
`;

View File

@@ -43,8 +43,6 @@ import "../card-features/hui-valve-position-card-feature";
import "../card-features/hui-water-heater-operation-modes-card-feature";
import "../card-features/hui-area-controls-card-feature";
import "../card-features/hui-bar-gauge-card-feature";
import "../card-features/hui-daily-forecast-card-feature";
import "../card-features/hui-hourly-forecast-card-feature";
import "../card-features/hui-trend-graph-card-feature";
import type { LovelaceCardFeatureConfig } from "../card-features/types";
@@ -70,13 +68,11 @@ const TYPES = new Set<LovelaceCardFeatureConfig["type"]>([
"cover-tilt-favorite",
"cover-tilt-position",
"cover-tilt",
"daily-forecast",
"date-set",
"fan-direction",
"fan-oscillate",
"fan-preset-modes",
"fan-speed",
"hourly-forecast",
"humidifier-modes",
"humidifier-toggle",
"lawn-mower-commands",

View File

@@ -419,6 +419,7 @@ export class HuiDialogEditBadge
.content {
width: 100%;
max-width: 100%;
gap: var(--ha-space-3);
}
}

View File

@@ -419,6 +419,7 @@ export class HuiDialogEditCard
.content {
width: 100%;
max-width: 100%;
gap: var(--ha-space-3);
}
}

View File

@@ -40,10 +40,8 @@ import { supportsCoverPositionCardFeature } from "../../card-features/hui-cover-
import { supportsCoverTiltCardFeature } from "../../card-features/hui-cover-tilt-card-feature";
import { supportsCoverTiltFavoriteCardFeature } from "../../card-features/hui-cover-tilt-favorite-card-feature";
import { supportsCoverTiltPositionCardFeature } from "../../card-features/hui-cover-tilt-position-card-feature";
import { supportsDailyForecastCardFeature } from "../../card-features/hui-daily-forecast-card-feature";
import { supportsDateSetCardFeature } from "../../card-features/hui-date-set-card-feature";
import { supportsFanDirectionCardFeature } from "../../card-features/hui-fan-direction-card-feature";
import { supportsHourlyForecastCardFeature } from "../../card-features/hui-hourly-forecast-card-feature";
import { supportsFanOscilatteCardFeature } from "../../card-features/hui-fan-oscillate-card-feature";
import { supportsFanPresetModesCardFeature } from "../../card-features/hui-fan-preset-modes-card-feature";
import { supportsFanSpeedCardFeature } from "../../card-features/hui-fan-speed-card-feature";
@@ -102,13 +100,11 @@ const UI_FEATURE_TYPES = [
"cover-tilt-favorite",
"cover-tilt-position",
"cover-tilt",
"daily-forecast",
"date-set",
"fan-direction",
"fan-oscillate",
"fan-preset-modes",
"fan-speed",
"hourly-forecast",
"humidifier-modes",
"humidifier-toggle",
"lawn-mower-commands",
@@ -151,9 +147,7 @@ const EDITABLES_FEATURE_TYPES = new Set<UiFeatureTypes>([
"counter-actions",
"cover-position-favorite",
"cover-tilt-favorite",
"daily-forecast",
"fan-preset-modes",
"hourly-forecast",
"humidifier-modes",
"lawn-mower-commands",
"media-player-playback",
@@ -189,16 +183,11 @@ const SUPPORTS_FEATURE_TYPES: Record<
"cover-tilt-favorite": supportsCoverTiltFavoriteCardFeature,
"cover-tilt-position": supportsCoverTiltPositionCardFeature,
"cover-tilt": supportsCoverTiltCardFeature,
"daily-forecast": (hass, context) =>
supportsDailyForecastCardFeature(
context.entity_id ? hass.states[context.entity_id] : undefined
),
"date-set": supportsDateSetCardFeature,
"fan-direction": supportsFanDirectionCardFeature,
"fan-oscillate": supportsFanOscilatteCardFeature,
"fan-preset-modes": supportsFanPresetModesCardFeature,
"fan-speed": supportsFanSpeedCardFeature,
"hourly-forecast": supportsHourlyForecastCardFeature,
"humidifier-modes": supportsHumidifierModesCardFeature,
"humidifier-toggle": supportsHumidifierToggleCardFeature,
"lawn-mower-commands": supportsLawnMowerCommandCardFeature,

View File

@@ -1,246 +0,0 @@
import { mdiThermometer, mdiWeatherRainy } from "@mdi/js";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-form/ha-form";
import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import { getSupportedForecastTypes } from "../../../../data/weather";
import type { HomeAssistant } from "../../../../types";
import {
DEFAULT_DAYS_TO_SHOW,
resolveDailyForecastType,
} from "../../card-features/hui-daily-forecast-card-feature";
import type {
DailyForecastCardFeatureConfig,
LovelaceCardFeatureContext,
} from "../../card-features/types";
import type { LovelaceCardFeatureEditor } from "../../types";
@customElement("hui-daily-forecast-card-feature-editor")
export class HuiDailyForecastCardFeatureEditor
extends LitElement
implements LovelaceCardFeatureEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state() private _config?: DailyForecastCardFeatureConfig;
public setConfig(config: DailyForecastCardFeatureConfig): void {
this._config = config;
}
private _schema = memoizeOne(
(
supportsDaily: boolean,
supportsTwiceDaily: boolean,
showTemperature: boolean,
showPrecipitation: boolean,
localize: HomeAssistant["localize"]
) =>
[
{
name: "forecast_type",
required: true,
disabled: !(supportsDaily && supportsTwiceDaily),
selector: {
select: {
mode: "dropdown",
options: [
{
value: "daily",
label: localize(
"ui.panel.lovelace.editor.features.types.daily-forecast.forecast_type_options.daily"
),
disabled: !supportsDaily,
},
{
value: "twice_daily",
label: localize(
"ui.panel.lovelace.editor.features.types.daily-forecast.forecast_type_options.twice_daily"
),
disabled: !supportsTwiceDaily,
},
],
},
},
},
{
name: "days_to_show",
default: DEFAULT_DAYS_TO_SHOW,
selector: { number: { min: 1, mode: "box" } },
},
{
name: "temperature",
type: "expandable",
flatten: true,
expanded: true,
iconPath: mdiThermometer,
schema: [
{
name: "show_temperature",
selector: { boolean: {} },
},
{
name: "show_current_temperature",
disabled: !showTemperature,
selector: { boolean: {} },
},
{
name: "color",
disabled: !showTemperature,
selector: {
ui_color: {
default_color: "state",
include_state: true,
},
},
},
],
},
{
name: "precipitation",
type: "expandable",
flatten: true,
expanded: true,
iconPath: mdiWeatherRainy,
schema: [
{
name: "show_precipitation",
selector: { boolean: {} },
},
{
name: "precipitation_type",
required: true,
disabled: !showPrecipitation,
selector: {
select: {
mode: "dropdown",
options: [
{
value: "amount",
label: localize(
"ui.panel.lovelace.editor.features.types.daily-forecast.precipitation_type_options.amount"
),
},
{
value: "probability",
label: localize(
"ui.panel.lovelace.editor.features.types.daily-forecast.precipitation_type_options.probability"
),
},
],
},
},
},
],
},
] as const satisfies readonly HaFormSchema[]
);
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
const stateObj = this.context?.entity_id
? this.hass.states[this.context.entity_id]
: undefined;
const supportedTypes = stateObj ? getSupportedForecastTypes(stateObj) : [];
const supportsDaily = supportedTypes.includes("daily");
const supportsTwiceDaily = supportedTypes.includes("twice_daily");
const resolvedType =
resolveDailyForecastType(stateObj, this._config.forecast_type) || "daily";
const showTemperature = this._config.show_temperature ?? true;
const showPrecipitation = this._config.show_precipitation ?? false;
const data: DailyForecastCardFeatureConfig = {
...this._config,
forecast_type: resolvedType,
days_to_show: this._config.days_to_show ?? DEFAULT_DAYS_TO_SHOW,
show_temperature: showTemperature,
show_current_temperature: this._config.show_current_temperature ?? true,
precipitation_type: this._config.precipitation_type ?? "amount",
};
const schema = this._schema(
supportsDaily,
supportsTwiceDaily,
showTemperature,
showPrecipitation,
this.hass.localize
);
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _valueChanged(ev: CustomEvent): void {
fireEvent(this, "config-changed", { config: ev.detail.value });
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
case "forecast_type":
return this.hass!.localize(
"ui.panel.lovelace.editor.features.types.daily-forecast.forecast_type"
);
case "days_to_show":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
);
case "show_temperature":
return this.hass!.localize(
"ui.panel.lovelace.editor.features.types.daily-forecast.show_temperature"
);
case "show_current_temperature":
return this.hass!.localize(
"ui.panel.lovelace.editor.features.types.daily-forecast.show_current_temperature"
);
case "show_precipitation":
return this.hass!.localize(
"ui.panel.lovelace.editor.features.types.daily-forecast.show_precipitation"
);
case "precipitation_type":
return this.hass!.localize(
"ui.panel.lovelace.editor.features.types.daily-forecast.precipitation_type"
);
case "color":
return this.hass!.localize(
"ui.panel.lovelace.editor.features.types.daily-forecast.color"
);
case "temperature":
return this.hass!.localize(
"ui.panel.lovelace.editor.features.types.daily-forecast.temperature"
);
case "precipitation":
return this.hass!.localize(
"ui.panel.lovelace.editor.features.types.daily-forecast.precipitation"
);
default:
return "";
}
};
}
declare global {
interface HTMLElementTagNameMap {
"hui-daily-forecast-card-feature-editor": HuiDailyForecastCardFeatureEditor;
}
}

View File

@@ -1,187 +0,0 @@
import { mdiThermometer, mdiWeatherRainy } from "@mdi/js";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-form/ha-form";
import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types";
import { DEFAULT_HOURS_TO_SHOW } from "../../card-features/hui-hourly-forecast-card-feature";
import type {
HourlyForecastCardFeatureConfig,
LovelaceCardFeatureContext,
} from "../../card-features/types";
import type { LovelaceCardFeatureEditor } from "../../types";
@customElement("hui-hourly-forecast-card-feature-editor")
export class HuiHourlyForecastCardFeatureEditor
extends LitElement
implements LovelaceCardFeatureEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state() private _config?: HourlyForecastCardFeatureConfig;
public setConfig(config: HourlyForecastCardFeatureConfig): void {
this._config = config;
}
private _schema = memoizeOne(
(
showTemperature: boolean,
showPrecipitation: boolean,
localize: HomeAssistant["localize"]
) =>
[
{
name: "hours_to_show",
default: DEFAULT_HOURS_TO_SHOW,
selector: { number: { min: 1, mode: "box" } },
},
{
name: "temperature",
type: "expandable",
flatten: true,
expanded: true,
iconPath: mdiThermometer,
schema: [
{
name: "show_temperature",
selector: { boolean: {} },
},
{
name: "color",
disabled: !showTemperature,
selector: {
ui_color: {
default_color: "state",
include_state: true,
},
},
},
],
},
{
name: "precipitation",
type: "expandable",
flatten: true,
expanded: true,
iconPath: mdiWeatherRainy,
schema: [
{
name: "show_precipitation",
selector: { boolean: {} },
},
{
name: "precipitation_type",
required: true,
disabled: !showPrecipitation,
selector: {
select: {
mode: "dropdown",
options: [
{
value: "amount",
label: localize(
"ui.panel.lovelace.editor.features.types.hourly-forecast.precipitation_type_options.amount"
),
},
{
value: "probability",
label: localize(
"ui.panel.lovelace.editor.features.types.hourly-forecast.precipitation_type_options.probability"
),
},
],
},
},
},
],
},
] as const satisfies readonly HaFormSchema[]
);
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
const showTemperature = this._config.show_temperature ?? true;
const showPrecipitation = this._config.show_precipitation ?? false;
const data: HourlyForecastCardFeatureConfig = {
...this._config,
hours_to_show: this._config.hours_to_show ?? DEFAULT_HOURS_TO_SHOW,
show_temperature: showTemperature,
precipitation_type: this._config.precipitation_type ?? "amount",
};
const schema = this._schema(
showTemperature,
showPrecipitation,
this.hass.localize
);
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _valueChanged(ev: CustomEvent): void {
fireEvent(this, "config-changed", { config: ev.detail.value });
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
case "hours_to_show":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
);
case "show_temperature":
return this.hass!.localize(
"ui.panel.lovelace.editor.features.types.hourly-forecast.show_temperature"
);
case "show_precipitation":
return this.hass!.localize(
"ui.panel.lovelace.editor.features.types.hourly-forecast.show_precipitation"
);
case "precipitation_type":
return this.hass!.localize(
"ui.panel.lovelace.editor.features.types.hourly-forecast.precipitation_type"
);
case "color":
return this.hass!.localize(
"ui.panel.lovelace.editor.features.types.hourly-forecast.color"
);
case "temperature":
return this.hass!.localize(
"ui.panel.lovelace.editor.features.types.hourly-forecast.temperature"
);
case "precipitation":
return this.hass!.localize(
"ui.panel.lovelace.editor.features.types.hourly-forecast.precipitation"
);
default:
return "";
}
};
}
declare global {
interface HTMLElementTagNameMap {
"hui-hourly-forecast-card-feature-editor": HuiHourlyForecastCardFeatureEditor;
}
}

View File

@@ -7,6 +7,7 @@ import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-form/ha-form";
import type { HaFormSchema } from "../../../../components/ha-form/types";
import { NavigationPathInfoController } from "../../../../data/navigation-path-controller";
import { ServiceInfoController } from "../../../../data/service-info-controller";
import type { HomeAssistant } from "../../../../types";
import { getShortcutCardDefaults } from "../../cards/hui-shortcut-card-defaults";
import type { ShortcutBadgeConfig } from "../../badges/types";
@@ -47,6 +48,8 @@ export class HuiShortcutBadgeEditor
private _navInfo = new NavigationPathInfoController(this);
private _serviceInfo = new ServiceInfoController(this);
public setConfig(config: ShortcutBadgeConfig): void {
assert(config, badgeConfigStruct);
this._config = config;
@@ -62,6 +65,11 @@ export class HuiShortcutBadgeEditor
this.hass,
action?.action === "navigate" ? action.navigation_path : undefined
);
this._serviceInfo.updateService(
action?.action === "perform-action" || action?.action === "call-service"
? action.perform_action || action.service
: undefined
);
}
}
@@ -149,7 +157,8 @@ export class HuiShortcutBadgeEditor
const defaults = getShortcutCardDefaults(
this.hass,
this._config.tap_action,
this._navInfo.info
this._navInfo.info,
this._serviceInfo.info
);
return html`

View File

@@ -8,6 +8,7 @@ import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/ha-form/ha-form";
import type { HaFormSchema } from "../../../../components/ha-form/types";
import { NavigationPathInfoController } from "../../../../data/navigation-path-controller";
import { ServiceInfoController } from "../../../../data/service-info-controller";
import type { HomeAssistant } from "../../../../types";
import { getShortcutCardDefaults } from "../../cards/hui-shortcut-card-defaults";
import type { ShortcutCardConfig } from "../../cards/types";
@@ -50,6 +51,8 @@ export class HuiShortcutCardEditor
private _navInfo = new NavigationPathInfoController(this);
private _serviceInfo = new ServiceInfoController(this);
public setConfig(config: ShortcutCardConfig): void {
assert(config, cardConfigStruct);
this._config = config;
@@ -65,6 +68,11 @@ export class HuiShortcutCardEditor
this.hass,
action?.action === "navigate" ? action.navigation_path : undefined
);
this._serviceInfo.updateService(
action?.action === "perform-action" || action?.action === "call-service"
? action.perform_action || action.service
: undefined
);
}
}
@@ -182,7 +190,8 @@ export class HuiShortcutCardEditor
const defaults = getShortcutCardDefaults(
this.hass,
this._config.tap_action,
this._navInfo.info
this._navInfo.info,
this._serviceInfo.info
);
const data = {

View File

@@ -6,7 +6,8 @@ import { fireEvent } from "../../../../common/dom/fire_event";
import { stringCompare } from "../../../../common/string/compare";
import type { HaSwitch } from "../../../../components/ha-switch";
import "../../../../components/user/ha-user-badge";
import "../../../../components/ha-list-item";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-switch";
import type {
LovelaceViewConfig,
@@ -65,24 +66,26 @@ export class HuiViewVisibilityEditor extends LitElement {
"ui.panel.lovelace.editor.edit_view.visibility.select_users"
)}
</p>
${this._sortedUsers(this._users).map(
(user) => html`
<ha-list-item graphic="avatar" hasMeta>
<ha-user-badge
slot="graphic"
.hass=${this.hass}
.user=${user}
></ha-user-badge>
<span>${user.name}</span>
<ha-switch
slot="meta"
.userId=${user.id}
@change=${this._valChange}
.checked=${this.checkUser(user.id)}
></ha-switch>
</ha-list-item>
`
)}
<ha-md-list>
${this._sortedUsers(this._users).map(
(user) => html`
<ha-md-list-item>
<ha-user-badge
slot="start"
.hass=${this.hass}
.user=${user}
></ha-user-badge>
<span slot="headline">${user.name}</span>
<ha-switch
slot="end"
.userId=${user.id}
@change=${this._valChange}
.checked=${this.checkUser(user.id)}
></ha-switch>
</ha-md-list-item>
`
)}
</ha-md-list>
`;
}
@@ -136,6 +139,16 @@ export class HuiViewVisibilityEditor extends LitElement {
:host {
display: block;
}
ha-md-list {
padding: 0;
}
ha-md-list-item {
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
--md-list-item-top-space: var(--ha-space-1);
--md-list-item-bottom-space: var(--ha-space-1);
--md-list-item-one-line-container-height: 48px;
}
`;
}

View File

@@ -2,7 +2,10 @@ import type { PropertyValues } from "lit";
import { LitElement, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isUnavailableState } from "../../../data/entity/entity";
import { SENSOR_DEVICE_CLASS_TIMESTAMP } from "../../../data/sensor";
import {
SENSOR_DEVICE_CLASS_UPTIME,
SENSOR_TIMESTAMP_DEVICE_CLASSES,
} from "../../../data/sensor";
import type { HomeAssistant } from "../../../types";
import type { EntitiesCardEntityConfig } from "../cards/types";
import { hasConfigOrEntityChanged } from "../common/has-changed";
@@ -50,13 +53,17 @@ class HuiSensorEntityRow extends LitElement implements LovelaceRow {
return html`
<hui-generic-entity-row .hass=${this.hass} .config=${this._config}>
${stateObj.attributes.device_class === SENSOR_DEVICE_CLASS_TIMESTAMP &&
!isUnavailableState(stateObj.state)
${SENSOR_TIMESTAMP_DEVICE_CLASSES.includes(
stateObj.attributes.device_class
) && !isUnavailableState(stateObj.state)
? html`
<hui-timestamp-display
.hass=${this.hass}
.ts=${new Date(stateObj.state)}
.format=${this._config.format}
.format=${this._config.format ??
(stateObj.attributes.device_class === SENSOR_DEVICE_CLASS_UPTIME
? "total"
: undefined)}
capitalize
></hui-timestamp-display>
`

View File

@@ -24,14 +24,14 @@ export const maintenanceEntityFilters: EntityFilter[] = [
},
{
domain: "binary_sensor",
device_class: ["battery", "battery_charging"],
device_class: ["battery"],
entity_category: "none",
},
];
const LOW_BATTERY_THRESHOLD = 20;
export const filterNeedsAttentionEntities = (
export const filterLowBatteryEntities = (
hass: HomeAssistant,
entityIds: string[]
): string[] =>
@@ -40,11 +40,26 @@ export const filterNeedsAttentionEntities = (
return !isNaN(stateValue) && stateValue <= LOW_BATTERY_THRESHOLD;
});
const computeBatteryTileCard = (entityId: string): TileCardConfig => ({
type: "tile",
entity: entityId,
name: { type: "device" },
});
export const filterUnavailableBatteryEntities = (
hass: HomeAssistant,
entityIds: string[]
): string[] =>
entityIds.filter((entityId) => {
return hass.states[entityId]?.state === "unavailable";
});
const computeBatteryTileCard = (
entities: HomeAssistant["entities"],
entityId: string
): TileCardConfig => {
const entity = entities[entityId];
const deviceId = entity?.device_id;
return {
type: "tile",
entity: entityId,
name: { type: deviceId ? "device" : "entity" },
};
};
const processAreasForBattery = (
areaIds: string[],
@@ -64,7 +79,7 @@ const processAreasForBattery = (
const areaCards: LovelaceCardConfig[] = [];
for (const entityId of areaBatteryEntities) {
areaCards.push(computeBatteryTileCard(entityId));
areaCards.push(computeBatteryTileCard(hass.entities, entityId));
}
if (areaCards.length > 0) {
@@ -97,7 +112,7 @@ const processUnassignedEntities = (
const cards: LovelaceCardConfig[] = [];
for (const entityId of unassignedEntities) {
cards.push(computeBatteryTileCard(entityId));
cards.push(computeBatteryTileCard(hass.entities, entityId));
}
return cards;

View File

@@ -43,6 +43,7 @@ export {
drawSelection,
EditorView,
highlightActiveLine,
hoverTooltip,
keymap,
lineNumbers,
rectangularSelection,
@@ -70,9 +71,10 @@ export const closeBracketsOverride = Prec.highest(
export {
haJinjaCompletionSource,
haJinjaHoverSource,
JINJA_FUNCTION_ARG_TYPES,
} from "./jinja_ha_completions";
export type { JinjaArgType } from "./jinja_ha_completions";
export type { HassArgHoverContext, JinjaArgType } from "./jinja_ha_completions";
export { closePercentBrace };
export const langCompartment = new Compartment();

File diff suppressed because it is too large Load Diff

View File

@@ -17,14 +17,24 @@ const addData = async (
addFunc = "__addLocaleData"
) => {
// Add function will only exist if constructor is polyfilled
if (typeof (Intl[obj] as any)?.[addFunc] === "function") {
const result = await fetch(
`${__STATIC_PATH__}locale-data/intl-${obj.toLowerCase()}/${language}.json`
);
// Ignore if polyfill data does not exist for language
if (typeof (Intl[obj] as any)?.[addFunc] !== "function") {
return;
}
const url = `${__STATIC_PATH__}locale-data/intl-${obj.toLowerCase()}/${language}.json`;
try {
const result = await fetch(url);
// 404 means polyfill data does not exist for the language; ignore silently.
if (result.ok) {
(Intl[obj] as any)[addFunc](await result.json());
}
} catch (err) {
// Network/access-control failures should not block startup, but they
// degrade i18n features so surface a warning for diagnostics.
// eslint-disable-next-line no-console
console.warn(`Failed to load Intl.${obj} locale data for ${language}`, {
url,
error: err,
});
}
};

View File

@@ -161,19 +161,18 @@ export const semanticColorStyles = css`
--ha-color-on-success-loud: var(--white-color);
/* Surfaces */
--ha-color-surface-default: var(--ha-color-neutral-95);
--ha-color-on-surface-default: var(--ha-color-neutral-05);
/* forms */
--ha-color-form-background: var(--ha-color-neutral-95);
--ha-color-form-background-hover: var(--ha-color-neutral-90);
--ha-color-form-background-disabled: var(--ha-color-neutral-80);
--ha-color-surface-default: var(--ha-color-white);
--ha-color-surface-low: var(--ha-color-neutral-95);
--ha-color-surface-lower: var(--ha-color-neutral-90);
--ha-color-surface-default-inverted: var(--ha-color-neutral-10);
--ha-color-surface-low-inverted: var(--ha-color-neutral-05);
--ha-color-surface-lower-inverted: var(--ha-color-black);
--ha-color-on-surface-default: var(--ha-color-neutral-05);
/* forms */
--ha-color-form-background: var(--ha-color-neutral-95);
--ha-color-form-background-hover: var(--ha-color-neutral-90);
--ha-color-form-background-disabled: var(--ha-color-neutral-80);
/* Scrollable fade */
--ha-color-shadow-scrollable-fade: rgba(0, 0, 0, 0.08);
@@ -314,16 +313,16 @@ export const darkSemanticColorStyles = css`
/* Surfaces */
--ha-color-surface-default: var(--ha-color-neutral-10);
--ha-color-surface-low: var(--ha-color-neutral-05);
--ha-color-surface-lower: var(--ha-color-black);
--ha-color-surface-default-inverted: var(--ha-color-white);
--ha-color-surface-low-inverted: var(--ha-color-neutral-95);
--ha-color-surface-lower-inverted: var(--ha-color-90);
--ha-color-on-surface-default: var(--ha-color-neutral-95);
/* forms */
--ha-color-form-background: var(--ha-color-neutral-20);
--ha-color-form-background-hover: var(--ha-color-neutral-30);
--ha-color-form-background-disabled: var(--ha-color-neutral-20);
--ha-color-surface-low: var(--ha-color-neutral-05);
--ha-color-surface-lower: var(--ha-color-black);
--ha-color-surface-default-inverted: var(--ha-color-white);
--ha-color-surface-low-inverted: var(--ha-color-neutral-95);
--ha-color-surface-lower-inverted: var(--ha-color-90);
}
`;

View File

@@ -8,7 +8,10 @@ import { computeStateDomain } from "../common/entity/compute_state_domain";
import { STRINGS_SEPARATOR_DOT } from "../common/const";
import "../components/ha-relative-time";
import { isUnavailableState } from "../data/entity/entity";
import { SENSOR_DEVICE_CLASS_TIMESTAMP } from "../data/sensor";
import {
SENSOR_TIMESTAMP_DEVICE_CLASSES,
SENSOR_DEVICE_CLASS_UPTIME,
} from "../data/sensor";
import type { UpdateEntity } from "../data/update";
import { computeUpdateStateDisplay } from "../data/update";
import "../panels/lovelace/components/hui-timestamp-display";
@@ -90,7 +93,9 @@ class StateDisplay extends LitElement {
return "—";
}
if (
(stateObj.attributes.device_class === SENSOR_DEVICE_CLASS_TIMESTAMP ||
(SENSOR_TIMESTAMP_DEVICE_CLASSES.includes(
this.stateObj.attributes.device_class
) ||
TIMESTAMP_STATE_DOMAINS.includes(domain)) &&
!isUnavailableState(stateObj.state)
) {
@@ -98,7 +103,10 @@ class StateDisplay extends LitElement {
<hui-timestamp-display
.hass=${this.hass}
.ts=${new Date(stateObj.state)}
format="relative"
.format=${this.stateObj.attributes.device_class ===
SENSOR_DEVICE_CLASS_UPTIME
? "total"
: "relative"}
capitalize
></hui-timestamp-display>
`;

View File

@@ -6,7 +6,10 @@ import { classMap } from "lit/directives/class-map";
import { computeDomain } from "../common/entity/compute_domain";
import "../components/entity/state-info";
import { isUnavailableState } from "../data/entity/entity";
import { SENSOR_DEVICE_CLASS_TIMESTAMP } from "../data/sensor";
import {
SENSOR_TIMESTAMP_DEVICE_CLASSES,
SENSOR_DEVICE_CLASS_UPTIME,
} from "../data/sensor";
import "../panels/lovelace/components/hui-timestamp-display";
import { haStyle } from "../resources/styles";
import type { HomeAssistant } from "../types";
@@ -38,13 +41,17 @@ class StateCardDisplay extends LitElement {
})}"
>
${computeDomain(this.stateObj.entity_id) === "sensor" &&
this.stateObj.attributes.device_class ===
SENSOR_DEVICE_CLASS_TIMESTAMP &&
SENSOR_TIMESTAMP_DEVICE_CLASSES.includes(
this.stateObj.attributes.device_class
) &&
!isUnavailableState(this.stateObj.state)
? html`<hui-timestamp-display
.hass=${this.hass}
.ts=${new Date(this.stateObj.state)}
format="datetime"
.format=${this.stateObj.attributes.device_class ===
SENSOR_DEVICE_CLASS_UPTIME
? "total"
: "datetime"}
capitalize
></hui-timestamp-display>`
: this.hass.formatEntityState(this.stateObj)}

View File

@@ -218,7 +218,8 @@
"all_secure": "All secure",
"no_media_playing": "No media playing",
"count_media_playing": "{count} {count, plural,\n one {playing}\n other {playing}\n}",
"count_maintenance_issues": "{count} {count, plural,\n one {issue}\n other {issues}\n}",
"count_maintenance_low_battery_issues": "{count} {count, plural,\n one {low battery}\n other {low batteries}\n}",
"count_maintenance_issues_unavailable_battery_entities": "{count} {count, plural,\n one {unavailable device}\n other {unavailable devices}\n}",
"all_maintenance_good": "All good",
"count_persons_home": "{count} {count, plural,\n one {person}\n other {people}\n} home",
"nobody_home": "No one home"
@@ -1497,6 +1498,9 @@
"area_label": "Area",
"description": "Configure which areas correspond to each vacuum segment"
},
"codemirror": {
"open_documentation": "Open documentation"
},
"safe_mode": {
"title": "Safe mode",
"text": "Home Assistant is running in safe mode, custom integrations and community frontend modules are not available. Restart Home Assistant to exit safe mode."
@@ -1729,7 +1733,8 @@
"no_areas_text_non_admin": "Ask an administrator to map your vacuum's segments to areas.",
"configure_area_mapping": "Configure area mapping",
"configure": "Configure",
"clean_areas_order_hint": "Cleaning order may not be supported by your vacuum."
"clean_areas_order_hint": "Cleaning order may not be supported by your vacuum.",
"other_areas": "Other areas"
},
"person": {
"create_zone": "Create zone from current location"
@@ -3867,7 +3872,8 @@
"no_listeners": "This template does not listen for any events and will not update automatically.",
"listeners": "This template listens for the following state changed events:",
"entity": "Entity",
"domain": "Domain"
"domain": "Domain",
"keyboard_tip": "Press {autocomplete} to trigger autocomplete, when your cursor is inside a function that supports it."
},
"statistics": {
"title": "Statistics",
@@ -7702,7 +7708,9 @@
"menu_remove_device": "Force-remove an unavailable device",
"start_exclusion": "Start exclusion",
"cancel_exclusion": "Cancel exclusion",
"follow_device_instructions": "Follow the directions that came with your device to trigger exclusion on the device.",
"ready_to_remove": "Ready to remove device.",
"follow_device_instructions": "Follow the directions that came with your device.",
"trigger_device_exclusion": "To trigger exclusion on the device:",
"removing_device": "Removing device",
"exclusion_failed": "An error occurred. Please check the logs for more information.",
"exclusion_finished": "Device {id} has been removed from your Z-Wave network."
@@ -10112,40 +10120,6 @@
"trend-graph": {
"label": "Trend graph",
"detail": "Show more detail"
},
"hourly-forecast": {
"label": "Hourly forecast",
"no_forecast": "No forecast data available",
"temperature": "Temperature",
"precipitation": "Precipitation",
"show_temperature": "Show temperature",
"show_precipitation": "Show precipitation",
"precipitation_type": "Precipitation type",
"precipitation_type_options": {
"amount": "Amount",
"probability": "Probability"
},
"color": "Color"
},
"daily-forecast": {
"label": "Daily forecast",
"no_forecast": "[%key:ui::panel::lovelace::editor::features::types::hourly-forecast::no_forecast%]",
"temperature": "[%key:ui::panel::lovelace::editor::features::types::hourly-forecast::temperature%]",
"precipitation": "[%key:ui::panel::lovelace::editor::features::types::hourly-forecast::precipitation%]",
"forecast_type": "Forecast type",
"forecast_type_options": {
"daily": "Daily",
"twice_daily": "Twice daily"
},
"show_temperature": "[%key:ui::panel::lovelace::editor::features::types::hourly-forecast::show_temperature%]",
"show_current_temperature": "Show current temperature",
"show_precipitation": "[%key:ui::panel::lovelace::editor::features::types::hourly-forecast::show_precipitation%]",
"precipitation_type": "[%key:ui::panel::lovelace::editor::features::types::hourly-forecast::precipitation_type%]",
"precipitation_type_options": {
"amount": "[%key:ui::panel::lovelace::editor::features::types::hourly-forecast::precipitation_type_options::amount%]",
"probability": "[%key:ui::panel::lovelace::editor::features::types::hourly-forecast::precipitation_type_options::probability%]"
},
"color": "[%key:ui::panel::lovelace::editor::features::types::hourly-forecast::color%]"
}
}
},