mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-19 15:26:36 +00:00
Implement forecast types for Weather (#15028)
* Implement forecast types * editor * Fix twice_daily * All cards * Review comments * hasforecast * card-editor * forecast default * Review comments * fix entity row * Remove legacy option * Check if selected forecast is supported when picking entity * Always show weather_to_show selector * comments * Update types.ts * Hourly before twice-daily * Expose forecast via WS instead of as state attributes * Unsubscribe on disconnect * lint * prettier * Fix _forecastSupported * Improve conditions for subscribing to forecast updates * Teach weather entity row and more info to subscribe * Fix subscribing * Deduplicate code in getForecast * Simplify * Tweak subscribe logic * Address review comments --------- Co-authored-by: Erik <erik@montnemery.com> Co-authored-by: Bram Kragten <mail@bramkragten.nl>
This commit is contained in:
parent
0eebc9095c
commit
ec58862f3e
@ -24,10 +24,19 @@ import {
|
|||||||
} from "home-assistant-js-websocket";
|
} from "home-assistant-js-websocket";
|
||||||
import { css, html, svg, SVGTemplateResult, TemplateResult } from "lit";
|
import { css, html, svg, SVGTemplateResult, TemplateResult } from "lit";
|
||||||
import { styleMap } from "lit/directives/style-map";
|
import { styleMap } from "lit/directives/style-map";
|
||||||
|
import { supportsFeature } from "../common/entity/supports-feature";
|
||||||
import { formatNumber } from "../common/number/format_number";
|
import { formatNumber } from "../common/number/format_number";
|
||||||
import "../components/ha-svg-icon";
|
import "../components/ha-svg-icon";
|
||||||
import type { HomeAssistant } from "../types";
|
import type { HomeAssistant } from "../types";
|
||||||
|
|
||||||
|
export const enum WeatherEntityFeature {
|
||||||
|
FORECAST_DAILY = 1,
|
||||||
|
FORECAST_HOURLY = 2,
|
||||||
|
FORECAST_TWICE_DAILY = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ForecastType = "legacy" | "hourly" | "daily" | "twice_daily";
|
||||||
|
|
||||||
interface ForecastAttribute {
|
interface ForecastAttribute {
|
||||||
temperature: number;
|
temperature: number;
|
||||||
datetime: string;
|
datetime: string;
|
||||||
@ -36,7 +45,7 @@ interface ForecastAttribute {
|
|||||||
precipitation_probability?: number;
|
precipitation_probability?: number;
|
||||||
humidity?: number;
|
humidity?: number;
|
||||||
condition?: string;
|
condition?: string;
|
||||||
daytime?: boolean;
|
is_daytime?: boolean;
|
||||||
pressure?: number;
|
pressure?: number;
|
||||||
wind_speed?: string;
|
wind_speed?: string;
|
||||||
}
|
}
|
||||||
@ -45,6 +54,7 @@ interface WeatherEntityAttributes extends HassEntityAttributeBase {
|
|||||||
attribution?: string;
|
attribution?: string;
|
||||||
humidity?: number;
|
humidity?: number;
|
||||||
forecast?: ForecastAttribute[];
|
forecast?: ForecastAttribute[];
|
||||||
|
is_daytime?: boolean;
|
||||||
pressure?: number;
|
pressure?: number;
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
visibility?: number;
|
visibility?: number;
|
||||||
@ -57,6 +67,11 @@ interface WeatherEntityAttributes extends HassEntityAttributeBase {
|
|||||||
wind_speed_unit: string;
|
wind_speed_unit: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ForecastEvent {
|
||||||
|
type: "hourly" | "daily" | "twice_daily";
|
||||||
|
forecast: [ForecastAttribute] | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WeatherEntity extends HassEntityBase {
|
export interface WeatherEntity extends HassEntityBase {
|
||||||
attributes: WeatherEntityAttributes;
|
attributes: WeatherEntityAttributes;
|
||||||
}
|
}
|
||||||
@ -225,9 +240,10 @@ export const getWeatherUnit = (
|
|||||||
|
|
||||||
export const getSecondaryWeatherAttribute = (
|
export const getSecondaryWeatherAttribute = (
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
stateObj: WeatherEntity
|
stateObj: WeatherEntity,
|
||||||
|
forecast: ForecastAttribute[]
|
||||||
): TemplateResult | undefined => {
|
): TemplateResult | undefined => {
|
||||||
const extrema = getWeatherExtrema(hass, stateObj);
|
const extrema = getWeatherExtrema(hass, stateObj, forecast);
|
||||||
|
|
||||||
if (extrema) {
|
if (extrema) {
|
||||||
return extrema;
|
return extrema;
|
||||||
@ -237,11 +253,11 @@ export const getSecondaryWeatherAttribute = (
|
|||||||
let attribute: string;
|
let attribute: string;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
stateObj.attributes.forecast?.length &&
|
forecast?.length &&
|
||||||
stateObj.attributes.forecast[0].precipitation !== undefined &&
|
forecast[0].precipitation !== undefined &&
|
||||||
stateObj.attributes.forecast[0].precipitation !== null
|
forecast[0].precipitation !== null
|
||||||
) {
|
) {
|
||||||
value = stateObj.attributes.forecast[0].precipitation!;
|
value = forecast[0].precipitation!;
|
||||||
attribute = "precipitation";
|
attribute = "precipitation";
|
||||||
} else if ("humidity" in stateObj.attributes) {
|
} else if ("humidity" in stateObj.attributes) {
|
||||||
value = stateObj.attributes.humidity!;
|
value = stateObj.attributes.humidity!;
|
||||||
@ -265,9 +281,10 @@ export const getSecondaryWeatherAttribute = (
|
|||||||
|
|
||||||
const getWeatherExtrema = (
|
const getWeatherExtrema = (
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
stateObj: WeatherEntity
|
stateObj: WeatherEntity,
|
||||||
|
forecast: ForecastAttribute[]
|
||||||
): TemplateResult | undefined => {
|
): TemplateResult | undefined => {
|
||||||
if (!stateObj.attributes.forecast?.length) {
|
if (!forecast?.length) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -275,18 +292,18 @@ const getWeatherExtrema = (
|
|||||||
let tempHigh: number | undefined;
|
let tempHigh: number | undefined;
|
||||||
const today = new Date().getDate();
|
const today = new Date().getDate();
|
||||||
|
|
||||||
for (const forecast of stateObj.attributes.forecast!) {
|
for (const fc of forecast!) {
|
||||||
if (new Date(forecast.datetime).getDate() !== today) {
|
if (new Date(fc.datetime).getDate() !== today) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (!tempHigh || forecast.temperature > tempHigh) {
|
if (!tempHigh || fc.temperature > tempHigh) {
|
||||||
tempHigh = forecast.temperature;
|
tempHigh = fc.temperature;
|
||||||
}
|
}
|
||||||
if (!tempLow || (forecast.templow && forecast.templow < tempLow)) {
|
if (!tempLow || (fc.templow && fc.templow < tempLow)) {
|
||||||
tempLow = forecast.templow;
|
tempLow = fc.templow;
|
||||||
}
|
}
|
||||||
if (!forecast.templow && (!tempLow || forecast.temperature < tempLow)) {
|
if (!fc.templow && (!tempLow || fc.temperature < tempLow)) {
|
||||||
tempLow = forecast.temperature;
|
tempLow = fc.temperature;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -510,7 +527,7 @@ export const weatherIcon = (state?: string, nightTime?: boolean): string =>
|
|||||||
|
|
||||||
const DAY_IN_MILLISECONDS = 86400000;
|
const DAY_IN_MILLISECONDS = 86400000;
|
||||||
|
|
||||||
export const isForecastHourly = (
|
const isForecastHourly = (
|
||||||
forecast?: ForecastAttribute[]
|
forecast?: ForecastAttribute[]
|
||||||
): boolean | undefined => {
|
): boolean | undefined => {
|
||||||
if (forecast && forecast?.length && forecast?.length > 2) {
|
if (forecast && forecast?.length && forecast?.length > 2) {
|
||||||
@ -538,3 +555,93 @@ export const getWeatherConvertibleUnits = (
|
|||||||
hass.callWS({
|
hass.callWS({
|
||||||
type: "weather/convertible_units",
|
type: "weather/convertible_units",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const getLegacyForecast = (
|
||||||
|
weather_attributes?: WeatherEntityAttributes | undefined
|
||||||
|
):
|
||||||
|
| {
|
||||||
|
forecast: ForecastAttribute[];
|
||||||
|
type: "daily" | "hourly" | "twice_daily";
|
||||||
|
}
|
||||||
|
| undefined => {
|
||||||
|
if (weather_attributes?.forecast && weather_attributes.forecast.length > 2) {
|
||||||
|
const hourly = isForecastHourly(weather_attributes.forecast);
|
||||||
|
if (hourly === true) {
|
||||||
|
const dateFirst = new Date(weather_attributes.forecast![0].datetime);
|
||||||
|
const datelast = new Date(
|
||||||
|
weather_attributes.forecast![
|
||||||
|
weather_attributes.forecast!.length - 1
|
||||||
|
].datetime
|
||||||
|
);
|
||||||
|
const dayDiff = datelast.getTime() - dateFirst.getTime();
|
||||||
|
const dayNight = dayDiff > DAY_IN_MILLISECONDS;
|
||||||
|
return {
|
||||||
|
forecast: weather_attributes.forecast,
|
||||||
|
type: dayNight ? "twice_daily" : "hourly",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { forecast: weather_attributes.forecast, type: "daily" };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getForecast = (
|
||||||
|
weather_attributes?: WeatherEntityAttributes | undefined,
|
||||||
|
forecast_event?: ForecastEvent,
|
||||||
|
forecast_type?: ForecastType | undefined
|
||||||
|
):
|
||||||
|
| {
|
||||||
|
forecast: ForecastAttribute[];
|
||||||
|
type: "daily" | "hourly" | "twice_daily";
|
||||||
|
}
|
||||||
|
| undefined => {
|
||||||
|
if (forecast_type === undefined) {
|
||||||
|
if (
|
||||||
|
forecast_event?.type !== undefined &&
|
||||||
|
forecast_event?.forecast &&
|
||||||
|
forecast_event?.forecast?.length > 2
|
||||||
|
) {
|
||||||
|
return { forecast: forecast_event.forecast, type: forecast_event?.type };
|
||||||
|
}
|
||||||
|
return getLegacyForecast(weather_attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (forecast_type === "legacy") {
|
||||||
|
return getLegacyForecast(weather_attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
forecast_type === forecast_event?.type &&
|
||||||
|
forecast_event?.forecast &&
|
||||||
|
forecast_event?.forecast?.length > 2
|
||||||
|
) {
|
||||||
|
return { forecast: forecast_event.forecast, type: forecast_type };
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const subscribeForecast = (
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entity_id: string,
|
||||||
|
forecast_type: "daily" | "hourly" | "twice_daily",
|
||||||
|
callback: (forecastevent: ForecastEvent) => void
|
||||||
|
) =>
|
||||||
|
hass.connection.subscribeMessage<ForecastEvent>(callback, {
|
||||||
|
type: "weather/subscribe_forecast",
|
||||||
|
forecast_type,
|
||||||
|
entity_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getDefaultForecastType = (stateObj: HassEntityBase) => {
|
||||||
|
if (supportsFeature(stateObj, WeatherEntityFeature.FORECAST_DAILY)) {
|
||||||
|
return "daily";
|
||||||
|
}
|
||||||
|
if (supportsFeature(stateObj, WeatherEntityFeature.FORECAST_HOURLY)) {
|
||||||
|
return "hourly";
|
||||||
|
}
|
||||||
|
if (supportsFeature(stateObj, WeatherEntityFeature.FORECAST_TWICE_DAILY)) {
|
||||||
|
return "twice_daily";
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
@ -13,15 +13,18 @@ import {
|
|||||||
PropertyValues,
|
PropertyValues,
|
||||||
nothing,
|
nothing,
|
||||||
} from "lit";
|
} from "lit";
|
||||||
import { customElement, property } from "lit/decorators";
|
import { customElement, property, state } from "lit/decorators";
|
||||||
import { formatDateWeekdayDay } from "../../../common/datetime/format_date";
|
import { formatDateWeekdayDay } from "../../../common/datetime/format_date";
|
||||||
import { formatTimeWeekday } from "../../../common/datetime/format_time";
|
import { formatTimeWeekday } from "../../../common/datetime/format_time";
|
||||||
import { formatNumber } from "../../../common/number/format_number";
|
import { formatNumber } from "../../../common/number/format_number";
|
||||||
import "../../../components/ha-svg-icon";
|
import "../../../components/ha-svg-icon";
|
||||||
import {
|
import {
|
||||||
|
getDefaultForecastType,
|
||||||
|
getForecast,
|
||||||
getWeatherUnit,
|
getWeatherUnit,
|
||||||
getWind,
|
getWind,
|
||||||
isForecastHourly,
|
subscribeForecast,
|
||||||
|
ForecastEvent,
|
||||||
WeatherEntity,
|
WeatherEntity,
|
||||||
weatherIcons,
|
weatherIcons,
|
||||||
} from "../../../data/weather";
|
} from "../../../data/weather";
|
||||||
@ -33,6 +36,48 @@ class MoreInfoWeather extends LitElement {
|
|||||||
|
|
||||||
@property() public stateObj?: WeatherEntity;
|
@property() public stateObj?: WeatherEntity;
|
||||||
|
|
||||||
|
@state() private _forecastEvent?: ForecastEvent;
|
||||||
|
|
||||||
|
@state() private _subscribed?: Promise<() => void>;
|
||||||
|
|
||||||
|
private _unsubscribeForecastEvents() {
|
||||||
|
if (this._subscribed) {
|
||||||
|
this._subscribed.then((unsub) => unsub());
|
||||||
|
this._subscribed = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _subscribeForecastEvents() {
|
||||||
|
this._unsubscribeForecastEvents();
|
||||||
|
if (!this.isConnected || !this.hass || !this.stateObj) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const forecastType = getDefaultForecastType(this.stateObj);
|
||||||
|
if (forecastType) {
|
||||||
|
this._subscribed = subscribeForecast(
|
||||||
|
this.hass!,
|
||||||
|
this.stateObj!.entity_id,
|
||||||
|
forecastType,
|
||||||
|
(event) => {
|
||||||
|
this._forecastEvent = event;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
if (this.hasUpdated) {
|
||||||
|
this._subscribeForecastEvents();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public disconnectedCallback(): void {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
this._unsubscribeForecastEvents();
|
||||||
|
}
|
||||||
|
|
||||||
protected shouldUpdate(changedProps: PropertyValues): boolean {
|
protected shouldUpdate(changedProps: PropertyValues): boolean {
|
||||||
if (changedProps.has("stateObj")) {
|
if (changedProps.has("stateObj")) {
|
||||||
return true;
|
return true;
|
||||||
@ -50,12 +95,33 @@ class MoreInfoWeather extends LitElement {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected updated(changedProps: PropertyValues): void {
|
||||||
|
super.updated(changedProps);
|
||||||
|
|
||||||
|
if (changedProps.has("stateObj") || !this._subscribed) {
|
||||||
|
const oldState = changedProps.get("stateObj") as
|
||||||
|
| WeatherEntity
|
||||||
|
| undefined;
|
||||||
|
if (
|
||||||
|
oldState?.entity_id !== this.stateObj?.entity_id ||
|
||||||
|
!this._subscribed
|
||||||
|
) {
|
||||||
|
this._subscribeForecastEvents();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected render() {
|
protected render() {
|
||||||
if (!this.hass || !this.stateObj) {
|
if (!this.hass || !this.stateObj) {
|
||||||
return nothing;
|
return nothing;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hourly = isForecastHourly(this.stateObj.attributes.forecast);
|
const forecastData = getForecast(
|
||||||
|
this.stateObj.attributes,
|
||||||
|
this._forecastEvent
|
||||||
|
);
|
||||||
|
const forecast = forecastData?.forecast;
|
||||||
|
const hourly = forecastData?.type === "hourly";
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
${this._showValue(this.stateObj.attributes.temperature)
|
${this._showValue(this.stateObj.attributes.temperature)
|
||||||
@ -144,12 +210,12 @@ class MoreInfoWeather extends LitElement {
|
|||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: ""}
|
: ""}
|
||||||
${this.stateObj.attributes.forecast
|
${forecast
|
||||||
? html`
|
? html`
|
||||||
<div class="section">
|
<div class="section">
|
||||||
${this.hass.localize("ui.card.weather.forecast")}:
|
${this.hass.localize("ui.card.weather.forecast")}:
|
||||||
</div>
|
</div>
|
||||||
${this.stateObj.attributes.forecast.map((item) =>
|
${forecast.map((item) =>
|
||||||
this._showValue(item.templow) || this._showValue(item.temperature)
|
this._showValue(item.templow) || this._showValue(item.temperature)
|
||||||
? html`<div class="flex">
|
? html`<div class="flex">
|
||||||
${item.condition
|
${item.condition
|
||||||
@ -176,6 +242,9 @@ class MoreInfoWeather extends LitElement {
|
|||||||
this.hass.locale,
|
this.hass.locale,
|
||||||
this.hass.config
|
this.hass.config
|
||||||
)}
|
)}
|
||||||
|
${item.is_daytime !== false
|
||||||
|
? this.hass!.localize("ui.card.weather.day")
|
||||||
|
: this.hass!.localize("ui.card.weather.night")}
|
||||||
</div>
|
</div>
|
||||||
`}
|
`}
|
||||||
<div class="templow">
|
<div class="templow">
|
||||||
|
@ -20,11 +20,13 @@ import "../../../components/ha-svg-icon";
|
|||||||
import { UNAVAILABLE } from "../../../data/entity";
|
import { UNAVAILABLE } from "../../../data/entity";
|
||||||
import { ActionHandlerEvent } from "../../../data/lovelace";
|
import { ActionHandlerEvent } from "../../../data/lovelace";
|
||||||
import {
|
import {
|
||||||
|
getForecast,
|
||||||
getSecondaryWeatherAttribute,
|
getSecondaryWeatherAttribute,
|
||||||
getWeatherStateIcon,
|
getWeatherStateIcon,
|
||||||
getWeatherUnit,
|
getWeatherUnit,
|
||||||
getWind,
|
getWind,
|
||||||
isForecastHourly,
|
subscribeForecast,
|
||||||
|
ForecastEvent,
|
||||||
weatherAttrIcons,
|
weatherAttrIcons,
|
||||||
WeatherEntity,
|
WeatherEntity,
|
||||||
weatherSVGStyles,
|
weatherSVGStyles,
|
||||||
@ -41,8 +43,6 @@ import type { LovelaceCard, LovelaceCardEditor } from "../types";
|
|||||||
import type { WeatherForecastCardConfig } from "./types";
|
import type { WeatherForecastCardConfig } from "./types";
|
||||||
import { formatDateWeekdayShort } from "../../../common/datetime/format_date";
|
import { formatDateWeekdayShort } from "../../../common/datetime/format_date";
|
||||||
|
|
||||||
const DAY_IN_MILLISECONDS = 86400000;
|
|
||||||
|
|
||||||
@customElement("hui-weather-forecast-card")
|
@customElement("hui-weather-forecast-card")
|
||||||
class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
|
class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
|
||||||
public static async getConfigElement(): Promise<LovelaceCardEditor> {
|
public static async getConfigElement(): Promise<LovelaceCardEditor> {
|
||||||
@ -72,13 +72,54 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
|
|||||||
|
|
||||||
@state() private _config?: WeatherForecastCardConfig;
|
@state() private _config?: WeatherForecastCardConfig;
|
||||||
|
|
||||||
|
@state() private _forecastEvent?: ForecastEvent;
|
||||||
|
|
||||||
|
@state() private _subscribed?: Promise<() => void>;
|
||||||
|
|
||||||
@property({ type: Boolean, reflect: true, attribute: "veryverynarrow" })
|
@property({ type: Boolean, reflect: true, attribute: "veryverynarrow" })
|
||||||
private _veryVeryNarrow = false;
|
private _veryVeryNarrow = false;
|
||||||
|
|
||||||
private _resizeObserver?: ResizeObserver;
|
private _resizeObserver?: ResizeObserver;
|
||||||
|
|
||||||
|
private _needForecastSubscription() {
|
||||||
|
return (
|
||||||
|
this._config!.forecast_type && this._config!.forecast_type !== "legacy"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _unsubscribeForecastEvents() {
|
||||||
|
if (this._subscribed) {
|
||||||
|
this._subscribed.then((unsub) => unsub());
|
||||||
|
this._subscribed = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _subscribeForecastEvents() {
|
||||||
|
this._unsubscribeForecastEvents();
|
||||||
|
if (
|
||||||
|
!this.isConnected ||
|
||||||
|
!this.hass ||
|
||||||
|
!this._config ||
|
||||||
|
!this._needForecastSubscription()
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._subscribed = subscribeForecast(
|
||||||
|
this.hass!,
|
||||||
|
this._config!.entity,
|
||||||
|
this._config!.forecast_type as "daily" | "hourly" | "twice_daily",
|
||||||
|
(event) => {
|
||||||
|
this._forecastEvent = event;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public connectedCallback(): void {
|
public connectedCallback(): void {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
|
if (this.hasUpdated && this._config && this.hass) {
|
||||||
|
this._subscribeForecastEvents();
|
||||||
|
}
|
||||||
this.updateComplete.then(() => this._attachObserver());
|
this.updateComplete.then(() => this._attachObserver());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,6 +127,7 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
|
|||||||
if (this._resizeObserver) {
|
if (this._resizeObserver) {
|
||||||
this._resizeObserver.disconnect();
|
this._resizeObserver.disconnect();
|
||||||
}
|
}
|
||||||
|
this._unsubscribeForecastEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
public getCardSize(): number {
|
public getCardSize(): number {
|
||||||
@ -111,7 +153,10 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected shouldUpdate(changedProps: PropertyValues): boolean {
|
protected shouldUpdate(changedProps: PropertyValues): boolean {
|
||||||
return hasConfigOrEntityChanged(this, changedProps);
|
return (
|
||||||
|
hasConfigOrEntityChanged(this, changedProps) ||
|
||||||
|
changedProps.has("forecastEvent")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public willUpdate(): void {
|
public willUpdate(): void {
|
||||||
@ -130,6 +175,10 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (changedProps.has("_config") || !this._subscribed) {
|
||||||
|
this._subscribeForecastEvents();
|
||||||
|
}
|
||||||
|
|
||||||
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
|
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
|
||||||
const oldConfig = changedProps.get("_config") as
|
const oldConfig = changedProps.get("_config") as
|
||||||
| WeatherForecastCardConfig
|
| WeatherForecastCardConfig
|
||||||
@ -172,23 +221,19 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const forecastData = getForecast(
|
||||||
|
stateObj.attributes,
|
||||||
|
this._forecastEvent,
|
||||||
|
this._config?.forecast_type
|
||||||
|
);
|
||||||
const forecast =
|
const forecast =
|
||||||
this._config?.show_forecast !== false &&
|
this._config?.show_forecast !== false && forecastData?.forecast?.length
|
||||||
stateObj.attributes.forecast?.length
|
? forecastData.forecast.slice(0, this._veryVeryNarrow ? 3 : 5)
|
||||||
? stateObj.attributes.forecast.slice(0, this._veryVeryNarrow ? 3 : 5)
|
|
||||||
: undefined;
|
: undefined;
|
||||||
const weather = !forecast || this._config?.show_current !== false;
|
const weather = !forecast || this._config?.show_current !== false;
|
||||||
|
|
||||||
const hourly = isForecastHourly(forecast);
|
const hourly = forecastData?.type === "hourly";
|
||||||
let dayNight: boolean | undefined;
|
const dayNight = forecastData?.type === "twice_daily";
|
||||||
|
|
||||||
if (hourly) {
|
|
||||||
const dateFirst = new Date(forecast![0].datetime);
|
|
||||||
const datelast = new Date(forecast![forecast!.length - 1].datetime);
|
|
||||||
const dayDiff = datelast.getTime() - dateFirst.getTime();
|
|
||||||
|
|
||||||
dayNight = dayDiff > DAY_IN_MILLISECONDS;
|
|
||||||
}
|
|
||||||
|
|
||||||
const weatherStateIcon = getWeatherStateIcon(stateObj.state, this);
|
const weatherStateIcon = getWeatherStateIcon(stateObj.state, this);
|
||||||
const name = this._config.name ?? computeStateName(stateObj);
|
const name = this._config.name ?? computeStateName(stateObj);
|
||||||
@ -285,7 +330,11 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
|
|||||||
)}
|
)}
|
||||||
`}
|
`}
|
||||||
`
|
`
|
||||||
: getSecondaryWeatherAttribute(this.hass, stateObj)}
|
: getSecondaryWeatherAttribute(
|
||||||
|
this.hass,
|
||||||
|
stateObj,
|
||||||
|
forecast!
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -308,7 +357,7 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
|
|||||||
{ weekday: "short" }
|
{ weekday: "short" }
|
||||||
)}
|
)}
|
||||||
<div class="daynight">
|
<div class="daynight">
|
||||||
${item.daytime === undefined || item.daytime
|
${item.is_daytime !== false
|
||||||
? this.hass!.localize(
|
? this.hass!.localize(
|
||||||
"ui.card.weather.day"
|
"ui.card.weather.day"
|
||||||
)
|
)
|
||||||
@ -340,7 +389,8 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
|
|||||||
item.condition!,
|
item.condition!,
|
||||||
this,
|
this,
|
||||||
!(
|
!(
|
||||||
item.daytime || item.daytime === undefined
|
item.is_daytime ||
|
||||||
|
item.is_daytime === undefined
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -12,6 +12,7 @@ import {
|
|||||||
import { LovelaceHeaderFooterConfig } from "../header-footer/types";
|
import { LovelaceHeaderFooterConfig } from "../header-footer/types";
|
||||||
import { HaDurationData } from "../../../components/ha-duration-input";
|
import { HaDurationData } from "../../../components/ha-duration-input";
|
||||||
import { LovelaceTileFeatureConfig } from "../tile-features/types";
|
import { LovelaceTileFeatureConfig } from "../tile-features/types";
|
||||||
|
import { ForecastType } from "../../../data/weather";
|
||||||
|
|
||||||
export interface AlarmPanelCardConfig extends LovelaceCardConfig {
|
export interface AlarmPanelCardConfig extends LovelaceCardConfig {
|
||||||
entity: string;
|
entity: string;
|
||||||
@ -444,6 +445,7 @@ export interface WeatherForecastCardConfig extends LovelaceCardConfig {
|
|||||||
name?: string;
|
name?: string;
|
||||||
show_current?: boolean;
|
show_current?: boolean;
|
||||||
show_forecast?: boolean;
|
show_forecast?: boolean;
|
||||||
|
forecast_type?: ForecastType;
|
||||||
secondary_info_attribute?: keyof TranslationDict["ui"]["card"]["weather"]["attributes"];
|
secondary_info_attribute?: keyof TranslationDict["ui"]["card"]["weather"]["attributes"];
|
||||||
theme?: string;
|
theme?: string;
|
||||||
tap_action?: ActionConfig;
|
tap_action?: ActionConfig;
|
||||||
|
@ -7,12 +7,14 @@ import type { LocalizeFunc } from "../../../../common/translations/localize";
|
|||||||
import "../../../../components/ha-form/ha-form";
|
import "../../../../components/ha-form/ha-form";
|
||||||
import type { SchemaUnion } from "../../../../components/ha-form/types";
|
import type { SchemaUnion } from "../../../../components/ha-form/types";
|
||||||
import { UNAVAILABLE } from "../../../../data/entity";
|
import { UNAVAILABLE } from "../../../../data/entity";
|
||||||
import type { WeatherEntity } from "../../../../data/weather";
|
import type { ForecastType, WeatherEntity } from "../../../../data/weather";
|
||||||
|
import { WeatherEntityFeature } from "../../../../data/weather";
|
||||||
import type { HomeAssistant } from "../../../../types";
|
import type { HomeAssistant } from "../../../../types";
|
||||||
import type { WeatherForecastCardConfig } from "../../cards/types";
|
import type { WeatherForecastCardConfig } from "../../cards/types";
|
||||||
import type { LovelaceCardEditor } from "../../types";
|
import type { LovelaceCardEditor } from "../../types";
|
||||||
import { actionConfigStruct } from "../structs/action-struct";
|
import { actionConfigStruct } from "../structs/action-struct";
|
||||||
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
|
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
|
||||||
|
import { supportsFeature } from "../../../../common/entity/supports-feature";
|
||||||
|
|
||||||
const cardConfigStruct = assign(
|
const cardConfigStruct = assign(
|
||||||
baseLovelaceCardConfig,
|
baseLovelaceCardConfig,
|
||||||
@ -22,6 +24,7 @@ const cardConfigStruct = assign(
|
|||||||
theme: optional(string()),
|
theme: optional(string()),
|
||||||
show_current: optional(boolean()),
|
show_current: optional(boolean()),
|
||||||
show_forecast: optional(boolean()),
|
show_forecast: optional(boolean()),
|
||||||
|
forecast_type: optional(string()),
|
||||||
secondary_info_attribute: optional(string()),
|
secondary_info_attribute: optional(string()),
|
||||||
tap_action: optional(actionConfigStruct),
|
tap_action: optional(actionConfigStruct),
|
||||||
hold_action: optional(actionConfigStruct),
|
hold_action: optional(actionConfigStruct),
|
||||||
@ -44,7 +47,7 @@ export class HuiWeatherForecastCardEditor
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
/* cannot show forecast in case it is unavailable on the entity */
|
/* cannot show forecast in case it is unavailable on the entity */
|
||||||
(config.show_forecast === true && this._has_forecast === false) ||
|
(config.show_forecast === true && this._hasForecast === false) ||
|
||||||
/* cannot hide both weather and forecast, need one of them */
|
/* cannot hide both weather and forecast, need one of them */
|
||||||
(config.show_current === false && config.show_forecast === false)
|
(config.show_current === false && config.show_forecast === false)
|
||||||
) {
|
) {
|
||||||
@ -53,20 +56,72 @@ export class HuiWeatherForecastCardEditor
|
|||||||
config: { ...config, show_current: true, show_forecast: false },
|
config: { ...config, show_current: true, show_forecast: false },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
!config.forecast_type ||
|
||||||
|
!this._forecastSupported(config.forecast_type)
|
||||||
|
) {
|
||||||
|
let forecastType: string | undefined;
|
||||||
|
if (this._forecastSupported("daily")) {
|
||||||
|
forecastType = "daily";
|
||||||
|
} else if (this._forecastSupported("hourly")) {
|
||||||
|
forecastType = "hourly";
|
||||||
|
} else if (this._forecastSupported("twice_daily")) {
|
||||||
|
forecastType = "twice_daily";
|
||||||
|
} else if (this._forecastSupported("legacy")) {
|
||||||
|
forecastType = "legacy";
|
||||||
|
}
|
||||||
|
fireEvent(this, "config-changed", {
|
||||||
|
config: { ...config, forecast_type: forecastType },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get _has_forecast(): boolean | undefined {
|
private get _stateObj(): WeatherEntity | undefined {
|
||||||
if (this.hass && this._config) {
|
if (this.hass && this._config) {
|
||||||
const stateObj = this.hass.states[this._config.entity] as WeatherEntity;
|
return this.hass.states[this._config.entity] as WeatherEntity;
|
||||||
if (stateObj && stateObj.state !== UNAVAILABLE) {
|
|
||||||
return !!stateObj.attributes.forecast?.length;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get _hasForecast(): boolean | undefined {
|
||||||
|
const stateObj = this._stateObj as WeatherEntity;
|
||||||
|
if (stateObj && stateObj.state !== UNAVAILABLE) {
|
||||||
|
return !!(
|
||||||
|
stateObj.attributes.forecast?.length ||
|
||||||
|
stateObj.attributes.supported_features
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _forecastSupported(forecastType: ForecastType): boolean {
|
||||||
|
const stateObj = this._stateObj as WeatherEntity;
|
||||||
|
if (forecastType === "legacy") {
|
||||||
|
return !!stateObj.attributes.forecast?.length;
|
||||||
|
}
|
||||||
|
if (forecastType === "daily") {
|
||||||
|
return supportsFeature(stateObj, WeatherEntityFeature.FORECAST_DAILY);
|
||||||
|
}
|
||||||
|
if (forecastType === "hourly") {
|
||||||
|
return supportsFeature(stateObj, WeatherEntityFeature.FORECAST_HOURLY);
|
||||||
|
}
|
||||||
|
if (forecastType === "twice_daily") {
|
||||||
|
return supportsFeature(
|
||||||
|
stateObj,
|
||||||
|
WeatherEntityFeature.FORECAST_TWICE_DAILY
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private _schema = memoizeOne(
|
private _schema = memoizeOne(
|
||||||
(localize: LocalizeFunc, hasForecast?: boolean) =>
|
(
|
||||||
|
localize: LocalizeFunc,
|
||||||
|
hasForecastLegacy?: boolean,
|
||||||
|
hasForecastDaily?: boolean,
|
||||||
|
hasForecastHourly?: boolean,
|
||||||
|
hasForecastTwiceDaily?: boolean
|
||||||
|
) =>
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
name: "entity",
|
name: "entity",
|
||||||
@ -86,7 +141,54 @@ export class HuiWeatherForecastCardEditor
|
|||||||
{ name: "theme", selector: { theme: {} } },
|
{ name: "theme", selector: { theme: {} } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
...(hasForecast
|
...(!hasForecastLegacy &&
|
||||||
|
(hasForecastDaily || hasForecastHourly || hasForecastTwiceDaily)
|
||||||
|
? ([
|
||||||
|
{
|
||||||
|
name: "forecast_type",
|
||||||
|
selector: {
|
||||||
|
select: {
|
||||||
|
options: [
|
||||||
|
...(hasForecastDaily
|
||||||
|
? ([
|
||||||
|
{
|
||||||
|
value: "daily",
|
||||||
|
label: localize(
|
||||||
|
"ui.panel.lovelace.editor.card.weather-forecast.daily"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
] as const)
|
||||||
|
: []),
|
||||||
|
...(hasForecastHourly
|
||||||
|
? ([
|
||||||
|
{
|
||||||
|
value: "hourly",
|
||||||
|
label: localize(
|
||||||
|
"ui.panel.lovelace.editor.card.weather-forecast.hourly"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
] as const)
|
||||||
|
: []),
|
||||||
|
...(hasForecastTwiceDaily
|
||||||
|
? ([
|
||||||
|
{
|
||||||
|
value: "twice_daily",
|
||||||
|
label: localize(
|
||||||
|
"ui.panel.lovelace.editor.card.weather-forecast.twice_daily"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
] as const)
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as const)
|
||||||
|
: []),
|
||||||
|
...(hasForecastDaily ||
|
||||||
|
hasForecastHourly ||
|
||||||
|
hasForecastTwiceDaily ||
|
||||||
|
hasForecastLegacy
|
||||||
? ([
|
? ([
|
||||||
{
|
{
|
||||||
name: "forecast",
|
name: "forecast",
|
||||||
@ -125,11 +227,17 @@ export class HuiWeatherForecastCardEditor
|
|||||||
return nothing;
|
return nothing;
|
||||||
}
|
}
|
||||||
|
|
||||||
const schema = this._schema(this.hass.localize, this._has_forecast);
|
const schema = this._schema(
|
||||||
|
this.hass.localize,
|
||||||
|
this._forecastSupported("legacy"),
|
||||||
|
this._forecastSupported("daily"),
|
||||||
|
this._forecastSupported("hourly"),
|
||||||
|
this._forecastSupported("twice_daily")
|
||||||
|
);
|
||||||
|
|
||||||
const data: WeatherForecastCardConfig = {
|
const data: WeatherForecastCardConfig = {
|
||||||
show_current: true,
|
show_current: true,
|
||||||
show_forecast: this._has_forecast,
|
show_forecast: this._hasForecast,
|
||||||
...this._config,
|
...this._config,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -184,6 +292,10 @@ export class HuiWeatherForecastCardEditor
|
|||||||
)} (${this.hass!.localize(
|
)} (${this.hass!.localize(
|
||||||
"ui.panel.lovelace.editor.card.config.optional"
|
"ui.panel.lovelace.editor.card.config.optional"
|
||||||
)})`;
|
)})`;
|
||||||
|
case "forecast_type":
|
||||||
|
return this.hass!.localize(
|
||||||
|
"ui.panel.lovelace.editor.card.weather-forecast.forecast_type"
|
||||||
|
);
|
||||||
case "forecast":
|
case "forecast":
|
||||||
return this.hass!.localize(
|
return this.hass!.localize(
|
||||||
"ui.panel.lovelace.editor.card.weather-forecast.weather_to_show"
|
"ui.panel.lovelace.editor.card.weather-forecast.weather_to_show"
|
||||||
|
@ -16,9 +16,13 @@ import "../../../components/entity/state-badge";
|
|||||||
import { isUnavailableState } from "../../../data/entity";
|
import { isUnavailableState } from "../../../data/entity";
|
||||||
import { ActionHandlerEvent } from "../../../data/lovelace";
|
import { ActionHandlerEvent } from "../../../data/lovelace";
|
||||||
import {
|
import {
|
||||||
|
getDefaultForecastType,
|
||||||
|
getForecast,
|
||||||
getSecondaryWeatherAttribute,
|
getSecondaryWeatherAttribute,
|
||||||
getWeatherStateIcon,
|
getWeatherStateIcon,
|
||||||
getWeatherUnit,
|
getWeatherUnit,
|
||||||
|
subscribeForecast,
|
||||||
|
ForecastEvent,
|
||||||
WeatherEntity,
|
WeatherEntity,
|
||||||
weatherSVGStyles,
|
weatherSVGStyles,
|
||||||
} from "../../../data/weather";
|
} from "../../../data/weather";
|
||||||
@ -38,6 +42,48 @@ class HuiWeatherEntityRow extends LitElement implements LovelaceRow {
|
|||||||
|
|
||||||
@state() private _config?: EntitiesCardEntityConfig;
|
@state() private _config?: EntitiesCardEntityConfig;
|
||||||
|
|
||||||
|
@state() private _forecastEvent?: ForecastEvent;
|
||||||
|
|
||||||
|
@state() private _subscribed?: Promise<() => void>;
|
||||||
|
|
||||||
|
private _unsubscribeForecastEvents() {
|
||||||
|
if (this._subscribed) {
|
||||||
|
this._subscribed.then((unsub) => unsub());
|
||||||
|
this._subscribed = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _subscribeForecastEvents() {
|
||||||
|
this._unsubscribeForecastEvents();
|
||||||
|
if (!this.hass || !this._config || !this.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stateObj = this.hass!.states[this._config!.entity];
|
||||||
|
const forecastType = getDefaultForecastType(stateObj);
|
||||||
|
if (forecastType) {
|
||||||
|
this._subscribed = subscribeForecast(
|
||||||
|
this.hass!,
|
||||||
|
stateObj.entity_id,
|
||||||
|
forecastType,
|
||||||
|
(event) => {
|
||||||
|
this._forecastEvent = event;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
if (this.hasUpdated) {
|
||||||
|
this._subscribeForecastEvents();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public disconnectedCallback(): void {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
this._unsubscribeForecastEvents();
|
||||||
|
}
|
||||||
|
|
||||||
public setConfig(config: EntitiesCardEntityConfig): void {
|
public setConfig(config: EntitiesCardEntityConfig): void {
|
||||||
if (!config?.entity) {
|
if (!config?.entity) {
|
||||||
throw new Error("Entity must be specified");
|
throw new Error("Entity must be specified");
|
||||||
@ -50,6 +96,13 @@ class HuiWeatherEntityRow extends LitElement implements LovelaceRow {
|
|||||||
return hasConfigOrEntityChanged(this, changedProps);
|
return hasConfigOrEntityChanged(this, changedProps);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected updated(changedProps: PropertyValues): void {
|
||||||
|
super.updated(changedProps);
|
||||||
|
if (changedProps.has("_config") || !this._subscribed) {
|
||||||
|
this._subscribeForecastEvents();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected render() {
|
protected render() {
|
||||||
if (!this.hass || !this._config) {
|
if (!this.hass || !this._config) {
|
||||||
return nothing;
|
return nothing;
|
||||||
@ -72,6 +125,9 @@ class HuiWeatherEntityRow extends LitElement implements LovelaceRow {
|
|||||||
const hasSecondary = this._config.secondary_info;
|
const hasSecondary = this._config.secondary_info;
|
||||||
const weatherStateIcon = getWeatherStateIcon(stateObj.state, this);
|
const weatherStateIcon = getWeatherStateIcon(stateObj.state, this);
|
||||||
|
|
||||||
|
const forecastData = getForecast(stateObj.attributes, this._forecastEvent);
|
||||||
|
const forecast = forecastData?.forecast;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class="icon-image ${classMap({
|
class="icon-image ${classMap({
|
||||||
@ -160,7 +216,7 @@ class HuiWeatherEntityRow extends LitElement implements LovelaceRow {
|
|||||||
`}
|
`}
|
||||||
</div>
|
</div>
|
||||||
<div class="secondary">
|
<div class="secondary">
|
||||||
${getSecondaryWeatherAttribute(this.hass!, stateObj)}
|
${getSecondaryWeatherAttribute(this.hass!, stateObj, forecast!)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
@ -254,6 +254,9 @@
|
|||||||
"day": "Day",
|
"day": "Day",
|
||||||
"night": "Night",
|
"night": "Night",
|
||||||
"forecast": "Forecast",
|
"forecast": "Forecast",
|
||||||
|
"forecast_daily": "Forecast daily",
|
||||||
|
"forecast_hourly": "Forecast hourly",
|
||||||
|
"forecast_twice_daily": "Forecast twice daily",
|
||||||
"high": "High",
|
"high": "High",
|
||||||
"low": "Low"
|
"low": "Low"
|
||||||
}
|
}
|
||||||
@ -4975,7 +4978,12 @@
|
|||||||
"weather_to_show": "Weather to Show",
|
"weather_to_show": "Weather to Show",
|
||||||
"show_both": "Show current Weather and Forecast",
|
"show_both": "Show current Weather and Forecast",
|
||||||
"show_only_current": "Show only current Weather",
|
"show_only_current": "Show only current Weather",
|
||||||
"show_only_forecast": "Show only Forecast"
|
"show_only_forecast": "Show only Forecast",
|
||||||
|
"forecast_type": "Select forecast type",
|
||||||
|
"no_type": "No type",
|
||||||
|
"daily": "Daily",
|
||||||
|
"hourly": "Hourly",
|
||||||
|
"twice_daily": "Twice daily"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"view": {
|
"view": {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user