diff --git a/.hass_dev/lovelace-mushroom-showcase.yaml b/.hass_dev/lovelace-mushroom-showcase.yaml index d8ec02622..686159c1c 100644 --- a/.hass_dev/lovelace-mushroom-showcase.yaml +++ b/.hass_dev/lovelace-mushroom-showcase.yaml @@ -14,4 +14,5 @@ views: - !include views/vacuum-view.yaml - !include views/lock-view.yaml - !include views/humidifier-view.yaml + - !include views/graph-view.yaml - !include views/select-view.yaml \ No newline at end of file diff --git a/.hass_dev/views/graph-view.yaml b/.hass_dev/views/graph-view.yaml new file mode 100644 index 000000000..3ec44b5a8 --- /dev/null +++ b/.hass_dev/views/graph-view.yaml @@ -0,0 +1,29 @@ +title: Graph +icon: mdi:chart-areaspline +cards: + - type: grid + title: Graph + columns: 2 + square: false + cards: + - type: custom:mushroom-graph-card + entity: sensor.outside_temperature + - type: custom:mushroom-graph-card + entity: sensor.outside_temperature + name: Temperature + - type: custom:mushroom-graph-card + entity: sensor.outside_temperature + icon: mdi:chart-areaspline + - type: custom:mushroom-graph-card + entity: sensor.outside_temperature + graph_color: pink + - type: horizontal-stack + title: Display mode + cards: + - type: custom:mushroom-graph-card + entity: sensor.outside_temperature + display_mode: standard + - type: custom:mushroom-graph-card + entity: sensor.outside_temperature + display_mode: compact + square: false \ No newline at end of file diff --git a/docs/cards/graph.md b/docs/cards/graph.md new file mode 100644 index 000000000..b4d35599f --- /dev/null +++ b/docs/cards/graph.md @@ -0,0 +1,27 @@ +# Graph card + +![Graph light](../images/graph-light.png) +![Graph dark](../images/graph-dark.png) + +## Description + +A graph card allow you to show the actual value and the history. + +## Configuration variables + +All the options are available in the lovelace editor but you can use `yaml` if you want. + +| Name | Type | Default | Description | +| :------------------ | :-------------------------------------------------- | :---------- | :--------------------------------------------- | +| `entity` | string | Required | Entity | +| `name` | string | Optional | Custom name | +| `icon` | string | Optional | Custom icon | +| `hours_to_show` | number | Optional | Number of hours to show in the history | +| `graph_color` | string | Optional | Entity chart and icon color | +| `primary_info` | `name` `state` `last-changed` `last-updated` `none` | `name` | Info to show as primary info | +| `secondary_info` | `name` `state` `last-changed` `last-updated` `none` | `state` | Info to show as secondary info | +| `graph_mode` | string | Optional | Mode `line` or `fill` | +| `display_mode` | string | Optional | Mode `standard` or `compact` | +| `tap_action` | action | `more-info` | Home assistant action to perform on tap | +| `hold_action` | action | `more-info` | Home assistant action to perform on hold | +| `double_tap_action` | action | `more-info` | Home assistant action to perform on double_tap | diff --git a/docs/images/graph-dark.png b/docs/images/graph-dark.png new file mode 100644 index 000000000..6d256685a Binary files /dev/null and b/docs/images/graph-dark.png differ diff --git a/docs/images/graph-light.png b/docs/images/graph-light.png new file mode 100644 index 000000000..f6d86bdf6 Binary files /dev/null and b/docs/images/graph-light.png differ diff --git a/src/cards/climate-card/climate-card-config.ts b/src/cards/climate-card/climate-card-config.ts index 7f4a87a21..f8e190a79 100644 --- a/src/cards/climate-card/climate-card-config.ts +++ b/src/cards/climate-card/climate-card-config.ts @@ -1,5 +1,5 @@ import { array, assign, boolean, object, optional, string } from "superstruct"; -import { HvacMode, LovelaceCardConfig } from "../../ha"; +import { HvacMode, PresetMode, LovelaceCardConfig } from "../../ha"; import { ActionsSharedConfig, actionsSharedConfigStruct } from "../../shared/config/actions-config"; import { AppearanceSharedConfig, @@ -18,12 +18,20 @@ export const HVAC_MODES: HvacMode[] = [ "off", ]; +export const PRESET_MODES: PresetMode[] = [ + "home", + "away", + "sleep", + "comfort", +] + export type ClimateCardConfig = LovelaceCardConfig & EntitySharedConfig & AppearanceSharedConfig & ActionsSharedConfig & { show_temperature_control?: false; hvac_modes?: HvacMode[]; + preset_modes?: PresetMode[]; collapsible_controls?: boolean; }; @@ -33,6 +41,7 @@ export const climateCardConfigStruct = assign( object({ show_temperature_control: optional(boolean()), hvac_modes: optional(array(string())), + preset_modes: optional(array(string())), collapsible_controls: optional(boolean()), }) ); diff --git a/src/cards/climate-card/climate-card-editor.ts b/src/cards/climate-card/climate-card-editor.ts index 7a2ad6cac..fa721c379 100644 --- a/src/cards/climate-card/climate-card-editor.ts +++ b/src/cards/climate-card/climate-card-editor.ts @@ -12,9 +12,9 @@ import { HaFormSchema } from "../../utils/form/ha-form"; import { stateIcon } from "../../utils/icons/state-icon"; import { loadHaComponents } from "../../utils/loader"; import { CLIMATE_CARD_EDITOR_NAME, CLIMATE_ENTITY_DOMAINS } from "./const"; -import { ClimateCardConfig, climateCardConfigStruct, HVAC_MODES } from "./climate-card-config"; +import { ClimateCardConfig, climateCardConfigStruct, HVAC_MODES, PRESET_MODES } from "./climate-card-config"; -const CLIMATE_LABELS = ["hvac_modes", "show_temperature_control"] as string[]; +const CLIMATE_LABELS = ["hvac_modes", "preset_modes", "show_temperature_control"] as string[]; const computeSchema = memoizeOne((localize: LocalizeFunc, icon?: string): HaFormSchema[] => [ { name: "entity", selector: { entity: { domain: CLIMATE_ENTITY_DOMAINS } } }, @@ -38,6 +38,19 @@ const computeSchema = memoizeOne((localize: LocalizeFunc, icon?: string): HaForm }, }, }, + { + name: "preset_modes", + selector: { + select: { + options: PRESET_MODES.map((mode) => ({ + value: mode, + label: mode, + })), + mode: "dropdown", + multiple: true, + }, + }, + }, { name: "show_temperature_control", selector: { boolean: {} } }, { name: "collapsible_controls", selector: { boolean: {} } }, ], diff --git a/src/cards/climate-card/climate-card.ts b/src/cards/climate-card/climate-card.ts index 6d6ddb0d4..8c7e73adb 100644 --- a/src/cards/climate-card/climate-card.ts +++ b/src/cards/climate-card/climate-card.ts @@ -37,13 +37,16 @@ import "./controls/climate-hvac-modes-control"; import { isHvacModesVisible } from "./controls/climate-hvac-modes-control"; import "./controls/climate-temperature-control"; import { isTemperatureControlVisible } from "./controls/climate-temperature-control"; -import { getHvacActionColor, getHvacActionIcon, getHvacModeColor } from "./utils"; +import "./controls/climate-preset-modes-control"; +import { isPresetModesVisible } from "./controls/climate-preset-modes-control"; +import { getHvacActionColor, getHvacActionIcon, getHvacModeColor, getPresetModeIcon } from "./utils"; -type ClimateCardControl = "temperature_control" | "hvac_mode_control"; +type ClimateCardControl = "temperature_control" | "hvac_mode_control" | "preset_mode_control"; const CONTROLS_ICONS: Record = { temperature_control: "mdi:thermometer", hvac_mode_control: "mdi:thermostat", + preset_mode_control: "mdi:form-dropdown" }; registerCustomCard({ @@ -108,7 +111,7 @@ export class ClimateCard extends MushroomBaseCard implements LovelaceCard { const entity_id = this._config.entity; const entity = this.hass.states[entity_id] as ClimateEntity; - + if (!entity) return; const controls: ClimateCardControl[] = []; @@ -119,6 +122,9 @@ export class ClimateCard extends MushroomBaseCard implements LovelaceCard { if (isHvacModesVisible(entity, this._config.hvac_modes)) { controls.push("hvac_mode_control"); } + if (isPresetModesVisible(entity, this._config.preset_modes)) { + controls.push("preset_mode_control"); + } } this._controls = controls; @@ -141,7 +147,8 @@ export class ClimateCard extends MushroomBaseCard implements LovelaceCard { const entity = this.hass.states[entity_id] as ClimateEntity; const name = this._config.name || entity.attributes.friendly_name || ""; - const icon = this._config.icon || stateIcon(entity); + // const icon = this._config.icon || stateIcon(entity); + const icon = getPresetModeIcon(entity.attributes.preset_mode) const appearance = computeAppearance(this._config); const picture = computeEntityPicture(entity, appearance.icon_type); @@ -160,6 +167,8 @@ export class ClimateCard extends MushroomBaseCard implements LovelaceCard { stateDisplay += ` - ${temperature} ${unit}`; } const rtl = computeRTL(this.hass); + + const preset_modes = this._config?.preset_modes ?? []; return html` @@ -252,7 +261,8 @@ export class ClimateCard extends MushroomBaseCard implements LovelaceCard { private renderActiveControl(entity: ClimateEntity): TemplateResult | null { const hvac_modes = this._config?.hvac_modes ?? []; - + + const preset_modes = this._config?.preset_modes ?? []; switch (this._activeControl) { case "temperature_control": return html` @@ -271,6 +281,15 @@ export class ClimateCard extends MushroomBaseCard implements LovelaceCard { .fill=${true} > `; + case "preset_mode_control": + return html` + + `; default: return null; } @@ -285,9 +304,10 @@ export class ClimateCard extends MushroomBaseCard implements LovelaceCard { cursor: pointer; } mushroom-climate-temperature-control, + mushroom-climate-preset-modes-control, mushroom-climate-hvac-modes-control { flex: 1; - } + }, `, ]; } diff --git a/src/cards/climate-card/controls/climate-preset-modes-control.ts b/src/cards/climate-card/controls/climate-preset-modes-control.ts new file mode 100644 index 000000000..515c4a40b --- /dev/null +++ b/src/cards/climate-card/controls/climate-preset-modes-control.ts @@ -0,0 +1,70 @@ +import { html, LitElement, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { styleMap } from "lit/directives/style-map.js"; +import { + ClimateEntity, + compareClimatePresetModes, + computeRTL, + HomeAssistant, + PresetMode, + isAvailable, +} from "../../../ha"; +import "../../../shared/button"; +import "../../../shared/button-group"; +import { getPresetModeIcon, getPresetModeColor } from "../utils"; + +export const isPresetModesVisible = (entity: ClimateEntity, modes?: PresetMode[]) => + (entity.attributes.preset_modes || []).some((mode) => (modes ?? []).includes(mode)); + +@customElement("mushroom-climate-preset-modes-control") +export class ClimatePresetModesControl extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public entity!: ClimateEntity; + + @property({ attribute: false }) public modes!: PresetMode[]; + + @property() public fill: boolean = false; + + private callService(e: CustomEvent) { + e.stopPropagation(); + const mode = (e.target! as any).mode as PresetMode; + this.hass.callService("climate", "set_preset_mode", { + entity_id: this.entity!.entity_id, + preset_mode: mode, + }); + } + + protected render(): TemplateResult { + const rtl = computeRTL(this.hass); + + const modes = this.entity.attributes.preset_modes + .filter((mode) => (this.modes ?? []).includes(mode)) + .sort(compareClimatePresetModes); + + return html` + + ${modes.map((mode) => this.renderModeButton(mode))} + + `; + } + + private renderModeButton(mode: PresetMode) { + const iconStyle = {}; + const color = getPresetModeColor(mode); + if (mode === this.entity.attributes.preset_mode) { + iconStyle["--icon-color"] = `rgb(${color})`; + iconStyle["--bg-color"] = `rgba(${color}, 0.2)`; + } + + return html` + + `; + } +} diff --git a/src/cards/climate-card/utils.ts b/src/cards/climate-card/utils.ts index d42223d19..b026c738f 100644 --- a/src/cards/climate-card/utils.ts +++ b/src/cards/climate-card/utils.ts @@ -1,4 +1,4 @@ -import { HvacAction, HvacMode } from "../../ha"; +import { HvacAction, HvacMode, PresetMode } from "../../ha"; export const CLIMATE_HVAC_MODE_COLORS: Record = { auto: "var(--rgb-state-climate-auto)", @@ -10,6 +10,13 @@ export const CLIMATE_HVAC_MODE_COLORS: Record = { off: "var(--rgb-state-climate-off)", }; +export const CLIMATE_PRESET_MODE_COLORS: Record = { + sleep: "var(--rgb-blue)", + away: "var(--rgb-blue)", + comfort: "var(--rgb-red)", + home: "var(--rgb-green)", +}; + export const CLIMATE_HVAC_ACTION_COLORS: Record = { cooling: "var(--rgb-state-climate-cool)", drying: "var(--rgb-state-climate-dry)", @@ -36,10 +43,21 @@ export const CLIMATE_HVAC_ACTION_ICONS: Record = { off: "mdi:power", }; +export const CLIMATE_PRESET_MODE_ICONS: Record = { + away: "mdi:home-export-outline", + home: "mdi:home-outline", + sleep: "mdi:bed-outline", + comfort: "mdi:sofa-outline", +}; + export function getHvacModeColor(hvacMode: HvacMode): string { return CLIMATE_HVAC_MODE_COLORS[hvacMode] ?? CLIMATE_HVAC_MODE_COLORS.off; } +export function getPresetModeColor(presetMode: PresetMode): string { + return CLIMATE_PRESET_MODE_COLORS[presetMode] ?? CLIMATE_PRESET_MODE_COLORS.away; +} + export function getHvacActionColor(hvacAction: HvacAction): string { return CLIMATE_HVAC_ACTION_COLORS[hvacAction] ?? CLIMATE_HVAC_ACTION_COLORS.off; } @@ -48,6 +66,10 @@ export function getHvacModeIcon(hvacMode: HvacMode): string { return CLIMATE_HVAC_MODE_ICONS[hvacMode] ?? "mdi:thermostat"; } +export function getPresetModeIcon(presetMode: PresetMode): string { + return CLIMATE_PRESET_MODE_ICONS[presetMode] ?? "mdi:fan"; +} + export function getHvacActionIcon(hvacAction: HvacAction): string | undefined { return CLIMATE_HVAC_ACTION_ICONS[hvacAction] ?? ""; } diff --git a/src/cards/graph-card/const.ts b/src/cards/graph-card/const.ts new file mode 100644 index 000000000..a3428c342 --- /dev/null +++ b/src/cards/graph-card/const.ts @@ -0,0 +1,17 @@ +import { PREFIX_NAME } from "../../const"; + +export const GRAPH_CARD_NAME = `${PREFIX_NAME}-graph-card`; +export const GRAPH_CARD_EDITOR_NAME = `${GRAPH_CARD_NAME}-editor`; +export const GRAPH_ENTITY_DOMAINS = ["counter", "input_number", "number", "sensor"]; + +export const GRAPH_MODE = ["line", "fill"] as const; +export const DISPLAY_MODE = ["standard", "compact"] as const; + +export const GRAPH_MINUTE = 60000; +export const GRAPH_HOUR = GRAPH_MINUTE * 60; +export const GRAPH_DEFAULT_HOURS = 24; + +export const GRAPH_HEIGHT_STANDARD_MARGIN = 20; +export const GRAPH_HEIGHT_COMPACT_MARGIN = 10; +export const GRAPH_HEIGHT_STANDARD = 80; +export const GRAPH_HEIGHT_COMPACT = 40; diff --git a/src/cards/graph-card/graph-card-config.ts b/src/cards/graph-card/graph-card-config.ts new file mode 100644 index 000000000..f1a7a2855 --- /dev/null +++ b/src/cards/graph-card/graph-card-config.ts @@ -0,0 +1,70 @@ +import { array, assign, boolean, enums, object, integer, optional, string } from "superstruct"; +import { LovelaceCardConfig } from "../../ha"; +import { ActionsSharedConfig, actionsSharedConfigStruct } from "../../shared/config/actions-config"; +import { + AppearanceSharedConfig, + appearanceSharedConfigStruct, +} from "../../shared/config/appearance-config"; +import { EntitySharedConfig, entitySharedConfigStruct } from "../../shared/config/entity-config"; +import { lovelaceCardConfigStruct } from "../../shared/config/lovelace-card-config"; + + +import { Info, INFOS } from "../../utils/info"; +import { TemplateCardConfig, templateCardConfigStruct } from "../template-card/template-card-config"; +import { DISPLAY_MODE, GRAPH_MODE } from "./const"; + +export type GraphMode = typeof GRAPH_MODE[number]; +export type DisplayMode = typeof DISPLAY_MODE[number]; + +// export interface GraphCardConfig extends LovelaceCardConfig { +// entity?: string; +// name?: string; +// icon?: string; +// hours_to_show?: number; +// graph_color?: string; +// primary_info?: Info; +// secondary_info?: Info; +// graph_mode?: GraphMode; +// display_mode?: DisplayMode; +// tap_action?: ActionConfig; +// hold_action?: ActionConfig; +// double_tap_action?: ActionConfig; +// } + +export type GraphCardConfig = TemplateCardConfig & { + hours_to_show?: number; + graph_color?: string, + graph_mode?: GraphMode; + display_mode?: DisplayMode; + points_per_hour?: number; + }; + +export const graphCardConfigStruct = assign( + templateCardConfigStruct, + //assign(entitySharedConfigStruct, appearanceSharedConfigStruct, actionsSharedConfigStruct), + object({ + hours_to_show: optional(integer()), + graph_color: optional(string()), + graph_mode: optional(enums(GRAPH_MODE)), + display_mode: optional(enums(DISPLAY_MODE)), + points_per_hour: optional(integer()), + }) +); + +// export const graphCardConfigStruct = assign( +// baseLovelaceCardConfig, +// object({ +// entity: optional(string()), +// icon: optional(string()), +// name: optional(string()), +// hours_to_show: optional(integer()), +// graph_color: optional(string()), +// primary_info: optional(enums(INFOS)), +// secondary_info: optional(enums(INFOS)), +// graph_mode: optional(enums(GRAPH_MODE)), +// display_mode: optional(enums(DISPLAY_MODE)), +// tap_action: optional(actionConfigStruct), +// hold_action: optional(actionConfigStruct), +// double_tap_action: optional(actionConfigStruct), +// }) +// ); diff --git a/src/cards/graph-card/graph-card-editor.ts b/src/cards/graph-card/graph-card-editor.ts new file mode 100644 index 000000000..34e9b01e2 --- /dev/null +++ b/src/cards/graph-card/graph-card-editor.ts @@ -0,0 +1,293 @@ +import { html, TemplateResult } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import memoizeOne from "memoize-one"; +import { assert } from "superstruct"; +import { atLeastHaVersion, LocalizeFunc, fireEvent, LovelaceCardEditor } from "../../ha"; +import setupCustomlocalize from "../../localize"; +import { computeActionsFormSchema } from "../../shared/config/actions-config"; +import { APPEARANCE_FORM_SCHEMA } from "../../shared/config/appearance-config"; +import { MushroomBaseElement } from "../../utils/base-element"; +import { GENERIC_LABELS } from "../../utils/form/generic-fields"; +import { HaFormSchema } from "../../utils/form/ha-form"; +import { UiAction } from "../../utils/form/ha-selector"; +import { stateIcon } from "../../utils/icons/state-icon"; +import { loadHaComponents } from "../../utils/loader"; +import { + DISPLAY_MODE, + GRAPH_CARD_EDITOR_NAME, + GRAPH_DEFAULT_HOURS, + GRAPH_ENTITY_DOMAINS, + GRAPH_MODE, +} from "./const"; + +import { GraphCardConfig, graphCardConfigStruct } from "./graph-card-config"; +import { SelectOption } from "../../utils/form/ha-selector"; +import { TEMPLATE_LABELS } from "../template-card/template-card-editor"; + +const actions: UiAction[] = ["more-info", "call-service", "none"]; +const GRAPH_LABELS = ["graph_mode", "display_mode"]; + +// const computeSchema = memoizeOne((localize: LocalizeFunc, icon?: string): HaFormSchema[] => [ +// { name: "entity", selector: { entity: { domain: GRAPH_ENTITY_DOMAINS } } }, +// { name: "name", selector: { text: {} } }, +// { name: "icon", selector: { icon: { placeholder: icon } } }, +// ...APPEARANCE_FORM_SCHEMA, +// { +// type: "grid", +// name: "", +// schema: [ +// { name: "graph_color", selector: { "mush-color": {} } }, +// { +// name: "hours_to_show", +// selector: { number: { min: 1, max: 168, mode: "box", step: 1 } }, +// }, +// ], +// }, +// { +// type: "grid", +// name: "", +// schema: [ +// { +// name: "graph_mode", +// selector: { +// select: { +// options: GRAPH_MODE.map( +// (mode) => +// { +// value: mode, +// label: +// localize(`editor.card.graph.graph_mode_type.${mode}`) || +// mode, +// } +// ) as SelectOption[], +// mode: "dropdown", +// }, +// }, +// }, +// { +// name: "display_mode", +// selector: { +// select: { +// options: DISPLAY_MODE.map( +// (mode) => +// { +// value: `${mode}`, +// label: +// localize(`editor.card.graph.display_mode_type.${mode}`) || +// mode, +// } +// ) as SelectOption[], +// mode: "dropdown", +// }, +// }, +// }, +// ], +// }, +// ]); + +// const computeSchema = memoizeOne((localize: LocalizeFunc, icon?: string): HaFormSchema[] => [ +const computeSchema = memoizeOne((localize: LocalizeFunc, version: string): HaFormSchema[] => [ + { name: "entity", selector: { entity: { domain: GRAPH_ENTITY_DOMAINS } } }, + { + name: "icon", + selector: atLeastHaVersion(version, 2022, 5) + ? { template: {} } + : { text: { multiline: true } }, + }, + { + name: "icon_color", + selector: atLeastHaVersion(version, 2022, 5) + ? { template: {} } + : { text: { multiline: true } }, + }, + { + name: "primary", + selector: atLeastHaVersion(version, 2022, 5) + ? { template: {} } + : { text: { multiline: true } }, + }, + { + name: "secondary", + selector: atLeastHaVersion(version, 2022, 5) + ? { template: {} } + : { text: { multiline: true } }, + }, + { + name: "badge_icon", + selector: atLeastHaVersion(version, 2022, 5) + ? { template: {} } + : { text: { multiline: true } }, + }, + { + name: "badge_color", + selector: atLeastHaVersion(version, 2022, 5) + ? { template: {} } + : { text: { multiline: true } }, + }, + { + name: "picture", + selector: atLeastHaVersion(version, 2022, 5) + ? { template: {} } + : { text: { multiline: true } }, + }, + { + type: "grid", + name: "", + schema: [ + { name: "layout", selector: { "mush-layout": {} } }, + { name: "fill_container", selector: { boolean: {} } }, + { name: "multiline_secondary", selector: { boolean: {} } }, + ], + }, + { + type: "grid", + name: "", + schema: [ + { name: "graph_color", selector: { "mush-color": {} } }, + { + name: "hours_to_show", + selector: { number: { min: 1, max: 168, mode: "box", step: 1 } }, + }, + ], + }, + { + type: "grid", + name: "", + schema: [ + { + name: "graph_mode", + selector: { + select: { + options: GRAPH_MODE.map( + (mode) => + { + value: mode, + label: + localize(`editor.card.graph.graph_mode_type.${mode}`) || + mode, + } + ) as SelectOption[], + mode: "dropdown", + }, + }, + }, + { + name: "display_mode", + selector: { + select: { + options: DISPLAY_MODE.map( + (mode) => + { + value: `${mode}`, + label: + localize(`editor.card.graph.display_mode_type.${mode}`) || + mode, + } + ) as SelectOption[], + mode: "dropdown", + }, + }, + }, + ], + }, + { + name: "points_per_hour", + selector: { number: { min: 1, max: 168, mode: "box", step: 1 } }, + }, + ...computeActionsFormSchema(), +]); +@customElement(GRAPH_CARD_EDITOR_NAME) +export class GraphCardEditor extends MushroomBaseElement implements LovelaceCardEditor { + @state() private _config?: GraphCardConfig; + + connectedCallback() { + super.connectedCallback(); + void loadHaComponents(); + } + + public setConfig(config: GraphCardConfig): void { + assert(config, graphCardConfigStruct); + this._config = config; + } + + private _computeLabelCallback = (schema: HaFormSchema) => { + const customLocalize = setupCustomlocalize(this.hass!); + + if (GENERIC_LABELS.includes(schema.name)) { + return customLocalize(`editor.card.generic.${schema.name}`); + } + + if (GRAPH_LABELS.includes(schema.name)) { + return customLocalize(`editor.card.graph.${schema.name}`); + } + return this.hass!.localize(`ui.panel.lovelace.editor.card.generic.${schema.name}`); + }; + + // protected render(): TemplateResult { + // if (!this.hass || !this._config) { + // return html``; + // } + + // const entityState = this._config.entity ? this.hass.states[this._config.entity] : undefined; + // const entityIcon = entityState ? stateIcon(entityState) : undefined; + // const icon = this._config.icon || entityIcon; + + // const customLocalize = setupCustomlocalize(this.hass!); + // const schema = computeSchema(customLocalize, icon); + + // this._config = { + // hours_to_show: GRAPH_DEFAULT_HOURS, + // ...this._config, + // }; + + // return html` + // + // `; + // } + + private _computeLabel = (schema: HaFormSchema) => { + const customLocalize = setupCustomlocalize(this.hass!); + + if (schema.name === "entity") { + return `${this.hass!.localize( + "ui.panel.lovelace.editor.card.generic.entity" + )} (${customLocalize("editor.card.template.entity_extra")})`; + } + if (GENERIC_LABELS.includes(schema.name)) { + return customLocalize(`editor.card.generic.${schema.name}`); + } + if (TEMPLATE_LABELS.includes(schema.name)) { + return customLocalize(`editor.card.template.${schema.name}`); + } + return this.hass!.localize(`ui.panel.lovelace.editor.card.generic.${schema.name}`); + }; + + + protected render(): TemplateResult { + if (!this.hass || !this._config) { + return html``; + } + + const customLocalize = setupCustomlocalize(this.hass!); + + return html` + + `; + } + + private _valueChanged(ev: CustomEvent): void { + fireEvent(this, "config-changed", { config: ev.detail.value }); + } +} diff --git a/src/cards/graph-card/graph-card.ts b/src/cards/graph-card/graph-card.ts new file mode 100644 index 000000000..c0f3e9dad --- /dev/null +++ b/src/cards/graph-card/graph-card.ts @@ -0,0 +1,477 @@ +import { + actionHandler, + ActionHandlerEvent, + computeRTL, + handleAction, + hasAction, + HomeAssistant, + LovelaceCard, + LovelaceCardEditor, + RenderTemplateResult, + subscribeRenderTemplate +} from "../../ha"; +import { computeAppearance } from "../../utils/appearance"; +import { HassEntity, UnsubscribeFunc} from "home-assistant-js-websocket"; +import { css, CSSResultGroup, html, PropertyValues, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { classMap } from "lit/directives/class-map.js"; +import { styleMap } from "lit/directives/style-map.js"; +import { fetchRecent } from "../../ha/data/history"; +import { coordinates } from "../../shared/graph/coordinates"; +import "../../shared/graph/graph-base"; +import { cardStyle } from "../../utils/card-styles"; +import { computeRgbColor } from "../../utils/colors"; +import { registerCustomCard } from "../../utils/custom-cards"; +import { MushroomBaseElement } from "../../utils/base-element"; +import { + GRAPH_CARD_EDITOR_NAME, + GRAPH_CARD_NAME, + GRAPH_DEFAULT_HOURS, + GRAPH_ENTITY_DOMAINS, + GRAPH_HEIGHT_COMPACT, + GRAPH_HEIGHT_COMPACT_MARGIN, + GRAPH_HEIGHT_STANDARD, + GRAPH_HEIGHT_STANDARD_MARGIN, + GRAPH_HOUR, + GRAPH_MINUTE, +} from "./const"; +import { GraphCardConfig } from "./graph-card-config"; + +registerCustomCard({ + type: GRAPH_CARD_NAME, + name: "Mushroom Graph Card", + description: "Graph Card for sensor entity", +}); + +const TEMPLATE_KEYS = [ + "icon", + "icon_color", + "badge_color", + "badge_icon", + "primary", + "secondary", + "picture", +] as const; +type TemplateKey = typeof TEMPLATE_KEYS[number]; + +@customElement(GRAPH_CARD_NAME) +export class GraphCard extends MushroomBaseElement implements LovelaceCard { + public static async getConfigElement(): Promise { + await import("./graph-card-editor"); + return document.createElement(GRAPH_CARD_EDITOR_NAME) as LovelaceCardEditor; + } + + public static async getStubConfig(hass: HomeAssistant): Promise { + const entities = Object.keys(hass.states); + const entity = entities.filter((e) => GRAPH_ENTITY_DOMAINS.includes(e.split(".")[0])); + return { + type: `custom:${GRAPH_CARD_NAME}`, + entity: entity[0], + points_per_hour: 2 + }; + } + + @state() private _config?: GraphCardConfig; + + @state() private _templateResults: Partial< + Record + > = {}; + + @state() private _unsubRenderTemplates: Map> = new Map(); + + + @state() private _coordinates?: number[][]; + + private _stateHistory?: HassEntity[]; + + private _date?: Date; + + private _fetching = false; + + getCardSize(): number | Promise { + return 1; + } + + setConfig(config: GraphCardConfig): void { + TEMPLATE_KEYS.forEach((key) => { + if (this._config?.[key] !== config[key] || this._config?.entity != config.entity) { + this._tryDisconnectKey(key); + } + }); + this._config = { + hours_to_show: GRAPH_DEFAULT_HOURS, + graph_mode: "fill", + display_mode: "standard", + tap_action: { + action: "toggle", + }, + hold_action: { + action: "more-info", + }, + ...config, + }; + } + + + public connectedCallback() { + super.connectedCallback(); + this._tryConnect(); + } + + public disconnectedCallback() { + this._tryDisconnect(); + } + + protected updated(changedProps: PropertyValues): void { + super.updated(changedProps); + // if (!this._config || !this.hass) { + // return; + // } + + if (!this._config || !this.hass || (this._fetching && !changedProps.has("_config"))) { + return; + } + + if (changedProps.has("_config")) { + const oldConfig = changedProps.get("_config") as GraphCardConfig; + if (!oldConfig || oldConfig.entity !== this._config!.entity) { + this._stateHistory = []; + } + + this._getCoordinates(); + } else if (Date.now() - this._date!.getTime() >= GRAPH_MINUTE) { + this._getCoordinates(); + } + + this._tryConnect(); + } + + // protected updated(changedProperties: PropertyValues) { + // if (!this._config || !this.hass || (this._fetching && !changedProperties.has("_config"))) { + // return; + // } + + // if (changedProperties.has("_config")) { + // const oldConfig = changedProperties.get("_config") as GraphCardConfig; + // if (!oldConfig || oldConfig.entity !== this._config!.entity) { + // this._stateHistory = []; + // } + + // this._getCoordinates(); + // } else if (Date.now() - this._date!.getTime() >= GRAPH_MINUTE) { + // this._getCoordinates(); + // } + // } + + private _handleAction(ev: ActionHandlerEvent) { + handleAction(this, this.hass!, this._config!, ev.detail.action!); + } + + public isTemplate(key: TemplateKey) { + const value = this._config?.[key]; + return value?.includes("{"); + } + + private getValue(key: TemplateKey) { + return this.isTemplate(key) ? this._templateResults[key]?.result : this._config?.[key]; + } + + private async _getCoordinates(): Promise { + this._fetching = true; + const endTime = new Date(); + const startTime = + !this._date || !this._stateHistory?.length + ? new Date(new Date().setHours(endTime.getHours() - this._config!.hours_to_show!)) + : this._date; + + if (this._stateHistory!.length) { + const inHoursToShow: HassEntity[] = []; + const outHoursToShow: HassEntity[] = []; + // Split into inside and outside of "hours to show". + this._stateHistory!.forEach((entity) => + (endTime.getTime() - new Date(entity.last_changed).getTime() <= + this._config!.hours_to_show! * GRAPH_HOUR + ? inHoursToShow + : outHoursToShow + ).push(entity) + ); + + if (outHoursToShow.length) { + // If we have values that are now outside of "hours to show", re-add the last entry. This could e.g. be + // the "initial state" from the history backend. Without it, it would look like there is no history data + // at the start at all in the database = graph would start suddenly instead of on the left side of the card. + inHoursToShow.push(outHoursToShow[outHoursToShow.length - 1]); + } + this._stateHistory = inHoursToShow; + } + + const stateHistory = await fetchRecent( + this.hass!, + this._config!.entity!, + startTime, + endTime, + Boolean(this._stateHistory!.length) + ); + + if (stateHistory.length && stateHistory[0].length) { + this._stateHistory!.push(...stateHistory[0]); + } + + this._coordinates = + coordinates( + this._stateHistory, + this._config!.hours_to_show!, + 500, + this._config!.display_mode === "standard" + ? GRAPH_HEIGHT_STANDARD + : GRAPH_HEIGHT_COMPACT, + this._config!.points_per_hour! + ) || []; + + this._date = endTime; + this._fetching = false; + } + + protected render(): TemplateResult { + if (!this._config || !this.hass || !this._config.entity) { + return html``; + } + + const entity_id = this._config.entity; + const entity = this.hass.states[entity_id]; + + // const name = this._config.name || entity.attributes.friendly_name || ""; + //const icon = this._config.icon || stateIcon(entity); + //const appearance = computeAppearance(this._config); + //const picture = computeEntityPicture(entity, appearance.icon_type); + + + const graphMode = this._config.graph_mode; + + let graphHeight: number = GRAPH_HEIGHT_STANDARD + GRAPH_HEIGHT_STANDARD_MARGIN; + + if (this._config.display_mode !== "standard") { + graphHeight = GRAPH_HEIGHT_COMPACT + GRAPH_HEIGHT_COMPACT_MARGIN; + } + + const icon = this.getValue("icon"); + const iconColor = this.getValue("icon_color"); + const badgeIcon = this.getValue("badge_icon"); + const badgeColor = this.getValue("badge_color"); + const primary = this.getValue("primary"); + const secondary = this.getValue("secondary"); + const picture = this.getValue("picture"); + + // // graphColor = iconColor; + // graphColor = graphColor ?? iconColor; + // console.log(graphColor) + const graphColor = this._config.graph_color ?? iconColor; + + const multiline_secondary = this._config.multiline_secondary; + + const appearance = computeAppearance({ + fill_container: this._config.fill_container, + layout: this._config.layout, + icon_type: Boolean(picture) ? "entity-picture" : Boolean(icon) ? "icon" : "none", + primary_info: Boolean(primary) ? "name" : "none", + secondary_info: Boolean(secondary) ? "state" : "none", + }); + + const rtl = computeRTL(this.hass); + + return html` + + + + ${picture + ? this.renderPicture(picture) + : icon + ? this.renderIcon(icon, iconColor) + : null} + ${(icon || picture) && badgeIcon + ? this.renderBadgeIcon(badgeIcon, badgeColor) + : undefined} + + + + + + + `; + } + + renderPicture(picture: string): TemplateResult { + return html` + + `; + } + + renderIcon(icon: string, iconColor?: string) { + const iconStyle = {}; + if (iconColor) { + const iconRgbColor = computeRgbColor(iconColor); + iconStyle["--icon-color"] = `rgb(${iconRgbColor})`; + iconStyle["--shape-color"] = `rgba(${iconRgbColor}, 0.2)`; + } + return html` + + `; + } + + renderBadgeIcon(badge: string, badgeColor?: string) { + const badgeStyle = {}; + if (badgeColor) { + const iconRgbColor = computeRgbColor(badgeColor); + badgeStyle["--main-color"] = `rgba(${iconRgbColor})`; + } + return html` + + `; + } + + private async _tryConnect(): Promise { + TEMPLATE_KEYS.forEach((key) => { + this._tryConnectKey(key); + }); + } + + private async _tryConnectKey(key: TemplateKey): Promise { + if ( + this._unsubRenderTemplates.get(key) !== undefined || + !this.hass || + !this._config || + !this.isTemplate(key) + ) { + return; + } + + try { + const sub = subscribeRenderTemplate( + this.hass.connection, + (result) => { + this._templateResults = { + ...this._templateResults, + [key]: result, + }; + }, + { + template: this._config[key] ?? "", + entity_ids: this._config.entity_id, + variables: { + config: this._config, + user: this.hass.user!.name, + entity: this._config.entity, + }, + strict: true, + } + ); + this._unsubRenderTemplates.set(key, sub); + await sub; + } catch (_err) { + const result = { + result: this._config[key] ?? "", + listeners: { + all: false, + domains: [], + entities: [], + time: false, + }, + }; + this._templateResults = { + ...this._templateResults, + [key]: result, + }; + this._unsubRenderTemplates.delete(key); + } + } + + private async _tryDisconnect(): Promise { + TEMPLATE_KEYS.forEach((key) => { + this._tryDisconnectKey(key); + }); + } + + private async _tryDisconnectKey(key: TemplateKey): Promise { + const unsubRenderTemplate = this._unsubRenderTemplates.get(key); + if (!unsubRenderTemplate) { + return; + } + + try { + const unsub = await unsubRenderTemplate; + unsub(); + this._unsubRenderTemplates.delete(key); + } catch (err: any) { + if (err.code === "not_found" || err.code === "template_error") { + // If we get here, the connection was probably already closed. Ignore. + } else { + throw err; + } + } + } + + static get styles(): CSSResultGroup { + return [super.styles, cardStyle]; + } + + // protected renderBadge(entity: HassEntity) { + // const unavailable = !isAvailable(entity); + // if (unavailable) { + // return super.renderBadge(entity); + // } else { + // return super.renderBadge(entity); + // } + // } +} + + +// + // ${!isAvailable(entity) + // ? html` + // + // ` + // : null} + + // \ No newline at end of file diff --git a/src/cards/graph-card/utils.ts b/src/cards/graph-card/utils.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/cards/media-player-card/media-player-card-config.ts b/src/cards/media-player-card/media-player-card-config.ts index 07bcaea7c..1b0b82b9b 100644 --- a/src/cards/media-player-card/media-player-card-config.ts +++ b/src/cards/media-player-card/media-player-card-config.ts @@ -1,4 +1,4 @@ -import { array, assign, boolean, enums, object, optional } from "superstruct"; +import { array, assign, boolean, enums, object, optional, string } from "superstruct"; import { LovelaceCardConfig } from "../../ha"; import { ActionsSharedConfig, actionsSharedConfigStruct } from "../../shared/config/actions-config"; import { @@ -31,6 +31,7 @@ export type MediaPlayerCardConfig = LovelaceCardConfig & EntitySharedConfig & AppearanceSharedConfig & ActionsSharedConfig & { + icon_color?: string; use_media_info?: boolean; show_volume_level?: boolean; volume_controls?: MediaPlayerVolumeControl[]; @@ -42,6 +43,7 @@ export const mediaPlayerCardConfigStruct = assign( lovelaceCardConfigStruct, assign(entitySharedConfigStruct, appearanceSharedConfigStruct, actionsSharedConfigStruct), object({ + icon_color: optional(string()), use_media_info: optional(boolean()), show_volume_level: optional(boolean()), volume_controls: optional(array(enums(MEDIA_PLAYER_VOLUME_CONTROLS))), diff --git a/src/cards/media-player-card/media-player-card-editor.ts b/src/cards/media-player-card/media-player-card-editor.ts index c135b0b74..6cf7b1993 100644 --- a/src/cards/media-player-card/media-player-card-editor.ts +++ b/src/cards/media-player-card/media-player-card-editor.ts @@ -30,7 +30,14 @@ export const MEDIA_LABELS = [ const computeSchema = memoizeOne((localize: LocalizeFunc, icon?: string): HaFormSchema[] => [ { name: "entity", selector: { entity: { domain: MEDIA_PLAYER_ENTITY_DOMAINS } } }, { name: "name", selector: { text: {} } }, - { name: "icon", selector: { icon: { placeholder: icon } } }, + { + type: "grid", + name: "", + schema: [ + { name: "icon", selector: { icon: { placeholder: icon } } }, + { name: "icon_color", selector: { "mush-color": {} } }, + ] + }, ...APPEARANCE_FORM_SCHEMA, { type: "grid", diff --git a/src/cards/media-player-card/media-player-card.ts b/src/cards/media-player-card/media-player-card.ts index e3a3d78fc..680b12b3a 100644 --- a/src/cards/media-player-card/media-player-card.ts +++ b/src/cards/media-player-card/media-player-card.ts @@ -1,6 +1,8 @@ +import { HassEntity } from "home-assistant-js-websocket"; import { css, CSSResultGroup, html, PropertyValues, TemplateResult } from "lit"; import { customElement, state } from "lit/decorators.js"; import { classMap } from "lit/directives/class-map.js"; +import { styleMap } from "lit/directives/style-map.js"; import { actionHandler, ActionHandlerEvent, @@ -20,6 +22,7 @@ import "../../shared/shape-icon"; import { computeAppearance } from "../../utils/appearance"; import { MushroomBaseCard } from "../../utils/base-card"; import { cardStyle } from "../../utils/card-styles"; +import { computeRgbColor } from "../../utils/colors"; import { registerCustomCard } from "../../utils/custom-cards"; import { computeEntityPicture } from "../../utils/info"; import { Layout } from "../../utils/layout"; @@ -175,7 +178,7 @@ export class MediaPlayerCard extends MushroomBaseCard implements LovelaceCard { const stateValue = this.volume != null && this._config.show_volume_level ? `${stateDisplay} - ${this.volume}%` - : stateDisplay; + : entity.attributes.source ?? stateDisplay; const rtl = computeRTL(this.hass); @@ -208,6 +211,25 @@ export class MediaPlayerCard extends MushroomBaseCard implements LovelaceCard { `; } + renderIcon(entity: HassEntity, icon: string): TemplateResult { + const active = isActive(entity); + const iconStyle = {}; + const iconColor = this._config?.icon_color; + if (iconColor) { + const iconRgbColor = computeRgbColor(iconColor); + iconStyle["--icon-color"] = `rgb(${iconRgbColor})`; + iconStyle["--shape-color"] = `rgba(${iconRgbColor}, 0.2)`; + } + return html` + + `; + } + private renderOtherControls(): TemplateResult | null { const otherControls = this._controls.filter((control) => control != this._activeControl); diff --git a/src/ha/data/climate.ts b/src/ha/data/climate.ts index bff0bfd61..922d9e73f 100644 --- a/src/ha/data/climate.ts +++ b/src/ha/data/climate.ts @@ -2,6 +2,8 @@ import { HassEntityAttributeBase, HassEntityBase } from "home-assistant-js-webso export type HvacMode = "off" | "heat" | "cool" | "heat_cool" | "auto" | "dry" | "fan_only"; +export type PresetMode = "sleep" | "away" | "home" | "comfort"; + export const CLIMATE_PRESET_NONE = "none"; export type HvacAction = "off" | "heating" | "cooling" | "drying" | "idle"; @@ -26,8 +28,8 @@ export type ClimateEntity = HassEntityBase & { max_humidity?: number; fan_mode?: string; fan_modes?: string[]; - preset_mode?: string; - preset_modes?: string[]; + preset_mode: PresetMode; + preset_modes: PresetMode[]; swing_mode?: string; swing_modes?: string[]; aux_heat?: "on" | "off"; @@ -52,5 +54,16 @@ const hvacModeOrdering: { [key in HvacMode]: number } = { off: 7, }; +const presetModeOrdering: { [key in PresetMode]: number } = { + sleep: 1, + away: 2, + home: 3, + comfort: 4, +}; + export const compareClimateHvacModes = (mode1: HvacMode, mode2: HvacMode) => hvacModeOrdering[mode1] - hvacModeOrdering[mode2]; + + +export const compareClimatePresetModes = (mode1: PresetMode, mode2: PresetMode) => + presetModeOrdering[mode1] - presetModeOrdering[mode2]; diff --git a/src/ha/data/graph.ts b/src/ha/data/graph.ts new file mode 100644 index 000000000..8e266e358 --- /dev/null +++ b/src/ha/data/graph.ts @@ -0,0 +1 @@ +export const GRAPH_STROKE_WIDTH = 1; diff --git a/src/ha/data/history.ts b/src/ha/data/history.ts new file mode 100644 index 000000000..aa8370f03 --- /dev/null +++ b/src/ha/data/history.ts @@ -0,0 +1,35 @@ +import { HomeAssistant } from "../../ha"; +import { HassEntity } from "home-assistant-js-websocket"; + +export const fetchRecent = ( + hass: HomeAssistant, + entityId: string, + startTime: Date, + endTime: Date, + skipInitialState = false, + significantChangesOnly?: boolean, + minimalResponse = true, + noAttributes?: boolean +): Promise => { + let url = "history/period"; + if (startTime) { + url += "/" + startTime.toISOString(); + } + url += "?filter_entity_id=" + entityId; + if (endTime) { + url += "&end_time=" + endTime.toISOString(); + } + if (skipInitialState) { + url += "&skip_initial_state"; + } + if (significantChangesOnly !== undefined) { + url += `&significant_changes_only=${Number(significantChangesOnly)}`; + } + if (minimalResponse) { + url += "&minimal_response"; + } + if (noAttributes) { + url += "&no_attributes"; + } + return hass.callApi("GET", url); +}; diff --git a/src/mushroom.ts b/src/mushroom.ts index cf8a1c6fe..835dc36d5 100644 --- a/src/mushroom.ts +++ b/src/mushroom.ts @@ -21,5 +21,6 @@ export { TemplateCard } from "./cards/template-card/template-card"; export { TitleCard } from "./cards/title-card/title-card"; export { UpdateCard } from "./cards/update-card/update-card"; export { VacuumCard } from "./cards/vacuum-card/vacuum-card"; +export { GraphCard } from "./cards/graph-card/graph-card"; console.info(`%c🍄 Mushroom 🍄 - ${version}`, "color: #ef5350; font-weight: 700;"); diff --git a/src/shared/graph/coordinates.ts b/src/shared/graph/coordinates.ts new file mode 100644 index 000000000..eda50aa6b --- /dev/null +++ b/src/shared/graph/coordinates.ts @@ -0,0 +1,96 @@ +// FROM : https://github.com/home-assistant/frontend/blob/dev/src/panels/lovelace/common/graph/coordinates.ts + +import { GRAPH_STROKE_WIDTH } from "../../ha/data/graph"; + +const average = (items: any[]): number => + items.reduce((sum, entry) => sum + parseFloat(entry.state), 0) / items.length; + +const lastValue = (items: any[]): number => parseFloat(items[items.length - 1].state) || 0; + +const calcPoints = ( + history: any, + hours: number, + width: number, + height: number, + points: number, + min: number, + max: number +): number[][] => { + const coords = [] as number[][]; + let yRatio = (max - min) / height; + yRatio = yRatio !== 0 ? yRatio : height; + let xRatio = width / (points * hours - 1); + xRatio = isFinite(xRatio) ? xRatio : width; + + const first = history.filter(Boolean)[0]; + let last = [average(first), lastValue(first)]; + + const getCoords = (item: any[], i: number, offset = 0, depth = 1) => { + if (depth > 1 && item) { + return item.forEach((subItem, index) => getCoords(subItem, i, index, depth - 1)); + } + + const x = xRatio * (i + offset / 6); + + if (item) { + last = [average(item), lastValue(item)]; + } + const y = height + GRAPH_STROKE_WIDTH / 2 - ((item ? last[0] : last[1]) - min) / yRatio; + return coords.push([x, y]); + }; + + for (let i = 0; i < history.length; i += 1) { + getCoords(history[i], i, 0); + } + + if (coords.length === 1) { + coords[1] = [width, coords[0][1]]; + } + + coords.push([width, coords[coords.length - 1][1]]); + return coords; +}; + +export const coordinates = ( + history: any, + hours: number, + width: number, + height: number, + points: number, +): number[][] | undefined => { + history.forEach((item) => { + item.state = Number(item.state); + }); + history = history.filter((item) => !Number.isNaN(item.state)); + + const now = new Date().getTime(); + + const reduce = (res, item) => { + const age = now - new Date(item.last_changed).getTime(); + const interval = (age/ (1000 * 3600) * points) - hours * points + const key = interval < 0 ? Math.floor(Math.abs(interval)) : 0; + + if (!res[key]) { + res[key] = []; + } + res[key].push(item); + return res; + }; + + history = history.reduce((res, item) => reduce(res, item), []); + history.length = Math.ceil(hours * points) + + if (!history.length) { + return undefined; + } + + let min = Number.MAX_VALUE + let max = Number.MIN_VALUE + history.forEach((item) => { + const val = average(item) + min = val < min ? val : min + max = val > max ? val : max + }) + + return calcPoints(history, hours, width, height, points, min, max); +}; diff --git a/src/shared/graph/graph-base.ts b/src/shared/graph/graph-base.ts new file mode 100644 index 000000000..ca17d1cf0 --- /dev/null +++ b/src/shared/graph/graph-base.ts @@ -0,0 +1,84 @@ +import { css, CSSResultGroup, html, LitElement, PropertyValues, svg, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { GRAPH_STROKE_WIDTH } from "../../ha/data/graph"; +import { computeRgbColor } from "../../utils/colors"; +import { getPath } from "../../utils/lovelace/graph/utils"; + +// FROM : https://github.com/home-assistant/frontend/blob/dev/src/panels/lovelace/components/hui-graph-base.ts +@customElement("mushroom-graph-base") +export class MushroomGraphBase extends LitElement { + @property() public coordinates?: any; + @property() public graphColor?: string; + @property() public graphMode?: string; + @property() public graphHeight?: number = 100; + + @state() private _path?: string; + + protected render(): TemplateResult { + let graphColor = `var(--accent-color)`; + if (this.graphColor) { + graphColor = `rgb(${computeRgbColor(this.graphColor)})`; + } + + return html` + ${this._path + ? svg` + + ${ + this.graphMode === "fill" + ? svg` + + + ` + : null + } + + + + + + + ` + : svg``} + `; + } + + public willUpdate(changedProps: PropertyValues) { + if (!this.coordinates) { + return; + } + + if (changedProps.has("coordinates")) { + this._path = getPath(this.coordinates); + } + } + + static get styles(): CSSResultGroup { + return css` + :host { + display: flex; + width: 100%; + } + .fill { + opacity: 0.1; + } + `; + } +} diff --git a/src/translations/en.json b/src/translations/en.json index ff50e278e..6f6633604 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -136,6 +136,18 @@ "climate": { "show_temperature_control": "Temperature control?", "hvac_modes": "HVAC Modes" + }, + "graph": { + "graph_mode": "Graph mode", + "display_mode": "Display mode", + "graph_mode_type": { + "fill": "Fill", + "line": "Line" + }, + "display_mode_type": { + "standard": "Standard", + "compact": "Compact" + } } }, "chip": { diff --git a/src/translations/fr.json b/src/translations/fr.json index c0820b3bf..2fd95497c 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -135,6 +135,18 @@ "climate": { "show_temperature_control": "Contrôle de la température?", "hvac_modes": "Modes du thermostat" + }, + "graph": { + "graph_mode": "Mode graphique", + "display_mode": "Mode d'affichage", + "graph_mode_type": { + "fill": "Remplissage", + "line": "Ligne" + }, + "display_mode_type": { + "standard": "Standard", + "compact": "Compact" + } } }, "chip": { diff --git a/src/utils/lovelace/graph/utils.ts b/src/utils/lovelace/graph/utils.ts new file mode 100644 index 000000000..aedb78511 --- /dev/null +++ b/src/utils/lovelace/graph/utils.ts @@ -0,0 +1,31 @@ +const midPoint = (_Ax: number, _Ay: number, _Bx: number, _By: number): number[] => { + const _Zx = (_Ax - _Bx) / 2 + _Bx; + const _Zy = (_Ay - _By) / 2 + _By; + return [_Zx, _Zy]; +}; + +export const getPath = (coords: number[][]): string => { + if (!coords.length) { + return ""; + } + + let next: number[]; + let Z: number[]; + const X = 0; + const Y = 1; + let path = ""; + let last = coords.filter(Boolean)[0]; + + path += `M ${last[X]},${last[Y]}`; + + for (const coord of coords) { + next = coord; + Z = midPoint(last[X], last[Y], next[X], next[Y]); + path += ` ${Z[X]},${Z[Y]}`; + path += ` Q${next[X]},${next[Y]}`; + last = next; + } + + path += ` ${next![X]},${next![Y]}`; + return path; +};