Compare commits

..

1 Commits

Author SHA1 Message Date
Paul Bottein
dde2230185 Rework weather forecast card features 2026-05-05 10:01:06 +02:00
26 changed files with 1486 additions and 230 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -78,28 +78,22 @@ export class HaObjectSelector extends LitElement {
};
private _renderItem(item: any, index: number) {
const fields = this.selector.object!.fields!;
const preferredLabel = this.selector.object!.label_field;
const hasValidLabelField = preferredLabel && preferredLabel in fields;
const labelField =
this.selector.object!.label_field ||
Object.keys(this.selector.object!.fields!)[0];
const label = hasValidLabelField
? formatSelectorValue(
this.hass,
item[preferredLabel!],
fields[preferredLabel!]?.selector
)
: Object.entries(fields)
.map(([key, field]) =>
formatSelectorValue(this.hass, item[key], field.selector)
)
.filter(Boolean)
.join(" · ");
const labelSelector = this.selector.object!.fields![labelField].selector;
const label = labelSelector
? formatSelectorValue(this.hass, item[labelField], labelSelector)
: "";
let description = "";
const descriptionField = this.selector.object!.description_field;
if (descriptionField && descriptionField in fields) {
const descriptionSelector = fields[descriptionField]?.selector;
if (descriptionField) {
const descriptionSelector =
this.selector.object!.fields![descriptionField].selector;
description = descriptionSelector
? formatSelectorValue(

View File

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

View File

@@ -98,30 +98,5 @@ export const formatSelectorValue = (
.join(", ");
}
if ("object" in selector) {
const { fields } = selector.object ?? {};
const items = ensureArray(value);
return items
.map((item) => {
if (item == null || typeof item !== "object") {
return String(item);
}
if (fields) {
return Object.entries(fields)
.filter(([key]) => key in item && item[key] != null)
.map(([key, field]) =>
formatSelectorValue(hass, item[key], field.selector)
)
.join(" = ");
}
return JSON.stringify(item);
})
.join(", ");
}
return ensureArray(value)
.map((v) =>
v != null && typeof v === "object" ? JSON.stringify(v) : String(v)
)
.join(", ");
return ensureArray(value).join(", ");
};

View File

@@ -1,7 +1,5 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import deepClone from "deep-clone-simple";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-button";
import "../../components/ha-form/ha-form";
@@ -9,15 +7,9 @@ import "../../components/ha-dialog-footer";
import "../../components/ha-dialog";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import type { HassDialog, ShowDialogParams } from "../make-dialog-manager";
import type { HassDialog } from "../make-dialog-manager";
import type { FormDialogData, FormDialogParams } from "./show-form-dialog";
interface StackEntry {
params: FormDialogParams;
data: FormDialogData;
nestedField?: string;
}
@customElement("dialog-form")
export class DialogForm
extends LitElement
@@ -33,8 +25,6 @@ export class DialogForm
@state() private _closeState?: "canceled" | "submitted";
@state() private _stack: StackEntry[] = [];
public async showDialog(params: FormDialogParams): Promise<void> {
this._params = params;
this._data = params.data || {};
@@ -46,41 +36,11 @@ export class DialogForm
return true;
}
private _handleNestedShowDialog = (
ev: HASSDomEvent<ShowDialogParams<unknown>>
) => {
if (ev.detail.dialogTag !== "dialog-form") {
return;
}
ev.stopPropagation();
const origin = ev.composedPath()[0] as HTMLElement & { name?: string };
this._stack = [
...this._stack,
{ params: this._params!, data: this._data, nestedField: origin?.name },
];
const nested = ev.detail.dialogParams as FormDialogParams;
this._params = nested;
this._data = nested?.data || {};
};
private _popStack(): string | undefined {
if (!this._stack.length) {
return undefined;
}
const prev = this._stack[this._stack.length - 1];
this._stack = this._stack.slice(0, -1);
this._params = prev.params;
this._data = prev.data;
return prev.nestedField;
}
private _dialogClosed(): void {
if (!this._closeState) {
this._params?.cancel?.();
}
this._closeState = undefined;
this._stack = [];
this._params = undefined;
this._data = {};
this._open = false;
@@ -89,44 +49,14 @@ export class DialogForm
private _submit(): void {
this._closeState = "submitted";
const submit = this._params?.submit;
const data = this._data;
const nestedField = this._popStack();
submit?.(data);
if (!nestedField) {
this.closeDialog();
return;
}
const schemaField = this._params?.schema.find(
(f) => "selector" in f && f.name === nestedField
);
const isMultiple =
schemaField &&
"selector" in schemaField &&
"object" in schemaField.selector &&
schemaField.selector.object?.multiple === true;
const current = this._data[nestedField];
const newValue = isMultiple
? [...(Array.isArray(current) ? current : []), data]
: data;
this._data = deepClone({ ...this._data, [nestedField]: newValue });
this._params?.submit?.(this._data);
this.closeDialog();
}
private _cancel(): void {
this._closeState = "canceled";
const cancel = this._params?.cancel;
const nestedField = this._popStack();
cancel?.();
if (!nestedField) {
this.closeDialog();
}
this._params?.cancel?.();
this.closeDialog();
}
private _valueChanged(ev: CustomEvent): void {
@@ -154,7 +84,6 @@ export class DialogForm
.data=${this._data}
.schema=${this._params.schema}
@value-changed=${this._valueChanged}
@show-dialog=${this._handleNestedShowDialog}
>
</ha-form>
<ha-dialog-footer slot="footer">

View File

@@ -29,6 +29,7 @@ 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";
@@ -506,7 +507,7 @@ class MoreInfoMediaPlayer extends LitElement {
<ha-icon-button
.id=${`media-control-row-button-${idSuffix}`}
hide-title
action=${ifDefined(action)}
.action=${action}
@click=${clickHandler}
.label=${title}
.path=${icon}
@@ -708,7 +709,7 @@ class MoreInfoMediaPlayer extends LitElement {
handleMediaControlClick(
this.hass!,
this.stateObj!,
(e.currentTarget as HTMLElement).getAttribute("action")!
(e.currentTarget as HaIconButton & { action: string }).action!
);
}

View File

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

View File

@@ -8,7 +8,6 @@ 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,
@@ -17,6 +16,7 @@ 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,11 +1073,9 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
}
ha-input-search {
flex: 1;
min-width: 0;
}
.header {
display: flex;
min-width: 0;
}
.search {
display: flex;

View File

@@ -0,0 +1,34 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { computeDomain } from "../../../../common/entity/compute_domain";
import { supportsFeature } from "../../../../common/entity/supports-feature";
import {
getDefaultForecastType,
getSupportedForecastTypes,
WeatherEntityFeature,
} from "../../../../data/weather";
import type { ForecastResolution } from "../types";
export const DEFAULT_DAYS_TO_SHOW = 7;
export const DEFAULT_HOURS_TO_SHOW = 24;
export const MS_PER_HOUR = 60 * 60 * 1000;
export const supportsForecast = (stateObj: HassEntity | undefined): boolean => {
if (!stateObj) return false;
if (computeDomain(stateObj.entity_id) !== "weather") return false;
return (
supportsFeature(stateObj, WeatherEntityFeature.FORECAST_DAILY) ||
supportsFeature(stateObj, WeatherEntityFeature.FORECAST_TWICE_DAILY) ||
supportsFeature(stateObj, WeatherEntityFeature.FORECAST_HOURLY)
);
};
export const resolveForecastResolution = (
stateObj: HassEntity | undefined,
configured?: ForecastResolution
): ForecastResolution | undefined => {
if (!stateObj) return undefined;
const supported = getSupportedForecastTypes(stateObj);
if (configured && supported.includes(configured)) return configured;
return getDefaultForecastType(stateObj);
};

View File

@@ -0,0 +1,443 @@
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 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,
} from "../../../data/weather";
import type {
HomeAssistant,
HomeAssistantConnection,
HomeAssistantInternationalization,
} from "../../../types";
import {
DEFAULT_DAYS_TO_SHOW,
DEFAULT_HOURS_TO_SHOW,
MS_PER_HOUR,
resolveForecastResolution,
supportsForecast,
} from "./common/forecast";
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
import type {
ForecastResolution,
LovelaceCardFeatureContext,
PrecipitationForecastCardFeatureConfig,
} from "./types";
const MAX_BAR_WIDTH = 16;
export const supportsPrecipitationForecastCardFeature = (
hass: HomeAssistant,
context: LovelaceCardFeatureContext
) =>
supportsForecast(
context.entity_id ? hass.states[context.entity_id] : undefined
);
@customElement("hui-precipitation-forecast-card-feature")
class HuiPrecipitationForecastCardFeature
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?: PrecipitationForecastCardFeatureConfig;
@state() private _forecast?: ForecastAttribute[];
@state() private _error?: string;
private _subscribed?: Promise<UnsubscribeFunc | undefined>;
private _subscribedType?: ForecastResolution;
static getStubConfig(): PrecipitationForecastCardFeatureConfig {
return {
type: "precipitation-forecast",
};
}
public static async getConfigElement(): Promise<LovelaceCardFeatureEditor> {
await import("../editor/config-elements/hui-precipitation-forecast-card-feature-editor");
return document.createElement(
"hui-precipitation-forecast-card-feature-editor"
) as LovelaceCardFeatureEditor;
}
public setConfig(config: PrecipitationForecastCardFeatureConfig): 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(): ForecastResolution | undefined {
return resolveForecastResolution(
this._stateObj,
this._config?.forecast_type
);
}
protected render() {
if (!this._config || !this.context || !supportsForecast(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 isHourly = this._subscribedType === "hourly";
const precipitationType = this._config.precipitation_type ?? "amount";
const customColor = this._config.color
? computeCssColor(this._config.color)
: undefined;
const fill = customColor ?? "var(--state-weather-rainy-color)";
if (isHourly) {
return html`
<div class="container">
${this._renderHourlyBars(precipitationType, fill)}
</div>
`;
}
const daysToShow = this._config.days_to_show ?? DEFAULT_DAYS_TO_SHOW;
const entriesPerDay = this._subscribedType === "twice_daily" ? 2 : 1;
const entries = this._forecast.slice(0, daysToShow * entriesPerDay);
if (!entries.length) {
return html`
<div class="container">
<div class="info">
${this._localize(
"ui.panel.lovelace.editor.features.types.precipitation-forecast.no_forecast"
)}
</div>
</div>
`;
}
return html`
<div class="container">
${this._renderDailyBars(entries, precipitationType, fill)}
</div>
`;
}
private _renderDailyBars(
entries: ForecastAttribute[],
precipitationType: "amount" | "probability",
fill: string
): 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;
let maxPrecipitation = 0;
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 dotRadius = 1.5;
const elements = entries.map((entry, i) => {
const value = getForecastPrecipitation(entry, precipitationType);
const x = slotWidth * i + slotWidth / 2;
if (!Number.isFinite(value) || value! <= 0 || maxPrecipitation <= 0) {
const cy = padding + drawableHeight - dotRadius;
return svg`<circle
cx=${x}
cy=${cy}
r=${dotRadius}
fill=${fill}
opacity="0.4"
></circle>`;
}
const barHeight = Math.max(
1,
(value! / maxPrecipitation) * drawableHeight
);
const y = padding + drawableHeight - barHeight;
return svg`<rect
x=${x - barWidth / 2}
y=${y}
width=${barWidth}
height=${barHeight}
fill=${fill}
opacity="0.4"
></rect>`;
});
return html`
<svg
width="100%"
height="100%"
viewBox="0 0 ${width} ${height}"
preserveAspectRatio="none"
>
${elements}
</svg>
`;
}
private _renderHourlyBars(
precipitationType: "amount" | "probability",
fill: string
): TemplateResult | typeof nothing {
if (!this._forecast?.length) {
return nothing;
}
const width = this.clientWidth || 300;
const height = this.clientHeight || 42;
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 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 entriesWithRain = 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 entriesWithRain) {
maxPrecipitation = Math.max(
maxPrecipitation,
getForecastPrecipitation(entry, precipitationType)!
);
}
}
const slotWidth = width / hoursToShow;
const barWidth = Math.max(1, Math.min(MAX_BAR_WIDTH, slotWidth - 2));
const dotRadius = 1.5;
const elements = inRange.map(({ entry, t }) => {
const value = getForecastPrecipitation(entry, precipitationType);
const xCenter = ((t - now) / timeRange) * width;
if (!Number.isFinite(value) || value! <= 0 || maxPrecipitation <= 0) {
const cy = height - dotRadius;
return svg`<circle
cx=${xCenter}
cy=${cy}
r=${dotRadius}
fill=${fill}
opacity="0.4"
></circle>`;
}
const x = xCenter - barWidth / 2;
const barHeight = Math.max(
1,
(value! / maxPrecipitation) * drawableHeight
);
const y = height - barHeight;
return svg`<rect
x=${x}
y=${y}
width=${barWidth}
height=${barHeight}
fill=${fill}
opacity="0.4"
></rect>`;
});
return html`
<svg
width="100%"
height="100%"
viewBox="0 0 ${width} ${height}"
preserveAspectRatio="none"
>
${elements}
</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-precipitation-forecast-card-feature": HuiPrecipitationForecastCardFeature;
}
}

View File

@@ -0,0 +1,502 @@
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 { styleMap } from "lit/directives/style-map";
import { computeCssColor } from "../../../common/color/compute-color";
import { consumeEntityState } from "../../../common/decorators/consume-context-entry";
import { transform } from "../../../common/decorators/transform";
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 { subscribeForecast } from "../../../data/weather";
import type {
HomeAssistant,
HomeAssistantConnection,
HomeAssistantInternationalization,
} from "../../../types";
import {
DEFAULT_DAYS_TO_SHOW,
DEFAULT_HOURS_TO_SHOW,
MS_PER_HOUR,
resolveForecastResolution,
supportsForecast,
} from "./common/forecast";
import { coordinates } from "../common/graph/coordinates";
import type { HuiGraphGradient } from "../components/hui-graph-base";
import "../components/hui-graph-base";
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
import type {
ForecastResolution,
LovelaceCardFeatureContext,
TemperatureForecastCardFeatureConfig,
} from "./types";
const MAX_BAR_WIDTH = 8;
const TEMP_GRADIENT_STOPS: { tempC: number; cssVar: string }[] = [
{ tempC: -20, cssVar: "--feature-temperature-freezing-color" },
{ tempC: 0, cssVar: "--feature-temperature-cold-color" },
{ tempC: 15, cssVar: "--feature-temperature-mild-color" },
{ tempC: 25, cssVar: "--feature-temperature-warm-color" },
{ tempC: 40, cssVar: "--feature-temperature-hot-color" },
];
export const supportsTemperatureForecastCardFeature = (
hass: HomeAssistant,
context: LovelaceCardFeatureContext
) =>
supportsForecast(
context.entity_id ? hass.states[context.entity_id] : undefined
);
@customElement("hui-temperature-forecast-card-feature")
class HuiTemperatureForecastCardFeature
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?: TemperatureForecastCardFeatureConfig;
@state() private _forecast?: ForecastAttribute[];
@state() private _hourly?: {
coordinates: [number, number][];
yAxisOrigin: number;
gradient?: HuiGraphGradient;
};
@state() private _error?: string;
private _subscribed?: Promise<UnsubscribeFunc | undefined>;
private _subscribedType?: ForecastResolution;
static getStubConfig(): TemperatureForecastCardFeatureConfig {
return {
type: "temperature-forecast",
};
}
public static async getConfigElement(): Promise<LovelaceCardFeatureEditor> {
await import("../editor/config-elements/hui-temperature-forecast-card-feature-editor");
return document.createElement(
"hui-temperature-forecast-card-feature-editor"
) as LovelaceCardFeatureEditor;
}
public setConfig(config: TemperatureForecastCardFeatureConfig): 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(): ForecastResolution | undefined {
return resolveForecastResolution(
this._stateObj,
this._config?.forecast_type
);
}
protected render() {
if (!this._config || !this.context || !supportsForecast(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 isHourly = this._subscribedType === "hourly";
const customColor = this._config.color
? computeCssColor(this._config.color)
: undefined;
if (isHourly) {
if (!this._hourly?.coordinates.length) {
return html`
<div class="container">
<div class="info">
${this._localize(
"ui.panel.lovelace.editor.features.types.temperature-forecast.no_forecast"
)}
</div>
</div>
`;
}
const graphStyle = customColor
? styleMap({ "--feature-color": customColor })
: nothing;
return html`
<div class="container">
<hui-graph-base
.coordinates=${this._hourly.coordinates}
.yAxisOrigin=${this._hourly.yAxisOrigin}
.gradient=${customColor ? undefined : this._hourly.gradient}
style=${graphStyle}
></hui-graph-base>
</div>
`;
}
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) =>
Number.isFinite(entry.temperature) && Number.isFinite(entry.templow)
)
.slice(0, daysToShow * entriesPerDay);
if (!entries.length) {
return html`
<div class="container">
<div class="info">
${this._localize(
"ui.panel.lovelace.editor.features.types.temperature-forecast.no_forecast"
)}
</div>
</div>
`;
}
return html`
<div class="container">${this._renderBars(entries, customColor)}</div>
`;
}
private _renderBars(
entries: ForecastAttribute[],
customColor: string | undefined
): 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;
let tempMin = Infinity;
let tempMax = -Infinity;
for (const entry of entries) {
tempMin = Math.min(tempMin, entry.templow!);
tempMax = Math.max(tempMax, entry.temperature);
}
if (tempMin === tempMax) {
tempMin -= 1;
tempMax += 1;
}
const yFor = (value: number) =>
padding +
drawableHeight -
((value - tempMin) / (tempMax - tempMin)) * drawableHeight;
const isFahrenheit = this._stateObj?.attributes?.temperature_unit === "°F";
const toDisplayUnit = (tempC: number) =>
isFahrenheit ? (tempC * 9) / 5 + 32 : tempC;
const tempGradient = !customColor
? (() => {
const stops = TEMP_GRADIENT_STOPS.map((stop) => ({
y: yFor(toDisplayUnit(stop.tempC)),
cssVar: stop.cssVar,
})).sort((a, b) => a.y - b.y);
const y1 = stops[0].y;
const y2 = stops[stops.length - 1].y;
const range = y2 - y1 || 1;
return svg`<defs>
<linearGradient
id="temp-gradient"
gradientUnits="userSpaceOnUse"
x1="0" y1=${y1}
x2="0" y2=${y2}
>
${stops.map(
(stop) =>
svg`<stop
offset=${(stop.y - y1) / range}
style="stop-color: var(${stop.cssVar})"
></stop>`
)}
</linearGradient>
</defs>`;
})()
: nothing;
const bars = entries.map((entry, i) => {
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 ?? "url(#temp-gradient)";
return svg`<rect
x=${x}
y=${yHigh}
width=${barWidth}
height=${barHeight}
rx=${rx}
ry=${rx}
fill=${fill}
></rect>`;
});
return html`
<svg
width="100%"
height="100%"
viewBox="0 0 ${width} ${height}"
preserveAspectRatio="none"
>
${tempGradient}${bars}
</svg>
`;
}
private _unsubscribeForecast() {
if (this._subscribed) {
this._subscribed.then((unsub) => unsub?.()).catch(() => undefined);
this._subscribed = undefined;
}
this._subscribedType = undefined;
this._hourly = undefined;
}
private _computeHourly(forecast: ForecastAttribute[]) {
if (!forecast.length || !this._stateObj) {
this._hourly = undefined;
return;
}
const data: [number, number][] = [];
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 currentTemp = this._stateObj.attributes?.temperature;
if (currentTemp != null && !Number.isNaN(Number(currentTemp))) {
data.push([now, Number(currentTemp)]);
}
for (const entry of 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._hourly = undefined;
return;
}
let dataMin = data[0][1];
let dataMax = data[0][1];
for (const [, t] of data) {
if (t < dataMin) dataMin = t;
if (t > dataMax) dataMax = t;
}
const range = dataMax - dataMin || dataMin * 0.1;
const minY = dataMin - range * 0.1;
const maxY = dataMax + range * 0.1;
const width = this.clientWidth || 300;
const height = this.clientHeight || 42;
const { points, yAxisOrigin } = coordinates(
data,
width,
height,
data.length,
{ minX: now, maxX: maxTime, minY, maxY }
);
points.pop();
const isFahrenheit = this._stateObj.attributes?.temperature_unit === "°F";
const toDisplayUnit = (tempC: number) =>
isFahrenheit ? (tempC * 9) / 5 + 32 : tempC;
const yFor = (temp: number) =>
height - ((temp - minY) / (maxY - minY || 1)) * height;
const stops = TEMP_GRADIENT_STOPS.map((stop) => ({
y: yFor(toDisplayUnit(stop.tempC)),
cssVar: stop.cssVar,
})).sort((a, b) => a.y - b.y);
const y1 = stops[0].y;
const y2 = stops[stops.length - 1].y;
const gradientRange = y2 - y1 || 1;
const gradient: HuiGraphGradient = {
x1: 0,
y1,
x2: 0,
y2,
stops: stops.map((stop) => ({
offset: (stop.y - y1) / gradientRange,
color: `var(${stop.cssVar})`,
})),
};
this._hourly = { coordinates: points, yAxisOrigin, gradient };
}
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 ?? [];
if (this._subscribedType === "hourly") {
this._computeHourly(this._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;
--feature-temperature-freezing-color: #a89bd8;
--feature-temperature-cold-color: #7dc8dc;
--feature-temperature-mild-color: #a8dc7c;
--feature-temperature-warm-color: #e89042;
--feature-temperature-hot-color: #d24530;
}
.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;
}
hui-graph-base {
width: 100%;
height: 100%;
--accent-color: var(--feature-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-temperature-forecast-card-feature": HuiTemperatureForecastCardFeature;
}
}

View File

@@ -1,6 +1,7 @@
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 ButtonCardData = Record<string, any>;
@@ -241,6 +242,25 @@ export interface TrendGraphCardFeatureConfig {
detail?: boolean;
}
export type ForecastResolution = "daily" | "twice_daily" | "hourly";
export interface TemperatureForecastCardFeatureConfig {
type: "temperature-forecast";
forecast_type?: ForecastResolution;
days_to_show?: number;
hours_to_show?: number;
color?: string;
}
export interface PrecipitationForecastCardFeatureConfig {
type: "precipitation-forecast";
forecast_type?: ForecastResolution;
days_to_show?: number;
hours_to_show?: number;
precipitation_type?: ForecastPrecipitationType;
color?: string;
}
export const AREA_CONTROL_DOMAINS = [
"light",
"fan",
@@ -295,6 +315,8 @@ export type LovelaceCardFeatureConfig =
| FanPresetModesCardFeatureConfig
| FanSpeedCardFeatureConfig
| TrendGraphCardFeatureConfig
| TemperatureForecastCardFeatureConfig
| PrecipitationForecastCardFeatureConfig
| HumidifierToggleCardFeatureConfig
| HumidifierModesCardFeatureConfig
| LawnMowerCommandsCardFeatureConfig

View File

@@ -1,9 +1,17 @@
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, svg } from "lit";
import { css, html, LitElement, nothing, svg } from "lit";
import { customElement, property, state } from "lit/decorators";
import { strokeWidth } from "../../../data/graph";
import { getPath } from "../common/graph/get-path";
export interface HuiGraphGradient {
x1: number;
y1: number;
x2: number;
y2: number;
stops: { offset: number; color: string }[];
}
@customElement("hui-graph-base")
export class HuiGraphBase extends LitElement {
@property({ attribute: false }) public coordinates?: number[][];
@@ -11,6 +19,8 @@ export class HuiGraphBase extends LitElement {
@property({ attribute: "y-axis-origin", type: Number })
public yAxisOrigin?: number;
@property({ attribute: false }) public gradient?: HuiGraphGradient;
@state() private _path?: string;
private _uniqueId = `graph-${Math.random().toString(36).substring(2, 9)}`;
@@ -22,9 +32,29 @@ export class HuiGraphBase extends LitElement {
const lastX = this.coordinates?.length
? this.coordinates[this.coordinates.length - 1][0]
: width;
const fill = this.gradient
? `url(#${this._uniqueId}-gradient)`
: "var(--accent-color)";
return html`
${this._path
? svg`<svg width="100%" height="100%" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none">
${
this.gradient
? svg`<defs>
<linearGradient
id="${this._uniqueId}-gradient"
gradientUnits="userSpaceOnUse"
x1=${this.gradient.x1} y1=${this.gradient.y1}
x2=${this.gradient.x2} y2=${this.gradient.y2}
>
${this.gradient.stops.map(
(s) =>
svg`<stop offset=${s.offset} style="stop-color: ${s.color}"></stop>`
)}
</linearGradient>
</defs>`
: nothing
}
<g>
<mask id="${this._uniqueId}-fill">
<path
@@ -33,7 +63,7 @@ export class HuiGraphBase extends LitElement {
d="${this._path} L ${lastX}, ${yAxisOrigin} L 0, ${yAxisOrigin} z"
/>
</mask>
<rect height="100%" width="100%" fill="var(--accent-color)" mask="url(#${this._uniqueId}-fill)"></rect>
<rect height="100%" width="100%" fill=${fill} mask="url(#${this._uniqueId}-fill)"></rect>
<mask id="${this._uniqueId}-line">
<path
vector-effect="non-scaling-stroke"
@@ -46,7 +76,7 @@ export class HuiGraphBase extends LitElement {
d=${this._path}
></path>
</mask>
<rect height="100%" width="100%" fill="var(--accent-color)" mask="url(#${this._uniqueId}-line)"></rect>
<rect height="100%" width="100%" fill=${fill} mask="url(#${this._uniqueId}-line)"></rect>
</g>
</svg>`
: svg`<svg width="100%" height="100%" viewBox="0 0 ${width} ${height}"></svg>`}

View File

@@ -43,6 +43,8 @@ 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-precipitation-forecast-card-feature";
import "../card-features/hui-temperature-forecast-card-feature";
import "../card-features/hui-trend-graph-card-feature";
import type { LovelaceCardFeatureConfig } from "../card-features/types";
@@ -87,10 +89,12 @@ const TYPES = new Set<LovelaceCardFeatureConfig["type"]>([
"media-player-volume-buttons",
"media-player-volume-slider",
"numeric-input",
"precipitation-forecast",
"select-options",
"trend-graph",
"target-humidity",
"target-temperature",
"temperature-forecast",
"toggle",
"update-actions",
"vacuum-commands",

View File

@@ -42,6 +42,8 @@ import { supportsCoverTiltFavoriteCardFeature } from "../../card-features/hui-co
import { supportsCoverTiltPositionCardFeature } from "../../card-features/hui-cover-tilt-position-card-feature";
import { supportsDateSetCardFeature } from "../../card-features/hui-date-set-card-feature";
import { supportsFanDirectionCardFeature } from "../../card-features/hui-fan-direction-card-feature";
import { supportsPrecipitationForecastCardFeature } from "../../card-features/hui-precipitation-forecast-card-feature";
import { supportsTemperatureForecastCardFeature } from "../../card-features/hui-temperature-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";
@@ -119,10 +121,12 @@ const UI_FEATURE_TYPES = [
"media-player-volume-buttons",
"media-player-volume-slider",
"numeric-input",
"precipitation-forecast",
"select-options",
"trend-graph",
"target-humidity",
"target-temperature",
"temperature-forecast",
"toggle",
"update-actions",
"vacuum-commands",
@@ -149,6 +153,8 @@ const EDITABLES_FEATURE_TYPES = new Set<UiFeatureTypes>([
"cover-tilt-favorite",
"fan-preset-modes",
"humidifier-modes",
"precipitation-forecast",
"temperature-forecast",
"lawn-mower-commands",
"media-player-playback",
"light-color-favorites",
@@ -202,10 +208,12 @@ const SUPPORTS_FEATURE_TYPES: Record<
"media-player-volume-buttons": supportsMediaPlayerVolumeButtonsCardFeature,
"media-player-volume-slider": supportsMediaPlayerVolumeSliderCardFeature,
"numeric-input": supportsNumericInputCardFeature,
"precipitation-forecast": supportsPrecipitationForecastCardFeature,
"select-options": supportsSelectOptionsCardFeature,
"trend-graph": supportsTrendGraphCardFeature,
"target-humidity": supportsTargetHumidityCardFeature,
"target-temperature": supportsTargetTemperatureCardFeature,
"temperature-forecast": supportsTemperatureForecastCardFeature,
toggle: supportsToggleCardFeature,
"update-actions": supportsUpdateActionsCardFeature,
"vacuum-commands": supportsVacuumCommandsCardFeature,

View File

@@ -0,0 +1,187 @@
import type { HassEntity } from "home-assistant-js-websocket";
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,
DEFAULT_HOURS_TO_SHOW,
resolveForecastResolution,
} from "../../card-features/common/forecast";
import type {
ForecastResolution,
LovelaceCardFeatureContext,
PrecipitationForecastCardFeatureConfig,
} from "../../card-features/types";
import type { LovelaceCardFeatureEditor } from "../../types";
@customElement("hui-precipitation-forecast-card-feature-editor")
export class HuiPrecipitationForecastCardFeatureEditor
extends LitElement
implements LovelaceCardFeatureEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state() private _config?: PrecipitationForecastCardFeatureConfig;
public setConfig(config: PrecipitationForecastCardFeatureConfig): void {
this._config = config;
}
private _schema = memoizeOne(
(
stateObj: HassEntity | undefined,
forecastType: ForecastResolution,
localize: HomeAssistant["localize"]
) => {
const supportedTypes = stateObj
? getSupportedForecastTypes(stateObj)
: [];
const isHourly = forecastType === "hourly";
return [
{
name: "forecast_type",
required: true,
disabled: supportedTypes.length <= 1,
selector: {
select: {
mode: "dropdown",
options: (
["daily", "twice_daily", "hourly"] as ForecastResolution[]
).map((value) => ({
value,
label: localize(
`ui.panel.lovelace.editor.features.types.precipitation-forecast.forecast_type_options.${value}`
),
disabled: !supportedTypes.includes(value),
})),
},
},
},
isHourly
? {
name: "hours_to_show",
default: DEFAULT_HOURS_TO_SHOW,
selector: { number: { min: 1, mode: "box" } },
}
: {
name: "days_to_show",
default: DEFAULT_DAYS_TO_SHOW,
selector: { number: { min: 1, mode: "box" } },
},
{
name: "precipitation_type",
required: true,
selector: {
select: {
mode: "dropdown",
options: [
{
value: "amount",
label: localize(
"ui.panel.lovelace.editor.features.types.precipitation-forecast.precipitation_type_options.amount"
),
},
{
value: "probability",
label: localize(
"ui.panel.lovelace.editor.features.types.precipitation-forecast.precipitation_type_options.probability"
),
},
],
},
},
},
{
name: "color",
selector: {
ui_color: {
default_color: "state",
include_state: true,
},
},
},
] 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 resolvedType =
resolveForecastResolution(stateObj, this._config.forecast_type) ||
"daily";
const isHourly = resolvedType === "hourly";
const data: PrecipitationForecastCardFeatureConfig = {
...this._config,
forecast_type: resolvedType,
precipitation_type: this._config.precipitation_type ?? "amount",
...(isHourly
? { hours_to_show: this._config.hours_to_show ?? DEFAULT_HOURS_TO_SHOW }
: { days_to_show: this._config.days_to_show ?? DEFAULT_DAYS_TO_SHOW }),
};
const schema = this._schema(stateObj, resolvedType, 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.temperature-forecast.forecast_type"
);
case "days_to_show":
case "hours_to_show":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
);
case "precipitation_type":
return this.hass!.localize(
"ui.panel.lovelace.editor.features.types.precipitation-forecast.precipitation_type"
);
case "color":
return this.hass!.localize(
"ui.panel.lovelace.editor.features.types.precipitation-forecast.color"
);
default:
return "";
}
};
}
declare global {
interface HTMLElementTagNameMap {
"hui-precipitation-forecast-card-feature-editor": HuiPrecipitationForecastCardFeatureEditor;
}
}

View File

@@ -0,0 +1,159 @@
import type { HassEntity } from "home-assistant-js-websocket";
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,
DEFAULT_HOURS_TO_SHOW,
resolveForecastResolution,
} from "../../card-features/common/forecast";
import type {
ForecastResolution,
LovelaceCardFeatureContext,
TemperatureForecastCardFeatureConfig,
} from "../../card-features/types";
import type { LovelaceCardFeatureEditor } from "../../types";
@customElement("hui-temperature-forecast-card-feature-editor")
export class HuiTemperatureForecastCardFeatureEditor
extends LitElement
implements LovelaceCardFeatureEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state() private _config?: TemperatureForecastCardFeatureConfig;
public setConfig(config: TemperatureForecastCardFeatureConfig): void {
this._config = config;
}
private _schema = memoizeOne(
(
stateObj: HassEntity | undefined,
forecastType: ForecastResolution,
localize: HomeAssistant["localize"]
) => {
const supportedTypes = stateObj
? getSupportedForecastTypes(stateObj)
: [];
const isHourly = forecastType === "hourly";
return [
{
name: "forecast_type",
required: true,
disabled: supportedTypes.length <= 1,
selector: {
select: {
mode: "dropdown",
options: (
["daily", "twice_daily", "hourly"] as ForecastResolution[]
).map((value) => ({
value,
label: localize(
`ui.panel.lovelace.editor.features.types.temperature-forecast.forecast_type_options.${value}`
),
disabled: !supportedTypes.includes(value),
})),
},
},
},
isHourly
? {
name: "hours_to_show",
default: DEFAULT_HOURS_TO_SHOW,
selector: { number: { min: 1, mode: "box" } },
}
: {
name: "days_to_show",
default: DEFAULT_DAYS_TO_SHOW,
selector: { number: { min: 1, mode: "box" } },
},
{
name: "color",
selector: {
ui_color: {
default_color: "state",
include_state: true,
},
},
},
] 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 resolvedType =
resolveForecastResolution(stateObj, this._config.forecast_type) ||
"daily";
const isHourly = resolvedType === "hourly";
const data: TemperatureForecastCardFeatureConfig = {
...this._config,
forecast_type: resolvedType,
...(isHourly
? { hours_to_show: this._config.hours_to_show ?? DEFAULT_HOURS_TO_SHOW }
: { days_to_show: this._config.days_to_show ?? DEFAULT_DAYS_TO_SHOW }),
};
const schema = this._schema(stateObj, resolvedType, 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.temperature-forecast.forecast_type"
);
case "days_to_show":
case "hours_to_show":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
);
case "color":
return this.hass!.localize(
"ui.panel.lovelace.editor.features.types.temperature-forecast.color"
);
default:
return "";
}
};
}
declare global {
interface HTMLElementTagNameMap {
"hui-temperature-forecast-card-feature-editor": HuiTemperatureForecastCardFeatureEditor;
}
}

View File

@@ -6,8 +6,7 @@ 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-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-list-item";
import "../../../../components/ha-switch";
import type {
LovelaceViewConfig,
@@ -66,26 +65,24 @@ export class HuiViewVisibilityEditor extends LitElement {
"ui.panel.lovelace.editor.edit_view.visibility.select_users"
)}
</p>
<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>
${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>
`
)}
`;
}
@@ -139,16 +136,6 @@ export class HuiViewVisibilityEditor extends LitElement {
:host {
display: block;
}
ha-md-list {
padding: 0;
}
ha-md-list-item {
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
--md-list-item-top-space: var(--ha-space-1);
--md-list-item-bottom-space: var(--ha-space-1);
--md-list-item-one-line-container-height: 48px;
}
`;
}

View File

@@ -48,18 +48,11 @@ export const filterUnavailableBatteryEntities = (
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 computeBatteryTileCard = (entityId: string): TileCardConfig => ({
type: "tile",
entity: entityId,
name: { type: "device" },
});
const processAreasForBattery = (
areaIds: string[],
@@ -79,7 +72,7 @@ const processAreasForBattery = (
const areaCards: LovelaceCardConfig[] = [];
for (const entityId of areaBatteryEntities) {
areaCards.push(computeBatteryTileCard(hass.entities, entityId));
areaCards.push(computeBatteryTileCard(entityId));
}
if (areaCards.length > 0) {
@@ -112,7 +105,7 @@ const processUnassignedEntities = (
const cards: LovelaceCardConfig[] = [];
for (const entityId of unassignedEntities) {
cards.push(computeBatteryTileCard(hass.entities, entityId));
cards.push(computeBatteryTileCard(entityId));
}
return cards;

View File

@@ -17,24 +17,14 @@ const addData = async (
addFunc = "__addLocaleData"
) => {
// Add function will only exist if constructor is polyfilled
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 (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 (result.ok) {
(Intl[obj] as any)[addFunc](await result.json());
}
} catch (err) {
// Network/access-control failures should not block startup, but they
// degrade i18n features so surface a warning for diagnostics.
// eslint-disable-next-line no-console
console.warn(`Failed to load Intl.${obj} locale data for ${language}`, {
url,
error: err,
});
}
};

View File

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

View File

@@ -10143,6 +10143,33 @@
"trend-graph": {
"label": "Trend graph",
"detail": "Show more detail"
},
"temperature-forecast": {
"label": "Temperature forecast",
"no_forecast": "No forecast data available",
"forecast_type": "Forecast type",
"forecast_type_options": {
"daily": "Daily",
"twice_daily": "Twice daily",
"hourly": "Hourly"
},
"color": "Color"
},
"precipitation-forecast": {
"label": "Precipitation forecast",
"no_forecast": "[%key:ui::panel::lovelace::editor::features::types::temperature-forecast::no_forecast%]",
"forecast_type": "[%key:ui::panel::lovelace::editor::features::types::temperature-forecast::forecast_type%]",
"forecast_type_options": {
"daily": "[%key:ui::panel::lovelace::editor::features::types::temperature-forecast::forecast_type_options::daily%]",
"twice_daily": "[%key:ui::panel::lovelace::editor::features::types::temperature-forecast::forecast_type_options::twice_daily%]",
"hourly": "[%key:ui::panel::lovelace::editor::features::types::temperature-forecast::forecast_type_options::hourly%]"
},
"precipitation_type": "Precipitation type",
"precipitation_type_options": {
"amount": "Amount",
"probability": "Probability"
},
"color": "[%key:ui::panel::lovelace::editor::features::types::temperature-forecast::color%]"
}
}
},