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) 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. 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/__tests__/autoconfig.test.ts b/__tests__/autoconfig.test.ts index d773517..a26e8e6 100644 --- a/__tests__/autoconfig.test.ts +++ b/__tests__/autoconfig.test.ts @@ -58,14 +58,14 @@ describe('SankeyChart autoconfig', () => { await (sankeyChart as any)['autoconfig'](); // eslint-disable-next-line @typescript-eslint/no-explicit-any const config = (sankeyChart as any).config; - const allEntities = config.sections.flatMap((s: { entities: { entity_id: string }[] }) => s.entities); - const gridExport = allEntities.find((e: { entity_id: string }) => e.entity_id === 'sensor.grid_out'); + const gridExport = config.nodes.find((n: { id: string }) => n.id === 'sensor.grid_out'); expect(gridExport).toBeDefined(); expect(gridExport.subtract_entities).toEqual(['sensor.grid_in']); - // all source entities should have grid export as a child - const sourceEntities = config.sections[0].entities; - sourceEntities.forEach((e: { children: string[] }) => { - expect(e.children).toContain('sensor.grid_out'); + // all source entities should link to grid export + const sourceNodes = config.nodes.filter((n: { id: string }) => ['sensor.grid_in', 'sensor.solar'].includes(n.id)); + sourceNodes.forEach((n: { id: string }) => { + const link = config.links.find((l: { source: string; target: string }) => l.source === n.id && l.target === 'sensor.grid_out'); + expect(link).toBeDefined(); }); }); @@ -93,14 +93,14 @@ describe('SankeyChart autoconfig', () => { await (sankeyChart as any)['autoconfig'](); // eslint-disable-next-line @typescript-eslint/no-explicit-any const config = (sankeyChart as any).config; - const allEntities = config.sections.flatMap((s: { entities: { entity_id: string }[] }) => s.entities); - const gridExport = allEntities.find((e: { entity_id: string }) => e.entity_id === 'sensor.grid_out'); + const gridExport = config.nodes.find((n: { id: string }) => n.id === 'sensor.grid_out'); expect(gridExport).toBeDefined(); expect(gridExport.subtract_entities).toEqual(['sensor.grid_in']); - // all source entities should have grid export as a child - const sourceEntities = config.sections[0].entities; - sourceEntities.forEach((e: { children: string[] }) => { - expect(e.children).toContain('sensor.grid_out'); + // all source entities should link to grid export + const sourceNodes = config.nodes.filter((n: { id: string }) => ['sensor.grid_in', 'sensor.solar'].includes(n.id)); + sourceNodes.forEach((n: { id: string }) => { + const link = config.links.find((l: { source: string; target: string }) => l.source === n.id && l.target === 'sensor.grid_out'); + expect(link).toBeDefined(); }); }); @@ -125,11 +125,10 @@ describe('SankeyChart autoconfig', () => { await (sankeyChart as any)['autoconfig'](); // eslint-disable-next-line @typescript-eslint/no-explicit-any const config = (sankeyChart as any).config; - const allEntities = config.sections.flatMap((s: { entities: { entity_id: string }[] }) => s.entities); - const gridExport = allEntities.find((e: { entity_id: string }) => e.entity_id === 'sensor.grid_out'); + const gridExport = config.nodes.find((n: { id: string }) => n.id === 'sensor.grid_out'); expect(gridExport).toBeDefined(); expect(gridExport.subtract_entities).toBeUndefined(); - const gridImport = allEntities.find((e: { entity_id: string }) => e.entity_id === 'sensor.grid_in'); + const gridImport = config.nodes.find((n: { id: string }) => n.id === 'sensor.grid_in'); expect(gridImport.subtract_entities).toBeUndefined(); }); @@ -153,9 +152,24 @@ 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)); + 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__/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/__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 c106a12..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), @@ -347,30 +347,40 @@ 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; + } } } 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 ed3dc02..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, @@ -14,10 +14,13 @@ 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 +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 471c5f4..696e180 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 { 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,36 +41,14 @@ 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', - // selector: { - // select: { - // mode: 'dropdown', - // options: [ - // { value: 'entity', label: localize('editor.entity_types.entity') }, - // ], - // }, - // }, - // }, ]; @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 +70,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 +91,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 c24f4ba..590ebed 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, SectionConfig, Node } 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 { NodeConfigOrStr } from '../types'; import { UNIT_PREFIXES } from '../const'; +import { migrateV3Config } from '../migrate'; @customElement('sankey-chart-editor') export class SankeyChartEditor extends LitElement implements LovelaceCardEditor { @@ -19,15 +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 { - 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(); @@ -79,29 +92,34 @@ 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(); } 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: SectionConfig[] = this._config?.sections || []; + 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 = ''; @@ -110,52 +128,105 @@ 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: SectionConfig[] = this._config?.sections || []; + + 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]; + if (!nodeInternal) return; + 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 || !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: SectionConfig[] = this._config?.sections || []; - 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: SectionConfig): void => { + 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(); }; private _updateConfig(): void { + // Config is already in V4 format - save directly fireEvent(this, 'config-changed', { config: this._config }); } @@ -263,7 +334,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 = this._getSections(); if (this._entityConfig) { return html` @@ -289,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; } @@ -325,7 +400,7 @@ export class SankeyChartEditor extends LitElement implements LovelaceCardEditor `; } - private _renderSections(sections: SectionConfig[]): TemplateResult { + private _renderSections(sections: Section[]): TemplateResult { return html`

${localize('editor.sections')}

@@ -346,13 +421,15 @@ export class SankeyChartEditor extends LitElement implements LovelaceCardEditor `, )} - ({ ...conf, sections: [...conf.sections, { entities: [] }] })} + ({ + ...conf, + sections: [...(conf.sections || []), {}] // Add empty section config, not entities + })} @click=${this._valueChanged} > ${localize('editor.add_section')} - +
`; @@ -383,3 +460,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/editor/section.ts b/src/editor/section.ts index 175b854..3546473 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; @@ -153,6 +154,7 @@ class SankeyChartSectionEditor extends LitElement { .entity ha-entity-picker { flex-grow: 1; + min-width: 0; } .edit-icon { diff --git a/src/ha-sankey-chart.ts b/src/ha-sankey-chart.ts index 23152ff..0822756 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'; @@ -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) { @@ -185,27 +185,23 @@ 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.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); + } }); } @@ -257,39 +253,48 @@ 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.type === 'grid' || source.type === 'battery') && !netFlows + ? undefined + : 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.type === 'grid' || source.type === 'battery') && !netFlows - ? undefined - : 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 gridSources = sources.filter(s => s.type === 'grid'); const seenFlowTo = new Set(); gridSources.forEach(grid => { @@ -298,38 +303,44 @@ class SankeyChart extends SubscribeMixin(LitElement) { const importEntities = grid.flow_from?.map(e => e.stat_energy_from) ?? (grid.stat_energy_from ? [grid.stat_energy_from] : []); if (exportEntities.length) { - // grid export exportEntities.forEach(stat_energy_to => { - if (seenFlowTo.has(stat_energy_to)) return; + if (!stat_energy_to || seenFlowTo.has(stat_energy_to)) return; seenFlowTo.add(stat_energy_to); - sections[1].entities.unshift({ - entity_id: stat_energy_to, - subtract_entities: netFlows ? importEntities : undefined, + nodes.push({ + id: stat_energy_to, + section: currentSection, type: 'entity', + name: '', + subtract_entities: netFlows ? importEntities : undefined, 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: netFlows ? [battery.stat_energy_from] : undefined, + nodes.push({ + id: battery.stat_energy_to, + section: currentSection, type: 'entity', + name: '', + subtract_entities: netFlows ? [battery.stat_energy_from] : undefined, 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; @@ -340,85 +351,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/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/migrate.ts b/src/migrate.ts new file mode 100644 index 0000000..b24490e --- /dev/null +++ b/src/migrate.ts @@ -0,0 +1,161 @@ +import { LovelaceCardConfig } from 'custom-card-helpers'; +import { CONVERSION_UNITS, UNIT_PREFIXES } from './const'; +import type { ActionConfigExtended, NodeType, ChildConfigOrStr, ReconcileConfig, SankeyChartConfig, SectionConfig } from './types'; + +export interface V3Config extends LovelaceCardConfig { + type: string; + autoconfig?: { + print_yaml?: boolean; + group_by_floor?: boolean; + group_by_area?: boolean; + }; + title?: string; + sections?: V3SectionConfig[]; + 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; +} + +export interface V3SectionConfig { + 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 { + 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 { + ...configWithoutSections, + nodes, + links, + sections, + }; +} diff --git a/src/section.ts b/src/section.ts index dcf3c06..69f45bc 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 => { @@ -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 diff --git a/src/types.ts b/src/types.ts index 88bbed3..f4844fc 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'; @@ -22,11 +20,16 @@ export const DEFAULT_CONFIG: Config = { min_state: 0, show_states: true, 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; @@ -34,7 +37,6 @@ export interface SankeyChartConfig extends LovelaceCardConfig { net_flows?: boolean; }; title?: string; - sections?: SectionConfig[]; convert_units_to?: '' | CONVERSION_UNITS; co2_intensity_entity?: string; gas_co2_intensity?: number; @@ -63,31 +65,27 @@ export interface SankeyChartConfig extends LovelaceCardConfig { ignore_missing_entities?: boolean; } -declare global { - interface HTMLElementTagNameMap { - 'sankey-chart-editor': LovelaceCardEditor; - 'hui-error-card': LovelaceCard; - } -} - -export type BoxType = 'entity' | 'passthrough' | 'remaining_parent_state' | 'remaining_child_state'; - -export interface EntityConfig { - entity_id: string; - add_entities?: string[]; - subtract_entities?: string[]; +export interface Node { + id: string; + section?: number; // index in sections array + type: NodeType + name?: string; attribute?: string; - type?: BoxType; - children?: ChildConfigOrStr[]; unit_of_measurement?: string; // for attribute - color?: string; - name?: string; + add_entities?: string[]; // temporary - will be replaced + subtract_entities?: string[]; // temporary - will be replaced + 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; @@ -95,13 +93,29 @@ export interface EntityConfig { parents_sum?: ReconcileConfig; } -export type EntityConfigInternal = EntityConfig & { +export interface Link { + source: string; + target: string; + value?: string; // optional connection entity +} + +export type NodeType = 'entity' | 'passthrough' | 'remaining_parent_state' | 'remaining_child_state'; + +export interface NodeInternal extends Node { children: ChildConfigOrStr[]; accountedState?: number; foundChildren?: string[]; -}; +} -export type EntityConfigOrStr = string | EntityConfig; +// 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; @@ -142,7 +156,6 @@ export interface ReconcileConfig { } export interface SectionConfig { - entities: EntityConfigOrStr[]; sort_by?: 'none' | 'state'; sort_dir?: 'asc' | 'desc'; sort_group_by_parent?: boolean; @@ -150,7 +163,7 @@ export interface SectionConfig { } export interface Section { - entities: EntityConfigInternal[]; + entities: NodeInternal[]; sort_by?: 'none' | 'state'; sort_dir?: 'asc' | 'desc'; sort_group_by_parent?: boolean; @@ -165,7 +178,9 @@ export interface Config extends SankeyChartConfig { min_box_size: number; min_box_distance: number; min_state: number; - sections: Section[]; + nodes: Node[]; + links: Link[]; + sections: Section[]; // calculated from nodes/links by depth } export interface Connection { @@ -180,11 +195,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[]; @@ -208,15 +223,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..e13ee0e 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,71 @@ 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 + // 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] || {}; + + 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 +248,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 +263,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 +281,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 +300,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; 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,