From 2bdd77a6e6915cd7ad932d4bce73eeca488922cf Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Wed, 8 Oct 2025 09:27:09 +0300 Subject: [PATCH 01/10] V4 config type --- src/editor/index.ts | 7 ++++ src/ha-sankey-chart.ts | 28 +++++---------- src/migrate.ts | 82 ++++++++++++++++++++++++++++++++++++++++++ src/types.ts | 42 ++++++++++++++++------ 4 files changed, 130 insertions(+), 29 deletions(-) create mode 100644 src/migrate.ts diff --git a/src/editor/index.ts b/src/editor/index.ts index c24f4ba..b311764 100644 --- a/src/editor/index.ts +++ b/src/editor/index.ts @@ -383,3 +383,10 @@ export class SankeyChartEditor extends LitElement implements LovelaceCardEditor `; } } + + +declare global { + interface HTMLElementTagNameMap { + 'sankey-chart-editor': LovelaceCardEditor; + } +} \ No newline at end of file diff --git a/src/ha-sankey-chart.ts b/src/ha-sankey-chart.ts index b8de918..7d75f65 100644 --- a/src/ha-sankey-chart.ts +++ b/src/ha-sankey-chart.ts @@ -100,7 +100,7 @@ class SankeyChart extends SubscribeMixin(LitElement) { }); return [ energyPromise.then(async collection => { - if (isAutoconfig && !this.config.sections.length) { + if (isAutoconfig && !this.config.nodes.length) { try { await this.autoconfig(collection.prefs); } catch (err: any) { @@ -108,7 +108,7 @@ class SankeyChart extends SubscribeMixin(LitElement) { } } return collection.subscribe(async data => { - if (isAutoconfig && !this.config.sections.length) { + if (isAutoconfig && !this.config.nodes.length) { try { await this.autoconfig(collection.prefs); } catch (err: any) { @@ -189,23 +189,13 @@ class SankeyChart extends SubscribeMixin(LitElement) { this.config = config; this.entityIds = []; - this.config.sections.forEach(({ entities }) => { - entities.forEach(ent => { - if (ent.type === 'entity') { - this.entityIds.push(ent.entity_id); - } - ent.children.forEach(childConf => { - if (typeof childConf === 'object' && childConf.connection_entity_id) { - this.entityIds.push(childConf.connection_entity_id); - } - }); - if (ent.add_entities) { - ent.add_entities.forEach(e => this.entityIds.push(e)); - } - if (ent.subtract_entities) { - ent.subtract_entities.forEach(e => this.entityIds.push(e)); - } - }); + this.config.nodes.forEach(({ id }) => { + this.entityIds.push(id); + }); + this.config.links.forEach(({ value }) => { + if (value) { + this.entityIds.push(value); + } }); } diff --git a/src/migrate.ts b/src/migrate.ts new file mode 100644 index 0000000..1bc5084 --- /dev/null +++ b/src/migrate.ts @@ -0,0 +1,82 @@ +import { LovelaceCardConfig } from 'custom-card-helpers'; +import { CONVERSION_UNITS, UNIT_PREFIXES } from './const'; +import type { ActionConfigExtended, NodeType, ChildConfigOrStr, ReconcileConfig, SankeyChartConfig } from './types'; + +interface V3Config extends LovelaceCardConfig { + type: string; + autoconfig?: { + print_yaml?: boolean; + group_by_floor?: boolean; + group_by_area?: boolean; + }; + title?: string; + sections?: SectionConfig[]; + convert_units_to?: '' | CONVERSION_UNITS; + co2_intensity_entity?: string; + gas_co2_intensity?: number; + monetary_unit?: string; + electricity_price?: number; + gas_price?: number; + unit_prefix?: '' | 'auto' | keyof typeof UNIT_PREFIXES; + round?: number; + height?: number; + wide?: boolean; + layout?: 'auto' | 'vertical' | 'horizontal'; + show_icons?: boolean; + show_names?: boolean; + show_states?: boolean; + show_units?: boolean; + energy_date_selection?: boolean; + min_box_size?: number; + min_box_distance?: number; + throttle?: number; + min_state?: number; + static_scale?: number; + sort_by?: 'none' | 'state'; + sort_dir?: 'asc' | 'desc'; + time_period_from?: string; + time_period_to?: string; + ignore_missing_entities?: boolean; +} + +interface SectionConfig { + entities: EntityConfigOrStr[]; + sort_by?: 'none' | 'state'; + sort_dir?: 'asc' | 'desc'; + sort_group_by_parent?: boolean; + min_width?: number; +} + +type EntityConfigOrStr = string | EntityConfig; + +export interface EntityConfig { + entity_id: string; + add_entities?: string[]; + subtract_entities?: string[]; + attribute?: string; + type?: NodeType; + children?: ChildConfigOrStr[]; + unit_of_measurement?: string; // for attribute + color?: string; + name?: string; + icon?: string; + color_on_state?: boolean; + color_above?: string; + color_below?: string; + color_limit?: number; + url?: string; + tap_action?: ActionConfigExtended; + double_tap_action?: ActionConfigExtended; + hold_action?: ActionConfigExtended; + children_sum?: ReconcileConfig; + parents_sum?: ReconcileConfig; +} + +export function migrateV3Config(config: V3Config): SankeyChartConfig { + // @TODO: Implement + return { + ...config, + nodes: [], + links: [], + }; +} diff --git a/src/types.ts b/src/types.ts index 798fda1..5bf9cf0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,18 +22,20 @@ export const DEFAULT_CONFIG: Config = { min_state: 0, show_states: true, show_units: true, - sections: [], + nodes: [], + links: [], }; export interface SankeyChartConfig extends LovelaceCardConfig { type: string; + nodes?: Node[]; + links?: Link[]; autoconfig?: { print_yaml?: boolean; group_by_floor?: boolean; group_by_area?: boolean; }; title?: string; - sections?: SectionConfig[]; convert_units_to?: '' | CONVERSION_UNITS; co2_intensity_entity?: string; gas_co2_intensity?: number; @@ -62,21 +64,40 @@ export interface SankeyChartConfig extends LovelaceCardConfig { ignore_missing_entities?: boolean; } -declare global { - interface HTMLElementTagNameMap { - 'sankey-chart-editor': LovelaceCardEditor; - 'hui-error-card': LovelaceCard; - } +export interface Node { + id: string; + type: NodeType + name: string; + attribute?: string; + unit_of_measurement?: string; // for attribute + color?: string; + icon?: string; + // color_on_state?: boolean; + // color_above?: string; + // color_below?: string; + // color_limit?: number; + // url?: string; + tap_action?: ActionConfigExtended; + double_tap_action?: ActionConfigExtended; + hold_action?: ActionConfigExtended; + // children_sum?: ReconcileConfig; + // parents_sum?: ReconcileConfig; +} + +export interface Link { + source: string; + target: string; + value: string; } -export type BoxType = 'entity' | 'passthrough' | 'remaining_parent_state' | 'remaining_child_state'; +export type NodeType = 'entity' | 'passthrough' | 'remaining_parent_state' | 'remaining_child_state'; export interface EntityConfig { entity_id: string; add_entities?: string[]; subtract_entities?: string[]; attribute?: string; - type?: BoxType; + type?: NodeType; children?: ChildConfigOrStr[]; unit_of_measurement?: string; // for attribute color?: string; @@ -164,7 +185,8 @@ export interface Config extends SankeyChartConfig { min_box_size: number; min_box_distance: number; min_state: number; - sections: Section[]; + nodes: Node[]; + links: Link[]; } export interface Connection { From dc8bff498076b8f69c4eaf5ddde18a53fa6fa176 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Wed, 8 Oct 2025 09:42:23 +0300 Subject: [PATCH 02/10] format update --- src/types.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/types.ts b/src/types.ts index 5bf9cf0..e321570 100644 --- a/src/types.ts +++ b/src/types.ts @@ -70,18 +70,23 @@ export interface Node { name: string; attribute?: string; unit_of_measurement?: string; // for attribute - color?: string; + color?: string | { + [color: string]: { + from?: number; + to?: number; + } + }; icon?: string; - // color_on_state?: boolean; - // color_above?: string; - // color_below?: string; - // color_limit?: number; - // url?: string; + // color_on_state?: boolean; // @depracated. use color instead + // color_above?: string; // @depracated. use color instead + // color_below?: string; // @depracated. use color instead + // color_limit?: number; // @depracated. use color instead + // url?: string; // @depracated. use tap_action instead tap_action?: ActionConfigExtended; double_tap_action?: ActionConfigExtended; hold_action?: ActionConfigExtended; - // children_sum?: ReconcileConfig; - // parents_sum?: ReconcileConfig; + children_sum?: ReconcileConfig; + parents_sum?: ReconcileConfig; } export interface Link { From b8e9b3b0a8c7be5093b04fcad93f9c18767e5167 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Sat, 11 Oct 2025 12:13:41 +0300 Subject: [PATCH 03/10] add migration --- __tests__/autoconfig.test.ts | 15 ++ __tests__/basic.test.ts | 61 +++-- __tests__/migrate.test.ts | 432 +++++++++++++++++++++++++++++++++++ __tests__/remaining.test.ts | 127 +++++----- src/chart.ts | 26 ++- src/const.ts | 4 - src/editor/entity.ts | 2 +- src/editor/index.ts | 36 ++- src/editor/section.ts | 7 +- src/ha-sankey-chart.ts | 239 +++++++++++-------- src/migrate.ts | 95 +++++++- src/types.ts | 54 ++--- src/utils.ts | 110 ++++++--- 13 files changed, 934 insertions(+), 274 deletions(-) create mode 100644 __tests__/migrate.test.ts diff --git a/__tests__/autoconfig.test.ts b/__tests__/autoconfig.test.ts index 4f944d0..3b6c981 100644 --- a/__tests__/autoconfig.test.ts +++ b/__tests__/autoconfig.test.ts @@ -58,6 +58,21 @@ describe('SankeyChart autoconfig', () => { await (sankeyChart as any)['autoconfig'](); // eslint-disable-next-line @typescript-eslint/no-explicit-any const config = (sankeyChart as any).config; + + // Check V4 format (nodes and links) + expect(Array.isArray(config.nodes)).toBe(true); + expect(config.nodes.length).toBeGreaterThan(0); + expect(Array.isArray(config.links)).toBe(true); + expect(config.links.length).toBeGreaterThan(0); + + // Check nodes contain expected entities + const allNodeIds = config.nodes.map((n: { id: string }) => n.id); + expect(allNodeIds).toContain('sensor.grid_in'); + expect(allNodeIds).toContain('sensor.solar'); + expect(allNodeIds).toContain('sensor.battery_in'); + expect(allNodeIds).toContain('sensor.device1'); + + // Check sections are calculated from nodes expect(Array.isArray(config.sections)).toBe(true); expect(config.sections.length).toBeGreaterThan(0); const allEntities = config.sections.flatMap((s: { entities: { entity_id: string }[] }) => s.entities.map((e: { entity_id: string }) => e.entity_id)); diff --git a/__tests__/basic.test.ts b/__tests__/basic.test.ts index 6788b61..e05c36f 100644 --- a/__tests__/basic.test.ts +++ b/__tests__/basic.test.ts @@ -3,7 +3,7 @@ import { HomeAssistant } from 'custom-card-helpers'; import '../src/ha-sankey-chart'; import '../src/chart'; import SankeyChart from '../src/ha-sankey-chart'; -import { SankeyChartConfig } from '../src/types'; +import type { SankeyChartConfig } from '../src/types'; import mockHass from './__mocks__/hass.mock'; import { LitElement } from 'lit'; @@ -49,16 +49,33 @@ describe('SankeyChart', () => { it('matches a simple snapshot', async () => { const config: SankeyChartConfig = { type: '', - sections: [ + nodes: [ + { + id: 'ent1', + section: 0, + type: 'entity', + name: '', + }, { - entities: [ - { - entity_id: 'ent1', - children: ['ent2', 'ent3'], - }, - ], + id: 'ent2', + section: 1, + type: 'entity', + name: '', }, - { entities: ['ent2', 'ent3'] }, + { + id: 'ent3', + section: 1, + type: 'entity', + name: '', + }, + ], + links: [ + { source: 'ent1', target: 'ent2' }, + { source: 'ent1', target: 'ent3' }, + ], + sections: [ + {}, + {}, ], }; sankeyChart.setConfig(config, true); @@ -84,22 +101,30 @@ describe('Missing entities', () => { }); test('treats missing entity as 0 when ignore_missing_entities is true', () => { - const config = { + const config: SankeyChartConfig = { type: 'custom:sankey-chart', ignore_missing_entities: true, - sections: [ + nodes: [ { - entities: [ - { - entity_id: 'sensor.missing', - children: ['sensor.ent2'], - }, - ], + id: 'sensor.missing', + section: 0, + type: 'entity', + name: '', }, { - entities: ['sensor.ent2'], + id: 'sensor.ent2', + section: 1, + type: 'entity', + name: '', }, ], + links: [ + { source: 'sensor.missing', target: 'sensor.ent2' }, + ], + sections: [ + {}, + {}, + ], }; element.setConfig(config, true); diff --git a/__tests__/migrate.test.ts b/__tests__/migrate.test.ts new file mode 100644 index 0000000..3cbb246 --- /dev/null +++ b/__tests__/migrate.test.ts @@ -0,0 +1,432 @@ +import { migrateV3Config } from '../src/migrate'; +import type { V3Config } from '../src/migrate'; + +describe('migrateV3Config', () => { + it('migrates a simple V3 config to V4 format', () => { + const v3Config: V3Config = { + type: 'custom:sankey-chart', + sections: [ + { + entities: [ + { + entity_id: 'sensor.grid', + children: ['sensor.home'], + }, + ], + }, + { + entities: ['sensor.home'], + }, + ], + }; + + const result = migrateV3Config(v3Config); + + // Check V4 structure + expect(result.nodes).toBeDefined(); + expect(result.links).toBeDefined(); + expect(result.sections).toBeDefined(); + + // Check nodes + expect(result.nodes).toHaveLength(2); + expect(result.nodes![0]).toMatchObject({ + id: 'sensor.grid', + section: 0, + type: 'entity', + }); + expect(result.nodes![1]).toMatchObject({ + id: 'sensor.home', + section: 1, + type: 'entity', + }); + + // Check links + expect(result.links).toHaveLength(1); + expect(result.links![0]).toMatchObject({ + source: 'sensor.grid', + target: 'sensor.home', + }); + + // Check sections are config-only + expect(result.sections).toHaveLength(2); + expect((result.sections![0] as any).entities).toBeUndefined(); + }); + + it('preserves entity properties in nodes', () => { + const v3Config: V3Config = { + type: 'custom:sankey-chart', + sections: [ + { + entities: [ + { + entity_id: 'sensor.solar', + name: 'Solar Power', + color: 'yellow', + icon: 'mdi:solar-power', + children: ['sensor.total'], + }, + ], + }, + { + entities: ['sensor.total'], + }, + ], + }; + + const result = migrateV3Config(v3Config); + + expect(result.nodes![0]).toMatchObject({ + id: 'sensor.solar', + section: 0, + type: 'entity', + name: 'Solar Power', + color: 'yellow', + icon: 'mdi:solar-power', + }); + }); + + it('migrates old color format to new range-based format', () => { + const v3Config: V3Config = { + type: 'custom:sankey-chart', + sections: [ + { + entities: [ + { + entity_id: 'sensor.temp', + color_on_state: true, + color_limit: 25, + color_below: 'blue', + color_above: 'red', + }, + ], + }, + ], + }; + + const result = migrateV3Config(v3Config); + + expect(result.nodes![0].color).toEqual({ + 'blue': { to: 25 }, + 'red': { from: 25 }, + }); + }); + + it('preserves simple string colors', () => { + const v3Config: V3Config = { + type: 'custom:sankey-chart', + sections: [ + { + entities: [ + { + entity_id: 'sensor.grid', + color: 'green', + }, + ], + }, + ], + }; + + const result = migrateV3Config(v3Config); + + expect(result.nodes![0].color).toBe('green'); + }); + + it('extracts section configs without entities', () => { + const v3Config: V3Config = { + type: 'custom:sankey-chart', + sections: [ + { + entities: ['sensor.a'], + sort_by: 'state', + sort_dir: 'desc', + min_width: 100, + }, + { + entities: ['sensor.b'], + sort_by: 'none', + sort_group_by_parent: true, + }, + ], + }; + + const result = migrateV3Config(v3Config); + + expect(result.sections).toHaveLength(2); + expect(result.sections![0]).toEqual({ + sort_by: 'state', + sort_dir: 'desc', + min_width: 100, + sort_group_by_parent: undefined, + }); + expect(result.sections![1]).toEqual({ + sort_by: 'none', + sort_dir: undefined, + min_width: undefined, + sort_group_by_parent: true, + }); + // Ensure entities are not in section configs + expect((result.sections![0] as any).entities).toBeUndefined(); + expect((result.sections![1] as any).entities).toBeUndefined(); + }); + + it('creates links from children with connection entities', () => { + const v3Config: V3Config = { + type: 'custom:sankey-chart', + sections: [ + { + entities: [ + { + entity_id: 'sensor.grid', + children: [ + { + entity_id: 'sensor.home', + connection_entity_id: 'sensor.grid_to_home', + }, + ], + }, + ], + }, + { + entities: ['sensor.home'], + }, + ], + }; + + const result = migrateV3Config(v3Config); + + expect(result.links).toHaveLength(1); + expect(result.links![0]).toEqual({ + source: 'sensor.grid', + target: 'sensor.home', + value: 'sensor.grid_to_home', + }); + }); + + it('handles multiple children', () => { + const v3Config: V3Config = { + type: 'custom:sankey-chart', + sections: [ + { + entities: [ + { + entity_id: 'sensor.total', + children: ['sensor.a', 'sensor.b', 'sensor.c'], + }, + ], + }, + { + entities: ['sensor.a', 'sensor.b', 'sensor.c'], + }, + ], + }; + + const result = migrateV3Config(v3Config); + + expect(result.links).toHaveLength(3); + expect(result.links).toContainEqual({ source: 'sensor.total', target: 'sensor.a', value: undefined }); + expect(result.links).toContainEqual({ source: 'sensor.total', target: 'sensor.b', value: undefined }); + expect(result.links).toContainEqual({ source: 'sensor.total', target: 'sensor.c', value: undefined }); + }); + + it('handles string entities', () => { + const v3Config: V3Config = { + type: 'custom:sankey-chart', + sections: [ + { + entities: ['sensor.a', 'sensor.b'], + }, + ], + }; + + const result = migrateV3Config(v3Config); + + expect(result.nodes).toHaveLength(2); + expect(result.nodes![0]).toMatchObject({ + id: 'sensor.a', + section: 0, + type: 'entity', + name: '', + }); + expect(result.nodes![1]).toMatchObject({ + id: 'sensor.b', + section: 0, + type: 'entity', + name: '', + }); + }); + + it('preserves entity type', () => { + const v3Config: V3Config = { + type: 'custom:sankey-chart', + sections: [ + { + entities: [ + { + entity_id: 'sensor.remaining', + type: 'remaining_child_state', + children: ['sensor.child'], + }, + ], + }, + { + entities: ['sensor.child'], + }, + ], + }; + + const result = migrateV3Config(v3Config); + + expect(result.nodes![0]).toMatchObject({ + id: 'sensor.remaining', + section: 0, + type: 'remaining_child_state', + }); + }); + + it('preserves add_entities and subtract_entities', () => { + const v3Config: V3Config = { + type: 'custom:sankey-chart', + sections: [ + { + entities: [ + { + entity_id: 'sensor.total', + add_entities: ['sensor.extra1', 'sensor.extra2'], + subtract_entities: ['sensor.loss1'], + }, + ], + }, + ], + }; + + const result = migrateV3Config(v3Config); + + expect(result.nodes![0]).toMatchObject({ + id: 'sensor.total', + add_entities: ['sensor.extra1', 'sensor.extra2'], + subtract_entities: ['sensor.loss1'], + }); + }); + + it('preserves tap actions', () => { + const v3Config: V3Config = { + type: 'custom:sankey-chart', + sections: [ + { + entities: [ + { + entity_id: 'sensor.grid', + tap_action: { + action: 'more-info', + }, + double_tap_action: { + action: 'toggle', + }, + }, + ], + }, + ], + }; + + const result = migrateV3Config(v3Config); + + expect(result.nodes![0]).toMatchObject({ + id: 'sensor.grid', + tap_action: { + action: 'more-info', + }, + double_tap_action: { + action: 'toggle', + }, + }); + }); + + it('preserves reconciliation configs', () => { + const v3Config: V3Config = { + type: 'custom:sankey-chart', + sections: [ + { + entities: [ + { + entity_id: 'sensor.total', + children_sum: { + should_be: 'equal', + reconcile_to: 'max', + }, + parents_sum: { + should_be: 'equal_or_less', + reconcile_to: 'min', + }, + }, + ], + }, + ], + }; + + const result = migrateV3Config(v3Config); + + expect(result.nodes![0]).toMatchObject({ + id: 'sensor.total', + children_sum: { + should_be: 'equal', + reconcile_to: 'max', + }, + parents_sum: { + should_be: 'equal_or_less', + reconcile_to: 'min', + }, + }); + }); + + it('handles empty sections array', () => { + const v3Config: V3Config = { + type: 'custom:sankey-chart', + sections: [], + }; + + const result = migrateV3Config(v3Config); + + expect(result.nodes).toEqual([]); + expect(result.links).toEqual([]); + expect(result.sections).toEqual([]); + }); + + it('handles missing sections', () => { + const v3Config: V3Config = { + type: 'custom:sankey-chart', + }; + + const result = migrateV3Config(v3Config); + + expect(result.nodes).toEqual([]); + expect(result.links).toEqual([]); + expect(result.sections).toEqual([]); + }); + + it('preserves global config properties', () => { + const v3Config: V3Config = { + type: 'custom:sankey-chart', + title: 'Energy Flow', + show_states: true, + unit_prefix: 'k', + round: 2, + min_state: 0.1, + sections: [ + { + entities: ['sensor.a'], + }, + ], + }; + + const result = migrateV3Config(v3Config); + + expect(result).toMatchObject({ + type: 'custom:sankey-chart', + title: 'Energy Flow', + show_states: true, + unit_prefix: 'k', + round: 2, + min_state: 0.1, + }); + }); +}); diff --git a/__tests__/remaining.test.ts b/__tests__/remaining.test.ts index c15d238..09fada8 100644 --- a/__tests__/remaining.test.ts +++ b/__tests__/remaining.test.ts @@ -30,78 +30,81 @@ describe('SankeyChart with remaining type entities', () => { min_state: 0.1, unit_prefix: 'k', round: 1, - sections: [ + nodes: [ { - entities: [ - { - entity_id: 'sensor.test_power', - name: 'Total', - color: 'var(--warning-color)', - children: ['tt', 'sensor.test_power3', 'Annet'], - }, - ], + id: 'sensor.test_power', + section: 0, + type: 'entity', + name: 'Total', + color: 'var(--warning-color)', }, { - entities: [ - { - entity_id: 'tt', - type: 'remaining_child_state', - name: 'Total\nAll\nAll\nAll\nAll\nAll\nIDK\nIDK\nIDK\nIDK\nIDK\nIDK\nIDK\nIDK\nIDK', - color: 'var(--warning-color)', - children: ['sensor.test_power1', 'sensor.test_power2', 'sensor.test_power4'], - color_on_state: true, - color_limit: 10.1, - color_below: 'darkslateblue', - }, - ], + id: 'tt', + section: 1, + type: 'remaining_child_state', + name: 'Total\nAll\nAll\nAll\nAll\nAll\nIDK\nIDK\nIDK\nIDK\nIDK\nIDK\nIDK\nIDK\nIDK', + color: { + 'darkslateblue': { to: 10.1 }, + 'var(--warning-color)': { from: 10.1 }, + }, }, { - entities: [ - { - entity_id: 'sensor.test_power1', - name: 'Varmtvann\nBlaa', - children: ['sensor.test_power3'], - }, - { - entity_id: 'sensor.test_power2', - name: 'Avfukter', - unit_of_measurement: 'В', - children: ['sensor.test_power3'], - }, - { - entity_id: 'sensor.test_power4', - children: ['sensor.test_power3'], - }, - { - entity_id: 'Annet', - type: 'remaining_child_state', - name: 'Annet', - children: ['sensor.test_power3'], - }, - ], + id: 'sensor.test_power1', + section: 2, + type: 'entity', + name: 'Varmtvann\nBlaa', }, { - entities: [ - { - entity_id: 'switch.plug_158d00022adfd9', - attribute: 'load_power', - unit_of_measurement: 'Wh', - tap_action: { - action: 'toggle', - }, - }, - ], + id: 'sensor.test_power2', + section: 2, + type: 'entity', + name: 'Avfukter', + unit_of_measurement: 'В', }, { - entities: [ - { - entity_id: 'sensor.test_power3', - color_below: 'red', - color: 'red', - color_limit: 14000, - }, - ], + id: 'sensor.test_power4', + section: 2, + type: 'entity', + name: '', }, + { + id: 'Annet', + section: 2, + type: 'remaining_child_state', + name: 'Annet', + }, + { + id: 'switch.plug_158d00022adfd9', + section: 3, + type: 'entity', + name: '', + attribute: 'load_power', + unit_of_measurement: 'Wh', + tap_action: { + action: 'toggle', + }, + }, + { + id: 'sensor.test_power3', + section: 4, + type: 'entity', + name: '', + color: { + 'red': { to: 14000 }, + }, + }, + ], + links: [ + { source: 'sensor.test_power', target: 'tt' }, + { source: 'sensor.test_power', target: 'sensor.test_power3' }, + { source: 'sensor.test_power', target: 'Annet' }, + { source: 'tt', target: 'sensor.test_power1' }, + { source: 'tt', target: 'sensor.test_power2' }, + { source: 'tt', target: 'sensor.test_power4' }, + { source: 'sensor.test_power1', target: 'sensor.test_power3' }, + { source: 'sensor.test_power2', target: 'sensor.test_power3' }, + { source: 'sensor.test_power4', target: 'sensor.test_power3' }, + { source: 'Annet', target: 'sensor.test_power3' }, ], }; sankeyChart.setConfig(config, true); diff --git a/src/chart.ts b/src/chart.ts index c106a12..2d91915 100644 --- a/src/chart.ts +++ b/src/chart.ts @@ -347,20 +347,30 @@ export class Chart extends LitElement { this.randomColors.set(entityId, generateRandomRGBColor()); } finalColor = this.randomColors.get(entityId)!; - } else if (entityConf.color_on_state) { + } else if (typeof entityConf.color === 'object') { + // Handle complex color format (range-based) let state4color = state; if (entityConf.type === 'passthrough') { // passthrough color is based on the child state const childState = this._getMemoizedState(this._findRelatedRealEntity(entityConf, 'children')); state4color = childState.state; } - const colorLimit = entityConf.color_limit ?? 1; - const colorBelow = entityConf.color_below ?? 'var(--primary-color)'; - const colorAbove = entityConf.color_above ?? 'var(--state-icon-color)'; - if (state4color > colorLimit) { - finalColor = colorAbove; - } else if (state4color < colorLimit) { - finalColor = colorBelow; + const colorRanges = entityConf.color as { [color: string]: { from?: number; to?: number } }; + // Find matching color range + for (const [color, range] of Object.entries(colorRanges)) { + const { from, to } = range; + if (from !== undefined && to !== undefined) { + if (state4color >= from && state4color <= to) { + finalColor = color; + break; + } + } else if (from !== undefined && state4color >= from) { + finalColor = color; + break; + } else if (to !== undefined && state4color <= to) { + finalColor = color; + break; + } } } diff --git a/src/const.ts b/src/const.ts index ed3dc02..26b6fca 100644 --- a/src/const.ts +++ b/src/const.ts @@ -14,10 +14,6 @@ export const CHAR_WIDTH_RATIO = 8.15; // px per char, trial and error export const MIN_HORIZONTAL_SECTION_W = 150; export const MIN_VERTICAL_SECTION_H = 150; -export const DEFAULT_ENTITY_CONF: Omit = { - type: 'entity', -}; - export const FT3_PER_M3 = 35.31; export type CONVERSION_UNITS = 'MJ' | 'gCO2' | 'monetary'; \ No newline at end of file diff --git a/src/editor/entity.ts b/src/editor/entity.ts index 471c5f4..366362c 100644 --- a/src/editor/entity.ts +++ b/src/editor/entity.ts @@ -2,7 +2,7 @@ import { HomeAssistant, stateIcon } from 'custom-card-helpers'; import { LitElement, html, TemplateResult, css, CSSResultGroup } from 'lit'; // eslint-disable-next-line @typescript-eslint/no-unused-vars import { customElement, property } from 'lit/decorators'; -import { EntityConfig, EntityConfigOrStr } from '../types'; +import type { EntityConfig, EntityConfigOrStr } from '../types'; import { localize } from '../localize/localize'; import { repeat } from 'lit/directives/repeat'; import { DEFAULT_ENTITY_CONF } from '../const'; diff --git a/src/editor/index.ts b/src/editor/index.ts index b311764..dfec117 100644 --- a/src/editor/index.ts +++ b/src/editor/index.ts @@ -5,13 +5,14 @@ import { HomeAssistant, fireEvent, LovelaceCardEditor, LovelaceConfig } from 'cu // eslint-disable-next-line @typescript-eslint/no-unused-vars import { customElement, property, state } from 'lit/decorators'; import { repeat } from 'lit/directives/repeat'; -import { SankeyChartConfig, SectionConfig } from '../types'; +import { SankeyChartConfig, Section } from '../types'; import { localize } from '../localize/localize'; -import { normalizeConfig } from '../utils'; +import { normalizeConfig, convertNodesToSections } from '../utils'; import './section'; import './entity'; import { EntityConfigOrStr } from '../types'; import { UNIT_PREFIXES } from '../const'; +import { migrateV3Config } from '../migrate'; @customElement('sankey-chart-editor') export class SankeyChartEditor extends LitElement implements LovelaceCardEditor { @@ -23,7 +24,17 @@ export class SankeyChartEditor extends LitElement implements LovelaceCardEditor private _initialized = false; public setConfig(config: SankeyChartConfig): void { - this._config = config; + // Convert V4 config to V3 format for editing + if (config.nodes && config.nodes.length > 0) { + // Convert nodes/links to sections with entities for editor + const sections = convertNodesToSections(config.nodes, config.links || [], config.sections); + this._config = { + ...config, + sections: sections as any, // V3 format sections with entities + }; + } else { + this._config = config; + } this.loadCardHelpers(); } @@ -91,7 +102,7 @@ export class SankeyChartEditor extends LitElement implements LovelaceCardEditor } const target = ev.target; if (typeof target.section === 'number') { - const sections: SectionConfig[] = this._config?.sections || []; + const sections: Section[] = this._config?.sections as any || []; this._config = { ...this._config!, sections: sections.map((section, i) => @@ -113,7 +124,7 @@ export class SankeyChartEditor extends LitElement implements LovelaceCardEditor const newConf = typeof value === 'string' ? { entity_id: value } : value; const target = ev.target; if (typeof target.section === 'number' && typeof target.index === 'number') { - const sections: SectionConfig[] = this._config?.sections || []; + const sections: Section[] = this._config?.sections as any || []; this._config = { ...this._config!, sections: sections.map((section, i) => { @@ -135,7 +146,7 @@ export class SankeyChartEditor extends LitElement implements LovelaceCardEditor } private _configEntity(sectionIndex: number, entityIndex: number): void { - const sections: SectionConfig[] = this._config?.sections || []; + const sections: Section[] = this._config?.sections as any || []; this._entityConfig = { sectionIndex, entityIndex, entity: sections[sectionIndex].entities[entityIndex] }; } @@ -147,7 +158,7 @@ export class SankeyChartEditor extends LitElement implements LovelaceCardEditor this._entityConfig = { ...this._entityConfig!, entity: entityConf }; }; - private _handleSectionChange = (index: number, sectionConf: SectionConfig): void => { + private _handleSectionChange = (index: number, sectionConf: Section): void => { this._config = { ...this._config!, sections: this._config?.sections?.map((section, i) => (i === index ? sectionConf : section)), @@ -156,7 +167,12 @@ export class SankeyChartEditor extends LitElement implements LovelaceCardEditor }; private _updateConfig(): void { - fireEvent(this, 'config-changed', { config: this._config }); + // Convert V3 format (with entities in sections) back to V4 format before saving + const configToSave = this._config?.sections && (this._config.sections[0] as any)?.entities + ? migrateV3Config(this._config as any) + : this._config; + + fireEvent(this, 'config-changed', { config: configToSave }); } private _computeSchema() { @@ -263,7 +279,7 @@ export class SankeyChartEditor extends LitElement implements LovelaceCardEditor const isMetric = this.hass.config.unit_system.length == 'km'; const config = normalizeConfig(this._config || ({} as SankeyChartConfig), isMetric); const { autoconfig } = config; - const sections: SectionConfig[] = config.sections || []; + const sections: Section[] = this._config?.sections as any || []; if (this._entityConfig) { return html` @@ -325,7 +341,7 @@ export class SankeyChartEditor extends LitElement implements LovelaceCardEditor `; } - private _renderSections(sections: SectionConfig[]): TemplateResult { + private _renderSections(sections: Section[]): TemplateResult { return html`

