diff --git a/README.md b/README.md index cc3cbbc6..0ce32bec 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,13 @@ This card was inspired by [another great card](https://github.com/cbulock/lovela | Name | Type | Default | Since | Description | |:-----|:-----|:-----|:-----|:-----| | type | string | **(required)** | v0.9.0 | Must be `custom:battery-state-card` | -| entities | list([Entity](#entity-object)) | **(required)** | v0.9.0 | List of entities +| entities | list([Entity](#entity-object)) | **(required)** | v0.9.0 | List of entities. It can be collection of entity/group IDs (strings) instead of Entity objects. | title | string | | v0.9.0 | Card title | sort_by_level | string | | v0.9.0 | Values: `asc`, `desc` -| collapse | number | | v1.0.0 | Number of entities to show. Rest will be available in expandable section ([example](#sorted-list-and-collapsed-view)) -| filter | [FilterGroups](#filter-groups) | | v1.3.0 | Filter groups to automatically include or exclude entities ([example](#entity-filtering-and-bulk-renaming)) +| collapse | number \| [Group](#group-object) | | v1.0.0 | Number of entities to show. Rest will be available in expandable section ([example](#sorted-list-and-collapsed-view)) +| filter | [Filters](#filters) | | v1.3.0 | Filter groups to automatically include or exclude entities ([example](#entity-filtering-and-bulk-renaming)) | bulk_rename | list([Convert](#convert)) | | v1.3.0 | Rename rules applied for all entities ([example](#entity-filtering-and-bulk-renaming)) +| style | string | | v1.4.0 | Extra CSS code to change/adjust the appearance ([example](#extra-styles)) +[common options](#common-options) (if specified they will be apllied to all entities) @@ -67,7 +68,7 @@ This card was inspired by [another great card](https://github.com/cbulock/lovela Note: the exact color is taken from CSS variable and it depends on your current template. -### Filter groups +### Filters | Name | Type | Default | Description | |:-----|:-----|:-----|:-----| | include | list([Filter](#filter-object)) | | Filters for auto adding entities @@ -135,6 +136,15 @@ Note: All of these values are optional but at least `entity_id` or `state` or `a | name | string | **(required)** | Name of the attribute | value | string | | Value of the attribute +### Group object + +| Name | Type | Default | Since | Description | +|:-----|:-----|:-----|:-----|:-----| +| name | string | | v1.4.0 | Name of the group. Keywords available: `{min}`, `{max}`, `{count}`, `{range}` +| secondary_info | string | | v1.4.0 | Secondary info text, shown in the second line. Same keywords available as in `name` +| icon | string | | v1.4.0 | Group icon +| min | number | | v1.4.0 | Minimal battery level. Batteries below that level won't be assigned to this group. +| max | number | | v1.4.0 | Maximal battery level. Batteries above that level won't be assigned to this group. ## Examples You can use this component as a card or as an entity (e.g. in `entities card`); @@ -281,6 +291,40 @@ You can setup as well colors only for lower battery levels and leave the default - sensor.bedroom_balcony_battery_level - sensor.bedroom_switch_battery_level ``` +### Battery groups + +Battery groups allow you to group together set of batteries/entities based on couple conditions. You can use HA group entities to tell which entities should go to the group, or you can set min/max battery levels, or specify explicit list of entities which should be assigned to particular group. + +Note: If you have battery groups defined in Home Assistant you can use their IDs instead of single entity ID (in `entities` collection). + +![image](https://user-images.githubusercontent.com/8268674/84313600-aa42c700-ab5e-11ea-829e-394b292f3cbe.png) + +```yaml +type: 'custom:battery-state-card' +title: Battery state card +sort_by_level: asc +collapse: + - name: 'Door sensors (min: {min}%, count: {count})' # special keywords in group name + secondary_info: 'Battery levels {range}%' # special keywords in group secondary info + icon: 'mdi:door' + entities: # explicit list of entities + - sensor.bedroom_balcony_battery_level + - sensor.main_door_battery_level + - sensor.living_room_balcony_battery_level + - group_id: group.motion_sensors_batteries # using HA group + secondary_info: No icon # Secondary info text + icon: null # removing default icon for this group (from HA group definition) + - group_id: group.temp_sensors_batteries + min: 99 # all entities below that level should show up ungroupped + icon: 'mdi:thermometer' # override for HA group icon +entities: + # if you need to specify some properties for any entity in the group + - entity: sensor.bedroom_balcony_battery_level + name: "Bedroom balkony door" + multiplier: 10 + # entities from below HA group won't be grouped as there is no corresponding collapsed group + - group.switches_batteries +``` ### Non-numeric state values @@ -409,6 +453,54 @@ If you add entities automatically you cannot specify properties for individual e secondary_info: "Battery state" # Static text ``` +### Extra styles + +You can add CSS code which can change the appearance of the card. + +Note: HTML code (including CSS class names) can change in next releases so your custom styles may require adjustments after card update. + +![image](https://user-images.githubusercontent.com/8268674/84653185-db2b4f00-af04-11ea-97a9-07f0dbb0800e.png) + +```yaml +- title: Glance view with custom CSS + type: 'custom:battery-state-card' + entities: + - group.all_battery_sensors + sort_by_level: asc + style: | + .card-content { + display: grid; + grid-template-columns: auto auto auto auto + } + .entity-row.entity-spacing { + margin: 8px 0; + } + .entity-row { + display: flex; + flex-direction: column; + } + .entity-row .name { + order: 1; + overflow: hidden; + width: 80px; + font-size: 12px + } + .entity-row.non-numeric-state .state { + display: none; + } + .entity-row:not(.non-numeric-state) .state { + position: absolute; + text-shadow: 1px 1px black; + width: 40px; + height: 40px; + display: flex; + justify-content: center; + align-items: center; + font-size: 12px; + overflow: hidden; + } +``` + ## Installation Once added to [HACS](https://community.home-assistant.io/t/custom-component-hacs/121727) add the following to your lovelace configuration diff --git a/package.json b/package.json index fa156238..146b9a6d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "battery-state-card", - "version": "1.3.7", + "version": "1.4.0", "description": "Battery State card for Home Assistant", "main": "dist/battery-state-card.js", "repository": { diff --git a/src/battery-provider.ts b/src/battery-provider.ts index b900eaa2..aae1d931 100644 --- a/src/battery-provider.ts +++ b/src/battery-provider.ts @@ -1,8 +1,9 @@ import BatteryViewModel from "./battery-vm"; -import { IBatteryStateCardConfig, IFilter, FilterOperator, IBatteryEntity } from "./types"; +import { IBatteryStateCardConfig, IFilter, FilterOperator, IBatteryEntity, IHomeAssistantGroupProps, IBatteriesResultViewData, IGroupDataMap } from "./types"; import { HassEntity, HomeAssistant } from "./ha-types"; import { log } from "./utils"; import { ActionFactory } from "./action"; +import { getBatteryCollections } from "./grouping"; /** * Properties which should be copied over to individual entities from the card @@ -139,6 +140,16 @@ export class BatteryProvider { */ private batteries: BatteryViewModel[] = []; + /** + * Groups to be resolved on HA state update. + */ + private groupsToResolve: string[] = []; + + /** + * Collection of groups and their properties taken from HA + */ + private groupsData: IGroupDataMap = {}; + /** * Whether include filters were processed already. */ @@ -155,25 +166,32 @@ export class BatteryProvider { this.processExplicitEntities(); } - /** - * Return batteries - * @param hass Home Assistant instance - */ - getBatteries(hass?: HomeAssistant): BatteryViewModel[] { + update(hass: HomeAssistant): boolean { + let updated = false; + if (!this.initialized) { + // groups and includes should be processed just once + this.initialized = true; - if (hass) { - if (!this.initialized) { - this.processIncludes(hass); - } + updated = this.processGroups(hass) || updated; - const updated = this.updateBatteries(hass); + updated = this.processIncludes(hass) || updated; + } - if (updated) { - this.processExcludes(hass); - } + updated = this.updateBatteries(hass) || updated; + + if (updated) { + this.processExcludes(hass); } - return this.batteries; + return updated; + } + + /** + * Return batteries + * @param hass Home Assistant instance + */ + getBatteries(): IBatteriesResultViewData { + return getBatteryCollections(this.config.collapse, this.batteries, this.groupsData); } /** @@ -210,6 +228,41 @@ export class BatteryProvider { return entity; }); + // remove groups to add them later + entities = entities.filter(e => { + if (!e.entity) { + throw new Error("Invalid configuration - missing property 'entity' on:\n" + JSON.stringify(e)); + } + + if (e.entity.startsWith("group.")) { + this.groupsToResolve.push(e.entity); + return false; + } + + return true; + }); + + // processing groups and entities from collapse property + // this way user doesn't need to put same IDs twice in the configuration + if (this.config.collapse && Array.isArray(this.config.collapse)) { + this.config.collapse.forEach(group => { + if (group.group_id) { + // check if it's not there already + if (this.groupsToResolve.indexOf(group.group_id) == -1) { + this.groupsToResolve.push(group.group_id); + } + } + else if (group.entities) { + group.entities.forEach(entity_id => { + // check if it's not there already + if (!entities.some(e => e.entity == entity_id)) { + entities.push({ entity: entity_id }); + } + }); + } + }); + } + this.batteries = entities.map(entity => this.createBattery(entity)); } @@ -217,12 +270,11 @@ export class BatteryProvider { * Adds batteries based on filter.include config. * @param hass Home Assistant instance */ - private processIncludes(hass: HomeAssistant) { - // avoiding processing filter.include again - this.initialized = true; + private processIncludes(hass: HomeAssistant): boolean { + let updated = false; if (!this.include) { - return; + return updated; } Object.keys(hass.states).forEach(entity_id => { @@ -231,9 +283,51 @@ export class BatteryProvider { // check if battery is not added already (via explicit entities) !this.batteries.some(b => b.entity_id == entity_id)) { + updated = true; this.batteries.push(this.createBattery({ entity: entity_id })); } }); + + return updated; + } + + /** + * Adds batteries from group entities (if they were on the list) + * @param hass Home Assistant instance + */ + private processGroups(hass: HomeAssistant): boolean { + + let updated = false; + + this.groupsToResolve.forEach(group_id => { + const groupEntity = hass.states[group_id]; + if (!groupEntity) { + log(`Group "${group_id}" not found`); + return; + } + + const groupData = groupEntity.attributes as IHomeAssistantGroupProps; + if (!Array.isArray(groupData.entity_id)) { + log(`Entities not found in "${group_id}"`); + return; + } + + groupData.entity_id.forEach(entity_id => { + // check if battery is on the list already + if (this.batteries.some(b => b.entity_id == entity_id)) { + return; + } + + updated = true; + this.batteries.push(this.createBattery({ entity: entity_id })); + }); + + this.groupsData[group_id] = groupData; + }); + + this.groupsToResolve = []; + + return updated; } /** diff --git a/src/battery-vm.ts b/src/battery-vm.ts index 1c34ce37..771866ee 100644 --- a/src/battery-vm.ts +++ b/src/battery-vm.ts @@ -167,7 +167,10 @@ class BatteryViewModel { } get classNames(): string { - return this.action ? "clickable" : ""; + const classNames = []; + this.action && classNames.push("clickable"); + !isNumber(this.level) && classNames.push("non-numeric-state"); + return classNames.join(" "); } /** @@ -186,7 +189,7 @@ class BatteryViewModel { this.name = this.config.name || entityData.attributes.friendly_name - this.level = this.getLevel(entityData); + this.level = this.getLevel(entityData, hass); // must be called after getting battery level this.charging = this.getChargingState(hass); @@ -199,8 +202,8 @@ class BatteryViewModel { * Gets battery level * @param entityData Entity state data */ - private getLevel(entityData: HassEntity): string { - const UnknownLevel = "Unknown"; + private getLevel(entityData: HassEntity, hass: HomeAssistant): string { + const UnknownLevel = hass.localize("state.default.unknown"); let level: string; if (this.config.attribute) { diff --git a/src/grouping.ts b/src/grouping.ts new file mode 100644 index 00000000..17ed6d39 --- /dev/null +++ b/src/grouping.ts @@ -0,0 +1,153 @@ +import { ICollapsingGroupConfig, IBatteryGroupViewData, IBatteriesResultViewData, IHomeAssistantGroupProps, IGroupDataMap } from "./types" +import BatteryViewModel from "./battery-vm" +import { log } from "./utils"; + +/** + * Returns battery collections to render + * @param config Collapsing config + * @param batteries Battery view models + * @param haGroupData Home assistant group data + */ +export const getBatteryCollections = (config: number | ICollapsingGroupConfig[] | undefined, batteries: BatteryViewModel[], haGroupData: IGroupDataMap): IBatteriesResultViewData => { + const result: IBatteriesResultViewData = { + batteries: [], + groups: [] + }; + + if (!config) { + result.batteries = batteries; + return result; + } + + if (typeof config == "number") { + result.batteries = batteries.slice(0, config); + result.groups.push(createGroup(haGroupData, batteries.slice(config))); + } + else {// make sure that max property is set for every group + populateMinMaxFields(config); + + batteries.forEach(b => { + const foundIndex = getGroupIndex(config, b, haGroupData); + if (foundIndex == -1) { + // batteries without group + result.batteries.push(b); + } + else { + // bumping group index as the first group is for the orphans + result.groups[foundIndex] = result.groups[foundIndex] || createGroup(haGroupData, [], config[foundIndex]); + result.groups[foundIndex].batteries.push(b); + } + }); + } + + // update group name and secondary info / replace keywords with values + result.groups.forEach(g => { + if (g.name) { + g.name = getEnrichedText(g.name, g); + } + + if (g.secondary_info) { + g.secondary_info = getEnrichedText(g.secondary_info, g); + } + }); + + return result; +} + +/** + * Returns group index to which battery should be assigned. + * @param config Collapsing groups config + * @param battery Batterry view model + * @param haGroupData Home assistant group data + */ +const getGroupIndex = (config: ICollapsingGroupConfig[], battery: BatteryViewModel, haGroupData: IGroupDataMap): number => { + return config.findIndex(group => { + + if (group.group_id && !haGroupData[group.group_id]?.entity_id?.some(id => battery.entity_id == id)) { + return false; + } + + if (group.entities && !group.entities.some(id => battery.entity_id == id)) { + return false + } + + const level = isNaN(Number(battery.level)) ? 0 : Number(battery.level); + + return level >= group.min! && level <= group.max!; + }); +} + +/** + * Sets missing max/min fields. + * @param config Collapsing groups config + */ +var populateMinMaxFields = (config: ICollapsingGroupConfig[]): void => config.forEach(groupConfig => { + if (groupConfig.min == undefined) { + groupConfig.min = 0; + } + + if (groupConfig.max != undefined && groupConfig.max < groupConfig.min) { + log("Collapse group min value should be lower than max.\n" + JSON.stringify(groupConfig, null, 2)); + return; + } + + if (groupConfig.max == undefined) { + groupConfig.max = 100; + } +}); + +/** + * Creates and returns group view data object. + * @param haGroupData Home assistant group data + * @param batteries Batterry view model + * @param config Collapsing group config + */ +const createGroup = (haGroupData: IGroupDataMap, batteries: BatteryViewModel[] = [], config?: ICollapsingGroupConfig): IBatteryGroupViewData => { + + if (config?.group_id && !haGroupData[config.group_id]) { + throw new Error("Group not found: " + config.group_id); + } + + let name = config?.name; + if (!name && config?.group_id) { + name = haGroupData[config.group_id].friendly_name; + } + + let icon = config?.icon; + if (icon === undefined && config?.group_id) { + icon = haGroupData[config.group_id].icon; + } + + return { + name: name, + icon: icon, + batteries: batteries, + secondary_info: config?.secondary_info + } +} + +/** + * Replaces all keywords, used in the text, with values + * @param text Text to process + * @param group Battery group view data + */ +const getEnrichedText = (text: string, group: IBatteryGroupViewData): string => { + text = text.replace(/\{[a-z]+\}/g, keyword => { + switch (keyword) { + case "{min}": + return group.batteries.reduce((agg, b) => agg > Number(b.level) ? Number(b.level) : agg, 100).toString(); + case "{max}": + return group.batteries.reduce((agg, b) => agg < Number(b.level) ? Number(b.level) : agg, 0).toString(); + case "{count}": + return group.batteries.length.toString(); + case "{range}": + const min = group.batteries.reduce((agg, b) => agg > Number(b.level) ? Number(b.level) : agg, 100).toString(); + const max = group.batteries.reduce((agg, b) => agg < Number(b.level) ? Number(b.level) : agg, 0).toString(); + return min == max ? min : min + "-" + max; + default: + return keyword; + } + }); + + return text; +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index b96f172e..4f9db8be 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,11 @@ import { HomeAssistant } from "./ha-types"; import { IBatteryStateCardConfig } from "./types"; -import { LitElement } from "./lit-element"; -import BatteryViewModel from "./battery-vm"; +import { LitElement, LitHtml } from "./lit-element"; import * as views from "./views"; import styles from "./styles"; import { ActionFactory } from "./action"; import { BatteryProvider } from "./battery-provider"; +import { processStyles } from "./utils"; /** * Card main class. @@ -20,17 +20,12 @@ class BatteryStateCard extends LitElement { /** * Card configuration. */ - public config: IBatteryStateCardConfig = {}; + private config: IBatteryStateCardConfig = {}; /** * Whether we should render it as an entity - not a card. */ - public simpleView: boolean = false; - - /** - * Battery objects to track. - */ - public batteries: BatteryViewModel[] = []; + private simpleView: boolean = false; /** * Battery provider for battery view models. @@ -38,14 +33,9 @@ class BatteryStateCard extends LitElement { private batteryProvider: BatteryProvider = null; /** - * Properties defined here are used by Polymer to detect - * changes and update card UI. + * Custom styles comming from config. */ - static get properties() { - return { - batteries: Array - }; - } + private cssStyles: string = ""; /** * CSS for the card @@ -60,8 +50,11 @@ class BatteryStateCard extends LitElement { * @param config Card configuration */ setConfig(config: IBatteryStateCardConfig) { - if (!config.entities && !config.entity && !config.filter?.include) { - throw new Error("You need to define entities or filter.include"); + if (!config.entities && + !config.entity && + !config.filter?.include && + !Array.isArray(config.collapse)) { + throw new Error("You need to define entities, filter.include or collapse.group"); } // check for changes @@ -79,7 +72,9 @@ class BatteryStateCard extends LitElement { this.simpleView = !!this.config.entity; this.batteryProvider = new BatteryProvider(this.config, this); - this.batteries = this.batteryProvider.getBatteries() + + // always render initial state, even though we don't have values yet + this.requestUpdate(); } /** @@ -90,34 +85,70 @@ class BatteryStateCard extends LitElement { ActionFactory.hass = hass; // to improve perf we release the task/thread - setTimeout(() => this.batteries = this.batteryProvider.getBatteries(hass), 0); + setTimeout(() => { + const updated = this.batteryProvider.update(hass); + if (updated) { + // trigger rendering + this.requestUpdate(); + } + }, 0); } /** * Renders the card. Called when update detected. */ render() { + + const viewData = this.batteryProvider.getBatteries(); + // check if we should render it without card container if (this.simpleView) { - return views.battery(this.batteries[0]); + return views.battery(viewData.batteries[0]); } - const batteryViews = this.batteries - .filter(battery => !battery.is_hidden) - .map(battery => views.battery(battery)); + let renderedViews: LitHtml[] = []; + + viewData.batteries.forEach(b => !b.is_hidden && renderedViews.push(views.battery(b))); - // filer cards (entity-filter) can produce empty collection - if (batteryViews.length == 0) { - // don't render anything + viewData.groups.forEach(g => { + const renderedBatteries: LitHtml[] = []; + g.batteries.forEach(b => !b.is_hidden && renderedBatteries.push(views.battery(b))); + if (renderedBatteries.length) { + renderedViews.push(views.collapsableWrapper(renderedBatteries, g)); + } + }); + + if (renderedViews.length == 0) { return views.empty(); } return views.card( this.config.name || this.config.title, - this.config.collapse ? [ views.collapsableWrapper(batteryViews, this.config.collapse) ] : batteryViews + renderedViews, ); } + /** + * Called just after the update is finished (including rendering) + */ + updated() { + if (!this.config?.style || this.cssStyles == this.config.style) { + return; + } + + this.cssStyles = this.config.style; + + let styleElem = this.shadowRoot!.querySelector("style"); + if (!styleElem) { + styleElem = document.createElement("style"); + styleElem.type = 'text/css' + this.shadowRoot!.appendChild(styleElem); + } + + // prefixing all selectors + styleElem.innerHTML = processStyles("ha-card", this.cssStyles); + } + /** * Gets the height of your card. * @@ -125,11 +156,16 @@ class BatteryStateCard extends LitElement { * the available columns. One is equal 50px. */ getCardSize() { - let size = this.batteries.length; + let size = this.config.entities?.length || 1; if (this.config.collapse) { - // +1 to account the expand button - size = this.config.collapse + 1; + if (typeof this.config.collapse == "number") { + // +1 to account the expand button + return this.config.collapse + 1; + } + else { + return this.config.collapse.length + 1; + } } // +1 to account header @@ -138,5 +174,4 @@ class BatteryStateCard extends LitElement { } // Registering card -customElements.define("battery-state-card", BatteryStateCard); - +customElements.define("battery-state-card", BatteryStateCard); \ No newline at end of file diff --git a/src/lit-element.ts b/src/lit-element.ts index b68cef04..149cb5a9 100644 --- a/src/lit-element.ts +++ b/src/lit-element.ts @@ -7,10 +7,21 @@ declare var LitElement: ILitElement; * Maybe in the future LitElement will be globally available but currently only Polymer.Element is there. */ var LitElement = LitElement || Object.getPrototypeOf(customElements.get("home-assistant-main")); -const { html, css } = LitElement.prototype; +const { html, css } = LitElement.prototype as { html: LitHtml, css: any }; -interface ILitElement extends Node { - new(): ILitElement +export type LitHtml = Function; +type ChangedProperties = { [propertyName: string]: any }; + +interface ILitElement extends HTMLElement { + new(): ILitElement; + requestUpdate(): Promise; + requestUpdate(propertyName: string, oldValue: any): Promise; + shouldUpdate(changedProperties: ChangedProperties): boolean; + update(changedProperties: ChangedProperties): void; + firstUpdated(changedProperties: ChangedProperties): void; + updated(changedProperties: ChangedProperties): void; + updateComplete(): Promise; + render(): LitHtml; } export { diff --git a/src/styles.css b/src/styles.css index 8fdf8733..082a9971 100644 --- a/src/styles.css +++ b/src/styles.css @@ -7,26 +7,33 @@ overflow: hidden; } -.battery { - display: flex; - align-items: center; +.entity-spacing { margin: 8px 0; } -.battery:first-child { +.entity-spacing:first-child { margin-top: 0; } -.battery .name { - flex: 1 0 60px; - margin: 0 5px 0 16px; +.entity-spacing:last-child { + margin-bottom: 0; +} + +.entity-row { + display: flex; + align-items: center; } -.battery .secondary { +.entity-row .name { + flex: 1; + margin: 0 6px; +} +.entity-row .secondary { color: var(--primary-color); } -.battery .icon { +.entity-row .icon { flex: 0 0 40px; border-radius: 50%; text-align: center; line-height: 40px; + margin-right: 10px; } @@ -34,29 +41,30 @@ display: none; } .expand + label { - display: block; - text-align: right; cursor: pointer; } -.expand + label > div { - display: inline-block; +.expand + label > .name { + flex: 1; +} +.expand + label div.chevron { transform: rotate(-90deg); font-size: 26px; - height: 29px; - width: 29px; - text-align: center; + height: 40px; + width: 40px; + display: flex; + justify-content: center; + align-items: center; } -.expand + label > div, +.expand + label .chevron, .expand + label + div { - transition: 0.5s ease-in-out; + transition: all 0.5s ease; } -.expand:checked + label > div { +.expand:checked + label .chevron { transform: rotate(-90deg) scaleX(-1); } .expand + label + div { - max-height: 0; overflow: hidden; } -.expand:checked + label + div { - max-height: 10000px; +.expand:not(:checked) + label + div { + max-height: 0 !important; } \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index fbe05cdc..eddbed79 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,4 @@ +import BatteryViewModel from "./battery-vm"; /** * Color threshold @@ -226,11 +227,50 @@ export interface IBatteryStateCardConfig extends IBatteryEntity { /** * Collapse after given number of entities */ - collapse?: number; + collapse?: number | ICollapsingGroupConfig[]; /** * Filters for auto adding or removing entities */ filter?: { [key in FilterGroups]: IFilter[] }; + + /** + * CSS code for the card + */ + style: string; +} + +export interface IHomeAssistantGroupProps { + entity_id: string[]; + friendly_name?: string; + icon?: string; +} + +export interface IGroupDataMap { + [group_id: string]: IHomeAssistantGroupProps +} + +export interface ICollapsingGroupConfig { + name?: string; + secondary_info?: string; + group_id?: string; + entities?: string[]; + icon?: string; + min?: number; + max?: number; +} + +export interface IBatteryGroupViewData { + name?: string; + secondary_info?: string; + icon?: string; + iconColor?: string; + batteries: BatteryViewModel[] +} + + +export interface IBatteriesResultViewData { + batteries: BatteryViewModel[]; + groups: IBatteryGroupViewData[]; } diff --git a/src/utils.ts b/src/utils.ts index ccba33a2..545a51ac 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,7 +1,7 @@ import { HomeAssistant } from "./ha-types"; console.info( - "%c BATTERY-STATE-CARD %c 1.3.7", + "%c BATTERY-STATE-CARD %c 1.4.0", "color: white; background: forestgreen; font-weight: 700;", "color: forestgreen; background: white; font-weight: 700;", ); @@ -98,6 +98,7 @@ export const getRelativeTime = (hass: HomeAssistant, rawDate: string): string => time = Math.round((Date.now() - time) / 1000); // convert to seconds diff + // https://github.com/yosilevy/home-assistant-polymer/blob/master/src/translations/en.json let relativeTime = ""; if (time < 60) { relativeTime = hass.localize("ui.components.relative_time.duration.second", "count", time); @@ -112,4 +113,11 @@ export const getRelativeTime = (hass: HomeAssistant, rawDate: string): string => } return hass.localize("ui.components.relative_time.past", "time", relativeTime); -} \ No newline at end of file +} + +/** + * Prefixes all css selectors with given value. + * @param containerCssPath Prefix to be added + * @param styles Styles to process + */ +export const processStyles = (containerCssPath: string, styles: string) => styles.replace(/([^\r\n,{}]+)(,(?=[^}]*{)|\s*{)/g, match => `${containerCssPath} ${match}`); \ No newline at end of file diff --git a/src/views.ts b/src/views.ts index cbb9154e..34eac06c 100644 --- a/src/views.ts +++ b/src/views.ts @@ -1,6 +1,7 @@ -import { html } from "./lit-element"; +import { html, LitHtml } from "./lit-element"; import BatteryViewModel from "./battery-vm"; import { isNumber } from "./utils"; +import { IBatteryGroupViewData } from "./types"; const header = (text: string) => html` @@ -11,21 +12,25 @@ const header = (text: string) => html` `; -const secondaryInfo = (model: BatteryViewModel) => model.secondary_info && html` -
${model.secondary_info}
+const secondaryInfo = (text?: string) => text && html` +
${text}
+`; + +const icon = (icon?: string, color?: string) => icon && html` +
+ +
`; export const battery = (model: BatteryViewModel) => html` -
-
- -
+
+ ${icon(model.icon, model.levelColor)}
${model.name} - ${secondaryInfo(model)} + ${secondaryInfo(model.secondary_info)}
${model.level}${isNumber(model.level) ? html` %` : ""} @@ -33,23 +38,33 @@ export const battery = (model: BatteryViewModel) => html`
`; -export const card = (headerText: string | undefined, contents: string[]) => html` +export const card = (headerText: string | undefined, contents: LitHtml[]) => { + return html` ${headerText ? header(headerText) : ""}
${contents}
-`; +` +}; -export const collapsableWrapper = (contents: string[], collapseAfter: number) => { +export const collapsableWrapper = (contents: LitHtml[], model: IBatteryGroupViewData) => { const elemId = "expander" + Math.random().toString().substr(2); return html` - ${contents.slice(0, collapseAfter)} +
- -
${contents.slice(collapseAfter)}
- ` + +
${contents}
+
+` }; export const empty = () => html``; \ No newline at end of file