diff --git a/public/static/images/weather/cloudy.png b/public/static/images/weather/cloudy.png new file mode 100644 index 0000000000..257b958bfc Binary files /dev/null and b/public/static/images/weather/cloudy.png differ diff --git a/public/static/images/weather/lightning-rainy.png b/public/static/images/weather/lightning-rainy.png new file mode 100644 index 0000000000..e28b7667bb Binary files /dev/null and b/public/static/images/weather/lightning-rainy.png differ diff --git a/public/static/images/weather/lightning.png b/public/static/images/weather/lightning.png new file mode 100644 index 0000000000..c4966bd5c9 Binary files /dev/null and b/public/static/images/weather/lightning.png differ diff --git a/public/static/images/weather/night.png b/public/static/images/weather/night.png new file mode 100644 index 0000000000..d7e79d2ac9 Binary files /dev/null and b/public/static/images/weather/night.png differ diff --git a/public/static/images/weather/partly-cloudy.png b/public/static/images/weather/partly-cloudy.png new file mode 100644 index 0000000000..a39a400ba1 Binary files /dev/null and b/public/static/images/weather/partly-cloudy.png differ diff --git a/public/static/images/weather/pouring.png b/public/static/images/weather/pouring.png new file mode 100644 index 0000000000..06bef9bc4c Binary files /dev/null and b/public/static/images/weather/pouring.png differ diff --git a/public/static/images/weather/rainy.png b/public/static/images/weather/rainy.png new file mode 100644 index 0000000000..e0c9a9a975 Binary files /dev/null and b/public/static/images/weather/rainy.png differ diff --git a/public/static/images/weather/snowy.png b/public/static/images/weather/snowy.png new file mode 100644 index 0000000000..059f8da70e Binary files /dev/null and b/public/static/images/weather/snowy.png differ diff --git a/public/static/images/weather/sunny.png b/public/static/images/weather/sunny.png new file mode 100644 index 0000000000..6c67835dce Binary files /dev/null and b/public/static/images/weather/sunny.png differ diff --git a/public/static/images/weather/windy.png b/public/static/images/weather/windy.png new file mode 100644 index 0000000000..9b4b20f227 Binary files /dev/null and b/public/static/images/weather/windy.png differ diff --git a/src/data/weather.ts b/src/data/weather.ts new file mode 100644 index 0000000000..34fc4da252 --- /dev/null +++ b/src/data/weather.ts @@ -0,0 +1,77 @@ +import { HomeAssistant } from "../types"; + +export const weatherIcons = { + "clear-night": "/static/images/weather/night.png", + cloudy: "/static/images/weather/cloudy.png", + exceptional: "hass:alert-circle-outline", + fog: "hass:weather-fog", + hail: "hass:weather-hail", + lightning: "/static/images/weather/lightning.png", + "lightning-rainy": "/static/images/weather/lightning-rainy.png", + partlycloudy: "/static/images/weather/partly-cloudy.png", + pouring: "/static/images/weather/pouring.png", + rainy: "/static/images/weather/rainy.png", + snowy: "/static/images/weather/snowy.png", + "snowy-rainy": "hass:weather-snowy-rainy", + sunny: "/static/images/weather/sunny.png", + windy: "/static/images/weather/windy.png", + "windy-variant": "hass:weather-windy-variant", +}; + +export const cardinalDirections = [ + "N", + "NNE", + "NE", + "ENE", + "E", + "ESE", + "SE", + "SSE", + "S", + "SSW", + "SW", + "WSW", + "W", + "WNW", + "NW", + "NNW", + "N", +]; + +const getWindBearingText = (degree: string): string => { + const degreenum = parseInt(degree, 10); + if (isFinite(degreenum)) { + // tslint:disable-next-line: no-bitwise + return cardinalDirections[(((degreenum + 11.25) / 22.5) | 0) % 16]; + } + return degree; +}; + +export const getWindBearing = (bearing: string): string => { + if (bearing != null) { + return getWindBearingText(bearing); + } + return ""; +}; + +export const getWeatherUnit = ( + hass: HomeAssistant, + measure: string +): string => { + const lengthUnit = hass.config.unit_system.length || ""; + switch (measure) { + case "pressure": + return lengthUnit === "km" ? "hPa" : "inHg"; + case "wind_speed": + return `${lengthUnit}/h`; + case "length": + return lengthUnit; + case "precipitation": + return lengthUnit === "km" ? "mm" : "in"; + case "humidity": + case "precipitation_probability": + return "%"; + default: + return hass.config.unit_system[measure] || ""; + } +}; diff --git a/src/panels/lovelace/create-element/create-row-element.ts b/src/panels/lovelace/create-element/create-row-element.ts index d23464f7fb..0bcf355154 100644 --- a/src/panels/lovelace/create-element/create-row-element.ts +++ b/src/panels/lovelace/create-element/create-row-element.ts @@ -31,6 +31,7 @@ const LAZY_LOAD_TYPES = { "lock-entity": () => import("../entity-rows/hui-lock-entity-row"), "timer-entity": () => import("../entity-rows/hui-timer-entity-row"), conditional: () => import("../special-rows/hui-conditional-row"), + "weather-entity": () => import("../entity-rows/hui-weather-entity-row"), divider: () => import("../special-rows/hui-divider-row"), section: () => import("../special-rows/hui-section-row"), weblink: () => import("../special-rows/hui-weblink-row"), @@ -63,6 +64,7 @@ const DOMAIN_TO_ELEMENT_TYPE = { // water heater should get it's own row. water_heater: "climate", input_datetime: "input-datetime", + weather: "weather", }; export const createRowElement = (config: EntityConfig) => diff --git a/src/panels/lovelace/entity-rows/hui-weather-entity-row.ts b/src/panels/lovelace/entity-rows/hui-weather-entity-row.ts new file mode 100644 index 0000000000..24262065a6 --- /dev/null +++ b/src/panels/lovelace/entity-rows/hui-weather-entity-row.ts @@ -0,0 +1,279 @@ +import { + html, + LitElement, + TemplateResult, + css, + CSSResult, + property, + customElement, + PropertyValues, +} from "lit-element"; +import { ifDefined } from "lit-html/directives/if-defined"; +import { classMap } from "lit-html/directives/class-map"; + +import "../../../components/entity/state-badge"; +import "../components/hui-warning"; + +import { LovelaceRow } from "./types"; +import { HomeAssistant, WeatherEntity } from "../../../types"; +import { EntitiesCardEntityConfig } from "../cards/types"; +import { hasConfigOrEntityChanged } from "../common/has-changed"; +import { UNAVAILABLE } from "../../../data/entity"; +import { weatherIcons, getWeatherUnit } from "../../../data/weather"; +import { DOMAINS_HIDE_MORE_INFO } from "../../../common/const"; +import { computeDomain } from "../../../common/entity/compute_domain"; +import { actionHandler } from "../common/directives/action-handler-directive"; +import { hasAction } from "../common/has-action"; +import { computeStateName } from "../../../common/entity/compute_state_name"; +import { ActionHandlerEvent } from "../../../data/lovelace"; +import { handleAction } from "../common/handle-action"; + +@customElement("hui-weather-entity-row") +class HuiWeatherEntityRow extends LitElement implements LovelaceRow { + @property() public hass?: HomeAssistant; + @property() private _config?: EntitiesCardEntityConfig; + + public setConfig(config: EntitiesCardEntityConfig): void { + if (!config?.entity) { + throw new Error("Invalid Configuration: 'entity' required"); + } + + this._config = config; + } + + protected shouldUpdate(changedProps: PropertyValues): boolean { + return hasConfigOrEntityChanged(this, changedProps); + } + + protected render(): TemplateResult { + if (!this.hass || !this._config) { + return html``; + } + + const stateObj = this.hass.states[this._config.entity] as WeatherEntity; + + if (!stateObj || stateObj.state === UNAVAILABLE) { + return html` + ${this.hass.localize( + "ui.panel.lovelace.warning.entity_not_found", + "entity", + this._config.entity + )} + `; + } + + const pointer = + (this._config.tap_action && this._config.tap_action.action !== "none") || + (this._config.entity && + !DOMAINS_HIDE_MORE_INFO.includes(computeDomain(this._config.entity))); + + const secondaryAttribute = this._getSecondaryAttribute(stateObj); + + return html` +
+ +
+
+
+
+ ${this._config.name || computeStateName(stateObj)} +
+
+ ${this.hass.localize(`state.weather.${stateObj.state}`) || + stateObj.state} +
+
+
+ ${stateObj.attributes.temperature}${getWeatherUnit(this.hass, "temperature")} +
+
+ ${secondaryAttribute + ? html` +
+ + ${secondaryAttribute} + +
+ ` + : ""} +
+
+ `; + } + + private _getSecondaryAttribute(stateObj: WeatherEntity): string | undefined { + const extrema = this._getExtrema(stateObj); + + if (extrema) { + return extrema; + } + + let value: number; + let attribute: string; + + if ( + stateObj.attributes.forecast?.length && + stateObj.attributes.forecast[0].precipitation !== undefined && + stateObj.attributes.forecast[0].precipitation !== null + ) { + value = stateObj.attributes.forecast[0].precipitation!; + attribute = "precipitation"; + } else if ("humidity" in stateObj.attributes) { + value = stateObj.attributes.humidity!; + attribute = "humidity"; + } else { + return undefined; + } + + return ` + ${this.hass!.localize( + `ui.card.weather.attributes.${attribute}` + )} ${value}${getWeatherUnit(this.hass!, attribute)} + `; + } + + private _getExtrema(stateObj: WeatherEntity): string | undefined { + if (!stateObj.attributes.forecast?.length) { + return undefined; + } + + let tempLow: number | undefined; + let tempHigh: number | undefined; + const today = new Date().getDate(); + + for (const forecast of stateObj.attributes.forecast!) { + if (new Date(forecast.datetime).getDate() !== today) { + break; + } + if (!tempHigh || forecast.temperature > tempHigh) { + tempHigh = forecast.temperature; + } + if (!tempLow || (forecast.templow && forecast.templow < tempLow)) { + tempLow = forecast.templow; + } + if (!forecast.templow && (!tempLow || forecast.temperature < tempLow)) { + tempLow = forecast.temperature; + } + } + + if (!tempLow && !tempHigh) { + return undefined; + } + + const unit = getWeatherUnit(this.hass!, "temperature"); + + return ` + ${ + tempHigh + ? ` + ${this.hass!.localize(`ui.card.weather.high`)} ${tempHigh}${unit} + ` + : "" + } + ${tempLow && tempHigh ? " / " : ""} + ${ + tempLow + ? ` + ${this.hass!.localize(`ui.card.weather.low`)} ${tempLow}${unit} + ` + : "" + } + `; + } + + private _handleAction(ev: ActionHandlerEvent) { + handleAction(this, this.hass!, this._config!, ev.detail.action!); + } + + static get styles(): CSSResult { + return css` + .pointer { + cursor: pointer; + } + + .main { + display: flex; + align-items: center; + justify-content: flex-end; + width: 100%; + } + + .name { + font-weight: 500; + } + + .state { + color: var(--secondary-text-color); + } + + .temperature { + font-size: 28px; + margin-right: 16px; + } + + .temperature span { + font-size: 18px; + position: absolute; + } + + .container { + flex: 1 0; + display: flex; + flex-flow: column; + } + + .info { + display: flex; + flex-flow: column; + justify-content: center; + } + + .info, + .attributes { + flex: 1 0; + margin-left: 16px; + overflow: hidden; + } + + .info > *, + .attributes > * { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .attributes { + padding-top: 1px; + color: var(--secondary-text-color); + } + + .attributes > *:not(:first-child) { + padding-left: 4px; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-weather-entity-row": HuiWeatherEntityRow; + } +} diff --git a/src/translations/en.json b/src/translations/en.json index c2585e1a2c..56ab3b5b9c 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -493,7 +493,8 @@ "humidity": "Humidity", "temperature": "Temperature", "visibility": "Visibility", - "wind_speed": "Wind speed" + "wind_speed": "Wind speed", + "precipitation": "Precipitation" }, "cardinal_direction": { "e": "E", @@ -513,7 +514,9 @@ "wnw": "WNW", "wsw": "WSW" }, - "forecast": "Forecast" + "forecast": "Forecast", + "high": "High", + "low": "Low" } }, "common": { diff --git a/src/types.ts b/src/types.ts index 348577ef0a..066c644fea 100644 --- a/src/types.ts +++ b/src/types.ts @@ -243,3 +243,19 @@ export interface LocalizeMixin { hass?: HomeAssistant; localize: LocalizeFunc; } + +interface ForecastAttribute { + temperature: number; + datetime: string; + templow?: number; + precipitation?: number; + humidity?: number; +} + +export type WeatherEntity = HassEntityBase & { + attributes: HassEntityAttributeBase & { + temperature: number; + humidity?: number; + forecast?: ForecastAttribute[]; + }; +};