mirror of
https://github.com/home-assistant/frontend.git
synced 2026-05-09 02:43:05 +00:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4f4cbd323 | ||
|
|
2e0df00f0f | ||
|
|
ce02f8072d | ||
|
|
c973aa7516 | ||
|
|
1e2328707c | ||
|
|
56368b88cd | ||
|
|
fcd4f177c1 | ||
|
|
7423ae7316 | ||
|
|
4427c581f1 | ||
|
|
cf86bb9821 | ||
|
|
897802dc16 | ||
|
|
dd65173c5a | ||
|
|
cf26753f7d | ||
|
|
d6ab8ffb16 | ||
|
|
2dc4b16eac | ||
|
|
1eba765bc2 | ||
|
|
398479ddd7 | ||
|
|
c4fd7bb3e1 | ||
|
|
4cfc67a95e | ||
|
|
e38d1964ca | ||
|
|
ec8b5c77bd | ||
|
|
425f2775e2 | ||
|
|
3a3d8191a3 | ||
|
|
04fca68549 | ||
|
|
35601a0900 | ||
|
|
e7016c15af | ||
|
|
624521e30b | ||
|
|
4876bfa639 | ||
|
|
5dea0764b2 |
@@ -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}/`)),
|
||||
},
|
||||
],
|
||||
|
||||
12
build-scripts/get-built-in-node-module-shim.cjs
Normal file
12
build-scripts/get-built-in-node-module-shim.cjs
Normal 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;
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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
|
||||
>
|
||||
`
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
42
src/components/ha-code-editor-jinja-arg-hover.ts
Normal file
42
src/components/ha-code-editor-jinja-arg-hover.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
101
src/components/ha-code-editor-jinja-hover.ts
Normal file
101
src/components/ha-code-editor-jinja-hover.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>`
|
||||
: ""}`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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> = (
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
34
src/data/compute-service-info.ts
Normal file
34
src/data/compute-service-info.ts
Normal 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
|
||||
);
|
||||
};
|
||||
@@ -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];
|
||||
|
||||
@@ -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[];
|
||||
|
||||
157
src/data/service-info-controller.ts
Normal file
157
src/data/service-info-controller.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")!
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
`
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 || "",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -419,6 +419,7 @@ export class HuiDialogEditBadge
|
||||
.content {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
gap: var(--ha-space-3);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -419,6 +419,7 @@ export class HuiDialogEditCard
|
||||
.content {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
gap: var(--ha-space-3);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
`
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user