${localize('editor.sections')}

diff --git a/src/editor/section.ts b/src/editor/section.ts index 175b854..ccb919a 100644 --- a/src/editor/section.ts +++ b/src/editor/section.ts @@ -2,17 +2,18 @@ import { HomeAssistant } from 'custom-card-helpers'; import { LitElement, html, TemplateResult, css, CSSResultGroup } from 'lit'; // eslint-disable-next-line @typescript-eslint/no-unused-vars import { customElement, property } from 'lit/decorators'; -import { SectionConfig } from '../types'; +import type { Section } from '../types'; import { localize } from '../localize/localize'; import { repeat } from 'lit/directives/repeat'; import { getEntityId } from '../utils'; + @customElement('sankey-chart-section-editor') class SankeyChartSectionEditor extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; - @property({ attribute: false }) public section!: SectionConfig; + @property({ attribute: false }) public section!: Section; @property({ attribute: false }) public index!: number; - @property({ attribute: false }) public onChange!: (sectionConf: SectionConfig) => void; + @property({ attribute: false }) public onChange!: (sectionConf: Section) => void; @property({ attribute: false }) public onConfigEntity!: (entityIndex: number) => void; @property({ attribute: false }) public onChangeEntity!: (ev: CustomEvent) => void; @property({ attribute: false }) public onAddEntity!: (ev: CustomEvent) => void; diff --git a/src/ha-sankey-chart.ts b/src/ha-sankey-chart.ts index 7d75f65..360b135 100644 --- a/src/ha-sankey-chart.ts +++ b/src/ha-sankey-chart.ts @@ -1,12 +1,12 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { LitElement, html, TemplateResult } from 'lit'; // eslint-disable-next-line @typescript-eslint/no-unused-vars -import { customElement, property, query, state } from 'lit/decorators'; +import { customElement, property, state } from 'lit/decorators'; -import type { Config, EntityConfigInternal, SankeyChartConfig, Section } from './types'; +import type { Config, SankeyChartConfig, SectionConfig } from './types'; import { version } from '../package.json'; import { localize } from './localize/localize'; -import { createPassthroughs, normalizeConfig, renderError } from './utils'; +import { convertNodesToSections, normalizeConfig, renderError } from './utils'; import { SubscribeMixin } from './subscribe-mixin'; import './chart'; import './print-config'; @@ -185,8 +185,14 @@ class SankeyChart extends SubscribeMixin(LitElement) { this.resetSubscriptions(); } - private setNormalizedConfig(config: Config): void { - this.config = config; + private setNormalizedConfig(config: Config | (Omit & { sections?: SectionConfig[] })): void { + // Convert SectionConfig[] to Section[] using nodes/links + if (config.nodes && config.nodes.length) { + const sectionConfigs = config.sections as SectionConfig[] | undefined; + config = { ...config, sections: convertNodesToSections(config.nodes, config.links, sectionConfigs) }; + } + + this.config = config as Config; this.entityIds = []; this.config.nodes.forEach(({ id }) => { @@ -246,68 +252,84 @@ class SankeyChart extends SubscribeMixin(LitElement) { }); const devicesWithoutParent = deviceNodes.filter(node => !parentLinks[node.id]); - const totalNode: EntityConfigInternal = { - entity_id: 'total', + const nodes: Config['nodes'] = []; + const links: Config['links'] = []; + const sections: SectionConfig[] = []; + + let currentSection = 0; + + // Add source nodes (section 0) + sources.forEach(source => { + if (!source.stat_energy_from) return; + const subtract = source.stat_energy_to + ? [source.stat_energy_to] + : source.flow_to?.map(e => e.stat_energy_to).filter(Boolean) as string[] | undefined; + nodes.push({ + id: source.stat_energy_from, + section: currentSection, + type: 'entity', + name: '', + subtract_entities: subtract, + color: getEnergySourceColor(source.type), + }); + links.push({ source: source.stat_energy_from, target: 'total' }); + }); + sections.push({}); // section 0 config + + currentSection++; + + // Add total node (section 1) + nodes.push({ + id: 'total', + section: currentSection, type: sources.length ? 'remaining_parent_state' : 'remaining_child_state', name: 'Total Consumption', - children: ['unknown'], children_sum: { should_be: 'equal_or_less', reconcile_to: 'max', }, - }; - - const sections = [ - { - entities: sources.map(source => { - const subtract = source.stat_energy_to - ? [source.stat_energy_to] - : source.flow_to?.map(e => e.stat_energy_to) || undefined; - return { - entity_id: source.stat_energy_from, - subtract_entities: subtract, - type: 'entity', - color: getEnergySourceColor(source.type), - children: ['total'], - }; - }), - }, - { - entities: [totalNode], - }, - ].filter(s => s.entities.length > 0) as Section[]; + }); + links.push({ source: 'total', target: 'unknown' }); + // Handle grid export const grid = sources.find(s => s.type === 'grid'); if (grid && grid?.flow_to?.length) { - // grid export grid?.flow_to.forEach(({ stat_energy_to }) => { - sections[1].entities.unshift({ - entity_id: stat_energy_to, - subtract_entities: (grid.flow_from || []).map(e => e.stat_energy_from), + if (!stat_energy_to) return; + nodes.push({ + id: stat_energy_to, + section: currentSection, type: 'entity', + name: '', + subtract_entities: (grid.flow_from || []).map(e => e.stat_energy_from).filter(Boolean) as string[], color: getEnergySourceColor(grid.type), - children: [], }); - sections[0].entities.forEach(entity => { - entity.children.unshift(stat_energy_to); + sources.forEach(source => { + if (!source.stat_energy_from) return; + links.push({ source: source.stat_energy_from, target: stat_energy_to }); }); }); } + // Handle battery charging const battery = sources.find(s => s.type === 'battery'); if (battery && battery.stat_energy_from && battery.stat_energy_to) { - // battery charging - sections[1].entities.unshift({ - entity_id: battery.stat_energy_to, - subtract_entities: [battery.stat_energy_from], + nodes.push({ + id: battery.stat_energy_to, + section: currentSection, type: 'entity', + name: '', + subtract_entities: [battery.stat_energy_from], color: getEnergySourceColor(battery.type), - children: [], }); - sections[0].entities.forEach(entity => { - entity.children.unshift(battery.stat_energy_to!); + sources.forEach(source => { + if (!source.stat_energy_from) return; + links.push({ source: source.stat_energy_from, target: battery.stat_energy_to! }); }); } + sections.push({}); // section 1 config + + currentSection++; const groupByFloor = this.config.autoconfig?.group_by_floor !== false; const groupByArea = this.config.autoconfig?.group_by_area !== false; @@ -318,85 +340,112 @@ class SankeyChart extends SubscribeMixin(LitElement) { devicesWithoutParent.map(d => d.id), ); const areas = Object.values(areasResult) - // put 'No area' last .sort((a, b) => (a.area.name === 'No area' ? 1 : b.area.name === 'No area' ? -1 : 0)); const floors = await fetchFloorRegistry(this.hass); const orphanAreas = areas.filter(a => !a.area.floor_id); + if (groupByFloor && orphanAreas.length !== areas.length) { - totalNode.children = [ - ...totalNode.children, - ...floors.map(f => f.floor_id), - ...(groupByArea ? orphanAreas.map(a => a.area.area_id) : orphanAreas.map(a => a.entities).flat()), - ]; - sections.push({ - entities: [ - ...floors.map( - (f): EntityConfigInternal => ({ - entity_id: f.floor_id, - type: 'remaining_child_state', - name: f.name, - children: groupByArea - ? areas.filter(a => a.area.floor_id === f.floor_id).map(a => a.area.area_id) - : areas - .filter(a => a.area.floor_id === f.floor_id) - .map(a => a.entities) - .flat(), - }), - ), - ], - sort_by: 'state', + // Add floor nodes + floors.forEach(f => { + nodes.push({ + id: f.floor_id, + section: currentSection, + type: 'remaining_child_state', + name: f.name, + }); + links.push({ source: 'total', target: f.floor_id }); + + const floorAreas = areas.filter(a => a.area.floor_id === f.floor_id); + if (groupByArea) { + floorAreas.forEach(a => { + links.push({ source: f.floor_id, target: a.area.area_id }); + }); + } else { + floorAreas.forEach(a => { + a.entities.forEach(entityId => { + links.push({ source: f.floor_id, target: entityId }); + }); + }); + } }); + + // Add orphan areas + if (groupByArea) { + orphanAreas.forEach(a => { + links.push({ source: 'total', target: a.area.area_id }); + }); + } else { + orphanAreas.forEach(a => { + a.entities.forEach(entityId => { + links.push({ source: 'total', target: entityId }); + }); + }); + } + + sections.push({ sort_by: 'state' }); // floor section with sorting + currentSection++; } else { - totalNode.children = [...totalNode.children, ...areas.map(a => a.area.area_id)]; + areas.forEach(a => { + links.push({ source: 'total', target: a.area.area_id }); + }); } + if (groupByArea) { - sections.push({ - entities: areas.map( - ({ area, entities }): EntityConfigInternal => ({ - entity_id: area.area_id, - type: 'remaining_child_state', - name: area.name, - children: entities, - }), - ), - sort_by: 'state', - sort_group_by_parent: true, + areas.forEach(({ area, entities }) => { + nodes.push({ + id: area.area_id, + section: currentSection, + type: 'remaining_child_state', + name: area.name, + }); + entities.forEach(entityId => { + links.push({ source: area.area_id, target: entityId }); + }); }); + sections.push({ sort_by: 'state', sort_group_by_parent: true }); // area section with sorting + currentSection++; } } else { - totalNode.children = [...totalNode.children, ...devicesWithoutParent.map(d => d.id)]; + devicesWithoutParent.forEach(d => { + links.push({ source: 'total', target: d.id }); + }); } + // Add device nodes const deviceSections = this.getDeviceSections(parentLinks, deviceNodes); deviceSections.forEach((section, i) => { if (section.length) { - sections.push({ - entities: section.map(d => ({ - entity_id: d.id, + section.forEach(d => { + nodes.push({ + id: d.id, + section: currentSection, type: 'entity', - name: d.name, - children: deviceSections[i + 1]?.filter(c => c.parent === d.id).map(c => c.id) || [], - })), - sort_by: 'state', - sort_group_by_parent: true, + name: d.name || '', + }); + const children = deviceSections[i + 1]?.filter(c => c.parent === d.id); + children?.forEach(c => { + links.push({ source: d.id, target: c.id }); + }); }); + sections.push({ sort_by: 'state', sort_group_by_parent: true }); // device section with sorting + currentSection++; } }); - // add unknown section after total node - const totalIndex = sections.findIndex(s => s.entities.find(e => e.entity_id === 'total')); - if (totalIndex !== -1 && sections[totalIndex + 1]) { - sections[totalIndex + 1]?.entities.push({ - entity_id: 'unknown', + // Add unknown node + const totalSection = nodes.find(n => n.id === 'total')?.section; + if (totalSection !== undefined) { + nodes.push({ + id: 'unknown', + section: totalSection + 1, type: 'remaining_parent_state', name: 'Unknown', - children: [], }); } - createPassthroughs(sections); - this.setNormalizedConfig({ ...this.config, sections }); + // setNormalizedConfig will convert sections (SectionConfig[]) to internal sections (Section[]) + this.setNormalizedConfig({ ...this.config, nodes, links, sections } as any); } private getDeviceSections(parentLinks: Record, deviceNodes: DeviceNode[]): DeviceNode[][] { diff --git a/src/migrate.ts b/src/migrate.ts index 1bc5084..b24490e 100644 --- a/src/migrate.ts +++ b/src/migrate.ts @@ -1,8 +1,8 @@ import { LovelaceCardConfig } from 'custom-card-helpers'; import { CONVERSION_UNITS, UNIT_PREFIXES } from './const'; -import type { ActionConfigExtended, NodeType, ChildConfigOrStr, ReconcileConfig, SankeyChartConfig } from './types'; +import type { ActionConfigExtended, NodeType, ChildConfigOrStr, ReconcileConfig, SankeyChartConfig, SectionConfig } from './types'; -interface V3Config extends LovelaceCardConfig { +export interface V3Config extends LovelaceCardConfig { type: string; autoconfig?: { print_yaml?: boolean; @@ -10,7 +10,7 @@ interface V3Config extends LovelaceCardConfig { group_by_area?: boolean; }; title?: string; - sections?: SectionConfig[]; + sections?: V3SectionConfig[]; convert_units_to?: '' | CONVERSION_UNITS; co2_intensity_entity?: string; gas_co2_intensity?: number; @@ -39,7 +39,7 @@ interface V3Config extends LovelaceCardConfig { ignore_missing_entities?: boolean; } -interface SectionConfig { +export interface V3SectionConfig { entities: EntityConfigOrStr[]; sort_by?: 'none' | 'state'; sort_dir?: 'asc' | 'desc'; @@ -73,10 +73,89 @@ export interface EntityConfig { } export function migrateV3Config(config: V3Config): SankeyChartConfig { - // @TODO: Implement + const nodes: SankeyChartConfig['nodes'] = []; + const links: SankeyChartConfig['links'] = []; + const sections: SankeyChartConfig['sections'] = []; + + if (!config.sections || config.sections.length === 0) { + return { + ...config, + nodes: [], + links: [], + sections: [], + }; + } + + // Convert sections to nodes with section index and extract section configs + config.sections.forEach((section, sectionIndex) => { + // Extract section config (without entities) + const sectionConfig: SectionConfig = { + sort_by: section.sort_by, + sort_dir: section.sort_dir, + sort_group_by_parent: section.sort_group_by_parent, + min_width: section.min_width, + }; + sections.push(sectionConfig); + + section.entities.forEach(entity => { + const entityConf = typeof entity === 'string' ? { entity_id: entity } : entity; + + // Create node + const node: NonNullable[number] = { + id: entityConf.entity_id, + section: sectionIndex, + type: entityConf.type || 'entity', + name: entityConf.name || '', + attribute: entityConf.attribute, + unit_of_measurement: entityConf.unit_of_measurement, + add_entities: entityConf.add_entities, + subtract_entities: entityConf.subtract_entities, + icon: entityConf.icon, + tap_action: entityConf.tap_action, + double_tap_action: entityConf.double_tap_action, + hold_action: entityConf.hold_action, + children_sum: entityConf.children_sum, + parents_sum: entityConf.parents_sum, + }; + + // Handle color migration + if (entityConf.color_on_state && entityConf.color_limit !== undefined) { + // Migrate old color format to new range-based format + const colors: any = {}; + if (entityConf.color_below) { + colors[entityConf.color_below] = { to: entityConf.color_limit }; + } + if (entityConf.color_above) { + colors[entityConf.color_above] = { from: entityConf.color_limit }; + } + node.color = colors; + } else if (entityConf.color) { + node.color = entityConf.color; + } + + nodes.push(node); + + // Create links from children + if (entityConf.children) { + entityConf.children.forEach(child => { + const childConf = typeof child === 'string' ? { entity_id: child } : child; + links.push({ + source: entityConf.entity_id, + target: childConf.entity_id, + value: 'connection_entity_id' in childConf ? childConf.connection_entity_id : undefined, + }); + }); + } + }); + }); + + // Remove old sections from config (will use new sections without entities) + const { sections: _oldSections, ...configWithoutSections } = config; + return { - ...config, - nodes: [], - links: [], + ...configWithoutSections, + nodes, + links, + sections, }; } diff --git a/src/types.ts b/src/types.ts index e321570..7f51bf1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,9 +2,7 @@ import { ActionConfig, BaseActionConfig, HapticType, - LovelaceCard, LovelaceCardConfig, - LovelaceCardEditor, } from 'custom-card-helpers'; import { HassEntity, HassServiceTarget } from 'home-assistant-js-websocket'; import { UNIT_PREFIXES, CONVERSION_UNITS } from './const'; @@ -24,12 +22,14 @@ export const DEFAULT_CONFIG: Config = { show_units: true, nodes: [], links: [], + sections: [], }; export interface SankeyChartConfig extends LovelaceCardConfig { type: string; nodes?: Node[]; links?: Link[]; + sections?: SectionConfig[]; autoconfig?: { print_yaml?: boolean; group_by_floor?: boolean; @@ -66,10 +66,13 @@ export interface SankeyChartConfig extends LovelaceCardConfig { export interface Node { id: string; + section?: number; // index in sections array type: NodeType - name: string; + name?: string; attribute?: string; unit_of_measurement?: string; // for attribute + add_entities?: string[]; // temporary - will be replaced + subtract_entities?: string[]; // temporary - will be replaced color?: string | { [color: string]: { from?: number; @@ -92,41 +95,16 @@ export interface Node { export interface Link { source: string; target: string; - value: string; + value?: string; // optional connection entity } export type NodeType = 'entity' | 'passthrough' | 'remaining_parent_state' | 'remaining_child_state'; -export interface EntityConfig { - entity_id: string; - add_entities?: string[]; - subtract_entities?: string[]; - attribute?: string; - type?: NodeType; - children?: ChildConfigOrStr[]; - unit_of_measurement?: string; // for attribute - color?: string; - name?: string; - icon?: string; - color_on_state?: boolean; - color_above?: string; - color_below?: string; - color_limit?: number; - url?: string; - tap_action?: ActionConfigExtended; - double_tap_action?: ActionConfigExtended; - hold_action?: ActionConfigExtended; - children_sum?: ReconcileConfig; - parents_sum?: ReconcileConfig; -} - -export type EntityConfigInternal = EntityConfig & { +export interface NodeInternal extends Node { children: ChildConfigOrStr[]; accountedState?: number; foundChildren?: string[]; -}; - -export type EntityConfigOrStr = string | EntityConfig; +} export type ChildConfig = { entity_id: string; @@ -167,7 +145,6 @@ export interface ReconcileConfig { } export interface SectionConfig { - entities: EntityConfigOrStr[]; sort_by?: 'none' | 'state'; sort_dir?: 'asc' | 'desc'; sort_group_by_parent?: boolean; @@ -175,7 +152,7 @@ export interface SectionConfig { } export interface Section { - entities: EntityConfigInternal[]; + entities: NodeInternal[]; sort_by?: 'none' | 'state'; sort_dir?: 'asc' | 'desc'; sort_group_by_parent?: boolean; @@ -192,6 +169,7 @@ export interface Config extends SankeyChartConfig { min_state: number; nodes: Node[]; links: Link[]; + sections: Section[]; // calculated from nodes/links by depth } export interface Connection { @@ -206,11 +184,11 @@ export interface Connection { } export interface Box { - config: EntityConfigInternal; + config: NodeInternal; entity: Omit & { state: string | number; }; - entity_id: string; + id: string; state: number; unit_of_measurement?: string; children: ChildConfigOrStr[]; @@ -234,15 +212,15 @@ export interface SectionState { } export interface ConnectionState { - parent: EntityConfigInternal; - child: EntityConfigInternal; + parent: NodeInternal; + child: NodeInternal; state: number; prevParentState: number; prevChildState: number; ready: boolean; calculating?: boolean; highlighted?: boolean; - passthroughs: EntityConfigInternal[]; + passthroughs: NodeInternal[]; } export interface NormalizedState { diff --git a/src/utils.ts b/src/utils.ts index a1cc16a..d5f4315 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -7,19 +7,19 @@ import { LovelaceCardConfig, } from 'custom-card-helpers'; import { html, TemplateResult } from 'lit'; -import { DEFAULT_ENTITY_CONF, UNIT_PREFIXES, FT3_PER_M3 } from './const'; +import { UNIT_PREFIXES, FT3_PER_M3 } from './const'; import { Box, - ChildConfigOrStr, Config, Connection, ConnectionState, DEFAULT_CONFIG, - EntityConfigInternal, - EntityConfigOrStr, SankeyChartConfig, Section, SectionConfig, + Node, + Link, + NodeInternal, } from './types'; import { addSeconds, @@ -34,6 +34,8 @@ import { startOfMonth, startOfYear, } from 'date-fns'; +import { migrateV3Config } from './migrate'; +import type { V3Config, V3SectionConfig } from './migrate'; export function generateRandomRGBColor(): string { const r = Math.floor(Math.random() * 256); @@ -86,7 +88,7 @@ export function normalizeStateValue( if (enableAutoPrefix) { // Find the most appropriate prefix based on the state value const magnitude = Math.abs(state * currentFactor); - + // Choose prefix based on the magnitude if (magnitude < 1) { unit_prefix = 'm'; @@ -123,28 +125,28 @@ function getUOMPrefix(unit_of_measurement: string): string { return (cleanUnit.length > 1 && Object.keys(UNIT_PREFIXES).find(p => unit_of_measurement!.indexOf(p) === 0)) || ''; } -export function getEntityId(entity: EntityConfigOrStr | ChildConfigOrStr): string { - return typeof entity === 'string' ? entity : entity.entity_id; +export function getEntityId(entity: string | Node | Record): string { + return typeof entity === 'string' ? entity : ((entity.id || (entity as Record).entity_id) as string); } export function getChildConnections( parent: Box, children: Box[], allConnections: ConnectionState[], - connectionsByParent: Map, + connectionsByParent: Map, ): Connection[] { // @NOTE don't take prevParentState from connection because it is different let prevParentState = 0; let state = 0; const childConnections = connectionsByParent.get(parent.config); return children.map(child => { - let connections = childConnections?.filter(c => c.child.entity_id === child.entity_id); + let connections = childConnections?.filter(c => c.child.id === child.id); if (!connections?.length) { connections = allConnections.filter( - c => c.passthroughs.includes(child) || c.passthroughs.includes(parent.config), + c => c.passthroughs.includes(child.config) || c.passthroughs.includes(parent.config), ); if (!connections.length) { - throw new Error(`Missing connection: ${parent.entity_id} - ${child.entity_id}`); + throw new Error(`Missing connection: ${parent.id} - ${child.id}`); } } state = connections.reduce((sum, c) => sum + c.state, 0); @@ -173,8 +175,66 @@ export function getChildConnections( }); } -export function normalizeConfig(conf: SankeyChartConfig, isMetric?: boolean): Config { - let config = { sections: [], ...cloneObj(conf) }; +export function convertNodesToSections(nodes: Node[], links: Link[], sectionConfigs?: SectionConfig[]): Section[] { + // Group nodes by section index + const nodesBySection = new Map(); + + nodes.forEach(node => { + const nodeInternal: NodeInternal = { + ...node, + children: [], + }; + + if (!nodesBySection.has(node.section || 0)) { + nodesBySection.set(node.section || 0, []); + } + nodesBySection.get(node.section || 0)!.push(nodeInternal); + }); + + // Build children arrays from links + links.forEach(link => { + const sourceNode = nodes.find(n => n.id === link.source); + if (sourceNode) { + const internalNode = nodesBySection.get(sourceNode.section || 0)?.find(n => n.id === link.source); + if (internalNode) { + if (link.value) { + // Connection has a specific entity + internalNode.children.push({ + entity_id: link.target, + connection_entity_id: link.value, + }); + } else { + // Simple connection + internalNode.children.push(link.target); + } + } + } + }); + + // Convert to sections, sorted by section index + const sectionIndices = Array.from(nodesBySection.keys()).sort((a, b) => a - b); + const sections: Section[] = sectionIndices.map(sectionIndex => { + // Get section config if available, otherwise use empty config + const sectionConfig = sectionConfigs?.[sectionIndex] || {}; + + return { + entities: nodesBySection.get(sectionIndex) || [], + sort_by: sectionConfig.sort_by, + sort_dir: sectionConfig.sort_dir, + sort_group_by_parent: sectionConfig.sort_group_by_parent, + min_width: sectionConfig.min_width, + }; + }); + + createPassthroughs(sections); + return sections; +} + +export function normalizeConfig(conf: SankeyChartConfig | V3Config, isMetric?: boolean): Config { + // V3 detection: check if sections have entities + let config: SankeyChartConfig = conf.sections?.some(section => (section as V3SectionConfig).entities) + ? migrateV3Config(conf as V3Config) + : cloneObj(conf); const { autoconfig } = conf; if (autoconfig || typeof autoconfig === 'object') { @@ -183,19 +243,12 @@ export function normalizeConfig(conf: SankeyChartConfig, isMetric?: boolean): Co unit_prefix: 'k', round: 1, ...config, - sections: [], + nodes: config.nodes || [], + links: config.links || [], }; } - const sections: Section[] = config.sections.map((section: SectionConfig) => ({ - ...section, - entities: section.entities.map(entityConf => - typeof entityConf === 'string' - ? { ...DEFAULT_ENTITY_CONF, children: [], entity_id: entityConf } - : { ...DEFAULT_ENTITY_CONF, children: [], ...entityConf }, - ), - })); - createPassthroughs(sections); + const sections: Section[] = convertNodesToSections(config.nodes || [], config.links || [], config.sections); const default_co2_per_ft3 = 55.0 + // gCO2e/ft3 tailpipe @@ -205,6 +258,8 @@ export function normalizeConfig(conf: SankeyChartConfig, isMetric?: boolean): Co gas_co2_intensity: isMetric ? default_co2_per_ft3 * FT3_PER_M3 : default_co2_per_ft3, ...config, min_state: config.min_state ? Math.abs(config.min_state) : 0, + nodes: config.nodes || [], + links: config.links || [], sections, }; } @@ -221,9 +276,10 @@ export function createPassthroughs(sections: Section[]): void { if (i > sectionIndex + 1) { for (let j = sectionIndex + 1; j < i; j++) { sections[j].entities.push({ - ...(typeof childConf === 'string' ? { entity_id: childConf } : childConf), + ...(typeof childConf === 'string' ? { id: childConf } : childConf), type: 'passthrough', children: [], + section: j, }); } } @@ -239,11 +295,11 @@ export function createPassthroughs(sections: Section[]): void { export function sortBoxes(parentBoxes: Box[], boxes: Box[], sort?: string, dir = 'desc') { if (sort === 'state') { const parentChildren = parentBoxes.map(p => - p.config.type === 'passthrough' ? [p.entity_id] : p.config.children.map(getEntityId), + p.config.type === 'passthrough' ? [p.id] : p.config.children.map(getEntityId), ); const sortByParent = (a: Box, b: Box, realSort: (a: Box, b: Box) => number) => { - let parentIndexA = parentChildren.findIndex(children => children.includes(a.entity_id)); - let parentIndexB = parentChildren.findIndex(children => children.includes(b.entity_id)); + let parentIndexA = parentChildren.findIndex(children => children.includes(a.id)); + let parentIndexB = parentChildren.findIndex(children => children.includes(b.id)); // sort orphans to the end if (parentIndexA === -1) { parentIndexA = parentChildren.length; From ed4e9592b400f456bcdbd0f6e1ef06667ca3ce25 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Tue, 27 Jan 2026 14:22:33 +0200 Subject: [PATCH 04/10] refactor!: Use v4 config format --- __tests__/autoconfig.test.ts | 2 +- __tests__/zoom.test.ts | 66 +++++++++------ src/chart.ts | 16 ++-- src/const.ts | 11 ++- src/editor/entity.ts | 33 +++----- src/editor/index.ts | 157 ++++++++++++++++++++++------------- src/handle-actions.ts | 4 +- src/label.ts | 2 +- src/section.ts | 4 +- src/types.ts | 10 +++ src/zoom.ts | 2 +- 11 files changed, 186 insertions(+), 121 deletions(-) diff --git a/__tests__/autoconfig.test.ts b/__tests__/autoconfig.test.ts index 3b6c981..ff9ed8b 100644 --- a/__tests__/autoconfig.test.ts +++ b/__tests__/autoconfig.test.ts @@ -75,7 +75,7 @@ describe('SankeyChart autoconfig', () => { // Check sections are calculated from nodes expect(Array.isArray(config.sections)).toBe(true); expect(config.sections.length).toBeGreaterThan(0); - const allEntities = config.sections.flatMap((s: { entities: { entity_id: string }[] }) => s.entities.map((e: { entity_id: string }) => e.entity_id)); + const allEntities = config.sections.flatMap((s: { entities: { id: string }[] }) => s.entities.map((e: { id: string }) => e.id)); expect(allEntities).toContain('sensor.grid_in'); expect(allEntities).toContain('sensor.solar'); expect(allEntities).toContain('sensor.battery_in'); diff --git a/__tests__/zoom.test.ts b/__tests__/zoom.test.ts index e94063a..f3b11e8 100644 --- a/__tests__/zoom.test.ts +++ b/__tests__/zoom.test.ts @@ -3,11 +3,21 @@ import { filterConfigByZoomEntity } from '../src/zoom'; const config = { type: '', + layout: 'auto' as const, + unit_prefix: '', + round: 0, + height: 200, + min_box_size: 3, + min_box_distance: 5, + min_state: 0, + nodes: [], + links: [], sections: [ { entities: [ { - entity_id: 'ent1', + id: 'ent1', + type: 'entity' as const, children: ['ent2', 'ent3'], }, ], @@ -15,11 +25,13 @@ const config = { { entities: [ { - entity_id: 'ent2', + id: 'ent2', + type: 'entity' as const, children: ['ent4'], }, { - entity_id: 'ent3', + id: 'ent3', + type: 'entity' as const, children: ['ent5'], }, ], @@ -27,11 +39,13 @@ const config = { { entities: [ { - entity_id: 'ent4', + id: 'ent4', + type: 'entity' as const, children: [], }, { - entity_id: 'ent5', + id: 'ent5', + type: 'entity' as const, children: [], }, ], @@ -41,27 +55,27 @@ const config = { describe('zoom action', () => { it('filters a config based on zoom entity', async () => { - expect(filterConfigByZoomEntity(config, config.sections[1].entities[0])).toEqual({ - type: '', - sections: [ - { - entities: [ - { - entity_id: 'ent2', - children: ['ent4'], - }, - ], - }, - { - entities: [ - { - entity_id: 'ent4', - children: [], - }, - ], - }, - ], - }); + const filtered = filterConfigByZoomEntity(config, config.sections[1].entities[0]); + expect(filtered.sections).toEqual([ + { + entities: [ + { + id: 'ent2', + type: 'entity', + children: ['ent4'], + }, + ], + }, + { + entities: [ + { + id: 'ent4', + type: 'entity', + children: [], + }, + ], + }, + ]); }); it('returns the same config when there is no zoom entity', async () => { expect(filterConfigByZoomEntity(config, undefined)).toEqual(config); diff --git a/src/chart.ts b/src/chart.ts index 2d91915..4aed4e2 100644 --- a/src/chart.ts +++ b/src/chart.ts @@ -92,7 +92,7 @@ export class Chart extends LitElement { this.config.sections.forEach(({ entities }, sectionIndex) => { entities.forEach(ent => { if (ent.type === 'entity') { - this.entityIds.push(ent.entity_id); + this.entityIds.push(ent.id); } else if (ent.type === 'passthrough') { return; } @@ -101,7 +101,7 @@ export class Chart extends LitElement { const childId = getEntityId(childConf); let child: EntityConfigInternal | undefined = ent; for (let i = sectionIndex + 1; i < this.config.sections.length; i++) { - child = this.config.sections[i]?.entities.find(e => e.entity_id === childId); + child = this.config.sections[i]?.entities.find(e => e.id === childId); if (!child) { this.error = new Error(localize('common.missing_child') + ' ' + getEntityId(childConf)); throw this.error; @@ -195,7 +195,7 @@ export class Chart extends LitElement { if (!parentState || !childState) { connection.state = 0; } else { - const connConfig = parent.children.find(c => getEntityId(c) === child.entity_id); + const connConfig = parent.children.find(c => getEntityId(c) === child.id); if (typeof connConfig === 'object' && connConfig.connection_entity_id) { const connectionState = this._getMemoizedState(connConfig.connection_entity_id).state ?? 0; connection.state = Math.min(parentState, childState, connectionState); @@ -225,7 +225,7 @@ export class Chart extends LitElement { private _getMemoizedState(entityConfOrStr: EntityConfigInternal | string) { if (!this.entityStates.has(entityConfOrStr)) { const entityConf = - typeof entityConfOrStr === 'string' ? { entity_id: entityConfOrStr, children: [] } : entityConfOrStr; + typeof entityConfOrStr === 'string' ? { id: entityConfOrStr, type: 'entity' as const, children: [] } : entityConfOrStr; const entity = this._getEntityState(entityConf); const unit_of_measurement = this._getUnitOfMeasurement( entityConf.unit_of_measurement || entity.attributes.unit_of_measurement, @@ -239,7 +239,7 @@ export class Chart extends LitElement { } if (entityConf.add_entities) { entityConf.add_entities.forEach(subId => { - const subEntity = this._getEntityState({ entity_id: subId, children: [] }); + const subEntity = this._getEntityState({ id: subId, type: 'entity' as const, children: [] }); const { state } = normalizeStateValue( this.config.unit_prefix, Number(subEntity.state), @@ -250,7 +250,7 @@ export class Chart extends LitElement { } if (entityConf.subtract_entities) { entityConf.subtract_entities.forEach(subId => { - const subEntity = this._getEntityState({ entity_id: subId, children: [] }); + const subEntity = this._getEntityState({ id: subId, type: 'entity' as const, children: [] }); const { state } = normalizeStateValue( this.config.unit_prefix, Number(subEntity.state), @@ -377,10 +377,10 @@ export class Chart extends LitElement { return { config: entityConf, entity: this._getEntityState(entityConf), - entity_id: getEntityId(entityConf), + id: getEntityId(entityConf), state, unit_of_measurement, - color: finalColor, + color: finalColor as string, children: entityConf.children, connections: { parents: [] }, top: 0, diff --git a/src/const.ts b/src/const.ts index 26b6fca..94a9645 100644 --- a/src/const.ts +++ b/src/const.ts @@ -1,4 +1,4 @@ -import { EntityConfig } from "./types"; +import type { NodeConfigForEditor } from './types'; export const UNIT_PREFIXES = { 'm': 0.001, @@ -16,4 +16,11 @@ export const MIN_VERTICAL_SECTION_H = 150; export const FT3_PER_M3 = 35.31; -export type CONVERSION_UNITS = 'MJ' | 'gCO2' | 'monetary'; \ No newline at end of file +export type CONVERSION_UNITS = 'MJ' | 'gCO2' | 'monetary'; + +export const DEFAULT_ENTITY_CONF: Partial = { + type: 'entity', + name: '', + children: [], + // No deprecated V3 properties (color_on_state, etc.) +}; \ No newline at end of file diff --git a/src/editor/entity.ts b/src/editor/entity.ts index 366362c..403e282 100644 --- a/src/editor/entity.ts +++ b/src/editor/entity.ts @@ -2,12 +2,12 @@ import { HomeAssistant, stateIcon } from 'custom-card-helpers'; import { LitElement, html, TemplateResult, css, CSSResultGroup } from 'lit'; // eslint-disable-next-line @typescript-eslint/no-unused-vars import { customElement, property } from 'lit/decorators'; -import type { EntityConfig, EntityConfigOrStr } from '../types'; +import type { NodeConfigForEditor, NodeConfigOrStr } from '../types'; import { localize } from '../localize/localize'; import { repeat } from 'lit/directives/repeat'; import { DEFAULT_ENTITY_CONF } from '../const'; -const computeSchema = (entityConf: EntityConfig, icon: string) => [ +const computeSchema = (nodeConf: NodeConfigForEditor, icon: string) => [ { name: 'type', selector: { @@ -22,12 +22,12 @@ const computeSchema = (entityConf: EntityConfig, icon: string) => [ }, }, }, - { name: 'entity_id', selector: { entity: {} } }, + { name: 'id', selector: { entity: {} } }, { type: 'grid', name: '', schema: [ - { name: 'attribute', selector: { attribute: { entity_id: entityConf.entity_id } } }, + { name: 'attribute', selector: { attribute: { entity_id: nodeConf.id } } }, { name: 'unit_of_measurement', selector: { text: {} } }, ], }, @@ -41,19 +41,8 @@ const computeSchema = (entityConf: EntityConfig, icon: string) => [ ], }, { name: 'tap_action', selector: { 'ui-action': {} } }, - { name: 'color_on_state', selector: { boolean: {} } }, - ...(entityConf.color_on_state - ? [ - { - name: 'color_limit', - selector: { number: { mode: 'box', unit_of_measurement: entityConf.unit_of_measurement, min: 0., step: 'any' } }, - }, - { name: 'color_above', selector: { text: {} } }, - { name: 'color_below', selector: { text: {} } }, - ] - : []), - // { - // name: 'children_sum.should_be', + // { + // name: 'children_sum.should_be', // selector: { // select: { // mode: 'dropdown', @@ -68,9 +57,9 @@ const computeSchema = (entityConf: EntityConfig, icon: string) => [ @customElement('sankey-chart-entity-editor') class SankeyChartEntityEditor extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; - @property({ attribute: false }) public entity!: EntityConfigOrStr; + @property({ attribute: false }) public entity!: NodeConfigOrStr; @property({ attribute: false }) public onClose!: () => void; - @property({ attribute: false }) public onChange!: (c: EntityConfigOrStr) => void; + @property({ attribute: false }) public onChange!: (c: NodeConfigOrStr) => void; private _valueChanged(ev: CustomEvent): void { this.onChange(ev.detail.value); @@ -92,7 +81,7 @@ class SankeyChartEntityEditor extends LitElement { detail: { value }, target, } = ev; - const conf = typeof this.entity === 'string' ? { entity_id: this.entity } : this.entity; + const conf = typeof this.entity === 'string' ? { id: this.entity, type: 'entity' as const, children: [] } : this.entity; let children = conf.children ?? []; if (typeof target?.index === 'number') { if (value) { @@ -113,10 +102,10 @@ class SankeyChartEntityEditor extends LitElement { } protected render(): TemplateResult | void { - const conf = typeof this.entity === 'string' ? { entity_id: this.entity } : this.entity; + const conf = typeof this.entity === 'string' ? { id: this.entity, type: 'entity' as const } : this.entity; const data = { ...DEFAULT_ENTITY_CONF, ...conf }; - const icon = data.icon || this._getEntityIcon(conf.entity_id); + const icon = data.icon || this._getEntityIcon(conf.id); const schema = computeSchema(data, icon); return html` diff --git a/src/editor/index.ts b/src/editor/index.ts index dfec117..3590bae 100644 --- a/src/editor/index.ts +++ b/src/editor/index.ts @@ -5,12 +5,12 @@ import { HomeAssistant, fireEvent, LovelaceCardEditor, LovelaceConfig } from 'cu // eslint-disable-next-line @typescript-eslint/no-unused-vars import { customElement, property, state } from 'lit/decorators'; import { repeat } from 'lit/directives/repeat'; -import { SankeyChartConfig, Section } from '../types'; +import { SankeyChartConfig, Section, Node } from '../types'; import { localize } from '../localize/localize'; import { normalizeConfig, convertNodesToSections } from '../utils'; import './section'; import './entity'; -import { EntityConfigOrStr } from '../types'; +import { NodeConfigOrStr } from '../types'; import { UNIT_PREFIXES } from '../const'; import { migrateV3Config } from '../migrate'; @@ -20,25 +20,27 @@ export class SankeyChartEditor extends LitElement implements LovelaceCardEditor @property({ attribute: false }) public lovelace?: LovelaceConfig; @state() private _config?: SankeyChartConfig; @state() private _helpers?: any; - @state() private _entityConfig?: { sectionIndex: number; entityIndex: number; entity: EntityConfigOrStr }; + @state() private _entityConfig?: { sectionIndex: number; entityIndex: number; entity: NodeConfigOrStr }; private _initialized = false; public setConfig(config: SankeyChartConfig): void { - // Convert V4 config to V3 format for editing - if (config.nodes && config.nodes.length > 0) { - // Convert nodes/links to sections with entities for editor - const sections = convertNodesToSections(config.nodes, config.links || [], config.sections); - this._config = { - ...config, - sections: sections as any, // V3 format sections with entities - }; - } else { - this._config = config; - } - + // Store config directly in V4 format + // Auto-migrate V3 configs if detected + this._config = config.sections?.some(s => (s as any).entities) + ? migrateV3Config(config as any) + : config; this.loadCardHelpers(); } + private _getSections(): Section[] { + if (!this._config) return []; + return convertNodesToSections( + this._config.nodes || [], + this._config.links || [], + this._config.sections + ); + } + protected shouldUpdate(): boolean { if (!this._initialized) { this._initialize(); @@ -97,22 +99,19 @@ export class SankeyChartEditor extends LitElement implements LovelaceCardEditor private _addEntity(ev): void { const value = ev.detail.value; - if (value === '') { - return; - } + if (value === '') return; + const target = ev.target; if (typeof target.section === 'number') { - const sections: Section[] = this._config?.sections as any || []; + const newNode: Node = { + id: value, + section: target.section, + type: 'entity', + }; + this._config = { ...this._config!, - sections: sections.map((section, i) => - i === target.section - ? { - ...section, - entities: [...section.entities, value], - } - : section, - ), + nodes: [...(this._config!.nodes || []), newNode], }; } ev.target.value = ''; @@ -121,41 +120,88 @@ export class SankeyChartEditor extends LitElement implements LovelaceCardEditor private _editEntity(ev): void { const { value } = ev.detail; - const newConf = typeof value === 'string' ? { entity_id: value } : value; const target = ev.target; - if (typeof target.section === 'number' && typeof target.index === 'number') { - const sections: Section[] = this._config?.sections as any || []; + + if (typeof target.section !== 'number' || typeof target.index !== 'number') { + return; + } + + const sections = this._getSections(); + const section = sections[target.section]; + if (!section) return; + + const nodeInternal = section.entities[target.index]; + const nodeId = nodeInternal.id; + + // Find node in config + const nodes = this._config?.nodes || []; + const nodeIndex = nodes.findIndex(n => n.id === nodeId); + if (nodeIndex < 0) return; + + const newConf = typeof value === 'string' ? { id: value } : value; + + if (!newConf.id) { + // Deleting node - remove from nodes and clean up links this._config = { ...this._config!, - sections: sections.map((section, i) => { - if (i !== target.section) { - return section; - } - const existing = section.entities[target.index]; - const newVal = typeof existing === 'string' ? newConf : { ...existing, ...newConf }; - return { - ...section, - entities: newConf?.entity_id - ? [...section.entities.slice(0, target.index), newVal, ...section.entities.slice(target.index + 1)] - : section.entities.filter((e, i) => i !== target.index), - }; - }), + nodes: nodes.filter((_, i) => i !== nodeIndex), + links: (this._config!.links || []).filter( + l => l.source !== nodeId && l.target !== nodeId + ), + }; + } else { + const updatedNodes = [...nodes]; + const existingNode = updatedNodes[nodeIndex]; + + // Merge changes + updatedNodes[nodeIndex] = { ...existingNode, ...newConf }; + + // Handle children sync to links + let updatedLinks = this._config!.links || []; + if ('children' in newConf) { + // Remove old links from this source + updatedLinks = updatedLinks.filter(l => l.source !== nodeId); + + // Add new links from children + (newConf.children || []).forEach(child => { + const childConf = typeof child === 'string' + ? { entity_id: child } + : child; + updatedLinks.push({ + source: nodeId, + target: childConf.entity_id, + value: 'connection_entity_id' in childConf + ? childConf.connection_entity_id + : undefined, + }); + }); + } + + this._config = { + ...this._config!, + nodes: updatedNodes, + links: updatedLinks, }; } + this._updateConfig(); } private _configEntity(sectionIndex: number, entityIndex: number): void { - const sections: Section[] = this._config?.sections as any || []; - this._entityConfig = { sectionIndex, entityIndex, entity: sections[sectionIndex].entities[entityIndex] }; + const sections = this._getSections(); + this._entityConfig = { + sectionIndex, + entityIndex, + entity: sections[sectionIndex].entities[entityIndex] + }; } - private _handleEntityChange = (entityConf: EntityConfigOrStr): void => { + private _handleEntityChange = (nodeConf: NodeConfigOrStr): void => { this._editEntity({ - detail: { value: entityConf }, + detail: { value: nodeConf }, target: { section: this._entityConfig?.sectionIndex, index: this._entityConfig?.entityIndex }, }); - this._entityConfig = { ...this._entityConfig!, entity: entityConf }; + this._entityConfig = { ...this._entityConfig!, entity: nodeConf }; }; private _handleSectionChange = (index: number, sectionConf: Section): void => { @@ -167,12 +213,8 @@ export class SankeyChartEditor extends LitElement implements LovelaceCardEditor }; private _updateConfig(): void { - // Convert V3 format (with entities in sections) back to V4 format before saving - const configToSave = this._config?.sections && (this._config.sections[0] as any)?.entities - ? migrateV3Config(this._config as any) - : this._config; - - fireEvent(this, 'config-changed', { config: configToSave }); + // Config is already in V4 format - save directly + fireEvent(this, 'config-changed', { config: this._config }); } private _computeSchema() { @@ -279,7 +321,7 @@ export class SankeyChartEditor extends LitElement implements LovelaceCardEditor const isMetric = this.hass.config.unit_system.length == 'km'; const config = normalizeConfig(this._config || ({} as SankeyChartConfig), isMetric); const { autoconfig } = config; - const sections: Section[] = this._config?.sections as any || []; + const sections = this._getSections(); if (this._entityConfig) { return html` @@ -364,7 +406,10 @@ export class SankeyChartEditor extends LitElement implements LovelaceCardEditor ({ ...conf, sections: [...conf.sections, { entities: [] }] })} + .configValue=${conf => ({ + ...conf, + sections: [...(conf.sections || []), {}] // Add empty section config, not entities + })} @click=${this._valueChanged} > ${localize('editor.add_section')} diff --git a/src/handle-actions.ts b/src/handle-actions.ts index 1d3612a..e3b0a2e 100644 --- a/src/handle-actions.ts +++ b/src/handle-actions.ts @@ -64,7 +64,7 @@ export const handleAction = async ( case "more-info": { fireEvent(node, "hass-more-info", { // @ts-ignore - entityId: actionConfig.entity ?? actionConfig.data?.entity_id ?? config.entity_id, + entityId: actionConfig.entity ?? actionConfig.data?.entity_id ?? config.id, }); break; } @@ -90,7 +90,7 @@ export const handleAction = async ( break; } case "toggle": { - toggleEntity(hass, config.entity_id); + toggleEntity(hass, config.id); forwardHaptic("light"); break; } diff --git a/src/label.ts b/src/label.ts index 0c0dde2..15fa2b6 100644 --- a/src/label.ts +++ b/src/label.ts @@ -67,7 +67,7 @@ export function renderLabel( ` : null} ${show_names - ? html`${!vertical ? html` ` : null}${!box.config.url ? html`${name}` : html`${name}`}` + ? html`${!vertical ? html` ` : null}${name}` : null}
`; } diff --git a/src/section.ts b/src/section.ts index dcf3c06..5624a32 100644 --- a/src/section.ts +++ b/src/section.ts @@ -22,8 +22,8 @@ export function renderBranchConnectors(props: { .map((b, boxIndex) => { const children = props.nextSection!.boxes.filter( child => - b.children.some(c => getEntityId(c) === child.entity_id) || - (b.config.type === 'passthrough' && b.entity_id === child.entity_id), + b.children.some(c => getEntityId(c) === child.id) || + (b.config.type === 'passthrough' && b.id === child.id), ); const connections = getChildConnections(b, children, props.allConnections, props.connectionsByParent).filter( c => { diff --git a/src/types.ts b/src/types.ts index 7f51bf1..01228f2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -106,6 +106,16 @@ export interface NodeInternal extends Node { foundChildren?: string[]; } +// Backward compatibility alias +export type EntityConfigInternal = NodeInternal; + +// Editor-specific types - working with nodes that have temporary children array +export interface NodeConfigForEditor extends Node { + children?: ChildConfigOrStr[]; // temporary UI property, synced with links +} + +export type NodeConfigOrStr = string | NodeConfigForEditor; + export type ChildConfig = { entity_id: string; connection_entity_id: string; diff --git a/src/zoom.ts b/src/zoom.ts index 88c4e11..1cb1648 100644 --- a/src/zoom.ts +++ b/src/zoom.ts @@ -7,7 +7,7 @@ export function filterConfigByZoomEntity(config: Config, zoomEntity?: EntityConf } let children: string[] = []; const newSections = config.sections.map(section => { - const newEntities = section.entities.filter(entity => entity === zoomEntity || children.includes(entity.entity_id)); + const newEntities = section.entities.filter(entity => entity === zoomEntity || children.includes(entity.id)); children = newEntities.flatMap(entity => entity.children.map(getEntityId)); return { ...section, From a3ae2afd799142ebbb94e8670f7f15b373a9007a Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Tue, 27 Jan 2026 14:24:02 +0200 Subject: [PATCH 05/10] fix: Make `tap_action` work on labels --- __tests__/__snapshots__/basic.test.ts.snap | 12 +++---- .../__snapshots__/remaining.test.ts.snap | 32 +++++++++---------- src/section.ts | 15 +++++---- 3 files changed, 31 insertions(+), 28 deletions(-) diff --git a/__tests__/__snapshots__/basic.test.ts.snap b/__tests__/__snapshots__/basic.test.ts.snap index 90ecc14..ae90bac 100644 --- a/__tests__/__snapshots__/basic.test.ts.snap +++ b/__tests__/__snapshots__/basic.test.ts.snap @@ -39,8 +39,8 @@ exports[`SankeyChart matches a simple snapshot 2`] = `
-
-
+
+
@@ -59,8 +59,8 @@ exports[`SankeyChart matches a simple snapshot 2`] = ` -
-
+
+
@@ -74,8 +74,8 @@ exports[`SankeyChart matches a simple snapshot 2`] = `
-
-
+
+
diff --git a/__tests__/__snapshots__/remaining.test.ts.snap b/__tests__/__snapshots__/remaining.test.ts.snap index 1d9a546..54f4c58 100644 --- a/__tests__/__snapshots__/remaining.test.ts.snap +++ b/__tests__/__snapshots__/remaining.test.ts.snap @@ -32,8 +32,8 @@ exports[`SankeyChart with remaining type entities matches snapshot 2`] = `
-
-
+
+
@@ -80,8 +80,7 @@ exports[`SankeyChart with remaining type entities matches snapshot 2`] = `
-
-
+IDK"> +
@@ -145,9 +145,9 @@ IDK" class=""> -
-
+
+
@@ -161,8 +161,8 @@ Blaa" class="">
-
-
+
+
@@ -176,8 +176,8 @@ Blaa" class="">
-
-
+
+
@@ -210,8 +210,8 @@ Blaa" class="">
-
-
+
+
@@ -225,8 +225,8 @@ Blaa" class="">
-
-
+
+
diff --git a/src/section.ts b/src/section.ts index 5624a32..69f45bc 100644 --- a/src/section.ts +++ b/src/section.ts @@ -120,14 +120,17 @@ export function renderSection(props: { ${extraSpacers ? html`
` : null} -
+
props.onTap(box)} + @dblclick=${() => props.onDoubleTap(box)} + @mouseenter=${() => props.onMouseEnter(box)} + @mouseleave=${props.onMouseLeave} + title=${formattedState + box.unit_of_measurement + ' ' + name} + >
props.onTap(box)} - @dblclick=${() => props.onDoubleTap(box)} - @mouseenter=${() => props.onMouseEnter(box)} - @mouseleave=${props.onMouseLeave} - title=${formattedState + box.unit_of_measurement + ' ' + name} class=${props.highlightedEntities.includes(box.config) ? 'hl' : ''} > ${show_icons && isNotPassthrough From c09a5a21275457400964970204919a9666e08ebe Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Tue, 27 Jan 2026 14:26:11 +0200 Subject: [PATCH 06/10] fix: Handle undefined node in section entities --- src/editor/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/editor/index.ts b/src/editor/index.ts index 3590bae..27870e1 100644 --- a/src/editor/index.ts +++ b/src/editor/index.ts @@ -131,6 +131,7 @@ export class SankeyChartEditor extends LitElement implements LovelaceCardEditor if (!section) return; const nodeInternal = section.entities[target.index]; + if (!nodeInternal) return; const nodeId = nodeInternal.id; // Find node in config @@ -140,7 +141,7 @@ export class SankeyChartEditor extends LitElement implements LovelaceCardEditor const newConf = typeof value === 'string' ? { id: value } : value; - if (!newConf.id) { + if (!newConf || !newConf.id) { // Deleting node - remove from nodes and clean up links this._config = { ...this._config!, From 1343a9e004772ba41a5d587255503779b8222c56 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Tue, 27 Jan 2026 14:42:42 +0200 Subject: [PATCH 07/10] docs: Update README to reflect new nodes and links configuration --- MIGRATION.md | 239 +++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 245 +++++++++++++++++++++++++++++++-------------------- 2 files changed, 387 insertions(+), 97 deletions(-) create mode 100644 MIGRATION.md diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..86e0c36 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,239 @@ +# Migration Guide: v3 to v4 Config Format + +> **Note:** If you use the visual card editor, your config will be automatically converted to v4 format when you save. This guide is for users who edit their configuration in YAML mode only. + +This guide explains how to manually convert your existing v3 configuration to the new v4 format. + +## Overview of Changes + +The v4 format separates the config into three distinct parts: + +| v3 Format | v4 Format | +|-----------|-----------| +| `sections[].entities[]` | `nodes[]` - flat list of all entities | +| `entity.children[]` | `links[]` - connections between nodes | +| `entity_id` | `id` | +| `connection_entity_id` | `value` (in links) | +| `color_on_state`, `color_limit`, `color_above`, `color_below` | `color` object with ranges | + +## Step-by-Step Migration + +### Step 1: Extract nodes from sections + +In v3, entities were nested inside sections. In v4, all nodes are in a flat `nodes[]` array with a `section` index. + +**v3:** +```yaml +sections: + - entities: + - entity_id: sensor.power + name: Total Power + - entity_id: sensor.solar + - entities: + - entity_id: sensor.device1 + - entity_id: sensor.device2 +``` + +**v4:** +```yaml +nodes: + - id: sensor.power # entity_id -> id + section: 0 # first section = index 0 + name: Total Power + - id: sensor.solar + section: 0 + - id: sensor.device1 + section: 1 # second section = index 1 + - id: sensor.device2 + section: 1 +``` + +### Step 2: Convert children to links + +In v3, parent-child relationships were defined via `children[]` on each entity. In v4, these become entries in the `links[]` array. + +**v3:** +```yaml +sections: + - entities: + - entity_id: sensor.power + children: + - sensor.device1 + - sensor.device2 + - entities: + - sensor.device1 + - sensor.device2 +``` + +**v4:** +```yaml +nodes: + - id: sensor.power + section: 0 + - id: sensor.device1 + section: 1 + - id: sensor.device2 + section: 1 +links: + - source: sensor.power + target: sensor.device1 + - source: sensor.power + target: sensor.device2 +``` + +### Step 3: Convert connection entities + +If you used `connection_entity_id` to specify how much flows between nodes, use the `value` property in links. + +**v3:** +```yaml +- entity_id: sensor.floor1 + children: + - entity_id: sensor.washer + connection_entity_id: sensor.washer_energy +``` + +**v4:** +```yaml +links: + - source: sensor.floor1 + target: sensor.washer + value: sensor.washer_energy +``` + +### Step 4: Convert color ranges (optional) + +If you used `color_on_state` with `color_limit`, `color_above`, and `color_below`, convert to the new color object format. + +**v3:** +```yaml +- entity_id: sensor.temperature + color_on_state: true + color_limit: 25 + color_above: red + color_below: green +``` + +**v4:** +```yaml +- id: sensor.temperature + color: + red: + from: 25 # red when >= 25 + green: + to: 25 # green when < 25 +``` + +The new format is more flexible and supports multiple ranges: + +```yaml +color: + red: + from: 30 # red when >= 30 + orange: + from: 20 + to: 30 # orange when >= 20 and < 30 + green: + to: 20 # green when < 20 +``` + +### Step 5: Move section config (if any) + +Section-level settings like `sort_by`, `sort_dir`, and `min_width` stay in the `sections[]` array, but without entities. + +**v3:** +```yaml +sections: + - entities: + - sensor.a + - sensor.b + sort_by: state + min_width: 200 +``` + +**v4:** +```yaml +nodes: + - id: sensor.a + section: 0 + - id: sensor.b + section: 0 +sections: + - sort_by: state + min_width: 200 +``` + +## Complete Example + +### Before (v3) + +```yaml +type: custom:sankey-chart +show_names: true +sections: + - entities: + - entity_id: sensor.grid + children: + - sensor.house + - entity_id: sensor.solar + color: orange + children: + - sensor.house + - entities: + - entity_id: sensor.house + children: + - sensor.hvac + - entity_id: sensor.washer + connection_entity_id: sensor.washer_power + - other + - entity_id: other + type: remaining_parent_state + name: Other + - entities: + - sensor.hvac + - sensor.washer +``` + +### After (v4) + +```yaml +type: custom:sankey-chart +show_names: true +nodes: + # Section 0 - Sources + - id: sensor.grid + section: 0 + - id: sensor.solar + section: 0 + color: orange + # Section 1 - House + - id: sensor.house + section: 1 + - id: other + section: 1 + type: remaining_parent_state + name: Other + # Section 2 - Consumers + - id: sensor.hvac + section: 2 + - id: sensor.washer + section: 2 +links: + - source: sensor.grid + target: sensor.house + - source: sensor.solar + target: sensor.house + - source: sensor.house + target: sensor.hvac + - source: sensor.house + target: sensor.washer + value: sensor.washer_power + - source: sensor.house + target: other +``` + +## Tips + +1. **Section indices are 0-based** - first section is 0, second is 1, etc. +2. **Links define the flow** - the order of links doesn't matter, but nodes are rendered in the order they appear +3. **Passthrough nodes** can use a custom id now but their links have to be defined with that id diff --git a/README.md b/README.md index 05704b8..4ce1f2f 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,9 @@ Install through [HACS](https://hacs.xyz/) | ----------------- | ------- | ------------------- | ------------------------------------------- | | type | string | | `custom:sankey-chart` | autoconfig | object | | Experimental. See [autoconfig](#autoconfig) -| sections | list | | Required unless using autoconfig. Entities to show divided by sections, see [sections object](#sections-object) for additional options. +| nodes | list | | List of entities/nodes to display. See [nodes object](#nodes-object). Required unless using autoconfig. +| links | list | | Connections between nodes. See [links object](#links-object) +| sections | list | | Section-level configuration (sorting, min_width). See [sections object](#sections-object) | layout | string | auto | Valid options are: 'horizontal' - flow left to right, 'vertical' - flow top to bottom & 'auto' - determine based on available space (based on the section->`min_witdh` option, which defaults to 150) | energy_date_selection | boolean | false | Integrate with the Energy Dashboard. Filters data based on the [energy-date-selection](https://www.home-assistant.io/dashboards/energy/) card. Use this only for accumulated data sensors (energy/water/gas) and with a `type:energy-date-selection` card. You still need to specify all your entities as HA doesn't know exactly how to connect them but you can use the general kWh entities that you have in the energy dashboard. In the future we may use areas to auto configure the chart. Not compatible with `time_period` | title | string | | Optional header title for the card @@ -51,45 +53,59 @@ Install through [HACS](https://hacs.xyz/) | time_period_to | string | now | End of custom time period. Not compatible with `energy_date_selection`. See [Time period](#time-period) | ignore_missing_entities | boolean | false | If true, missing entities will be treated as having a state of 0 instead of throwing an error | -### Sections object +### Nodes object | Name | Type | Requirement | Default | Description | | ----------------- | ------- | ------------ | ------------------- | ------------------------------------------- | -| entities | list | **Required** | | Entities to show in this section. Could be just the entity_id as a string or an object, see [entities object](#entities-object) for additional options. Note that the order of this list matters -| sort_by | string | **Optional** | | Sort the entities in this section. Overrides the top level option -| sort_dir | string | **Optional** | desc | Sorting direction for this section. Overrides the top level option -| sort_group_by_parent | boolean | **Optional** | false | Group entities by parent before sorting. See [#135](https://github.com/MindFreeze/ha-sankey-chart/issues/135) -| min_width | number | **Optional** | | Minimum section width in pixels. Only relevant while in horizontal layout - -### Entities object - -| Name | Type | Requirement | Default | Description | -| ----------------- | ------- | ------------ | ------------------- | ------------------------------------------- | -| entity_id | string | **Required** | | Entity id of the sensor +| id | string | **Required** | | Entity id of the sensor +| section | number | **Optional** | | Index of the section this node belongs to (0-based). Determines horizontal/vertical position | attribute | string | **Optional** | | Use the value of an attribute instead of the state of the entity. unit_of_measurement and id will still come from the entity. For more complex customization, please use HA templates. | type | string | **Optional** | entity | Possible values are 'entity', 'passthrough', 'remaining_parent_state', 'remaining_child_state'. See [entity types](#entity-types) -| children | list | **Optional** | | List of entity ids (strings or [childred objects](#children-object)) describing child entities (branches). Only entities in subsequent sections will be connected. *The last section must not contain `children:`* | name | string | **Optional** | entity name from HA | Custom label for this entity | icon | string | **Optional** | entity icon from HA | Custom icon for this entity | unit_of_measurement| string | **Optional** | unit_of_measurement from HA | Custom unit_of_measurement for this entity. Useful when using attribute. If it contains a unit prefix, that must be in latin. Ex GВт, not ГВт -| color | string | **Optional** | var(--primary-color)| Color of the box. Example values: 'red', '#FFAA2C', 'rgb(255, 170, 44)', 'random' (assigns a random RGB color) -| color_on_state | boolean | **Optional** | false | Color the box based on state value -| color_limit | string | **Optional** | 1 | State value for coloring the box based on state value -| color_above | string | **Optional** | var(--state-icon-color)| Color for state value above color_limit -| color_below | string | **Optional** | var(--primary-color)| Color for state value below color_limit -| url | string | **Optional** | | Specifying a URL will make the entity label into a link +| color | string/object | **Optional** | var(--primary-color)| Color of the box. Can be a simple color string ('red', '#FFAA2C', 'rgb(255, 170, 44)', 'random') or a range object for state-based coloring. See [color ranges](#color-ranges) | add_entities | list | **Optional** | | Experimental. List of entity ids. Their states will be added to this entity, showing a sum. | subtract_entities | list | **Optional** | | Experimental. List of entity ids. Their states will be subtracted from this entity's state | tap_action | action | **Optional** | more-info | Home assistant action to perform on tap. Supported action types are `more-info`, `zoom`, `navigate`, `url`, `toggle`, `call-service`, `fire-dom-event`. Ex: `action: zoom` +| double_tap_action | action | **Optional** | | Home assistant action to perform on double tap +| hold_action | action | **Optional** | | Home assistant action to perform on hold | children_sum | object | **Optional** | | [reconcile config](#reconcile-config). Determines how to handle mismatches between parents & children. For example if the sum of the energy from all rooms shouldn't exceed the energy of the whole house. See [#37](https://github.com/MindFreeze/ha-sankey-chart/issues/37) and its related issues | parents_sum | object | **Optional** | | [reconcile config](#reconcile-config). Determines how to handle mismatches between parents & children. For example if the sum of the energy from all rooms shouldn't exceed the energy of the whole house. See [#37](https://github.com/MindFreeze/ha-sankey-chart/issues/37) and its related issues -### Children object +### Links object | Name | Type | Requirement | Default | Description | | -------------------- | ------- | ------------ | ------------------- | ------------------------------------------- | -| entity_id | string | **Required** | | Entity id of the child box -| connection_entity_id | string | **Optional** | | Entity id of the sensor to that determines how much of the parent flows into the child +| source | string | **Required** | | Entity id of the parent/source node +| target | string | **Required** | | Entity id of the child/target node +| value | string | **Optional** | | Entity id of a sensor that determines how much of the parent flows into the child (connection entity) + +### Color ranges + +You can color nodes based on their state value by using an object instead of a simple color string: + +```yaml +nodes: + - id: sensor.temperature + color: + red: + from: 30 # red when >= 30 + orange: + from: 20 + to: 30 # orange when >= 20 and < 30 + green: + to: 20 # green when < 20 +``` + +### Sections object + +| Name | Type | Requirement | Default | Description | +| ----------------- | ------- | ------------ | ------------------- | ------------------------------------------- | +| sort_by | string | **Optional** | | Sort the entities in this section. Overrides the top level option +| sort_dir | string | **Optional** | desc | Sorting direction for this section. Overrides the top level option +| sort_group_by_parent | boolean | **Optional** | false | Group entities by parent before sorting. See [#135](https://github.com/MindFreeze/ha-sankey-chart/issues/135) +| min_width | number | **Optional** | | Minimum section width in pixels. Only relevant while in horizontal layout ### Reconcile config @@ -104,32 +120,38 @@ Install through [HACS](https://hacs.xyz/) - `passthrough` - Used for connecting entities across sections, passing through intermediate sections. The card creates such passtroughs automatically when needed but you can create them manually in order to have the connection pass through a specific place. See issue [#9](https://github.com/MindFreeze/ha-sankey-chart/issues/9). Here is an example passthrough config: ```yaml -- entity_id: sensor.child_sensor - type: passthrough - # Note that passthrough entities have no children as they always connect to their own entity_id in the next section +nodes: + - id: sensor.child_sensor + type: passthrough + # Note that passthrough entities have no children as they always connect to their own id in the next section ``` -- `remaining_parent_state` - Used for representing the unaccounted state from this entity's parent. Formerly known as the `remaining` configuration. Useful for displaying the unmeasured state as "Other". See issue [#2](https://github.com/MindFreeze/ha-sankey-chart/issues/2) & [#28](https://github.com/MindFreeze/ha-sankey-chart/issues/28). Only 1 is allowed per group. If you add 2, the state will not be split between them but an error will appear. Obviously it must be listed in some prior entity's children. Example: +- `remaining_parent_state` - Used for representing the unaccounted state from this entity's parent. Formerly known as the `remaining` configuration. Useful for displaying the unmeasured state as "Other". See issue [#2](https://github.com/MindFreeze/ha-sankey-chart/issues/2) & [#28](https://github.com/MindFreeze/ha-sankey-chart/issues/28). Only 1 is allowed per group. If you add 2, the state will not be split between them but an error will appear. Obviously it must be listed as a target in some link. Example: ```yaml -- entity_id: whatever # as long as it is unique - type: remaining_parent_state - name: Other +nodes: + - id: other_consumption # as long as it is unique + type: remaining_parent_state + name: Other ``` - `remaining_child_state` - Used for representing the unaccounted state in this entity's children. Like `remaining_parent_state` but in reverse. Useful for displaying discrepancies where the children add up to more than the parent. See issue [#2](https://github.com/MindFreeze/ha-sankey-chart/issues/2) & [#15](https://github.com/MindFreeze/ha-sankey-chart/issues/15). Example: ```yaml -- entity_id: whatever # as long as it is unique - type: remaining_child_state - name: Discrepancy - children: - # the relevant child entities +nodes: + - id: discrepancy # as long as it is unique + type: remaining_child_state + name: Discrepancy +links: + - source: discrepancy + target: sensor.child1 + - source: discrepancy + target: sensor.child2 ``` ### Autoconfig -This card supports automatic configuration generation based on the HA energy dashboard. It will set default values for some config parameters and populate the `sections` param. This is meant to show energy data and assumes you have configured your [Energy Dashboard in HA](https://my.home-assistant.io/redirect/config_energy). Use it like this: +This card supports automatic configuration generation based on the HA energy dashboard. It will set default values for some config parameters and populate the `nodes` and `links` arrays. This is meant to show energy data and assumes you have configured your [Energy Dashboard in HA](https://my.home-assistant.io/redirect/config_energy). Use it like this: ```yaml - type: energy-date-selection # you can put this anywhere you want but it is required for energy dashboard integration @@ -202,15 +224,18 @@ time_period_to: "now/d" ```yaml - type: custom:sankey-chart show_names: true - sections: - - entities: - - entity_id: sensor.power - children: - - sensor.washing_machine_power - - sensor.other_power - - entities: - - sensor.washing_machine_power - - sensor.other_power + nodes: + - id: sensor.power + section: 0 + - id: sensor.washing_machine_power + section: 1 + - id: sensor.other_power + section: 1 + links: + - source: sensor.power + target: sensor.washing_machine_power + - source: sensor.power + target: sensor.other_power ``` ### Energy use @@ -223,46 +248,65 @@ time_period_to: "now/d" unit_prefix: k round: 1 wide: true - sections: - - entities: - - entity_id: sensor.solar - color: var(--warning-color) - children: - - sensor.total_energy - - entity_id: sensor.grid - children: - - sensor.total_energy - - entity_id: sensor.battery - color: var(--success-color) - children: - - sensor.total_energy - - entities: - - entity_id: sensor.total_energy - children: - - sensor.floor1 - - sensor.floor2 - - sensor.garage - - entities: - - entity_id: sensor.garage - color: purple - children: - - sensor.ev_charger - - garage_other - - entity_id: sensor.floor1 - children: - - sensor.living_room - - entity_id: sensor.washer - connection_entity_id: sensor.washer_energy_net - - entity_id: sensor.floor2 - - entities: - - entity_id: sensor.ev_charger - tap_action: - action: toggle - - entity_id: garage_other - type: remaining_parent_state - name: Other - - sensor.living_room - - sensor.washer + nodes: + # Section 0 - Sources + - id: sensor.solar + section: 0 + color: var(--warning-color) + - id: sensor.grid + section: 0 + - id: sensor.battery + section: 0 + color: var(--success-color) + # Section 1 - Total + - id: sensor.total_energy + section: 1 + # Section 2 - Distribution + - id: sensor.garage + section: 2 + color: purple + - id: sensor.floor1 + section: 2 + - id: sensor.floor2 + section: 2 + # Section 3 - End consumers + - id: sensor.ev_charger + section: 3 + tap_action: + action: toggle + - id: garage_other + section: 3 + type: remaining_parent_state + name: Other + - id: sensor.living_room + section: 3 + - id: sensor.washer + section: 3 + links: + # Sources -> Total + - source: sensor.solar + target: sensor.total_energy + - source: sensor.grid + target: sensor.total_energy + - source: sensor.battery + target: sensor.total_energy + # Total -> Distribution + - source: sensor.total_energy + target: sensor.floor1 + - source: sensor.total_energy + target: sensor.floor2 + - source: sensor.total_energy + target: sensor.garage + # Distribution -> End consumers + - source: sensor.garage + target: sensor.ev_charger + - source: sensor.garage + target: garage_other + - source: sensor.floor1 + target: sensor.living_room + - source: sensor.floor1 + target: sensor.washer + value: sensor.washer_energy_net # connection entity ``` ### Reconcile state @@ -272,18 +316,21 @@ Example config where the state of the children must not exceed their parent. `re ```yaml - type: custom:sankey-chart show_names: true - sections: - - entities: - - entity_id: sensor.power - children_sum: - should_be: equal_or_less - reconcile_to: max - children: - - sensor.washing_machine_power - - sensor.other_power - - entities: - - sensor.washing_machine_power - - sensor.other_power + nodes: + - id: sensor.power + section: 0 + children_sum: + should_be: equal_or_less + reconcile_to: max + - id: sensor.washing_machine_power + section: 1 + - id: sensor.other_power + section: 1 + links: + - source: sensor.power + target: sensor.washing_machine_power + - source: sensor.power + target: sensor.other_power ``` You can find more examples and help in the HA forum @@ -296,6 +343,10 @@ Currently this chart just shows historical data based on a energy-date-selection ## FAQ +**Q: How do I migrate my config from the old format (v3) to the new format (v4)?** + +**A:** See the [Migration Guide](MIGRATION.md) for step-by-step instructions on converting your configuration. + **Q: Do my entities need to be added to the energy dashboard first?** **A:** This card doesn't know/care if an entity is in the energy dashboard. Unless you use `autoconfig` because that relies entirely on the energy dashboard. From 7ea8ba282a97e318bf2f9d197a55706cbbc7665b Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Wed, 28 Jan 2026 14:58:40 +0200 Subject: [PATCH 08/10] docs: Add CLAUDE.md --- CLAUDE.md | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..cc9c32e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,68 @@ +# CLAUDE.md + +## Project Overview + +**ha-sankey-chart** is a Home Assistant Lovelace custom card that visualizes energy/power/water flows using Sankey diagrams. It shows connections between entities (sources → consumers) in an interactive chart. + +## Tech Stack + +- **Lit 2.8** - Web component framework +- **TypeScript** - Primary language +- **Rollup** - Module bundler +- **Jest** - Testing framework + +## Commands + +```bash +npm start # Dev server with watch (http://127.0.0.1:3000/ha-sankey-chart.js) +npm run build # Lint + production build +npm run lint # ESLint check +npm test # Run Jest tests +``` + +## Project Structure + +``` +src/ +├── ha-sankey-chart.ts # Main card entry point +├── chart.ts # Chart rendering component +├── types.ts # TypeScript type definitions +├── utils.ts # Utility functions +├── energy.ts # Energy dashboard integration +├── migrate.ts # V3→V4 config migration +├── editor/ # Visual card editor components +└── localize/ # i18n translations +__tests__/ # Jest test files +dist/ # Build output +``` + +## Architecture + +- **Lit decorators**: `@customElement`, `@property`, `@state` +- **Main components**: `sankey-chart` (card), `sankey-chart-base` (renderer), `sankey-chart-editor` +- **Config format (v4)**: Flat `nodes[]` array + `links[]` connections + `sections[]` settings +- **State management**: `SubscribeMixin` for Home Assistant real-time subscriptions + +## Key Patterns + +1. **Config normalization**: `normalizeConfig()` auto-migrates v3→v4 format +2. **State values**: `normalizeStateValue()` handles unit prefixes (m, k, M, G, T) +3. **Entity types**: `entity`, `passthrough`, `remaining_parent_state`, `remaining_child_state` +4. **Actions**: tap/hold/double-tap via `handleAction()` from custom-card-helpers + +## Testing + +Tests in `__tests__/*.test.ts` use mock Home Assistant objects from `__mocks__/hass.mock.ts`. Run `npm test` before committing. + +## Git Workflow + +- **Main branch**: `master` (stable releases) +- **Current branch**: `v4` (new config format) + +## Key Files for Common Tasks + +- **Add new config option**: [types.ts](src/types.ts), [ha-sankey-chart.ts](src/ha-sankey-chart.ts) +- **Modify rendering**: [chart.ts](src/chart.ts), [section.ts](src/section.ts) +- **Update editor UI**: [editor/](src/editor/) +- **Change styling**: [styles.ts](src/styles.ts) +- **Fix state calculations**: [utils.ts](src/utils.ts) From e34211cde5f5bbfe36498dcdb85e0cfbf75f2a71 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Wed, 28 Jan 2026 14:59:35 +0200 Subject: [PATCH 09/10] fix: update editor --- src/editor/entity.ts | 11 ----------- src/editor/index.ts | 5 ++--- src/editor/section.ts | 1 + src/utils.ts | 7 ++++++- 4 files changed, 9 insertions(+), 15 deletions(-) diff --git a/src/editor/entity.ts b/src/editor/entity.ts index 403e282..696e180 100644 --- a/src/editor/entity.ts +++ b/src/editor/entity.ts @@ -41,17 +41,6 @@ const computeSchema = (nodeConf: NodeConfigForEditor, icon: string) => [ ], }, { name: 'tap_action', selector: { 'ui-action': {} } }, - // { - // name: 'children_sum.should_be', - // selector: { - // select: { - // mode: 'dropdown', - // options: [ - // { value: 'entity', label: localize('editor.entity_types.entity') }, - // ], - // }, - // }, - // }, ]; @customElement('sankey-chart-entity-editor') diff --git a/src/editor/index.ts b/src/editor/index.ts index 27870e1..f924f8a 100644 --- a/src/editor/index.ts +++ b/src/editor/index.ts @@ -405,8 +405,7 @@ export class SankeyChartEditor extends LitElement implements LovelaceCardEditor `, )} - ({ ...conf, sections: [...(conf.sections || []), {}] // Add empty section config, not entities @@ -414,7 +413,7 @@ export class SankeyChartEditor extends LitElement implements LovelaceCardEditor @click=${this._valueChanged} > ${localize('editor.add_section')} - +
`; diff --git a/src/editor/section.ts b/src/editor/section.ts index ccb919a..3546473 100644 --- a/src/editor/section.ts +++ b/src/editor/section.ts @@ -154,6 +154,7 @@ class SankeyChartSectionEditor extends LitElement { .entity ha-entity-picker { flex-grow: 1; + min-width: 0; } .edit-icon { diff --git a/src/utils.ts b/src/utils.ts index d5f4315..e13ee0e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -212,7 +212,12 @@ export function convertNodesToSections(nodes: Node[], links: Link[], sectionConf }); // Convert to sections, sorted by section index - const sectionIndices = Array.from(nodesBySection.keys()).sort((a, b) => a - b); + // Include both sections with nodes AND empty sections defined in sectionConfigs + const nodeIndices = Array.from(nodesBySection.keys()); + const configIndices = sectionConfigs ? sectionConfigs.map((_, i) => i) : []; + const allIndices = new Set([...nodeIndices, ...configIndices]); + const sectionIndices = Array.from(allIndices).sort((a, b) => a - b); + const sections: Section[] = sectionIndices.map(sectionIndex => { // Get section config if available, otherwise use empty config const sectionConfig = sectionConfigs?.[sectionIndex] || {}; From 764c7b5747c76f9ed170f26c07ea8c9fc86fbfbb Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Wed, 28 Jan 2026 15:11:13 +0200 Subject: [PATCH 10/10] editor fix --- src/editor/index.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/editor/index.ts b/src/editor/index.ts index f924f8a..590ebed 100644 --- a/src/editor/index.ts +++ b/src/editor/index.ts @@ -5,7 +5,7 @@ import { HomeAssistant, fireEvent, LovelaceCardEditor, LovelaceConfig } from 'cu // eslint-disable-next-line @typescript-eslint/no-unused-vars import { customElement, property, state } from 'lit/decorators'; import { repeat } from 'lit/directives/repeat'; -import { SankeyChartConfig, Section, Node } from '../types'; +import { SankeyChartConfig, Section, SectionConfig, Node } from '../types'; import { localize } from '../localize/localize'; import { normalizeConfig, convertNodesToSections } from '../utils'; import './section'; @@ -92,7 +92,15 @@ export class SankeyChartEditor extends LitElement implements LovelaceCardEditor ev.detail.value.time_period_from = undefined; ev.detail.value.time_period_to = undefined; } - this._config = { ...ev.detail.value }; + // Preserve original nodes, links, and sections (don't use normalized values which include entities) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { nodes, links, sections, ...otherValues } = ev.detail.value; + this._config = { + ...otherValues, + nodes: this._config!.nodes, + links: this._config!.links, + sections: this._config!.sections, + }; } this._updateConfig(); } @@ -206,9 +214,13 @@ export class SankeyChartEditor extends LitElement implements LovelaceCardEditor }; private _handleSectionChange = (index: number, sectionConf: Section): void => { + // Only extract SectionConfig properties, not the entities array + const { sort_by, sort_dir, sort_group_by_parent, min_width } = sectionConf; + const sectionConfigOnly: SectionConfig = { sort_by, sort_dir, sort_group_by_parent, min_width }; + this._config = { ...this._config!, - sections: this._config?.sections?.map((section, i) => (i === index ? sectionConf : section)), + sections: this._config?.sections?.map((section, i) => (i === index ? sectionConfigOnly : section)), }; this._updateConfig(); }; @@ -348,6 +360,10 @@ export class SankeyChartEditor extends LitElement implements LovelaceCardEditor const newConf = { ...conf }; if (val && !conf.autoconfig) { newConf.autoconfig = { print_yaml: false }; + // Clear manual config when enabling autoconfig + newConf.nodes = []; + newConf.links = []; + newConf.sections = []; } else if (!val && conf.autoconfig) { delete newConf.autoconfig; }