diff --git a/README.md b/README.md index 996b668..19655f6 100644 --- a/README.md +++ b/README.md @@ -89,23 +89,25 @@ resources: | `name` | string | optional | Custom name to display. Defaults to the entity's friendly name. | | `temperature_entity` | string | optional | Bring your own temperature entity to override the temperature from the main weather `entity`. | | `show_current` | boolean | `true` | Show current weather conditions. | +| `show_forecast` | boolean | `true` | Show forecast section. | | `default_forecast` | string | `daily` | Default forecast to view (`daily` or `hourly`). | -| `icons_path` | string | optional | Path to custom icons. For example. `/local/img/my-custom-weather-icons`. See [Custom Weather Icons](#custom-weather-icons) for more details. | -| `forecast` | object | optional | Forecast configuration options. | -| `forecast_actions` | object | optional | Actions for the forecast area. See [actions](#actions) | -| `tap_action` | object | optional | Defines the type action to perform on tap for the main card. Action defaults to `more-info`. See [Home Assistant Actions](https://www.home-assistant.io/dashboards/actions/). | +| `icons_path` | string | optional | Path to custom icons. For example, `/local/img/my-custom-weather-icons`. See [Custom Weather Icons](#custom-weather-icons) for more details. | +| `forecast` | object | optional | Forecast configuration options. See [Forecast Object](#forecast-object). | +| `forecast_action` | object | optional | Actions for the forecast area. See [Forecast Actions](#forecast-actions). | +| `tap_action` | object | optional | Defines the type of action to perform on tap for the main card. Action defaults to `more-info`. See [Home Assistant Actions](https://www.home-assistant.io/dashboards/actions/). | | `hold_action` | object | optional | Defines the type of action to perform on hold for the main card. See [Home Assistant Actions](https://www.home-assistant.io/dashboards/actions/). | | `double_tap_action` | object | optional | Defines the type of action to perform on double click for the main card. See [Home Assistant Actions](https://www.home-assistant.io/dashboards/actions/). | ### Forecast Object -| Name | Type | Default | Description | -| :---------------- | :------ | :------- | :------------------------------------------------------------------------------------------------------------------------------------------- | -| `mode` | string | `simple` | Forecast display mode (`simple` or `chart`). | -| `show_sun_times` | boolean | `true` | Show sunrise/sunset times in hourly forecast. | -| `extra_attribute` | string | optional | The extra attribute to show below the weather forecast. Currently supports, `precipitation_probability`, `wind_direction` and `wind_bearing` | +| Name | Type | Default | Description | +| :------------------ | :------ | :------- | :------------------------------------------------------------------------------------------------------------------------------------------- | +| `mode` | string | `simple` | Forecast display mode (`simple` or `chart`). | +| `show_sun_times` | boolean | `true` | Show sunrise/sunset times in hourly forecast. | +| `hourly_group_size` | number | `1` | Number of hours to group together in hourly forecast. Group data will be aggregated per forecast attribute. | +| `extra_attribute` | string | optional | The extra attribute to show below the weather forecast. Currently supports, `precipitation_probability`, `wind_direction` and `wind_bearing` | -### Actions +### Forecast Actions Actions support standard Home Assistant card actions. However, one additional action has been defined: `toggle-forecast` will toggle the forecast between daily and hourly forecast. diff --git a/package.json b/package.json index 9e67fef..aba1d3a 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "source": [ "src/index.ts" ], - "publicUrl": "/hacsfiles/weather-forecast-card/", + "publicUrl": "/hacsfiles/ha-weather-forecast-card/", "includeNodeModules": true } }, @@ -43,4 +43,4 @@ "parcel": "^2.16.1", "typescript": "^5.9.3" } -} +} \ No newline at end of file diff --git a/src/components/wfc-weather-condition-icon-provider.ts b/src/components/wfc-weather-condition-icon-provider.ts index 03eba78..00e4e22 100644 --- a/src/components/wfc-weather-condition-icon-provider.ts +++ b/src/components/wfc-weather-condition-icon-provider.ts @@ -40,7 +40,7 @@ export class WfcWeatherConditionIconProvider extends LitElement { } private getWeatherStateIcon(): TemplateResult | typeof nothing { - if (this.config.icons_path) { + if (this.config?.icons_path?.trim()) { const { path, state } = this.getCustomWeatherIconPath(); return html` ${state} `; @@ -78,8 +78,10 @@ export class WfcWeatherConditionIconProvider extends LitElement { } } + const iconsPath = this.config.icons_path?.trim().replace(/\/$/, ""); + return { - path: `${this.config.icons_path}/${condition}.svg`, + path: `${iconsPath}/${condition}.svg`, state: condition, }; }; diff --git a/src/editor/weather-forecast-card-editor.ts b/src/editor/weather-forecast-card-editor.ts new file mode 100644 index 0000000..8b2c92e --- /dev/null +++ b/src/editor/weather-forecast-card-editor.ts @@ -0,0 +1,513 @@ +import { LitElement, html, TemplateResult, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import memoizeOne from "memoize-one"; +import { + HomeAssistant, + fireEvent, + LovelaceCardEditor, + LocalizeFunc, +} from "custom-card-helpers"; +import { + WeatherForecastCardConfig, + WeatherForecastCardForecastActionConfig, + WeatherForecastCardForecastConfig, +} from "../types"; + +type HaFormSelector = + | { entity: { domain?: string; device_class?: string | string[] } } + | { boolean: {} } + | { text: {} } + | { entity_name: {} } + | { number: { min?: number; max?: number } } + | { ui_action: { default_action: string } } + | { + select: { + mode?: "dropdown" | "list"; + options: Array<{ value: string; label: string }>; + custom_value?: boolean; + }; + }; + +type HaFormSchema = { + name: + | keyof WeatherForecastCardEditorConfig + | `forecast.${keyof WeatherForecastCardForecastConfig}` + | `forecast_action.${keyof WeatherForecastCardForecastActionConfig}` + | ""; + type?: string; + iconPath?: TemplateResult; + schema?: HaFormSchema[]; + flatten?: boolean; + default?: string | boolean | number; + required?: boolean; + selector?: HaFormSelector; + context?: { entity?: string }; + optional?: boolean; + disabled?: boolean; +}; + +type WeatherForecastCardEditorConfig = { + forecast_mode?: "show_both" | "show_current" | "show_forecast"; + forecast_interactions?: any; + interactions?: any; + advanced_settings?: any; +} & WeatherForecastCardConfig; + +@customElement("weather-forecast-card-editor") +export class WeatherForecastCardEditor + extends LitElement + implements LovelaceCardEditor +{ + @property({ attribute: false }) public hass!: HomeAssistant; + @state() private _config!: WeatherForecastCardEditorConfig; + + public setConfig(config: WeatherForecastCardEditorConfig): void { + this._config = config; + } + + private _schema = memoizeOne( + (localize: LocalizeFunc): HaFormSchema[] => + [ + ...this._genericSchema(localize), + ...this._forecastSchema(localize), + ...this._interactionsSchema(localize), + ...this._advancedSchema(localize), + ] as const + ); + + private _genericSchema = (localize: LocalizeFunc): HaFormSchema[] => + [ + { + name: "entity", + required: true, + selector: { entity: { domain: "weather" } }, + optional: false, + }, + { + name: "name", + selector: { text: {} }, + optional: true, + }, + { + name: "temperature_entity", + selector: { + entity: { domain: "sensor", device_class: "temperature" }, + }, + optional: true, + }, + { + name: "forecast_mode", + default: "show_both", + selector: { + select: { + options: [ + { + value: "show_both", + label: localize( + "ui.panel.lovelace.editor.card.weather-forecast.show_both" + ), + }, + { + value: "show_current", + label: localize( + "ui.panel.lovelace.editor.card.weather-forecast.show_only_current" + ), + }, + { + value: "show_forecast", + label: localize( + "ui.panel.lovelace.editor.card.weather-forecast.show_only_forecast" + ), + }, + ], + }, + }, + }, + { + name: "default_forecast", + default: "daily", + optional: true, + selector: { + select: { + options: [ + { + value: "hourly", + label: localize( + "ui.panel.lovelace.editor.card.weather-forecast.hourly" + ), + }, + { + value: "daily", + label: localize( + "ui.panel.lovelace.editor.card.weather-forecast.daily" + ), + }, + ], + }, + }, + }, + ] as const; + + private _forecastSchema = (localize: LocalizeFunc): HaFormSchema[] => + [ + { + name: "forecast.mode", + default: "simple", + selector: { + select: { + options: [ + { + value: "simple", + label: "Simple", + }, + { + value: "chart", + label: "Chart", + }, + ], + }, + }, + optional: true, + }, + { + name: "forecast.extra_attribute", + optional: true, + selector: { + select: { + mode: "dropdown", + options: [ + { + value: "none", + label: + localize( + "ui.panel.lovelace.editor.card.weather-forecast.none" + ) || "(no attribute)", + }, + { + value: "wind_bearing", + label: + localize("ui.card.weather.attributes.wind_bearing") || + "Wind bearing", + }, + { + value: "wind_direction", + label: + localize("ui.card.weather.attributes.wind_direction") || + "Wind direction", + }, + { + value: "precipitation_probability", + label: + localize( + "ui.card.weather.attributes.precipitation_probability" + ) || "Precipitation probability", + }, + ], + }, + }, + }, + { + name: "forecast.show_sun_times", + selector: { boolean: {} }, + default: true, + optional: true, + }, + { + name: "forecast.hourly_group_size", + optional: true, + selector: { number: { min: 1, max: 4 } }, + default: 1, + }, + ] as const; + + private _interactionsSchema = (localize: LocalizeFunc): HaFormSchema[] => + [ + { + name: "forecast_interactions", + type: "expandable", + flatten: true, + schema: [ + { + name: "forecast_action.tap_action", + selector: { + ui_action: { + default_action: "toggle-forecast", + }, + }, + }, + { + name: "", + type: "optional_actions", + flatten: true, + schema: (["hold_action", "double_tap_action"] as const).map( + (action) => ({ + name: `forecast_action.${action}`, + selector: { + ui_action: { + default_action: "none" as const, + }, + }, + }) + ), + }, + ], + }, + { + name: "interactions", + type: "expandable", + flatten: true, + schema: [ + { + name: "tap_action", + selector: { + ui_action: { + default_action: "more-info", + }, + }, + }, + { + name: "", + type: "optional_actions", + flatten: true, + schema: (["hold_action", "double_tap_action"] as const).map( + (action) => ({ + name: action, + selector: { + ui_action: { + default_action: "none" as const, + }, + }, + }) + ), + }, + ], + }, + ] as const; + + private _advancedSchema = (localize: LocalizeFunc): HaFormSchema[] => + [ + { + name: "advanced_settings", + type: "expandable", + flatten: true, + schema: [ + { + name: "icons_path", + selector: { text: {} }, + optional: true, + }, + ], + }, + ] as const; + + protected render(): TemplateResult | typeof nothing { + if (!this.hass || !this._config) { + return nothing; + } + + const schema = this._schema(this.hass.localize); + + const data = { + ...flattenNestedKeys(this._config), + }; + + data.forecast_mode = + data.show_current && data.show_forecast + ? "show_both" + : data.show_current + ? "show_current" + : "show_forecast"; + + return html` + + + `; + } + + private _computeLabel = (schema: HaFormSchema): string | undefined => { + const name = schema.name.startsWith("forecast_action.") + ? schema.name.split(".")[1] + : schema.name; + + switch (name) { + case "entity": + return `${this.hass!.localize( + "ui.panel.lovelace.editor.card.generic.entity" + )} (${( + this.hass!.localize( + "ui.panel.lovelace.editor.card.config.required" + ) || "required" + ).toLocaleLowerCase()})`; + case "name": + return this.hass.localize("ui.panel.lovelace.editor.card.generic.name"); + case "temperature_entity": + return `${this.hass!.localize( + "ui.card.weather.attributes.temperature" + )} ${( + this.hass!.localize("ui.panel.lovelace.editor.card.generic.entity") || + "entity" + ).toLocaleLowerCase()}`; + case "forecast_mode": + return this.hass!.localize( + "ui.panel.lovelace.editor.card.weather-forecast.weather_to_show" + ); + case "default_forecast": + return this.hass!.localize( + "ui.panel.lovelace.editor.card.weather-forecast.forecast_type" + ); + case "icons_path": + return "Path to custom icons"; + case "forecast.extra_attribute": + return `Extra ${( + this.hass!.localize("ui.card.weather.forecast") || "forecast" + ).toLocaleLowerCase()} ${( + this.hass!.localize( + "ui.panel.lovelace.editor.card.generic.attribute" + ) || "attribute" + ).toLocaleLowerCase()}`; + case "forecast.mode": + return "Forecast display mode"; + case "forecast.show_sun_times": + return "Show sunrise and sunset times"; + case "forecast.hourly_group_size": + return "Hourly forecast group size"; + case "forecast_interactions": + return `${this.hass!.localize("ui.card.weather.forecast")} ${( + this.hass!.localize( + `ui.panel.lovelace.editor.card.generic.interactions` + ) || "interactions" + ).toLocaleLowerCase()}`; + case "advanced_settings": + return this.hass!.localize( + "ui.dialogs.helper_settings.generic.advanced_settings" + ); + default: + return this.hass!.localize( + `ui.panel.lovelace.editor.card.generic.${name}` + ); + } + }; + + private _computeHelper = (schema: HaFormSchema): string | undefined => { + switch (schema.name) { + case "temperature_entity": + return "Optional temperature sensor entity to override the weather entity's temperature."; + case "default_forecast": + return "Select the default forecast type to show when forecasts are enabled. Users can still toggle between hourly and daily forecasts if both are available."; + case "forecast.extra_attribute": + return "Select an extra attribute to display below each forecast."; + case "forecast_interactions": + return "Action to perform when the forecast section is interacted with. Default tap action toggles between hourly and daily forecasts."; + case "interactions": + return "Action to perform when the non-forecast area of the card is interacted with."; + case "icons_path": + return "Path to custom weather condition icons (e.g., /local/img/weather)."; + case "forecast.hourly_group_size": + return "Aggregate hourly forecast data into groups to reduce the number of forecast entries shown."; + case "name": + return "Overrides the friendly name of the entity."; + default: + return undefined; + } + }; + + private _valueChanged(ev: CustomEvent): void { + ev.stopPropagation(); + + const config = ev.detail.value as WeatherForecastCardEditorConfig; + + if (config.forecast_mode === "show_both") { + config.show_current = true; + config.show_forecast = true; + } else if (config.forecast_mode === "show_current") { + config.show_current = true; + config.show_forecast = false; + } else { + config.show_current = false; + config.show_forecast = true; + } + + delete config.forecast_mode; + + const newConfig = moveDottedKeysToNested(config); + + if (newConfig?.forecast?.extra_attribute === "none") { + delete newConfig.forecast.extra_attribute; + } + + fireEvent(this, "config-changed", { config: newConfig }); + } +} + +const moveDottedKeysToNested = (obj: Record) => { + const result: Record = { ...obj }; + + for (const key of Object.keys(obj)) { + if (!key.startsWith("forecast.") && !key.startsWith("forecast_action.")) + continue; + + const parts = key.split("."); + if (parts.length < 2) continue; + + const [prefix, prop] = parts; + if (!prefix || !prop) continue; + + if (!result[prefix] || typeof result[prefix] !== "object") { + result[prefix] = {}; + } + + result[prefix][prop] = obj[key]; + delete result[key]; + } + + return result; +}; + +const flattenNestedKeys = (obj: Record) => { + const result: Record = {}; + + for (const key in obj) { + const value = obj[key]; + + if ( + key === "forecast" && + value && + typeof value === "object" && + !Array.isArray(value) + ) { + for (const innerKey in value) { + result[`forecast.${innerKey}`] = value[innerKey]; + } + continue; + } + + if ( + key === "forecast_action" && + value && + typeof value === "object" && + !Array.isArray(value) + ) { + for (const innerKey in value) { + result[`forecast_action.${innerKey}`] = value[innerKey]; + } + continue; + } + + result[key] = value; + } + + return result; +}; + +declare global { + interface HTMLElementTagNameMap { + "weather-forecast-card-editor": WeatherForecastCardEditor; + } +} diff --git a/src/index.ts b/src/index.ts index 6f0a2db..c359ff6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import { WeatherForecastCard } from "./weather-forecast-card"; import * as pjson from "../package.json"; +import "./editor/weather-forecast-card-editor"; declare global { interface Window { diff --git a/src/types.ts b/src/types.ts index 318971d..a4b2a93 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,25 +16,30 @@ export interface ForecastToggleActionConfig extends BaseActionConfig { action: "toggle-forecast"; } +export interface WeatherForecastCardForecastConfig { + extra_attribute?: string; + mode?: ForecastMode; + show_sun_times?: boolean; + hourly_group_size?: number; +} + +export interface WeatherForecastCardForecastActionConfig { + tap_action?: ForecastActionConfig; + hold_action?: ForecastActionConfig; + double_tap_action?: ForecastActionConfig; +} + export interface WeatherForecastCardConfig { type: "custom:weather-forecast-card"; entity: string; name?: string; temperature_entity?: string; show_current?: boolean; + show_forecast?: boolean; default_forecast?: "hourly" | "daily"; icons_path?: string; - forecast?: { - extra_attribute?: string; - mode?: ForecastMode; - show_sun_times?: boolean; - hourly_group_size?: number; - }; - forecast_action?: { - tap_action?: ForecastActionConfig; - hold_action?: ForecastActionConfig; - double_tap_action?: ForecastActionConfig; - }; + forecast?: WeatherForecastCardForecastConfig; + forecast_action?: WeatherForecastCardForecastActionConfig; tap_action?: ActionConfig | undefined; hold_action?: ActionConfig | undefined; double_tap_action?: ActionConfig | undefined; diff --git a/src/weather-forecast-card.ts b/src/weather-forecast-card.ts index 26d1452..6a28532 100644 --- a/src/weather-forecast-card.ts +++ b/src/weather-forecast-card.ts @@ -41,6 +41,21 @@ import "./components/wfc-forecast-chart"; import "./components/wfc-forecast-simple"; import "./components/wfc-current-weather"; +const DEFAULT_CONFIG: Partial = { + type: "custom:weather-forecast-card", + show_current: true, + show_forecast: true, + default_forecast: "daily", + forecast: { + mode: ForecastMode.Simple, + show_sun_times: true, + }, + forecast_action: { + tap_action: { action: "toggle-forecast" }, + }, + tap_action: { action: "more-info" }, +}; + export class WeatherForecastCard extends LitElement { @property({ attribute: false }) public hass?: ExtendedHomeAssistant; @state() private config?: WeatherForecastCardConfig; @@ -60,24 +75,40 @@ export class WeatherForecastCard extends LitElement { static styles = styles as CSSResultGroup; + public static async getConfigElement() { + return document.createElement("weather-forecast-card-editor"); + } + + public static getStubConfig( + hass: ExtendedHomeAssistant + ): Partial { + const weatherEntities = Object.keys(hass?.states ?? {}).filter((entityId) => + entityId.startsWith("weather.") + ); + + const defaultEntity = + weatherEntities.find((entityId) => entityId === "weather.home") || + weatherEntities[0] || + ""; + + return { + ...DEFAULT_CONFIG, + entity: defaultEntity, + }; + } + public setConfig(config: WeatherForecastCardConfig): void { if (!config || !config.entity) { throw new Error("entity is required"); } - const defaults: Omit = { - show_current: true, - default_forecast: "daily", - forecast: { - show_sun_times: true, - }, - forecast_action: { - tap_action: { action: "toggle-forecast" }, - }, - tap_action: { action: "more-info" }, - }; + if (config.show_current === false && config.show_forecast === false) { + throw new Error( + "At least one of show_current or show_forecast must be true" + ); + } - this.config = merge(defaults, config); + this.config = merge({}, DEFAULT_CONFIG, config); this._currentForecastType = this.config.default_forecast || "daily"; } @@ -179,39 +210,42 @@ export class WeatherForecastCard extends LitElement { > ` : nothing} -
- ${isChartMode - ? html` - - ` - : html` - - `} -
+ ${this.config.show_forecast === false + ? nothing + : html`
+ ${isChartMode + ? html` + + ` + : html` + + `} +
`} `;