From de656b5a9060e6e78734977cb3ccecee5225cd4e Mon Sep 17 00:00:00 2001 From: Sergio Cinos Date: Mon, 5 Aug 2024 22:03:25 +0200 Subject: [PATCH] Adds an option to fill the forecast segment with icons (#715) * Adds an option to fill the forecast segment with icons * Fix typos * Improved error detection and error message * undefined icon_fill behaves like icon_fill:single * Move icon_fill config check to renderCore * Tests for icon_fill errors * Add tests for new behaviour --- README.md | 58 ++++++++++++++---------- cypress/e2e/config.cy.ts | 19 ++++++++ cypress/e2e/weather-bar.cy.ts | 80 ++++++++++++++++++++++++++++++++++ src/hourly-weather.ts | 12 +++++ src/localize/languages/en.json | 3 +- src/types.ts | 2 + src/weather-bar.ts | 29 ++++++++++-- 7 files changed, 174 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index f669da1b..19e5ce92 100644 --- a/README.md +++ b/README.md @@ -61,30 +61,31 @@ decimal by 1). Otherwise, the integration may complain of a duplicate unique ID. ## Options -| Name | Type | Requirement | Description | Default | -|----------------------------------|------------------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------| -| `type` | string | **Required** | `custom:hourly-weather` | | -| `entity` | string | **Required** | Home Assistant weather entity ID. | | -| `forecast_type` | string | **Optional** | The type of forecast data to use. One of `hourly`, `daily`, or `twice-daily`. If not specified, the card will attempt to use the finest-grained data available. | | -| `name` | string | **Optional** | Card name (set to `null` to hide) | `Hourly Weather` | -| `icons` | bool | **Optional** | Whether to show icons instead of text labels | `false` | -| `num_segments` | number | **Optional** | Number of forecast segments to show (integer >= 1) | `12` | -| ~~`num_hours`~~ | number | **Optional** | _Deprecated:_ Use `num_segments` instead | `12` | -| `offset` | number | **Optional** | Number of forecast segments to offset from start | `0` | -| `label_spacing` | number | **Optional** | Space between time/temperature labels (integer >= 1) | `2` | -| `colors` | [object][color] | **Optional** | Set colors for all or some conditions | | -| `hide_hours` | bool | **Optional** | Whether to hide hour labels under the bar | `false` | -| `hide_temperatures` | bool | **Optional** | Whether to hide temperatures under the bar | `false` | -| `round_temperatures` | bool | **Optional** | Whether to round temperatures to the nearest whole number | `false` | -| `hide_bar` | bool | **Optional** | Whether to hide the bar itself | `false` | -| `show_wind` | [Wind][wind] | **Optional** | Whether to show wind speed and/or direction under the bar | `'false'` | -| `show_precipitation_amounts` | bool | **Optional** | Whether to show precipitation (rain) amount under the bar | `false` | -| `show_precipitation_probability` | bool | **Optional** | Whether to show precipitation (rain) probability under the bar | `false` | -| `show_date` | [string][dates] | **Optional** | Whether to show date under the bar | `'false'` | -| `tap_action` | [object][action] | **Optional** | Action to take on tap | `action: more-info` | -| `hold_action` | [object][action] | **Optional** | Action to take on hold | `none` | -| `double_tap_action` | [object][action] | **Optional** | Action to take on double tap | `none` | -| `language` | string | **Optional** | Language to use for card (overrides HA & user settings) | | +| Name | Type | Requirement | Description | Default | +|----------------------------------|---------------------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------| +| `type` | string | **Required** | `custom:hourly-weather` | | +| `entity` | string | **Required** | Home Assistant weather entity ID. | | +| `forecast_type` | string | **Optional** | The type of forecast data to use. One of `hourly`, `daily`, or `twice-daily`. If not specified, the card will attempt to use the finest-grained data available. | | +| `name` | string | **Optional** | Card name (set to `null` to hide) | `Hourly Weather` | +| `icons` | bool | **Optional** | Whether to show icons instead of text labels | `false` | +| `num_segments` | number | **Optional** | Number of forecast segments to show (integer >= 1) | `12` | +| ~~`num_hours`~~ | number | **Optional** | _Deprecated:_ Use `num_segments` instead | `12` | +| `offset` | number | **Optional** | Number of forecast segments to offset from start | `0` | +| `label_spacing` | number | **Optional** | Space between time/temperature labels (integer >= 1) | `2` | +| `colors` | [object][color] | **Optional** | Set colors for all or some conditions | | +| `hide_hours` | bool | **Optional** | Whether to hide hour labels under the bar | `false` | +| `hide_temperatures` | bool | **Optional** | Whether to hide temperatures under the bar | `false` | +| `round_temperatures` | bool | **Optional** | Whether to round temperatures to the nearest whole number | `false` | +| `hide_bar` | bool | **Optional** | Whether to hide the bar itself | `false` | +| `icon_fill` | [string][icon_fill] | **Optional** | Whether to repeat the icon inside the bar | `'single` | +| `show_wind` | [Wind][wind] | **Optional** | Whether to show wind speed and/or direction under the bar | `'false'` | +| `show_precipitation_amounts` | bool | **Optional** | Whether to show precipitation (rain) amount under the bar | `false` | +| `show_precipitation_probability` | bool | **Optional** | Whether to show precipitation (rain) probability under the bar | `false` | +| `show_date` | [string][dates] | **Optional** | Whether to show date under the bar | `'false'` | +| `tap_action` | [object][action] | **Optional** | Action to take on tap | `action: more-info` | +| `hold_action` | [object][action] | **Optional** | Action to take on hold | `none` | +| `double_tap_action` | [object][action] | **Optional** | Action to take on double tap | `none` | +| `language` | string | **Optional** | Language to use for card (overrides HA & user settings) | | > Note that some of the more advanced options are not available in the card editor UI and must be configured via YAML. @@ -205,6 +206,14 @@ colors: - `boundary` Show date at the boundary between days - `all` Always show date +### Icon Fill Options + +`icon_fill` can be one of the following values: + +- `single` Show one icon per forecast span (default) +- `full` Show one icon per forecast segment. +- `` (an integer). Show icons every _n-th_ forecast segment (will show at least one icon per forecast span) + ## Upgrades ### Version 3 ➡️ 4 @@ -231,5 +240,6 @@ structure. [color]: #color-options [wind]: #wind-options +[icon_fill]: #icon-fill-options [dates]: #date-options [action]: #action-options diff --git a/cypress/e2e/config.cy.ts b/cypress/e2e/config.cy.ts index 7bc3a574..5802b27f 100644 --- a/cypress/e2e/config.cy.ts +++ b/cypress/e2e/config.cy.ts @@ -284,6 +284,25 @@ describe('Config', () => { .and('contain', 'exceptional: {\n "foreground": "#12345678"\n}') .and('contain', 'hail: {\n "background": "foo(240, 100%, 50%)",\n "foreground": "rgb(0, 255, 0, 0)"\n}'); }); + it('errors for invalid string values for icon_fill', () => { + cy.configure({ + //@ts-expect-error This is testing invalid config + icon_fill: 'all' //valid values are 'single', 'full' or integer + }); + cy.get('hui-error-card') + .shadow() + .find('p') + .should('have.text', "icon_fill must be either a positive integer or one of 'single' or 'full'"); + }); + it('errors for invalid integer values for icon_fill', () => { + cy.configure({ + icon_fill: -1 + }); + cy.get('hui-error-card') + .shadow() + .find('p') + .should('have.text', "icon_fill must be either a positive integer or one of 'single' or 'full'"); + }); describe('Templates', () => { it('supports templated name', () => { cy.configure({ diff --git a/cypress/e2e/weather-bar.cy.ts b/cypress/e2e/weather-bar.cy.ts index 3909d2c9..16ec67be 100644 --- a/cypress/e2e/weather-bar.cy.ts +++ b/cypress/e2e/weather-bar.cy.ts @@ -213,6 +213,86 @@ describe('Weather bar', () => { }); }); }); + describe('Icon fill', () => { + function verifyIcons (cy, expectedIcons) { + cy.get('weather-bar') + .shadow() + .find('div.bar > div > span.condition-icon') + .should('have.length', expectedIcons.length) + .find('ha-icon') + .each((el, i) => { + cy.wrap(el).invoke('attr', 'icon') + .should('exist') + .and('eq', expectedIcons[i]); + }); + } + + it('fills the blocks with icons', () => { + const expectedIcons = [ + 'mdi:weather-cloudy', + 'mdi:weather-cloudy', + 'mdi:weather-cloudy', + 'mdi:weather-partly-cloudy', + 'mdi:weather-partly-cloudy', + 'mdi:weather-partly-cloudy', + 'mdi:weather-sunny', + 'mdi:weather-sunny', + 'mdi:weather-sunny', + 'mdi:weather-sunny', + 'mdi:weather-sunny', + 'mdi:weather-night' + ]; + cy.configure({ + icons: true, + icon_fill: 'full' + }); + verifyIcons(cy, expectedIcons); + }); + it('a single icon for each block', () => { + const expectedIcons = [ + 'mdi:weather-cloudy', + 'mdi:weather-partly-cloudy', + 'mdi:weather-sunny', + 'mdi:weather-night' + ]; + cy.configure({ + icons: true, + icon_fill: 'single' + }); + verifyIcons(cy, expectedIcons); + }); + it('spaces icons', () => { + const expectedIcons = [ + 'mdi:weather-cloudy', + 'mdi:weather-cloudy', + 'mdi:weather-partly-cloudy', + 'mdi:weather-partly-cloudy', + 'mdi:weather-sunny', + 'mdi:weather-sunny', + 'mdi:weather-sunny', + 'mdi:weather-night' + ]; + cy.configure({ + icons: true, + icon_fill: 2 + }); + verifyIcons(cy, expectedIcons); + }); + it('shows at least one icon per block', () => { + const expectedIcons = [ + 'mdi:weather-cloudy', + 'mdi:weather-partly-cloudy', + 'mdi:weather-sunny', + 'mdi:weather-sunny', + 'mdi:weather-night' + ]; + cy.configure({ + icons: true, + icon_fill: 4 + }); + verifyIcons(cy, expectedIcons); + }) + }); describe('Axes', () => { it('shows the correct number of axes', () => { cy.get('weather-bar') diff --git a/src/hourly-weather.ts b/src/hourly-weather.ts index a8a1e04d..288472c0 100644 --- a/src/hourly-weather.ts +++ b/src/hourly-weather.ts @@ -320,6 +320,7 @@ export class HourlyWeatherCard extends LitElement { const offset = parseInt(config.offset ?? '0', 10); const labelSpacing = parseInt(config.label_spacing ?? '2', 10); const forecastNotAvailable = !forecast || !forecast.length; + const icon_fill = config.icon_fill; if (numSegments < 1) { // REMARK: Ok, so I'm re-using a localized string here. Probably not the best, but it avoids repeating for no good reason @@ -340,6 +341,16 @@ export class HourlyWeatherCard extends LitElement { return await this._showError(this.localize('errors.offset_must_be_positive_int', 'offset', 'label_spacing')); } + if (icon_fill) { + const isFull = config.icon_fill === 'full'; + const isSingle = config.icon_fill === 'single'; + const valueAsNumber = Number(config.icon_fill); //Undefined and non-numerical strings will be converted NaN, but null is 0 + const isPositiveInteger = Number.isInteger(valueAsNumber) && valueAsNumber > 0; + if (!isFull && !isSingle && !isPositiveInteger) { + return await this._showError(this.localize('errors.invalid_value_icon_fill')); + } + } + let showWind = config.show_wind; if (typeof showWind === 'boolean') { showWind = showWind ? 'true' : 'false'; @@ -401,6 +412,7 @@ export class HourlyWeatherCard extends LitElement { .hide_temperatures=${!!config.hide_temperatures} .round_temperatures=${!!config.round_temperatures} .hide_bar=${!!config.hide_bar} + .icon_fill=${config.icon_fill} .show_wind=${showWind} .show_precipitation_amounts=${!!config.show_precipitation_amounts} .show_precipitation_probability=${!!config.show_precipitation_probability} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 887c4186..5b7bb806 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -37,7 +37,8 @@ "offset_must_be_positive_int": "offset must be a positive integer", "forecast_not_available": "Forecast not available", "check_entity": "Check the configured forecast entity.", - "no_wind_barbs_with_string_bearing": "Wind barbs are not supported when weather entity uses cardinal directions for wind bearing." + "no_wind_barbs_with_string_bearing": "Wind barbs are not supported when weather entity uses cardinal directions for wind bearing.", + "invalid_value_icon_fill": "icon_fill must be either a positive integer or one of 'single' or 'full'" }, "conditions": { "clear": "Clear", diff --git a/src/types.ts b/src/types.ts index 5b6ace36..34440a71 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,6 +9,7 @@ declare global { export type WindType = 'true' | 'false' | 'speed' | 'direction' | 'barb' | 'barb-and-speed' | 'barb-and-direction' | 'barb-speed-and-direction'; export type ShowDateType = 'false' | 'boundary' | 'all'; +export type IconFillType = 'single' | 'full' | number; export interface HourlyWeatherCardConfig extends LovelaceCardConfig { type: string; @@ -22,6 +23,7 @@ export interface HourlyWeatherCardConfig extends LovelaceCardConfig { offset?: string; // number colors?: ColorConfig; hide_bar?: boolean; + icon_fill?: IconFillType; hide_hours?: boolean; hide_temperatures?: boolean; round_temperatures?: boolean; diff --git a/src/weather-bar.ts b/src/weather-bar.ts index f1959b49..e25b8ca6 100644 --- a/src/weather-bar.ts +++ b/src/weather-bar.ts @@ -4,7 +4,7 @@ import { StyleInfo, styleMap } from 'lit/directives/style-map.js'; import tippy, { Instance } from 'tippy.js'; import { LABELS, ICONS } from "./conditions"; import { getWindBarbSVG } from "./lib/svg-wind-barbs"; -import type { ColorMap, ConditionSpan, SegmentTemperature, SegmentWind, SegmentPrecipitation, WindType, ShowDateType } from "./types"; +import type { ColorMap, ConditionSpan, SegmentTemperature, SegmentWind, SegmentPrecipitation, WindType, ShowDateType, IconFillType } from "./types"; const tippyStyles: string = process.env.TIPPY_CSS || ''; @@ -39,6 +39,9 @@ export class WeatherBar extends LitElement { @property({ type: Boolean }) hide_bar = false; + @property({ type: String }) + icon_fill: IconFillType = 'single'; + @property({ type: String }) show_wind: WindType = 'false'; @@ -68,12 +71,30 @@ export class WeatherBar extends LitElement { let icon = ICONS[cond[0]]; if (icon === cond[0]) icon = 'mdi:weather-' + icon; else icon = 'mdi:' + icon; + + const iconMarkup: TemplateResult[] = []; + if (!this.icons) { + iconMarkup.push(html`${label}`); + } else { + let iconSize: IconFillType; + if (!this.icon_fill || this.icon_fill === 'single') { + iconSize = cond[1]; // grid width of segment, so one icon + } else if (this.icon_fill === 'full') { + iconSize = 1; + } else { + iconSize = Math.max(Number(this.icon_fill) || 0, 1); //`Number(this.icon_fill) || 0` will evaluate as 0 if icon_fill is null or undefined. + } + let iconGridStart = 1; + for (let i = 0; i < cond[1]; i += iconSize) { + const iconStyles: Readonly = { gridColumnStart: String(iconGridStart), gridColumnEnd: String(iconGridStart += iconSize * 2) }; + iconMarkup.push(html``) + } + } + const barStyles: Readonly = { gridColumnStart: String(gridStart), gridColumnEnd: String(gridStart += cond[1] * 2) }; conditionBars.push(html`
- ${this.icons ? - html`` : - html`${label}`} + ${iconMarkup}
`); }