mirror of
https://github.com/home-assistant/frontend.git
synced 2026-05-06 09:23:08 +00:00
Compare commits
1 Commits
dev
...
forecast-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dde2230185 |
@@ -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}/`)),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
>
|
||||
`
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(", ");
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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!
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
34
src/panels/lovelace/card-features/common/forecast.ts
Normal file
34
src/panels/lovelace/card-features/common/forecast.ts
Normal 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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>`}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user