Compare commits

...

155 commits

Author SHA1 Message Date
dependabot[bot]
ec2b29e814
chore(deps): bump the react group in /docs/developer with 2 updates (#138)
Some checks failed
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Has been cancelled
2026-05-08 23:20:58 +02:00
dependabot[bot]
ab5bf92729
chore(deps): bump the react group in /docs/user with 2 updates (#137) 2026-05-08 23:20:48 +02:00
Julian Pawlowski
0df089cc11 chore(release): bump version to 0.31.0b4
Some checks failed
Auto-Tag on Version Bump / Check and create version tag (push) Has been cancelled
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Has been cancelled
Lint / Ruff (push) Has been cancelled
Validate / Hassfest validation (push) Has been cancelled
Validate / HACS validation (push) Has been cancelled
2026-05-03 22:19:02 +00:00
Julian Pawlowski
1f74451adf chore(blueprints): disable automatic blueprint installation
Blueprints are kept in the repository for development but are not yet
ready for distribution. Commented out the install and remove calls in
async_setup and async_remove_entry so they are not copied to the user's
HA config directory on integration setup.

Release-Notes: skip
User-Impact: none
2026-05-03 22:17:02 +00:00
Julian Pawlowski
c2ff9cd2f2 fix(blueprints): fix home_connect_alt service call and correct sensor description
Two fixes in all 4 home_connect_alt blueprints:

1. home_connect_alt.start_program uses its own `device_id` field (not HA's
   standard target mechanism) and `program_key` (not `program`). The
   previous `target: entity_id:` was silently ignored, causing the service
   call to fail due to missing required `device_id`. Fixed by:
   - Removing `target: entity_id:` block
   - Adding `data.device_id: "{{ device_id(program_entity) }}"`
   - Renaming `program:` to `program_key:`
   - Adding `| int` filter to numeric option values

2. Same remote_start_sensor description fix as the non-alt variants
   (RemoteControlActive → RemoteControlStartAllowed).

Also reset blueprint version from v2.0.0 to v1.0.0.

Release-Notes: skip
Released-Bug: no
2026-05-03 22:16:56 +00:00
Julian Pawlowski
95d0278241 fix(blueprints): correct remote_start_sensor description in home_connect blueprints
The input field described the wrong binary sensor entity. The automation
correctly triggers on `RemoteControlStartAllowed`, but the label and
description still referenced `RemoteControlActive`.

Updated all 4 home_connect (non-alt) blueprints:
- dishwasher_home_connect.yaml
- washing_machine_home_connect.yaml
- dryer_home_connect.yaml
- laundry_day_pipeline_home_connect.yaml

Also reset blueprint version from v2.0.0 to v1.0.0 (version was bumped
prematurely, blueprints not yet released).

Release-Notes: skip
Released-Bug: no
2026-05-03 22:16:46 +00:00
Julian Pawlowski
b93eedf00e feat(services): add power-profile-weighted window selection
Add `include_current_interval` parameter to `find_cheapest_block` and
`find_cheapest_schedule` services, controlling whether the currently
active price interval can be the start of the selected window.

Add power-profile weighting to `find_cheapest_contiguous_window`: accepts
an optional `power_profile` list that weights each interval's price by
relative power draw (e.g. heat-up phase heavier than steady state). Without
a profile the behaviour is unchanged (uniform weighting).

Extend search-range tests and add price-window unit tests covering weighted
and unweighted scenarios, edge cases, and sequential scheduling interactions.
Update scheduling-actions documentation with parameter and profile examples.

Impact: Users can now model appliances with non-uniform power draw (e.g. heat
pumps, washing machines) to find truly cheapest windows based on actual energy
cost rather than average price.
2026-05-03 22:16:08 +00:00
Julian Pawlowski
ba08bd34c6 chore(periods): house keeping 2026-05-03 19:43:31 +00:00
Julian Pawlowski
9cb5b35184 fix(periods): separate smoothed levels from period detection
Keep raw Tibber API levels for best/peak period filtering while leaving smoothed levels in priceInfo for display-oriented sensors. Also make relaxation retry each flex step with the configured level filter before falling back to level_filter="any", and add regression tests for both paths.

Impact: Best-price periods no longer extend into expensive intervals because of level smoothing, and adjacent best/peak windows stay separated as expected.
2026-05-03 19:40:34 +00:00
Julian Pawlowski
dc4933ec5c feat(services): add price_source parameter to get_chartdata and get_apexcharts_yaml
Add a `price_source` field (total | energy | tax, default: total) to both
services, allowing users to choose which price component is used as the
primary chart series.

- get_chartdata: all 9 interval.get("total") calls now use price_source
- get_apexcharts_yaml: price_source forwarded through all 4 JS
  data_generator calls; yaxis template variables resolve to
  yaxis_min_energy / yaxis_min_tax when price_source != "total"
- Metadata-only path: always computes yaxis_suggested_energy and
  yaxis_suggested_tax alongside the main yaxis bounds so the
  chart_metadata sensor can expose the correct axis scale for any source
- chart_metadata sensor: exposes yaxis_min_energy, yaxis_max_energy,
  yaxis_min_tax, yaxis_max_tax as new attributes
- services.yaml + all 5 language files (en, de, nb, nl, sv): price_source
  field and selector options added

Impact: Users can now chart the raw energy (spot) price or the tax component separately, with correct Y-axis scaling in ApexCharts.

Co-authored-by: Copilot <copilot@github.com>
2026-05-03 18:48:36 +00:00
dependabot[bot]
75d7e20a22
chore(deps): bump the docusaurus group in /docs/user with 7 updates (#133)
Some checks failed
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Has been cancelled
Bumps the docusaurus group in /docs/user with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [@docusaurus/core](https://github.com/facebook/docusaurus/tree/HEAD/packages/docusaurus) | `3.10.0` | `3.10.1` |
| [@docusaurus/faster](https://github.com/facebook/docusaurus/tree/HEAD/packages/docusaurus-faster) | `3.10.0` | `3.10.1` |
| [@docusaurus/preset-classic](https://github.com/facebook/docusaurus/tree/HEAD/packages/docusaurus-preset-classic) | `3.10.0` | `3.10.1` |
| [@docusaurus/theme-mermaid](https://github.com/facebook/docusaurus/tree/HEAD/packages/docusaurus-theme-mermaid) | `3.10.0` | `3.10.1` |
| [@docusaurus/module-type-aliases](https://github.com/facebook/docusaurus/tree/HEAD/packages/docusaurus-module-type-aliases) | `3.10.0` | `3.10.1` |
| [@docusaurus/tsconfig](https://github.com/facebook/docusaurus/tree/HEAD/packages/docusaurus-tsconfig) | `3.10.0` | `3.10.1` |
| [@docusaurus/types](https://github.com/facebook/docusaurus/tree/HEAD/packages/docusaurus-types) | `3.10.0` | `3.10.1` |


Updates `@docusaurus/core` from 3.10.0 to 3.10.1
- [Release notes](https://github.com/facebook/docusaurus/releases)
- [Changelog](https://github.com/facebook/docusaurus/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/docusaurus/commits/v3.10.1/packages/docusaurus)

Updates `@docusaurus/faster` from 3.10.0 to 3.10.1
- [Release notes](https://github.com/facebook/docusaurus/releases)
- [Changelog](https://github.com/facebook/docusaurus/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/docusaurus/commits/v3.10.1/packages/docusaurus-faster)

Updates `@docusaurus/preset-classic` from 3.10.0 to 3.10.1
- [Release notes](https://github.com/facebook/docusaurus/releases)
- [Changelog](https://github.com/facebook/docusaurus/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/docusaurus/commits/v3.10.1/packages/docusaurus-preset-classic)

Updates `@docusaurus/theme-mermaid` from 3.10.0 to 3.10.1
- [Release notes](https://github.com/facebook/docusaurus/releases)
- [Changelog](https://github.com/facebook/docusaurus/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/docusaurus/commits/v3.10.1/packages/docusaurus-theme-mermaid)

Updates `@docusaurus/module-type-aliases` from 3.10.0 to 3.10.1
- [Release notes](https://github.com/facebook/docusaurus/releases)
- [Changelog](https://github.com/facebook/docusaurus/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/docusaurus/commits/v3.10.1/packages/docusaurus-module-type-aliases)

Updates `@docusaurus/tsconfig` from 3.10.0 to 3.10.1
- [Release notes](https://github.com/facebook/docusaurus/releases)
- [Changelog](https://github.com/facebook/docusaurus/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/docusaurus/commits/v3.10.1/packages/docusaurus-tsconfig)

Updates `@docusaurus/types` from 3.10.0 to 3.10.1
- [Release notes](https://github.com/facebook/docusaurus/releases)
- [Changelog](https://github.com/facebook/docusaurus/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/docusaurus/commits/v3.10.1/packages/docusaurus-types)

---
updated-dependencies:
- dependency-name: "@docusaurus/core"
  dependency-version: 3.10.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: docusaurus
- dependency-name: "@docusaurus/faster"
  dependency-version: 3.10.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: docusaurus
- dependency-name: "@docusaurus/preset-classic"
  dependency-version: 3.10.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: docusaurus
- dependency-name: "@docusaurus/theme-mermaid"
  dependency-version: 3.10.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: docusaurus
- dependency-name: "@docusaurus/module-type-aliases"
  dependency-version: 3.10.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: docusaurus
- dependency-name: "@docusaurus/tsconfig"
  dependency-version: 3.10.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: docusaurus
- dependency-name: "@docusaurus/types"
  dependency-version: 3.10.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: docusaurus
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-02 12:05:26 +02:00
dependabot[bot]
fa6342cf72
chore(deps): bump the docusaurus group in /docs/developer with 7 updates (#132)
Bumps the docusaurus group in /docs/developer with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [@docusaurus/core](https://github.com/facebook/docusaurus/tree/HEAD/packages/docusaurus) | `3.10.0` | `3.10.1` |
| [@docusaurus/faster](https://github.com/facebook/docusaurus/tree/HEAD/packages/docusaurus-faster) | `3.10.0` | `3.10.1` |
| [@docusaurus/preset-classic](https://github.com/facebook/docusaurus/tree/HEAD/packages/docusaurus-preset-classic) | `3.10.0` | `3.10.1` |
| [@docusaurus/theme-mermaid](https://github.com/facebook/docusaurus/tree/HEAD/packages/docusaurus-theme-mermaid) | `3.10.0` | `3.10.1` |
| [@docusaurus/module-type-aliases](https://github.com/facebook/docusaurus/tree/HEAD/packages/docusaurus-module-type-aliases) | `3.10.0` | `3.10.1` |
| [@docusaurus/tsconfig](https://github.com/facebook/docusaurus/tree/HEAD/packages/docusaurus-tsconfig) | `3.10.0` | `3.10.1` |
| [@docusaurus/types](https://github.com/facebook/docusaurus/tree/HEAD/packages/docusaurus-types) | `3.10.0` | `3.10.1` |


Updates `@docusaurus/core` from 3.10.0 to 3.10.1
- [Release notes](https://github.com/facebook/docusaurus/releases)
- [Changelog](https://github.com/facebook/docusaurus/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/docusaurus/commits/v3.10.1/packages/docusaurus)

Updates `@docusaurus/faster` from 3.10.0 to 3.10.1
- [Release notes](https://github.com/facebook/docusaurus/releases)
- [Changelog](https://github.com/facebook/docusaurus/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/docusaurus/commits/v3.10.1/packages/docusaurus-faster)

Updates `@docusaurus/preset-classic` from 3.10.0 to 3.10.1
- [Release notes](https://github.com/facebook/docusaurus/releases)
- [Changelog](https://github.com/facebook/docusaurus/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/docusaurus/commits/v3.10.1/packages/docusaurus-preset-classic)

Updates `@docusaurus/theme-mermaid` from 3.10.0 to 3.10.1
- [Release notes](https://github.com/facebook/docusaurus/releases)
- [Changelog](https://github.com/facebook/docusaurus/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/docusaurus/commits/v3.10.1/packages/docusaurus-theme-mermaid)

Updates `@docusaurus/module-type-aliases` from 3.10.0 to 3.10.1
- [Release notes](https://github.com/facebook/docusaurus/releases)
- [Changelog](https://github.com/facebook/docusaurus/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/docusaurus/commits/v3.10.1/packages/docusaurus-module-type-aliases)

Updates `@docusaurus/tsconfig` from 3.10.0 to 3.10.1
- [Release notes](https://github.com/facebook/docusaurus/releases)
- [Changelog](https://github.com/facebook/docusaurus/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/docusaurus/commits/v3.10.1/packages/docusaurus-tsconfig)

Updates `@docusaurus/types` from 3.10.0 to 3.10.1
- [Release notes](https://github.com/facebook/docusaurus/releases)
- [Changelog](https://github.com/facebook/docusaurus/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/docusaurus/commits/v3.10.1/packages/docusaurus-types)

---
updated-dependencies:
- dependency-name: "@docusaurus/core"
  dependency-version: 3.10.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: docusaurus
- dependency-name: "@docusaurus/faster"
  dependency-version: 3.10.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: docusaurus
- dependency-name: "@docusaurus/preset-classic"
  dependency-version: 3.10.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: docusaurus
- dependency-name: "@docusaurus/theme-mermaid"
  dependency-version: 3.10.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: docusaurus
- dependency-name: "@docusaurus/module-type-aliases"
  dependency-version: 3.10.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: docusaurus
- dependency-name: "@docusaurus/tsconfig"
  dependency-version: 3.10.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: docusaurus
- dependency-name: "@docusaurus/types"
  dependency-version: 3.10.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: docusaurus
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-02 12:05:11 +02:00
dependabot[bot]
5d80dc7df4
chore(deps): update pytest-homeassistant-custom-component requirement (#131)
Some checks failed
Lint / Ruff (push) Has been cancelled
Validate / Hassfest validation (push) Has been cancelled
Validate / HACS validation (push) Has been cancelled
2026-05-01 00:18:38 +02:00
dependabot[bot]
92a53991d9
chore(deps): bump ghcr.io/devcontainers/features/node (#130)
Some checks failed
Lint / Ruff (push) Has been cancelled
Validate / Hassfest validation (push) Has been cancelled
Validate / HACS validation (push) Has been cancelled
2026-04-28 23:32:14 +02:00
dependabot[bot]
1b2a74f812
chore(deps): update pytest-homeassistant-custom-component requirement (#129)
Some checks are pending
Lint / Ruff (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
Validate / HACS validation (push) Waiting to run
2026-04-28 08:43:41 +02:00
Julian Pawlowski
bb8f5aa8cc chore(testing): add optional Pyright checks for tests
Some checks failed
Validate / HACS validation (push) Has been cancelled
Lint / Ruff (push) Has been cancelled
Validate / Hassfest validation (push) Has been cancelled
Add a dedicated type-check-tests helper, wire it into check-all behind --with-test-types, and align the affected tests with current typing and helper contracts.

Impact: No direct user-facing change.

User-Impact: none
2026-04-25 22:46:43 +00:00
Julian Pawlowski
bbcfdd4443 fix(periods): stabilize best and peak period outputs
Recompute merged relaxed periods from raw intervals, harden numeric period option normalization, update day-volatility handling for zero or negative averages, and expose day context on period binary sensors.

Add focused regressions for overlap merges, cache invalidation, day statistics, and visible binary sensor attributes.

Impact: Best and peak period entities stay consistent on negative-price days, refresh correctly when same-day prices change, and expose the documented day context attributes.
2026-04-25 22:46:38 +00:00
Julian Pawlowski
10c83d6720 fix(periods): keep negative best-price windows strictly local
Always treat prices at or below zero as valid best-price intervals, rescue short
negative cores with directly adjacent cheap shoulders before min-length filtering,
and block geometric or shape-based widening for periods that already contain a
negative-price core.

Impact: Negative best-price periods no longer expand into positive edge intervals on days with extreme negative prices.
2026-04-25 20:00:04 +00:00
Julian Pawlowski
c8f40e0b8a Merge branch 'main' of https://github.com/jpawlowski/hass.tibber_prices 2026-04-25 18:36:05 +00:00
Julian Pawlowski
870b716681 feat(settings): add local settings for script permissions
Introduce a new settings file to define permissions for various script executions.

Impact: Enables controlled execution of linting, type-checking, and testing scripts.
2026-04-25 18:36:02 +00:00
dependabot[bot]
1ffc8bd426
chore(deps): update ruff requirement (#128)
Some checks are pending
Lint / Ruff (push) Waiting to run
Validate / HACS validation (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
2026-04-24 22:50:20 +02:00
dependabot[bot]
e4c805c508
chore(deps): update pre-commit requirement (#125)
Some checks failed
Lint / Ruff (push) Has been cancelled
Validate / Hassfest validation (push) Has been cancelled
Validate / HACS validation (push) Has been cancelled
2026-04-23 10:34:11 +02:00
dependabot[bot]
f79c8b9e05
chore(deps): update pytest-asyncio requirement from >=0.23.0 to >=1.3.0 (#127) 2026-04-23 10:33:54 +02:00
dependabot[bot]
807098f93e
chore(deps): update pytest requirement from >=8.0.0 to >=9.0.3 (#126)
Some checks are pending
Lint / Ruff (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
Validate / HACS validation (push) Waiting to run
2026-04-22 23:49:22 +02:00
dependabot[bot]
d535dd110a
chore(deps): update ruff requirement (#124) 2026-04-22 23:49:01 +02:00
dependabot[bot]
f3b2d8e6ab
chore(deps): update pre-commit requirement (#123)
Some checks are pending
Lint / Ruff (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
Validate / HACS validation (push) Waiting to run
2026-04-22 09:04:35 +02:00
dependabot[bot]
df746bf892
chore(deps): update pytest-homeassistant-custom-component requirement (#122)
Some checks failed
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Has been cancelled
Lint / Ruff (push) Has been cancelled
Validate / Hassfest validation (push) Has been cancelled
Validate / HACS validation (push) Has been cancelled
2026-04-21 01:20:46 +02:00
Julian Pawlowski
96f36a3339 feat(services): add plan_charging service for battery/EV scheduling
Accepts battery parameters (capacity, current/target SoC, max power) and
returns a cost-minimized charging schedule with per-interval power, SoC
progression, and total cost — no manual duration calculation needed.

Supports fixed, continuous (min_charge_power_w), and stepped
(charge_power_steps_w) charging modes, deadline-aware two-pass planning
(must_reach_soc + must_reach_by / must_reach_by_event), and round-trip
economics (expected_discharge_price, reserve_for_discharge,
max_cost_per_kwh) for arbitrage use cases. Includes min_charge_duration
and max_cycles_per_day constraints.

Groups deadline fields (must_reach_soc_*, must_reach_by,
must_reach_by_event) into a dedicated section so a deadline use case can
be configured in one place. Battery section lists capacity before the
percent SoC fields that depend on it. Response exposes stable reason
codes (already_at_target, energy_unreachable, energy_unreachable_by_
deadline, no_intervals_after_economic_filter, …) documented in the
service description and user docs.
2026-04-20 21:43:41 +00:00
Julian Pawlowski
093e904329 docs: add blueprint import badges and update automation examples
Some checks are pending
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Waiting to run
Lint / Ruff (push) Waiting to run
Validate / HACS validation (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
automation-examples.md:
- Add info callout blocks with "Blueprint available" badges and
  one-click import links for each appliance section (dishwasher,
  washing machine, dryer, EV charging, pipeline)
- Update YAML examples to reflect current service API (remove
  relaxation_applied template, fix indentation)

sensor-reference.md:
- Remove stale data-refs attributes from current_price_trend,
  next_price_trend_change, and today_volatility anchors

examples/:
- Add examples/scripts/ directory with placeholder for
  tibber_notify_residents.yaml script example

User-Impact: none
2026-04-20 18:45:20 +00:00
Julian Pawlowski
e75e0ed1dc feat(blueprints): add appliance scheduling blueprints with auto-install
Bundle automation and script blueprints into the integration and
install them automatically at HA startup via _install_blueprints().
Remove them cleanly when the last config entry is removed.

Automation blueprints (standalone):
- dishwasher, washing_machine, dryer — smart plug and Home Connect
  variants (HC: door/remote-start sensors; HC Alt: program selector)

Automation blueprints (pipeline):
- laundry_day_pipeline — chains washer → dryer for multiple loads,
  HC and HC Alt variants

Other automation blueprints:
- ev_charging, heat_pump_price_level, heat_pump_smart_boost,
  home_battery, water_heater, laundry_day_pipeline (smart plug)

Script blueprint:
- notify_residents — presence-aware dispatcher for up to 10 residents
  with auto-discovered mobile_app notify services, iOS/Android push
  settings, and per-resident notify overrides

Notification UX across all blueprints:
- Apple Watch-optimised titles (~25 chars) and messages (most
  important info first, middle-dot separators, emoji anchors)
- Customisable notification titles via blueprint inputs (standalone)
- Comma-separated notify services for simple multi-target delivery
- Advanced script path for presence filtering and platform push data

Impact: Users get ready-to-use blueprints installed automatically
with the integration for scheduling appliances during cheap Tibber
price windows. No manual import required.
2026-04-20 18:45:05 +00:00
Julian Pawlowski
2d2873f75f feat(entity): expose integration version as sw_version in device info
Read the version field from manifest.json at import time via a new
INTEGRATION_VERSION constant in const.py. Pass it as sw_version to
DeviceInfo in TibberPricesEntity so the current integration version
is visible in the HA device registry.

Impact: Device page in HA now shows the installed integration version
under firmware/software version.
2026-04-20 18:44:34 +00:00
Julian Pawlowski
e01cc5d447 feat(services): allow entity IDs as service parameter values
Add entity_resolver module that lets all service parameters accept
HA entity references in place of literal values. The entity's current
state (or a specific attribute via the @attr syntax) is resolved at
call time and coerced to the expected Python type.

Syntax:
  "sensor.washing_duration"           → uses entity state
  "sensor.washing_duration@run_minutes" → uses entity attribute

Apply or_entity_ref() and resolve_entity_references() to all five
service handlers (get_price, find_cheapest_block, find_cheapest_hours,
find_cheapest_schedule, get_chartdata) for every parameter where a
dynamic value from another entity is useful (duration, start/end times,
offsets, etc.).

Add five new translation keys for entity-resolution error messages
(invalid_entity_reference, entity_not_found, entity_attribute_not_found,
entity_state_unavailable, entity_value_conversion_failed) across all
five language files.

Fix pytest warning filter to suppress AsyncMock cleanup noise, and
update test_resource_cleanup to mock hass.config_entries.async_entries
so the blueprint-removal path in async_remove_entry does not raise.

Impact: Automations and scripts can pass sensor entity IDs as service
parameters (e.g. duration from a sensor) instead of having to use
template-based workarounds.
2026-04-20 18:44:24 +00:00
Julian Pawlowski
a8d1519a26 docs(scheduling): update docs for sequential scheduling parameter
Replace workaround recommendations (2× find_cheapest_block) with the
new sequential: true parameter. Rewrite washer→dryer example as a single
find_cheapest_schedule call. Update quick reference tables.

Release-Notes: skip
2026-04-19 14:17:41 +00:00
Julian Pawlowski
31fca73ccd feat(services): add sequential parameter to find_cheapest_schedule
When sequential: true, tasks are placed in declaration order instead of
being sorted by duration. Each task's search window starts after the
previous task ends (plus gap_minutes). If a task cannot be placed, all
subsequent tasks in the chain are also marked unscheduled.

Adds 12 tests covering ordering, chaining, gap enforcement, and
chain-breaking behavior.

Impact: Users can now schedule dependent appliances (e.g., washing
machine → dryer) in a single find_cheapest_schedule call with guaranteed
order, instead of chaining two find_cheapest_block calls.
2026-04-19 14:17:32 +00:00
Julian Pawlowski
0162394263 docs(scheduling-actions): enhance dishwasher and appliance scheduling examples
Some checks are pending
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Waiting to run
Updated the dishwasher automation to include planning and execution steps using input_datetime helpers for better reliability across restarts. Clarified the use of find_cheapest_schedule for independent appliances and added caution regarding task order. Improved messaging in notifications for planned schedules.

Impact: Users can now schedule appliances more reliably, ensuring planned start times persist through Home Assistant restarts.

docs(sidebars): rename automation section for clarity

Changed the sidebar label from '🤖 Automations' to '🤖 Automations & Usage' to better reflect the content and improve user navigation.

Impact: Users will find it easier to locate automation-related documentation.
2026-04-19 13:56:52 +00:00
Julian Pawlowski
3057642cba refactor(icons): streamline service icon definitions and enhance chartdata sections
Some checks failed
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Waiting to run
Lint / Ruff (push) Has been cancelled
Validate / Hassfest validation (push) Has been cancelled
Validate / HACS validation (push) Has been cancelled
Removed unnecessary sections from the get_apexcharts_yaml service and added new fields for search tuning and cost estimation in chartdata services. This improves the clarity and usability of the service definitions.

Impact: Users will benefit from a more concise and organized service structure, enhancing the overall experience.
2026-04-19 12:35:13 +00:00
Julian Pawlowski
60b2de0379 refactor(periods): replace cross-day extension with bidirectional bridging
The old extension algorithm extended a single late-evening period forward
past midnight by appending qualifying intervals one-directionally.  This
caused false extensions (e.g. peak 19:45–21:30 extended to 01:00 by
pulling in 14 declining-price intervals).

Replace with bidirectional bridging: two independently qualifying periods
on both sides of midnight are merged only when separated by a small gap
(≤4 intervals = 1 hour) and the combined period passes the CV quality
gate (≤25%).  A period ending well before midnight is no longer touched.

User-Impact: none
2026-04-19 11:47:45 +00:00
Julian Pawlowski
303a7c7835 feat(pricing): add relaxation logic for progressive filter loosening
Some checks are pending
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Waiting to run
Lint / Ruff (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
Validate / HACS validation (push) Waiting to run
Implement a new service that progressively relaxes user-defined filters to ensure a result is always returned when price data is available. This includes three phases: halving the minimum distance from average, expanding level filters, and reducing duration.

Impact: Users will receive results even when strict filters would otherwise yield no matches, improving the reliability of scheduling actions.

feat(pricing): enhance scheduling actions with new parameters

Introduce new parameters `smooth_outliers`, `min_distance_from_avg`, and `allow_relaxation` to scheduling actions, allowing for better control over price selection and ensuring results are meaningfully different from average prices.

Impact: Users can now fine-tune their scheduling actions to avoid marginal savings and ensure more uniform pricing within selected windows.

docs(scheduling): update documentation for new features

Revise the scheduling actions documentation to include new parameters and their effects, such as outlier smoothing and minimum distance from average, along with examples for better user understanding.

Impact: Users will have clearer guidance on how to utilize new features effectively in their automations.

test(scheduling): add tests for new relaxation logic

Implement unit tests to verify the behavior of the new relaxation logic in scheduling actions, ensuring that filters are correctly relaxed and results are returned as expected.

Impact: Increased test coverage and reliability of the scheduling features.
2026-04-18 21:27:05 +00:00
Julian Pawlowski
63c3404fbd Merge branch 'main' of https://github.com/jpawlowski/hass.tibber_prices
Some checks are pending
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Waiting to run
Lint / Ruff (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
Validate / HACS validation (push) Waiting to run
2026-04-18 09:53:48 +00:00
Julian Pawlowski
7783a0b629 refactor(periods): enhance period gap handling and cross-day validation
Improve the logic for trimming trailing gap-tolerance intervals in periods to prevent misleading period end shifts. Additionally, refine cross-day boundary validation for both best and peak periods to eliminate false extremes caused by inter-day reference shifts.

Impact: More accurate period calculations and reduced artifacts in reported price periods.
2026-04-18 09:53:31 +00:00
dependabot[bot]
63a187fe5c
chore(deps): bump astral-sh/setup-uv from 8.0.0 to 8.1.0 (#119) 2026-04-18 08:56:12 +02:00
dependabot[bot]
9a4ee04cfa
chore(deps-dev): bump typescript from 6.0.2 to 6.0.3 in /docs/user (#120) 2026-04-18 08:55:57 +02:00
dependabot[bot]
d66b3f4ec0
chore(deps-dev): bump typescript from 6.0.2 to 6.0.3 in /docs/developer (#121) 2026-04-18 08:48:23 +02:00
Julian Pawlowski
2b63440933 refactor(periods): enhance peak period filtering and validation logic
Some checks failed
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Waiting to run
Lint / Ruff (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
Validate / HACS validation (push) Waiting to run
Auto-Tag on Version Bump / Check and create version tag (push) Has been cancelled
Improve the filtering of peak periods to eliminate cross-day artifacts and ensure that only genuine high-price windows are retained. This includes adjustments to the criteria for peak classification and the introduction of validation against previous day's prices for overnight intervals.

Impact: Users will experience more accurate peak pricing data, reducing misleading peak classifications on flat days.
2026-04-17 22:24:18 +00:00
Julian Pawlowski
4f2bea6720 docs(concepts): clarify V-shaped and U-shaped price day definitions
Enhanced descriptions for V-shaped and U-shaped price days to include their characteristics and how they are classified as 'valley' by the Day Pattern sensor.

Impact: Users gain a clearer understanding of price patterns and their implications for automation.

docs(glossary): update V-Shaped Day definition for clarity

Revised the definition of V-Shaped Day to include U-Shaped Day as an informal term, emphasizing the classification as 'valley' by the Day Pattern sensor.

Impact: Users receive a more comprehensive explanation of price day classifications.

docs(period-calculation): improve algorithm overview and phase descriptions

Added detailed explanations for each phase of the period calculation algorithm, including visual aids and clarifications on the importance of each phase.

Impact: Users can better understand the underlying logic of price period calculations.

docs(sensors-price-phases): refine sensor descriptions for clarity

Updated descriptions of price phase sensors to clarify the shapes and conditions they represent, including distinctions between V-shaped and U-shaped curves.

Impact: Users benefit from improved clarity on sensor classifications and their applications in automations.
2026-04-17 22:23:55 +00:00
Julian Pawlowski
8ebff9bc9a chore(devcontainer): add MermaidChart extension for diagram support
Include the MermaidChart extension to enhance diagramming capabilities within the development environment.

Impact: Users can now create and visualize diagrams directly in their code editor.
2026-04-17 21:03:57 +00:00
Julian Pawlowski
75da094c81 refactor(day_patterns): rename double valley/peak to double dip/duck curve
Some checks are pending
Auto-Tag on Version Bump / Check and create version tag (push) Waiting to run
Lint / Ruff (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
Validate / HACS validation (push) Waiting to run
Updated pattern names for clarity and consistency in the codebase. The changes include renaming constants and updating related logic to reflect the new terminology.

Impact: Improved readability and understanding of day pattern classifications for developers.
2026-04-17 14:37:17 +00:00
Julian Pawlowski
ba3e127ac7 refactor(day_pattern): enhance pattern classification with price boundaries
Refactor the pattern classification logic to include start and end prices for better accuracy in identifying day patterns. This change improves the classification of price patterns, particularly for cases involving valleys and peaks.

Impact: Users will experience more accurate price pattern classifications, leading to better decision-making based on price trends.
2026-04-17 14:02:02 +00:00
Julian Pawlowski
2092d28ece chore(scripts): normalize release note footer across all backends
The Copilot backend relies on the AI to reproduce the footer text exactly,
which leads to subtle differences (URL parameter order, whitespace, emoji
encoding) compared to the git-cliff template in cliff.toml.

Fix: define CANONICAL_FOOTER as a single bash constant and pipe all three
backends (copilot, git-cliff, manual) through ensure_canonical_footer(),
which strips any existing BMAC footer block and appends the canonical one.

Also tell the AI not to generate a footer at all, since it is now added
programmatically. The manual backend gains a footer it previously lacked.

User-Impact: none
2026-04-17 12:41:55 +00:00
Julian Pawlowski
432eb6502c chore(release): bump version to 0.31.0b3 2026-04-17 12:23:04 +00:00
Julian Pawlowski
db02f262b6 perf(interval_pool): skip redundant API calls when prior fetch covers range
Some checks are pending
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Waiting to run
Lint / Ruff (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
Validate / HACS validation (push) Waiting to run
The PRICE_INFO endpoint returns all available intervals (~384) regardless
of the requested range. When fetching multiple missing ranges, subsequent
calls are redundant if the first response already covers them.

After each fetch, track returned timestamps and skip ranges that are
already covered by previously fetched data.

Impact: Reduces redundant Tibber API calls, especially after restarts or
cache invalidation when multiple gaps exist in the interval pool.
2026-04-17 12:00:57 +00:00
Julian Pawlowski
361498b7f5 perf(interval_pool): run GC after touch-only interval updates
Touch operations create dead intervals in old fetch groups, but GC only
ran when new intervals were added. Dead intervals accumulated until
the next fetch with genuinely new data.

Now run GC after touch-only paths and schedule a save if data changed.

Impact: Reduces memory usage by cleaning up stale fetch groups promptly
instead of letting dead intervals accumulate between API fetches.
2026-04-17 12:00:48 +00:00
Julian Pawlowski
ebcb9cfe77 fix(interval_pool): rebuild index after dead interval cleanup without empty groups
Cherry-pick of v0.30.1 hotfix (ec3bc9f). When _cleanup_dead_intervals
compacted group lists but no groups became fully empty, the index retained
stale interval_index values pointing past compacted list ends, causing
IndexError in _get_cached_intervals.

Now rebuilds the index whenever dead intervals are removed, even if no
groups are deleted.

Includes regression test for Issue #118 and updated touch operation tests
to reflect that GC now runs immediately after touch-only paths.

Closes #118

Impact: Eliminates IndexError crash for users with brand-new Tibber accounts
that have limited price history, where partial group compaction was most likely.
2026-04-17 12:00:37 +00:00
Julian Pawlowski
6b4c46a305 chore(scripts): disable git-cliff --include-path by default
git-cliff ≤2.12.0 --include-path ignores the commit range and generates
a full changelog. The commit_parsers in cliff.toml already filter
non-user-facing types/scopes, making path filtering redundant.

Changed RELEASE_NOTES_CLIFF_FILTER_PATHS default from true to false.

User-Impact: none
2026-04-17 11:59:34 +00:00
Julian Pawlowski
c85f4991ab feat(sensors): add new price phase and duration sensors
Introduce additional sensors for current price phase, phase duration, and upcoming phase timings to enhance user visibility of pricing dynamics.

Impact: Users can now monitor current price phases and their durations, improving decision-making based on real-time pricing information.
2026-04-17 08:52:36 +00:00
Julian Pawlowski
c3173a16d6 refactor(attributes): streamline phase type retrieval and attribute building
Consolidate logic for determining current price phase and associated attributes by introducing shared helper functions. This enhances code maintainability and reduces duplication across components.

Impact: Improved clarity and efficiency in price phase handling for users.
2026-04-17 08:52:17 +00:00
Julian Pawlowski
752a0c5dbc chore(tsconfig): update compiler options to ignore deprecations
Some checks failed
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Has been cancelled
Lint / Ruff (push) Has been cancelled
Validate / Hassfest validation (push) Has been cancelled
Validate / HACS validation (push) Has been cancelled
Modified the TypeScript configuration files to set the ignoreDeprecations option to "6.0" for improved compatibility with future TypeScript versions.

User-Impact: none
2026-04-15 11:18:39 +00:00
Julian Pawlowski
2adb64e5a0 refactor(translations): update terminology for previous interval price ranks
Revised the descriptions and names for the previous interval price rank entities across multiple language translation files to enhance clarity and consistency.

Impact: Users will see improved terminology in the interface, making it clearer that the ranks refer to the previous interval's prices.
2026-04-15 10:43:29 +00:00
Julian Pawlowski
7629c0f628 refactor(repairs): simplify currency mode change notification to one-shot
Remove the DATA_STATISTICS_REVIEW_REQUIRED flag and all associated
persistence logic. The flag approach was over-engineered: we cannot
detect whether the Recorder statistics have been fixed, and requiring
the user to re-save display settings as acknowledgement is bad UX.

New design: show the repair notice once when the mode changes.
The user dismisses it when done reviewing. The HA Recorder will
independently show its own unit-change dialog — that is sufficient.

Changes:
- Remove DATA_STATISTICS_REVIEW_REQUIRED constant from const.py
- Remove _check_statistics_review_repair() from __init__.py
- Remove ir import from __init__.py (no longer needed there)
- Remove flag set/clear logic from options_flow.py
- Change is_persistent=False (no restart persistence needed)
- Update all 5 translations: restore simple "Dismiss this notice" ending
2026-04-15 10:00:59 +00:00
Julian Pawlowski
09edcdb9a3 fix(repairs): reliably show statistics-review issue on every HA restart
We have no way to programmatically detect whether the Recorder statistics
have been fixed. Dismissing the Repairs notification does not mean the
problem is resolved, only that the user has seen it.

Revert to delete + create on every async_setup_entry when the flag is set.
This guarantees the issue is visible after every restart until the user
explicitly acknowledges completion by re-saving the display settings in
the options flow.

Remove the dismissed_version auto-clear logic that was treating dismissal
as acknowledgement (it was not).

Update all 5 translation files: replace "Dismiss this notice" with
instructions to re-save display settings as the only way to permanently
close the notification.

Released-Bug: no
2026-04-15 09:56:24 +00:00
Julian Pawlowski
e6ec54d8c5 fix(repairs): force statistics-review issue visible on mode change
async_create_issue preserves dismissed_version when called with the same
issue_id. This means that if a user had previously dismissed the repair
issue, changing the currency mode again would set the flag but the issue
would stay hidden (because it still has a dismissed_version).

Fix: use delete + create in the options flow when mode_changed=True.
This resets dismissed_version so the new instance is always visible.

The __init__.py path (HA restart with flag set) continues to use plain
async_create_issue so that a restart alone does not un-dismiss an issue
the user already acknowledged.

Released-Bug: no
2026-04-15 09:47:30 +00:00
Julian Pawlowski
5b5d5e73b0 fix(repairs): respect dismissal of statistics-review repair issue
The previous implementation used delete + create on every async_setup_entry,
which reset dismissed_version and forced the issue to reappear after every
HA restart regardless of whether the user had dismissed it.

Fix: use async_create_issue (internally get-or-create, preserves
dismissed_version when params are unchanged) instead of delete + create.
The options flow still uses delete + create when the mode changes again,
ensuring the issue is forced into view for any new change.

Also auto-clear the DATA_STATISTICS_REVIEW_REQUIRED flag from
config_entry.data when the issue is detected as dismissed on setup,
so the flag does not linger indefinitely after the user has acknowledged
the issue.

Released-Bug: no
2026-04-15 09:37:54 +00:00
Julian Pawlowski
e5474d50ec refactor(release-notes): improve GitHub release auto-update checks
Enhance the script to only check for existing releases if the specified tag exists locally. Added authentication check for the GitHub CLI before querying releases, with informative logging for users.

Impact: Users will receive clearer messages regarding the status of GitHub releases and authentication requirements.
2026-04-15 09:32:54 +00:00
Julian Pawlowski
aa3f909814 refactor(docs): remove deprecated price phase sensors from documentation
Updated the sensor reference documentation to remove obsolete price phase sensors, ensuring clarity and relevance for users.

Impact: Users will see a cleaner and more accurate list of available sensors.
2026-04-15 09:03:03 +00:00
Julian Pawlowski
76a3a0f1fd feat(docs): add price phase sensors documentation and update references
Introduced comprehensive documentation for price phase sensors, detailing their functionality and usage. Updated links in existing documentation for clarity.

Impact: Users can now understand and utilize price phase sensors effectively in their configurations.
2026-04-15 08:49:37 +00:00
Julian Pawlowski
ee9adce9d5 feat(docs): add comprehensive configuration documentation for Tibber Prices integration
Some checks failed
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Waiting to run
Lint / Ruff (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
Validate / HACS validation (push) Waiting to run
Auto-Tag on Version Bump / Check and create version tag (push) Has been cancelled
Introduced new documentation files covering various configuration aspects such as chart data export, currency display, general settings, peak price periods, price levels, price ratings, price trends, and volatility. Each section provides detailed explanations of settings, their impacts, and migration guidance for legacy features.

Impact: Users gain clear guidance on configuring the Tibber Prices integration, enhancing usability and understanding of features.

### Notes
- New files include config-chart-export.md, config-currency.md, config-general.md, config-peak-price.md, config-price-level.md, config-price-rating.md, config-price-trend.md, config-runtime-overrides.md, and config-volatility.md.
- Updated sidebar for improved navigation within the documentation.
2026-04-14 21:11:37 +00:00
Julian Pawlowski
240acac00a refactor(AGENTS.md): clarify legacy migration guidelines
Updated the legacy migration section to specify that only changes released under a public version tag require migration code. Added clarifications regarding uncommitted changes and new features not needing migration.

Impact: Provides clearer guidance for developers on handling legacy migrations, reducing potential confusion.
2026-04-14 21:10:47 +00:00
Julian Pawlowski
33fa536198 chore: bump version 2026-04-14 20:42:08 +00:00
Julian Pawlowski
1d065b11cd fix(services): use injected now in resolve_search_range day offset
_resolve_time_with_day_offset() was calling dt_util.now() internally
instead of using the injected now parameter. This caused incorrect date
calculations in tests and any caller that passes a specific reference time.

Also add missing price_rank_* sensor keys to TIME_SENSITIVE_ENTITY_KEYS
in coordinator/constants.py so quarter-hour refresh is registered for all
11 price rank sensors (current/next/previous interval and hour variants).

Rename dt as dt_utils → dt as dt_util (ICN001) across 11 files to follow
the project-wide import alias convention. Apply ruff auto-fixes for import
ordering and collapsing single-item imports throughout the codebase.

Released-Bug: no
2026-04-14 19:33:24 +00:00
Julian Pawlowski
07788a57ea chore(pyproject): update Python version requirement to 3.14.2
Adjusted the required Python version in the project configuration to ensure compatibility with the latest features and improvements.

Impact: Users must have Python 3.14.2 or higher to run the project.
2026-04-14 19:30:57 +00:00
Julian Pawlowski
ccf1d6185d docs(configuration): clarify stored vs display precision and statistics guidance
Restructure configuration.md: separate "stored precision" from "default display
precision" tables to avoid confusion between internal representation and what is
shown in dashboards. Add note that HA shows its own unit-change dialog (delayed);
our repair issue appears immediately as step 1. Recommend deleting old statistics
data rather than re-labeling, which would leave wrong values with the new unit.

Update faq.md examples to use display precision values for consistency with the
documentation.
2026-04-14 19:28:57 +00:00
Julian Pawlowski
061b42b8f3 feat(options): show persistent repair issue after currency mode change
Add DATA_STATISTICS_REVIEW_REQUIRED flag to config_entry.data. Set on
currency mode change, cleared on same-mode save. On every async_setup_entry
with flag set, delete and recreate the repair issue so it reappears after
HA restart even if previously dismissed.

Repair issue text explains that HA Recorder shows its own unit-change
dialog (delayed) and recommends deleting old statistic data rather than
re-labeling, which would leave wrong values with the new unit.

Impact: Users are notified to review statistics and automations after
switching between base/subunit currency mode. Notification persists across
HA restarts until acknowledged by saving display settings again.
2026-04-14 19:28:47 +00:00
Julian Pawlowski
a4ad506e01 feat(sensor): use dynamic precision for price rounding and display
Add get_display_precision() to const.py returning DISPLAY_PRECISION_SUBUNIT (2)
or DISPLAY_PRECISION_BASE (4) based on config. Replace hardcoded round(..., 2)
with get_display_precision() in all calculators and attribute builders.
Add _update_suggested_precision() to sensor core; syncs entity registry
suggested_display_precision on every coordinator update.

Interval price sensors get full precision (2 or 4 dp); other MONETARY sensors
get half precision (1 or 2 dp) as sensible default.

Impact: Price sensor states and attributes now correctly use 4 decimal places
in base-currency mode (was always 2). Display precision in dashboards updates
automatically when currency mode changes.
2026-04-14 19:28:19 +00:00
Julian Pawlowski
6d22ea7151 docs(sensors-volatility): clarify price rank calculation and provide automation example
Enhance the documentation for volatility sensors by explaining that the maximum price rank will never reach 100% and providing a YAML automation example for managing device usage during peak pricing.

Impact: Users gain a clearer understanding of price rank behavior and practical automation guidance.
2026-04-14 19:18:25 +00:00
Julian Pawlowski
91147bd79c chore(devcontainer): Revert pytest ignorance
Some checks are pending
Auto-Tag on Version Bump / Check and create version tag (push) Waiting to run
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Waiting to run
Lint / Ruff (push) Waiting to run
Validate / HACS validation (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
2026-04-14 15:37:06 +00:00
Julian Pawlowski
2e7ccc36c5 docs(CONTRIBUTING): improve clarity and formatting in contribution guidelines
Refactor the contribution guidelines to enhance readability and consistency in formatting. Adjusted code blocks and list formatting for better visual structure.

Impact: Contributors will find it easier to follow the guidelines when contributing to the project.

---

docs(README): update automation examples for better readability

Reformatted YAML automation examples in the README to improve clarity and consistency. Indentation and structure were adjusted for better understanding.

Impact: Users will have clearer examples for setting up automations with the integration.

---

chore(manifest): streamline manifest file formatting

Consolidated formatting in the manifest file for consistency. Adjusted the codeowners and requirements sections for a cleaner look.

User-Impact: none

---

chore(pyproject): enhance project configuration for better linting and testing

Updated the pyproject.toml file to improve linting configurations and testing options. Added specific rules for ruff and pytest to align with project standards.

User-Impact: none

---

chore(manifest_schema): simplify JSON schema for integration manifest

Refined the manifest schema by consolidating enum definitions for better readability and maintenance.

User-Impact: none

---

chore(prettier): add Prettier configuration for consistent code formatting

Introduced a Prettier configuration file to standardize code formatting across the project, ensuring consistency in style.

User-Impact: none
2026-04-14 15:35:16 +00:00
Julian Pawlowski
9efa7809d0 refactor(colors, icons): enhance trend-related color and icon handling
Some checks are pending
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Waiting to run
Lint / Ruff (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
Validate / HACS validation (push) Waiting to run
Updated color and icon logic to include additional keys for price trends, outlooks, and trajectories. This improves the visual representation of price changes in the UI.

Impact: Users will see more accurate color coding and icons for price trends and forecasts.
2026-04-14 09:22:34 +00:00
dependabot[bot]
ff08df24e7
chore(deps): update pytest-asyncio requirement from >=0.23.0 to >=1.3.0 (#115) 2026-04-14 07:40:59 +02:00
dependabot[bot]
707e1d47da
chore(deps): update pytest requirement from >=8.0.0 to >=9.0.3 (#114) 2026-04-14 07:39:38 +02:00
dependabot[bot]
236a15bea4
chore(deps): update pytest-homeassistant-custom-component requirement (#113) 2026-04-14 07:39:08 +02:00
dependabot[bot]
f2a8cd6777
chore(deps): bump actions/github-script from 7 to 9 (#112) 2026-04-14 07:38:47 +02:00
dependabot[bot]
9af252fb61
chore(deps): bump actions/setup-python from 5 to 6 (#111) 2026-04-14 07:38:36 +02:00
dependabot[bot]
4b0aa4a93b
chore(deps): bump softprops/action-gh-release from 2 to 3 (#110) 2026-04-14 07:38:18 +02:00
dependabot[bot]
a54c1353e1
chore(deps): bump actions/upload-pages-artifact from 4 to 5 (#109) 2026-04-14 07:38:02 +02:00
Julian Pawlowski
27ab58bbf5 refactor(manager): enhance API error handling and fallback logic
Some checks are pending
Auto-Tag on Version Bump / Check and create version tag (push) Waiting to run
Lint / Ruff (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
Validate / HACS validation (push) Waiting to run
Improve error handling for API fetch failures by implementing a fallback to cached intervals. This ensures the system can continue functioning during transient API issues.

Impact: Users experience fewer interruptions when the API is temporarily unavailable, as cached data will be used seamlessly.
2026-04-13 12:28:36 +00:00
Julian Pawlowski
bf95dc5efc refactor(translations): update error messages for array_fields validation
Modified error messages in multiple language translation files to remove unnecessary curly braces around the template placeholder for improved clarity.

Impact: Users will see clearer error messages when invalid array_fields are provided.
2026-04-13 12:13:34 +00:00
Julian Pawlowski
f4313485cd chore(release): bump version to 0.31.0b1 2026-04-13 12:06:50 +00:00
Julian Pawlowski
729bf307ca refactor(services): enhance validation for service parameters and error messages
Improved validation logic for service parameters in find_cheapest_hours, find_cheapest_schedule, and chartdata services. Added checks for unique task names, ensured that segment durations do not exceed total duration, and clarified error messages for better user understanding.

Impact: Users will receive clearer error messages and improved validation when using the services, leading to a more robust experience.
2026-04-13 12:02:19 +00:00
Julian Pawlowski
9042ea6efb refactor(chartdata): enhance filter requirements for insert_nulls mode
Some checks are pending
Lint / Ruff (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
Validate / HACS validation (push) Waiting to run
Updated the filter logic to include period_filter alongside level_filter and rating_level_filter for segment definitions. This change ensures that users can utilize period_filter effectively when defining segments.

Impact: Users can now use period_filter in addition to existing filters for more flexible segment definitions.
2026-04-13 09:45:02 +00:00
Julian Pawlowski
71696380a6 refactor(definitions): remove diagnostic entity category from day pattern sensors
Some checks failed
Auto-Tag on Version Bump / Check and create version tag (push) Waiting to run
Lint / Ruff (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
Validate / HACS validation (push) Waiting to run
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Has been cancelled
Updated sensor definitions to enhance clarity and maintain consistency by removing the diagnostic entity category from day pattern sensors.

Impact: No user-facing changes.
2026-04-12 20:37:53 +00:00
Julian Pawlowski
4b7001b731 refactor(generate-notes): update comment formatting for clarity
Revised the comment regarding explicitly skipped commits to enhance readability and maintain consistency in documentation style.

Impact: Improved clarity for developers reviewing the release notes generation script.
2026-04-12 20:13:37 +00:00
github-actions[bot]
6f0b7aa837 chore(release): sync manifest.json with tag v0.31.0b0 2026-04-12 19:56:50 +00:00
Julian Pawlowski
4ba159d815 feat(translations): enhance price trend change descriptions and usage tips
Updated the long descriptions and usage tips for the price trend change sensors in multiple languages (de, en, nb, nl, sv) to provide clearer guidance on detection mechanics and expected behavior during V-shaped price days.

Impact: Users will have a better understanding of how the sensors operate and can make more informed decisions regarding automation based on price trends.
2026-04-12 19:55:57 +00:00
Julian Pawlowski
4a72cde62a chore(release): bump version to 0.31.0b1 2026-04-12 19:41:59 +00:00
Julian Pawlowski
adf85792d5 refactor(shape_extension): improve period extension logic and documentation
Some checks are pending
Auto-Tag on Version Bump / Check and create version tag (push) Waiting to run
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Waiting to run
Lint / Ruff (push) Waiting to run
Validate / HACS validation (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
Refactor the period extension logic to clarify the handling of primary and fallback price levels. Update the documentation to reflect the changes in how periods extend into adjacent intervals.

Impact: Users will benefit from clearer price extension behavior and improved performance in period calculations.
2026-04-12 16:30:19 +00:00
Julian Pawlowski
1706bd7c0e feat(workflow): add auto-assign GitHub Action for issue assignment
Implement an auto-assign workflow to automatically assign newly opened issues to the repository owner.

Impact: Streamlines issue management by ensuring the owner is automatically assigned to new issues.
2026-04-12 16:29:33 +00:00
Julian Pawlowski
b1e0245a60 refactor(coordinator): use IQR% as primary flat-day metric in period relaxation
Replace CV with IQR% as the primary indicator for flat-day detection
in _compute_day_effective_min(). CV is inflated by isolated price spikes
(a single spike at 2× the average pushes CV to 15-25% while the core
price band stays flat), causing the flat-day adaptation to be missed.

IQR% (spread of the central 50% of prices / median) is unaffected by
tail outliers and correctly identifies "flat core + spike" days.

Threshold: LOW_IQR_PCT_FLAT_DAY_THRESHOLD = 15.0%
  - IQR% ≈ 1.35 × CV for symmetric data, so 15% ≈ old CV threshold of 10%
  - Extra headroom catches flat days with a single outlier (IQR%~3%,
    CV~20%) that were previously missed

CV retained as fallback for edge cases where iqr_pct is None
(near-zero or negative median prices).

Impact: Flat days with a single isolated price spike are now correctly
identified, reducing unnecessary relaxation iterations on those days.
2026-04-12 15:31:40 +00:00
Julian Pawlowski
51a62d712f feat(sensor): add next/previous/rolling-hour price rank sensors
Rename the three existing price rank sensors from price_rank_* to
current_interval_price_rank_* to clarify they rank the current
quarter-hour interval's price, not a daily aggregate — consistent with
current_interval_price_level / current_interval_price_rating naming.

Add 8 new rank sensors covering additional subjects and reference windows:
- next_interval_price_rank_{today,today_tomorrow}
- previous_interval_price_rank_{today,today_tomorrow}
- current_hour_price_rank_{today,today_tomorrow}   (5-interval rolling avg)
- next_hour_price_rank_{today,today_tomorrow}       (5-interval rolling avg)

All new sensors are disabled by default. The volatility calculator gains a
subject parameter (_get_subject_price / _get_subject_price_attr_key /
_get_rolling_hour_avg_price) to select which price to rank. Sensor key
routing in value_getters.py and attributes/__init__.py updated accordingly.

No migration entries needed — the original price_rank_* sensors were never
released to users.

All 5 translation files updated. sensor-reference.md regenerated (129 entities).

Impact: Users can now track price rank for the next interval (look-ahead),
the previous interval (logging), and rolling hourly averages — for both
same-day and two-day reference windows.
2026-04-12 15:02:27 +00:00
Julian Pawlowski
dd59c687e3 chore(configuration): enhance development configuration for Home Assistant
Updated the configuration files to improve development experience by explicitly loading useful integrations and adjusting logging levels. Added YAML schemas for configuration and services to ensure proper structure and validation.

Impact: Developers will have a more streamlined setup process and better logging during integration development.
2026-04-12 14:45:15 +00:00
Julian Pawlowski
3ba8e91958 chore(config): update core integrations for development environment
Enhance the configuration for the HTTP component to support development in Codespaces and DevContainer. This includes settings for server host, IP banning, trusted proxies, and CORS.

Impact: Improved development experience by allowing easier access and configuration in development environments.
2026-04-12 14:33:45 +00:00
Julian Pawlowski
a2fe572dc2 chore(style): reformat Docusaurus package.json files from 4-space to 2-space indent
Apply consistent 2-space indentation to package.json in both docs/developer
and docs/user. No dependency changes.

Release-Notes: skip
2026-04-12 14:16:11 +00:00
Julian Pawlowski
aa9a1200b8 chore(style): normalize Markdown list indentation across all docs
Convert four-space-indented list items (`-   item`) to standard two-space
(`- item`) in AGENTS.md, CONTRIBUTING.md, README.md, and all Docusaurus
documentation pages (developer and user, including versioned snapshots).
No content changes.

Release-Notes: skip
2026-04-12 14:15:31 +00:00
Julian Pawlowski
e163a47d57 chore(style): normalize indentation and line continuations in shell scripts
Apply consistent 4-space indentation and trailing-operator style for line
continuations (&&, |) across all development and release scripts. No logic
changes.

Release-Notes: skip
2026-04-12 14:15:17 +00:00
Julian Pawlowski
a93ad1ac96 chore(style): reformat JSON config files from 4-space to 2-space indent
Apply consistent 2-space indentation to all project-level JSON configuration
files: devcontainer.json, devcontainer-extensions.json, manifest.json,
icons.json, hacs.json, .markdownlint.json, and translation_schema.json.
No content changes.

Release-Notes: skip
2026-04-12 14:15:04 +00:00
Julian Pawlowski
a957334990 docs(sensors): document price rank sensors and IQR volatility band attributes
Update sensors-volatility.md to cover the three new price rank sensors and the
IQR-based volatility attributes (typical price band / price spike count).
Section headers include technical terms in parentheses for experts:
"Typical Price Band Statistics (IQR)" and "Price Rank Sensors (Percentile Rank)".
Attribute tables list Tukey fence formulas and plain-language explanations
side-by-side.

Regenerate sensor-reference.md to include price_rank_today,
price_rank_tomorrow, and price_rank_today_tomorrow with translations for all
five supported languages.

Impact: Users have full documentation for the new sensors including examples,
formulas, and a multi-language lookup table.
2026-04-12 14:14:31 +00:00
Julian Pawlowski
0ca52f8d3c feat(translations): add custom descriptions for price rank and volatility band sensors (5 languages)
Add entity descriptions, long descriptions, and usage tips for the three new
price_rank_* sensors and the updated volatility sensors with IQR attributes.
Plain-language terms are used as primary labels (e.g. "typical price band",
"price rank"); technical terms are included parenthetically for experts
(e.g. "IQR", "percentile rank", "Tukey fences") in all five languages.

Impact: Sensors show descriptive help text in the entity detail view, making it
easier for users to understand what each sensor measures without consulting
external documentation.
2026-04-12 14:14:16 +00:00
Julian Pawlowski
7b477cd4c7 feat(translations): add UI labels for price rank sensors (5 languages)
Add entity name translations for price_rank_today, price_rank_tomorrow, and
price_rank_today_tomorrow sensors in English, German, Norwegian, Dutch, and
Swedish.

Impact: Sensor display names appear correctly in the Home Assistant UI for all
supported languages.
2026-04-12 14:14:02 +00:00
Julian Pawlowski
6f5261785b feat(sensor): add price rank sensors and IQR-based volatility attributes
Add three new price rank sensors that show where today's/tomorrow's/combined
average price falls relative to all intervals in the evaluated window:
- price_rank_today: today's average price percentile rank (0–100%)
- price_rank_tomorrow: tomorrow's average price percentile rank
- price_rank_today_tomorrow: combined today+tomorrow percentile rank

Extend all volatility sensors with IQR-based band statistics:
- price_typical_spread: interquartile range (IQR) in currency subunit
- price_typical_spread_%: IQR as percentage of daily average
- price_spike_count: number of intervals outside Tukey fences (outliers)

Add calculate_iqr_stats() utility function in utils/price.py that computes
the 25th/75th percentiles, IQR, outer fences (Q1 - 1.5×IQR / Q3 + 1.5×IQR),
and outlier count for any list of price values. Entity keys and attribute
names use plain language (`price_rank`, `price_typical_spread`) as primary
labels; technical terms (percentile rank, IQR) are included parenthetically
in descriptions and documentation.

Impact: Users can now see where current day prices rank compared to their window and how tightly clustered or spike-prone a day's prices are.
2026-04-12 14:13:47 +00:00
Julian Pawlowski
c89248d493 feat(services): add reason codes and schedule comparison details to find services
Add structured reason codes to no-result responses for find_cheapest_block,
find_cheapest_hours, and find_cheapest_schedule. Each handler now classifies
why no result was returned: no_data_in_range, no_intervals_matching_level_filter,
insufficient_intervals_after_filter, or insufficient_contiguous_window.

Add include_comparison_details flag to find_cheapest_schedule. When enabled,
each scheduled task includes a price_comparison field showing the most expensive
alternative window (mean, min, max, start, end) for cost-savings context.

Document stable reason code contracts in en.json service descriptions.
Add corresponding field translations to all locales (de, nb, nl, sv).

Impact: Automations and scripts can now react to why no window was found,
and schedules can display concrete savings vs. worst-case pricing.
2026-04-12 12:47:11 +00:00
Julian Pawlowski
32b080d178 chore(scripts): improve release tooling with trailer filtering and Impact rendering
cliff.toml:
- Extract Impact footer value from commit footers and use as release note
  text when present, falling back to scope+message format otherwise
- Fix whitespace in body template (remove extra indentation)

scripts/release/generate-notes:
- Add RELEASE_NOTES_TRAILER_SKIP_FILTER to exclude commits marked with
  Release-Notes: skip, User-Impact: none, or Released-Bug: no trailers
- Add RELEASE_NOTES_COMPACT_DIFF and RELEASE_NOTES_DIFF_MAX_BYTES to
  limit AI diff context size for faster, more focused prompts
- Add RELEASE_NOTES_CLIFF_FILTER_PATHS to restrict cliff to user-facing
  paths only when generating AI-assisted notes
- Add RELEASE_NOTES_CLIFF_SINGLE_RELEASE to pass --latest to cliff
- Define USER_FACING_PATHS list for scoped AI diff context

Release-Notes: skip
User-Impact: none
2026-04-12 12:11:56 +00:00
Julian Pawlowski
6e990564b9 docs(agents): update script workflow guidance and commit behavior rules
AGENTS.md:
- Replace the minimal 'Type checking and linting' block with a full
  script selection guide including preferred dev flow (auto-healing),
  check-only flow, and agent behavior rules for fix vs. verify intent
- Add new scripts: format-all, lint-fix, lint-all, check-all
- Clarify commit execution rules: agents only run git commit on explicit
  user request; a one-time request does not authorize future commits;
  git push is never suggested or executed

CONTRIBUTING.md:
- Add reference to commit-messages.instructions.md for full commit rules
- Add trailer examples for suppressing release notes on internal fixes

Release-Notes: skip
User-Impact: none
2026-04-12 12:11:46 +00:00
Julian Pawlowski
b2d63c2b6d chore(scripts): add explicit format/fix/check modes for all file types
Split lint workflow into three clearly separated modes:

- scripts/format: Python-only formatting (Ruff format)
- scripts/lint-fix: Python-only lint auto-fixes (Ruff check --fix)
- scripts/lint: convenience wrapper (delegates to format + lint-fix)

Add all-in-one scripts covering Python and non-Python files (Prettier for
JSON/JSONC/Markdown/YAML, shfmt for shell scripts):

- scripts/format-all: format all file types
- scripts/check-all: check-only for all file types (CI/CD parity)
- scripts/lint-all: format-all + lint-fix in one command

Release-Notes: skip
User-Impact: none
2026-04-12 12:11:38 +00:00
Julian Pawlowski
3fda932442 chore(devcontainer): wire commit message instructions and align jsonc formatting
- Link commit-messages.instructions.md via commitMessageGeneration.instructions
  setting so the VS Code SCM Generate button applies project commit rules
- Add explicit editor.formatOnSave: true to [jsonc] language block,
  matching the existing [json] block for consistent behavior

Release-Notes: skip
User-Impact: none
2026-04-12 12:11:29 +00:00
Julian Pawlowski
a240393911 chore(github): add commit message instructions for VS Code and Copilot
Add .github/instructions/commit-messages.instructions.md with Conventional
Commit rules, Impact footer guidance, and release-notes skip trailers.

Wired to VS Code via github.copilot.chat.commitMessageGeneration.instructions
in devcontainer.json so the SCM Generate button uses these rules.

Release-Notes: skip
User-Impact: none
2026-04-12 12:11:23 +00:00
Julian Pawlowski
1d3c55097d fix(periods): rename periods_remaining to period_count_remaining
Consistent naming with the period_count_* family introduced in the
previous commit (period_count_total, period_count_today,
period_count_tomorrow).

periods_remaining was the last attribute in the navigation triplet
using the old plural form. Renamed to period_count_remaining to follow
the established pattern: all countable period metrics use the
period_count_* prefix.

BREAKING CHANGE: periods_remaining renamed to period_count_remaining.

Impact: All four period count attributes now share the same prefix
(period_count_total, period_count_today, period_count_tomorrow,
period_count_remaining), making automation templates more predictable.
2026-04-12 10:05:21 +00:00
Julian Pawlowski
779e22a84e fix(periods): replace redundant total attributes with per-day counts
Some checks are pending
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Waiting to run
Lint / Ruff (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
Validate / HACS validation (push) Waiting to run
Removed periods_found_total and replaced with period_count_today /
period_count_tomorrow. The old attribute counted all periods including
yesterday (coordinator scope), causing a discrepancy vs. the displayed
list (sensor scope, today+tomorrow only).

Renamed periods_total → period_count_total for naming consistency with
the new per-day attributes. Recalculate period_position / period_count_total /
periods_remaining after the today+tomorrow filter so all three navigation
attributes reflect the filtered scope.

period_count_tomorrow is always present (0 when no tomorrow data or no
periods found), enabling automations without default(0) guards.

Removed internal periods_found key from relaxation metadata — it was
only consumed by add_calculation_summary_attributes which is now removed.

BREAKING CHANGE: periods_found_total removed (replace with
period_count_today + period_count_tomorrow). periods_total renamed to
period_count_total.

Impact: Period navigation attributes (position/total/remaining) now
correctly reflect today+tomorrow scope. Per-day counts allow automations
to distinguish "2 periods today, 0 tomorrow" from "1+1".
2026-04-12 09:50:31 +00:00
Julian Pawlowski
9e1ba10f0b refactor(options_flow): optimize loading of override translations based on active overrides 2026-04-12 08:54:41 +00:00
Julian Pawlowski
a8d5230531 feat(periods): implement geometric_extension_attempted flag and time_range filtering
Phase 3: When geometric bonus intervals cause CV gate failure, strip them
from period edges (unextended boundaries) and set geometric_extension_attempted=True
on the summary. Previously only geometric_extension_active was tracked.
Moved LOW_PRICE_QUALITY_BYPASS_THRESHOLD constant to types.py for shared access.

Phase 4: Add time_range: tuple[datetime, datetime] | None parameter to
build_periods(), calculate_periods(), and calculate_periods_with_relaxation().
Filters candidate intervals to [start, end) without affecting day-wide reference
prices. Refactored _apply_segment_forcing() to use time_range instead of the
restricted_prices list approach.

Impact: Period statistics now accurately reflect when geometric flex extension
was attempted but reverted due to quality gate failure. Segment forcing uses
a cleaner API that preserves full price context for reference calculations.
2026-04-12 08:24:38 +00:00
Julian Pawlowski
796eb4b422 feat(periods): implement geometric_extension_attempted flag and time_range filtering
Phase 3: When geometric bonus intervals cause CV gate failure, strip them
from period edges (unextended boundaries) and set geometric_extension_attempted=True
on the summary. Previously only geometric_extension_active was tracked.
Moved LOW_PRICE_QUALITY_BYPASS_THRESHOLD constant to types.py for shared access.

Phase 4: Add time_range: tuple[datetime, datetime] | None parameter to
build_periods(), calculate_periods(), and calculate_periods_with_relaxation().
Filters candidate intervals to [start, end) without affecting day-wide reference
prices. Refactored _apply_segment_forcing() to use time_range instead of the
restricted_prices list approach.

Impact: Period statistics now accurately reflect when geometric flex extension
was attempted but reverted due to quality gate failure. Segment forcing uses
a cleaner API that preserves full price context for reference calculations.
2026-04-12 08:24:25 +00:00
Julian Pawlowski
4ddd19b132 feat(periods): geometric V-shape flex extension for period detection
Some checks are pending
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Waiting to run
Lint / Ruff (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
Validate / HACS validation (push) Waiting to run
Uses valley/peak knee points from day pattern analysis to grant extra
flex to price intervals that fall inside detected geometric zones,
making period detection more permissive within V-shape (best price)
or Λ-shape (peak price) price formations.

New options:
- CONF_BEST_PRICE_GEOMETRIC_FLEX (0-25%, default 0 = disabled)
- CONF_PEAK_PRICE_GEOMETRIC_FLEX (0-25%, default 0 = disabled)

Implementation:
- compute_geometric_flex_bonus() in level_filtering.py checks if
  interval falls inside valley/peak zone and returns extra_flex
- period_building.py applies geo bonus per-interval via
  criteria._replace(flex=...) and sets geometric_bonus_applied flag
- period_statistics.py reports geometric_extension_active and
  geometric_extension_intervals in period summaries
- Day patterns threaded through full pipeline:
  data_transformation → coordinator/core → periods →
  relaxation → calculate_periods → price_context
- UI sliders in both extension_settings sections
- Translations: en, de, nb, nl, sv

Impact: Users with clearly V-shaped or Λ-shaped daily price curves
can enable geometric flex to improve period detection accuracy within
those characteristic shapes without increasing global flex.
2026-04-11 21:49:24 +00:00
Julian Pawlowski
e44f639b41 feat(editorconfig,devcontainer): configure JSON formatting settings for consistency 2026-04-11 21:49:04 +00:00
Julian Pawlowski
b7f1efce1f feat(best_price,peak_price): add optional extension to VERY_CHEAP/VERY_EXPENSIVE intervals
After period detection, optionally walk left/right from each period boundary
to absorb adjacent VERY_CHEAP (best price) or VERY_EXPENSIVE (peak price)
intervals (step 7.5 in the pipeline).

New constants: CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP, CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS,
CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE, CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS.
Defaults: off / 4 intervals (1 hour per side). Hard maximum: 12 intervals (3 hours).

Config stored under "extension_settings" section, reflected in period hash
for correct cache invalidation.

New module: coordinator/period_handlers/shape_extension.py handles the
boundary walk, stat recalculation, and extension_intervals_added bookkeeping.

Impact: Users can opt-in to wider best/peak price windows that include
extreme-level adjacent intervals, reducing missed very cheap/expensive slots
at period edges.
2026-04-11 21:24:44 +00:00
Julian Pawlowski
447dc907e6 feat(sensors): add day pattern detection sensors (valley/peak/flat/rising/falling)
Introduces a new day_pattern.py module that analyses the 15-min price curve
for each calendar day (yesterday/today/tomorrow) and classifies its shape.

New sensors:
  day_pattern_yesterday / day_pattern_today / day_pattern_tomorrow
  EntityCategory.DIAGNOSTIC, SensorDeviceClass.ENUM

Patterns: valley, peak, double_valley, double_peak, flat, rising, falling, mixed

The detector uses centred-rolling smoothing, prominence-filtered extrema,
Kneedle-based knee detection, and monotone segment building.
Coordinator populates transformed_data["dayPatterns"] after priceInfo enrichment.

Impact: Users can trigger automations based on the shape of the day's price
curve, e.g. pre-heat when tomorrow is a valley day.
2026-04-11 21:07:16 +00:00
Julian Pawlowski
999ecd358f docs(user): collapse code blocks and normalize labels
Wrap most user-guide code examples in collapsible details sections to reduce visual noise while keeping Mermaid diagrams expanded.

Standardized summary labels across the user docs to a small set of readable patterns and removed technical or awkward wording from visible collapse titles.

Impact: User documentation is easier to scan, with long examples hidden by default and more consistent expand/collapse labels.
2026-04-11 19:51:58 +00:00
Julian Pawlowski
f6a49d9cf3 Merge branch 'main' of https://github.com/jpawlowski/hass.tibber_prices
# Conflicts:
#	docs/user/docs/actions.md
#	docs/user/docs/automation-examples.md
#	docs/user/sidebars.ts
2026-04-11 19:31:57 +00:00
Julian Pawlowski
4cc150df6f docs(actions): restructure into dedicated Actions category
Split monolithic actions.md into focused sub-pages:
- actions.md: lightweight overview with links
- scheduling-actions.md: renamed from scheduling-services.md
- chart-actions.md: get_chartdata + get_apexcharts_yaml
- data-actions.md: get_price + refresh_user_data

New  Actions sidebar category. Reference keeps only sensor-reference.
Updated cross-references in 6 files.

Impact: Clearer navigation — users find actions by purpose instead of
scrolling a long page. Consistent "actions" terminology (HA standard).
2026-04-11 19:25:17 +00:00
Julian Pawlowski
83ec3910bd docs(services): add scheduling services user documentation
New comprehensive documentation page for all 5 scheduling services:
- Decision flowchart for choosing the right service
- Detailed parameter reference with examples for each service
- Response format examples with realistic data
- Practical automation examples (overnight scheduling, EV charging,
  peak price avoidance)
- Power profile and search range explanations

Also updated:
- actions.md: Added scheduling services overview, entry_id as optional
- automation-examples.md: Cross-reference to scheduling services guide
- Sidebar: Added scheduling-services to Reference category

Impact: Users have comprehensive documentation with a decision guide,
practical examples, and automation templates for the new services.
2026-04-11 19:01:15 +00:00
Julian Pawlowski
9142f87abd docs(services): add scheduling services user documentation
New comprehensive documentation page for all 5 scheduling services:
- Decision flowchart for choosing the right service
- Detailed parameter reference with examples for each service
- Response format examples with realistic data
- Practical automation examples (overnight scheduling, EV charging,
  peak price avoidance)
- Power profile and search range explanations

Also updated:
- actions.md: Added scheduling services overview, entry_id as optional
- automation-examples.md: Cross-reference to scheduling services guide
- Sidebar: Added scheduling-services to Reference category

Impact: Users have comprehensive documentation with a decision guide,
practical examples, and automation templates for the new services.
2026-04-11 18:58:37 +00:00
Julian Pawlowski
6e0613c055 feat(services): add 5 scheduling services for price-optimized time windows
New services for finding optimal electricity price windows:
- find_cheapest_block: Cheapest contiguous time block (e.g., dishwasher)
- find_cheapest_hours: Cheapest N hours, non-contiguous (e.g., EV charging)
- find_cheapest_schedule: Multi-task scheduling with no-overlap (e.g., shared circuit)
- find_most_expensive_block: Most expensive contiguous block (peak avoidance)
- find_most_expensive_hours: Most expensive N hours (consumption shifting)

Key features:
- Flexible search range (today, tomorrow, today+tomorrow, rolling window)
- Power profile support for variable consumption patterns
- Price level filtering (e.g., only CHEAP/VERY_CHEAP intervals)
- Comparison details showing savings vs. alternatives
- Sliding window algorithm (O(n)) for block search, greedy scheduling
  for multi-task optimization

Also includes:
- Shared validation utilities (search range, price level, power profile)
- entry_id now optional on all services (auto-selects single home)
- Input validation for existing services (time range, filter conflicts)
- Service icons for all new and existing services
- Translations for all 5 languages (en, de, nb, nl, sv)
- Removed 10 unused config.error translation keys (replaced by exceptions)
- Tests for price window algorithms and search range resolution

Impact: Users can find optimal time windows for appliances, EV charging,
and multi-device scheduling via HA service calls. Existing services
improved with optional entry_id and better input validation.
2026-04-11 18:58:27 +00:00
Julian Pawlowski
8aa5769784 feat(devcontainer): add yq and additional CLI tools, improve compatibility symlinks
Some checks are pending
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Waiting to run
Lint / Ruff (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
Validate / HACS validation (push) Waiting to run
2026-04-11 15:56:14 +00:00
Julian Pawlowski
c7af02f7c2 feat(devcontainer): add common CLI tools and setup symlinks for compatibility 2026-04-11 15:17:20 +00:00
Julian Pawlowski
2f704a35a3 refactor: remove dead code across integration
Remove unused functions, constants, and entity definitions that were
left over from previous refactorings. All removed code was either
superseded by better implementations or never actually called.

Removed functions:
- entity_utils/helpers.py: translate_level(), translate_rating_level()
  (HA handles ENUM translation automatically via translations/*.json)
- entity_utils/attributes.py: build_timestamp_attribute(),
  build_period_attributes() (superseded by inline implementations)
- sensor/helpers.py: get_hourly_price_value(), aggregate_window_data()
  (replaced by Calculator Pattern in sensor/calculators/)

Removed constants and definitions:
- const.py: CONF_CHART_DATA_CONFIG (DATA_CHART_CONFIG is the active one),
  PRICE_LEVEL_OPTIONS, PRICE_RATING_OPTIONS, VOLATILITY_OPTIONS,
  PRICE_TREND_OPTIONS (never imported; options defined inline in
  definitions.py due to HA import timing constraints),
  async_get_home_type_translation() (sync version used instead)
- coordinator/core.py: FRESH_TO_CACHED_SECONDS (leftover from old
  caching strategy, never referenced)
- switch/definitions.py: BEST_PRICE_SWITCH_ENTITIES (duplicate of
  BEST_PRICE_SWITCH_ENTITY_DESCRIPTIONS using base class instead of
  custom TibberPricesSwitchEntityDescription subclass)

Cleanup:
- entity_utils/__init__.py: Remove exports for deleted functions
- sensor/helpers.py: Remove now-unused imports (timedelta,
  get_intervals_for_day_offsets, get_price_value, Callable)
- entity_utils/helpers.py: Remove unused get_price_level_translation
  import after translate_level() removal
- sensor/definitions.py: Update 7x "Keep in sync with *_OPTIONS"
  comments to reference individual PRICE_LEVEL_*/PRICE_RATING_*/
  VOLATILITY_* constants instead

Impact: No user-visible changes. Reduces codebase by ~130 lines.
Improves maintainability by eliminating misleading dead code.
2026-04-11 12:13:26 +00:00
Julian Pawlowski
d6bd933e90 docs(period-calculation): clarify Best Price definition and V-shaped price day behavior 2026-04-11 12:09:15 +00:00
Julian Pawlowski
07117801d2 fix(docs): correct inaccuracies and add missing documentation
Documentation fixes:
- configuration.md: Fix default min period length (30→60 min)
- configuration.md: Fix Average Sensor Display location (Step 6→General Settings)
- sensors-volatility.md: Add missing price_volatility attribute to table
- sensors-trends.md: Document undocumented next_price_trend_change_in sensor
- actions.md: Document undocumented get_price service

Code quality fixes:
- get_price.py: Fix misleading module docstring
- timing.py: Remove dead code (unreachable return None)
- binary_sensor/core.py: Simplify redundant tomorrow_data logic

Impact: Users get accurate documentation. No behavioral changes.
2026-04-11 11:51:52 +00:00
Julian Pawlowski
1db86d1766 feat(docs): improve sidebar and navbar UX
Disable autoCollapseCategories to prevent unwanted collapsing when
clicking an active category header twice.

Add hideOnScroll to navbar for more reading space on long pages.

Add category link targets in both sidebars so category headers are
clickable and navigate to the section overview page.

Impact: Sidebar navigation no longer collapses unexpectedly.
Category titles are now direct navigation links. Navbar hides while
scrolling, giving more screen space for content.
2026-04-11 11:20:10 +00:00
Julian Pawlowski
c610dbe1a3 fix(tests): move LogCaptureFixture import under TYPE_CHECKING for better type hinting 2026-04-11 11:05:55 +00:00
Julian Pawlowski
ac7cd5b572 fix(lint): apply Python 3.14 ruff rules and update HA minimum version
Add UP037 to ruff ignore list to preserve quoted TYPE_CHECKING forward
references (PEP 649 lazy eval breaks get_type_hints() at runtime for
TYPE_CHECKING-guarded imports).

Move datetime imports into TYPE_CHECKING blocks in sensor/calculators
timing.py and trend.py (TC003, type-only usage confirmed).

Apply PEP 758 parenthesis-free except clauses across 7 files via
ruff format with target-version=py314.

Update hacs.json minimum HA version to 2026.4.0, the first HA release
requiring Python 3.14.

Impact: Linter config now correctly handles Python 3.14 semantics.
Users need HA >= 2026.4 (Python 3.14) to use this integration.
2026-04-11 10:56:34 +00:00
Julian Pawlowski
f2f0d296d1 docs(readme): restructure as HACS storefront with updated features
Complete README rewrite focused on user value propositions instead
of exhaustive technical entity tables. Updated feature list to
reflect 100+ entities across sensors, binary sensors, switches,
and number platforms.

Key changes:
- "Why This Integration?" section with 5 value categories
- Compact entity highlights table replacing 6 detailed tables
- Added missing features: forecasts, trends, volatility, runtime
  config (switches/numbers), period timing, interval pool
- Fixed broken /user/sensors link → /user/sensor-reference
- Reduced automation examples to 2 highlights + link
- Moved troubleshooting to doc links (FAQ + Troubleshooting)
- Updated service: → action: in YAML examples

Impact: README now serves as an inviting HACS storefront that
showcases all integration capabilities and encourages users to
try it, instead of overwhelming with technical details.
2026-04-11 10:55:21 +00:00
Julian Pawlowski
cbbfadbf4f refactor(docs): restructure navigation and split large pages
Split sensors.md (1,693 lines) into 7 focused pages:
- sensors-overview: binary sensors, core price, min/max, diagnostics
- sensors-average: median/mean, automation examples, key attributes
- sensors-ratings-levels: 3-level ratings + 5-level system
- sensors-volatility: CV formula, 4 sensors, configuration
- sensors-trends: outlook, trajectory, current/next, decision guide
- sensors-timing: period timing state diagram + examples
- sensors-energy-tax: energy/tax breakdown + use cases

Extract relaxation deep-dive from period-calculation.md into
dedicated period-relaxation.md. Remove duplicate ApexCharts section
from automation-examples.md (cross-references chart-examples.md).

Reorganize sidebar into semantic categories:
- Sensors (7 pages), Price Periods (2), Dashboards & Charts (4),
  Reference (sensor-reference + actions)

Update all cross-references across 10 pages, EntitySearch DOC_NAMES,
and generator template for new page slugs.

Impact: Users can find information faster with shorter, focused pages
and a clearer navigation structure. No content was removed — only
reorganized and deduplicated.
2026-04-11 10:33:58 +00:00
Julian Pawlowski
c494d0e39d docs: remove outdated last updated and integration version notes from period calculation 2026-04-11 09:57:46 +00:00
Julian Pawlowski
c892d7376c docs: document entity reference system in AGENTS.md
Add documentation for the new multi-language entity reference system:
- Generator script usage (generate-sensor-reference, --check mode)
- Entity reference annotation guidelines for doc pages
- Updated scripts/check description to include reference freshness
- Reminder to regenerate reference after adding/renaming entities

Impact: Future sessions know how to maintain the entity reference
system and keep it in sync with translation file changes.
2026-04-11 09:56:10 +00:00
Julian Pawlowski
0e699ae142 feat(docs): add multi-language entity reference with search
Add a comprehensive entity reference system that helps users find
entities across all 5 supported languages (EN, DE, NO, NL, SV).

Core components:
- Generator script (scripts/docs/generate-sensor-reference) that
  builds sensor-reference.md from translation files with --check
  mode for CI validation
- EntityRef component for compact inline entity annotations with
  tooltip and version-aware linking to the reference table
- EntitySearch component with live filtering, clickable results,
  keyboard navigation, "/" shortcut to focus, category filter chips,
  match highlighting, copy-entity-ID button per row, back-links to
  documentation pages, persistent row highlights, hash-based deep
  linking, and mobile-responsive layout
- MDXComponents theme override for global component registration

Documentation updates:
- New sensor-reference.md page (115 entities x 5 languages)
- EntityRef annotations across 10 documentation pages
- Sidebar entry for quick navigation
- CI integration (docusaurus.yml + scripts/check)
- Ruff per-file-ignores for scripts/ (T201, INP001)

Impact: Users can now find any entity by its localized display name
regardless of their UI language. Inline EntityRef annotations link
directly to the multi-language lookup table with version-aware URLs.
2026-04-11 09:55:28 +00:00
Julian Pawlowski
cd59834277 Merge branch 'main' of https://github.com/jpawlowski/hass.tibber_prices
Some checks are pending
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Waiting to run
2026-04-11 08:30:43 +00:00
Julian Pawlowski
89de3dcadf docs(user): wrap large YAML blocks in collapsible details elements
All YAML code blocks >12-14 lines in example and concept pages are now
wrapped in HTML <details>/<summary> elements, reducing scroll depth
and making complex pages easier to navigate.

Pages affected:
- automation-examples.md: 9 blocks (heat pump, EV, battery automations)
- dashboard-examples.md: 6 blocks (button-card, mushroom, grid layouts)
- sensors.md: 7 blocks (practical examples, energy/tax templates, timing)
- icon-colors.md: 8 blocks (card_mod methods, complete dashboard, custom colors)
- period-calculation.md: 3 blocks (sensor attr reference, midnight example, automations)

Blocks left visible: short examples (<13 lines), Good/Bad comparisons,
Method 1 (primary recommended approach in icon-colors.md).

Impact: Users can scan page structure without scrolling past long YAML.
Code is still one click away, not hidden behind navigation.
2026-04-11 08:30:25 +00:00
dependabot[bot]
40a80247a0
chore(deps): bump the react group in /docs/user with 2 updates (#106) 2026-04-11 09:18:28 +02:00
dependabot[bot]
16f74d6419
chore(deps): bump the react group in /docs/developer with 2 updates (#107) 2026-04-11 09:18:16 +02:00
Julian Pawlowski
5314454a26 docs(user): add community examples page with NL solar feed-in templates
Some checks are pending
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Waiting to run
Lint / Ruff (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
Validate / HACS validation (push) Waiting to run
New dedicated page for community-contributed examples, starting with
Dutch solar feed-in compensation (saldering) patterns adapted from
GitHub Discussion #105 (OdynBrouwer). Includes input_number helpers
for country-specific tax rates, template sensors for feed-in with
and without saldering, smart export automation, and dashboard card.
German spot price share template as starting point. Norwegian/Swedish
sections as placeholders for future contributions.

YAML code blocks use collapsible details elements to keep the page
scannable. Page is framed generically to accommodate any type of
community example in the future (not just country-specific).

Added cross-reference from sensors.md Energy Price section and new
"Community" sidebar category.

Impact: Users (especially Netherlands) can find ready-to-adapt template
examples for country-specific price calculations using the energy_price
and tax attributes. Framework ready for additional community examples.
2026-04-10 14:57:02 +00:00
Julian Pawlowski
6e7b7b3ceb docs(agents): document entity migration & repairs pattern
Added Entity Migration & Breaking Change Repairs section (Common Pitfalls #6)
documenting the separation between migrations.py, coordinator/repairs.py, and
_migrate_config_options().

Additional changes:
- migrations.py added to allowed root files list and component structure tree
- Common Tasks "Add a new sensor": step 6 for ENTITY_KEY_RENAMES registration
- Duration sensor pattern (native=MINUTES, suggested=HOURS) documented

Impact: Future sessions generate correct migration code on first try without
rediscovering the pattern.
2026-04-10 12:21:55 +00:00
Julian Pawlowski
565397b8ca feat(migrations): add entity auto-migration system with HA repairs
Adds migrations.py with automatic entity registry migration for renamed
sensor keys. Separated from coordinator/repairs.py (runtime issues) and
__init__.py _migrate_config_options() (config format changes).

- ENTITY_KEY_RENAMES dict maps old→new entity keys (extensible)
- _auto_migrate_entity_keys() updates unique_id, preserves entity_id
- Handles partial migration (new entity already exists → remove old)
- Creates persistent HA repair issue after migration via ir.async_create_issue()
- Called in async_setup_entry() after _migrate_config_options()

Migrates: trend_change_in_minutes → next_price_trend_change_in

Repair issue informs users about:
- Auto-migrated entity renames (entity_id preserved, no action needed)
- Duration sensor value unit change (hours → minutes): update automation
  thresholds from `state < 0.25` to `state < 15` for 15-minute checks

All 5 language files (en, de, nb, nl, sv) updated with translations.

BREAKING CHANGE: Duration sensors (remaining time, starts in, period
duration, trend change countdown) now report state values in minutes
instead of hours. Display unit in dashboards remains hours by default.
Update numeric comparisons in automations accordingly.

Impact: Users upgrading from previous releases see an informational
repair notice guiding them through any required automation updates.
Entity renames are handled transparently with no loss of history.
2026-04-10 12:21:49 +00:00
Julian Pawlowski
2a08515ba0 fix(sensors): use consistent rounding for trend duration calculations
Some checks are pending
Lint / Ruff (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
Validate / HACS validation (push) Waiting to run
Replaced int(time.minutes_until()) with time.minutes_until_rounded()
in trend calculator (3 locations). The int() call truncated values
(14.7 → 14) while timing sensors used standard rounding (14.7 → 15).

All duration sensors now use the same rounding method
(math.floor(seconds/60 + 0.5)), matching HA's timestamp rendering
behavior.

Impact: Trend countdown values may differ by ±1 minute compared to
previous behavior. Consistency across all duration sensors improved.
2026-04-10 09:13:32 +00:00
Julian Pawlowski
faa3b2b71a feat(sensors)!: use native minutes for all duration sensors
Changed native_unit_of_measurement from HOURS to MINUTES for all 7
duration sensors. HA auto-converts to hours for display via
suggested_unit_of_measurement=HOURS.

Sensors affected:
- next_price_trend_change_in
- best_price_period_duration, best_price_remaining_minutes,
  best_price_next_in_minutes
- peak_price_period_duration, peak_price_remaining_minutes,
  peak_price_next_in_minutes

Removed _minutes_to_hours() conversion function — calculator values
(minutes) are now passed through directly.

BREAKING CHANGE: State values for all duration sensors change from
hours to minutes (e.g. 1.5 → 90). The display unit remains hours
(suggested_unit_of_measurement). Automations using numeric state
comparisons must be updated (multiply old thresholds by 60).

Impact: Users with automations comparing duration sensor states
numerically need to update thresholds. Dashboard display is unchanged
for new installations. Existing installations retain their configured
display unit but the underlying numeric value changes.
2026-04-10 09:08:38 +00:00
Julian Pawlowski
b1b41be9aa feat(sensors)!: rename trend change countdown sensor for naming consistency
Renamed trend_change_in_minutes → next_price_trend_change_in to align
with its sibling sensor next_price_trend_change (timestamp variant).

Follows the established best/peak price naming pattern where related
sensors share a common prefix (e.g. best_price_next_start_time /
best_price_next_in_minutes).

Updated entity key, translation key, friendly names (all 5 languages),
custom translations, coordinator constants, attribute routing, and
cache-clear mapping.

BREAKING CHANGE: Entity ID changes from
sensor.<home>_trend_change_in_minutes to
sensor.<home>_next_price_trend_change_in. Automations and dashboards
referencing the old entity ID must be updated.

Impact: Users with automations or dashboard cards referencing the old
sensor name need to update references. The sensor retains identical
functionality and attributes.
2026-04-10 09:08:27 +00:00
Julian Pawlowski
74cca1857a fix(docs): update image URLs in README for proper rendering
Some checks failed
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Waiting to run
Lint / Ruff (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
Validate / HACS validation (push) Waiting to run
Auto-Tag on Version Bump / Check and create version tag (push) Has been cancelled
2026-04-09 19:28:41 +00:00
Julian Pawlowski
112b169f26 feat(docs): enable mdx1 compatibility for admonitions in Docusaurus config 2026-04-09 19:20:15 +00:00
github-actions[bot]
84deafbdf5 docs: add version snapshot v0.30.0 and cleanup old versions [skip ci] 2026-04-09 19:07:16 +00:00
606 changed files with 120239 additions and 68995 deletions

View file

@ -0,0 +1,11 @@
{
"permissions": {
"allow": [
"Bash(./scripts/lint-check)",
"Bash(./scripts/type-check)",
"Bash(./scripts/test tests/services/test_plan_charging.py tests/services/test_energy_calculator.py tests/services/test_power_scheduler.py)",
"Bash(./scripts/test)",
"Bash(./scripts/check)"
]
}
}

View file

@ -1,6 +1,4 @@
{
"recommendations": [],
"unwantedRecommendations": [
"ms-python.pylint"
]
"unwantedRecommendations": ["ms-python.pylint"]
}

View file

@ -1,17 +1,13 @@
{
"name": "jpawlowski/hass.tibber_prices",
"image": "mcr.microsoft.com/devcontainers/python:3.14",
"image": "mcr.microsoft.com/devcontainers/base:debian",
"postCreateCommand": "bash .devcontainer/setup-git.sh && scripts/setup/setup",
"postStartCommand": "scripts/motd",
"containerEnv": {
"PYTHONASYNCIODEBUG": "1",
"TIBBER_PRICES_DEV": "1"
},
"forwardPorts": [
8123,
3000,
3001
],
"forwardPorts": [8123, 3000, 3001],
"portsAttributes": {
"8123": {
"label": "Home Assistant",
@ -38,7 +34,8 @@
"ms-python.vscode-pylance",
"ms-vscode-remote.remote-containers",
"redhat.vscode-yaml",
"ryanluker.vscode-coverage-gutters"
"ryanluker.vscode-coverage-gutters",
"MermaidChart.vscode-mermaid-chart"
],
"settings": {
"editor.tabSize": 4,
@ -56,9 +53,7 @@
"reportUnusedCoroutine": "none",
"reportMissingTypeStubs": "none"
},
"python.analysis.include": [
"custom_components/tibber_prices"
],
"python.analysis.include": ["custom_components/tibber_prices"],
"python.analysis.exclude": [
"**/.venv/**",
"**/venv/**",
@ -69,14 +64,20 @@
"**/node_modules/**"
],
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
"python.analysis.extraPaths": [
"${workspaceFolder}/.venv/lib/python3.14/site-packages"
],
"python.analysis.extraPaths": ["${workspaceFolder}/.venv/lib/python3.14/site-packages"],
"python.terminal.activateEnvironment": true,
"python.terminal.activateEnvInCurrentTerminal": true,
"python.testing.pytestArgs": [
"--no-cov"
],
"python.testing.pytestArgs": ["--no-cov"],
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.tabSize": 2
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.tabSize": 2
},
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.formatOnSave": true,
@ -102,18 +103,19 @@
"markdown.validate.fragmentLinks.enabled": "ignore",
"json.schemas": [
{
"fileMatch": [
"homeassistant/components/*/manifest.json"
],
"fileMatch": ["homeassistant/components/*/manifest.json"],
"url": "${containerWorkspaceFolder}/schemas/json/manifest_schema.json"
},
{
"fileMatch": [
"homeassistant/components/*/translations/*.json"
],
"fileMatch": ["homeassistant/components/*/translations/*.json"],
"url": "${containerWorkspaceFolder}/schemas/json/translation_schema.json"
}
],
"github.copilot.chat.commitMessageGeneration.instructions": [
{
"file": ".github/instructions/commit-messages.instructions.md"
}
],
"git.useConfigOnly": false
}
}
@ -127,18 +129,44 @@
"ghcr.io/flexwie/devcontainer-features/op:1": {
"version": "latest"
},
"ghcr.io/devcontainers/features/node:1": {
"ghcr.io/devcontainers/features/node:2": {
"version": "24"
},
"ghcr.io/devcontainers/features/rust:1": {
"version": "latest",
"profile": "minimal"
},
"ghcr.io/devcontainer-community/devcontainer-features/yq:1": {
"version": "latest"
},
"ghcr.io/devcontainers-extra/features/apt-packages:1": {
"packages": [
"autoconf",
"automake",
"bat",
"eza",
"fd-find",
"ffmpeg",
"fzf",
"git-delta",
"httpie",
"hyperfine",
"ipython3",
"jo",
"jq",
"libpcap-dev",
"libssl-dev",
"libtool",
"libturbojpeg0",
"libpcap-dev"
"miller",
"moreutils",
"pipx",
"ripgrep",
"shellcheck",
"shfmt",
"sqlite3",
"tree",
"yamllint"
]
}
}

View file

@ -51,15 +51,15 @@ if grep -q '^\[alias\]' ~/.gitconfig.host; then
# First, collect all aliases from host config
TEMP_ALIASES=$(mktemp)
sed -n '/^\[alias\]/,/^\[/p' ~/.gitconfig.host | \
grep -v '^\[' | \
grep -v '^$' | \
sed -n '/^\[alias\]/,/^\[/p' ~/.gitconfig.host |
grep -v '^\[' |
grep -v '^$' |
while IFS= read -r line; do
# Skip aliases with macOS-specific paths
if echo "$line" | grep -q -E '/(Applications|usr/local)'; then
continue
fi
echo "$line" >> "$TEMP_ALIASES"
echo "$line" >>"$TEMP_ALIASES"
done
# Apply each alias (git config --global overwrites existing values = idempotent)
@ -68,8 +68,8 @@ if grep -q '^\[alias\]' ~/.gitconfig.host; then
ALIAS_NAME=$(echo "$line" | awk '{print $1}')
ALIAS_VALUE=$(echo "$line" | sed "s/^$ALIAS_NAME = //")
git config --global "alias.$ALIAS_NAME" "$ALIAS_VALUE" 2>/dev/null || true
done < "$TEMP_ALIASES"
echo " Synced $(wc -l < "$TEMP_ALIASES") aliases"
done <"$TEMP_ALIASES"
echo " Synced $(wc -l <"$TEMP_ALIASES") aliases"
fi
rm -f "$TEMP_ALIASES"

View file

@ -1,6 +1,7 @@
# top-most EditorConfig file
root = true
# Default settings - AI-friendly baseline
[*]
charset = utf-8
end_of_line = lf
@ -9,9 +10,71 @@ trim_trailing_whitespace = true
indent_style = space
indent_size = 4
# Python - Home Assistant & Ruff defaults (120 chars)
[*.py]
# Python style aligns with Black
indent_size = 4
max_line_length = 120
# YAML - Home Assistant configs, GitHub workflows
[*.{yaml,yml}]
indent_size = 2
# JSON - manifest.json, translations, etc.
[*.json]
indent_size = 2
# Markdown - READMEs, docs (preserve AI formatting)
[*.md]
indent_size = 2
trim_trailing_whitespace = false
max_line_length = off
# TOML - pyproject.toml, Python packaging
[*.toml]
indent_size = 4
[*.md]
trim_trailing_whitespace = false
# Shell scripts - setup scripts, CI/CD
[*.{sh,bash}]
indent_size = 4
end_of_line = lf
# JavaScript/TypeScript - Frontend panel development
[*.{js,ts,jsx,tsx,mjs,cjs}]
indent_size = 2
# CSS/SCSS - Frontend styling
[*.{css,scss,sass}]
indent_size = 2
# HTML - Lovelace cards, frontend templates
[*.html]
indent_size = 2
# XML - Android Auto integration, etc.
[*.xml]
indent_size = 2
# Jinja2 templates - Home Assistant templates
[*.jinja,*.jinja2,*.j2]
indent_size = 2
# Makefiles require tabs
[Makefile]
indent_style = tab
# GitHub-specific files
[.github/workflows/*.{yml,yaml}]
indent_size = 2
[.github/dependabot.{yml,yaml}]
indent_size = 2
# Docker files
[Dockerfile*]
indent_size = 2
[*.dockerignore]
indent_size = 2
[docker-compose*.{yml,yaml}]
indent_size = 2

2
.github/FUNDING.yml vendored
View file

@ -1,4 +1,4 @@
# These are supported funding model platforms
github: [ jpawlowski ]
github: [jpawlowski]
buy_me_a_coffee: jpawlowski

View file

@ -3,30 +3,30 @@ name: "Bug report"
description: "Report a bug with the custom integration"
labels: ["bug"]
body:
- type: markdown
- type: markdown
attributes:
value: Before you open a new issue, search through the existing issues to see if others have had the same problem.
- type: input
- type: input
attributes:
label: "Home Assistant version"
description: "The version of Home Assistant you are using"
placeholder: "2025.1.0"
validations:
required: true
- type: input
- type: input
attributes:
label: "Integration version"
description: "The version of this custom integration you are using"
placeholder: "1.0.0"
validations:
required: false
- type: textarea
- type: textarea
attributes:
label: "System Health details"
description: "Paste the data from the System Health card in Home Assistant (https://www.home-assistant.io/more-info/system-health#github-issues)"
validations:
required: false
- type: checkboxes
- type: checkboxes
attributes:
label: Checklist
options:
@ -38,13 +38,13 @@ body:
required: true
- label: This issue is not a duplicate issue of any [previous issues](https://github.com/jpawlowski/hass.tibber_prices/issues?q=is%3Aissue+label%3A%22Bug%22+)..
required: true
- type: textarea
- type: textarea
attributes:
label: "Describe the issue"
description: "A clear and concise description of what the issue is."
validations:
required: true
- type: textarea
- type: textarea
attributes:
label: Reproduction steps
description: "Without steps to reproduce, it will be hard to fix. It is very important that you fill out this part. Issues without it will be closed."
@ -55,7 +55,7 @@ body:
...
validations:
required: true
- type: textarea
- type: textarea
attributes:
label: "Debug logs"
description: "To enable debug logs check this https://www.home-assistant.io/integrations/logger/, this **needs** to include _everything_ from startup of Home Assistant to the point where you encounter the issue."
@ -63,7 +63,7 @@ body:
validations:
required: true
- type: textarea
- type: textarea
attributes:
label: "Diagnostics dump"
description: "Drag the diagnostics dump file here. (see https://www.home-assistant.io/integrations/diagnostics/ for info)"

View file

@ -3,10 +3,10 @@ name: "Feature request"
description: "Suggest an idea for this custom integration"
labels: ["Feature request"]
body:
- type: markdown
- type: markdown
attributes:
value: Before you open a new feature request, search through the existing feature requests to see if others have had the same idea.
- type: checkboxes
- type: checkboxes
attributes:
label: Checklist
options:
@ -17,7 +17,7 @@ body:
- label: This issue is not a duplicate feature request of [previous feature requests](https://github.com/jpawlowski/hass.tibber_prices/issues?q=is%3Aissue+label%3A%22Feature+Request%22+).
required: true
- type: textarea
- type: textarea
attributes:
label: "Is your feature request related to a problem? Please describe."
description: "A clear and concise description of what the problem is."
@ -25,21 +25,21 @@ body:
validations:
required: true
- type: textarea
- type: textarea
attributes:
label: "Describe the solution you'd like"
description: "A clear and concise description of what you want to happen."
validations:
required: true
- type: textarea
- type: textarea
attributes:
label: "Describe alternatives you've considered"
description: "A clear and concise description of any alternative solutions or features you've considered."
validations:
required: true
- type: textarea
- type: textarea
attributes:
label: "Additional context"
description: "Add any other context or screenshots about the feature request here."

View file

@ -0,0 +1,95 @@
---
description: "Use when writing or suggesting git commit messages, deciding commit type/scope, or preparing release-note-relevant commit trailers."
---
# Commit Message Rules (Release-Notes Aware)
Use these rules whenever you generate or suggest commit messages.
## Primary Goal
Write technically correct Conventional Commit messages while ensuring release notes only include user-relevant changes.
## Required Format
Use this structure:
<type>(<scope>): <short summary>
<body>
Impact: <user-facing outcome>
### Notes
- Keep summary imperative and concise.
- Keep body technical (what changed and why).
- Keep Impact user-facing (what users notice).
## Type Selection
- Use feat for new user-visible capability.
- Use fix only for user-visible bug fixes.
- Use perf for user-visible reliability/performance improvements.
- Use docs, test, refactor, chore, ci, build for non-user-facing work.
## Critical Rule: Internal/Unreleased Fixes
If a fix addresses code that was not released to users yet, DO NOT treat it as a user-facing fix.
In that case:
- Prefer chore(...) or refactor(...) instead of fix(...), and/or
- Add an explicit trailer in the commit body:
- Release-Notes: skip
- User-Impact: none
- Released-Bug: no
Any one of these trailers is enough.
## How To Decide Released vs Unreleased
When uncertain whether users were affected, check if the introducing commit was part of a release tag:
./scripts/release/check-if-released <commit-hash>
Interpretation:
- NOT RELEASED -> treat as internal/non-user-facing.
- ALREADY RELEASED -> user-facing fix is possible.
## Release Notes Alignment
This repository's release notes generator excludes commits with any of these trailers:
- Release-Notes: skip
- User-Impact: none
- Released-Bug: no
Therefore, add one of them whenever you intentionally want to exclude a commit from release notes.
## Examples
### User-facing fix
fix(config_flow): prevent setup failure on invalid home selection
Validate home selection before entry creation to avoid runtime errors when stale API data is returned.
Impact: Setup wizard no longer fails for users when home data changes during configuration.
### Internal-only fix for unreleased code
chore(periods): adjust extension guard for new geometric matcher
Tune guard conditions in the new matcher implementation to avoid edge-case misclassification during development.
User-Impact: none
### Alternative with explicit skip marker
fix(periods): correct follow-up edge case in unreleased geometric matcher
Adjust comparison threshold in iterative matcher pass.
Release-Notes: skip

25
.github/workflows/auto-assign.yml vendored Normal file
View file

@ -0,0 +1,25 @@
---
name: Auto-assign
on:
issues:
types:
- opened
jobs:
auto-assign:
name: Assign to owner
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Assign issue to owner
uses: actions/github-script@v9
with:
script: |
await github.rest.issues.addAssignees({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
assignees: [context.repo.owner],
});

View file

@ -5,7 +5,7 @@ on:
branches:
- main
paths:
- 'custom_components/tibber_prices/manifest.json'
- "custom_components/tibber_prices/manifest.json"
permissions:
contents: write

View file

@ -4,10 +4,10 @@ on:
push:
branches: [main]
paths:
- 'docs/**'
- '.github/workflows/docusaurus.yml'
- "docs/**"
- ".github/workflows/docusaurus.yml"
tags:
- 'v*.*.*'
- "v*.*.*"
workflow_dispatch:
# Concurrency control: cancel in-progress deployments
@ -47,11 +47,20 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: 24
cache: 'npm'
cache: "npm"
cache-dependency-path: |
docs/user/package-lock.json
docs/developer/package-lock.json
# VERIFY GENERATED DOCS
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.14"
- name: Verify sensor reference is up-to-date
run: python3 scripts/docs/generate-sensor-reference --check
# USER DOCS BUILD
- name: Install user docs dependencies
working-directory: docs/user
@ -154,7 +163,7 @@ jobs:
uses: actions/configure-pages@v6
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
uses: actions/upload-pages-artifact@v5
with:
path: ./deploy-root

View file

@ -5,14 +5,14 @@ on:
branches:
- "main"
paths-ignore:
- 'docs/**'
- '.github/workflows/docusaurus.yml'
- "docs/**"
- ".github/workflows/docusaurus.yml"
pull_request:
branches:
- "main"
paths-ignore:
- 'docs/**'
- '.github/workflows/docusaurus.yml'
- "docs/**"
- ".github/workflows/docusaurus.yml"
permissions: {}
@ -34,7 +34,7 @@ jobs:
python-version: "3.14"
- name: Install uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: "0.9.3"

View file

@ -3,11 +3,11 @@ name: Generate Release Notes
on:
push:
tags:
- 'v*.*.*' # Triggers on version tags like v1.0.0, v2.1.3, etc.
- "v*.*.*" # Triggers on version tags like v1.0.0, v2.1.3, etc.
workflow_dispatch:
inputs:
tag:
description: 'Tag version to release (e.g., v0.3.0)'
description: "Tag version to release (e.g., v0.3.0)"
required: true
type: string
@ -255,7 +255,7 @@ jobs:
- name: Create GitHub Release
if: steps.version_check.outputs.warning == ''
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
name: ${{ steps.release_notes.outputs.title }}
body: ${{ steps.release_notes.outputs.notes }}

View file

@ -8,14 +8,14 @@ on:
branches:
- main
paths-ignore:
- 'docs/**'
- '.github/workflows/docusaurus.yml'
- "docs/**"
- ".github/workflows/docusaurus.yml"
pull_request:
branches:
- main
paths-ignore:
- 'docs/**'
- '.github/workflows/docusaurus.yml'
- "docs/**"
- ".github/workflows/docusaurus.yml"
permissions: {}

View file

@ -1,9 +1,20 @@
{
"default": true,
"MD004": false,
"MD013": false,
"MD033": false,
"MD036": false,
"MD041": false,
"no-inline-html": false,
"line-length": false,
"first-line-heading": false
"no-trailing-punctuation": false,
"no-inline-html": {
"allowed_elements": ["br", "details", "summary", "img", "a", "kbd"]
},
"code-block-style": {
"style": "fenced"
},
"emphasis-style": {
"style": "underscore"
},
"strong-style": {
"style": "asterisk"
}
}

View file

@ -11,6 +11,12 @@ __pycache__/
env/
venv/
# Ignore compiled YAML or generated docs
*.yaml
*.yml
# Ignore local HA dev instance config (not production code)
config/
# Ignore YAML schemas (structural files with specific formatting conventions)
schemas/yaml/
# Ignore Docusaurus documentation sites they have their own toolchain
# and Prettier reformats <details> blocks inside lists in a way that breaks MDX
docs/

37
.prettierrc.yaml Normal file
View file

@ -0,0 +1,37 @@
# Prettier configuration for Home Assistant Custom Component Development
# Aligned with .editorconfig and .markdownlint.json
printWidth: 120
tabWidth: 2
useTabs: false
semi: true
singleQuote: false
quoteProps: "as-needed"
trailingComma: "es5"
bracketSpacing: true
arrowParens: "always"
proseWrap: "preserve"
endOfLine: "lf"
# File-specific overrides
overrides:
# Markdown - preserve formatting, avoid conflicts with markdownlint
- files: "*.md"
options:
proseWrap: "preserve"
printWidth: 120
trailingComma: "none"
# JSON - Home Assistant manifest, translations
- files: "*.json"
options:
tabWidth: 2
trailingComma: "none"
# JSONC - VS Code settings, devcontainer config
- files: "*.jsonc"
options:
tabWidth: 2
trailingComma: "none"
# YAML would go here, but it's in .prettierignore (handled by redhat.vscode-yaml)

535
AGENTS.md

File diff suppressed because it is too large Load diff

11
CODEOWNERS Normal file
View file

@ -0,0 +1,11 @@
# CODEOWNERS
#
# This file defines code owners for this repository.
# Code owners are automatically requested for review when a pull request
# modifies files they own.
#
# See: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
#
# NOTE: This file is updated automatically by initialize.sh when using the blueprint.
* @jpawlowski

View file

@ -72,7 +72,18 @@ Impact: <user-visible effects>
**Types:** `feat`, `fix`, `docs`, `refactor`, `chore`, `test`
For full commit-message rules (including release-note skip trailers for internal/unreleased fixes), see:
- `.github/instructions/commit-messages.instructions.md`
Important trailers for commits that should NOT appear in release notes:
- `Release-Notes: skip`
- `User-Impact: none`
- `Released-Bug: no`
**Example:**
```bash
git commit -m "feat(sensors): add daily average price sensor
@ -81,7 +92,7 @@ Added new sensor that calculates average price for the entire day.
Impact: Users can now track daily average prices for cost analysis."
```
See [`AGENTS.md`](AGENTS.md) section "Git Workflow Guidance" for detailed guidelines.
See `.github/instructions/commit-messages.instructions.md` for detailed commit-message guidelines.
## Submitting Changes
@ -111,6 +122,7 @@ See [`AGENTS.md`](AGENTS.md) section "Git Workflow Guidance" for detailed guidel
- **Python version**: 3.13+
Always run before committing:
```bash
./scripts/lint
```
@ -136,6 +148,7 @@ Documentation is organized in two Docusaurus sites:
- Navigation via `docs/developer/sidebars.ts`
**When adding new documentation:**
1. Place file in appropriate `docs/*/docs/` directory
2. Add to corresponding `sidebars.ts` for navigation
3. Update translations when changing `translations/en.json` (update ALL language files)
@ -145,6 +158,7 @@ Documentation is organized in two Docusaurus sites:
Report bugs via [GitHub Issues](../../issues/new/choose).
**Great bug reports include:**
- Quick summary and background
- Steps to reproduce (be specific!)
- Expected vs. actual behavior

385
README.md
View file

@ -1,7 +1,7 @@
# Tibber Prices - Custom Home Assistant Integration
<p align="center">
<img src="images/header.svg" alt="Tibber Prices Custom Integration for Tibber" width="600">
<img src="https://raw.githubusercontent.com/jpawlowski/hass.tibber_prices/main/images/header.svg" alt="Tibber Prices Custom Integration for Tibber" width="600">
</p>
[![GitHub Release][releases-shield]][releases]
@ -11,372 +11,184 @@
[![hacs][hacsbadge]][hacs]
[![Project Maintenance][maintenance-shield]][user_profile]
<a href="https://www.buymeacoffee.com/jpawlowski" target="_blank"><img src="images/bmc-button.svg" alt="Buy Me A Coffee" height="41" width="174"></a>
<a href="https://www.buymeacoffee.com/jpawlowski" target="_blank"><img src="https://raw.githubusercontent.com/jpawlowski/hass.tibber_prices/main/images/bmc-button.svg" alt="Buy Me A Coffee" height="41" width="174"></a>
> **⚠️ Not affiliated with Tibber**
> This is an independent, community-maintained custom integration for Home Assistant. It is **not** an official Tibber product and is **not** affiliated with or endorsed by Tibber AS.
A custom Home Assistant integration that provides advanced electricity price information and ratings from Tibber. This integration fetches **quarter-hourly** electricity prices, enriches them with statistical analysis, and provides smart indicators to help you optimize your energy consumption and save money.
**The most comprehensive Tibber price integration for Home Assistant.** Get 100+ sensors with quarter-hourly precision, intelligent best/peak price period detection, price forecasts, trend analysis, volatility tracking, and beautiful chart visualizations - all from a single integration. Automate your energy consumption like a pro.
## 📖 Documentation
**[📚 Complete Documentation](https://jpawlowski.github.io/hass.tibber_prices/)** - Two comprehensive documentation sites:
**[📚 Complete Documentation](https://jpawlowski.github.io/hass.tibber_prices/)** — Installation, guides, examples, and full sensor reference:
- **[👤 User Documentation](https://jpawlowski.github.io/hass.tibber_prices/user/)** - Installation, configuration, usage guides, and examples
- **[🔧 Developer Documentation](https://jpawlowski.github.io/hass.tibber_prices/developer/)** - Architecture, contributing guidelines, and development setup
- **[👤 User Documentation](https://jpawlowski.github.io/hass.tibber_prices/user/)** — Setup, sensors, automations, dashboards
- **[🔧 Developer Documentation](https://jpawlowski.github.io/hass.tibber_prices/developer/)** — Architecture, contributing, development
**Quick Links:**
- [Installation Guide](https://jpawlowski.github.io/hass.tibber_prices/user/installation) - Step-by-step setup instructions
- [Sensor Reference](https://jpawlowski.github.io/hass.tibber_prices/user/sensors) - Complete list of all sensors and attributes
- [Chart Examples](https://jpawlowski.github.io/hass.tibber_prices/user/chart-examples) - ApexCharts visualizations
- [Automation Examples](https://jpawlowski.github.io/hass.tibber_prices/user/automation-examples) - Real-world automation scenarios
- [Changelog](https://github.com/jpawlowski/hass.tibber_prices/releases) - Release history and notes
[Installation](https://jpawlowski.github.io/hass.tibber_prices/user/installation) · [Sensor Reference](https://jpawlowski.github.io/hass.tibber_prices/user/sensor-reference) · [Charts](https://jpawlowski.github.io/hass.tibber_prices/user/chart-examples) · [Automations](https://jpawlowski.github.io/hass.tibber_prices/user/automation-examples) · [FAQ](https://jpawlowski.github.io/hass.tibber_prices/user/faq) · [Changelog](https://github.com/jpawlowski/hass.tibber_prices/releases)
## ✨ Features
## ✨ Why This Integration?
- **Quarter-Hourly Price Data**: Access detailed 15-minute interval pricing (384 data points across 4 days: day before yesterday/yesterday/today/tomorrow)
- **Flexible Currency Display**: Choose between base currency (€, kr) or subunit (ct, øre) display - configurable per your preference with smart defaults
- **Multi-Currency Support**: Automatic detection and formatting for EUR, NOK, SEK, DKK, USD, and GBP
- **Price Level Indicators**: Know when you're in a VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, or VERY_EXPENSIVE period
- **Statistical Sensors**: Track lowest, highest, and average prices for the day
- **Price Ratings**: Quarter-hourly ratings comparing current prices to 24-hour trailing averages
- **Smart Indicators**: Binary sensors to detect peak hours and best price hours for automations
- **Beautiful ApexCharts**: Auto-generated chart configurations with dynamic Y-axis scaling ([see examples](https://jpawlowski.github.io/hass.tibber_prices/user/chart-examples))
- **Chart Metadata Sensor**: Dynamic chart configuration for optimal visualization
- **Intelligent Caching**: Minimizes API calls while ensuring data freshness across Home Assistant restarts
- **Custom Actions** (backend services): API endpoints for advanced integrations (ApexCharts support included)
- **Diagnostic Sensors**: Monitor data freshness and availability
- **Reliable API Usage**: Uses only official Tibber [`priceInfo`](https://developer.tibber.com/docs/reference#priceinfo) and [`priceInfoRange`](https://developer.tibber.com/docs/reference#subscription) endpoints - no legacy APIs. Price ratings and statistics are calculated locally for maximum reliability and future-proofing.
Most Tibber integrations give you a single price sensor. This one gives you a **complete energy optimization toolkit**:
### 🔮 Know What's Coming
- **Quarter-hourly precision** — 15-minute interval prices, not just hourly averages
- **Price forecasts** — See average prices for the next 1h, 2h, 3h, ... up to 12h ahead
- **Trend analysis** — Know if prices are rising, falling, or stable — and when the next trend change happens
- **Price trajectory** — Detect turning points before they happen (first-half vs second-half window comparison)
- **Price outlook** — Instantly see if the next hours will be cheaper or more expensive than now
### ⚡ Automate Smartly
- **Best Price & Peak Price Periods** — Intelligent binary sensors that detect the cheapest and most expensive periods of the day, with configurable flexibility, relaxation strategies, and gap tolerance ([how it works](https://jpawlowski.github.io/hass.tibber_prices/user/period-calculation))
- **Period timing sensors** — Duration, end time, remaining minutes, progress percentage, and countdown to next period — everything you need for advanced automations
- **Runtime configuration** — Adjust period detection parameters on the fly via switches and number entities, without restarting — perfect for automations that adapt to your schedule
- **5-level price classification** — VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, VERY_EXPENSIVE from Tibber's API
- **3-level price ratings** — LOW, NORMAL, HIGH based on 24h trailing average comparison
### 📊 Visualize Beautifully
- **Auto-generated ApexCharts** — One action call generates a complete chart configuration with dynamic Y-axis scaling and color-coded price levels ([see examples](https://jpawlowski.github.io/hass.tibber_prices/user/chart-examples))
- **Dynamic icons & colors** — Every sensor adapts its icon and color to the current price state — cheap prices glow green, expensive ones turn red ([icon guide](https://jpawlowski.github.io/hass.tibber_prices/user/dynamic-icons))
- **Chart data export** — Flexible data API with filtering, resolution control, and multiple output formats for any visualization card
### 📈 Understand Your Market
- **Volatility analysis** — Know if today's prices are stable or wild (low/moderate/high/very_high)
- **Daily & rolling statistics** — Min, max, average, median for today, tomorrow, trailing 24h, and leading 24h
- **Energy & tax breakdown** — See spot price vs. tax components as sensor attributes
- **Multi-currency support** — EUR, NOK, SEK, DKK, USD, GBP with configurable base/subunit display (€ vs ct, kr vs øre)
### 🛡️ Built for Reliability
- **Intelligent caching** — Multi-layer caching minimizes API calls, survives HA restarts, auto-invalidates at midnight
- **High-performance interval pool** — O(1) timestamp lookups, gap detection, auto-fetching of missing data
- **Quarter-hour precision updates** — Sensors refresh at :00/:15/:30/:45 boundaries, independent of API polling
- **Official API only** — Uses Tibber's [`priceInfo`](https://developer.tibber.com/docs/reference#priceinfo) and [`priceInfoRange`](https://developer.tibber.com/docs/reference#subscription) endpoints. All ratings and statistics are calculated locally.
## 🚀 Quick Start
### Step 1: Install the Integration
### Step 1: Install via HACS
**Prerequisites:** This integration requires [HACS](https://hacs.xyz/) (Home Assistant Community Store) to be installed.
Click the button below to open the integration directly in HACS:
**Prerequisites:** [HACS](https://hacs.xyz/) (Home Assistant Community Store) must be installed.
[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
Then:
1. Click "Download" to install
2. **Restart Home Assistant**
1. Click "Download" to install the integration
2. **Restart Home Assistant** (required after installation)
> **Note:** The My Home Assistant redirect will first take you to a landing page. Click the button there to open your Home Assistant instance. If the repository is not yet in the HACS default store, HACS will ask if you want to add it as a custom repository.
### Step 2: Add and Configure the Integration
**Important:** You must have installed the integration first (see Step 1) and restarted Home Assistant!
#### Option 1: One-Click Setup (Quick)
Click the button below to open the configuration dialog:
### Step 2: Configure
[![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=tibber_prices)
This will guide you through:
1. Enter your Tibber API token ([get one here](https://developer.tibber.com/settings/access-token))
2. Select your Tibber home
3. Configure price thresholds (optional)
3. Configure price thresholds (optional — sensible defaults are provided)
#### Option 2: Manual Configuration
Or manually: **Settings****Devices & Services****+ Add Integration** → search "Tibber Price Information & Ratings"
1. Go to **Settings** → **Devices & Services**
2. Click **"+ Add Integration"**
3. Search for "Tibber Price Information & Ratings"
4. Follow the configuration steps (same as Option 1)
### Step 3: Done!
### Step 3: Start Using!
- 30+ sensors are now available (key sensors enabled by default)
- Configure additional sensors in **Settings****Devices & Services****Tibber Price Information & Ratings** → **Entities**
- Use sensors in automations, dashboards, and scripts
- **100+ sensors** are now available (key sensors enabled by default, advanced ones ready to enable)
- Explore entities in **Settings****Devices & Services** → **Tibber Price Information & Ratings**
- Start building automations, dashboards, and energy-saving workflows
📖 **[Full Installation Guide →](https://jpawlowski.github.io/hass.tibber_prices/user/installation)**
## 📊 Available Entities
## 📊 What You Get
The integration provides **30+ sensors** across different categories. Key sensors are enabled by default, while advanced sensors can be enabled as needed.
The integration provides **100+ entities** across sensors, binary sensors, switches, and number entities. Here are the highlights — all key sensors are **enabled by default**:
> **Rich Sensor Attributes**: All sensors include extensive attributes with timestamps, context data, and detailed explanations. Enable **Extended Descriptions** in the integration options to add `long_description` and `usage_tips` attributes to every sensor, providing in-context documentation directly in Home Assistant's UI.
<img src="https://raw.githubusercontent.com/jpawlowski/hass.tibber_prices/main/docs/user/static/img/entities-overview.jpg" width="400" alt="Entity list showing dynamic icons for different price states">
**[📋 Complete Sensor Reference](https://jpawlowski.github.io/hass.tibber_prices/user/sensors)** - Full list with descriptions and attributes
| Category | Highlights | Count |
| ----------------------- | ----------------------------------------------------------------------------- | ----- |
| **💰 Prices** | Current, next & previous interval price + rolling hour averages | 6+ |
| **📊 Statistics** | Daily min/max/avg for today & tomorrow, 24h trailing & leading windows | 12+ |
| **🔮 Forecasts** | Next 1h12h average prices, price outlook & trajectory sensors | 20+ |
| **📈 Trends** | Current trend direction, next trend change time & countdown | 3 |
| **📉 Volatility** | Today, tomorrow, next 24h & combined volatility levels | 4 |
| **🏷️ Levels & Ratings** | 5-level (API) and 3-level (computed) classification per interval, hour & day | 12+ |
| **⏰ Period Timing** | Best/peak: end time, duration, remaining, progress, next start | 10+ |
| **🔌 Binary Sensors** | Best price period, peak price period, tomorrow data available, API connection | 4+ |
| **🎛️ Runtime Config** | Switches & numbers to adjust period detection live — no restart needed | 14 |
| **🔧 Diagnostics** | Data lifecycle status, home metadata, grid info, subscription status | 15+ |
### Core Price Sensors (Enabled by Default)
> **Every sensor includes rich attributes** — timestamps, detailed descriptions, and context data. Enable **Extended Descriptions** in the integration options to get `long_description` and `usage_tips` on every entity.
| Entity | Description |
| -------------------------- | ------------------------------------------------- |
| Current Electricity Price | Current 15-minute interval price |
| Next Interval Price | Price for the next 15-minute interval |
| Current Hour Average Price | Average of current hour's 4 intervals |
| Next Hour Average Price | Average of next hour's 4 intervals |
| Current Price Level | API classification (VERY_CHEAP to VERY_EXPENSIVE) |
| Next Interval Price Level | Price level for next interval |
| Current Hour Price Level | Price level for current hour average |
| Next Hour Price Level | Price level for next hour average |
📖 **[Complete Sensor Reference →](https://jpawlowski.github.io/hass.tibber_prices/user/sensor-reference)** — All entities with descriptions, attributes, and multi-language lookup
### Statistical Sensors (Enabled by Default)
## 🤖 Automation Sneak Peek
| Entity | Description |
| ------------------------- | ------------------------------------------- |
| Today's Lowest Price | Minimum price for today |
| Today's Highest Price | Maximum price for today |
| Today's Average Price | Mean price across today's intervals |
| Tomorrow's Lowest Price | Minimum price for tomorrow (when available) |
| Tomorrow's Highest Price | Maximum price for tomorrow (when available) |
| Tomorrow's Average Price | Mean price for tomorrow (when available) |
| Leading 24h Average Price | Average of next 24 hours from now |
| Leading 24h Minimum Price | Lowest price in next 24 hours |
| Leading 24h Maximum Price | Highest price in next 24 hours |
> See the **[full automation examples guide](https://jpawlowski.github.io/hass.tibber_prices/user/automation-examples)** for more recipes.
### Price Rating Sensors (Enabled by Default)
| Entity | Description |
| -------------------------- | --------------------------------------------------------- |
| Current Price Rating | % difference from 24h trailing average (current interval) |
| Next Interval Price Rating | % difference from 24h trailing average (next interval) |
| Current Hour Price Rating | % difference for current hour average |
| Next Hour Price Rating | % difference for next hour average |
> **How ratings work**: Compares each interval to the average of the previous 96 intervals (24 hours). Positive values mean prices are above average, negative means below average.
### Binary Sensors (Enabled by Default)
| Entity | Description |
| ------------------------- | ----------------------------------------------------------------------------------------- |
| Peak Price Period | ON when in a detected peak price period ([how it works](https://jpawlowski.github.io/hass.tibber_prices/user/period-calculation)) |
| Best Price Period | ON when in a detected best price period ([how it works](https://jpawlowski.github.io/hass.tibber_prices/user/period-calculation)) |
| Tibber API Connection | Connection status to Tibber API |
| Tomorrow's Data Available | Whether tomorrow's price data is available |
### Diagnostic Sensors (Enabled by Default)
| Entity | Description |
| --------------- | ------------------------------------------ |
| Data Expiration | Timestamp when current data expires |
| Price Forecast | Formatted list of upcoming price intervals |
### Additional Sensors (Disabled by Default)
The following sensors are available but disabled by default. Enable them in `Settings > Devices & Services > Tibber Price Information & Ratings > Entities`:
- **Previous Interval Price** & **Previous Interval Price Level**: Historical data for the last 15-minute interval
- **Previous Interval Price Rating**: Rating for the previous interval
- **Trailing 24h Average Price**: Average of the past 24 hours from now
- **Trailing 24h Minimum/Maximum Price**: Min/max in the past 24 hours
> **Note**: Currency display is configurable during setup. Choose between:
> - **Base currency** (€/kWh, kr/kWh) - decimal values, differences visible from 3rd-4th decimal
> - **Subunit** (ct/kWh, øre/kWh) - larger values, differences visible from 1st decimal
>
> Smart defaults: EUR → subunit (German/Dutch preference), NOK/SEK/DKK → base (Scandinavian preference). Supported currencies: EUR, NOK, SEK, DKK, USD, GBP.
## Automation Examples> **Note:** See the [full automation examples guide](https://jpawlowski.github.io/hass.tibber_prices/user/automation-examples) for more advanced recipes.
### Run Appliances During Cheap Hours
Use the `binary_sensor.tibber_best_price_period` to automatically start appliances during detected best price periods:
**Run appliances when electricity is cheapest:**
```yaml
automation:
- alias: "Run Dishwasher During Cheap Hours"
- alias: "Start Dishwasher During Best Price Period"
trigger:
- platform: state
entity_id: binary_sensor.tibber_best_price_period
to: "on"
condition:
- condition: time
after: "21:00:00"
before: "06:00:00"
action:
- service: switch.turn_on
- action: switch.turn_on
target:
entity_id: switch.dishwasher
```
> **Learn more:** The [period calculation guide](https://jpawlowski.github.io/hass.tibber_prices/user/period-calculation) explains how Best/Peak Price periods are identified and how you can configure filters (flexibility, minimum distance from average, price level filters with gap tolerance).
### Notify on Extremely High Prices
Get notified when prices reach the VERY_EXPENSIVE level:
**Reduce heating when prices spike above average:**
```yaml
automation:
- alias: "Notify on Very Expensive Electricity"
trigger:
- platform: state
entity_id: sensor.tibber_current_interval_price_level
to: "VERY_EXPENSIVE"
action:
- service: notify.mobile_app
data:
title: "⚠️ High Electricity Prices"
message: "Current electricity price is in the VERY EXPENSIVE range. Consider reducing consumption."
```
### Temperature Control Based on Price Ratings
Adjust heating/cooling when current prices are significantly above the 24h average:
```yaml
automation:
- alias: "Reduce Heating During High Price Ratings"
- alias: "Reduce Heating During High Prices"
trigger:
- platform: numeric_state
entity_id: sensor.tibber_current_interval_price_rating
above: 20 # More than 20% above 24h average
action:
- service: climate.set_temperature
- action: climate.set_temperature
target:
entity_id: climate.living_room
data:
temperature: 19 # Lower target temperature
temperature: 19
```
### Smart EV Charging Based on Tomorrow's Prices
📖 **[More automations →](https://jpawlowski.github.io/hass.tibber_prices/user/automation-examples)** — EV charging, heat pump control, price notifications, and more
Start charging when tomorrow's prices drop below today's average:
## 📈 Chart Visualizations
```yaml
automation:
- alias: "Smart EV Charging"
trigger:
- platform: state
entity_id: binary_sensor.tibber_best_price_interval
to: "on"
condition:
- condition: numeric_state
entity_id: sensor.tibber_current_interval_price_rating
below: -15 # At least 15% below average
- condition: numeric_state
entity_id: sensor.ev_battery_level
below: 80
action:
- service: switch.turn_on
target:
entity_id: switch.ev_charger
```
Generate beautiful price charts with a single action call — dynamic Y-axis, color-coded price levels, and multiple chart modes included.
## Troubleshooting
<img src="https://raw.githubusercontent.com/jpawlowski/hass.tibber_prices/main/docs/user/static/img/charts/rolling-window.jpg" width="600" alt="Dynamic 48h rolling window chart with color-coded price levels">
### No data appearing
📖 **[Chart examples & setup →](https://jpawlowski.github.io/hass.tibber_prices/user/chart-examples)** | **[Actions reference →](https://jpawlowski.github.io/hass.tibber_prices/user/actions)**
1. Check your API token is valid at [developer.tibber.com](https://developer.tibber.com/settings/access-token)
2. Verify you have an active Tibber subscription
3. Check the Home Assistant logs for detailed error messages (`Settings > System > Logs`)
4. Restart the integration: `Settings > Devices & Services > Tibber Price Information & Ratings > ⋮ > Reload`
## ❓ Help & Support
### Missing tomorrow's price data
- Tomorrow's price data typically becomes available between **13:00 and 15:00** each day (Nordic time)
- The integration automatically checks more frequently during this window
- Check `binary_sensor.tibber_tomorrows_data_available` to see if data is available
- If data is unavailable after 15:00, verify it's available in the Tibber app first
### Prices not updating at quarter-hour boundaries
- Entities automatically refresh at 00/15/30/45-minute marks without waiting for API polls
- Check `sensor.tibber_data_expiration` to verify data freshness
- The integration caches data intelligently and survives Home Assistant restarts
### Currency or units showing incorrectly
- Currency is automatically detected from your Tibber account
- Display mode (base currency vs. subunit) can be configured in integration options: `Settings > Devices & Services > Tibber Price Information & Ratings > Configure`
- Supported currencies: EUR, NOK, SEK, DKK, USD, and GBP
- Smart defaults apply: EUR users get subunit (ct), Scandinavian users get base currency (kr)
## Advanced Features
### Sensor Attributes
Every sensor includes rich attributes beyond just the state value. These attributes provide context, timestamps, and additional data useful for automations and templates.
**Standard attributes available on most sensors:**
- `timestamp` - ISO 8601 timestamp for the data point
- `description` - Brief explanation of what the sensor represents
- `level_id` and `level_value` - For price level sensors (e.g., `VERY_CHEAP` = -2)
**Extended descriptions** (enable in integration options):
- `long_description` - Detailed explanation of the sensor's purpose
- `usage_tips` - Practical suggestions for using the sensor in automations
**Example - Current Price sensor attributes:**
```yaml
timestamp: "2025-11-03T14:15:00+01:00"
description: "The current electricity price per kWh"
long_description: "Shows the current price per kWh from your Tibber subscription"
usage_tips: "Use this to track prices or to create automations that run when electricity is cheap"
```
**Example template using attributes:**
```yaml
template:
- sensor:
- name: "Price Status"
state: >
{% set price = states('sensor.tibber_current_electricity_price') | float %}
{% set timestamp = state_attr('sensor.tibber_current_electricity_price', 'timestamp') %}
Price at {{ timestamp }}: {{ price }} ct/kWh
```
📖 **[View all sensors and attributes →](https://jpawlowski.github.io/hass.tibber_prices/user/sensors)**
### Dynamic Icons & Visual Indicators
All sensors feature dynamic icons that change based on price levels, providing instant visual feedback in your dashboards.
<img src="docs/user/static/img/entities-overview.jpg" width="400" alt="Entity list showing dynamic icons for different price states">
_Dynamic icons adapt to price levels, trends, and period states - showing CHEAP prices, FALLING trend, and active Best Price Period_
📖 **[Dynamic Icons Guide →](https://jpawlowski.github.io/hass.tibber_prices/user/dynamic-icons)** | **[Icon Colors Guide →](https://jpawlowski.github.io/hass.tibber_prices/user/icon-colors)**
### Custom Actions
The integration provides custom actions (they still appear as services under the hood) for advanced use cases. These actions show up in Home Assistant under **Developer Tools → Actions**.
- `tibber_prices.get_chartdata` - Get price data in chart-friendly formats for any visualization card
- `tibber_prices.get_apexcharts_yaml` - Generate complete ApexCharts configurations
- `tibber_prices.refresh_user_data` - Manually refresh account information
📖 **[Action documentation and examples →](https://jpawlowski.github.io/hass.tibber_prices/user/actions)**
### Chart Visualizations (Optional)
The integration includes built-in support for creating price visualization cards with automatic Y-axis scaling and color-coded series.
<img src="docs/user/static/img/charts/rolling-window.jpg" width="600" alt="Example: Dynamic 48h rolling window chart">
_Optional: Dynamic 48h chart with automatic Y-axis scaling - generated via `get_apexcharts_yaml` action_
📖 **[Chart examples and setup guide →](https://jpawlowski.github.io/hass.tibber_prices/user/chart-examples)**
- 📖 **[FAQ](https://jpawlowski.github.io/hass.tibber_prices/user/faq)** — Common questions answered
- 🔧 **[Troubleshooting](https://jpawlowski.github.io/hass.tibber_prices/user/troubleshooting)** — Solving common issues
- 🐛 **[Report an Issue](https://github.com/jpawlowski/hass.tibber_prices/issues/new)** — Found a bug? Let us know
## 🤝 Contributing
Contributions are welcome! Please read the [Contributing Guidelines](CONTRIBUTING.md) and [Developer Documentation](https://jpawlowski.github.io/hass.tibber_prices/developer/) before submitting pull requests.
Contributions are welcome! See the [Contributing Guidelines](CONTRIBUTING.md) and [Developer Documentation](https://jpawlowski.github.io/hass.tibber_prices/developer/) to get started.
### For Contributors
- **[Developer Setup](https://jpawlowski.github.io/hass.tibber_prices/developer/setup)** - Get started with DevContainer
- **[Architecture Guide](https://jpawlowski.github.io/hass.tibber_prices/developer/architecture)** - Understand the codebase
- **[Release Management](https://jpawlowski.github.io/hass.tibber_prices/developer/release-management)** - Release process and versioning
- **[Developer Setup](https://jpawlowski.github.io/hass.tibber_prices/developer/setup)** — DevContainer-based development environment
- **[Architecture Guide](https://jpawlowski.github.io/hass.tibber_prices/developer/architecture)** — Understand the codebase
- **[Release Management](https://jpawlowski.github.io/hass.tibber_prices/developer/release-management)** — Release process and versioning
## 🤖 Development Note
This integration is developed with extensive AI assistance (GitHub Copilot, Claude, and other AI tools). While AI enables rapid development and helps implement complex features, it's possible that some edge cases or subtle bugs may exist that haven't been discovered yet. If you encounter any issues, please [open an issue](https://github.com/jpawlowski/hass.tibber_prices/issues/new) - we'll work on fixing them (with AI help, of course! 😊).
This integration is developed with extensive AI assistance (GitHub Copilot, Claude, and other AI tools). While AI enables rapid development, it's possible that some edge cases haven't been discovered yet. If you encounter any issues, please [open an issue](https://github.com/jpawlowski/hass.tibber_prices/issues/new) — we'll fix them (with AI help, of course! 😊).
The integration is actively maintained and benefits from AI's ability to quickly understand and implement Home Assistant patterns, maintain consistency across the codebase, and handle complex data transformations. Quality is ensured through automated linting (Ruff), Home Assistant's type checking, and real-world testing.
Quality is ensured through automated linting (Ruff), static type checking (Pyright), and real-world testing.
## 📄 License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
This project is licensed under the MIT License — see the [LICENSE](LICENSE) file for details.
---
@ -386,7 +198,6 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
[commits]: https://github.com/jpawlowski/hass.tibber_prices/commits/main
[hacs]: https://github.com/hacs/integration
[hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge
[exampleimg]: https://raw.githubusercontent.com/jpawlowski/hass.tibber_prices/main/images/example.png
[license-shield]: https://img.shields.io/github/license/jpawlowski/hass.tibber_prices.svg?style=for-the-badge
[maintenance-shield]: https://img.shields.io/badge/maintainer-%40jpawlowski-blue.svg?style=for-the-badge
[user_profile]: https://github.com/jpawlowski

View file

@ -5,13 +5,19 @@
# Template for the changelog body
header = ""
body = """
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}
{% for commit in commits %}
- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}{{ commit.message | upper_first }}\
{% if commit.breaking %} [**BREAKING**]{% endif %} \
([{{ commit.id | truncate(length=7, end="") }}](https://github.com/jpawlowski/hass.tibber_prices/commit/{{ commit.id }}))
{% endfor %}
{% for group, commits in commits | group_by(attribute="group") -%}
### {{ group | striptags | trim | upper_first }}
{% for commit in commits -%}
{% set impact_text = "" -%}
{% set footers = commit.footers | default(value=[]) -%}
{% for footer in footers -%}
{% if footer.token == "Impact" -%}
{% set impact_text = footer.value -%}
{% endif -%}
{% endfor -%}
- {% if impact_text %}{{ impact_text | trim | upper_first }}{% else %}{% if commit.scope %}**{{ commit.scope }}**: {% endif %}{{ commit.message | upper_first }}{% endif %}{% if commit.breaking %} [**BREAKING**]{% endif %} ([{{ commit.id | truncate(length=7, end="") }}](https://github.com/jpawlowski/hass.tibber_prices/commit/{{ commit.id }}))
{% endfor %}
{% endfor %}
---
@ -25,7 +31,8 @@ trim = true
[git]
# Parse conventional commits
conventional_commits = true
# Include all commits (even non-conventional)
# Keep unconventional commits in parsing pipeline; parser rules decide what to skip.
# This avoids noisy parse-error warnings on older commit history.
filter_unconventional = false
split_commits = false
@ -33,22 +40,28 @@ split_commits = false
commit_parsers = [
# Skip manifest.json version bumps (release housekeeping)
{ message = "^chore\\(release\\): bump version", skip = true },
# Skip explicit revert commits; final net state should drive release notes
{ message = "^revert", skip = true },
# Skip development environment changes (not user-relevant)
{ message = "^(feat|fix|chore|refactor)\\((devcontainer|vscode|scripts|dev-env|environment)\\):", skip = true },
# Skip CI/CD infrastructure changes (not user-relevant)
{ message = "^(feat|fix|chore|ci)\\((ci|workflow|actions|github-actions)\\):", skip = true },
# Keep dependency updates - these ARE relevant for users
{ message = "^chore\\(deps\\):", group = "📦 Dependencies" },
# Regular commit types
{ message = "^feat", group = "🎉 New Features" },
{ message = "^fix", group = "🐛 Bug Fixes" },
{ message = "^docs?", group = "📚 Documentation" },
{ message = "^perf", group = "⚡ Performance" },
{ message = "^refactor", group = "🔧 Maintenance & Refactoring" },
{ message = "^style", group = "🎨 Styling" },
{ message = "^test", group = "🧪 Testing" },
{ message = "^chore", group = "🔧 Maintenance & Refactoring" },
{ message = "^build", group = "📦 Build" },
# Skip non-user-facing fix scopes
{ message = "^fix\\((docs|lint|types|tests?|ci|workflow|scripts|devcontainer|vscode|build|release)\\):", skip = true },
# User-facing categories aligned with AI output style
{ message = "^feat", group = "🎉 What's New" },
{ message = "^fix", group = "🐛 Fixed" },
{ message = "^perf", group = "⚡ More Reliable" },
{ message = "^chore\\(deps\\):", group = "📦 Updated Dependencies" },
# Skip mostly developer-facing categories
{ message = "^docs?", skip = true },
{ message = "^refactor", skip = true },
{ message = "^style", skip = true },
{ message = "^test", skip = true },
{ message = "^build", skip = true },
{ message = "^chore", skip = true },
# Final fallback to avoid ungrouped commits
{ message = ".*", skip = true },
]
# Protect breaking changes
@ -56,5 +69,5 @@ commit_preprocessors = [
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/jpawlowski/hass.tibber_prices/issues/${2}))" },
]
# Filter out commits
filter_commits = false
# Apply commit parser filtering rules
filter_commits = true

View file

@ -1,23 +1,58 @@
# Development-friendly config that excludes go2rtc which has compatibility issues
# yaml-language-server: $schema=../schemas/yaml/configuration_schema.yaml
# Development-friendly Home Assistant configuration
#
# We don't use default_config to avoid HA OS-specific integrations like go2rtc
# that expect specific container environments. Instead, we explicitly load
# the integrations useful for custom component development.
# https://www.home-assistant.io/integrations/homeassistant/
homeassistant:
debug: true
# Disable analytics, diagnostics and error reporting for development instance
# Debugging integration
# https://www.home-assistant.io/integrations/debugpy/
debugpy:
# Privacy & analytics settings
# https://www.home-assistant.io/integrations/analytics/
analytics:
# Disable usage analytics to prevent skewing production statistics
# https://analytics.home-assistant.io should only reflect real user installations
# Analytics are disabled to prevent development instances from skewing
# production statistics at https://analytics.home-assistant.io
# System monitoring
# https://www.home-assistant.io/integrations/system_health/
system_health:
# Provides system health information in Settings > System > Repairs
# Safe for development - only shows local system status
# https://www.home-assistant.io/integrations/diagnostics/
# Note: Diagnostics integration cannot be disabled, but without analytics
# and with internal_url set, no data is sent externally
# Note: The diagnostics integration is always loaded and cannot be disabled.
# With analytics disabled, diagnostic data stays local and isn't sent anywhere.
# Core integrations needed for development
# Core integrations
http:
# Development server settings for Codespaces/DevContainer
server_host: "0.0.0.0"
# Disable IP banning for development to avoid lockouts
ip_ban_enabled: false
# Allow access from Codespaces reverse proxy
use_x_forwarded_for: true
trusted_proxies:
- 127.0.0.0/8
- ::1
- 192.168.0.0/16
- 172.16.0.0/12
- 10.0.0.0/8
# CORS for development
cors_allowed_origins:
- "*"
# Config UI integration - useful for development
config:
# Frontend - required for UI
frontend:
# Optional: Enable custom themes
# themes: !include_dir_merge_named themes
automation:
@ -25,13 +60,106 @@ script:
scene:
# Useful default_config integrations for development
# https://www.home-assistant.io/integrations/history/
history:
# https://www.home-assistant.io/integrations/logbook/
logbook:
# https://www.home-assistant.io/integrations/conversation/
# conversation:
# Note: Uncomment to enable voice assistant/conversation features
# Dependencies (hassil, home-assistant-intents) are pre-installed in bootstrap
# https://www.home-assistant.io/integrations/webhook/
webhook:
# https://www.home-assistant.io/integrations/my/
my:
# https://www.home-assistant.io/integrations/recorder/
recorder:
# Development-friendly database settings
# Reduce database size and improve performance
purge_keep_days: 2
commit_interval: 30
# Exclude entities you don't need history for
exclude:
domains:
# Sun position changes constantly, rarely needed in dev
- sun
# Backups don't need history
- backup
# Updates don't need full history tracking
- update
entity_globs:
# Time sensors change every second/minute
- sensor.time*
- sensor.date*
# Uptime sensors not interesting for development
- sensor.*uptime*
- sensor.*last_boot*
# Memory/CPU sensors create a lot of data
- sensor.*memory*
- sensor.*cpu*
event_types:
# Very frequent, rarely needed in development
- call_service
# System events create lots of noise
- system_log_event
# Component loading events
- component_loaded
energy:
# https://www.home-assistant.io/integrations/logger/
logger:
default: info
logs:
# Main integration logger - applies to ALL sub-loggers by default
# Reduce noise from chatty components
homeassistant.components.recorder: warning
homeassistant.components.recorder.util: warning
homeassistant.components.websocket_api: warning
homeassistant.components.http.ban: warning
homeassistant.components.zeroconf: warning
homeassistant.components.ssdp: warning
homeassistant.components.bluetooth: warning
# Conversation can be noisy with hassil
homeassistant.components.conversation: warning
# Analytics/metrics are not interesting during development
homeassistant.components.analytics: error
# Hide platform setup messages (scene, binary_sensor, sensor, etc.)
homeassistant.components.scene: warning
homeassistant.components.binary_sensor: warning
homeassistant.components.sensor: warning
homeassistant.components.event: warning
homeassistant.components.switch: warning
# HTTP/network
homeassistant.components.http: warning
# Keep loader at warning level to see real issues with our integration
homeassistant.loader: warning
# Hide the verbose "Setting up X" messages during startup
# but keep warnings/errors visible
homeassistant.bootstrap: warning
homeassistant.setup: warning
# Core system - keep visible for important messages
homeassistant.core: info
# IMPORTANT for custom integration development:
# Coordinator issues (API calls, update failures)
homeassistant.helpers.update_coordinator: info
# Entity registration problems
homeassistant.helpers.entity_registry: info
# Config flow debugging (setup, options)
homeassistant.config_entries: info
# Your integration debug logging - shows EVERYTHING from your integration
custom_components.tibber_prices: debug
# Reduce verbosity for details loggers (change to 'debug' for deep debugging)

View file

@ -7,6 +7,8 @@ https://github.com/jpawlowski/hass.tibber_prices
from __future__ import annotations
from pathlib import Path
import shutil
from typing import TYPE_CHECKING, Any
import voluptuous as vol
@ -41,6 +43,7 @@ from .interval_pool import (
async_remove_pool_storage,
async_save_pool_state,
)
from .migrations import check_entity_migrations
from .services import async_setup_services
if TYPE_CHECKING:
@ -92,6 +95,53 @@ CONFIG_SCHEMA = vol.Schema(
)
def _install_blueprints(config_dir: str) -> None:
"""Copy bundled blueprints to the HA config blueprints directory.
Always overwrites existing files so blueprints stay in sync with the
integration version. Removes orphan files that are no longer shipped.
Handles both automation and script blueprint domains.
"""
for bp_domain in ("automation", "script"):
src = Path(__file__).parent / "blueprints" / bp_domain
dst = Path(config_dir) / "blueprints" / bp_domain / DOMAIN
if not src.is_dir():
LOGGER.debug("No bundled %s blueprints directory found, skipping", bp_domain)
continue
dst.mkdir(parents=True, exist_ok=True)
shipped: set[str] = set()
for src_file in src.rglob("*.yaml"):
rel = src_file.relative_to(src)
# Only copy files from the tibber_prices sub-folder
if rel.parts[0] != DOMAIN:
continue
dest_file = Path(config_dir) / "blueprints" / bp_domain / rel
dest_file.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src_file, dest_file)
shipped.add(rel.parts[-1])
# Remove orphan blueprints no longer shipped with the integration
if dst.is_dir():
for existing in dst.glob("*.yaml"):
if existing.name not in shipped:
existing.unlink()
LOGGER.info("Removed orphan %s blueprint %s", bp_domain, existing.name)
LOGGER.debug("Installed %d bundled %s blueprints to %s", len(shipped), bp_domain, dst)
def _remove_blueprints(config_dir: str) -> None:
"""Remove all integration-managed blueprints from the config directory."""
for bp_domain in ("automation", "script"):
bp_dir = Path(config_dir) / "blueprints" / bp_domain / DOMAIN
if bp_dir.is_dir():
shutil.rmtree(bp_dir)
LOGGER.info("Removed bundled %s blueprints directory %s", bp_domain, bp_dir)
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
"""Set up the Tibber Prices component from configuration.yaml."""
# Store chart export configuration in hass.data for sensor access
@ -119,6 +169,9 @@ async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
LOGGER.debug("No chart_metadata configuration found in configuration.yaml")
hass.data[DOMAIN][DATA_CHART_METADATA_CONFIG] = {}
# Blueprints are kept in the repo but not distributed yet.
# await hass.async_add_executor_job(_install_blueprints, hass.config.config_dir)
return True
@ -222,6 +275,9 @@ async def async_setup_entry(
# Migrate config options if needed (e.g., set default currency display mode for existing configs)
await _migrate_config_options(hass, entry)
# Check for entity migrations (renames, breaking changes) and create repairs
check_entity_migrations(hass, entry)
# Preload translations to populate the cache
await async_load_translations(hass, "en")
await async_load_standard_translations(hass, "en")
@ -362,6 +418,11 @@ async def async_remove_entry(
await async_remove_pool_storage(hass, entry.entry_id)
LOGGER.debug(f"[tibber_prices] async_remove_entry removed interval pool storage for entry_id={entry.entry_id}")
# Blueprints are kept in the repo but not distributed yet.
# remaining = [e for e in hass.config_entries.async_entries(DOMAIN) if e.entry_id != entry.entry_id]
# if not remaining:
# await hass.async_add_executor_job(_remove_blueprints, hass.config.config_dir)
async def async_reload_entry(
hass: HomeAssistant,

View file

@ -4,16 +4,16 @@ from __future__ import annotations
import asyncio
import base64
from datetime import datetime, timedelta
import logging
import re
import socket
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any
from zoneinfo import ZoneInfo
import aiohttp
from homeassistant.util import dt as dt_utils
from homeassistant.util import dt as dt_util
from .exceptions import (
TibberPricesApiClientAuthenticationError,
@ -21,12 +21,7 @@ from .exceptions import (
TibberPricesApiClientError,
TibberPricesApiClientPermissionError,
)
from .helpers import (
flatten_price_info,
prepare_headers,
verify_graphql_response,
verify_response_or_raise,
)
from .helpers import flatten_price_info, prepare_headers, verify_graphql_response, verify_response_or_raise
from .queries import TibberPricesQueryType
if TYPE_CHECKING:
@ -163,9 +158,7 @@ class TibberPricesApiClient:
"""
# Import here to avoid circular dependency (interval_pool imports TibberPricesApiClient)
from custom_components.tibber_prices.interval_pool import ( # noqa: PLC0415
get_price_intervals_for_range,
)
from custom_components.tibber_prices.interval_pool import get_price_intervals_for_range # noqa: PLC0415
price_info = await get_price_intervals_for_range(
api_client=self,
@ -581,7 +574,7 @@ class TibberPricesApiClient:
"""
Calculate day before yesterday midnight in home's timezone.
CRITICAL: Uses REAL TIME (dt_utils.now()), NOT TimeService.now().
CRITICAL: Uses REAL TIME (dt_util.now()), NOT TimeService.now().
This ensures API boundary calculations are based on actual current time,
not simulated time from TimeService.
@ -594,7 +587,7 @@ class TibberPricesApiClient:
"""
# Get current REAL time (not TimeService)
now = dt_utils.now()
now = dt_util.now()
# Convert to home's timezone or fallback to HA system timezone
if home_timezone:
@ -607,10 +600,10 @@ class TibberPricesApiClient:
home_timezone,
error,
)
now_in_home_tz = dt_utils.as_local(now)
now_in_home_tz = dt_util.as_local(now)
else:
# Fallback to HA system timezone
now_in_home_tz = dt_utils.as_local(now)
now_in_home_tz = dt_util.as_local(now)
# Calculate day before yesterday midnight
return (now_in_home_tz - timedelta(days=2)).replace(hour=0, minute=0, second=0, microsecond=0)
@ -640,7 +633,7 @@ class TibberPricesApiClient:
Timezone-aware datetime object.
"""
return dt_utils.parse_datetime(timestamp_str) or dt_utils.now()
return dt_util.parse_datetime(timestamp_str) or dt_util.now()
def _calculate_cursor_for_home(self, home_timezone: str | None) -> str:
"""

View file

@ -7,6 +7,7 @@ from typing import TYPE_CHECKING
from custom_components.tibber_prices.const import get_display_unit_factor
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
from custom_components.tibber_prices.entity_utils import add_icon_color_attribute
from custom_components.tibber_prices.sensor.attributes.metadata import _find_current_segment_in_data
# Constants for price display conversion
_SUBUNIT_FACTOR = 100 # Conversion factor for subunit currency (ct/øre)
@ -25,6 +26,57 @@ if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
def get_current_phase_type(coordinator_data: dict, *, time: TibberPricesTimeService) -> str | None:
"""
Return the type of the currently active intra-day price phase.
Delegates to the shared segment finder in sensor/attributes/metadata.py.
Args:
coordinator_data: The coordinator's data dict.
time: TibberPricesTimeService instance.
Returns:
Phase type string or None if no segment data is available.
"""
if not coordinator_data:
return None
current_index, segments = _find_current_segment_in_data(coordinator_data, time=time)
if current_index is None or segments is None:
return None
return segments[current_index].get("type")
def get_phase_attributes(coordinator_data: dict, *, time: TibberPricesTimeService) -> dict | None:
"""
Build start/end attributes for in_*_price_phase binary sensors.
Args:
coordinator_data: The coordinator's data dict.
time: TibberPricesTimeService instance.
Returns:
Dict with start and end timestamps, or None if unavailable.
"""
if not coordinator_data:
return None
current_index, segments = _find_current_segment_in_data(coordinator_data, time=time)
if current_index is None or segments is None:
return None
segment = segments[current_index]
attrs: dict = {}
if start := segment.get("start"):
attrs["start"] = start
if end := segment.get("end"):
attrs["end"] = end
return attrs or None
def get_tomorrow_data_available_attributes(
coordinator_data: dict,
*,
@ -119,6 +171,19 @@ def get_price_intervals_attributes(
if not filtered_periods:
return build_no_periods_result(time=time)
# Recalculate position metadata after filtering (coordinator stamped values include yesterday)
# Use shallow copies so coordinator dicts are not mutated
total_filtered = len(filtered_periods)
filtered_periods = [
period
| {
"period_position": i,
"period_count_total": total_filtered,
"period_count_remaining": total_filtered - i,
}
for i, period in enumerate(filtered_periods, 1)
]
# Find current or next period based on current time
current_period = None
@ -218,6 +283,22 @@ def add_price_attributes(attributes: dict, current_period: dict, factor: int) ->
attributes["volatility"] = current_period["volatility"] # Volatility is not a price, keep as-is
def add_day_statistics_attributes(attributes: dict, current_period: dict) -> None:
"""Add per-day context attributes for the current/next period.
Day price range fields are already stored in minor currency units (ct/ore)
by the period summary builder and therefore must not be converted again here.
"""
if "day_volatility_%" in current_period:
attributes["day_volatility_%"] = current_period["day_volatility_%"]
if "day_price_min" in current_period:
attributes["day_price_min"] = current_period["day_price_min"]
if "day_price_max" in current_period:
attributes["day_price_max"] = current_period["day_price_max"]
if "day_price_span" in current_period:
attributes["day_price_span"] = current_period["day_price_span"]
def add_comparison_attributes(attributes: dict, current_period: dict, factor: int) -> None:
"""
Add price comparison attributes (priority 4).
@ -251,10 +332,55 @@ def add_detail_attributes(attributes: dict, current_period: dict) -> None:
attributes["period_interval_count"] = current_period["period_interval_count"]
if "period_position" in current_period:
attributes["period_position"] = current_period["period_position"]
if "periods_total" in current_period:
attributes["periods_total"] = current_period["periods_total"]
if "periods_remaining" in current_period:
attributes["periods_remaining"] = current_period["periods_remaining"]
if "period_count_total" in current_period:
attributes["period_count_total"] = current_period["period_count_total"]
if "period_count_remaining" in current_period:
attributes["period_count_remaining"] = current_period["period_count_remaining"]
def add_period_count_attributes(
attributes: dict,
period_summaries: list[dict],
time: TibberPricesTimeService,
) -> None:
"""
Add per-day period count attributes (priority 5.5).
Counts how many periods fall on today and tomorrow so automations can check
things like "only charge if there are at least 2 cheap periods today".
Args:
attributes: Target dict to add attributes to
period_summaries: All period summaries (already filtered to today+tomorrow)
time: TibberPricesTimeService instance for date comparison
"""
now = time.now()
today = time.get_local_date()
tomorrow = time.get_local_date(offset_days=1)
count_today = 0
count_tomorrow = 0
for period in period_summaries:
start = period.get("start")
if start is None:
continue
if hasattr(start, "date"):
period_date = start.date()
else:
from datetime import datetime # noqa: PLC0415
period_date = datetime.fromisoformat(str(start)).date()
if period_date == today:
count_today += 1
elif period_date == tomorrow:
count_tomorrow += 1
_ = now # used for clarity only
attributes["period_count_today"] = count_today
attributes["period_count_tomorrow"] = count_tomorrow
def add_relaxation_attributes(attributes: dict, current_period: dict) -> None:
@ -278,13 +404,11 @@ def add_calculation_summary_attributes(attributes: dict, period_metadata: dict)
"""
Add calculation summary attributes (priority 7).
Provides diagnostic visibility into the period calculation: how many periods
were requested vs. found, whether any flat days triggered adaptive min_periods,
and whether relaxation could not satisfy all days.
Provides diagnostic visibility into the period calculation: whether any flat days
triggered adaptive min_periods, and whether relaxation could not satisfy all days.
Only adds non-default/interesting values to avoid clutter:
- min_periods_configured: always added (useful reference for automations)
- periods_found_total: always added
- flat_days_detected: only when > 0 (explains why fewer periods than configured)
- relaxation_incomplete: only when True (diagnostic flag for troubleshooting)
@ -296,9 +420,6 @@ def add_calculation_summary_attributes(attributes: dict, period_metadata: dict)
if "min_periods_requested" in relaxation_meta:
attributes["min_periods_configured"] = relaxation_meta["min_periods_requested"]
if "periods_found" in relaxation_meta:
attributes["periods_found_total"] = relaxation_meta["periods_found"]
flat_days = relaxation_meta.get("flat_days_detected", 0)
if flat_days > 0:
attributes["flat_days_detected"] = flat_days
@ -368,12 +489,13 @@ def build_final_attributes_simple(
2. Core decision attributes (level, rating_level, rating_difference_%)
3. Price statistics (price_mean, price_median, price_min, price_max, price_spread, volatility)
4. Price differences (period_price_diff_from_daily_min, period_price_diff_from_daily_min_%)
5. Detail information (period_interval_count, period_position, periods_total, periods_remaining)
6. Relaxation information (relaxation_active, relaxation_level, relaxation_threshold_original_%,
5. Day context (day_volatility_%, day_price_min, day_price_max, day_price_span)
6. Detail information (period_interval_count, period_position, period_count_total, period_count_remaining)
7. Relaxation information (relaxation_active, relaxation_level, relaxation_threshold_original_%,
relaxation_threshold_applied_%) - only if current period was relaxed
7. Calculation summary (min_periods_configured, periods_found_total, flat_days_detected,
8. Calculation summary (min_periods_configured, flat_days_detected,
relaxation_incomplete) - diagnostic info about the overall calculation
8. Meta information (periods list)
9. Meta information (periods list)
Args:
current_period: The current or next period (already complete from coordinator)
@ -409,17 +531,23 @@ def build_final_attributes_simple(
# 4. Price differences (converted to display units)
add_comparison_attributes(attributes, current_period, factor)
# 5. Detail information
# 5. Day context attributes (already in minor units)
add_day_statistics_attributes(attributes, current_period)
# 6. Detail information
add_detail_attributes(attributes, current_period)
# 6. Relaxation information (only if current period was relaxed)
# 6.5 Per-day period counts (how many cheap/peak periods per day)
add_period_count_attributes(attributes, period_summaries, time)
# 7. Relaxation information (only if current period was relaxed)
add_relaxation_attributes(attributes, current_period)
# 7. Calculation summary (diagnostic: min_periods_configured, periods_found_total, etc.)
# 8. Calculation summary (diagnostic: min_periods_configured, flat_days_detected, etc.)
if period_metadata:
add_calculation_summary_attributes(attributes, period_metadata)
# 8. Meta information (periods array - prices converted to display units)
# 9. Meta information (periods array - prices converted to display units)
attributes["periods"] = _convert_periods_to_display_units(period_summaries, factor)
return attributes
@ -429,13 +557,14 @@ def build_final_attributes_simple(
result: dict = {
"timestamp": timestamp,
}
add_period_count_attributes(result, period_summaries, time)
if period_metadata:
add_calculation_summary_attributes(result, period_metadata)
result["periods"] = _convert_periods_to_display_units(period_summaries, factor)
return result
async def build_async_extra_state_attributes( # noqa: PLR0913
async def build_async_extra_state_attributes(
entity_key: str,
translation_key: str | None,
hass: HomeAssistant,
@ -498,7 +627,7 @@ async def build_async_extra_state_attributes( # noqa: PLR0913
return attributes or None
def build_sync_extra_state_attributes( # noqa: PLR0913
def build_sync_extra_state_attributes(
entity_key: str,
translation_key: str | None,
hass: HomeAssistant,

View file

@ -9,10 +9,7 @@ from custom_components.tibber_prices.coordinator.core import get_connection_stat
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
from custom_components.tibber_prices.entity import TibberPricesEntity
from custom_components.tibber_prices.entity_utils import get_binary_sensor_icon
from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorEntityDescription
from homeassistant.core import callback
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.restore_state import RestoreEntity
@ -20,6 +17,8 @@ from homeassistant.helpers.restore_state import RestoreEntity
from .attributes import (
build_async_extra_state_attributes,
build_sync_extra_state_attributes,
get_current_phase_type,
get_phase_attributes,
get_price_intervals_attributes,
get_tomorrow_data_available_attributes,
)
@ -27,9 +26,7 @@ from .attributes import (
if TYPE_CHECKING:
from collections.abc import Callable
from custom_components.tibber_prices.coordinator import (
TibberPricesDataUpdateCoordinator,
)
from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
@ -64,7 +61,6 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
"relaxation_threshold_applied_%",
# Calculation Summary (diagnostic, changes daily → not useful in history)
"min_periods_configured",
"periods_found_total",
"flat_days_detected",
"relaxation_incomplete",
# Redundant/Derived
@ -73,8 +69,8 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
"rating_difference_%",
"period_price_diff_from_daily_min",
"period_price_diff_from_daily_min_%",
"periods_total",
"periods_remaining",
"period_count_total",
"period_count_remaining",
}
)
@ -142,6 +138,9 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
state_getters = {
"peak_price_period": self._peak_price_state,
"best_price_period": self._best_price_state,
"in_rising_price_phase": lambda: self._in_phase_state("rising"),
"in_falling_price_phase": lambda: self._in_phase_state("falling"),
"in_flat_price_phase": lambda: self._in_phase_state("flat"),
"connection": lambda: get_connection_state(self.coordinator),
"tomorrow_data_available": self._tomorrow_data_available_state,
"has_ventilation_system": self._has_ventilation_system_state,
@ -188,6 +187,15 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
time = self.coordinator.time
return time.is_time_in_period(start, end)
def _in_phase_state(self, phase_type: str) -> bool | None:
"""Return True if the current intra-day price phase matches phase_type."""
if not self.coordinator.data:
return None
current_type = get_current_phase_type(self.coordinator.data, time=self.coordinator.time)
if current_type is None:
return None
return current_type == phase_type
def _tomorrow_data_available_state(self) -> bool | None:
"""Return True if tomorrow's data is fully available, False if not, None if unknown."""
# Auth errors: Cannot reliably check - return unknown
@ -207,11 +215,8 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
# Get expected intervals for tomorrow (handles DST)
expected_intervals = self.coordinator.time.get_expected_intervals_for_day(tomorrow_date)
if interval_count == expected_intervals:
return True
if interval_count == 0:
return False
return False
# True only when ALL intervals are available (partial = not available)
return interval_count == expected_intervals
@property
def available(self) -> bool:
@ -312,6 +317,9 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
if key == "tomorrow_data_available":
return self._get_tomorrow_data_available_attributes()
if key in ("in_rising_price_phase", "in_falling_price_phase", "in_flat_price_phase"):
return get_phase_attributes(self.coordinator.data, time=self.coordinator.time)
return None
@callback

View file

@ -2,10 +2,7 @@
from __future__ import annotations
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntityDescription,
)
from homeassistant.components.binary_sensor import BinarySensorDeviceClass, BinarySensorEntityDescription
from homeassistant.const import EntityCategory
# Period lookahead removed - icons show "waiting" state if ANY future periods exist
@ -22,6 +19,22 @@ ENTITY_DESCRIPTIONS = (
translation_key="best_price_period",
icon="mdi:clock-check",
),
# Price phase binary sensors — ON when current intra-day phase matches the type
BinarySensorEntityDescription(
key="in_rising_price_phase",
translation_key="in_rising_price_phase",
icon="mdi:trending-up",
),
BinarySensorEntityDescription(
key="in_falling_price_phase",
translation_key="in_falling_price_phase",
icon="mdi:trending-down",
),
BinarySensorEntityDescription(
key="in_flat_price_phase",
translation_key="in_flat_price_phase",
icon="mdi:trending-neutral",
),
BinarySensorEntityDescription(
key="connection",
translation_key="connection",

View file

@ -101,13 +101,19 @@ class PeriodSummary(TypedDict, total=False):
period_price_diff_from_daily_min: float # Difference from daily min
period_price_diff_from_daily_min_pct: float # Difference from daily min (%)
# Detail information (priority 5)
# Day context (priority 5)
day_volatility_pct: float | None # Volatility of the period's day (%), None for zero-average days
day_price_min: float # Daily minimum price in minor currency (ct/ore)
day_price_max: float # Daily maximum price in minor currency (ct/ore)
day_price_span: float # Daily price span in minor currency (ct/ore)
# Detail information (priority 6)
period_interval_count: int # Number of intervals in period
period_position: int # Period position (1-based)
periods_total: int # Total number of periods
periods_remaining: int # Remaining periods after this one
period_count_total: int # Total number of periods
period_count_remaining: int # Remaining periods after this one
# Relaxation information (priority 6 - only if period was relaxed)
# Relaxation information (priority 7 - only if period was relaxed)
relaxation_active: bool # Whether this period was found via relaxation
relaxation_level: int # Relaxation level used (1-based)
relaxation_threshold_original_pct: float # Original flex threshold (%)
@ -125,9 +131,10 @@ class PeriodAttributes(BaseAttributes, total=False):
2. Core decision attributes (level, rating_level, rating_difference_%)
3. Price statistics (price_mean, price_median, price_min, price_max, price_spread, volatility)
4. Price comparison (period_price_diff_from_daily_min, period_price_diff_from_daily_min_%)
5. Detail information (period_interval_count, period_position, periods_total, periods_remaining)
6. Relaxation information (only if period was relaxed)
7. Meta information (periods list)
5. Day context (day_volatility_%, day_price_min, day_price_max, day_price_span)
6. Detail information (period_interval_count, period_position, period_count_total, period_count_remaining)
7. Relaxation information (only if period was relaxed)
8. Meta information (periods list)
"""
# Time information (priority 1) - start/end refer to current/next period
@ -152,19 +159,25 @@ class PeriodAttributes(BaseAttributes, total=False):
period_price_diff_from_daily_min: float # Difference from daily min
period_price_diff_from_daily_min_pct: float # Difference from daily min (%)
# Detail information (priority 5)
# Day context (priority 5)
day_volatility_pct: float | None # Volatility of the period's day (%), None for zero-average days
day_price_min: float # Daily minimum price in minor currency (ct/ore)
day_price_max: float # Daily maximum price in minor currency (ct/ore)
day_price_span: float # Daily price span in minor currency (ct/ore)
# Detail information (priority 6)
period_interval_count: int # Number of intervals in current/next period
period_position: int # Period position (1-based)
periods_total: int # Total number of periods found
periods_remaining: int # Remaining periods after current/next one
period_count_total: int # Total number of periods found
period_count_remaining: int # Remaining periods after current/next one
# Relaxation information (priority 6 - only if period was relaxed)
# Relaxation information (priority 7 - only if period was relaxed)
relaxation_active: bool # Whether current/next period was found via relaxation
relaxation_level: int # Relaxation level used (1-based)
relaxation_threshold_original_pct: float # Original flex threshold (%)
relaxation_threshold_applied_pct: float # Applied flex threshold after relaxation (%)
# Meta information (priority 7)
# Meta information (priority 8)
periods: list[PeriodSummary] # All periods found (sorted by start time)

View file

@ -0,0 +1,295 @@
blueprint:
name: "Tibber Prices: Dishwasher (Smart Plug)"
description: >
**Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v1.0.0
Automatically run your dishwasher at the cheapest electricity price
overnight using a smart plug.
Open your
[Tibber Prices configuration](https://my.home-assistant.io/redirect/integration/?domain=tibber_prices)
to verify the integration is installed and set up.
**What it does:**
- Plans the cheapest 2-hour window overnight (every evening)
- Starts the dishwasher automatically at the cheapest time
- Sends a notification with the planned time and price
- Survives Home Assistant restarts (uses `input_datetime` helper)
**Prerequisites:**
- [Tibber Prices integration](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) installed via HACS
- One helper (created in Settings → Helpers):
- Date & Time (`input_datetime`) — stores the planned start time
- Smart plug switch for the dishwasher
**How it works:**
1. Every evening at the configured time, the blueprint finds the
cheapest window overnight
2. The planned start time is saved to the helper (survives restarts)
3. At the planned time, the smart plug turns on
4. A notification confirms the plan and the start
**Other variants:**
[Home Connect](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dishwasher_home_connect.yaml)
·
[Home Connect Alt](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dishwasher_home_connect_alt.yaml)
domain: automation
author: jpawlowski
homeassistant:
min_version: "2024.6.0"
source_url: https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dishwasher.yaml
input:
appliance:
name: Appliance
icon: mdi:dishwasher
description: Select the smart plug that controls your dishwasher.
input:
appliance_switch:
name: Dishwasher Smart Plug
description: The switch entity controlling the dishwasher.
selector:
entity:
filter:
domain: switch
schedule:
name: Schedule
icon: mdi:calendar-clock
description: Configure when to plan and the search window.
input:
plan_time:
name: Planning Time
description: >
When to search for the cheapest window each day.
Typically in the evening after loading the dishwasher.
default: "20:00:00"
selector:
time:
start_helper:
name: Start Time Helper
description: >
An `input_datetime` helper (type: Date and Time) that stores
the planned start time. Create in Settings → Helpers.
selector:
entity:
filter:
domain: input_datetime
duration:
name: Program Duration
description: >
Typical dishwasher program duration in minutes.
ECO 50°C ≈ 120 min, Auto ≈ 90 min, Intensive ≈ 150 min.
default: 120
selector:
number:
min: 30
max: 240
step: 5
unit_of_measurement: min
mode: slider
search_start:
name: Search Window Start
description: >
Earliest time the dishwasher may start.
Typically late evening after loading.
default: "22:00:00"
selector:
time:
search_end:
name: Search Window End
description: >
Latest time the dishwasher must finish by.
The program must complete before this time.
default: "06:00:00"
selector:
time:
runtime_overrides:
name: Runtime Overrides
icon: mdi:tune-vertical
collapsed: true
description: >
Optionally connect helpers to override settings from your
dashboard at runtime. When a helper is connected and has
a valid value, it takes priority over the fixed default.
Leave empty to always use the fixed defaults.
input:
duration_override:
name: "Override: Program Duration"
description: >
`input_number` helper to change the duration from your
dashboard without reconfiguring the blueprint.
**Create in Settings → Helpers → Number** with the same
min/max as the Duration slider above.
default: ""
selector:
entity:
filter:
domain: input_number
notifications:
name: Notifications
icon: mdi:bell-outline
collapsed: true
description: Optional mobile notifications for planning and start.
input:
notify_service:
name: Notification Service
description: >
One or more notify services, comma-separated
(e.g., `notify.mobile_app_yourphone` or
`notify.mobile_app_phone, notify.mobile_app_tablet`).
Leave empty to disable all notifications.
default: ""
selector:
text:
mode: single
max_exceeded: silent
triggers:
- trigger: time
at: !input plan_time
id: plan
- trigger: time
at: !input start_helper
id: execute
variables:
_blueprint_variant: "smart_plug"
appliance_switch: !input appliance_switch
start_helper: !input start_helper
_duration_default: !input duration
_duration_override: !input duration_override
duration: >
{% set o = _duration_override %}
{% if o and states(o) not in ['unknown', 'unavailable'] %}
{{ states(o) | int(_duration_default) }}
{% else %}
{{ _duration_default }}
{% endif %}
search_start: !input search_start
search_end: !input search_end
notify_service: !input notify_service
actions:
# Check: Tibber Prices integration installed?
- variables:
_tp_entities: "{{ integration_entities('tibber_prices') | list }}"
- if:
- condition: template
value_template: "{{ _tp_entities | length == 0 }}"
then:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🍽️ Dishwasher — Setup Required"
message: >
The Tibber Prices integration is not installed or not
configured. Install it via HACS and set up your Tibber
account before using this blueprint.
- stop: "Tibber Prices integration not found"
# ════════════════════════════════════════════════════════
# PLAN: Find cheapest window
# ════════════════════════════════════════════════════════
- choose:
- conditions:
- condition: trigger
id: plan
sequence:
- action: tibber_prices.find_cheapest_block
data:
duration: >
{{ '%02d:%02d:00' | format(
(duration | int) // 60,
(duration | int) % 60) }}
search_start_time: "{{ search_start }}"
search_end_time: "{{ search_end }}"
search_end_day_offset: 1
response_variable: result
- if:
- condition: template
value_template: "{{ result.window_found }}"
then:
- action: input_datetime.set_datetime
target:
entity_id: "{{ start_helper }}"
data:
datetime: "{{ result.window.start }}"
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🍽️ Dishwasher Planned"
message: >
Start at {{ result.window.start | as_datetime
| as_local | as_timestamp
| timestamp_custom('%H:%M') }}.
Avg price: {{ result.window.price_mean | round(1) }}
{{ result.price_unit }}/kWh.
{% if result.relaxation_applied | default(false) %}
(Filters relaxed to find window.)
{% endif %}
else:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🍽️ Dishwasher"
message: >
No cheap window found overnight. Consider running
manually or adjusting the search window.
# ════════════════════════════════════════════════════
# EXECUTE: Start dishwasher
# ════════════════════════════════════════════════════
- conditions:
- condition: trigger
id: execute
sequence:
- action: switch.turn_on
target:
entity_id: "{{ appliance_switch }}"
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🍽️ Dishwasher Started"
message: >
Smart plug turned on. Program should finish in
~{{ duration }} minutes.

View file

@ -0,0 +1,445 @@
blueprint:
name: "Tibber Prices: Dishwasher (Home Connect)"
description: >
**Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v1.0.0
**Device-driven** dishwasher automation with electricity price
optimization using the **Home Connect** integration (HA Core).
**How it works:**
1. Select your program on the dishwasher
2. Close the door and enable Remote Start
3. The blueprint reads the estimated duration from the device
4. Finds the cheapest electricity window before your deadline
5. Tells the dishwasher when to start via `StartInRelative`
6. The dishwasher manages the countdown internally — no HA timers
**No scheduling needed** — the dishwasher handles the delayed start
itself. No `input_datetime` helpers required. Survives HA restarts
because the countdown runs on the appliance.
**Prerequisites:**
- [Tibber Prices integration](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) installed via HACS
- [Home Connect](https://www.home-assistant.io/integrations/home_connect/) integration configured
- **Remote Start** enabled on the dishwasher
**Other variants:**
[Smart Plug](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dishwasher.yaml)
·
[Home Connect Alt](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dishwasher_home_connect_alt.yaml)
domain: automation
author: jpawlowski
homeassistant:
min_version: "2024.11.0"
source_url: https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dishwasher_home_connect.yaml
input:
appliance:
name: Appliance
icon: mdi:dishwasher
description: >
Select your Home Connect dishwasher device and entities.
input:
appliance_device:
name: Dishwasher Device
description: >
Your dishwasher from the Home Connect integration.
Used to target the start command.
selector:
device:
filter:
integration: home_connect
door_sensor:
name: Door Sensor
description: >
The door sensor of your dishwasher
(e.g., `binary_sensor.dishwasher_door`).
selector:
entity:
filter:
integration: home_connect
domain: binary_sensor
device_class: door
remote_start_sensor:
name: Remote Start Sensor
description: >
The "Remote Control Start Allowed" binary sensor
(e.g., `binary_sensor.dishwasher_remote_start`).
Must be **on** for the automation to proceed.
selector:
entity:
filter:
integration: home_connect
domain: binary_sensor
estimated_duration_entity:
name: Estimated Program Duration
description: >
The "Estimated Total Program Time" sensor.
If unavailable, the fallback duration is used instead.
selector:
entity:
filter:
integration: home_connect
domain: sensor
operation_state_entity:
name: Operation State
description: >
The "Operation State" sensor.
Used to verify the machine is ready before planning.
selector:
entity:
filter:
integration: home_connect
domain: sensor
schedule:
name: Schedule
icon: mdi:calendar-clock
description: >
Configure the deadline and fallback duration.
input:
must_finish_by:
name: Must Finish By
description: >
The program must be finished by this time.
If this time has already passed today, the deadline
automatically moves to tomorrow (overnight mode).
default: "06:00:00"
selector:
time:
duration_fallback:
name: Fallback Duration (minutes)
description: >
Used **only** if the device doesn't report the estimated
duration. Normally the duration is read automatically.
ECO 50°C ≈ 120 min, Auto ≈ 90 min, Intensive ≈ 150 min.
default: 120
selector:
number:
min: 30
max: 240
step: 5
unit_of_measurement: min
mode: slider
notifications:
name: Notifications
icon: mdi:bell-outline
collapsed: true
description: >
Optional notifications. Use **simple mode** (just a service)
or point to an **advanced script** for multi-target,
presence-aware, and platform-specific notifications.
input:
notify_service:
name: Quick Notification (Simple)
description: >
One or more notify services, comma-separated
(e.g., `notify.mobile_app_yourphone` or
`notify.mobile_app_phone, notify.mobile_app_tablet`).
Ignored when the advanced script is set.
default: ""
selector:
text:
notification_script:
name: Notification Script (Advanced)
description: >
A `script.*` entity for advanced notifications
(multiple recipients, presence filtering, iOS/Android).
When set, replaces the simple notification.
Receives structured variables (event_type, appliance,
title, message, and context data).
default: ""
selector:
entity:
filter:
domain: script
title_setup_required:
name: "Title: Setup Required"
default: "🍽️ Dishwasher — Setup Required"
selector:
text:
title_not_ready:
name: "Title: Not Ready"
default: "🍽️ Dishwasher — Not Ready"
selector:
text:
title_no_cheap_slot:
name: "Title: No Cheap Slot"
default: "🍽️ Dishwasher — No Cheap Slot"
selector:
text:
title_planned:
name: "Title: Planned"
default: "🍽️ Dishwasher — Planned!"
selector:
text:
mode: single
max_exceeded: silent
triggers:
- trigger: state
entity_id: !input door_sensor
to: "off"
- trigger: state
entity_id: !input remote_start_sensor
to: "on"
conditions:
- condition: state
entity_id: !input door_sensor
state: "off"
- condition: state
entity_id: !input remote_start_sensor
state: "on"
variables:
_blueprint_variant: "home_connect"
appliance_device: !input appliance_device
door_sensor: !input door_sensor
remote_start_sensor: !input remote_start_sensor
estimated_duration_entity: !input estimated_duration_entity
operation_state_entity: !input operation_state_entity
must_finish_by_time: !input must_finish_by
duration_fallback: !input duration_fallback
notify_service: !input notify_service
notification_script: !input notification_script
title_setup_required: !input title_setup_required
title_not_ready: !input title_not_ready
title_no_cheap_slot: !input title_no_cheap_slot
title_planned: !input title_planned
actions:
# ════════════════════════════════════════════════════════
# PREFLIGHT CHECKS
# ════════════════════════════════════════════════════════
- variables:
_tp_entities: "{{ integration_entities('tibber_prices') | list }}"
- if:
- condition: template
value_template: "{{ _tp_entities | length == 0 }}"
then:
- variables:
_n_title: "{{ title_setup_required }}"
_n_message: >
Install the Tibber Prices integration via HACS and
configure your Tibber account.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: setup_required
appliance: dishwasher
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "Tibber Prices integration not found"
- if:
- condition: template
value_template: >
{% set op = states(operation_state_entity) %}
{{ op not in ['unknown', 'unavailable']
and 'Ready' not in op
and 'Inactive' not in op }}
then:
- variables:
_n_title: "{{ title_not_ready }}"
_n_message: >
State: {{ states(operation_state_entity) }}.
Ensure it's idle with Remote Start enabled.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: not_ready
appliance: dishwasher
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "Machine not ready"
# ════════════════════════════════════════════════════════
# READ DEVICE DATA
# ════════════════════════════════════════════════════════
- variables:
_raw_duration: "{{ states(estimated_duration_entity) }}"
duration: >
{% set raw = states(estimated_duration_entity) %}
{% if raw not in ['unknown', 'unavailable', 'None', '']
and ':' in raw %}
{% set parts = raw.split(':') %}
{{ (parts[0] | int(0)) * 60 + (parts[1] | int(0)) }}
{% elif raw not in ['unknown', 'unavailable', 'None', '']
and raw | int(0) > 0 %}
{{ raw | int }}
{% else %}
{{ duration_fallback }}
{% endif %}
deadline: >
{% set dl = today_at(must_finish_by_time) %}
{% if dl <= now() %}
{{ (dl + timedelta(days=1)).isoformat() }}
{% else %}
{{ dl.isoformat() }}
{% endif %}
# ════════════════════════════════════════════════════════
# FIND CHEAPEST WINDOW
# ════════════════════════════════════════════════════════
- action: tibber_prices.find_cheapest_block
data:
duration: >
{{ '%02d:%02d:00' | format(
(duration | int) // 60,
(duration | int) % 60) }}
must_finish_by: "{{ deadline }}"
response_variable: result
- if:
- condition: template
value_template: "{{ not result.window_found }}"
then:
- variables:
_n_title: "{{ title_no_cheap_slot }}"
_n_message: >
No cheap slot before
{{ deadline | as_datetime | as_local
| as_timestamp | timestamp_custom('%H:%M') }}
for {{ duration }} min.
Run manually or extend the deadline.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: no_window
appliance: dishwasher
title: "{{ _n_title }}"
message: "{{ _n_message }}"
deadline: "{{ deadline }}"
duration_minutes: "{{ duration | int }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "No cheap window found"
# ════════════════════════════════════════════════════════
# START WITH DELAY (device manages countdown)
# ════════════════════════════════════════════════════════
- variables:
_window_start: "{{ result.window.start | as_datetime }}"
start_in_relative: >
{{ [0, ((_window_start - now()).total_seconds()) | int] | max }}
# Dishwashers use StartInRelative = seconds until start
- action: home_connect.start_selected_program
target:
device_id: "{{ appliance_device }}"
data:
b_s_h_common_option_start_in_relative: "{{ start_in_relative }}"
- variables:
_n_title: "{{ title_planned }}"
_n_message: >
{% if start_in_relative | int > 0 %}
⏰ {{ _window_start | as_local
| as_timestamp | timestamp_custom('%H:%M') }}
(in {{ (start_in_relative | int / 3600) | round(1) }} h)
· ~{{ duration }} min
· {{ result.window.price_mean | round(1) }} {{ result.price_unit }}/kWh
{% else %}
▶️ Starting now!
· ~{{ duration }} min
· {{ result.window.price_mean | round(1) }} {{ result.price_unit }}/kWh
{% endif %}
{% if _raw_duration in ['unknown', 'unavailable', 'None', ''] %}
· ⚠️ Duration estimated
{% endif %}
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: planned
appliance: dishwasher
title: "{{ _n_title }}"
message: "{{ _n_message }}"
start_time: "{{ _window_start | as_local | as_timestamp | timestamp_custom('%Y-%m-%dT%H:%M:%S') }}"
duration_minutes: "{{ duration | int }}"
price_mean: "{{ result.window.price_mean | round(1) }}"
price_unit: "{{ result.price_unit }}"
using_fallback_duration: "{{ _raw_duration in ['unknown', 'unavailable', 'None', ''] }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"

View file

@ -0,0 +1,507 @@
blueprint:
name: "Tibber Prices: Dishwasher (Home Connect Alt)"
description: >
**Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v1.0.0
**Device-driven** dishwasher automation with electricity price
optimization using **Home Connect Alt**
([HACS integration by ekutner](https://my.home-assistant.io/redirect/hacs_repository/?owner=ekutner&repository=home-connect-hass&category=integration)).
**How it works:**
1. Select your program on the dishwasher
2. Close the door and enable Remote Start
3. The blueprint reads the program and estimated duration from the
device automatically
4. Finds the cheapest electricity window before your deadline
5. Tells the dishwasher when to start via `StartInRelative`
6. The dishwasher manages the countdown internally — no HA timers
**No scheduling needed** — the dishwasher handles the delayed start
itself. No `input_datetime` helpers required. Survives HA restarts
because the countdown runs on the appliance.
**Prerequisites:**
- [Tibber Prices integration](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) installed via HACS
- [Home Connect Alt](https://my.home-assistant.io/redirect/hacs_repository/?owner=ekutner&repository=home-connect-hass&category=integration) integration configured
- **Remote Start** enabled on the dishwasher
**Other variants:**
[Smart Plug](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dishwasher.yaml)
·
[Home Connect](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dishwasher_home_connect.yaml)
domain: automation
author: jpawlowski
homeassistant:
min_version: "2024.11.0"
source_url: https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dishwasher_home_connect_alt.yaml
input:
appliance:
name: Appliance Entities
icon: mdi:dishwasher
description: >
Select your Home Connect Alt dishwasher entities.
All entities belong to the same appliance device.
input:
program_entity:
name: Program Select Entity
description: >
The **Programs** select entity of your dishwasher
(e.g., `select.dishwasher_programs`).
Used to read the selected program and as target for starting.
selector:
entity:
filter:
integration: home_connect_alt
domain: select
door_sensor:
name: Door Sensor
description: >
The door sensor of your dishwasher
(e.g., `binary_sensor.dishwasher_door`).
selector:
entity:
filter:
integration: home_connect_alt
domain: binary_sensor
device_class: door
remote_start_sensor:
name: Remote Start Sensor
description: >
The "Remote Control Start Allowed" binary sensor
(e.g., `binary_sensor.dishwasher_bsh_common_status_remotecontrolstartallowed`).
Must be **on** for the automation to proceed.
selector:
entity:
filter:
integration: home_connect_alt
domain: binary_sensor
estimated_duration_entity:
name: Estimated Program Duration
description: >
The "Estimated Total Program Time" sensor
(e.g., `sensor.dishwasher_estimated_total_program_time`).
Shows the expected duration in `H:MM` format.
If unavailable, the fallback duration is used instead.
selector:
entity:
filter:
integration: home_connect_alt
domain: sensor
operation_state_entity:
name: Operation State
description: >
The "Operation State" sensor
(e.g., `sensor.dishwasher_operation_state`).
Used to verify the machine is ready before planning.
selector:
entity:
filter:
integration: home_connect_alt
domain: sensor
schedule:
name: Schedule
icon: mdi:calendar-clock
description: >
Configure the deadline and fallback duration.
input:
must_finish_by:
name: Must Finish By
description: >
The program must be finished by this time.
If this time has already passed today, the deadline
automatically moves to tomorrow (overnight mode).
default: "06:00:00"
selector:
time:
duration_fallback:
name: Fallback Duration (minutes)
description: >
Used **only** if the device doesn't report the estimated
duration (e.g., program not yet fully selected on the
appliance). Normally the duration is read automatically.
ECO 50°C ≈ 120 min, Auto ≈ 90 min, Intensive ≈ 150 min.
default: 120
selector:
number:
min: 30
max: 240
step: 5
unit_of_measurement: min
mode: slider
notifications:
name: Notifications
icon: mdi:bell-outline
collapsed: true
description: >
Optional notifications. Use **simple mode** (just a service)
or point to an **advanced script** for multi-target,
presence-aware, and platform-specific notifications.
input:
notify_service:
name: Quick Notification (Simple)
description: >
One or more notify services, comma-separated
(e.g., `notify.mobile_app_yourphone` or
`notify.mobile_app_phone, notify.mobile_app_tablet`).
Ignored when the advanced script is set.
default: ""
selector:
text:
notification_script:
name: Notification Script (Advanced)
description: >
A `script.*` entity for advanced notifications
(multiple recipients, presence filtering, iOS/Android).
When set, replaces the simple notification.
Receives structured variables (event_type, appliance,
title, message, and context data).
default: ""
selector:
entity:
filter:
domain: script
title_setup_required:
name: "Title: Setup Required"
default: "🍽️ Dishwasher — Setup Required"
selector:
text:
title_not_ready:
name: "Title: Not Ready"
default: "🍽️ Dishwasher — Not Ready"
selector:
text:
title_no_program:
name: "Title: No Program"
default: "🍽️ Dishwasher — No Program"
selector:
text:
title_no_cheap_slot:
name: "Title: No Cheap Slot"
default: "🍽️ Dishwasher — No Cheap Slot"
selector:
text:
title_planned:
name: "Title: Planned"
default: "🍽️ Dishwasher — Planned!"
selector:
text:
mode: single
max_exceeded: silent
triggers:
# Fire when door closes OR remote start becomes active
- trigger: state
entity_id: !input door_sensor
to: "off"
- trigger: state
entity_id: !input remote_start_sensor
to: "on"
conditions:
# Both conditions must be true regardless of which trigger fired
- condition: state
entity_id: !input door_sensor
state: "off"
- condition: state
entity_id: !input remote_start_sensor
state: "on"
variables:
_blueprint_variant: "home_connect_alt"
program_entity: !input program_entity
door_sensor: !input door_sensor
remote_start_sensor: !input remote_start_sensor
estimated_duration_entity: !input estimated_duration_entity
operation_state_entity: !input operation_state_entity
must_finish_by_time: !input must_finish_by
duration_fallback: !input duration_fallback
notify_service: !input notify_service
notification_script: !input notification_script
title_setup_required: !input title_setup_required
title_not_ready: !input title_not_ready
title_no_program: !input title_no_program
title_no_cheap_slot: !input title_no_cheap_slot
title_planned: !input title_planned
actions:
# ════════════════════════════════════════════════════════
# PREFLIGHT CHECKS
# ════════════════════════════════════════════════════════
# Check: Tibber Prices integration installed?
- variables:
_tp_entities: "{{ integration_entities('tibber_prices') | list }}"
- if:
- condition: template
value_template: "{{ _tp_entities | length == 0 }}"
then:
- variables:
_n_title: "{{ title_setup_required }}"
_n_message: >
Install the Tibber Prices integration via HACS and
configure your Tibber account.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: setup_required
appliance: dishwasher
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "Tibber Prices integration not found"
# Check: Machine is ready (not already running)?
- if:
- condition: template
value_template: >
{% set op = states(operation_state_entity) %}
{{ op not in ['unknown', 'unavailable']
and 'Ready' not in op
and 'Inactive' not in op }}
then:
- variables:
_n_title: "{{ title_not_ready }}"
_n_message: >
State: {{ states(operation_state_entity) }}.
Ensure it's idle with Remote Start enabled.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: not_ready
appliance: dishwasher
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "Machine not ready"
# ════════════════════════════════════════════════════════
# READ DEVICE DATA
# ════════════════════════════════════════════════════════
- variables:
# Read selected program from device
selected_program: "{{ states(program_entity) }}"
# Read estimated duration from device (H:MM format → minutes)
_raw_duration: "{{ states(estimated_duration_entity) }}"
duration: >
{% set raw = states(estimated_duration_entity) %}
{% if raw not in ['unknown', 'unavailable', 'None', '']
and ':' in raw %}
{% set parts = raw.split(':') %}
{{ (parts[0] | int(0)) * 60 + (parts[1] | int(0)) }}
{% else %}
{{ duration_fallback }}
{% endif %}
# Compute deadline (auto-detect overnight)
deadline: >
{% set dl = today_at(must_finish_by_time) %}
{% if dl <= now() %}
{{ (dl + timedelta(days=1)).isoformat() }}
{% else %}
{{ dl.isoformat() }}
{% endif %}
# Validate program is selected
- if:
- condition: template
value_template: >
{{ selected_program in ['unknown', 'unavailable', 'None', ''] }}
then:
- variables:
_n_title: "{{ title_no_program }}"
_n_message: >
Select a program, close the door, and enable
Remote Start.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: no_program
appliance: dishwasher
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "No program selected"
# ════════════════════════════════════════════════════════
# FIND CHEAPEST WINDOW
# ════════════════════════════════════════════════════════
- action: tibber_prices.find_cheapest_block
data:
duration: >
{{ '%02d:%02d:00' | format(
(duration | int) // 60,
(duration | int) % 60) }}
must_finish_by: "{{ deadline }}"
response_variable: result
- if:
- condition: template
value_template: "{{ not result.window_found }}"
then:
- variables:
_n_title: "{{ title_no_cheap_slot }}"
_n_message: >
No cheap slot before
{{ deadline | as_datetime | as_local
| as_timestamp | timestamp_custom('%H:%M') }}
for {{ duration }} min.
Run manually or extend the deadline.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: no_window
appliance: dishwasher
title: "{{ _n_title }}"
message: "{{ _n_message }}"
deadline: "{{ deadline }}"
duration_minutes: "{{ duration | int }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "No cheap window found"
# ════════════════════════════════════════════════════════
# START WITH DELAY (device manages countdown)
# ════════════════════════════════════════════════════════
- variables:
_window_start: "{{ result.window.start | as_datetime }}"
# Dishwashers use StartInRelative (seconds until program starts)
start_in_relative: >
{{ [0, ((_window_start - now()).total_seconds()) | int] | max }}
_device_id: "{{ device_id(program_entity) }}"
- action: home_connect_alt.start_program
data:
device_id: "{{ _device_id }}"
program_key: "{{ selected_program }}"
options:
- key: BSH.Common.Option.StartInRelative
value: "{{ start_in_relative | int }}"
- variables:
_n_title: "{{ title_planned }}"
_n_message: >
{{ selected_program.split('.')[-1] }}
{% if start_in_relative | int > 0 %}
· ⏰ {{ _window_start | as_local
| as_timestamp | timestamp_custom('%H:%M') }}
(in {{ (start_in_relative | int / 3600) | round(1) }} h)
{% else %}
· ▶️ Starting now!
{% endif %}
· ~{{ duration }} min
· {{ result.window.price_mean | round(1) }} {{ result.price_unit }}/kWh
{% if _raw_duration in ['unknown', 'unavailable', 'None', ''] %}
· ⚠️ Duration estimated
{% endif %}
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: planned
appliance: dishwasher
title: "{{ _n_title }}"
message: "{{ _n_message }}"
start_time: "{{ _window_start | as_local | as_timestamp | timestamp_custom('%Y-%m-%dT%H:%M:%S') }}"
duration_minutes: "{{ duration | int }}"
price_mean: "{{ result.window.price_mean | round(1) }}"
price_unit: "{{ result.price_unit }}"
selected_program: "{{ selected_program }}"
using_fallback_duration: "{{ _raw_duration in ['unknown', 'unavailable', 'None', ''] }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"

View file

@ -0,0 +1,261 @@
blueprint:
name: "Tibber Prices: Dryer (Smart Plug)"
description: >
**Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v1.0.0
Automatically run your dryer at the cheapest electricity price
overnight using a smart plug.
**Prerequisites:**
- [Tibber Prices integration](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) installed via HACS
- One helper: Date & Time (`input_datetime`) — stores the planned start time
- Smart plug switch for the dryer
**Tip:** For multiple wash + dry cycles, use the
[Laundry Day Pipeline](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/laundry_day_pipeline.yaml)
blueprint instead.
**Other variants:**
[Home Connect](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dryer_home_connect.yaml)
·
[Home Connect Alt](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dryer_home_connect_alt.yaml)
domain: automation
author: jpawlowski
homeassistant:
min_version: "2024.6.0"
source_url: https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dryer.yaml
input:
appliance:
name: Appliance
icon: mdi:tumble-dryer
description: Select the smart plug that controls your dryer.
input:
appliance_switch:
name: Dryer Smart Plug
description: The switch entity controlling the dryer.
selector:
entity:
filter:
domain: switch
schedule:
name: Schedule
icon: mdi:calendar-clock
description: Configure when to plan and the search window.
input:
plan_time:
name: Planning Time
description: >
When to search for the cheapest window each day.
Typically in the evening after loading the dryer.
default: "20:00:00"
selector:
time:
start_helper:
name: Start Time Helper
description: >
An `input_datetime` helper (type: Date and Time) that stores
the planned start time. Create in Settings → Helpers.
selector:
entity:
filter:
domain: input_datetime
duration:
name: Program Duration
description: >
Typical dry program duration in minutes.
Cotton Dry ≈ 60 min, Extra Dry ≈ 75 min, Gentle ≈ 90 min.
default: 65
selector:
number:
min: 15
max: 180
step: 5
unit_of_measurement: min
mode: slider
search_start:
name: Search Window Start
description: >
Earliest time the dryer may start.
Typically late evening.
default: "22:00:00"
selector:
time:
search_end:
name: Search Window End
description: >
Latest time the dryer must finish by.
The program must complete before this time.
default: "06:00:00"
selector:
time:
runtime_overrides:
name: Runtime Overrides
icon: mdi:tune-vertical
collapsed: true
description: >
Optionally connect helpers to override settings from your
dashboard at runtime. When a helper is connected and has
a valid value, it takes priority over the fixed default.
Leave empty to always use the fixed defaults.
input:
duration_override:
name: "Override: Program Duration"
description: >
`input_number` helper to change the duration from your
dashboard without reconfiguring the blueprint.
**Create in Settings → Helpers → Number** with the same
min/max as the Duration slider above.
default: ""
selector:
entity:
filter:
domain: input_number
notifications:
name: Notifications
icon: mdi:bell-outline
collapsed: true
description: >
Optional mobile notifications for planning and start events.
input:
notify_service:
name: Notification Service
description: >
One or more notify services, comma-separated
(e.g., `notify.mobile_app_yourphone` or
`notify.mobile_app_phone, notify.mobile_app_tablet`).
Leave empty to disable all notifications.
default: ""
selector:
text:
mode: single
max_exceeded: silent
triggers:
- trigger: time
at: !input plan_time
id: plan
- trigger: time
at: !input start_helper
id: execute
variables:
_blueprint_variant: "smart_plug"
appliance_switch: !input appliance_switch
start_helper: !input start_helper
_duration_default: !input duration
_duration_override: !input duration_override
duration: >
{% set o = _duration_override %}
{% if o and states(o) not in ['unknown', 'unavailable'] %}
{{ states(o) | int(_duration_default) }}
{% else %}
{{ _duration_default }}
{% endif %}
search_start: !input search_start
search_end: !input search_end
notify_service: !input notify_service
actions:
- variables:
_tp_entities: "{{ integration_entities('tibber_prices') | list }}"
- if:
- condition: template
value_template: "{{ _tp_entities | length == 0 }}"
then:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🌀 Dryer — Setup Required"
message: >
The Tibber Prices integration is not installed.
- stop: "Tibber Prices integration not found"
- choose:
- conditions:
- condition: trigger
id: plan
sequence:
- action: tibber_prices.find_cheapest_block
data:
duration: >
{{ '%02d:%02d:00' | format(
(duration | int) // 60,
(duration | int) % 60) }}
search_start_time: "{{ search_start }}"
search_end_time: "{{ search_end }}"
search_end_day_offset: 1
response_variable: result
- if:
- condition: template
value_template: "{{ result.window_found }}"
then:
- action: input_datetime.set_datetime
target:
entity_id: "{{ start_helper }}"
data:
datetime: "{{ result.window.start }}"
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🌀 Dryer Planned"
message: >
Start at {{ result.window.start | as_datetime
| as_local | as_timestamp
| timestamp_custom('%H:%M') }}.
Avg price: {{ result.window.price_mean | round(1) }}
{{ result.price_unit }}/kWh.
else:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🌀 Dryer"
message: No cheap window found. Consider running manually.
- conditions:
- condition: trigger
id: execute
sequence:
- action: switch.turn_on
target:
entity_id: "{{ appliance_switch }}"
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🌀 Dryer Started"
message: >
Smart plug turned on. Program should finish in
~{{ duration }} minutes.

View file

@ -0,0 +1,458 @@
blueprint:
name: "Tibber Prices: Dryer (Home Connect)"
description: >
**Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v1.0.0
**Device-driven** dryer automation with electricity price
optimization using the **Home Connect** integration (HA Core).
**How it works:**
1. Select your program on the dryer
2. Close the door and enable Remote Start
3. The blueprint reads the estimated duration from the device
4. Finds the cheapest electricity window before your deadline
5. Tells the dryer when to finish via `FinishInRelative`
6. The dryer calculates when to start and manages the countdown
internally — no HA timers
**Important:** Dryers use `FinishInRelative` (like washing machines).
The appliance receives the deadline and calculates the optimal start
time itself.
**No scheduling needed** — the dryer handles the delayed start
itself. No `input_datetime` helpers required. Survives HA restarts
because the countdown runs on the appliance.
**Prerequisites:**
- [Tibber Prices integration](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) installed via HACS
- [Home Connect](https://www.home-assistant.io/integrations/home_connect/) integration configured
- **Remote Start** enabled on the dryer
**Tip:** For multiple wash + dry cycles, use the
[Laundry Day Pipeline](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/laundry_day_pipeline_home_connect.yaml)
blueprint instead.
**Other variants:**
[Smart Plug](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dryer.yaml)
·
[Home Connect Alt](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dryer_home_connect_alt.yaml)
domain: automation
author: jpawlowski
homeassistant:
min_version: "2024.11.0"
source_url: https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dryer_home_connect.yaml
input:
appliance:
name: Appliance
icon: mdi:tumble-dryer
description: >
Select your Home Connect dryer device and entities.
input:
appliance_device:
name: Dryer Device
description: >
Your dryer from the Home Connect integration.
Used to target the start command.
selector:
device:
filter:
integration: home_connect
door_sensor:
name: Door Sensor
description: >
The door sensor of your dryer.
selector:
entity:
filter:
integration: home_connect
domain: binary_sensor
device_class: door
remote_start_sensor:
name: Remote Start Sensor
description: >
The "Remote Control Start Allowed" binary sensor.
Must be **on** for the automation to proceed.
selector:
entity:
filter:
integration: home_connect
domain: binary_sensor
estimated_duration_entity:
name: Estimated Program Duration
description: >
The "Estimated Total Program Time" sensor.
If unavailable, the fallback duration is used instead.
selector:
entity:
filter:
integration: home_connect
domain: sensor
operation_state_entity:
name: Operation State
description: >
The "Operation State" sensor.
Used to verify the machine is ready before planning.
selector:
entity:
filter:
integration: home_connect
domain: sensor
schedule:
name: Schedule
icon: mdi:calendar-clock
description: >
Configure the deadline and fallback duration.
input:
must_finish_by:
name: Must Finish By
description: >
The program must be finished by this time.
If this time has already passed today, the deadline
automatically moves to tomorrow (overnight mode).
default: "06:00:00"
selector:
time:
duration_fallback:
name: Fallback Duration (minutes)
description: >
Used **only** if the device doesn't report the estimated
duration. Normally the duration is read automatically.
Cotton Dry ≈ 60 min, Extra Dry ≈ 75 min, Gentle ≈ 90 min.
default: 65
selector:
number:
min: 15
max: 180
step: 5
unit_of_measurement: min
mode: slider
notifications:
name: Notifications
icon: mdi:bell-outline
collapsed: true
description: >
Optional notifications. Use **simple mode** (just a service)
or point to an **advanced script** for multi-target,
presence-aware, and platform-specific notifications.
input:
notify_service:
name: Quick Notification (Simple)
description: >
One or more notify services, comma-separated
(e.g., `notify.mobile_app_yourphone` or
`notify.mobile_app_phone, notify.mobile_app_tablet`).
Ignored when the advanced script is set.
default: ""
selector:
text:
notification_script:
name: Notification Script (Advanced)
description: >
A `script.*` entity for advanced notifications
(multiple recipients, presence filtering, iOS/Android).
When set, replaces the simple notification.
Receives structured variables (event_type, appliance,
title, message, and context data).
default: ""
selector:
entity:
filter:
domain: script
title_setup_required:
name: "Title: Setup Required"
default: "🌀 Dryer — Setup Required"
selector:
text:
title_not_ready:
name: "Title: Not Ready"
default: "🌀 Dryer — Not Ready"
selector:
text:
title_no_cheap_slot:
name: "Title: No Cheap Slot"
default: "🌀 Dryer — No Cheap Slot"
selector:
text:
title_planned:
name: "Title: Planned"
default: "🌀 Dryer — Planned!"
selector:
text:
mode: single
max_exceeded: silent
triggers:
- trigger: state
entity_id: !input door_sensor
to: "off"
- trigger: state
entity_id: !input remote_start_sensor
to: "on"
conditions:
- condition: state
entity_id: !input door_sensor
state: "off"
- condition: state
entity_id: !input remote_start_sensor
state: "on"
variables:
_blueprint_variant: "home_connect"
appliance_device: !input appliance_device
door_sensor: !input door_sensor
remote_start_sensor: !input remote_start_sensor
estimated_duration_entity: !input estimated_duration_entity
operation_state_entity: !input operation_state_entity
must_finish_by_time: !input must_finish_by
duration_fallback: !input duration_fallback
notify_service: !input notify_service
notification_script: !input notification_script
title_setup_required: !input title_setup_required
title_not_ready: !input title_not_ready
title_no_cheap_slot: !input title_no_cheap_slot
title_planned: !input title_planned
actions:
# ════════════════════════════════════════════════════════
# PREFLIGHT CHECKS
# ════════════════════════════════════════════════════════
- variables:
_tp_entities: "{{ integration_entities('tibber_prices') | list }}"
- if:
- condition: template
value_template: "{{ _tp_entities | length == 0 }}"
then:
- variables:
_n_title: "{{ title_setup_required }}"
_n_message: >
Install the Tibber Prices integration via HACS and
configure your Tibber account.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: setup_required
appliance: dryer
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "Tibber Prices integration not found"
- if:
- condition: template
value_template: >
{% set op = states(operation_state_entity) %}
{{ op not in ['unknown', 'unavailable']
and 'Ready' not in op
and 'Inactive' not in op }}
then:
- variables:
_n_title: "{{ title_not_ready }}"
_n_message: >
State: {{ states(operation_state_entity) }}.
Ensure it's idle with Remote Start enabled.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: not_ready
appliance: dryer
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "Machine not ready"
# ════════════════════════════════════════════════════════
# READ DEVICE DATA
# ════════════════════════════════════════════════════════
- variables:
_raw_duration: "{{ states(estimated_duration_entity) }}"
duration: >
{% set raw = states(estimated_duration_entity) %}
{% if raw not in ['unknown', 'unavailable', 'None', '']
and ':' in raw %}
{% set parts = raw.split(':') %}
{{ (parts[0] | int(0)) * 60 + (parts[1] | int(0)) }}
{% elif raw not in ['unknown', 'unavailable', 'None', '']
and raw | int(0) > 0 %}
{{ raw | int }}
{% else %}
{{ duration_fallback }}
{% endif %}
deadline: >
{% set dl = today_at(must_finish_by_time) %}
{% if dl <= now() %}
{{ (dl + timedelta(days=1)).isoformat() }}
{% else %}
{{ dl.isoformat() }}
{% endif %}
# ════════════════════════════════════════════════════════
# FIND CHEAPEST WINDOW
# ════════════════════════════════════════════════════════
- action: tibber_prices.find_cheapest_block
data:
duration: >
{{ '%02d:%02d:00' | format(
(duration | int) // 60,
(duration | int) % 60) }}
must_finish_by: "{{ deadline }}"
response_variable: result
- if:
- condition: template
value_template: "{{ not result.window_found }}"
then:
- variables:
_n_title: "{{ title_no_cheap_slot }}"
_n_message: >
No cheap slot before
{{ deadline | as_datetime | as_local
| as_timestamp | timestamp_custom('%H:%M') }}
for {{ duration }} min.
Run manually or extend the deadline.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: no_window
appliance: dryer
title: "{{ _n_title }}"
message: "{{ _n_message }}"
deadline: "{{ deadline }}"
duration_minutes: "{{ duration | int }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "No cheap window found"
# ════════════════════════════════════════════════════════
# START WITH DELAY (device manages countdown)
# ════════════════════════════════════════════════════════
- variables:
_window_start: "{{ result.window.start | as_datetime }}"
_window_end: >
{{ (_window_start + timedelta(minutes=duration | int)).isoformat() }}
finish_in_relative: >
{% set window_end = _window_start + timedelta(minutes=duration | int) %}
{% set seconds_until_end = ((window_end - now()).total_seconds()) | int %}
{{ [duration | int * 60, seconds_until_end] | max }}
# Dryers use FinishInRelative
- action: home_connect.set_program_and_options
target:
device_id: "{{ appliance_device }}"
data:
affects_to: active_program
b_s_h_common_option_finish_in_relative: "{{ finish_in_relative }}"
- variables:
_n_title: "{{ title_planned }}"
_n_message: >
{% set delay = finish_in_relative | int - (duration | int * 60) %}
{% if delay > 0 %}
⏰ ~{{ _window_start | as_local
| as_timestamp | timestamp_custom('%H:%M') }}
(in {{ (delay / 3600) | round(1) }} h)
· ~{{ duration }} min
· {{ result.window.price_mean | round(1) }} {{ result.price_unit }}/kWh
{% else %}
▶️ Starting now!
· ~{{ duration }} min
· {{ result.window.price_mean | round(1) }} {{ result.price_unit }}/kWh
{% endif %}
{% if _raw_duration in ['unknown', 'unavailable', 'None', ''] %}
· ⚠️ Duration estimated
{% endif %}
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: planned
appliance: dryer
title: "{{ _n_title }}"
message: "{{ _n_message }}"
start_time: "{{ _window_start | as_local | as_timestamp | timestamp_custom('%Y-%m-%dT%H:%M:%S') }}"
duration_minutes: "{{ duration | int }}"
price_mean: "{{ result.window.price_mean | round(1) }}"
price_unit: "{{ result.price_unit }}"
using_fallback_duration: "{{ _raw_duration in ['unknown', 'unavailable', 'None', ''] }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"

View file

@ -0,0 +1,510 @@
blueprint:
name: "Tibber Prices: Dryer (Home Connect Alt)"
description: >
**Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v1.0.0
**Device-driven** dryer automation with electricity price
optimization using **Home Connect Alt**
([HACS integration by ekutner](https://my.home-assistant.io/redirect/hacs_repository/?owner=ekutner&repository=home-connect-hass&category=integration)).
**How it works:**
1. Select your program on the dryer
2. Close the door and enable Remote Start
3. The blueprint reads the program and estimated duration from the
device automatically
4. Finds the cheapest electricity window before your deadline
5. Tells the dryer when to finish via `FinishInRelative`
6. The dryer calculates when to start and manages the countdown
internally — no HA timers
**Important:** Dryers use `FinishInRelative` (like washing machines).
The appliance receives the deadline and calculates the optimal start
time itself.
**No scheduling needed** — the dryer handles the delayed start
itself. No `input_datetime` helpers required. Survives HA restarts
because the countdown runs on the appliance.
**Prerequisites:**
- [Tibber Prices integration](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) installed via HACS
- [Home Connect Alt](https://my.home-assistant.io/redirect/hacs_repository/?owner=ekutner&repository=home-connect-hass&category=integration) integration configured
- **Remote Start** enabled on the dryer
**Tip:** For multiple wash + dry cycles, use the
[Laundry Day Pipeline](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/laundry_day_pipeline_home_connect_alt.yaml)
blueprint instead.
**Other variants:**
[Smart Plug](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dryer.yaml)
·
[Home Connect](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dryer_home_connect.yaml)
domain: automation
author: jpawlowski
homeassistant:
min_version: "2024.11.0"
source_url: https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dryer_home_connect_alt.yaml
input:
appliance:
name: Appliance Entities
icon: mdi:tumble-dryer
description: >
Select your Home Connect Alt dryer entities.
All entities belong to the same appliance device.
input:
program_entity:
name: Program Select Entity
description: >
The **Programs** select entity of your dryer
(e.g., `select.dryer_programs`).
Used to read the selected program and as target for starting.
selector:
entity:
filter:
integration: home_connect_alt
domain: select
door_sensor:
name: Door Sensor
description: >
The door sensor of your dryer
(e.g., `binary_sensor.dryer_door`).
selector:
entity:
filter:
integration: home_connect_alt
domain: binary_sensor
device_class: door
remote_start_sensor:
name: Remote Start Sensor
description: >
The "Remote Control Start Allowed" binary sensor
(e.g., `binary_sensor.dryer_bsh_common_status_remotecontrolstartallowed`).
Must be **on** for the automation to proceed.
selector:
entity:
filter:
integration: home_connect_alt
domain: binary_sensor
estimated_duration_entity:
name: Estimated Program Duration
description: >
The "Estimated Total Program Time" sensor
(e.g., `sensor.dryer_estimated_total_program_time`).
Shows the expected duration in `H:MM` format.
If unavailable, the fallback duration is used instead.
selector:
entity:
filter:
integration: home_connect_alt
domain: sensor
operation_state_entity:
name: Operation State
description: >
The "Operation State" sensor
(e.g., `sensor.dryer_operation_state`).
Used to verify the machine is ready before planning.
selector:
entity:
filter:
integration: home_connect_alt
domain: sensor
schedule:
name: Schedule
icon: mdi:calendar-clock
description: >
Configure the deadline and fallback duration.
input:
must_finish_by:
name: Must Finish By
description: >
The program must be finished by this time.
If this time has already passed today, the deadline
automatically moves to tomorrow (overnight mode).
default: "06:00:00"
selector:
time:
duration_fallback:
name: Fallback Duration (minutes)
description: >
Used **only** if the device doesn't report the estimated
duration (e.g., program not yet fully selected on the
appliance). Normally the duration is read automatically.
Cotton Dry ≈ 60 min, Extra Dry ≈ 75 min, Gentle ≈ 90 min.
default: 65
selector:
number:
min: 15
max: 180
step: 5
unit_of_measurement: min
mode: slider
notifications:
name: Notifications
icon: mdi:bell-outline
collapsed: true
description: >
Optional notifications. Use **simple mode** (just a service)
or point to an **advanced script** for multi-target,
presence-aware, and platform-specific notifications.
input:
notify_service:
name: Quick Notification (Simple)
description: >
One or more notify services, comma-separated
(e.g., `notify.mobile_app_yourphone` or
`notify.mobile_app_phone, notify.mobile_app_tablet`).
Ignored when the advanced script is set.
default: ""
selector:
text:
notification_script:
name: Notification Script (Advanced)
description: >
A `script.*` entity for advanced notifications
(multiple recipients, presence filtering, iOS/Android).
When set, replaces the simple notification.
Receives structured variables (event_type, appliance,
title, message, and context data).
default: ""
selector:
entity:
filter:
domain: script
title_setup_required:
name: "Title: Setup Required"
default: "🌀 Dryer — Setup Required"
selector:
text:
title_not_ready:
name: "Title: Not Ready"
default: "🌀 Dryer — Not Ready"
selector:
text:
title_no_program:
name: "Title: No Program"
default: "🌀 Dryer — No Program"
selector:
text:
title_no_cheap_slot:
name: "Title: No Cheap Slot"
default: "🌀 Dryer — No Cheap Slot"
selector:
text:
title_planned:
name: "Title: Planned"
default: "🌀 Dryer — Planned!"
selector:
text:
mode: single
max_exceeded: silent
triggers:
- trigger: state
entity_id: !input door_sensor
to: "off"
- trigger: state
entity_id: !input remote_start_sensor
to: "on"
conditions:
- condition: state
entity_id: !input door_sensor
state: "off"
- condition: state
entity_id: !input remote_start_sensor
state: "on"
variables:
_blueprint_variant: "home_connect_alt"
program_entity: !input program_entity
door_sensor: !input door_sensor
remote_start_sensor: !input remote_start_sensor
estimated_duration_entity: !input estimated_duration_entity
operation_state_entity: !input operation_state_entity
must_finish_by_time: !input must_finish_by
duration_fallback: !input duration_fallback
notify_service: !input notify_service
notification_script: !input notification_script
title_setup_required: !input title_setup_required
title_not_ready: !input title_not_ready
title_no_program: !input title_no_program
title_no_cheap_slot: !input title_no_cheap_slot
title_planned: !input title_planned
actions:
# ════════════════════════════════════════════════════════
# PREFLIGHT CHECKS
# ════════════════════════════════════════════════════════
- variables:
_tp_entities: "{{ integration_entities('tibber_prices') | list }}"
- if:
- condition: template
value_template: "{{ _tp_entities | length == 0 }}"
then:
- variables:
_n_title: "{{ title_setup_required }}"
_n_message: >
Install the Tibber Prices integration via HACS and
configure your Tibber account.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: setup_required
appliance: dryer
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "Tibber Prices integration not found"
- if:
- condition: template
value_template: >
{% set op = states(operation_state_entity) %}
{{ op not in ['unknown', 'unavailable']
and 'Ready' not in op
and 'Inactive' not in op }}
then:
- variables:
_n_title: "{{ title_not_ready }}"
_n_message: >
State: {{ states(operation_state_entity) }}.
Ensure it's idle with Remote Start enabled.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: not_ready
appliance: dryer
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "Machine not ready"
# ════════════════════════════════════════════════════════
# READ DEVICE DATA
# ════════════════════════════════════════════════════════
- variables:
selected_program: "{{ states(program_entity) }}"
_raw_duration: "{{ states(estimated_duration_entity) }}"
duration: >
{% set raw = states(estimated_duration_entity) %}
{% if raw not in ['unknown', 'unavailable', 'None', '']
and ':' in raw %}
{% set parts = raw.split(':') %}
{{ (parts[0] | int(0)) * 60 + (parts[1] | int(0)) }}
{% else %}
{{ duration_fallback }}
{% endif %}
deadline: >
{% set dl = today_at(must_finish_by_time) %}
{% if dl <= now() %}
{{ (dl + timedelta(days=1)).isoformat() }}
{% else %}
{{ dl.isoformat() }}
{% endif %}
- if:
- condition: template
value_template: >
{{ selected_program in ['unknown', 'unavailable', 'None', ''] }}
then:
- variables:
_n_title: "{{ title_no_program }}"
_n_message: >
Select a program, close the door, and enable
Remote Start.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: no_program
appliance: dryer
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "No program selected"
# ════════════════════════════════════════════════════════
# FIND CHEAPEST WINDOW
# ════════════════════════════════════════════════════════
- action: tibber_prices.find_cheapest_block
data:
duration: >
{{ '%02d:%02d:00' | format(
(duration | int) // 60,
(duration | int) % 60) }}
must_finish_by: "{{ deadline }}"
response_variable: result
- if:
- condition: template
value_template: "{{ not result.window_found }}"
then:
- variables:
_n_title: "{{ title_no_cheap_slot }}"
_n_message: >
No cheap slot before
{{ deadline | as_datetime | as_local
| as_timestamp | timestamp_custom('%H:%M') }}
for {{ duration }} min.
Run manually or extend the deadline.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: no_window
appliance: dryer
title: "{{ _n_title }}"
message: "{{ _n_message }}"
deadline: "{{ deadline }}"
duration_minutes: "{{ duration | int }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "No cheap window found"
# ════════════════════════════════════════════════════════
# START WITH DELAY (device manages countdown)
# ════════════════════════════════════════════════════════
- variables:
_window_start: "{{ result.window.start | as_datetime }}"
_window_end: >
{{ (_window_start + timedelta(minutes=duration | int)).isoformat() }}
finish_in_relative: >
{% set window_end = _window_start + timedelta(minutes=duration | int) %}
{% set seconds_until_end = ((window_end - now()).total_seconds()) | int %}
{{ [duration | int * 60, seconds_until_end] | max }}
_device_id: "{{ device_id(program_entity) }}"
- action: home_connect_alt.start_program
data:
device_id: "{{ _device_id }}"
program_key: "{{ selected_program }}"
options:
- key: BSH.Common.Option.FinishInRelative
value: "{{ finish_in_relative | int }}"
- variables:
_n_title: "{{ title_planned }}"
_n_message: >
{{ selected_program.split('.')[-1] }}
{% set delay = finish_in_relative | int - (duration | int * 60) %}
{% if delay > 0 %}
· ⏰ ~{{ _window_start | as_local
| as_timestamp | timestamp_custom('%H:%M') }}
(in {{ (delay / 3600) | round(1) }} h)
{% else %}
· ▶️ Starting now!
{% endif %}
· ~{{ duration }} min
· {{ result.window.price_mean | round(1) }} {{ result.price_unit }}/kWh
{% if _raw_duration in ['unknown', 'unavailable', 'None', ''] %}
· ⚠️ Duration estimated
{% endif %}
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: planned
appliance: dryer
title: "{{ _n_title }}"
message: "{{ _n_message }}"
start_time: "{{ _window_start | as_local | as_timestamp | timestamp_custom('%Y-%m-%dT%H:%M:%S') }}"
duration_minutes: "{{ duration | int }}"
price_mean: "{{ result.window.price_mean | round(1) }}"
price_unit: "{{ result.price_unit }}"
using_fallback_duration: "{{ _raw_duration in ['unknown', 'unavailable', 'None', ''] }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"

View file

@ -0,0 +1,301 @@
blueprint:
name: "Tibber Prices: EV Charging — Cheapest Hours Overnight"
description: >
**Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v1.0.0
Automatically charge your electric vehicle during the cheapest hours
overnight. Uses `find_cheapest_hours` to select the cheapest
individual 15-minute intervals — the charger may pause and resume
between segments.
**What it does:**
- Finds the cheapest intervals within a configurable search window
- Stores the first segment's start time in a helper
- Turns the charger on/off based on an interval schedule
- Optional: Skips planning if battery is already above a threshold
**Prerequisites:**
- [Tibber Prices integration](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) installed via HACS
- One helper: Date & Time (`input_datetime`) for the charge start
- A smart plug or charger switch entity
**Alternative:** If your charger can't pause/resume, use
`find_cheapest_block` instead (see the Dishwasher Smart Plug
blueprint for a contiguous-window example).
domain: automation
author: jpawlowski
homeassistant:
min_version: "2024.6.0"
source_url: https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/ev_charging.yaml
input:
vehicle:
name: Vehicle / Charger
icon: mdi:ev-station
description: Configure your EV charger switch and optional battery sensor.
input:
charger_switch:
name: Charger Switch
description: >
The switch entity that controls your EV charger
(smart plug or charger integration).
selector:
entity:
filter:
domain: switch
battery_sensor:
name: Battery Level Sensor (optional)
description: >
If provided, charging is only planned when the battery
is below the threshold. Leave empty to always plan.
default: ""
selector:
entity:
filter:
domain: sensor
device_class: battery
battery_threshold:
name: Battery Threshold
description: >
Only plan charging if battery level is below this
percentage. Ignored if no battery sensor is selected.
default: 80
selector:
number:
min: 10
max: 100
step: 5
unit_of_measurement: "%"
schedule:
name: Schedule
icon: mdi:calendar-clock
description: Configure charging times and the overnight search window.
input:
plan_time:
name: Planning Time
description: >
When to search for the cheapest hours each day.
Should be before the search window starts.
default: "18:00:00"
selector:
time:
charge_duration:
name: Total Charging Duration
description: >
How many hours of cheap charging to find.
default: "04:00:00"
selector:
time:
min_segment:
name: Minimum Segment Duration
description: >
Shortest uninterrupted charging segment. Prevents
very short on/off cycles that stress the charger.
default: "00:30:00"
selector:
time:
search_start:
name: Search Window Start
description: >
Earliest time charging may begin.
default: "18:00:00"
selector:
time:
search_end:
name: Search Window End
description: >
Latest time charging must finish by.
The vehicle should be ready by this time.
default: "07:00:00"
selector:
time:
runtime_overrides:
name: Runtime Overrides
icon: mdi:tune-vertical
collapsed: true
description: >
Optionally connect helpers to override settings from your
dashboard at runtime. When a helper is connected and has
a valid value, it takes priority over the fixed default.
Leave empty to always use the fixed defaults.
input:
charge_duration_override:
name: "Override: Charging Duration"
description: >
`input_number` helper to change the charging duration
(in hours) from your dashboard. Useful when daily
charging needs vary.
**Create in Settings → Helpers → Number**
(min: 0.5, max: 12, step: 0.5, unit: h).
default: ""
selector:
entity:
filter:
domain: input_number
notifications:
name: Notifications
icon: mdi:bell-outline
collapsed: true
description: >
Optional mobile notifications for charging schedule
and start/stop events.
input:
notify_service:
name: Notification Service
description: >
One or more notify services, comma-separated
(e.g., `notify.mobile_app_yourphone` or
`notify.mobile_app_phone, notify.mobile_app_tablet`).
Leave empty to disable all notifications.
default: ""
selector:
text:
mode: single
max_exceeded: silent
triggers:
- trigger: time
at: !input plan_time
id: plan
variables:
_blueprint_variant: "ev_charging"
charger_switch: !input charger_switch
battery_sensor: !input battery_sensor
battery_threshold: !input battery_threshold
_charge_duration_default: !input charge_duration
_charge_duration_override: !input charge_duration_override
charge_duration: >
{% set o = _charge_duration_override %}
{% if o and states(o) not in ['unknown', 'unavailable'] %}
{% set hours = states(o) | float(4) %}
{{ '%02d:%02d:00' | format(hours | int, ((hours % 1) * 60) | int) }}
{% else %}
{{ _charge_duration_default }}
{% endif %}
min_segment: !input min_segment
search_start: !input search_start
search_end: !input search_end
notify_service: !input notify_service
actions:
# Check: Tibber Prices integration installed?
- variables:
_tp_entities: "{{ integration_entities('tibber_prices') | list }}"
- if:
- condition: template
value_template: "{{ _tp_entities | length == 0 }}"
then:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🔌 EV Charging — Setup Required"
message: The Tibber Prices integration is not installed.
- stop: "Tibber Prices integration not found"
# ════════════════════════════════════════════════════════
# BATTERY CHECK
# ════════════════════════════════════════════════════════
- if:
- condition: template
value_template: >
{{ battery_sensor | length > 0
and states(battery_sensor) | int(0) >= battery_threshold | int }}
then:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🔌 EV Charging Skipped"
message: >
Battery at {{ states(battery_sensor) }}% (threshold:
{{ battery_threshold }}%). No charging needed.
- stop: "Battery above threshold"
# ════════════════════════════════════════════════════════
# FIND CHEAPEST HOURS
# ════════════════════════════════════════════════════════
- action: tibber_prices.find_cheapest_hours
data:
duration: "{{ charge_duration }}"
min_segment_duration: "{{ min_segment }}"
search_start_time: "{{ search_start }}"
search_end_time: "{{ search_end }}"
search_end_day_offset: 1
response_variable: result
- if:
- condition: template
value_template: "{{ result.intervals_found }}"
then:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🔌 EV Charging Planned"
message: >
{{ result.schedule.segment_count }} charging sessions:
{% for seg in result.schedule.segments %}
• {{ seg.start | as_datetime | as_local | as_timestamp
| timestamp_custom('%H:%M') }}{{ seg.end | as_datetime
| as_local | as_timestamp | timestamp_custom('%H:%M') }}
({{ seg.price_mean | round(1) }} {{ result.price_unit }})
{% endfor %}
# Turn on/off charger for each segment
- repeat:
for_each: "{{ result.schedule.segments }}"
sequence:
- delay: >
{{ ((repeat.item.start | as_datetime | as_local
| as_timestamp) - (now() | as_timestamp)) | int }}
- action: switch.turn_on
target:
entity_id: "{{ charger_switch }}"
- delay: >
{{ ((repeat.item.end | as_datetime | as_local
| as_timestamp) - (repeat.item.start | as_datetime
| as_local | as_timestamp)) | int }}
- action: switch.turn_off
target:
entity_id: "{{ charger_switch }}"
else:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🔌 EV Charging"
message: No cheap intervals found in the search window.

View file

@ -0,0 +1,235 @@
blueprint:
name: "Tibber Prices: Heat Pump — Temperature by Price Level"
description: >
**Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v1.0.0
Adjust your heat pump target temperature based on the current
electricity price rating. Higher target when cheap, lower when
expensive — the simplest real-time heat pump optimization.
**What it does:**
- Reacts every 15 minutes when the price sensor updates
- Sets one of 5 target temperatures based on `rating_level`
(VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, VERY_EXPENSIVE)
- No helpers needed — pure sensor-based
**Prerequisites:**
- [Tibber Prices integration](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) installed via HACS
- A `climate.*` entity for your heat pump
**See also:**
[Heat Pump Smart Boost](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/heat_pump_smart_boost.yaml)
— a more advanced variant that extends boost during V-shaped
price valleys using trend awareness.
domain: automation
author: jpawlowski
homeassistant:
min_version: "2024.6.0"
source_url: https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/heat_pump_price_level.yaml
input:
devices:
name: Devices
icon: mdi:heat-pump-outline
description: Select your heat pump and the Tibber Prices sensor.
input:
price_sensor:
name: Current Price Sensor
description: >
The `sensor.<home>_current_electricity_price` from
Tibber Prices. Must have `rating_level` attribute.
selector:
entity:
filter:
domain: sensor
integration: tibber_prices
heat_pump_entity:
name: Heat Pump
description: Your heat pump climate entity.
selector:
entity:
filter:
domain: climate
temperatures:
name: Target Temperatures
icon: mdi:thermometer
description: >
Set the target temperature for each price level.
Temperatures are in °C.
input:
temp_very_cheap:
name: VERY_CHEAP Temperature
description: Maximum comfort when prices are very low.
default: 23.0
selector:
number:
min: 15
max: 30
step: 0.5
unit_of_measurement: °C
temp_cheap:
name: CHEAP Temperature
description: Slightly above normal for moderate savings.
default: 22.0
selector:
number:
min: 15
max: 30
step: 0.5
unit_of_measurement: °C
temp_normal:
name: NORMAL Temperature
description: Baseline comfort temperature for average prices.
default: 20.5
selector:
number:
min: 15
max: 30
step: 0.5
unit_of_measurement: °C
temp_expensive:
name: EXPENSIVE Temperature
description: Reduced temperature to save during high prices.
default: 19.0
selector:
number:
min: 15
max: 30
step: 0.5
unit_of_measurement: °C
temp_very_expensive:
name: VERY_EXPENSIVE Temperature
description: Minimum to save energy during peak prices.
default: 18.0
selector:
number:
min: 15
max: 30
step: 0.5
unit_of_measurement: °C
runtime_overrides:
name: Runtime Overrides
icon: mdi:tune-vertical
collapsed: true
description: >
Optionally connect a helper to shift all target temperatures
at once (e.g., +2°C comfort boost in winter, 1°C in summer).
Leave empty to always use the fixed defaults.
input:
temperature_offset_override:
name: "Override: Temperature Offset"
description: >
`input_number` helper to shift ALL target temperatures
up or down from your dashboard.
**Create in Settings → Helpers → Number**
(min: 5, max: 5, step: 0.5, unit: °C).
default: ""
selector:
entity:
filter:
domain: input_number
notifications:
name: Notifications
icon: mdi:bell-outline
collapsed: true
description: >
Optional mobile notifications for temperature adjustments.
input:
notify_service:
name: Notification Service
description: >
One or more notify services, comma-separated
(e.g., `notify.mobile_app_yourphone` or
`notify.mobile_app_phone, notify.mobile_app_tablet`).
Leave empty to disable all notifications.
default: ""
selector:
text:
mode: restart
triggers:
- trigger: state
entity_id: !input price_sensor
variables:
_blueprint_variant: "heat_pump_price_level"
price_sensor: !input price_sensor
heat_pump_entity: !input heat_pump_entity
_temp_vc: !input temp_very_cheap
_temp_c: !input temp_cheap
_temp_n: !input temp_normal
_temp_e: !input temp_expensive
_temp_ve: !input temp_very_expensive
_temp_offset_override: !input temperature_offset_override
_temp_offset: >
{% set o = _temp_offset_override %}
{% if o and states(o) not in ['unknown', 'unavailable'] %}
{{ states(o) | float(0) }}
{% else %}
0
{% endif %}
temp_very_cheap: "{{ (_temp_vc | float) + (_temp_offset | float) }}"
temp_cheap: "{{ (_temp_c | float) + (_temp_offset | float) }}"
temp_normal: "{{ (_temp_n | float) + (_temp_offset | float) }}"
temp_expensive: "{{ (_temp_e | float) + (_temp_offset | float) }}"
temp_very_expensive: "{{ (_temp_ve | float) + (_temp_offset | float) }}"
notify_service: !input notify_service
level: >
{{ state_attr(price_sensor, 'rating_level') | default('NORMAL') }}
actions:
# Check: Tibber Prices integration installed?
- variables:
_tp_entities: "{{ integration_entities('tibber_prices') | list }}"
- if:
- condition: template
value_template: "{{ _tp_entities | length == 0 }}"
then:
- stop: "Tibber Prices integration not found"
# ════════════════════════════════════════════════════════
# SET TEMPERATURE BASED ON PRICE LEVEL
# ════════════════════════════════════════════════════════
- variables:
target_temp: >
{% if level == 'VERY_CHEAP' %}
{{ temp_very_cheap }}
{% elif level == 'CHEAP' %}
{{ temp_cheap }}
{% elif level == 'EXPENSIVE' %}
{{ temp_expensive }}
{% elif level == 'VERY_EXPENSIVE' %}
{{ temp_very_expensive }}
{% else %}
{{ temp_normal }}
{% endif %}
- action: climate.set_temperature
target:
entity_id: "{{ heat_pump_entity }}"
data:
temperature: "{{ target_temp | float }}"
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🌡️ Heat Pump Adjusted"
message: >
Price level: {{ level }}. Target temperature set to
{{ target_temp }}°C.

View file

@ -0,0 +1,282 @@
blueprint:
name: "Tibber Prices: Heat Pump — Smart Boost with Trend Awareness"
description: >
**Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v1.0.0
Advanced heat pump optimization that extends the boost window
beyond the detected Best Price Period using trend sensors.
**Why?** On V-shaped price days, the Best Price Period may cover
only 12 hours, but prices remain favorable for 46 hours. By
checking the price level AND the trend, you can safely boost
during the entire cheap valley.
**Logic:**
- **Boost** when EITHER: (a) inside a Best Price Period, OR
(b) price is CHEAP/VERY_CHEAP AND trend is stable/falling
- **Return to normal** when NEITHER condition is true
**Prerequisites:**
- [Tibber Prices integration](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) installed via HACS
- A `climate.*` entity for your heat pump
**See also:**
[Heat Pump Price Level](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/heat_pump_price_level.yaml)
— simpler variant that adjusts to 5 different temperatures per
price level without trend awareness.
domain: automation
author: jpawlowski
homeassistant:
min_version: "2024.6.0"
source_url: https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/heat_pump_smart_boost.yaml
input:
devices:
name: Devices
icon: mdi:heat-pump-outline
description: >
Select your heat pump and the Tibber Prices sensors.
input:
period_sensor:
name: Best Price Period Sensor
description: >
The `binary_sensor.<home>_best_price_period` from
Tibber Prices.
selector:
entity:
filter:
domain: binary_sensor
integration: tibber_prices
price_sensor:
name: Current Price Sensor
description: >
The `sensor.<home>_current_electricity_price` from
Tibber Prices. Must have `rating_level` attribute.
selector:
entity:
filter:
domain: sensor
integration: tibber_prices
trend_sensor:
name: Price Outlook Sensor (1h)
description: >
The `sensor.<home>_price_outlook_1h` from Tibber Prices.
Must have `trend_value` attribute. `rising` means current
price is LOWER than the future average — so it's actually
a good time to boost.
selector:
entity:
filter:
domain: sensor
integration: tibber_prices
heat_pump_entity:
name: Heat Pump
description: Your heat pump climate entity.
selector:
entity:
filter:
domain: climate
temperatures:
name: Temperatures
icon: mdi:thermometer
description: Boost and normal target temperatures.
input:
boost_temperature:
name: Boost Temperature
description: Target during the extended cheap window.
default: 22.0
selector:
number:
min: 15
max: 30
step: 0.5
unit_of_measurement: °C
normal_temperature:
name: Normal Temperature
description: Target when no cheap conditions apply.
default: 20.5
selector:
number:
min: 15
max: 30
step: 0.5
unit_of_measurement: °C
runtime_overrides:
name: Runtime Overrides
icon: mdi:tune-vertical
collapsed: true
description: >
Optionally connect helpers to override settings from your
dashboard at runtime. When a helper is connected and has
a valid value, it takes priority over the fixed default.
Leave empty to always use the fixed defaults.
input:
boost_temperature_override:
name: "Override: Boost Temperature"
description: >
`input_number` helper to change the boost temperature
from your dashboard.
**Create in Settings → Helpers → Number**
(min: 15, max: 30, step: 0.5, unit: °C).
default: ""
selector:
entity:
filter:
domain: input_number
normal_temperature_override:
name: "Override: Normal Temperature"
description: >
`input_number` helper to change the normal temperature
from your dashboard.
**Create in Settings → Helpers → Number**
(min: 15, max: 30, step: 0.5, unit: °C).
default: ""
selector:
entity:
filter:
domain: input_number
notifications:
name: Notifications
icon: mdi:bell-outline
collapsed: true
description: >
Optional mobile notifications for boost start/stop events.
input:
notify_service:
name: Notification Service
description: >
One or more notify services, comma-separated
(e.g., `notify.mobile_app_yourphone` or
`notify.mobile_app_phone, notify.mobile_app_tablet`).
Leave empty to disable all notifications.
default: ""
selector:
text:
mode: restart
triggers:
# Best price period starts/stops
- trigger: state
entity_id: !input period_sensor
to: "on"
id: period_start
- trigger: state
entity_id: !input period_sensor
to: "off"
id: period_end
# Price updates every 15 minutes
- trigger: state
entity_id: !input price_sensor
id: price_update
variables:
_blueprint_variant: "heat_pump_smart_boost"
period_sensor: !input period_sensor
price_sensor: !input price_sensor
trend_sensor: !input trend_sensor
heat_pump_entity: !input heat_pump_entity
_boost_temp_default: !input boost_temperature
_boost_temp_override: !input boost_temperature_override
boost_temperature: >
{% set o = _boost_temp_override %}
{% if o and states(o) not in ['unknown', 'unavailable'] %}
{{ states(o) | float(_boost_temp_default) }}
{% else %}
{{ _boost_temp_default }}
{% endif %}
_normal_temp_default: !input normal_temperature
_normal_temp_override: !input normal_temperature_override
normal_temperature: >
{% set o = _normal_temp_override %}
{% if o and states(o) not in ['unknown', 'unavailable'] %}
{{ states(o) | float(_normal_temp_default) }}
{% else %}
{{ _normal_temp_default }}
{% endif %}
notify_service: !input notify_service
actions:
# Check: Tibber Prices integration installed?
- variables:
_tp_entities: "{{ integration_entities('tibber_prices') | list }}"
- if:
- condition: template
value_template: "{{ _tp_entities | length == 0 }}"
then:
- stop: "Tibber Prices integration not found"
# ════════════════════════════════════════════════════════
# EVALUATE BOOST CONDITIONS
# ════════════════════════════════════════════════════════
- variables:
in_period: >
{{ is_state(period_sensor, 'on') }}
is_cheap: >
{{ state_attr(price_sensor, 'rating_level')
| default('NORMAL') in ['VERY_CHEAP', 'CHEAP'] }}
trend_ok: >
{{ state_attr(trend_sensor, 'trend_value')
| int(0) <= 0 }}
should_boost: >
{{ in_period or (is_cheap and trend_ok) }}
- choose:
# ── BOOST ──
- conditions:
- condition: template
value_template: "{{ should_boost }}"
sequence:
- action: climate.set_temperature
target:
entity_id: "{{ heat_pump_entity }}"
data:
temperature: "{{ boost_temperature | float }}"
- if:
- condition: template
value_template: >
{{ notify_service | length > 0
and trigger.id == 'period_start' }}
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🌡️ Heat Pump — Boost Active"
message: >
{% if in_period %}Best price period started.
{% else %}Price is cheap and trend is favorable.
{% endif %}
Target set to {{ boost_temperature }}°C.
# ── RETURN TO NORMAL ──
default:
- action: climate.set_temperature
target:
entity_id: "{{ heat_pump_entity }}"
data:
temperature: "{{ normal_temperature | float }}"
- if:
- condition: template
value_template: >
{{ notify_service | length > 0
and trigger.id == 'period_end' }}
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🌡️ Heat Pump — Normal Mode"
message: >
Cheap window ended. Target back to
{{ normal_temperature }}°C.

View file

@ -0,0 +1,390 @@
blueprint:
name: "Tibber Prices: Home Battery — Charge Cheap, Discharge Expensive"
description: >
**Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v1.0.0
Optimize your home battery by charging from the grid during cheap
prices and discharging during expensive periods.
**What it does:**
- **Best Price Period ON** → Charge from grid (if SOC below threshold)
- **Peak Price Period ON** → Discharge to grid (if SOC above threshold)
- **Both OFF** → Stop grid charging/discharging (solar-only mode)
- Optional: Volatility check — skip charging on flat-price days
**Prerequisites:**
- [Tibber Prices integration](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) installed via HACS
- Switch entities for grid charging and grid discharge
- Optional: Battery SOC sensor for threshold logic
domain: automation
author: jpawlowski
homeassistant:
min_version: "2024.6.0"
source_url: https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/home_battery.yaml
input:
sensors:
name: Tibber Prices Sensors
icon: mdi:chart-timeline-variant-shimmer
description: Select the period sensors from Tibber Prices.
input:
best_price_sensor:
name: Best Price Period Sensor
description: >
`binary_sensor.<home>_best_price_period` — triggers
charging.
selector:
entity:
filter:
domain: binary_sensor
integration: tibber_prices
peak_price_sensor:
name: Peak Price Period Sensor
description: >
`binary_sensor.<home>_peak_price_period` — triggers
discharging.
selector:
entity:
filter:
domain: binary_sensor
integration: tibber_prices
battery:
name: Battery
icon: mdi:battery-charging-60
description: Configure your battery switches and thresholds.
input:
charge_switch:
name: Grid Charging Switch
description: >
Switch that enables charging from the grid.
selector:
entity:
filter:
domain: switch
discharge_switch:
name: Grid Discharge Switch
description: >
Switch that enables discharging to grid / home.
selector:
entity:
filter:
domain: switch
soc_sensor:
name: Battery SOC Sensor (optional)
description: >
State of Charge sensor (0100%). Leave empty to skip
SOC checks.
default: ""
selector:
entity:
filter:
domain: sensor
device_class: battery
charge_max_soc:
name: Max SOC for Charging
description: >
Only charge from grid if SOC is below this level.
default: 90
selector:
number:
min: 50
max: 100
step: 5
unit_of_measurement: "%"
discharge_min_soc:
name: Min SOC for Discharging
description: >
Only discharge if SOC is above this level.
default: 20
selector:
number:
min: 5
max: 50
step: 5
unit_of_measurement: "%"
check_volatility:
name: Skip Charging on Flat-Price Days
description: >
When enabled, grid charging is skipped when volatility
is "low" (charging from grid wouldn't save much money).
default: true
selector:
boolean:
runtime_overrides:
name: Runtime Overrides
icon: mdi:tune-vertical
collapsed: true
description: >
Optionally connect helpers to override settings from your
dashboard at runtime. When a helper is connected and has
a valid value, it takes priority over the fixed default.
Leave empty to always use the fixed defaults.
input:
charge_max_soc_override:
name: "Override: Max SOC for Charging"
description: >
`input_number` helper to adjust the charge threshold
from your dashboard (e.g., before travel or bad weather).
**Create in Settings → Helpers → Number**
(min: 50, max: 100, step: 5, unit: %).
default: ""
selector:
entity:
filter:
domain: input_number
discharge_min_soc_override:
name: "Override: Min SOC for Discharging"
description: >
`input_number` helper to adjust the discharge threshold
from your dashboard.
**Create in Settings → Helpers → Number**
(min: 5, max: 50, step: 5, unit: %).
default: ""
selector:
entity:
filter:
domain: input_number
notifications:
name: Notifications
icon: mdi:bell-outline
collapsed: true
description: >
Optional mobile notifications for charge/discharge events.
input:
notify_service:
name: Notification Service
description: >
One or more notify services, comma-separated
(e.g., `notify.mobile_app_yourphone` or
`notify.mobile_app_phone, notify.mobile_app_tablet`).
Leave empty to disable all notifications.
default: ""
selector:
text:
mode: restart
triggers:
- trigger: state
entity_id: !input best_price_sensor
to: "on"
id: charge_start
- trigger: state
entity_id: !input best_price_sensor
to: "off"
id: charge_end
- trigger: state
entity_id: !input peak_price_sensor
to: "on"
id: discharge_start
- trigger: state
entity_id: !input peak_price_sensor
to: "off"
id: discharge_end
variables:
_blueprint_variant: "home_battery"
best_price_sensor: !input best_price_sensor
peak_price_sensor: !input peak_price_sensor
charge_switch: !input charge_switch
discharge_switch: !input discharge_switch
soc_sensor: !input soc_sensor
_charge_max_soc_default: !input charge_max_soc
_charge_max_soc_override: !input charge_max_soc_override
charge_max_soc: >
{% set o = _charge_max_soc_override %}
{% if o and states(o) not in ['unknown', 'unavailable'] %}
{{ states(o) | int(_charge_max_soc_default) }}
{% else %}
{{ _charge_max_soc_default }}
{% endif %}
_discharge_min_soc_default: !input discharge_min_soc
_discharge_min_soc_override: !input discharge_min_soc_override
discharge_min_soc: >
{% set o = _discharge_min_soc_override %}
{% if o and states(o) not in ['unknown', 'unavailable'] %}
{{ states(o) | int(_discharge_min_soc_default) }}
{% else %}
{{ _discharge_min_soc_default }}
{% endif %}
check_volatility: !input check_volatility
notify_service: !input notify_service
actions:
# Check: Tibber Prices integration installed?
- variables:
_tp_entities: "{{ integration_entities('tibber_prices') | list }}"
- if:
- condition: template
value_template: "{{ _tp_entities | length == 0 }}"
then:
- stop: "Tibber Prices integration not found"
# ════════════════════════════════════════════════════════
# CHARGE / DISCHARGE / STOP
# ════════════════════════════════════════════════════════
- choose:
# ── CHARGE during Best Price Period ──
- conditions:
- condition: trigger
id: charge_start
sequence:
# Volatility check
- if:
- condition: template
value_template: >
{{ check_volatility
and state_attr(best_price_sensor, 'volatility')
| default('normal') == 'low' }}
then:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🔋 Battery — Skipped (Low Volatility)"
message: >
Prices are flat today. Grid charging skipped
(savings would be minimal).
- stop: "Low volatility — skipping grid charge"
# SOC check
- if:
- condition: template
value_template: >
{{ soc_sensor | length > 0
and states(soc_sensor) | int(0) >= charge_max_soc | int }}
then:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🔋 Battery — Already Charged"
message: >
SOC at {{ states(soc_sensor) }}% (max:
{{ charge_max_soc }}%). Skipping.
- stop: "SOC above charge threshold"
# Start charging
- action: switch.turn_on
target:
entity_id: "{{ charge_switch }}"
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🔋 Battery — Grid Charging"
message: >
Best price period started. Charging from grid.
{% if soc_sensor | length > 0 %}
SOC: {{ states(soc_sensor) }}%.
{% endif %}
# ── DISCHARGE during Peak Price Period ──
- conditions:
- condition: trigger
id: discharge_start
sequence:
# SOC check
- if:
- condition: template
value_template: >
{{ soc_sensor | length > 0
and states(soc_sensor) | int(0) <= discharge_min_soc | int }}
then:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🔋 Battery — Too Low to Discharge"
message: >
SOC at {{ states(soc_sensor) }}% (min:
{{ discharge_min_soc }}%). Skipping.
- stop: "SOC below discharge threshold"
# Start discharging
- action: switch.turn_on
target:
entity_id: "{{ discharge_switch }}"
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🔋 Battery — Discharging"
message: >
Peak price period started. Discharging battery.
{% if soc_sensor | length > 0 %}
SOC: {{ states(soc_sensor) }}%.
{% endif %}
# ── STOP charging when best price ends ──
- conditions:
- condition: trigger
id: charge_end
sequence:
- action: switch.turn_off
target:
entity_id: "{{ charge_switch }}"
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🔋 Battery — Charge Stopped"
message: Best price period ended. Grid charging off.
# ── STOP discharging when peak price ends ──
- conditions:
- condition: trigger
id: discharge_end
sequence:
- action: switch.turn_off
target:
entity_id: "{{ discharge_switch }}"
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🔋 Battery — Discharge Stopped"
message: Peak price period ended. Grid discharge off.

View file

@ -0,0 +1,588 @@
blueprint:
name: "Tibber Prices: Laundry Day Pipeline (Smart Plug)"
description: >
**Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v1.0.0
Schedule multiple wash + dry cycles at the cheapest electricity prices
using smart plug switches.
Open your
[Tibber Prices configuration](https://my.home-assistant.io/redirect/integration/?domain=tibber_prices)
to verify the integration is installed and set up.
**What it does:**
- Plans 15 wash + dry cycles with automatic price optimization
- Finds the cheapest time windows for each appliance cycle
- Sends mobile notifications for laundry transfer reminders
- Optional pipeline mode: next wash starts while dryer runs
**Prerequisites:**
- [Tibber Prices integration](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) installed via HACS
- Two helpers (created in Settings → Helpers):
- Toggle (`input_boolean`) — starts laundry day when turned on
- Number (`input_number`, min 1, max 5, step 1) — how many loads
- Smart plug switches for washer and dryer
**How it works:**
```
Load 1: [══ Wash 1 ══] → transfer → [══ Dry 1 ══]
Load 2: (pipeline) [══ Wash 2 ══] → transfer → [══ Dry 2 ══]
```
1. Turn on the toggle to start laundry day
2. Each wash + dry cycle is planned at the cheapest available price
3. You receive notifications when it's time to transfer laundry
4. The toggle turns off automatically when all loads are done
**Pipeline mode** (optional): When your wash cycle takes longer than
your dry cycle, the next wash can start while the dryer is still
running. This significantly reduces total laundry time.
**Other variants:**
[Home Connect](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/laundry_day_pipeline_home_connect.yaml)
·
[Home Connect Alt](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/laundry_day_pipeline_home_connect_alt.yaml)
domain: automation
author: jpawlowski
homeassistant:
min_version: "2024.6.0"
source_url: https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/laundry_day_pipeline.yaml
input:
appliances:
name: Appliances
icon: mdi:washing-machine
description: Configure your washing machine and dryer.
input:
washer_switch:
name: Washing Machine Switch
description: Smart plug controlling the washing machine.
selector:
entity:
filter:
domain: switch
include_dryer:
name: Include Dryer
description: >
Enable to schedule dryer cycles after each wash.
Disable if you hang laundry to dry.
default: true
selector:
boolean:
dryer_switch:
name: Dryer Switch
description: >
Smart plug controlling the dryer.
Only used when "Include Dryer" is enabled.
selector:
entity:
filter:
domain: switch
durations:
name: Program Durations
icon: mdi:timer-outline
description: >
Set typical program durations for your appliances.
Include a small buffer (~5 min) for cycle-to-cycle variation.
input:
washer_duration:
name: Wash Cycle Duration
description: >
Typical wash program duration in minutes.
ECO 40-60 ≈ 90 min, Cotton 60°C ≈ 120 min, Quick ≈ 45 min.
default: 95
selector:
number:
min: 15
max: 240
step: 5
unit_of_measurement: min
mode: slider
dryer_duration:
name: Dry Cycle Duration
description: >
Typical dry program duration in minutes.
Cotton Dry ≈ 60 min, Extra Dry ≈ 75 min, Gentle ≈ 90 min.
default: 65
selector:
number:
min: 15
max: 180
step: 5
unit_of_measurement: min
mode: slider
transfer_time:
name: Transfer Time
description: >
Minutes to transfer laundry from washer to dryer.
You'll get a notification when it's time.
default: 15
selector:
number:
min: 5
max: 60
step: 5
unit_of_measurement: min
mode: slider
schedule:
name: Schedule
icon: mdi:calendar-clock
description: Configure the trigger, load count, and deadline.
input:
trigger_entity:
name: Laundry Day Toggle
description: >
An `input_boolean` helper that starts laundry day when turned on.
Create in Settings → Helpers → Toggle.
selector:
entity:
filter:
domain: input_boolean
loads_entity:
name: Number of Loads
description: >
An `input_number` helper (15) for how many wash cycles to run.
Create in Settings → Helpers → Number (min: 1, max: 5, step: 1).
selector:
entity:
filter:
domain: input_number
deadline_time:
name: Must Finish By
description: >
All laundry must be finished by this time today.
The scheduler only looks for cheap windows before this deadline.
default: "22:00:00"
selector:
time:
advanced:
name: Advanced
icon: mdi:cog
collapsed: true
description: Pipeline mode and fine-tuning options.
input:
pipeline_mode:
name: Pipeline Mode
description: >
When enabled, the next wash starts immediately after the dryer
begins — without waiting for the dryer to finish. This creates
a pipeline where washer and dryer overlap, cutting total time
by roughly one dry cycle per load.
**Only safe when wash duration ≥ dryer duration.**
If your dryer takes longer than your washer, leave this off.
default: false
selector:
boolean:
runtime_overrides:
name: Runtime Overrides
icon: mdi:tune-vertical
collapsed: true
description: >
Optionally connect helpers to override durations from your
dashboard at runtime. When a helper is connected and has
a valid value, it takes priority over the fixed default.
Leave empty to always use the fixed defaults.
input:
washer_duration_override:
name: "Override: Wash Cycle Duration"
description: >
`input_number` helper to change the wash duration from
your dashboard (e.g., ECO vs. Quick program).
**Create in Settings → Helpers → Number**
(min: 15, max: 240, step: 5, unit: min).
default: ""
selector:
entity:
filter:
domain: input_number
dryer_duration_override:
name: "Override: Dry Cycle Duration"
description: >
`input_number` helper to change the dry duration from
your dashboard.
**Create in Settings → Helpers → Number**
(min: 15, max: 180, step: 5, unit: min).
default: ""
selector:
entity:
filter:
domain: input_number
notifications:
name: Notifications
icon: mdi:bell-outline
collapsed: true
description: Optional mobile notifications for transfer reminders and progress.
input:
notify_service:
name: Notification Service
description: >
One or more notify services, comma-separated
(e.g., `notify.mobile_app_yourphone` or
`notify.mobile_app_phone, notify.mobile_app_tablet`).
Leave empty to disable all notifications.
default: ""
selector:
text:
# Only one laundry day at a time
mode: single
max_exceeded: warning
triggers:
- trigger: state
entity_id: !input trigger_entity
to: "on"
# Expose inputs as template variables
variables:
# Blueprint versioning — for compatibility checks
_blueprint_variant: "smart_plug"
# Input variables
washer_switch: !input washer_switch
dryer_switch: !input dryer_switch
include_dryer: !input include_dryer
_washer_duration_default: !input washer_duration
_washer_duration_override: !input washer_duration_override
washer_duration: >
{% set o = _washer_duration_override %}
{% if o and states(o) not in ['unknown', 'unavailable'] %}
{{ states(o) | int(_washer_duration_default) }}
{% else %}
{{ _washer_duration_default }}
{% endif %}
_dryer_duration_default: !input dryer_duration
_dryer_duration_override: !input dryer_duration_override
dryer_duration: >
{% set o = _dryer_duration_override %}
{% if o and states(o) not in ['unknown', 'unavailable'] %}
{{ states(o) | int(_dryer_duration_default) }}
{% else %}
{{ _dryer_duration_default }}
{% endif %}
transfer_time: !input transfer_time
loads_entity: !input loads_entity
deadline_time: !input deadline_time
pipeline_mode: !input pipeline_mode
notify_service: !input notify_service
total_loads: "{{ states(loads_entity) | int(1) }}"
trigger_entity: !input trigger_entity
actions:
# Check: Tibber Prices integration installed?
- variables:
_tp_entities: "{{ integration_entities('tibber_prices') | list }}"
# Check: Integration installed?
- if:
- condition: template
value_template: "{{ _tp_entities | length == 0 }}"
then:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🧺 Laundry Day — Setup Required"
message: >
The Tibber Prices integration is not installed or not
configured. Install it via HACS and set up your Tibber
account before using this blueprint.
- action: input_boolean.turn_off
target:
entity_id: "{{ trigger_entity }}"
- stop: "Tibber Prices integration not found"
# ════════════════════════════════════════════════════════
# VALIDATION
# ════════════════════════════════════════════════════════
- if:
- condition: template
value_template: "{{ total_loads < 1 or total_loads > 5 }}"
then:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🧺 Laundry Day"
message: >
Invalid number of loads: {{ total_loads }}.
Set {{ loads_entity }} between 1 and 5.
- action: input_boolean.turn_off
target:
entity_id: "{{ trigger_entity }}"
- stop: "Invalid load count"
# ════════════════════════════════════════════════════════
# START NOTIFICATION
# ════════════════════════════════════════════════════════
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🧺 Laundry Day Started"
message: >
Planning {{ total_loads }}
load{{ 's' if total_loads | int > 1 else '' }}
(wash {{ washer_duration }} min
{{ '+ dry ' ~ dryer_duration ~ ' min' if include_dryer else '' }}).
Must finish by {{ deadline_time[:5] }}.
# ════════════════════════════════════════════════════════
# MAIN PIPELINE LOOP
# ════════════════════════════════════════════════════════
- repeat:
count: "{{ total_loads }}"
sequence:
# Check if user cancelled (turned off the toggle)
- if:
- condition: template
value_template: "{{ is_state(trigger_entity, 'off') }}"
then:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🧺 Laundry Day Cancelled"
message: >
Stopped after {{ repeat.index - 1 }}
of {{ total_loads }} loads.
- stop: "Cancelled by user"
# ── PLAN WASH ──────────────────────────────────
- action: tibber_prices.find_cheapest_block
data:
duration: >
{{ '%02d:%02d:00' | format(
(washer_duration | int) // 60,
(washer_duration | int) % 60) }}
must_finish_by: >
{{ today_at(deadline_time).isoformat() }}
response_variable: wash_result
- if:
- condition: template
value_template: "{{ not wash_result.window_found }}"
then:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🧺 Laundry Day — Problem"
message: >
No cheap window found for wash {{ repeat.index }}/{{ total_loads }}.
{{ wash_result.reason | default('Not enough time before deadline?') }}
- action: input_boolean.turn_off
target:
entity_id: "{{ trigger_entity }}"
- stop: "No wash window found"
# ── WAIT UNTIL WASH START ──────────────────────
- delay:
seconds: >
{{ max(0,
((wash_result.window.start | as_datetime) - now())
.total_seconds() | int) }}
# ── START WASH ─────────────────────────────────
- action: switch.turn_on
target:
entity_id: "{{ washer_switch }}"
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: >
👕 Wash {{ repeat.index }}/{{ total_loads }} Started
message: >
Running until
~{{ (now() + timedelta(minutes=washer_duration | int))
| as_timestamp | timestamp_custom('%H:%M') }}.
Price: {{ wash_result.window.price_mean | round(1) }}
{{ wash_result.price_unit }}/kWh avg.
{% if wash_result.relaxation_applied | default(false) %}
(Filters relaxed to find window.)
{% endif %}
# ── WAIT FOR WASH TO COMPLETE ──────────────────
- delay:
minutes: "{{ washer_duration }}"
# ── WASH DONE ─────────────────────────────────
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: >
✅ Wash {{ repeat.index }}/{{ total_loads }} Done!
message: >
{% if include_dryer %}
Transfer laundry to the dryer!
{% endif %}
{% if repeat.index | int < total_loads | int %}
{% if include_dryer %}Then load{% else %}Load{% endif %}
the washer for load {{ repeat.index + 1 }}.
{% endif %}
# ── DRYER (if enabled) ─────────────────────────
- if:
- condition: template
value_template: "{{ include_dryer }}"
then:
# Wait for transfer
- delay:
minutes: "{{ transfer_time }}"
# Plan dryer
- action: tibber_prices.find_cheapest_block
data:
duration: >
{{ '%02d:%02d:00' | format(
(dryer_duration | int) // 60,
(dryer_duration | int) % 60) }}
must_finish_by: >
{{ today_at(deadline_time).isoformat() }}
response_variable: dry_result
- if:
- condition: template
value_template: "{{ not dry_result.window_found }}"
then:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "⚠️ Dryer {{ repeat.index }} — No Window"
message: >
No cheap window found for dryer {{ repeat.index }}.
Consider running the dryer manually.
{{ dry_result.reason | default('') }}
# Don't abort — continue with next wash cycle
- if:
- condition: template
value_template: >
{{ dry_result.window_found | default(false) }}
then:
# Wait until dryer start
- delay:
seconds: >
{{ max(0,
((dry_result.window.start | as_datetime) - now())
.total_seconds() | int) }}
# START DRYER
- action: switch.turn_on
target:
entity_id: "{{ dryer_switch }}"
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: >
🌀 Dryer {{ repeat.index }}/{{ total_loads }}
Started
message: >
Running until
~{{ (now() + timedelta(minutes=dryer_duration | int))
| as_timestamp | timestamp_custom('%H:%M') }}.
{% if pipeline_mode
and repeat.index | int < total_loads | int %}
Next wash will be planned now —
dryer runs in parallel.
{% endif %}
# Wait for dryer to finish
# UNLESS pipeline mode AND more loads to come
- if:
- condition: template
value_template: >
{{ not (pipeline_mode
and repeat.index | int < total_loads | int) }}
then:
- delay:
minutes: "{{ dryer_duration }}"
# ════════════════════════════════════════════════════════
# ALL DONE
# ════════════════════════════════════════════════════════
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🎉 Laundry Day Complete!"
message: >
All {{ total_loads }}
load{{ 's' if total_loads | int > 1 else '' }}
washed{{ ' and dried' if include_dryer else '' }}.
Time to fold! 🧺
- action: input_boolean.turn_off
target:
entity_id: "{{ trigger_entity }}"

View file

@ -0,0 +1,284 @@
blueprint:
name: "Tibber Prices: Washing Machine (Smart Plug)"
description: >
**Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v1.0.0
Automatically run your washing machine at the cheapest electricity
price overnight using a smart plug.
Open your
[Tibber Prices configuration](https://my.home-assistant.io/redirect/integration/?domain=tibber_prices)
to verify the integration is installed and set up.
**What it does:**
- Plans the cheapest window overnight for one wash cycle
- Starts the washing machine automatically at the cheapest time
- Sends a notification with the planned time and price
- Survives Home Assistant restarts (uses `input_datetime` helper)
**Prerequisites:**
- [Tibber Prices integration](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) installed via HACS
- One helper (created in Settings → Helpers):
- Date & Time (`input_datetime`) — stores the planned start time
- Smart plug switch for the washing machine
**Tip:** For multiple wash + dry cycles in one day, use the
[Laundry Day Pipeline](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/laundry_day_pipeline.yaml)
blueprint instead.
**Other variants:**
[Home Connect](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/washing_machine_home_connect.yaml)
·
[Home Connect Alt](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/washing_machine_home_connect_alt.yaml)
domain: automation
author: jpawlowski
homeassistant:
min_version: "2024.6.0"
source_url: https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/washing_machine.yaml
input:
appliance:
name: Appliance
icon: mdi:washing-machine
description: Select the smart plug that controls your washing machine.
input:
appliance_switch:
name: Washing Machine Smart Plug
description: The switch entity controlling the washing machine.
selector:
entity:
filter:
domain: switch
schedule:
name: Schedule
icon: mdi:calendar-clock
description: Configure when to plan and the search window.
input:
plan_time:
name: Planning Time
description: >
When to search for the cheapest window each day.
default: "20:00:00"
selector:
time:
start_helper:
name: Start Time Helper
description: >
An `input_datetime` helper (type: Date and Time) that stores
the planned start time. Create in Settings → Helpers.
selector:
entity:
filter:
domain: input_datetime
duration:
name: Program Duration
description: >
Typical wash program duration in minutes.
ECO 40-60 ≈ 90 min, Cotton 60°C ≈ 120 min, Quick ≈ 45 min.
default: 95
selector:
number:
min: 15
max: 240
step: 5
unit_of_measurement: min
mode: slider
search_start:
name: Search Window Start
description: >
Earliest time the washing machine may start.
Typically late evening after loading.
default: "22:00:00"
selector:
time:
search_end:
name: Search Window End
description: >
Latest time the wash must finish by.
The program must complete before this time.
default: "06:00:00"
selector:
time:
runtime_overrides:
name: Runtime Overrides
icon: mdi:tune-vertical
collapsed: true
description: >
Optionally connect helpers to override settings from your
dashboard at runtime. When a helper is connected and has
a valid value, it takes priority over the fixed default.
Leave empty to always use the fixed defaults.
input:
duration_override:
name: "Override: Program Duration"
description: >
`input_number` helper to change the duration from your
dashboard without reconfiguring the blueprint.
**Create in Settings → Helpers → Number** with the same
min/max as the Duration slider above.
default: ""
selector:
entity:
filter:
domain: input_number
notifications:
name: Notifications
icon: mdi:bell-outline
collapsed: true
description: Optional mobile notifications.
input:
notify_service:
name: Notification Service
description: >
One or more notify services, comma-separated
(e.g., `notify.mobile_app_yourphone` or
`notify.mobile_app_phone, notify.mobile_app_tablet`).
Leave empty to disable all notifications.
default: ""
selector:
text:
mode: single
max_exceeded: silent
triggers:
- trigger: time
at: !input plan_time
id: plan
- trigger: time
at: !input start_helper
id: execute
variables:
_blueprint_variant: "smart_plug"
appliance_switch: !input appliance_switch
start_helper: !input start_helper
_duration_default: !input duration
_duration_override: !input duration_override
duration: >
{% set o = _duration_override %}
{% if o and states(o) not in ['unknown', 'unavailable'] %}
{{ states(o) | int(_duration_default) }}
{% else %}
{{ _duration_default }}
{% endif %}
search_start: !input search_start
search_end: !input search_end
notify_service: !input notify_service
actions:
# Check: Tibber Prices integration installed?
- variables:
_tp_entities: "{{ integration_entities('tibber_prices') | list }}"
- if:
- condition: template
value_template: "{{ _tp_entities | length == 0 }}"
then:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "👕 Washing Machine — Setup Required"
message: >
The Tibber Prices integration is not installed or not
configured. Install it via HACS and set up your Tibber
account before using this blueprint.
- stop: "Tibber Prices integration not found"
# ════════════════════════════════════════════════════════
# PLAN / EXECUTE
# ════════════════════════════════════════════════════════
- choose:
- conditions:
- condition: trigger
id: plan
sequence:
- action: tibber_prices.find_cheapest_block
data:
duration: >
{{ '%02d:%02d:00' | format(
(duration | int) // 60,
(duration | int) % 60) }}
search_start_time: "{{ search_start }}"
search_end_time: "{{ search_end }}"
search_end_day_offset: 1
response_variable: result
- if:
- condition: template
value_template: "{{ result.window_found }}"
then:
- action: input_datetime.set_datetime
target:
entity_id: "{{ start_helper }}"
data:
datetime: "{{ result.window.start }}"
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "👕 Washing Machine Planned"
message: >
Start at {{ result.window.start | as_datetime
| as_local | as_timestamp
| timestamp_custom('%H:%M') }}.
Avg price: {{ result.window.price_mean | round(1) }}
{{ result.price_unit }}/kWh.
{% if result.relaxation_applied | default(false) %}
(Filters relaxed to find window.)
{% endif %}
else:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "👕 Washing Machine"
message: >
No cheap window found. Consider running manually
or adjusting the search window.
- conditions:
- condition: trigger
id: execute
sequence:
- action: switch.turn_on
target:
entity_id: "{{ appliance_switch }}"
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "👕 Washing Machine Started"
message: >
Smart plug turned on. Program should finish in
~{{ duration }} minutes.

View file

@ -0,0 +1,458 @@
blueprint:
name: "Tibber Prices: Washing Machine (Home Connect)"
description: >
**Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v1.0.0
**Device-driven** washing machine automation with electricity price
optimization using the **Home Connect** integration (HA Core).
**How it works:**
1. Select your program on the washing machine
2. Close the door and enable Remote Start
3. The blueprint reads the estimated duration from the device
4. Finds the cheapest electricity window before your deadline
5. Tells the machine when to finish via `FinishInRelative`
6. The machine calculates when to start and manages the countdown
internally — no HA timers
**Important:** Washing machines use `FinishInRelative` (not
`StartInRelative` like dishwashers). The appliance receives the
deadline and calculates the optimal start time itself.
**No scheduling needed** — the machine handles the delayed start
itself. No `input_datetime` helpers required. Survives HA restarts
because the countdown runs on the appliance.
**Prerequisites:**
- [Tibber Prices integration](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) installed via HACS
- [Home Connect](https://www.home-assistant.io/integrations/home_connect/) integration configured
- **Remote Start** enabled on the washing machine
**Tip:** For multiple wash + dry cycles, use the
[Laundry Day Pipeline](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/laundry_day_pipeline_home_connect.yaml)
blueprint instead.
**Other variants:**
[Smart Plug](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/washing_machine.yaml)
·
[Home Connect Alt](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/washing_machine_home_connect_alt.yaml)
domain: automation
author: jpawlowski
homeassistant:
min_version: "2024.11.0"
source_url: https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/washing_machine_home_connect.yaml
input:
appliance:
name: Appliance
icon: mdi:washing-machine
description: >
Select your Home Connect washing machine device and entities.
input:
appliance_device:
name: Washing Machine Device
description: >
Your washing machine from the Home Connect integration.
Used to target the start command.
selector:
device:
filter:
integration: home_connect
door_sensor:
name: Door Sensor
description: >
The door sensor of your washing machine.
selector:
entity:
filter:
integration: home_connect
domain: binary_sensor
device_class: door
remote_start_sensor:
name: Remote Start Sensor
description: >
The "Remote Control Start Allowed" binary sensor.
Must be **on** for the automation to proceed.
selector:
entity:
filter:
integration: home_connect
domain: binary_sensor
estimated_duration_entity:
name: Estimated Program Duration
description: >
The "Estimated Total Program Time" sensor.
If unavailable, the fallback duration is used instead.
selector:
entity:
filter:
integration: home_connect
domain: sensor
operation_state_entity:
name: Operation State
description: >
The "Operation State" sensor.
Used to verify the machine is ready before planning.
selector:
entity:
filter:
integration: home_connect
domain: sensor
schedule:
name: Schedule
icon: mdi:calendar-clock
description: >
Configure the deadline and fallback duration.
input:
must_finish_by:
name: Must Finish By
description: >
The program must be finished by this time.
If this time has already passed today, the deadline
automatically moves to tomorrow (overnight mode).
default: "06:00:00"
selector:
time:
duration_fallback:
name: Fallback Duration (minutes)
description: >
Used **only** if the device doesn't report the estimated
duration. Normally the duration is read automatically.
ECO 40-60 ≈ 90 min, Cotton 60°C ≈ 120 min, Quick ≈ 45 min.
default: 95
selector:
number:
min: 15
max: 240
step: 5
unit_of_measurement: min
mode: slider
notifications:
name: Notifications
icon: mdi:bell-outline
collapsed: true
description: >
Optional notifications. Use **simple mode** (just a service)
or point to an **advanced script** for multi-target,
presence-aware, and platform-specific notifications.
input:
notify_service:
name: Quick Notification (Simple)
description: >
One or more notify services, comma-separated
(e.g., `notify.mobile_app_yourphone` or
`notify.mobile_app_phone, notify.mobile_app_tablet`).
Ignored when the advanced script is set.
default: ""
selector:
text:
notification_script:
name: Notification Script (Advanced)
description: >
A `script.*` entity for advanced notifications
(multiple recipients, presence filtering, iOS/Android).
When set, replaces the simple notification.
Receives structured variables (event_type, appliance,
title, message, and context data).
default: ""
selector:
entity:
filter:
domain: script
title_setup_required:
name: "Title: Setup Required"
default: "👕 Washing Machine — Setup Required"
selector:
text:
title_not_ready:
name: "Title: Not Ready"
default: "👕 Washing Machine — Not Ready"
selector:
text:
title_no_cheap_slot:
name: "Title: No Cheap Slot"
default: "👕 Washing Machine — No Cheap Slot"
selector:
text:
title_planned:
name: "Title: Planned"
default: "👕 Washing Machine — Planned!"
selector:
text:
mode: single
max_exceeded: silent
triggers:
- trigger: state
entity_id: !input door_sensor
to: "off"
- trigger: state
entity_id: !input remote_start_sensor
to: "on"
conditions:
- condition: state
entity_id: !input door_sensor
state: "off"
- condition: state
entity_id: !input remote_start_sensor
state: "on"
variables:
_blueprint_variant: "home_connect"
appliance_device: !input appliance_device
door_sensor: !input door_sensor
remote_start_sensor: !input remote_start_sensor
estimated_duration_entity: !input estimated_duration_entity
operation_state_entity: !input operation_state_entity
must_finish_by_time: !input must_finish_by
duration_fallback: !input duration_fallback
notify_service: !input notify_service
notification_script: !input notification_script
title_setup_required: !input title_setup_required
title_not_ready: !input title_not_ready
title_no_cheap_slot: !input title_no_cheap_slot
title_planned: !input title_planned
actions:
# ════════════════════════════════════════════════════════
# PREFLIGHT CHECKS
# ════════════════════════════════════════════════════════
- variables:
_tp_entities: "{{ integration_entities('tibber_prices') | list }}"
- if:
- condition: template
value_template: "{{ _tp_entities | length == 0 }}"
then:
- variables:
_n_title: "{{ title_setup_required }}"
_n_message: >
Install the Tibber Prices integration via HACS and
configure your Tibber account.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: setup_required
appliance: washing_machine
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "Tibber Prices integration not found"
- if:
- condition: template
value_template: >
{% set op = states(operation_state_entity) %}
{{ op not in ['unknown', 'unavailable']
and 'Ready' not in op
and 'Inactive' not in op }}
then:
- variables:
_n_title: "{{ title_not_ready }}"
_n_message: >
State: {{ states(operation_state_entity) }}.
Ensure it's idle with Remote Start enabled.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: not_ready
appliance: washing_machine
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "Machine not ready"
# ════════════════════════════════════════════════════════
# READ DEVICE DATA
# ════════════════════════════════════════════════════════
- variables:
_raw_duration: "{{ states(estimated_duration_entity) }}"
duration: >
{% set raw = states(estimated_duration_entity) %}
{% if raw not in ['unknown', 'unavailable', 'None', '']
and ':' in raw %}
{% set parts = raw.split(':') %}
{{ (parts[0] | int(0)) * 60 + (parts[1] | int(0)) }}
{% elif raw not in ['unknown', 'unavailable', 'None', '']
and raw | int(0) > 0 %}
{{ raw | int }}
{% else %}
{{ duration_fallback }}
{% endif %}
deadline: >
{% set dl = today_at(must_finish_by_time) %}
{% if dl <= now() %}
{{ (dl + timedelta(days=1)).isoformat() }}
{% else %}
{{ dl.isoformat() }}
{% endif %}
# ════════════════════════════════════════════════════════
# FIND CHEAPEST WINDOW
# ════════════════════════════════════════════════════════
- action: tibber_prices.find_cheapest_block
data:
duration: >
{{ '%02d:%02d:00' | format(
(duration | int) // 60,
(duration | int) % 60) }}
must_finish_by: "{{ deadline }}"
response_variable: result
- if:
- condition: template
value_template: "{{ not result.window_found }}"
then:
- variables:
_n_title: "{{ title_no_cheap_slot }}"
_n_message: >
No cheap slot before
{{ deadline | as_datetime | as_local
| as_timestamp | timestamp_custom('%H:%M') }}
for {{ duration }} min.
Run manually or extend the deadline.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: no_window
appliance: washing_machine
title: "{{ _n_title }}"
message: "{{ _n_message }}"
deadline: "{{ deadline }}"
duration_minutes: "{{ duration | int }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "No cheap window found"
# ════════════════════════════════════════════════════════
# START WITH DELAY (device manages countdown)
# ════════════════════════════════════════════════════════
- variables:
_window_start: "{{ result.window.start | as_datetime }}"
_window_end: >
{{ (_window_start + timedelta(minutes=duration | int)).isoformat() }}
finish_in_relative: >
{% set window_end = _window_start + timedelta(minutes=duration | int) %}
{% set seconds_until_end = ((window_end - now()).total_seconds()) | int %}
{{ [duration | int * 60, seconds_until_end] | max }}
# Washing machines use FinishInRelative
- action: home_connect.set_program_and_options
target:
device_id: "{{ appliance_device }}"
data:
affects_to: active_program
b_s_h_common_option_finish_in_relative: "{{ finish_in_relative }}"
- variables:
_n_title: "{{ title_planned }}"
_n_message: >
{% set delay = finish_in_relative | int - (duration | int * 60) %}
{% if delay > 0 %}
⏰ ~{{ _window_start | as_local
| as_timestamp | timestamp_custom('%H:%M') }}
(in {{ (delay / 3600) | round(1) }} h)
· ~{{ duration }} min
· {{ result.window.price_mean | round(1) }} {{ result.price_unit }}/kWh
{% else %}
▶️ Starting now!
· ~{{ duration }} min
· {{ result.window.price_mean | round(1) }} {{ result.price_unit }}/kWh
{% endif %}
{% if _raw_duration in ['unknown', 'unavailable', 'None', ''] %}
· ⚠️ Duration estimated
{% endif %}
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: planned
appliance: washing_machine
title: "{{ _n_title }}"
message: "{{ _n_message }}"
start_time: "{{ _window_start | as_local | as_timestamp | timestamp_custom('%Y-%m-%dT%H:%M:%S') }}"
duration_minutes: "{{ duration | int }}"
price_mean: "{{ result.window.price_mean | round(1) }}"
price_unit: "{{ result.price_unit }}"
using_fallback_duration: "{{ _raw_duration in ['unknown', 'unavailable', 'None', ''] }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"

View file

@ -0,0 +1,513 @@
blueprint:
name: "Tibber Prices: Washing Machine (Home Connect Alt)"
description: >
**Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v1.0.0
**Device-driven** washing machine automation with electricity price
optimization using **Home Connect Alt**
([HACS integration by ekutner](https://my.home-assistant.io/redirect/hacs_repository/?owner=ekutner&repository=home-connect-hass&category=integration)).
**How it works:**
1. Select your program on the washing machine
2. Close the door and enable Remote Start
3. The blueprint reads the program and estimated duration from the
device automatically
4. Finds the cheapest electricity window before your deadline
5. Tells the washing machine when to finish via `FinishInRelative`
6. The machine calculates when to start and manages the countdown
internally — no HA timers
**Important:** Washing machines use `FinishInRelative` (not
`StartInRelative` like dishwashers). The appliance receives the
deadline and calculates the optimal start time itself.
**No scheduling needed** — the machine handles the delayed start
itself. No `input_datetime` helpers required. Survives HA restarts
because the countdown runs on the appliance.
**Prerequisites:**
- [Tibber Prices integration](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) installed via HACS
- [Home Connect Alt](https://my.home-assistant.io/redirect/hacs_repository/?owner=ekutner&repository=home-connect-hass&category=integration) integration configured
- **Remote Start** enabled on the washing machine
**Tip:** For multiple wash + dry cycles, use the
[Laundry Day Pipeline](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/laundry_day_pipeline_home_connect_alt.yaml)
blueprint instead.
**Other variants:**
[Smart Plug](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/washing_machine.yaml)
·
[Home Connect](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/washing_machine_home_connect.yaml)
domain: automation
author: jpawlowski
homeassistant:
min_version: "2024.11.0"
source_url: https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/washing_machine_home_connect_alt.yaml
input:
appliance:
name: Appliance Entities
icon: mdi:washing-machine
description: >
Select your Home Connect Alt washing machine entities.
All entities belong to the same appliance device.
input:
program_entity:
name: Program Select Entity
description: >
The **Programs** select entity of your washing machine
(e.g., `select.washer_programs`).
Used to read the selected program and as target for starting.
selector:
entity:
filter:
integration: home_connect_alt
domain: select
door_sensor:
name: Door Sensor
description: >
The door sensor of your washing machine
(e.g., `binary_sensor.washer_door`).
selector:
entity:
filter:
integration: home_connect_alt
domain: binary_sensor
device_class: door
remote_start_sensor:
name: Remote Start Sensor
description: >
The "Remote Control Start Allowed" binary sensor
(e.g., `binary_sensor.washer_bsh_common_status_remotecontrolstartallowed`).
Must be **on** for the automation to proceed.
selector:
entity:
filter:
integration: home_connect_alt
domain: binary_sensor
estimated_duration_entity:
name: Estimated Program Duration
description: >
The "Estimated Total Program Time" sensor
(e.g., `sensor.washer_estimated_total_program_time`).
Shows the expected duration in `H:MM` format.
If unavailable, the fallback duration is used instead.
selector:
entity:
filter:
integration: home_connect_alt
domain: sensor
operation_state_entity:
name: Operation State
description: >
The "Operation State" sensor
(e.g., `sensor.washer_operation_state`).
Used to verify the machine is ready before planning.
selector:
entity:
filter:
integration: home_connect_alt
domain: sensor
schedule:
name: Schedule
icon: mdi:calendar-clock
description: >
Configure the deadline and fallback duration.
input:
must_finish_by:
name: Must Finish By
description: >
The program must be finished by this time.
If this time has already passed today, the deadline
automatically moves to tomorrow (overnight mode).
default: "06:00:00"
selector:
time:
duration_fallback:
name: Fallback Duration (minutes)
description: >
Used **only** if the device doesn't report the estimated
duration (e.g., program not yet fully selected on the
appliance). Normally the duration is read automatically.
ECO 40-60 ≈ 90 min, Cotton 60°C ≈ 120 min, Quick ≈ 45 min.
default: 95
selector:
number:
min: 15
max: 240
step: 5
unit_of_measurement: min
mode: slider
notifications:
name: Notifications
icon: mdi:bell-outline
collapsed: true
description: >
Optional notifications. Use **simple mode** (just a service)
or point to an **advanced script** for multi-target,
presence-aware, and platform-specific notifications.
input:
notify_service:
name: Quick Notification (Simple)
description: >
One or more notify services, comma-separated
(e.g., `notify.mobile_app_yourphone` or
`notify.mobile_app_phone, notify.mobile_app_tablet`).
Ignored when the advanced script is set.
default: ""
selector:
text:
notification_script:
name: Notification Script (Advanced)
description: >
A `script.*` entity for advanced notifications
(multiple recipients, presence filtering, iOS/Android).
When set, replaces the simple notification.
Receives structured variables (event_type, appliance,
title, message, and context data).
default: ""
selector:
entity:
filter:
domain: script
title_setup_required:
name: "Title: Setup Required"
default: "👕 Washing Machine — Setup Required"
selector:
text:
title_not_ready:
name: "Title: Not Ready"
default: "👕 Washing Machine — Not Ready"
selector:
text:
title_no_program:
name: "Title: No Program"
default: "👕 Washing Machine — No Program"
selector:
text:
title_no_cheap_slot:
name: "Title: No Cheap Slot"
default: "👕 Washing Machine — No Cheap Slot"
selector:
text:
title_planned:
name: "Title: Planned"
default: "👕 Washing Machine — Planned!"
selector:
text:
mode: single
max_exceeded: silent
triggers:
- trigger: state
entity_id: !input door_sensor
to: "off"
- trigger: state
entity_id: !input remote_start_sensor
to: "on"
conditions:
- condition: state
entity_id: !input door_sensor
state: "off"
- condition: state
entity_id: !input remote_start_sensor
state: "on"
variables:
_blueprint_variant: "home_connect_alt"
program_entity: !input program_entity
door_sensor: !input door_sensor
remote_start_sensor: !input remote_start_sensor
estimated_duration_entity: !input estimated_duration_entity
operation_state_entity: !input operation_state_entity
must_finish_by_time: !input must_finish_by
duration_fallback: !input duration_fallback
notify_service: !input notify_service
notification_script: !input notification_script
title_setup_required: !input title_setup_required
title_not_ready: !input title_not_ready
title_no_program: !input title_no_program
title_no_cheap_slot: !input title_no_cheap_slot
title_planned: !input title_planned
actions:
# ════════════════════════════════════════════════════════
# PREFLIGHT CHECKS
# ════════════════════════════════════════════════════════
- variables:
_tp_entities: "{{ integration_entities('tibber_prices') | list }}"
- if:
- condition: template
value_template: "{{ _tp_entities | length == 0 }}"
then:
- variables:
_n_title: "{{ title_setup_required }}"
_n_message: >
Install the Tibber Prices integration via HACS and
configure your Tibber account.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: setup_required
appliance: washing_machine
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "Tibber Prices integration not found"
- if:
- condition: template
value_template: >
{% set op = states(operation_state_entity) %}
{{ op not in ['unknown', 'unavailable']
and 'Ready' not in op
and 'Inactive' not in op }}
then:
- variables:
_n_title: "{{ title_not_ready }}"
_n_message: >
State: {{ states(operation_state_entity) }}.
Ensure it's idle with Remote Start enabled.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: not_ready
appliance: washing_machine
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "Machine not ready"
# ════════════════════════════════════════════════════════
# READ DEVICE DATA
# ════════════════════════════════════════════════════════
- variables:
selected_program: "{{ states(program_entity) }}"
_raw_duration: "{{ states(estimated_duration_entity) }}"
duration: >
{% set raw = states(estimated_duration_entity) %}
{% if raw not in ['unknown', 'unavailable', 'None', '']
and ':' in raw %}
{% set parts = raw.split(':') %}
{{ (parts[0] | int(0)) * 60 + (parts[1] | int(0)) }}
{% else %}
{{ duration_fallback }}
{% endif %}
deadline: >
{% set dl = today_at(must_finish_by_time) %}
{% if dl <= now() %}
{{ (dl + timedelta(days=1)).isoformat() }}
{% else %}
{{ dl.isoformat() }}
{% endif %}
- if:
- condition: template
value_template: >
{{ selected_program in ['unknown', 'unavailable', 'None', ''] }}
then:
- variables:
_n_title: "{{ title_no_program }}"
_n_message: >
Select a program, close the door, and enable
Remote Start.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: no_program
appliance: washing_machine
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "No program selected"
# ════════════════════════════════════════════════════════
# FIND CHEAPEST WINDOW
# ════════════════════════════════════════════════════════
- action: tibber_prices.find_cheapest_block
data:
duration: >
{{ '%02d:%02d:00' | format(
(duration | int) // 60,
(duration | int) % 60) }}
must_finish_by: "{{ deadline }}"
response_variable: result
- if:
- condition: template
value_template: "{{ not result.window_found }}"
then:
- variables:
_n_title: "{{ title_no_cheap_slot }}"
_n_message: >
No cheap slot before
{{ deadline | as_datetime | as_local
| as_timestamp | timestamp_custom('%H:%M') }}
for {{ duration }} min.
Run manually or extend the deadline.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: no_window
appliance: washing_machine
title: "{{ _n_title }}"
message: "{{ _n_message }}"
deadline: "{{ deadline }}"
duration_minutes: "{{ duration | int }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "No cheap window found"
# ════════════════════════════════════════════════════════
# START WITH DELAY (device manages countdown)
# ════════════════════════════════════════════════════════
- variables:
_window_start: "{{ result.window.start | as_datetime }}"
# Washing machines use FinishInRelative
# (seconds from now until program must be finished)
_window_end: >
{{ (_window_start + timedelta(minutes=duration | int)).isoformat() }}
finish_in_relative: >
{% set window_end = _window_start + timedelta(minutes=duration | int) %}
{% set seconds_until_end = ((window_end - now()).total_seconds()) | int %}
{{ [duration | int * 60, seconds_until_end] | max }}
_device_id: "{{ device_id(program_entity) }}"
- action: home_connect_alt.start_program
data:
device_id: "{{ _device_id }}"
program_key: "{{ selected_program }}"
options:
- key: BSH.Common.Option.FinishInRelative
value: "{{ finish_in_relative | int }}"
- variables:
_n_title: "{{ title_planned }}"
_n_message: >
{{ selected_program.split('.')[-1] }}
{% set delay = finish_in_relative | int - (duration | int * 60) %}
{% if delay > 0 %}
· ⏰ ~{{ _window_start | as_local
| as_timestamp | timestamp_custom('%H:%M') }}
(in {{ (delay / 3600) | round(1) }} h)
{% else %}
· ▶️ Starting now!
{% endif %}
· ~{{ duration }} min
· {{ result.window.price_mean | round(1) }} {{ result.price_unit }}/kWh
{% if _raw_duration in ['unknown', 'unavailable', 'None', ''] %}
· ⚠️ Duration estimated
{% endif %}
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: planned
appliance: washing_machine
title: "{{ _n_title }}"
message: "{{ _n_message }}"
start_time: "{{ _window_start | as_local | as_timestamp | timestamp_custom('%Y-%m-%dT%H:%M:%S') }}"
duration_minutes: "{{ duration | int }}"
price_mean: "{{ result.window.price_mean | round(1) }}"
price_unit: "{{ result.price_unit }}"
selected_program: "{{ selected_program }}"
using_fallback_duration: "{{ _raw_duration in ['unknown', 'unavailable', 'None', ''] }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"

View file

@ -0,0 +1,233 @@
blueprint:
name: "Tibber Prices: Water Heater — Boost During Cheap Prices"
description: >
**Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v1.0.0
Automatically boost your water heater during the cheapest price
periods and return to eco temperature when prices rise.
**What it does:**
- Raises the water heater temperature during the Best Price Period
- Lowers it back to eco when the period ends
- Real-time reaction — no planning or helpers needed
**Prerequisites:**
- [Tibber Prices integration](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) installed via HACS
- A `water_heater` entity (or `climate` entity for heat-pump boilers)
domain: automation
author: jpawlowski
homeassistant:
min_version: "2024.6.0"
source_url: https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/water_heater.yaml
input:
devices:
name: Devices
icon: mdi:water-boiler
description: Select your water heater and the Tibber Prices period sensor.
input:
period_sensor:
name: Best Price Period Sensor
description: >
The `binary_sensor.<home>_best_price_period` from Tibber Prices.
selector:
entity:
filter:
domain: binary_sensor
integration: tibber_prices
water_heater_entity:
name: Water Heater
description: >
Your water heater entity. Works with `water_heater.*`
or `climate.*` (for heat-pump water heaters).
selector:
entity:
filter:
domain:
- water_heater
- climate
temperatures:
name: Temperatures
icon: mdi:thermometer
description: Configure boost and eco temperatures.
input:
boost_temperature:
name: Boost Temperature
description: Target temperature during cheap prices.
default: 60
selector:
number:
min: 40
max: 80
step: 1
unit_of_measurement: °C
eco_temperature:
name: Eco Temperature
description: Target temperature outside cheap periods.
default: 45
selector:
number:
min: 30
max: 60
step: 1
unit_of_measurement: °C
runtime_overrides:
name: Runtime Overrides
icon: mdi:tune-vertical
collapsed: true
description: >
Optionally connect helpers to override settings from your
dashboard at runtime. When a helper is connected and has
a valid value, it takes priority over the fixed default.
Leave empty to always use the fixed defaults.
input:
boost_temperature_override:
name: "Override: Boost Temperature"
description: >
`input_number` helper to change the boost temperature
from your dashboard.
**Create in Settings → Helpers → Number**
(min: 40, max: 80, step: 1, unit: °C).
default: ""
selector:
entity:
filter:
domain: input_number
eco_temperature_override:
name: "Override: Eco Temperature"
description: >
`input_number` helper to change the eco temperature
from your dashboard.
**Create in Settings → Helpers → Number**
(min: 30, max: 60, step: 1, unit: °C).
default: ""
selector:
entity:
filter:
domain: input_number
notifications:
name: Notifications
icon: mdi:bell-outline
collapsed: true
description: >
Optional mobile notifications for temperature changes.
input:
notify_service:
name: Notification Service
description: >
One or more notify services, comma-separated
(e.g., `notify.mobile_app_yourphone` or
`notify.mobile_app_phone, notify.mobile_app_tablet`).
Leave empty to disable all notifications.
default: ""
selector:
text:
mode: restart
triggers:
- trigger: state
entity_id: !input period_sensor
to: "on"
id: period_start
- trigger: state
entity_id: !input period_sensor
to: "off"
id: period_end
variables:
_blueprint_variant: "water_heater"
water_heater_entity: !input water_heater_entity
_boost_temp_default: !input boost_temperature
_boost_temp_override: !input boost_temperature_override
boost_temperature: >
{% set o = _boost_temp_override %}
{% if o and states(o) not in ['unknown', 'unavailable'] %}
{{ states(o) | float(_boost_temp_default) }}
{% else %}
{{ _boost_temp_default }}
{% endif %}
_eco_temp_default: !input eco_temperature
_eco_temp_override: !input eco_temperature_override
eco_temperature: >
{% set o = _eco_temp_override %}
{% if o and states(o) not in ['unknown', 'unavailable'] %}
{{ states(o) | float(_eco_temp_default) }}
{% else %}
{{ _eco_temp_default }}
{% endif %}
notify_service: !input notify_service
actions:
# Check: Tibber Prices integration installed?
- variables:
_tp_entities: "{{ integration_entities('tibber_prices') | list }}"
- if:
- condition: template
value_template: "{{ _tp_entities | length == 0 }}"
then:
- stop: "Tibber Prices integration not found"
# ════════════════════════════════════════════════════════
# BOOST / ECO
# ════════════════════════════════════════════════════════
- choose:
- conditions:
- condition: trigger
id: period_start
sequence:
# Determine the correct service based on domain
- variables:
target_domain: "{{ water_heater_entity.split('.')[0] }}"
- action: "{{ target_domain }}.set_temperature"
target:
entity_id: "{{ water_heater_entity }}"
data:
temperature: "{{ boost_temperature }}"
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🔥 Water Heater — Boost Active"
message: >
Target raised to {{ boost_temperature }}°C during
the best price period.
- conditions:
- condition: trigger
id: period_end
sequence:
- variables:
target_domain: "{{ water_heater_entity.split('.')[0] }}"
- action: "{{ target_domain }}.set_temperature"
target:
entity_id: "{{ water_heater_entity }}"
data:
temperature: "{{ eco_temperature }}"
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🔥 Water Heater — Back to Eco"
message: >
Best price period ended. Target back to
{{ eco_temperature }}°C.

View file

@ -0,0 +1,507 @@
blueprint:
name: "Tibber Prices: Notify Residents"
description: >
**Companion script blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
appliance blueprints** · Blueprint v1.0.0
Advanced notification dispatcher that replaces the simple
"Quick Notification" in any Tibber Prices appliance blueprint.
**Features:**
- Up to **10 residents** — just pick a person, devices are
discovered automatically
- **Auto-discovery** — finds all Mobile App notify services
from the person's device trackers and notifies every device
- **Presence filtering** — only notify people who are home
- **iOS and Android** platform-specific options (interruption
level, notification channel, priority)
- **Notify service override** — for Telegram, groups, or any
non-mobile-app service
- Notifications **grouped by appliance** with smart tag
replacement (new events replace old ones)
**How to use:**
1. Create a new script from this blueprint
2. Add your residents — just select their person entity
3. In any Tibber Prices appliance blueprint, select this script
as **Notification Script (Advanced)**
4. Done! The appliance blueprint passes all context automatically
**Auto-discovery explained:** For each person, the script reads
the assigned device trackers (e.g., `device_tracker.alice_iphone`)
and derives the matching `notify.mobile_app_*` service
automatically. All devices of a person get notified — no manual
service configuration needed.
**Override:** If a person should receive notifications via
Telegram, a group, or a custom service instead of (or in addition
to) their mobile devices, set the optional "Notify Service
Override" field. When set, only the override service is used.
**Taking control:** Click "Take control" in the script editor
for full YAML access. The 10-slot limit no longer applies.
domain: script
author: jpawlowski
homeassistant:
min_version: 2024.6.0
input:
presence_settings:
name: Presence Settings
icon: mdi:home-account
description: >
Control whether notifications are filtered by who is home.
input:
filter_by_presence:
name: Only notify people who are home
description: >
When enabled, only residents whose person entity shows
`home` will receive the notification.
Disabled = everyone gets notified regardless of location.
default: true
selector:
boolean:
resident_1:
name: "Resident 1"
icon: mdi:account
description: >
First notification recipient. Select the person entity —
their mobile devices are discovered automatically.
input:
resident_1_person:
name: "Resident 1 — Person"
description: >
Person entity (e.g., `person.alice`).
Leave empty to skip this slot.
default: ""
selector:
entity:
filter:
domain: person
resident_1_override:
name: "Resident 1 — Notify Service Override"
description: >
Optional: a specific notify service to use instead of
auto-discovered mobile devices (e.g.,
`notify.telegram_alice` or `notify.family_group`).
When set, auto-discovery is skipped for this resident.
default: ""
selector:
text:
resident_2:
name: "Resident 2"
icon: mdi:account
collapsed: true
description: "Second notification recipient."
input:
resident_2_person:
name: "Resident 2 — Person"
default: ""
selector:
entity:
filter:
domain: person
resident_2_override:
name: "Resident 2 — Notify Service Override"
default: ""
selector:
text:
resident_3:
name: "Resident 3"
icon: mdi:account
collapsed: true
description: "Third notification recipient."
input:
resident_3_person:
name: "Resident 3 — Person"
default: ""
selector:
entity:
filter:
domain: person
resident_3_override:
name: "Resident 3 — Notify Service Override"
default: ""
selector:
text:
resident_4:
name: "Resident 4"
icon: mdi:account
collapsed: true
description: "Fourth notification recipient."
input:
resident_4_person:
name: "Resident 4 — Person"
default: ""
selector:
entity:
filter:
domain: person
resident_4_override:
name: "Resident 4 — Notify Service Override"
default: ""
selector:
text:
resident_5:
name: "Resident 5"
icon: mdi:account
collapsed: true
description: "Fifth notification recipient."
input:
resident_5_person:
name: "Resident 5 — Person"
default: ""
selector:
entity:
filter:
domain: person
resident_5_override:
name: "Resident 5 — Notify Service Override"
default: ""
selector:
text:
resident_6:
name: "Resident 6"
icon: mdi:account
collapsed: true
description: "Sixth notification recipient."
input:
resident_6_person:
name: "Resident 6 — Person"
default: ""
selector:
entity:
filter:
domain: person
resident_6_override:
name: "Resident 6 — Notify Service Override"
default: ""
selector:
text:
resident_7:
name: "Resident 7"
icon: mdi:account
collapsed: true
description: "Seventh notification recipient."
input:
resident_7_person:
name: "Resident 7 — Person"
default: ""
selector:
entity:
filter:
domain: person
resident_7_override:
name: "Resident 7 — Notify Service Override"
default: ""
selector:
text:
resident_8:
name: "Resident 8"
icon: mdi:account
collapsed: true
description: "Eighth notification recipient."
input:
resident_8_person:
name: "Resident 8 — Person"
default: ""
selector:
entity:
filter:
domain: person
resident_8_override:
name: "Resident 8 — Notify Service Override"
default: ""
selector:
text:
resident_9:
name: "Resident 9"
icon: mdi:account
collapsed: true
description: "Ninth notification recipient."
input:
resident_9_person:
name: "Resident 9 — Person"
default: ""
selector:
entity:
filter:
domain: person
resident_9_override:
name: "Resident 9 — Notify Service Override"
default: ""
selector:
text:
resident_10:
name: "Resident 10"
icon: mdi:account
collapsed: true
description: "Tenth notification recipient."
input:
resident_10_person:
name: "Resident 10 — Person"
default: ""
selector:
entity:
filter:
domain: person
resident_10_override:
name: "Resident 10 — Notify Service Override"
default: ""
selector:
text:
# ════════════════════════════════════════════════════════════
# Script fields — received from the appliance blueprint
# via script.turn_on → data → variables
# ════════════════════════════════════════════════════════════
fields:
event_type:
name: Event Type
description: >
What happened. Values: setup_required, not_ready, no_program,
no_window, planned, started, prepare_washer, timeout,
invalid_loads, wash_planned, wash_done, dryer_planned,
dryer_skipped, cancelled, complete.
required: true
example: planned
selector:
text:
appliance:
name: Appliance
description: >
Which appliance sent this. Values: dishwasher,
washing_machine, dryer, laundry_pipeline.
required: true
example: dishwasher
selector:
text:
title:
name: Title
description: Default notification title (with emoji).
required: true
example: "🍽️ Dishwasher — Planned!"
selector:
text:
message:
name: Message
description: Default notification message body.
required: true
example: "Starts at 02:15 (in 3.2 h). Duration: ~120 min."
selector:
text:
start_time:
name: Start Time
description: >
ISO start time (planned/wash_planned/dryer_planned events).
example: "2025-01-15T02:15:00"
selector:
text:
duration_minutes:
name: Duration (minutes)
description: >
Program duration in minutes (planned/no_window events).
example: "120"
selector:
text:
price_mean:
name: Average Price
description: >
Mean price in the selected window (planned events).
example: "18.5"
selector:
text:
price_unit:
name: Price Unit
description: >
Currency unit (planned events), e.g., "ct/kWh" or "øre/kWh".
example: "ct/kWh"
selector:
text:
selected_program:
name: Selected Program
description: >
Appliance program name (planned events, Home Connect Alt only).
example: "Dishcare.Dishwasher.Program.Eco50"
selector:
text:
using_fallback_duration:
name: Using Fallback Duration
description: >
"True" if the duration is a fallback estimate (planned events).
example: "False"
selector:
text:
deadline:
name: Deadline
description: >
The deadline that was exceeded (no_window events).
example: "2025-01-15T08:00:00"
selector:
text:
load_index:
name: Load Index
description: >
Current load number (pipeline events).
example: "2"
selector:
text:
total_loads:
name: Total Loads
description: >
Total number of loads planned (pipeline events).
example: "3"
selector:
text:
# ════════════════════════════════════════════════════════════
# Variables — map blueprint inputs to template variables
# ════════════════════════════════════════════════════════════
variables:
filter_by_presence: !input filter_by_presence
r1_person: !input resident_1_person
r1_override: !input resident_1_override
r2_person: !input resident_2_person
r2_override: !input resident_2_override
r3_person: !input resident_3_person
r3_override: !input resident_3_override
r4_person: !input resident_4_person
r4_override: !input resident_4_override
r5_person: !input resident_5_person
r5_override: !input resident_5_override
r6_person: !input resident_6_person
r6_override: !input resident_6_override
r7_person: !input resident_7_person
r7_override: !input resident_7_override
r8_person: !input resident_8_person
r8_override: !input resident_8_override
r9_person: !input resident_9_person
r9_override: !input resident_9_override
r10_person: !input resident_10_person
r10_override: !input resident_10_override
# Build a flat list of {service, person} notification targets.
# For each resident with a person entity set:
# - If an override service is configured → use that
# - Otherwise → auto-discover mobile_app notify services
# from the person's device_trackers attribute
notify_targets: >
{% set slots = [
{'person': r1_person, 'override': r1_override},
{'person': r2_person, 'override': r2_override},
{'person': r3_person, 'override': r3_override},
{'person': r4_person, 'override': r4_override},
{'person': r5_person, 'override': r5_override},
{'person': r6_person, 'override': r6_override},
{'person': r7_person, 'override': r7_override},
{'person': r8_person, 'override': r8_override},
{'person': r9_person, 'override': r9_override},
{'person': r10_person, 'override': r10_override},
] %}
{% set ns = namespace(targets=[]) %}
{% for slot in slots if slot.person != '' %}
{% set override = slot.override | default('') %}
{% if override | length > 0 %}
{% set ns.targets = ns.targets
+ [{'service': override, 'person': slot.person}] %}
{% else %}
{% set trackers = state_attr(slot.person,
'device_trackers') or [] %}
{% for t in trackers %}
{% set dev_name = t.split('.')[1] %}
{% if services.notify is defined
and 'mobile_app_' ~ dev_name in services.notify %}
{% set ns.targets = ns.targets
+ [{'service': 'notify.mobile_app_' ~ dev_name,
'person': slot.person}] %}
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
{{ ns.targets }}
# Events that bypass presence filtering (always notify everyone)
critical_events:
- complete
- cancelled
- timeout
icon: mdi:bell-ring
mode: parallel
max: 10
sequence:
- repeat:
for_each: "{{ notify_targets }}"
sequence:
# ── Presence check ──────────────────────────────
- condition: template
value_template: >
{% set person_id = repeat.item.person %}
{% if not filter_by_presence %}
true
{% elif event_type in critical_events %}
true
{% else %}
{{ states(person_id) == 'home' }}
{% endif %}
# ── Send notification ───────────────────────────
- action: "{{ repeat.item.service }}"
data:
title: "{{ title }}"
message: "{{ message }}"
data:
# iOS — interruption level
push:
interruption-level: >
{% if event_type in ['planned', 'wash_planned',
'dryer_planned', 'complete'] %}
time-sensitive
{% else %}
active
{% endif %}
# Android — channel and priority
channel: tibber_prices
importance: >
{% if event_type in ['planned', 'wash_planned',
'dryer_planned', 'complete'] %}
high
{% else %}
default
{% endif %}
ttl: 0
priority: high
# Group & replace — new events replace old ones
group: "tibber_{{ appliance }}"
tag: "tibber_{{ appliance }}_{{ event_type }}"

View file

@ -7,9 +7,7 @@ The actual implementation is in the config_flow_handlers package.
from __future__ import annotations
from .config_flow_handlers.options_flow import (
TibberPricesOptionsFlowHandler as OptionsFlowHandler,
)
from .config_flow_handlers.options_flow import TibberPricesOptionsFlowHandler as OptionsFlowHandler
from .config_flow_handlers.schemas import (
get_best_price_schema,
get_options_init_schema,
@ -23,9 +21,7 @@ from .config_flow_handlers.schemas import (
get_user_schema,
get_volatility_schema,
)
from .config_flow_handlers.subentry_flow import (
TibberPricesSubentryFlowHandler as SubentryFlowHandler,
)
from .config_flow_handlers.subentry_flow import TibberPricesSubentryFlowHandler as SubentryFlowHandler
from .config_flow_handlers.user_flow import TibberPricesConfigFlowHandler as ConfigFlow
from .config_flow_handlers.validators import (
TibberPricesCannotConnectError,

View file

@ -20,9 +20,7 @@ Supporting modules:
from __future__ import annotations
# Phase 3: Import flow handlers from their new modular structure
from custom_components.tibber_prices.config_flow_handlers.options_flow import (
TibberPricesOptionsFlowHandler,
)
from custom_components.tibber_prices.config_flow_handlers.options_flow import TibberPricesOptionsFlowHandler
from custom_components.tibber_prices.config_flow_handlers.schemas import (
get_best_price_schema,
get_options_init_schema,
@ -36,12 +34,8 @@ from custom_components.tibber_prices.config_flow_handlers.schemas import (
get_user_schema,
get_volatility_schema,
)
from custom_components.tibber_prices.config_flow_handlers.subentry_flow import (
TibberPricesSubentryFlowHandler,
)
from custom_components.tibber_prices.config_flow_handlers.user_flow import (
TibberPricesConfigFlowHandler,
)
from custom_components.tibber_prices.config_flow_handlers.subentry_flow import TibberPricesSubentryFlowHandler
from custom_components.tibber_prices.config_flow_handlers.user_flow import TibberPricesConfigFlowHandler
from custom_components.tibber_prices.config_flow_handlers.validators import (
TibberPricesCannotConnectError,
TibberPricesInvalidAuthError,

View file

@ -2,8 +2,8 @@
from __future__ import annotations
import logging
from copy import deepcopy
import logging
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
@ -52,6 +52,7 @@ from custom_components.tibber_prices.const import (
CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
CONF_BEST_PRICE_MIN_DISTANCE_FROM_AVG,
CONF_BEST_PRICE_MIN_PERIOD_LENGTH,
CONF_CURRENCY_DISPLAY_MODE,
CONF_MIN_PERIODS_BEST,
CONF_MIN_PERIODS_PEAK,
CONF_PEAK_PRICE_FLEX,
@ -82,7 +83,7 @@ from custom_components.tibber_prices.const import (
get_display_unit_factor,
)
from homeassistant.config_entries import ConfigFlowResult, OptionsFlow
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers import entity_registry as er, issue_registry as ir
_LOGGER = logging.getLogger(__name__)
@ -378,7 +379,6 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
# Load template and connector from common section
template = await async_get_translation(self.hass, ["common", "override_warning_template"], language)
_LOGGER.debug("Loaded template: %s", template)
if template:
translations["override_warning_template"] = template
@ -502,10 +502,34 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
currency_code = tibber_data.coordinator.data.get("currency")
if user_input is not None:
# Detect currency display mode change before saving
old_mode = self.config_entry.options.get(CONF_CURRENCY_DISPLAY_MODE)
new_mode = user_input.get(CONF_CURRENCY_DISPLAY_MODE)
# Update options with new values
self._options.update(user_input)
# async_create_entry automatically handles change detection and listener triggering
self._save_options_if_changed()
# Notify user of currency display mode change via Repairs
if old_mode is not None and new_mode is not None and old_mode != new_mode:
issue_id = f"currency_display_mode_changed_{self.config_entry.entry_id}"
# delete + create resets dismissed_version so the issue is always visible
# for a new mode change, even if a previous instance was dismissed.
ir.async_delete_issue(self.hass, DOMAIN, issue_id)
ir.async_create_issue(
self.hass,
DOMAIN,
issue_id,
is_fixable=False,
is_persistent=False,
severity=ir.IssueSeverity.WARNING,
translation_key="currency_display_mode_changed",
translation_placeholders={
"home_name": self.config_entry.title,
},
)
# Return to menu for more changes
return await self.async_step_init()
@ -645,8 +669,8 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
placeholders = self._get_entity_warning_placeholders("best_price")
placeholders.update(self._get_override_warning_placeholder("best_price", overrides))
# Load translations for override warnings
override_translations = await self._get_override_translations()
# Load translations for override warnings only when overrides are active
override_translations = await self._get_override_translations() if overrides else {}
return self.async_show_form(
step_id="best_price",
@ -717,8 +741,8 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
placeholders = self._get_entity_warning_placeholders("peak_price")
placeholders.update(self._get_override_warning_placeholder("peak_price", overrides))
# Load translations for override warnings
override_translations = await self._get_override_translations()
# Load translations for override warnings only when overrides are active
override_translations = await self._get_override_translations() if overrides else {}
return self.async_show_form(
step_id="peak_price",

View file

@ -12,22 +12,32 @@ import voluptuous as vol
from custom_components.tibber_prices.const import (
BEST_PRICE_MAX_LEVEL_OPTIONS,
CONF_AVERAGE_SENSOR_DISPLAY,
CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP,
CONF_BEST_PRICE_FLEX,
CONF_BEST_PRICE_GEOMETRIC_FLEX,
CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS,
CONF_BEST_PRICE_MAX_LEVEL,
CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
CONF_BEST_PRICE_MIN_DISTANCE_FROM_AVG,
CONF_BEST_PRICE_MIN_PERIOD_LENGTH,
CONF_BEST_PRICE_SEGMENT_FORCING,
CONF_BEST_PRICE_SEGMENT_MIN_PERIODS,
CONF_CURRENCY_DISPLAY_MODE,
CONF_ENABLE_MIN_PERIODS_BEST,
CONF_ENABLE_MIN_PERIODS_PEAK,
CONF_EXTENDED_DESCRIPTIONS,
CONF_MIN_PERIODS_BEST,
CONF_MIN_PERIODS_PEAK,
CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE,
CONF_PEAK_PRICE_FLEX,
CONF_PEAK_PRICE_GEOMETRIC_FLEX,
CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
CONF_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
CONF_PEAK_PRICE_MIN_DISTANCE_FROM_AVG,
CONF_PEAK_PRICE_MIN_LEVEL,
CONF_PEAK_PRICE_MIN_PERIOD_LENGTH,
CONF_PEAK_PRICE_SEGMENT_FORCING,
CONF_PEAK_PRICE_SEGMENT_MIN_PERIODS,
CONF_PRICE_LEVEL_GAP_TOLERANCE,
CONF_PRICE_RATING_GAP_TOLERANCE,
CONF_PRICE_RATING_HYSTERESIS,
@ -49,21 +59,31 @@ from custom_components.tibber_prices.const import (
CONF_VOLATILITY_THRESHOLD_MODERATE,
CONF_VOLATILITY_THRESHOLD_VERY_HIGH,
DEFAULT_AVERAGE_SENSOR_DISPLAY,
DEFAULT_BEST_PRICE_EXTEND_TO_VERY_CHEAP,
DEFAULT_BEST_PRICE_FLEX,
DEFAULT_BEST_PRICE_GEOMETRIC_FLEX,
DEFAULT_BEST_PRICE_MAX_EXTENSION_INTERVALS,
DEFAULT_BEST_PRICE_MAX_LEVEL,
DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
DEFAULT_BEST_PRICE_MIN_DISTANCE_FROM_AVG,
DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH,
DEFAULT_BEST_PRICE_SEGMENT_FORCING,
DEFAULT_BEST_PRICE_SEGMENT_MIN_PERIODS,
DEFAULT_ENABLE_MIN_PERIODS_BEST,
DEFAULT_ENABLE_MIN_PERIODS_PEAK,
DEFAULT_EXTENDED_DESCRIPTIONS,
DEFAULT_MIN_PERIODS_BEST,
DEFAULT_MIN_PERIODS_PEAK,
DEFAULT_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE,
DEFAULT_PEAK_PRICE_FLEX,
DEFAULT_PEAK_PRICE_GEOMETRIC_FLEX,
DEFAULT_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
DEFAULT_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
DEFAULT_PEAK_PRICE_MIN_DISTANCE_FROM_AVG,
DEFAULT_PEAK_PRICE_MIN_LEVEL,
DEFAULT_PEAK_PRICE_MIN_PERIOD_LENGTH,
DEFAULT_PEAK_PRICE_SEGMENT_FORCING,
DEFAULT_PEAK_PRICE_SEGMENT_MIN_PERIODS,
DEFAULT_PRICE_LEVEL_GAP_TOLERANCE,
DEFAULT_PRICE_RATING_GAP_TOLERANCE,
DEFAULT_PRICE_RATING_HYSTERESIS,
@ -86,7 +106,9 @@ from custom_components.tibber_prices.const import (
DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH,
DISPLAY_MODE_BASE,
DISPLAY_MODE_SUBUNIT,
MAX_EXTENSION_INTERVALS,
MAX_GAP_COUNT,
MAX_GEOMETRIC_FLEX,
MAX_MIN_PERIOD_LENGTH,
MAX_MIN_PERIODS,
MAX_PRICE_LEVEL_GAP_TOLERANCE,
@ -102,6 +124,7 @@ from custom_components.tibber_prices.const import (
MAX_PRICE_TREND_STRONGLY_FALLING,
MAX_PRICE_TREND_STRONGLY_RISING,
MAX_RELAXATION_ATTEMPTS,
MAX_SEGMENT_MIN_PERIODS,
MAX_VOLATILITY_THRESHOLD_HIGH,
MAX_VOLATILITY_THRESHOLD_MODERATE,
MAX_VOLATILITY_THRESHOLD_VERY_HIGH,
@ -151,7 +174,7 @@ ConfigOverrides = dict[str, dict[str, Any]]
def is_field_overridden(
config_key: str,
config_section: str, # noqa: ARG001 - kept for API compatibility
config_section: str,
overrides: ConfigOverrides | None,
) -> bool:
"""
@ -618,6 +641,7 @@ def get_best_price_schema(
period_settings = options.get("period_settings", {})
flexibility_settings = options.get("flexibility_settings", {})
relaxation_settings = options.get("relaxation_and_target_periods", {})
extension_settings = options.get("extension_settings", {})
# Get current values for override display
min_period_length = int(
@ -633,6 +657,19 @@ def get_best_price_schema(
enable_min_periods = relaxation_settings.get(CONF_ENABLE_MIN_PERIODS_BEST, DEFAULT_ENABLE_MIN_PERIODS_BEST)
min_periods = int(relaxation_settings.get(CONF_MIN_PERIODS_BEST, DEFAULT_MIN_PERIODS_BEST))
relaxation_attempts = int(relaxation_settings.get(CONF_RELAXATION_ATTEMPTS_BEST, DEFAULT_RELAXATION_ATTEMPTS_BEST))
extend_to_very_cheap = bool(
extension_settings.get(CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP, DEFAULT_BEST_PRICE_EXTEND_TO_VERY_CHEAP)
)
max_extension_intervals_best = int(
extension_settings.get(CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS, DEFAULT_BEST_PRICE_MAX_EXTENSION_INTERVALS)
)
geometric_flex_best = int(extension_settings.get(CONF_BEST_PRICE_GEOMETRIC_FLEX, DEFAULT_BEST_PRICE_GEOMETRIC_FLEX))
segment_forcing_best = bool(
extension_settings.get(CONF_BEST_PRICE_SEGMENT_FORCING, DEFAULT_BEST_PRICE_SEGMENT_FORCING)
)
segment_min_periods_best = int(
extension_settings.get(CONF_BEST_PRICE_SEGMENT_MIN_PERIODS, DEFAULT_BEST_PRICE_SEGMENT_MIN_PERIODS)
)
# Build section schemas with optional override warnings
period_warning = get_section_override_warning("best_price", "period_settings", overrides, translations) or {}
@ -754,6 +791,55 @@ def get_best_price_schema(
vol.Schema(relaxation_fields),
{"collapsed": True},
),
vol.Required("extension_settings"): section(
vol.Schema(
{
vol.Optional(
CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP,
default=extend_to_very_cheap,
): BooleanSelector(selector.BooleanSelectorConfig()),
vol.Optional(
CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS,
default=max_extension_intervals_best,
): NumberSelector(
NumberSelectorConfig(
min=1,
max=MAX_EXTENSION_INTERVALS,
step=1,
mode=NumberSelectorMode.SLIDER,
)
),
vol.Optional(
CONF_BEST_PRICE_GEOMETRIC_FLEX,
default=geometric_flex_best,
): NumberSelector(
NumberSelectorConfig(
min=0,
max=MAX_GEOMETRIC_FLEX,
step=1,
unit_of_measurement="%",
mode=NumberSelectorMode.SLIDER,
)
),
vol.Optional(
CONF_BEST_PRICE_SEGMENT_FORCING,
default=segment_forcing_best,
): BooleanSelector(selector.BooleanSelectorConfig()),
vol.Optional(
CONF_BEST_PRICE_SEGMENT_MIN_PERIODS,
default=segment_min_periods_best,
): NumberSelector(
NumberSelectorConfig(
min=1,
max=MAX_SEGMENT_MIN_PERIODS,
step=1,
mode=NumberSelectorMode.SLIDER,
)
),
}
),
{"collapsed": True},
),
}
)
@ -779,6 +865,7 @@ def get_peak_price_schema(
period_settings = options.get("period_settings", {})
flexibility_settings = options.get("flexibility_settings", {})
relaxation_settings = options.get("relaxation_and_target_periods", {})
extension_settings = options.get("extension_settings", {})
# Get current values for override display
min_period_length = int(
@ -794,6 +881,19 @@ def get_peak_price_schema(
enable_min_periods = relaxation_settings.get(CONF_ENABLE_MIN_PERIODS_PEAK, DEFAULT_ENABLE_MIN_PERIODS_PEAK)
min_periods = int(relaxation_settings.get(CONF_MIN_PERIODS_PEAK, DEFAULT_MIN_PERIODS_PEAK))
relaxation_attempts = int(relaxation_settings.get(CONF_RELAXATION_ATTEMPTS_PEAK, DEFAULT_RELAXATION_ATTEMPTS_PEAK))
extend_to_very_expensive = bool(
extension_settings.get(CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE, DEFAULT_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE)
)
max_extension_intervals_peak = int(
extension_settings.get(CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS, DEFAULT_PEAK_PRICE_MAX_EXTENSION_INTERVALS)
)
geometric_flex_peak = int(extension_settings.get(CONF_PEAK_PRICE_GEOMETRIC_FLEX, DEFAULT_PEAK_PRICE_GEOMETRIC_FLEX))
segment_forcing_peak = bool(
extension_settings.get(CONF_PEAK_PRICE_SEGMENT_FORCING, DEFAULT_PEAK_PRICE_SEGMENT_FORCING)
)
segment_min_periods_peak = int(
extension_settings.get(CONF_PEAK_PRICE_SEGMENT_MIN_PERIODS, DEFAULT_PEAK_PRICE_SEGMENT_MIN_PERIODS)
)
# Build section schemas with optional override warnings
period_warning = get_section_override_warning("peak_price", "period_settings", overrides, translations) or {}
@ -915,6 +1015,55 @@ def get_peak_price_schema(
vol.Schema(relaxation_fields),
{"collapsed": True},
),
vol.Required("extension_settings"): section(
vol.Schema(
{
vol.Optional(
CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE,
default=extend_to_very_expensive,
): BooleanSelector(selector.BooleanSelectorConfig()),
vol.Optional(
CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
default=max_extension_intervals_peak,
): NumberSelector(
NumberSelectorConfig(
min=1,
max=MAX_EXTENSION_INTERVALS,
step=1,
mode=NumberSelectorMode.SLIDER,
)
),
vol.Optional(
CONF_PEAK_PRICE_GEOMETRIC_FLEX,
default=geometric_flex_peak,
): NumberSelector(
NumberSelectorConfig(
min=0,
max=MAX_GEOMETRIC_FLEX,
step=1,
unit_of_measurement="%",
mode=NumberSelectorMode.SLIDER,
)
),
vol.Optional(
CONF_PEAK_PRICE_SEGMENT_FORCING,
default=segment_forcing_peak,
): BooleanSelector(selector.BooleanSelectorConfig()),
vol.Optional(
CONF_PEAK_PRICE_SEGMENT_MIN_PERIODS,
default=segment_min_periods_peak,
): NumberSelector(
NumberSelectorConfig(
min=1,
max=MAX_SEGMENT_MIN_PERIODS,
step=1,
mode=NumberSelectorMode.SLIDER,
)
),
}
),
{"collapsed": True},
),
}
)

View file

@ -7,9 +7,7 @@ from typing import TYPE_CHECKING, Any
import voluptuous as vol
from custom_components.tibber_prices.config_flow_handlers.options_flow import (
TibberPricesOptionsFlowHandler,
)
from custom_components.tibber_prices.config_flow_handlers.options_flow import TibberPricesOptionsFlowHandler
from custom_components.tibber_prices.config_flow_handlers.schemas import (
get_reauth_confirm_schema,
get_select_home_schema,
@ -20,26 +18,11 @@ from custom_components.tibber_prices.config_flow_handlers.validators import (
TibberPricesInvalidAuthError,
validate_api_token,
)
from custom_components.tibber_prices.const import (
DOMAIN,
LOGGER,
get_default_options,
get_translation,
)
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from custom_components.tibber_prices.const import DOMAIN, LOGGER, get_default_options, get_translation
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import callback
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from homeassistant.helpers.selector import SelectOptionDict, SelectSelector, SelectSelectorConfig, SelectSelectorMode
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigSubentryFlow
@ -65,7 +48,7 @@ class TibberPricesConfigFlowHandler(ConfigFlow, domain=DOMAIN):
@callback
def async_get_supported_subentry_types(
cls,
config_entry: ConfigEntry, # noqa: ARG003
config_entry: ConfigEntry,
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this integration."""
# Temporarily disabled: Time-travel feature not yet fully implemented
@ -85,7 +68,7 @@ class TibberPricesConfigFlowHandler(ConfigFlow, domain=DOMAIN):
"""Return True if match_dict matches this flow."""
return bool(other_flow.get("domain") == DOMAIN)
async def async_step_reauth(self, entry_data: dict[str, Any]) -> ConfigFlowResult: # noqa: ARG002
async def async_step_reauth(self, entry_data: dict[str, Any]) -> ConfigFlowResult:
"""Handle reauth flow when access token becomes invalid."""
entry_id = self.context.get("entry_id")
if entry_id:
@ -295,7 +278,7 @@ class TibberPricesConfigFlowHandler(ConfigFlow, domain=DOMAIN):
description_placeholders={"tibber_url": "https://developer.tibber.com"},
)
async def async_step_select_home(self, user_input: dict | None = None) -> ConfigFlowResult: # noqa: PLR0911
async def async_step_select_home(self, user_input: dict | None = None) -> ConfigFlowResult:
"""Handle home selection during initial setup."""
homes = self._viewer.get("homes", []) if self._viewer else []
@ -458,7 +441,7 @@ class TibberPricesConfigFlowHandler(ConfigFlow, domain=DOMAIN):
valid_to_dt = datetime.fromisoformat(valid_to)
if valid_to_dt < datetime.now(valid_to_dt.tzinfo):
return "expired"
except (ValueError, AttributeError):
except ValueError, AttributeError:
pass # If parsing fails, continue with other checks
# Check validFrom (contract start date)
@ -468,7 +451,7 @@ class TibberPricesConfigFlowHandler(ConfigFlow, domain=DOMAIN):
valid_from_dt = datetime.fromisoformat(valid_from)
if valid_from_dt > datetime.now(valid_from_dt.tzinfo):
return "future"
except (ValueError, AttributeError):
except ValueError, AttributeError:
pass # If parsing fails, assume active
return "active"

View file

@ -9,12 +9,7 @@ from typing import TYPE_CHECKING, Any
import aiofiles
from homeassistant.const import (
CURRENCY_DOLLAR,
CURRENCY_EURO,
UnitOfPower,
UnitOfTime,
)
from homeassistant.const import CURRENCY_DOLLAR, CURRENCY_EURO, UnitOfPower, UnitOfTime
if TYPE_CHECKING:
from collections.abc import Sequence
@ -25,10 +20,14 @@ if TYPE_CHECKING:
DOMAIN = "tibber_prices"
LOGGER = logging.getLogger(__package__)
# Integration version from manifest.json (used for DeviceInfo sw_version)
INTEGRATION_VERSION: str = json.loads((Path(__file__).parent / "manifest.json").read_text(encoding="utf-8"))["version"]
# Data storage keys
DATA_CHART_CONFIG = "chart_config" # Key for chart export config in hass.data
DATA_CHART_METADATA_CONFIG = "chart_metadata_config" # Key for chart metadata config in hass.data
# Config entry data flag: set when user switches currency display mode.
# Configuration keys
CONF_EXTENDED_DESCRIPTIONS = "extended_descriptions"
CONF_VIRTUAL_TIME_OFFSET_DAYS = (
@ -68,7 +67,16 @@ CONF_RELAXATION_ATTEMPTS_BEST = "relaxation_attempts_best"
CONF_ENABLE_MIN_PERIODS_PEAK = "enable_min_periods_peak"
CONF_MIN_PERIODS_PEAK = "min_periods_peak"
CONF_RELAXATION_ATTEMPTS_PEAK = "relaxation_attempts_peak"
CONF_CHART_DATA_CONFIG = "chart_data_config" # YAML config for chart data export
CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP = "best_price_extend_to_very_cheap"
CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS = "best_price_max_extension_intervals"
CONF_BEST_PRICE_GEOMETRIC_FLEX = "best_price_geometric_flex"
CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE = "peak_price_extend_to_very_expensive"
CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS = "peak_price_max_extension_intervals"
CONF_PEAK_PRICE_GEOMETRIC_FLEX = "peak_price_geometric_flex"
CONF_BEST_PRICE_SEGMENT_FORCING = "best_price_segment_forcing"
CONF_BEST_PRICE_SEGMENT_MIN_PERIODS = "best_price_segment_min_periods"
CONF_PEAK_PRICE_SEGMENT_FORCING = "peak_price_segment_forcing"
CONF_PEAK_PRICE_SEGMENT_MIN_PERIODS = "peak_price_segment_min_periods"
ATTRIBUTION = "Data provided by Tibber"
@ -132,6 +140,16 @@ DEFAULT_RELAXATION_ATTEMPTS_BEST = 11 # Default: 11 steps allows escalation fro
DEFAULT_ENABLE_MIN_PERIODS_PEAK = True # Default: minimum periods feature enabled for peak price
DEFAULT_MIN_PERIODS_PEAK = 2 # Default: require at least 2 peak price periods (when enabled)
DEFAULT_RELAXATION_ATTEMPTS_PEAK = 11 # Default: 11 steps allows escalation from 20% to 50% (3% increment per step)
DEFAULT_BEST_PRICE_EXTEND_TO_VERY_CHEAP = False # Default: disabled (opt-in feature)
DEFAULT_BEST_PRICE_MAX_EXTENSION_INTERVALS = 4 # Default: up to 4 intervals (1 hour) per side
DEFAULT_BEST_PRICE_GEOMETRIC_FLEX = 0 # Default: 0% (disabled); positive int % (e.g. 10 = 10%)
DEFAULT_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE = False # Default: disabled (opt-in feature)
DEFAULT_PEAK_PRICE_MAX_EXTENSION_INTERVALS = 4 # Default: up to 4 intervals (1 hour) per side
DEFAULT_PEAK_PRICE_GEOMETRIC_FLEX = 0 # Default: 0% (disabled); positive int % (e.g. 10 = 10%)
DEFAULT_BEST_PRICE_SEGMENT_FORCING = False # Default: disabled (opt-in W-shape feature)
DEFAULT_BEST_PRICE_SEGMENT_MIN_PERIODS = 1 # Default: at least 1 period required per segment
DEFAULT_PEAK_PRICE_SEGMENT_FORCING = False # Default: disabled (opt-in M-shape feature)
DEFAULT_PEAK_PRICE_SEGMENT_MIN_PERIODS = 1 # Default: at least 1 period required per segment
# Validation limits (used in GUI schemas and server-side validation)
# These ensure consistency between frontend and backend validation
@ -140,6 +158,9 @@ MAX_DISTANCE_PERCENTAGE = 50 # Maximum distance from average percentage (GUI sl
MAX_GAP_COUNT = 8 # Maximum gap count for level filtering (GUI slider limit)
MAX_MIN_PERIODS = 10 # Maximum number of minimum periods per day (GUI slider limit)
MAX_RELAXATION_ATTEMPTS = 12 # Maximum relaxation attempts (GUI slider limit)
MAX_EXTENSION_INTERVALS = 12 # Maximum extension intervals per side (GUI slider limit = 3 hours)
MAX_GEOMETRIC_FLEX = 25 # Maximum geometric flex bonus percentage (GUI slider limit)
MAX_SEGMENT_MIN_PERIODS = 5 # Maximum per-segment minimum periods (GUI slider limit)
MIN_PERIOD_LENGTH = 15 # Minimum period length in minutes (1 quarter hour)
MAX_MIN_PERIOD_LENGTH = 180 # Maximum for minimum period length setting (3 hours - realistic for required minimum)
@ -408,6 +429,19 @@ def get_default_options(currency_code: str | None) -> dict[str, Any]:
CONF_MIN_PERIODS_PEAK: DEFAULT_MIN_PERIODS_PEAK,
CONF_RELAXATION_ATTEMPTS_PEAK: DEFAULT_RELAXATION_ATTEMPTS_PEAK,
},
# Nested section: Extension settings (shared by best/peak price)
"extension_settings": {
CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP: DEFAULT_BEST_PRICE_EXTEND_TO_VERY_CHEAP,
CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS: DEFAULT_BEST_PRICE_MAX_EXTENSION_INTERVALS,
CONF_BEST_PRICE_GEOMETRIC_FLEX: DEFAULT_BEST_PRICE_GEOMETRIC_FLEX,
CONF_BEST_PRICE_SEGMENT_FORCING: DEFAULT_BEST_PRICE_SEGMENT_FORCING,
CONF_BEST_PRICE_SEGMENT_MIN_PERIODS: DEFAULT_BEST_PRICE_SEGMENT_MIN_PERIODS,
CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE: DEFAULT_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE,
CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS: DEFAULT_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
CONF_PEAK_PRICE_GEOMETRIC_FLEX: DEFAULT_PEAK_PRICE_GEOMETRIC_FLEX,
CONF_PEAK_PRICE_SEGMENT_FORCING: DEFAULT_PEAK_PRICE_SEGMENT_FORCING,
CONF_PEAK_PRICE_SEGMENT_MIN_PERIODS: DEFAULT_PEAK_PRICE_SEGMENT_MIN_PERIODS,
},
}
@ -427,14 +461,42 @@ def get_display_unit_factor(config_entry: ConfigEntry) -> int:
Example:
price_base = 0.2534 # Internal: 0.2534 €/kWh
factor = get_display_unit_factor(config_entry)
display_value = round(price_base * factor, 2)
# → 25.34 ct/kWh (subunit) or 0.25 €/kWh (base)
precision = get_display_precision(config_entry)
display_value = round(price_base * factor, precision)
# → 25.34 ct/kWh (subunit, 2 decimals) or 0.2534 €/kWh (base, 4 decimals)
"""
display_mode = config_entry.options.get(CONF_CURRENCY_DISPLAY_MODE, DISPLAY_MODE_SUBUNIT)
return 100 if display_mode == DISPLAY_MODE_SUBUNIT else 1
# Rounding precision constants for display currency
DISPLAY_PRECISION_SUBUNIT = 2 # Decimal places for subunit currency (ct, øre)
DISPLAY_PRECISION_BASE = 4 # Decimal places for base currency (€, kr)
def get_display_precision(config_entry: ConfigEntry) -> int:
"""
Get decimal precision for rounding prices in the configured display currency.
Subunit currencies (ct, øre) use 2 decimal places (e.g., 25.34 ct/kWh).
Base currencies (, kr) use 4 decimal places (e.g., 0.2534 /kWh).
This ensures sufficient precision for all currency modes:
- Subunit: 2 decimals (the sub-cent level is rarely meaningful)
- Base: 4 decimals (preserves full API precision for EUR/NOK/SEK prices)
Args:
config_entry: ConfigEntry with currency_display_mode option
Returns:
2 for subunit currency, 4 for base currency
"""
display_mode = config_entry.options.get(CONF_CURRENCY_DISPLAY_MODE, DISPLAY_MODE_SUBUNIT)
return DISPLAY_PRECISION_SUBUNIT if display_mode == DISPLAY_MODE_SUBUNIT else DISPLAY_PRECISION_BASE
def get_display_unit_string(config_entry: ConfigEntry, currency_code: str | None) -> str:
"""
Get unit string for display based on configuration.
@ -487,40 +549,6 @@ PRICE_TREND_STABLE = "stable"
PRICE_TREND_RISING = "rising"
PRICE_TREND_STRONGLY_RISING = "strongly_rising"
# Sensor options (lowercase versions for ENUM device class)
# NOTE: These constants define the valid enum options, but they are not used directly
# in sensor/definitions.py due to import timing issues. Instead, the options are defined inline
# in the SensorEntityDescription objects. Keep these in sync with sensor/definitions.py!
PRICE_LEVEL_OPTIONS = [
PRICE_LEVEL_VERY_CHEAP.lower(),
PRICE_LEVEL_CHEAP.lower(),
PRICE_LEVEL_NORMAL.lower(),
PRICE_LEVEL_EXPENSIVE.lower(),
PRICE_LEVEL_VERY_EXPENSIVE.lower(),
]
PRICE_RATING_OPTIONS = [
PRICE_RATING_LOW.lower(),
PRICE_RATING_NORMAL.lower(),
PRICE_RATING_HIGH.lower(),
]
VOLATILITY_OPTIONS = [
VOLATILITY_LOW.lower(),
VOLATILITY_MODERATE.lower(),
VOLATILITY_HIGH.lower(),
VOLATILITY_VERY_HIGH.lower(),
]
# Trend options for enum sensors (lowercase versions for ENUM device class)
PRICE_TREND_OPTIONS = [
PRICE_TREND_STRONGLY_FALLING,
PRICE_TREND_FALLING,
PRICE_TREND_STABLE,
PRICE_TREND_RISING,
PRICE_TREND_STRONGLY_RISING,
]
# Valid options for best price maximum level filter
# Sorted from cheap to expensive: user selects "up to how expensive"
BEST_PRICE_MAX_LEVEL_OPTIONS = [
@ -1019,26 +1047,6 @@ def get_price_level_translation(
return get_translation(["sensor", "current_interval_price_level", "price_levels", level], language)
async def async_get_home_type_translation(
hass: HomeAssistant,
home_type: str,
language: str = "en",
) -> str | None:
"""
Get a localized translation for a home type asynchronously.
Args:
hass: HomeAssistant instance
home_type: The home type (e.g., APARTMENT, HOUSE, etc.)
language: The language code (defaults to English)
Returns:
The localized home type if found, None otherwise
"""
return await async_get_translation(hass, ["home_types", home_type], language)
def get_home_type_translation(
home_type: str,
language: str = "en",

View file

@ -16,11 +16,7 @@ Main components:
- period_handlers/: Period calculation sub-package
"""
from .constants import (
MINUTE_UPDATE_ENTITY_KEYS,
STORAGE_VERSION,
TIME_SENSITIVE_ENTITY_KEYS,
)
from .constants import MINUTE_UPDATE_ENTITY_KEYS, STORAGE_VERSION, TIME_SENSITIVE_ENTITY_KEYS
from .core import TibberPricesDataUpdateCoordinator
from .time_service import TibberPricesTimeService

View file

@ -88,11 +88,36 @@ TIME_SENSITIVE_ENTITY_KEYS = frozenset(
# Binary sensors that check if current time is in a period
"peak_price_period",
"best_price_period",
# Binary sensors for current intra-day price phase
"in_rising_price_phase",
"in_falling_price_phase",
"in_flat_price_phase",
# Best/Peak price timestamp sensors (periods only change at interval boundaries)
"best_price_end_time",
"best_price_next_start_time",
"peak_price_end_time",
"peak_price_next_start_time",
# Current price phase timing sensors (phase boundaries only change at interval boundaries)
"current_price_phase_end_time",
"current_price_phase_duration",
"next_rising_phase_start_time",
"next_falling_phase_start_time",
"next_flat_phase_start_time",
# Current/next price phase enum sensors
"current_price_phase",
"next_price_phase",
# Price rank sensors (rank of current/next/previous interval within a day scope)
"current_interval_price_rank_today",
"current_interval_price_rank_tomorrow",
"current_interval_price_rank_today_tomorrow",
"current_hour_price_rank_today",
"current_hour_price_rank_today_tomorrow",
"next_interval_price_rank_today",
"next_interval_price_rank_today_tomorrow",
"next_hour_price_rank_today",
"next_hour_price_rank_today_tomorrow",
"previous_interval_price_rank_today",
"previous_interval_price_rank_today_tomorrow",
# Lifecycle sensor needs quarter-hour precision for state transitions:
# - 23:45: turnover_pending (last interval before midnight)
# - 00:00: turnover complete (after midnight API update)
@ -116,7 +141,14 @@ MINUTE_UPDATE_ENTITY_KEYS = frozenset(
"peak_price_remaining_minutes",
"peak_price_progress",
"peak_price_next_in_minutes",
# Current price phase countdown/progress sensors (need minute updates)
"current_price_phase_remaining_minutes",
"current_price_phase_progress",
# Next-phase countdown sensors (need minute updates)
"next_rising_phase_in_minutes",
"next_falling_phase_in_minutes",
"next_flat_phase_in_minutes",
# Trend change countdown sensor (needs minute updates)
"trend_change_in_minutes",
"next_price_trend_change_in",
}
)

View file

@ -2,8 +2,8 @@
from __future__ import annotations
import logging
from datetime import timedelta
import logging
from typing import TYPE_CHECKING, Any
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
@ -24,16 +24,11 @@ from custom_components.tibber_prices.api import (
TibberPricesApiClientError,
)
from custom_components.tibber_prices.const import DOMAIN
from custom_components.tibber_prices.utils.price import (
find_price_data_for_interval,
)
from custom_components.tibber_prices.utils.price import find_price_data_for_interval
from homeassistant.exceptions import ConfigEntryAuthFailed
from . import helpers
from .constants import (
STORAGE_VERSION,
UPDATE_INTERVAL,
)
from .constants import STORAGE_VERSION, UPDATE_INTERVAL
from .data_transformation import TibberPricesDataTransformer
from .listeners import TibberPricesListenerManager
from .midnight_handler import TibberPricesMidnightHandler
@ -44,9 +39,6 @@ from .time_service import TibberPricesTimeService
_LOGGER = logging.getLogger(__name__)
# Lifecycle state transition thresholds
FRESH_TO_CACHED_SECONDS = 300 # 5 minutes
def get_connection_state(coordinator: TibberPricesDataUpdateCoordinator) -> bool | None:
"""
@ -791,6 +783,18 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
else:
# Check for repair conditions after successful update
await self._check_repair_conditions(result, current_time)
# Fire event when new data was fetched from API (not cached)
if api_called and result and "priceInfo" in result and len(result["priceInfo"]) > 0:
self.hass.bus.async_fire(
"tibber_prices_data_updated",
{
"home_id": self._home_id,
"entry_id": self.config_entry.entry_id,
"interval_count": len(result["priceInfo"]),
},
)
return result
async def _track_rate_limit_error(self, error: Exception) -> None:
@ -866,9 +870,11 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Get threshold percentages from config options."""
return self._data_transformer.get_threshold_percentages()
def _calculate_periods_for_price_info(self, price_info: dict[str, Any]) -> dict[str, Any]:
def _calculate_periods_for_price_info(
self, price_info: list[dict[str, Any]], day_patterns: dict[str, Any] | None = None
) -> dict[str, Any]:
"""Calculate periods (best price and peak price) for the given price info."""
return self._period_calculator.calculate_periods_for_price_info(price_info)
return self._period_calculator.calculate_periods_for_price_info(price_info, day_patterns)
def _transform_data(self, raw_data: dict[str, Any]) -> dict[str, Any]:
"""Transform raw data for main entry (aggregated view of all homes)."""

View file

@ -7,6 +7,7 @@ import logging
from typing import TYPE_CHECKING, Any
from custom_components.tibber_prices import const as _const
from custom_components.tibber_prices.coordinator.period_handlers.day_pattern import detect_day_patterns
from custom_components.tibber_prices.utils.price import enrich_price_info_with_differences
if TYPE_CHECKING:
@ -20,6 +21,24 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
def _build_period_calculation_intervals(enriched_intervals: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Return enriched intervals with raw Tibber levels restored for period logic."""
period_intervals = copy.deepcopy(enriched_intervals)
for interval in period_intervals:
original_level = interval.pop("_original_level", None)
if original_level is not None:
interval["level"] = original_level
return period_intervals
def _strip_internal_enrichment_fields(enriched_intervals: list[dict[str, Any]]) -> None:
"""Remove internal enrichment helpers before exposing priceInfo."""
for interval in enriched_intervals:
interval.pop("_original_level", None)
class TibberPricesDataTransformer:
"""Handles data transformation, enrichment, and period calculations."""
@ -27,7 +46,7 @@ class TibberPricesDataTransformer:
self,
config_entry: ConfigEntry,
log_prefix: str,
calculate_periods_fn: Callable[[dict[str, Any]], dict[str, Any]],
calculate_periods_fn: Callable[[list[dict[str, Any]], dict[str, Any] | None], dict[str, Any]],
time: TibberPricesTimeService,
) -> None:
"""Initialize the data transformer."""
@ -263,6 +282,9 @@ class TibberPricesDataTransformer:
time=self.time,
)
period_intervals = _build_period_calculation_intervals(enriched_intervals)
_strip_internal_enrichment_fields(enriched_intervals)
# Store enriched intervals directly as priceInfo (flat list)
transformed_data = {
"home_id": home_id,
@ -270,9 +292,18 @@ class TibberPricesDataTransformer:
"currency": currency,
}
# Detect day patterns (yesterday / today / tomorrow)
# IMPORTANT: Must be computed BEFORE pricePeriods so geometric flex can use pattern data
transformed_data["dayPatterns"] = detect_day_patterns(
transformed_data["priceInfo"],
time=self.time,
)
# Calculate periods (best price and peak price)
if "priceInfo" in transformed_data:
transformed_data["pricePeriods"] = self._calculate_periods_fn(transformed_data["priceInfo"])
transformed_data["pricePeriods"] = self._calculate_periods_fn(
period_intervals, transformed_data.get("dayPatterns")
)
# Cache the transformed data
self._cached_transformed_data = transformed_data

View file

@ -2,8 +2,8 @@
from __future__ import annotations
import logging
from datetime import timedelta
import logging
from typing import TYPE_CHECKING, Any
from homeassistant.util import dt as dt_util

View file

@ -19,20 +19,37 @@ from __future__ import annotations
# Re-export main API functions
from .core import calculate_periods
# Re-export day pattern detection
from .day_pattern import detect_day_patterns
# Re-export outlier filtering
from .outlier_filtering import filter_price_outliers
# Re-export relaxation
from .relaxation import calculate_periods_with_relaxation
# Re-export shape extension
from .shape_extension import extend_periods_for_shape
# Re-export constants and types
from .types import (
ALL_DAY_PATTERNS,
DAY_PATTERN_DOUBLE_DIP,
DAY_PATTERN_DUCK_CURVE,
DAY_PATTERN_FALLING,
DAY_PATTERN_FLAT,
DAY_PATTERN_MIXED,
DAY_PATTERN_PEAK,
DAY_PATTERN_RISING,
DAY_PATTERN_VALLEY,
INDENT_L0,
INDENT_L1,
INDENT_L2,
INDENT_L3,
INDENT_L4,
INDENT_L5,
DayPatternDict,
SegmentDict,
TibberPricesIntervalCriteria,
TibberPricesPeriodConfig,
TibberPricesPeriodData,
@ -41,12 +58,23 @@ from .types import (
)
__all__ = [
"ALL_DAY_PATTERNS",
"DAY_PATTERN_DOUBLE_DIP",
"DAY_PATTERN_DUCK_CURVE",
"DAY_PATTERN_FALLING",
"DAY_PATTERN_FLAT",
"DAY_PATTERN_MIXED",
"DAY_PATTERN_PEAK",
"DAY_PATTERN_RISING",
"DAY_PATTERN_VALLEY",
"INDENT_L0",
"INDENT_L1",
"INDENT_L2",
"INDENT_L3",
"INDENT_L4",
"INDENT_L5",
"DayPatternDict",
"SegmentDict",
"TibberPricesIntervalCriteria",
"TibberPricesPeriodConfig",
"TibberPricesPeriodData",
@ -54,5 +82,7 @@ __all__ = [
"TibberPricesThresholdConfig",
"calculate_periods",
"calculate_periods_with_relaxation",
"detect_day_patterns",
"extend_periods_for_shape",
"filter_price_outliers",
]

View file

@ -5,31 +5,33 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from datetime import datetime
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
from .types import TibberPricesPeriodConfig
from .outlier_filtering import (
filter_price_outliers,
)
from .outlier_filtering import filter_price_outliers
from .period_building import (
add_interval_ends,
build_periods,
calculate_reference_prices,
extend_negative_core_periods_for_min_length,
extend_periods_across_midnight,
filter_periods_by_end_date,
filter_periods_by_min_length,
filter_superseded_periods,
filter_weak_peak_periods,
split_intervals_by_day,
)
from .period_statistics import (
extract_period_summaries,
)
from .period_statistics import extract_period_summaries
from .shape_extension import extend_periods_for_shape
from .types import TibberPricesThresholdConfig
# Flex limits to prevent degenerate behavior (see docs/development/period-calculation-theory.md)
MAX_SAFE_FLEX = 0.50 # 50% - hard cap: above this, period detection becomes unreliable
MAX_OUTLIER_FLEX = 0.25 # 25% - cap for outlier filtering: above this, spike detection too permissive
MIN_SEGMENT_FORCING_INTERVALS = 8 # Minimum intervals per day half to attempt segment forcing (< 2 hours is too few)
def calculate_periods(
@ -37,6 +39,8 @@ def calculate_periods(
*,
config: TibberPricesPeriodConfig,
time: TibberPricesTimeService,
day_patterns_by_date: dict | None = None,
time_range: tuple[datetime, datetime] | None = None,
) -> dict[str, Any]:
"""
Calculate price periods (best or peak) from price data.
@ -58,6 +62,10 @@ def calculate_periods(
config: Period configuration containing reverse_sort, flex, min_distance_from_avg,
min_period_length, threshold_low, and threshold_high.
time: TibberPricesTimeService instance (required).
day_patterns_by_date: Optional dict mapping date day pattern dict for geometric flex bonus.
time_range: Optional (start_inclusive, end_exclusive) window passed through to
build_periods(). When set, only intervals within [start, end) are considered
as period candidates. Used by Phase 4 segment forcing.
Returns:
Dict with:
@ -71,7 +79,7 @@ def calculate_periods(
from .types import INDENT_L0 # noqa: PLC0415
_LOGGER = logging.getLogger(__name__) # noqa: N806
_LOGGER = logging.getLogger(__name__)
# Extract config values
reverse_sort = config.reverse_sort
@ -131,7 +139,7 @@ def calculate_periods(
# User's flex setting still applies to period criteria (in_flex check).
# Import details logger locally (core.py imports logger locally in function)
_LOGGER_DETAILS = logging.getLogger(__name__ + ".details") # noqa: N806
_LOGGER_DETAILS = logging.getLogger(__name__ + ".details")
outlier_flex = min(abs(flex) * 100, MAX_OUTLIER_FLEX * 100)
if abs(flex) * 100 > MAX_OUTLIER_FLEX * 100:
@ -155,6 +163,8 @@ def calculate_periods(
"intervals_by_day": intervals_by_day, # Needed for day volatility calculation
"flex": flex,
"min_distance_from_avg": min_distance_from_avg,
"geometric_extra_flex": config.geometric_extra_flex, # Extra flex for geometric zone
"day_patterns_by_date": day_patterns_by_date, # Pattern data keyed by date (may be None)
}
raw_periods = build_periods(
all_prices_smoothed, # Use smoothed prices for period formation
@ -163,6 +173,7 @@ def calculate_periods(
level_filter=config.level_filter,
gap_count=config.gap_count,
time=time,
time_range=time_range,
)
_LOGGER.debug(
@ -173,6 +184,35 @@ def calculate_periods(
config.level_filter or "None",
)
# Step 3.5: Segment forcing for W/M-shaped days (opt-in, default disabled)
# For days detected as W-shape (DOUBLE_DIP for best) or M-shape (DUCK_CURVE for peak),
# ensures each price valley/peak segment has at least segment_min_periods periods.
if config.segment_forcing and day_patterns_by_date:
raw_periods = _apply_segment_forcing(
all_prices_smoothed,
raw_periods,
price_context,
config,
day_patterns_by_date=day_patterns_by_date,
time=time,
)
_LOGGER.debug(
"%sAfter segment_forcing: %d periods total",
INDENT_L0,
len(raw_periods),
)
# Step 3.75: Rescue short negative best-price cores before min-length filtering.
# This keeps <= 0 prices as the hard core and only adds directly adjacent cheap
# shoulders when needed to reach the configured minimum length.
if not reverse_sort:
raw_periods = extend_negative_core_periods_for_min_length(
raw_periods,
all_prices_sorted,
min_period_length,
time=time,
)
# Step 4: Filter by minimum length
raw_periods = filter_periods_by_min_length(raw_periods, min_period_length, time=time)
_LOGGER.debug(
@ -209,9 +249,25 @@ def calculate_periods(
time=time,
)
# Step 8: Cross-day extension for late-night periods
# If a best-price period ends near midnight and tomorrow has continued low prices,
# extend the period across midnight to give users the full cheap window
# Step 7.5: Extend periods into adjacent VERY_CHEAP / VERY_EXPENSIVE intervals
# This is an opt-in feature (disabled by default) that adds contiguous
# extreme-level intervals on each side of an already-found period.
if config.extend_to_extreme and config.max_extension_intervals > 0:
period_summaries = extend_periods_for_shape(
period_summaries,
all_prices_sorted,
price_context,
reverse_sort=reverse_sort,
max_extension_intervals=config.max_extension_intervals,
thresholds=thresholds,
time=time,
)
# Step 8: Cross-day bridging for midnight-split periods
# If two periods exist on both sides of midnight separated by a small gap
# (artifact of per-day reference price changes), merge them into one period.
# Requires evidence on BOTH sides — periods ending well before midnight
# are NOT extended because they ended naturally.
period_summaries = extend_periods_across_midnight(
period_summaries,
all_prices_sorted,
@ -229,6 +285,16 @@ def calculate_periods(
reverse_sort=reverse_sort,
)
# Step 10: Filter weak peak periods
# Peak periods whose mean price is barely above daily average are likely
# cross-day artifacts rather than genuine high-price windows
if reverse_sort:
period_summaries = filter_weak_peak_periods(
period_summaries,
avg_price_by_day,
time=time,
)
return {
"periods": period_summaries, # Lightweight summaries only
"metadata": {
@ -245,3 +311,168 @@ def calculate_periods(
"avg_prices": {k.isoformat(): v for k, v in avg_price_by_day.items()},
},
}
# ─── Segment forcing helpers ──────────────────────────────────────────────────
def _period_belongs_to_side(
period: list[dict],
side_times: set,
time: TibberPricesTimeService,
) -> bool:
"""Return True if the majority of a period's intervals are in side_times."""
if not period:
return False
in_side = sum(1 for iv in period if time.get_interval_time(iv) in side_times)
return in_side * 2 >= len(period)
def _apply_segment_forcing(
all_prices_smoothed: list[dict],
periods: list[list[dict]],
price_context: dict[str, Any],
config: TibberPricesPeriodConfig,
*,
day_patterns_by_date: dict,
time: TibberPricesTimeService,
) -> list[list[dict]]:
"""
Force at least segment_min_periods periods per segment for W/M-shaped days.
For DOUBLE_DIP days (best price): splits at the central price peak and
ensures each valley side has the required number of periods.
For DUCK_CURVE days (peak price): splits at the central price valley and
ensures each peak side has the required number of periods.
Args:
all_prices_smoothed: Outlier-filtered prices used for period building.
periods: Already-found periods from the global build_periods call.
price_context: Context dict with reference/average prices + filter settings.
config: Period configuration including segment_forcing parameters.
day_patterns_by_date: Detected day patterns keyed by date.
time: TibberPricesTimeService instance.
Returns:
Updated periods list with any new segment-forced periods appended.
"""
import logging # noqa: PLC0415
from .period_building import build_periods # noqa: PLC0415
from .types import DAY_PATTERN_DOUBLE_DIP, DAY_PATTERN_DUCK_CURVE, INDENT_L1, INDENT_L2 # noqa: PLC0415
_LOGGER = logging.getLogger(__name__)
reverse_sort = config.reverse_sort
target_pattern = DAY_PATTERN_DUCK_CURVE if reverse_sort else DAY_PATTERN_DOUBLE_DIP
segment_min_periods = config.segment_min_periods
merged_periods = list(periods)
for day_date, day_pattern in day_patterns_by_date.items():
if day_pattern is None or day_pattern.get("pattern") != target_pattern:
continue
# Collect and sort this day's intervals
day_intervals = sorted(
(
iv
for iv in all_prices_smoothed
if (t := time.get_interval_time(iv)) is not None and t.date() == day_date
),
key=time.get_interval_time, # type: ignore[arg-type]
)
if len(day_intervals) < MIN_SEGMENT_FORCING_INTERVALS: # need at least a few intervals per segment
continue
# Find the central extremum in the middle 50% of the day
# DOUBLE_DIP → central peak = highest price between the two valleys
# DUCK_CURVE → central valley = lowest price between the two peaks
n = len(day_intervals)
middle = day_intervals[n // 4 : 3 * n // 4]
if not middle:
continue
if not reverse_sort:
split_iv = max(middle, key=lambda iv: iv.get("total") or 0)
else:
split_iv = min(middle, key=lambda iv: iv.get("total") or float("inf"))
split_time = time.get_interval_time(split_iv)
if split_time is None:
continue
side_a = [iv for iv in day_intervals if (t := time.get_interval_time(iv)) is not None and t <= split_time]
side_b = [iv for iv in day_intervals if (t := time.get_interval_time(iv)) is not None and t > split_time]
_LOGGER.debug(
"%sSegment forcing %s (%s): split at %s (%d+%d intervals)",
INDENT_L1,
day_date,
target_pattern,
split_time.strftime("%H:%M"),
len(side_a),
len(side_b),
)
for side_name, side_intervals in (("A", side_a), ("B", side_b)):
side_times = {time.get_interval_time(iv) for iv in side_intervals}
count_in_side = sum(1 for p in merged_periods if _period_belongs_to_side(p, side_times, time))
_LOGGER.debug(
"%sSide %s: %d existing periods (need %d)",
INDENT_L2,
side_name,
count_in_side,
segment_min_periods,
)
if count_in_side >= segment_min_periods:
continue
# Run period detection restricted to this segment side via time_range.
# The full all_prices_smoothed (including other days) is passed so that
# reference price context remains day-wide; time_range restricts which
# intervals are EVALUATED as period candidates to this side only.
sorted_side = sorted(side_intervals, key=time.get_interval_time) # type: ignore[arg-type]
side_start = time.get_interval_time(sorted_side[0])
# end = one interval duration past the last interval's start
side_end = time.get_interval_time(sorted_side[-1])
if side_start is None or side_end is None:
continue
side_end = side_end + time.get_interval_duration()
new_raw = build_periods(
all_prices_smoothed,
price_context,
reverse_sort=reverse_sort,
level_filter=config.level_filter,
gap_count=config.gap_count,
time=time,
time_range=(side_start, side_end),
)
# Add non-duplicate periods; flag them with segment_forced=True
added = 0
for new_period in new_raw:
new_times = {time.get_interval_time(iv) for iv in new_period if time.get_interval_time(iv) is not None}
is_dup = any(
bool(
new_times
& {time.get_interval_time(iv) for iv in existing if time.get_interval_time(iv) is not None}
)
for existing in merged_periods
)
if not is_dup:
merged_periods.append([{**iv, "segment_forced": True} for iv in new_period])
added += 1
_LOGGER.debug(
"%sSide %s: added %d forced periods (%d candidates from restricted run)",
INDENT_L2,
side_name,
added,
len(new_raw),
)
return merged_periods

View file

@ -0,0 +1,633 @@
"""
Day price pattern detection for Tibber Prices.
Analyses quarter-hourly price intervals for a calendar day and classifies them
into a small set of patterns that are meaningful for switching decisions:
VALLEY - Single price minimum (U/V-shape, cheap middle)
PEAK - Single price maximum (Lambda-shape, expensive middle)
DOUBLE_DIP - Two minima separated by a peak (W-shape)
DUCK_CURVE - Two peaks with midday valley (M-shape, solar duck curve)
FLAT - No significant variation (CV <= 10 %)
RISING - Monotonically / persistently rising
FALLING - Monotonically / persistently falling
MIXED - Multiple extrema that do not neatly fit above patterns
For VALLEY and PEAK the module also locates the *knee points* (left and right
inflection points of the flanks) using a simplified Kneedle algorithm so that
Phases 3+ can extend period boundaries geometrically.
Intra-day segments are surfaced as a list of consecutive region dicts, allowing
automations to query "is the current hour in a rising segment?".
All functions are pure (no side effects) and operate on already-enriched
interval dicts produced by utils/price.py.
"""
from __future__ import annotations
import logging
import math
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from datetime import date, datetime
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
_LOGGER = logging.getLogger(__name__)
_LOGGER_DETAILS = logging.getLogger(__name__ + ".details")
# ─── constants ────────────────────────────────────────────────────────────────
# A day is considered "flat" if its coefficient of variation is below this value.
# Reuses the same threshold as relaxation.py (LOW_CV_FLAT_DAY_THRESHOLD = 10.0).
FLAT_CV_THRESHOLD = 10.0 # %
# Minimum amplitude an extremum must have to count as "significant".
# Defined as a fraction of the day's price span. 0.20 = 20 % of span.
MIN_EXTREMUM_AMPLITUDE_RATIO = 0.20
# Smoothing window (in 15-min intervals) for the rolling-average pre-filter.
SMOOTH_WINDOW = 4 # 4 x 15 min = 1 h
# Minimum intervals in a day to attempt pattern detection.
MIN_DAY_INTERVALS = 4
# Minimum intervals in a series to search for extrema.
MIN_EXTREMA_INTERVALS = 3
# Edge zone: relative position threshold for RISING / FALLING detection.
_EDGE_ZONE = 0.25
# Pattern string constants
DAY_PATTERN_VALLEY = "valley"
DAY_PATTERN_PEAK = "peak"
DAY_PATTERN_DOUBLE_DIP = "double_dip"
DAY_PATTERN_DUCK_CURVE = "duck_curve"
DAY_PATTERN_FLAT = "flat"
DAY_PATTERN_RISING = "rising"
DAY_PATTERN_FALLING = "falling"
DAY_PATTERN_MIXED = "mixed"
# Segment type constants
SEGMENT_TYPE_RISING = "rising"
SEGMENT_TYPE_FALLING = "falling"
SEGMENT_TYPE_FLAT = "flat"
# ─── public API ───────────────────────────────────────────────────────────────
def detect_day_patterns(
all_prices: list[dict[str, Any]],
*,
time: TibberPricesTimeService,
) -> dict[str, dict[str, Any]]:
"""
Detect price patterns for yesterday, today, and tomorrow.
Groups enriched price intervals by calendar day and runs pattern detection
on each. Always returns all three keys; ``tomorrow`` may be ``None`` if
data is not yet available.
Args:
all_prices: Flat list of enriched price interval dicts (the same list
that ``coordinator.data["priceInfo"]`` holds).
time: TibberPricesTimeService (needed for timezone-aware date boundaries).
Returns:
``{"yesterday": <dict|None>, "today": <dict|None>, "tomorrow": <dict|None>}``
where each value is a ``DayPatternDict`` (see _detect_single_day_pattern).
"""
# ── group intervals by calendar day ────────────────────────────────────────
from .period_building import split_intervals_by_day # avoid circular at import time # noqa: PLC0415
intervals_by_day, _ = split_intervals_by_day(all_prices, time=time)
now = time.now()
today_date: date = now.date()
import datetime as _dt # noqa: PLC0415
yesterday_date = today_date - _dt.timedelta(days=1)
tomorrow_date = today_date + _dt.timedelta(days=1)
result: dict[str, dict[str, Any] | None] = {
"yesterday": None,
"today": None,
"tomorrow": None,
}
day_map: dict[str, date] = {
"yesterday": yesterday_date,
"today": today_date,
"tomorrow": tomorrow_date,
}
for label, date_key in day_map.items():
intervals = intervals_by_day.get(date_key)
if intervals and len(intervals) >= MIN_DAY_INTERVALS:
try:
result[label] = _detect_single_day_pattern(intervals, time=time)
except Exception:
_LOGGER.exception("Day pattern detection failed for %s (%s)", label, date_key)
result[label] = None
else:
result[label] = None
return result # type: ignore[return-value]
# ─── single-day detection ─────────────────────────────────────────────────────
def _detect_single_day_pattern(
intervals: list[dict[str, Any]],
*,
time: TibberPricesTimeService,
) -> dict[str, Any]:
"""
Analyse a single day's intervals and return a DayPatternDict.
The returned dict has the shape described in AGENTS.md (DayPatternDict).
"""
# Extract prices and datetimes (already tz-aware from enrichment)
prices_raw: list[float] = [float(iv["total"]) for iv in intervals]
times: list[datetime] = [time.get_interval_time(iv) for iv in intervals] # type: ignore[misc]
# ── coefficient of variation ────────────────────────────────────────────────
n = len(prices_raw)
mean_price = sum(prices_raw) / n
variance = sum((p - mean_price) ** 2 for p in prices_raw) / n
std_dev = math.sqrt(variance)
cv_pct = round((std_dev / abs(mean_price)) * 100, 1) if mean_price != 0 else 0.0
# ── smooth prices (1-h rolling average) ────────────────────────────────────
smoothed = _smooth_prices(prices_raw, window=SMOOTH_WINDOW)
# ── find significant extrema ────────────────────────────────────────────────
price_span = max(prices_raw) - min(prices_raw) if prices_raw else 0.0
extrema = _find_significant_extrema(smoothed, min_amplitude=price_span * MIN_EXTREMUM_AMPLITUDE_RATIO)
# ── classify pattern ────────────────────────────────────────────────────────
pattern, confidence = _classify_pattern(
extrema,
cv_pct,
times,
start_price=smoothed[0],
end_price=smoothed[-1],
)
# ── knee points + primary extreme time ─────────────────────────────────────
extreme_time: datetime | None = None
valley_start: datetime | None = None
valley_end: datetime | None = None
peak_start: datetime | None = None
peak_end: datetime | None = None
if pattern == DAY_PATTERN_VALLEY:
# Primary extreme = global minimum
min_idx = prices_raw.index(min(prices_raw))
extreme_time = times[min_idx] if min_idx < len(times) else None
lk, rk = _find_knee_points(smoothed, min_idx)
valley_start = times[lk] if lk is not None and lk < len(times) else None
valley_end = times[rk] if rk is not None and rk < len(times) else None
elif pattern == DAY_PATTERN_PEAK:
max_idx = prices_raw.index(max(prices_raw))
extreme_time = times[max_idx] if max_idx < len(times) else None
lk, rk = _find_knee_points(smoothed, max_idx)
peak_start = times[lk] if lk is not None and lk < len(times) else None
peak_end = times[rk] if rk is not None and rk < len(times) else None
elif pattern == DAY_PATTERN_DOUBLE_DIP and extrema:
# Primary extreme = deeper of the two minima
min_extrema = [e for e in extrema if e["type"] == "min"]
if min_extrema:
primary = min(min_extrema, key=lambda e: e["price"])
extreme_time = times[primary["idx"]] if primary["idx"] < len(times) else None
elif pattern == DAY_PATTERN_DUCK_CURVE and extrema:
max_extrema = [e for e in extrema if e["type"] == "max"]
if max_extrema:
primary = max(max_extrema, key=lambda e: e["price"])
extreme_time = times[primary["idx"]] if primary["idx"] < len(times) else None
# The valley between the two peaks is the cheap zone for best-price periods.
# Compute knee points around the deepest minimum so that compute_geometric_flex_bonus
# can apply extra flex to intervals in this zone (same mechanism as VALLEY).
min_extrema_dp = [e for e in extrema if e["type"] == "min"]
if min_extrema_dp:
valley_extreme = min(min_extrema_dp, key=lambda e: e["price"])
lk, rk = _find_knee_points(smoothed, valley_extreme["idx"])
valley_start = times[lk] if lk is not None and lk < len(times) else None
valley_end = times[rk] if rk is not None and rk < len(times) else None
# ── intra-day segments ──────────────────────────────────────────────────────
segments = _detect_segments(extrema, prices_raw, times)
result: dict[str, Any] = {
"pattern": pattern,
"confidence": round(confidence, 3),
"day_cv_percent": cv_pct,
"segments": segments,
"extreme_time": extreme_time,
"valley_start": valley_start,
"valley_end": valley_end,
"peak_start": peak_start,
"peak_end": peak_end,
}
_LOGGER_DETAILS.debug(
" Day pattern: %s (confidence=%.2f, cv=%.1f%%, extrema=%d, segments=%d)",
pattern,
confidence,
cv_pct,
len(extrema),
len(segments),
)
return result
# ─── smoothing ────────────────────────────────────────────────────────────────
def _smooth_prices(prices: list[float], window: int = SMOOTH_WINDOW) -> list[float]:
"""
Apply a centred rolling-average with the given window width.
Edge intervals use a narrower window (no zero-padding) so that pattern
detection at the start/end of the day is not distorted.
"""
n = len(prices)
half = window // 2
smoothed: list[float] = []
for i in range(n):
lo = max(0, i - half)
hi = min(n, i + half + 1)
smoothed.append(sum(prices[lo:hi]) / (hi - lo))
return smoothed
# ─── extrema detection ────────────────────────────────────────────────────────
def _find_significant_extrema(
smoothed: list[float],
*,
min_amplitude: float,
) -> list[dict[str, Any]]:
"""
Find local minima and maxima in the smoothed price series.
A local extremum is retained only if it exceeds *min_amplitude* above/below
both of its closest neighbours of the opposite polarity (prominence filter).
Returns a list of ``{"idx": int, "type": "min"|"max", "price": float}``
entries sorted by index.
"""
n = len(smoothed)
if n < MIN_EXTREMA_INTERVALS:
return []
# ── raw local extrema (strict local min/max) ────────────────────────────────
# NOTE: We intentionally do NOT require the extremum to be below/above the
# day's start and end prices. That check was too restrictive for solar-
# influenced days (spring/summer) where overnight prices are as cheap as the
# midday valley, causing the midday dip to go undetected. The amplitude/
# prominence filter below is sufficient to suppress noise.
candidates: list[dict[str, Any]] = []
for i in range(1, n - 1):
prev_p = smoothed[i - 1]
cur_p = smoothed[i]
next_p = smoothed[i + 1]
if cur_p <= prev_p and cur_p <= next_p:
candidates.append({"idx": i, "type": "min", "price": cur_p})
elif cur_p >= prev_p and cur_p >= next_p:
candidates.append({"idx": i, "type": "max", "price": cur_p})
if not candidates:
return []
# ── amplitude filter ────────────────────────────────────────────────────────
# For each candidate, measure prominence against the most representative
# reference price available.
#
# Problem with pure local-neighbourhood mean: a broad, flat-bottomed valley
# (e.g. a 5-hour cheap midday zone) pulls the neighbourhood mean down toward
# the valley price itself, making the prominence appear near-zero even though
# the valley is clearly significant on the full day.
#
# Solution: use max(local_mean, day_mean) for minima and min(local_mean,
# day_mean) for maxima. This picks the reference that gives the LARGEST
# separation for genuine extrema:
# - Deep/broad valley: local_mean ≈ valley price → day_mean wins (higher).
# - Overnight plateau max: local_mean ≈ plateau price → day_mean wins (lower).
# - Sharp isolated spike: local_mean already high → day_mean may be lower,
# but the spike still has large prominence either way.
day_mean = sum(smoothed) / len(smoothed)
significant: list[dict[str, Any]] = []
for cand in candidates:
idx = cand["idx"]
hw = max(4, n // 8) # neighbourhood half-width: ≥4 intervals, up to 1/8 of day
lo = max(0, idx - hw)
hi = min(n, idx + hw + 1)
neighbourhood = smoothed[lo:hi]
local_mean = sum(neighbourhood) / len(neighbourhood)
if cand["type"] == "min":
reference = max(local_mean, day_mean) # broad valley: day_mean dominates
prominence = reference - cand["price"]
else:
reference = min(local_mean, day_mean) # plateau max: day_mean dominates
prominence = cand["price"] - reference
if prominence >= min_amplitude * 0.8: # slight tolerance on the threshold
significant.append(cand)
# ── deduplicate: keep only the most extreme value between alternating types ──
return _deduplicate_extrema(significant)
def _deduplicate_extrema(extrema: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""
Ensure extrema alternate between min and max.
Between two consecutive minima (or two consecutive maxima), keep only the
more extreme one. This mirrors the classical definition of alternating
local extrema.
"""
if not extrema:
return []
result: list[dict[str, Any]] = [extrema[0]]
for e in extrema[1:]:
last = result[-1]
if e["type"] == last["type"]:
# Same type - keep the more extreme one
if e["type"] == "min":
if e["price"] < last["price"]:
result[-1] = e
elif e["price"] > last["price"]:
result[-1] = e
else:
result.append(e)
return result
# ─── pattern classification ───────────────────────────────────────────────────
def _classify_pattern(
extrema: list[dict[str, Any]],
cv_pct: float,
times: list[datetime],
start_price: float = 0.0,
end_price: float = 0.0,
) -> tuple[str, float]:
"""
Classify the day into a pattern string and confidence score (0-1).
Args:
extrema: List of significant extrema (already deduplicated).
cv_pct: Coefficient of variation for the day (%).
times: Timestamps of all intervals (for position calculations).
start_price: Smoothed price of the first interval (day start).
end_price: Smoothed price of the last interval (day end).
Returns:
(pattern_string, confidence_float)
"""
n_times = len(times)
# ── flat day ────────────────────────────────────────────────────────────────
if cv_pct <= FLAT_CV_THRESHOLD:
# Confidence scales with how flat it is relative to threshold
confidence = max(0.5, 1.0 - cv_pct / FLAT_CV_THRESHOLD)
return DAY_PATTERN_FLAT, confidence
# ── no significant extrema → check for monotone trend ──────────────────────
if not extrema:
# Without extrema, check if prices have a clear directional trend using
# the day's start/end price difference relative to span.
if start_price > 0 and end_price > 0 and n_times >= MIN_DAY_INTERVALS:
price_change = end_price - start_price
# Require at least 5% absolute change relative to the mean price to
# distinguish a genuine trend from flat-ish noise above FLAT_CV_THRESHOLD.
mean_price = (start_price + end_price) / 2
relative_change = abs(price_change) / mean_price if mean_price > 0 else 0
if relative_change > 0.05:
if price_change > 0:
return DAY_PATTERN_RISING, min(0.65, 0.4 + relative_change)
return DAY_PATTERN_FALLING, min(0.65, 0.4 + relative_change)
return DAY_PATTERN_MIXED, 0.4
n_extrema = len(extrema)
types = [e["type"] for e in extrema]
# ── single extremum ─────────────────────────────────────────────────────────
if n_extrema == 1:
e = extrema[0]
# Check position: central extrema → stronger pattern
rel_pos = e["idx"] / max(1, n_times - 1)
centrality = 1.0 - abs(rel_pos - 0.5) * 2 # 0 at edges, 1 at centre
if e["type"] == "min":
confidence = 0.6 + 0.4 * centrality
return DAY_PATTERN_VALLEY, confidence
# max
# Check if it's edge-dominant: peak near start -> FALLING, near end -> RISING
if rel_pos < _EDGE_ZONE:
return DAY_PATTERN_FALLING, 0.6
if rel_pos > 1.0 - _EDGE_ZONE:
return DAY_PATTERN_RISING, 0.6
confidence = 0.6 + 0.4 * centrality
return DAY_PATTERN_PEAK, confidence
# ── two extrema ─────────────────────────────────────────────────────────────
if n_extrema == 2:
if types == ["max", "min"]:
# Check if max is above both endpoints → genuine interior peak
max_price = extrema[0]["price"]
if start_price > 0 and end_price > 0 and max_price > start_price and max_price > end_price:
return DAY_PATTERN_PEAK, 0.65
return DAY_PATTERN_FALLING, 0.7
if types == ["min", "max"]:
# Check if min is below both endpoints → genuine interior valley
# (avoids misclassifying as RISING a day that starts/ends expensive
# but has a cheap midday zone, e.g. spring solar duck-curve).
min_price = extrema[0]["price"]
if start_price > 0 and end_price > 0 and min_price < start_price and min_price < end_price:
return DAY_PATTERN_VALLEY, 0.65
return DAY_PATTERN_RISING, 0.7
if types == ["min", "min"]:
return DAY_PATTERN_DOUBLE_DIP, 0.65
if types == ["max", "max"]:
return DAY_PATTERN_DUCK_CURVE, 0.65
# ── three extrema ────────────────────────────────────────────────────────────
if n_extrema == 3:
# min-max-min → W-shape
if types == ["min", "max", "min"]:
return DAY_PATTERN_DOUBLE_DIP, 0.75
# max-min-max → duck curve (solar midday valley between morning/evening peaks)
if types == ["max", "min", "max"]:
return DAY_PATTERN_DUCK_CURVE, 0.75
# min-max or max-min with trailing → RISING/FALLING with extra bump
if types[0] == "min" and types[-1] == "max":
return DAY_PATTERN_RISING, 0.55
if types[0] == "max" and types[-1] == "min":
return DAY_PATTERN_FALLING, 0.55
# ── four or more extrema ─────────────────────────────────────────────────────
# Count dominating type
n_min = types.count("min")
n_max = types.count("max")
if abs(n_min - n_max) <= 1:
return DAY_PATTERN_MIXED, 0.5
# More minima: day is mostly cheap → loosely valley-ish
if n_min > n_max:
return DAY_PATTERN_MIXED, 0.45
return DAY_PATTERN_MIXED, 0.45
# ─── knee point detection (simplified Kneedle) ───────────────────────────────
def _find_knee_points(
smoothed: list[float],
extreme_idx: int,
) -> tuple[int | None, int | None]:
"""
Find the left and right knee points of a V-/Λ-shaped flank.
Uses a simplified Kneedle algorithm:
1. Normalise each flank to [0,1] on both axes.
2. Compute the perpendicular distance of each point from the straight line
connecting the flank start to the extreme point.
3. The knee is the point of maximum perpendicular distance.
Args:
smoothed: Smoothed price series for the full day.
extreme_idx: Index of the valley minimum (VALLEY) or peak maximum (PEAK).
is_minimum: True for valley (prices falling then rising),
False for peak (prices rising then falling).
Returns:
``(left_knee_idx, right_knee_idx)`` - indices into ``smoothed``.
Either may be ``None`` if the flank is too short.
"""
n = len(smoothed)
left_idx = _find_knee_on_flank(smoothed, start=0, end=extreme_idx)
right_idx = _find_knee_on_flank(smoothed, start=extreme_idx, end=n - 1)
return left_idx, right_idx
def _find_knee_on_flank(
prices: list[float],
start: int,
end: int,
) -> int | None:
"""
Locate the knee on one flank using the simplified Kneedle method.
Args:
prices: Full price series.
start: Index of flank start.
end: Index of flank end (the extreme point).
descending: True if prices fall from start end, False if they rise.
Returns:
Index of knee point, or ``None`` if flank is fewer than 4 intervals.
"""
length = end - start
if length < MIN_EXTREMA_INTERVALS:
return None
p_start = prices[start]
p_end = prices[end]
# Normalise so that start=(0,0) and end=(1,1)
px_range = float(length)
py_range = p_end - p_start
if abs(py_range) < 1e-9:
return None # Flat flank - no knee
max_dist = 0.0
knee_idx: int | None = None
for i in range(start + 1, end):
# Normalised coordinates
nx = (i - start) / px_range
ny = (prices[i] - p_start) / py_range
# For the line y=x: perpendicular distance = |ny - nx| / sqrt(2)
dist = abs(ny - nx) / math.sqrt(2)
if dist > max_dist:
max_dist = dist
knee_idx = i
return knee_idx
# ─── intra-day segment detection ─────────────────────────────────────────────
def _detect_segments(
extrema: list[dict[str, Any]],
prices: list[float],
times: list[datetime],
) -> list[dict[str, Any]]:
"""
Build a list of monotone segments separated by the detected extrema.
Each segment is a dict with:
type - "rising" | "falling" | "flat"
start - tz-aware datetime of first interval
end - tz-aware datetime of last interval
price_min - min price in segment (EUR/NOK/SEK)
price_max - max price in segment
price_mean - mean price in segment
"""
n = len(prices)
if n == 0:
return []
# Build boundary indices: 0, all extremum indices, n-1
boundaries = [0, *sorted(e["idx"] for e in extrema), n - 1]
# Deduplicate consecutive boundaries
boundaries = list(dict.fromkeys(boundaries)) # preserves order, removes dupes
segments: list[dict[str, Any]] = []
for seg_i in range(len(boundaries) - 1):
lo = boundaries[seg_i]
hi = boundaries[seg_i + 1]
if hi <= lo:
continue
seg_prices = prices[lo : hi + 1]
price_start = prices[lo]
price_end = prices[hi]
delta = price_end - price_start
span = max(seg_prices) - min(seg_prices)
if span < (max(prices) - min(prices)) * 0.05:
seg_type = SEGMENT_TYPE_FLAT
elif delta > 0:
seg_type = SEGMENT_TYPE_RISING
else:
seg_type = SEGMENT_TYPE_FALLING
seg: dict[str, Any] = {
"type": seg_type,
"start": times[lo].isoformat() if lo < len(times) and times[lo] is not None else None,
"end": times[hi].isoformat() if hi < len(times) and times[hi] is not None else None,
"price_min": round(min(seg_prices), 4),
"price_max": round(max(seg_prices), 4),
"price_mean": round(sum(seg_prices) / len(seg_prices), 4),
}
segments.append(seg)
return segments

View file

@ -11,9 +11,11 @@ See docs/development/period-calculation-theory.md for detailed explanation.
from __future__ import annotations
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from datetime import datetime
from .types import TibberPricesIntervalCriteria
from custom_components.tibber_prices.const import PRICE_LEVEL_MAPPING
@ -130,6 +132,17 @@ def check_interval_criteria(
Tuple of (in_flex, meets_min_distance)
"""
# ============================================================
# FAST PATH: Negative/zero prices always qualify as best price
# ============================================================
# When price ≤ 0 the consumer is paid or gets free electricity.
# This is unconditionally the cheapest possible outcome regardless
# of daily average, flex setting, or level filter.
# Bypasses both flex AND min_distance: a negative price is always
# maximally "far below average" in the economically meaningful sense.
if not criteria.reverse_sort and price <= 0:
return True, True
# Normalize inputs to absolute values for consistent calculation
flex_abs = abs(criteria.flex)
min_distance_abs = abs(criteria.min_distance_from_avg)
@ -141,22 +154,19 @@ def check_interval_criteria(
# - Peak price (reverse_sort=True): daily MAXIMUM
# - Best price (reverse_sort=False): daily MINIMUM
#
# Standard formula (positive daily minimum):
# Flex base = max(price_span, abs(ref_price)):
# - On V-shape days (tiny minimum, large span): span wins → meaningful flex band
# - On flat days (large minimum, small span): ref_price wins → same as before
#
# WHY NOT plain ref_price * flex: When daily_min is a single low outlier
# (e.g., min=1 ct, avg=19 ct), the flex band collapses to near-zero
# (1 ct * 15% = 0.15 ct) and no period of sufficient length can be found.
#
# WHY NOT plain span * flex: On flat days (e.g., min=30 ct, span=3 ct),
# this makes the band much narrower than before, breaking existing behaviour.
#
# Examples with flex=15%:
# - V-shape: min=1 ct, avg=19 ct → span=18 ct → flex_base=18 → threshold=1+2.7=3.7 ct (spans fixed)
# - Flat: min=30 ct, avg=33 ct → span=3 ct → flex_base=30 → threshold=30+4.5=34.5 ct (unchanged)
# - Normal: min=10 ct, avg=20 ct → span=10 ct → flex_base=10 → threshold=10+1.5=11.5 ct (unchanged)
# Examples with flex=15% (positive minimum):
# - V-shape: min=1 ct, avg=19 ct → span=18 ct → flex_base=18 → threshold=1+2.7=3.7 ct
# - Flat: min=30 ct, avg=33 ct → span=3 ct → flex_base=30 → threshold=30+4.5=34.5 ct
# - Normal: min=10 ct, avg=20 ct → span=10 ct → flex_base=10 → threshold=10+1.5=11.5 ct
# Positive shoulders around a short negative core are handled later in the
# raw-period pipeline, where adjacency can be evaluated locally. Keeping the
# interval filter day-agnostic avoids creating a global halo across the whole day.
price_span = abs(criteria.avg_price - criteria.ref_price)
flex_base = max(price_span, abs(criteria.ref_price))
@ -202,7 +212,7 @@ def check_interval_criteria(
if scale_factor < SCALE_FACTOR_WARNING_THRESHOLD:
import logging # noqa: PLC0415
_LOGGER = logging.getLogger(f"{__name__}.details") # noqa: N806
_LOGGER = logging.getLogger(f"{__name__}.details")
_LOGGER.debug(
"High flex %.1f%% detected: Reducing min_distance %.1f%%%.1f%% (scale %.2f)",
flex_abs * 100,
@ -241,3 +251,56 @@ def check_interval_criteria(
meets_min_distance = price <= min_distance_threshold
return in_flex, meets_min_distance
def compute_geometric_flex_bonus(
interval_time: datetime,
day_pattern: dict[str, Any] | None,
*,
extra_flex: float,
reverse_sort: bool,
) -> float:
"""
Return extra flex if interval falls within the valley/peak geometric zone.
For best price (reverse_sort=False): widens flex inside the VALLEY zone
defined by [valley_start, valley_end] knee points.
For peak price (reverse_sort=True): widens flex inside the PEAK zone
defined by [peak_start, peak_end] knee points.
Args:
interval_time: Timezone-aware datetime of the interval's start.
day_pattern: DayPatternDict for the interval's calendar day, or None.
extra_flex: Additional flex to add (decimal, e.g. 0.10 for 10%).
reverse_sort: True for peak price, False for best price.
Returns:
``extra_flex`` if the interval is inside the geometric zone, else ``0.0``.
"""
if not day_pattern or extra_flex <= 0:
return 0.0
pattern = day_pattern.get("pattern", "")
if reverse_sort:
# Peak price: expand inside PEAK (Λ-shape) zone
if pattern != "peak":
return 0.0
zone_start = day_pattern.get("peak_start")
zone_end = day_pattern.get("peak_end")
else:
# Best price: expand inside VALLEY zone.
# Also handles DUCK_CURVE (solar duck-curve: expensive morning/evening, cheap midday)
# where valley_start/valley_end mark the knee points around the midday minimum.
if pattern not in ("valley", "duck_curve"):
return 0.0
zone_start = day_pattern.get("valley_start")
zone_end = day_pattern.get("valley_end")
if zone_start is None or zone_end is None:
return 0.0
if zone_start <= interval_time <= zone_end:
return extra_flex
return 0.0

View file

@ -14,8 +14,8 @@ Uses statistical methods:
from __future__ import annotations
import logging
from datetime import datetime
import logging
from typing import NamedTuple
from custom_components.tibber_prices.utils.price import calculate_coefficient_of_variation

View file

@ -2,26 +2,24 @@
from __future__ import annotations
import logging
from datetime import date, datetime, timedelta
import logging
from typing import TYPE_CHECKING, Any
from custom_components.tibber_prices.const import PRICE_LEVEL_MAPPING
from custom_components.tibber_prices.const import PRICE_LEVEL_CHEAP, PRICE_LEVEL_MAPPING, PRICE_LEVEL_VERY_CHEAP
if TYPE_CHECKING:
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
from .level_filtering import (
apply_level_filter,
check_interval_criteria,
)
from .types import TibberPricesIntervalCriteria
from .level_filtering import apply_level_filter, check_interval_criteria, compute_geometric_flex_bonus
from .types import CROSS_DAY_OVERNIGHT_VALIDATION_HOUR, TibberPricesIntervalCriteria
_LOGGER = logging.getLogger(__name__)
_LOGGER_DETAILS = logging.getLogger(__name__ + ".details")
# Module-local log indentation (each module starts at level 0)
INDENT_L0 = "" # Entry point / main function
NEGATIVE_CORE_NO_SHOULDER_INTERVALS = 8 # 2 hours at 15-min resolution
def split_intervals_by_day(
@ -53,7 +51,199 @@ def calculate_reference_prices(intervals_by_day: dict[date, list[dict]], *, reve
return ref_prices
def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building logic requires many arguments, statements, and branches
def _trim_trailing_gaps(period: list[dict]) -> list[dict]:
"""Remove trailing gap-tolerance intervals from a period.
Gap-tolerance intervals at the trailing edge of a period represent
the transition out of the period's price level (e.g., the first
NORMAL interval after a sequence of EXPENSIVE ones). Keeping them
shifts the reported period end by up to gap_count intervals.
Interior gaps (surrounded by qualifying intervals on both sides)
are kept because they represent brief dips within an otherwise
continuous period.
"""
while period and period[-1].get("is_level_gap", False):
period = period[:-1]
return period
def _build_period_interval(price_data: dict, *, time: TibberPricesTimeService) -> dict | None:
"""Build the internal interval representation used by raw periods."""
starts_at = time.get_interval_time(price_data)
if starts_at is None:
return None
price_original = float(price_data.get("_original_price", price_data["total"]))
return {
"interval_hour": starts_at.hour,
"interval_minute": starts_at.minute,
"interval_time": f"{starts_at.hour:02d}:{starts_at.minute:02d}",
"price": price_original,
"interval_start": starts_at,
"smoothing_was_impactful": False,
"is_level_gap": False,
"geometric_bonus_applied": False,
}
def _get_longest_negative_core_length(period: list[dict]) -> int:
"""Return the longest contiguous run of intervals with price <= 0."""
longest = 0
current = 0
for interval in period:
if float(interval.get("price", 0.0)) <= 0:
current += 1
longest = max(longest, current)
else:
current = 0
return longest
def _collect_contiguous_best_price_side(
interval_index: dict[datetime, dict],
start_cursor: datetime,
step: timedelta,
*,
max_intervals: int,
time: TibberPricesTimeService,
) -> list[dict]:
"""Collect directly adjacent favourable intervals on one side of a negative core."""
for target_level in (PRICE_LEVEL_VERY_CHEAP, PRICE_LEVEL_CHEAP):
additions: list[dict] = []
cursor = start_cursor
for _ in range(max_intervals):
price_data = interval_index.get(cursor)
if price_data is None or price_data.get("level") != target_level:
break
period_interval = _build_period_interval(price_data, time=time)
if period_interval is None:
break
additions.append(period_interval)
cursor += step
if additions:
if step < timedelta(0):
additions.reverse()
return additions
return []
def _select_nearest_extensions(
left_candidates: list[dict],
right_candidates: list[dict],
*,
max_total_additions: int,
) -> tuple[list[dict], list[dict]]:
"""Select the nearest left/right additions until the target length is reached."""
left_nearest = list(reversed(left_candidates))
right_nearest = right_candidates.copy()
selected_left_nearest: list[dict] = []
selected_right: list[dict] = []
prefer_left = bool(left_nearest)
while max_total_additions > 0 and (left_nearest or right_nearest):
if prefer_left and left_nearest:
selected_left_nearest.append(left_nearest.pop(0))
max_total_additions -= 1
elif not prefer_left and right_nearest:
selected_right.append(right_nearest.pop(0))
max_total_additions -= 1
elif left_nearest:
selected_left_nearest.append(left_nearest.pop(0))
max_total_additions -= 1
elif right_nearest:
selected_right.append(right_nearest.pop(0))
max_total_additions -= 1
prefer_left = not prefer_left
return list(reversed(selected_left_nearest)), selected_right
def extend_negative_core_periods_for_min_length(
periods: list[list[dict]],
all_prices: list[dict],
min_period_length: int,
*,
time: TibberPricesTimeService,
) -> list[list[dict]]:
"""Locally extend short negative best-price cores into directly adjacent cheap shoulders.
This rescue step is intentionally narrow:
- only periods that already contain a negative/zero core are considered
- only periods shorter than the configured minimum length are extended
- only directly adjacent VERY_CHEAP/CHEAP intervals may be added
- multi-hour negative blocks stay untouched to preserve a strict negative-only period
"""
if not periods:
return periods
min_intervals = time.minutes_to_intervals(min_period_length)
if min_intervals <= 0:
return periods
interval_index: dict[datetime, dict] = {}
for price_data in all_prices:
starts_at = time.get_interval_time(price_data)
if starts_at is not None:
interval_index[starts_at] = price_data
interval_duration = time.get_interval_duration()
extended_periods: list[list[dict]] = []
for period in periods:
negative_core_length = _get_longest_negative_core_length(period)
if (
negative_core_length == 0
or negative_core_length >= NEGATIVE_CORE_NO_SHOULDER_INTERVALS
or len(period) >= min_intervals
):
extended_periods.append(period)
continue
period_start = period[0].get("interval_start")
period_end = period[-1].get("interval_start")
if period_start is None or period_end is None:
extended_periods.append(period)
continue
needed_intervals = min_intervals - len(period)
left_candidates = _collect_contiguous_best_price_side(
interval_index,
period_start - interval_duration,
-interval_duration,
max_intervals=needed_intervals,
time=time,
)
right_candidates = _collect_contiguous_best_price_side(
interval_index,
period_end + interval_duration,
interval_duration,
max_intervals=needed_intervals,
time=time,
)
selected_left, selected_right = _select_nearest_extensions(
left_candidates,
right_candidates,
max_total_additions=needed_intervals,
)
if selected_left or selected_right:
extended_periods.append([*selected_left, *period, *selected_right])
else:
extended_periods.append(period)
return extended_periods
def build_periods(
all_prices: list[dict],
price_context: dict[str, Any],
*,
@ -61,6 +251,7 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
level_filter: str | None = None,
gap_count: int = 0,
time: TibberPricesTimeService,
time_range: tuple[datetime, datetime] | None = None,
) -> list[list[dict]]:
"""
Build periods, allowing periods to cross midnight (day boundary).
@ -77,12 +268,18 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
level_filter: Level filter string ("cheap", "expensive", "any", None)
gap_count: Number of allowed consecutive intervals deviating by exactly 1 level step
time: TibberPricesTimeService instance (required)
time_range: Optional (start_inclusive, end_exclusive) window. When set, only intervals
within [start, end) are considered as period candidates. Reference prices
(from price_context) remain day-wide and are unaffected by this filter.
Used by Phase 4 segment forcing to restrict detection to one segment side.
"""
ref_prices = price_context["ref_prices"]
avg_prices = price_context["avg_prices"]
flex = price_context["flex"]
min_distance_from_avg = price_context["min_distance_from_avg"]
geometric_extra_flex: float = float(price_context.get("geometric_extra_flex", 0.0))
day_patterns_by_date: dict[date, dict[str, Any]] | None = price_context.get("day_patterns_by_date")
# Calculate level_order if level_filter is active
level_order = None
@ -124,11 +321,15 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
)
for day in ref_prices
}
for price_data in all_prices:
starts_at = time.get_interval_time(price_data)
if starts_at is None:
continue
# Filter by time range if specified (Phase 4 segment forcing)
if time_range is not None and not (time_range[0] <= starts_at < time_range[1]):
continue
date_key = starts_at.date()
# Use smoothed price for criteria checks (flex/distance)
@ -147,7 +348,52 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
# Check flex and minimum distance criteria (using smoothed price and interval's own day reference)
criteria = criteria_by_day[ref_date]
in_flex, meets_min_distance = check_interval_criteria(price_for_criteria, criteria)
# Compute geometric flex bonus if pattern-aware expansion is enabled.
# Best-price days with a negative daily minimum are handled by the dedicated
# negative-core logic; applying a day-wide geometric valley bonus there would
# reintroduce broad positive shoulders around a negative core.
geo_bonus = 0.0
if (
geometric_extra_flex > 0
and day_patterns_by_date is not None
and not (not reverse_sort and criteria.ref_price < 0)
):
day_pattern_for_date = day_patterns_by_date.get(ref_date)
geo_bonus = compute_geometric_flex_bonus(
starts_at,
day_pattern_for_date,
extra_flex=geometric_extra_flex,
reverse_sort=reverse_sort,
)
effective_criteria = criteria._replace(flex=criteria.flex + geo_bonus) if geo_bonus > 0 else criteria
in_flex, meets_min_distance = check_interval_criteria(price_for_criteria, effective_criteria)
# Cross-day boundary validation (symmetric for best AND peak periods):
# Overnight intervals (00:00-05:59) must ALSO qualify against the previous
# day's reference price. This prevents day-boundary artifacts in BOTH directions:
#
# PEAK example: A 30ct interval becomes "peak" against tomorrow's lower max (35ct)
# but wasn't peak against today's higher max (39ct).
# BEST example: An 8ct interval becomes "best" against today's lower min (5ct, flex
# allows ≤7.5ct) but actually a 7ct interval qualifying today wouldn't have
# qualified yesterday when min was 4ct (flex allows ≤6ct).
#
# In both cases the apparent "extreme" is just a relative shift between adjacent
# days, not a genuine outlier worth reporting.
if in_flex and starts_at.hour < CROSS_DAY_OVERNIGHT_VALIDATION_HOUR:
prev_day = date_key - timedelta(days=1)
prev_criteria = criteria_by_day.get(prev_day)
if prev_criteria is not None:
prev_effective = (
prev_criteria._replace(flex=prev_criteria.flex + geo_bonus) if geo_bonus > 0 else prev_criteria
)
in_prev_flex, _ = check_interval_criteria(price_for_criteria, prev_effective)
if not in_prev_flex:
# Fails against previous day → boundary artifact, treat as not in flex
in_flex = False
intervals_filtered_by_flex += 1
# Track why intervals are filtered
if not in_flex:
@ -159,7 +405,7 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
smoothing_was_impactful = False
if price_data.get("_smoothed", False):
# Check if original price would have passed the same criteria
in_flex_original, meets_min_distance_original = check_interval_criteria(price_original, criteria)
in_flex_original, meets_min_distance_original = check_interval_criteria(price_original, effective_criteria)
# Smoothing was impactful if original would have failed but smoothed passed
smoothing_was_impactful = (in_flex and meets_min_distance) and not (
in_flex_original and meets_min_distance_original
@ -184,15 +430,23 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
# Only True if smoothing changed whether the interval qualified for period inclusion
"smoothing_was_impactful": smoothing_was_impactful,
"is_level_gap": is_level_gap, # Track if kept due to level gap tolerance
"geometric_bonus_applied": geo_bonus > 0, # True if interval is in geometric zone
}
)
elif current_period:
# Criteria no longer met, end current period
# Criteria no longer met, end current period.
# Trim trailing gap-tolerance intervals: these sit at the boundary
# between the period's price level and the next, and would shift
# the period end by up to gap_count intervals.
current_period = _trim_trailing_gaps(current_period)
if current_period:
periods.append(current_period)
current_period = []
consecutive_gaps = 0 # Reset gap counter
# Add final period if exists
if current_period:
current_period = _trim_trailing_gaps(current_period)
if current_period:
periods.append(current_period)
@ -368,6 +622,86 @@ def _filter_superseded_today_periods(
return kept
def _filter_best_superseded_periods(
today_late: list[dict],
tomorrow_early: list[dict],
other: list[dict],
improvement_threshold: float,
) -> list[dict]:
"""Filter best-price today-late periods superseded by cheaper tomorrow alternatives."""
if not tomorrow_early:
return other + today_late + tomorrow_early
# Find the cheapest tomorrow early period
best_tomorrow = min(tomorrow_early, key=lambda p: p.get("price_mean", float("inf")))
best_tomorrow_price = best_tomorrow.get("price_mean")
if best_tomorrow_price is None:
return other + today_late + tomorrow_early
kept_today = _filter_superseded_today_periods(
today_late,
best_tomorrow,
best_tomorrow_price,
improvement_threshold,
)
return other + kept_today + tomorrow_early
def _filter_peak_superseded_periods(
today_late: list[dict],
tomorrow_early: list[dict],
other: list[dict],
improvement_threshold: float,
) -> list[dict]:
"""
Filter peak-price tomorrow-early periods that are artifacts of day-boundary reclassification.
If today has a genuine late-night peak and tomorrow's early-morning "peak" is
significantly LOWER in price, the tomorrow period is a cross-day artifact:
the same overnight prices are classified differently because they sit near
a different day's maximum.
"""
if not today_late or not tomorrow_early:
return other + today_late + tomorrow_early
# Find the strongest today late peak (highest mean price)
best_today_peak = max(today_late, key=lambda p: p.get("price_mean", 0))
best_today_price = best_today_peak.get("price_mean")
if best_today_price is None or best_today_price <= 0:
return other + today_late + tomorrow_early
kept_tomorrow: list[dict] = []
for tomorrow_period in tomorrow_early:
tomorrow_price = tomorrow_period.get("price_mean")
if tomorrow_price is None:
kept_tomorrow.append(tomorrow_period)
continue
# How much LOWER is tomorrow's peak vs today's peak? (as percentage)
price_drop_pct = ((best_today_price - tomorrow_price) / best_today_price * 100) if best_today_price > 0 else 0
if price_drop_pct >= improvement_threshold:
_LOGGER.info(
"Peak supersession: Tomorrow %s-%s (%.2f) is %.1f%% below today's peak %s-%s (%.2f) → filtered as artifact",
tomorrow_period["start"].strftime("%H:%M"),
tomorrow_period["end"].strftime("%H:%M"),
tomorrow_price,
price_drop_pct,
best_today_peak["start"].strftime("%H:%M"),
best_today_peak["end"].strftime("%H:%M"),
best_today_price,
)
else:
kept_tomorrow.append(tomorrow_period)
return other + today_late + kept_tomorrow
def filter_superseded_periods(
period_summaries: list[dict],
*,
@ -375,24 +709,23 @@ def filter_superseded_periods(
reverse_sort: bool,
) -> list[dict]:
"""
Filter out late-night today periods that are superseded by better tomorrow periods.
Filter out cross-day periods that are artifacts of day-boundary price reclassification.
For BEST PRICE (reverse_sort=False):
When tomorrow's data becomes available, some late-night periods that were found
through relaxation may no longer make sense. If tomorrow has a significantly
better period in the early morning, the late-night today period is obsolete.
better (cheaper) period in the early morning, the late-night today period is obsolete.
Example:
- Today 23:30-00:00 at 0.70 kr (found via relaxation, was best available)
- Tomorrow 04:00-05:30 at 0.50 kr (much better alternative)
The today period is superseded and should be filtered out
This only applies to best-price periods (reverse_sort=False).
Peak-price periods are not filtered this way.
For PEAK PRICE (reverse_sort=True):
Inverted logic: tomorrow's early-morning periods that are significantly LOWER
than today's late-night peak are cross-day artifacts. Overnight prices often
qualify as "peak" against tomorrow's (lower) daily max, but don't represent
genuine high-price windows when viewed across the day boundary.
"""
from .types import ( # noqa: PLC0415
CROSS_DAY_LATE_PERIOD_START_HOUR,
CROSS_DAY_MAX_EXTENSION_HOUR,
CROSS_DAY_EARLY_MORNING_HOUR,
CROSS_DAY_SUPERSESSION_START_HOUR,
SUPERSESSION_PRICE_IMPROVEMENT_PCT,
)
@ -402,8 +735,7 @@ def filter_superseded_periods(
reverse_sort,
)
# Only filter for best-price periods
if reverse_sort or not period_summaries:
if not period_summaries:
return period_summaries
now = time.now()
@ -415,8 +747,8 @@ def filter_superseded_periods(
period_summaries,
today,
tomorrow,
CROSS_DAY_LATE_PERIOD_START_HOUR,
CROSS_DAY_MAX_EXTENSION_HOUR,
CROSS_DAY_SUPERSESSION_START_HOUR,
CROSS_DAY_EARLY_MORNING_HOUR,
)
_LOGGER.debug(
@ -426,103 +758,164 @@ def filter_superseded_periods(
len(other),
)
# If no tomorrow early periods, nothing to compare against
if not tomorrow_early:
_LOGGER.debug("No tomorrow early periods - skipping supersession check")
return period_summaries
# Find the best tomorrow early period (lowest mean price)
best_tomorrow = min(tomorrow_early, key=lambda p: p.get("price_mean", float("inf")))
best_tomorrow_price = best_tomorrow.get("price_mean")
if best_tomorrow_price is None:
return period_summaries
# Filter superseded today periods
kept_today = _filter_superseded_today_periods(
if reverse_sort:
# PEAK: Filter tomorrow-early periods superseded by today-late peaks
result = _filter_peak_superseded_periods(
today_late,
best_tomorrow,
best_tomorrow_price,
tomorrow_early,
other,
SUPERSESSION_PRICE_IMPROVEMENT_PCT,
)
else:
# BEST: Filter today-late periods superseded by cheaper tomorrow alternatives
result = _filter_best_superseded_periods(
today_late,
tomorrow_early,
other,
SUPERSESSION_PRICE_IMPROVEMENT_PCT,
)
# Reconstruct and sort by start time
result = other + kept_today + tomorrow_early
result.sort(key=lambda p: p.get("start") or time.now())
return result
def _is_period_eligible_for_extension(
period: dict,
today: date,
late_hour_threshold: int,
) -> bool:
"""
Check if a period is eligible for cross-day extension.
Eligibility criteria:
- Period has valid start and end times
- Period ends on today (not yesterday or tomorrow)
- Period ends late (after late_hour_threshold, e.g. 20:00)
"""
period_end = period.get("end")
period_start = period.get("start")
if not period_end or not period_start:
return False
if period_end.date() != today:
return False
return period_end.hour >= late_hour_threshold
def _find_extension_intervals(
period_end: datetime,
price_lookup: dict[str, dict],
criteria: Any,
max_extension_time: datetime,
interval_duration: timedelta,
def filter_weak_peak_periods(
period_summaries: list[dict],
avg_prices: dict,
*,
time: TibberPricesTimeService,
) -> list[dict]:
"""
Find consecutive intervals after period_end that meet criteria.
Filter peak periods whose mean price is barely above the daily average.
Iterates forward from period_end, adding intervals while they
meet the flex and min_distance criteria. Stops at first failure
or when reaching max_extension_time.
A genuine peak period should have prices meaningfully above the daily average.
Periods that are only marginally above average are typically cross-day artifacts
where overnight prices qualify as "peak" against a low daily maximum.
Safety: At least one period per day is always preserved (the one with the
highest premium above average). This prevents removing all peaks on flat days.
Only applies to peak periods. Best-price filtering is not needed because
cheap periods near the daily average are still useful for scheduling.
"""
from .level_filtering import check_interval_criteria # noqa: PLC0415
from .types import CROSS_DAY_OVERNIGHT_VALIDATION_HOUR, PEAK_MIN_PREMIUM_ABOVE_AVG_PCT # noqa: PLC0415
extension_intervals: list[dict] = []
check_time = period_end
if not period_summaries:
return period_summaries
while check_time < max_extension_time:
price_data = price_lookup.get(check_time.isoformat())
if not price_data:
break # No more data
# Calculate premium for each period and group by day
period_premiums: list[tuple[dict, float, date]] = []
for period in period_summaries:
period_mean = period.get("price_mean")
period_start = period.get("start")
price = float(price_data["total"])
in_flex, meets_min_distance = check_interval_criteria(price, criteria)
if period_mean is None or period_start is None:
period_premiums.append((period, float("inf"), date.min))
continue
if not (in_flex and meets_min_distance):
break # Criteria no longer met
day_key = period_start.date()
daily_avg = avg_prices.get(day_key) or avg_prices.get(str(day_key))
extension_intervals.append(price_data)
check_time = check_time + interval_duration
if daily_avg is None or daily_avg <= 0:
period_premiums.append((period, float("inf"), day_key))
continue
return extension_intervals
# For overnight/morning periods (before 06:00), use the HIGHER of
# current day and previous day averages. This prevents overnight prices
# from appearing as "peaks" when tomorrow's average is lower due to
# midday valleys (e.g., solar surplus). A genuine peak must be high
# relative to BOTH days' price landscape.
effective_avg = daily_avg
if period_start.hour < CROSS_DAY_OVERNIGHT_VALIDATION_HOUR:
prev_day = day_key - timedelta(days=1)
prev_avg = avg_prices.get(prev_day) or avg_prices.get(str(prev_day))
if prev_avg is not None and prev_avg > daily_avg:
effective_avg = prev_avg
_LOGGER_DETAILS.debug(
"%sWeak peak check: Period %s uses prev-day avg %.4f instead of %.4f (overnight cross-day)",
INDENT_L0,
period_start.strftime("%H:%M"),
prev_avg,
daily_avg,
)
premium_pct = ((period_mean - effective_avg) / effective_avg) * 100
period_premiums.append((period, premium_pct, day_key))
# Find the best (highest premium) period per day
best_per_day: dict[date, float] = {}
for _period, premium, day in period_premiums:
if day not in best_per_day or premium > best_per_day[day]:
best_per_day[day] = premium
# Filter: keep periods that pass threshold OR are the best for their day
kept: list[dict] = []
removed = 0
for period, premium, day in period_premiums:
is_best_for_day = premium >= best_per_day.get(day, float("-inf"))
if premium >= PEAK_MIN_PREMIUM_ABOVE_AVG_PCT:
kept.append(period)
elif is_best_for_day:
# Preserve at least one period per day even if below threshold
kept.append(period)
_LOGGER_DETAILS.debug(
"%sWeak peak preserved (best for day %s): premium=%.1f%% < threshold=%.1f%%",
INDENT_L0,
day,
premium,
PEAK_MIN_PREMIUM_ABOVE_AVG_PCT,
)
else:
period_start = period.get("start")
_LOGGER.info(
"Weak peak filtered: Period %s-%s mean=%.2f is only %.1f%% above daily avg (need ≥%.1f%%)",
period_start.strftime("%H:%M") if period_start else "?",
period["end"].strftime("%H:%M") if period.get("end") else "?",
period.get("price_mean", 0),
premium,
PEAK_MIN_PREMIUM_ABOVE_AVG_PCT,
)
removed += 1
if removed > 0:
_LOGGER.info(
"Weak peak filter: %d/%d periods kept (removed %d below %.0f%% premium threshold)",
len(kept),
len(period_summaries),
removed,
PEAK_MIN_PREMIUM_ABOVE_AVG_PCT,
)
return kept
def _collect_original_period_prices(
def _gap_spans_midnight(a_end: datetime, b_start: datetime) -> bool:
"""
Check if the gap between two periods spans a midnight boundary.
Uses the last covered moment of period A (end - 1 minute, since end is
exclusive) to determine the calendar day. Returns True when A's last
interval is on a different (earlier) date than B's first interval.
Examples:
A ends 00:00 (last interval 23:45 same day), B starts 00:15 True
A ends 23:30, B starts 00:00 next day True
A ends 21:30, B starts 22:00 same day False
"""
a_last_moment = a_end - timedelta(minutes=1)
return a_last_moment.date() < b_start.date()
def _collect_period_prices(
period_start: datetime,
period_end: datetime,
price_lookup: dict[str, dict],
interval_duration: timedelta,
) -> list[float]:
"""Collect prices from original period for CV calculation."""
"""Collect prices within a time range from the price lookup."""
prices: list[float] = []
current = period_start
while current < period_end:
@ -533,33 +926,29 @@ def _collect_original_period_prices(
return prices
def _build_extended_period(
period: dict,
extension_intervals: list[dict],
def _build_bridged_period(
period_a: dict,
period_b: dict,
combined_prices: list[float],
combined_cv: float,
interval_duration: timedelta,
gap_intervals: int,
) -> dict:
"""Create extended period dict with updated statistics."""
period_start = period["start"]
period_end = period["end"]
new_end = period_end + (interval_duration * len(extension_intervals))
"""Create a merged period dict from two bridged periods with updated statistics."""
bridged = period_a.copy()
bridged["end"] = period_b["end"]
bridged["duration_minutes"] = int((period_b["end"] - period_a["start"]).total_seconds() / 60)
bridged["period_interval_count"] = len(combined_prices)
bridged["cross_day_bridged"] = True
bridged["cross_day_bridge_gap_intervals"] = gap_intervals
extended = period.copy()
extended["end"] = new_end
extended["duration_minutes"] = int((new_end - period_start).total_seconds() / 60)
extended["period_interval_count"] = len(combined_prices)
extended["cross_day_extended"] = True
extended["cross_day_extension_intervals"] = len(extension_intervals)
# Recalculate price statistics for the combined period
bridged["price_min"] = min(combined_prices)
bridged["price_max"] = max(combined_prices)
bridged["price_mean"] = sum(combined_prices) / len(combined_prices)
bridged["price_spread"] = bridged["price_max"] - bridged["price_min"]
bridged["price_coefficient_variation_%"] = round(combined_cv, 1)
# Recalculate price statistics
extended["price_min"] = min(combined_prices)
extended["price_max"] = max(combined_prices)
extended["price_mean"] = sum(combined_prices) / len(combined_prices)
extended["price_spread"] = extended["price_max"] - extended["price_min"]
extended["price_coefficient_variation_%"] = round(combined_cv, 1)
return extended
return bridged
def extend_periods_across_midnight(
@ -571,20 +960,24 @@ def extend_periods_across_midnight(
reverse_sort: bool,
) -> list[dict]:
"""
Extend late-night periods across midnight if favorable prices continue.
Bridge periods across midnight when separated by a small gap.
When a period ends close to midnight and tomorrow's data shows continued
favorable prices, extend the period into the next day. This prevents
artificial period breaks at midnight when it's actually better to continue.
When two independently qualifying periods exist on either side of midnight,
separated only by a few non-qualifying intervals (typically caused by per-day
reference price changes at the day boundary), merge them into a single period.
Example: Best price period 22:00-23:45 today could extend to 04:00 tomorrow
if prices remain low overnight.
Key principle: requires evidence on BOTH sides of midnight.
A period ending at 21:30 will NOT be bridged it ended because prices
changed, not because of midnight. Only genuine midnight-split periods
(where favorable conditions continue on both sides) are merged.
Example: Best price period 22:00-23:45 today + period 00:15-03:00 tomorrow
Bridged into 22:00-03:00 (if gap 4 intervals and CV passes).
Rules:
- Only extends periods ending after CROSS_DAY_LATE_PERIOD_START_HOUR (20:00)
- Won't extend beyond CROSS_DAY_MAX_EXTENSION_HOUR (08:00) next day
- Extension must pass same flex criteria as original period
- Quality Gate (CV check) applies to extended period
- Requires periods on BOTH sides of the midnight boundary
- Gap between periods must be CROSS_DAY_MAX_BRIDGE_GAP_INTERVALS (4 = 1 hour)
- Quality Gate (CV check) applies to the merged period
Args:
period_summaries: List of period summary dicts (already processed)
@ -594,19 +987,14 @@ def extend_periods_across_midnight(
reverse_sort: True for peak price, False for best price
Returns:
Updated list of period summaries with extensions applied
Updated list of period summaries with bridges applied
"""
from custom_components.tibber_prices.utils.price import calculate_coefficient_of_variation # noqa: PLC0415
from .types import ( # noqa: PLC0415
CROSS_DAY_LATE_PERIOD_START_HOUR,
CROSS_DAY_MAX_EXTENSION_HOUR,
PERIOD_MAX_CV,
TibberPricesIntervalCriteria,
)
from .types import CROSS_DAY_MAX_BRIDGE_GAP_INTERVALS, PERIOD_MAX_CV # noqa: PLC0415
if not period_summaries or not all_prices:
if not period_summaries or len(period_summaries) < 2 or not all_prices:
return period_summaries
# Build price lookup by timestamp
@ -616,99 +1004,77 @@ def extend_periods_across_midnight(
if interval_time:
price_lookup[interval_time.isoformat()] = price_data
ref_prices = price_context.get("ref_prices", {})
avg_prices = price_context.get("avg_prices", {})
flex = price_context.get("flex", 0.15)
min_distance = price_context.get("min_distance_from_avg", 0)
now = time.now()
today = now.date()
tomorrow = today + timedelta(days=1)
interval_duration = time.get_interval_duration()
# Max extension time (e.g., 08:00 tomorrow)
max_extension_time = time.start_of_local_day(now) + timedelta(days=1, hours=CROSS_DAY_MAX_EXTENSION_HOUR)
# Sort periods by start time for pairwise comparison
sorted_periods = sorted(period_summaries, key=lambda p: p.get("start") or now)
extended_summaries = []
result: list[dict] = []
skip_indices: set[int] = set()
for period in period_summaries:
# Check eligibility for extension
if not _is_period_eligible_for_extension(period, today, CROSS_DAY_LATE_PERIOD_START_HOUR):
extended_summaries.append(period)
for i, period_a in enumerate(sorted_periods):
if i in skip_indices:
continue
# Get tomorrow's reference prices
tomorrow_ref = ref_prices.get(tomorrow) or ref_prices.get(str(tomorrow))
tomorrow_avg = avg_prices.get(tomorrow) or avg_prices.get(str(tomorrow))
# Try to bridge with the next period
if i + 1 < len(sorted_periods):
period_b = sorted_periods[i + 1]
a_end = period_a.get("end")
b_start = period_b.get("start")
if tomorrow_ref is None or tomorrow_avg is None:
extended_summaries.append(period)
continue
if (
a_end and b_start and _gap_spans_midnight(a_end, b_start) and b_start >= a_end # No overlap
):
gap = b_start - a_end
gap_intervals = int(gap.total_seconds() / interval_duration.total_seconds())
# Set up criteria for extension check
criteria = TibberPricesIntervalCriteria(
ref_price=tomorrow_ref,
avg_price=tomorrow_avg,
flex=flex,
min_distance_from_avg=min_distance,
reverse_sort=reverse_sort,
)
# Find extension intervals
extension_intervals = _find_extension_intervals(
period["end"],
price_lookup,
criteria,
max_extension_time,
interval_duration,
)
if not extension_intervals:
extended_summaries.append(period)
continue
# Collect all prices for CV check
original_prices = _collect_original_period_prices(
period["start"],
period["end"],
if gap_intervals <= CROSS_DAY_MAX_BRIDGE_GAP_INTERVALS:
# Collect all prices from A.start through B.end (including gap)
combined_prices = _collect_period_prices(
period_a["start"],
period_b["end"],
price_lookup,
interval_duration,
)
extension_prices = [float(p["total"]) for p in extension_intervals]
combined_prices = original_prices + extension_prices
# Quality Gate: Check CV of extended period
if combined_prices:
combined_cv = calculate_coefficient_of_variation(combined_prices)
if combined_cv is not None and combined_cv <= PERIOD_MAX_CV:
# Extension passes quality gate
extended_period = _build_extended_period(
period,
extension_intervals,
bridged = _build_bridged_period(
period_a,
period_b,
combined_prices,
combined_cv,
interval_duration,
gap_intervals,
)
_LOGGER.info(
"Cross-day extension: Period %s-%s extended to %s (+%d intervals, CV=%.1f%%)",
period["start"].strftime("%H:%M"),
period["end"].strftime("%H:%M"),
extended_period["end"].strftime("%H:%M"),
len(extension_intervals),
"Cross-day bridge: Merged %s-%s + %s-%s%s-%s (gap=%d intervals, CV=%.1f%%)",
period_a["start"].strftime("%H:%M"),
period_a["end"].strftime("%H:%M"),
period_b["start"].strftime("%H:%M"),
period_b["end"].strftime("%H:%M"),
bridged["start"].strftime("%H:%M"),
bridged["end"].strftime("%H:%M"),
gap_intervals,
combined_cv,
)
extended_summaries.append(extended_period)
else:
# Extension would exceed quality gate
result.append(bridged)
skip_indices.add(i + 1)
continue
_LOGGER_DETAILS.debug(
"%sCross-day extension rejected for period %s-%s: CV=%.1f%% > %.1f%%",
"%sCross-day bridge rejected %s-%s + %s-%s: CV=%.1f%% > %.1f%%",
INDENT_L0,
period["start"].strftime("%H:%M"),
period["end"].strftime("%H:%M"),
period_a["start"].strftime("%H:%M"),
period_a["end"].strftime("%H:%M"),
period_b["start"].strftime("%H:%M"),
period_b["end"].strftime("%H:%M"),
combined_cv or 0,
PERIOD_MAX_CV,
)
extended_summaries.append(period)
return extended_summaries
result.append(period_a)
return result

View file

@ -3,11 +3,13 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
from .types import TibberPricesPeriodConfig
_LOGGER = logging.getLogger(__name__)
_LOGGER_DETAILS = logging.getLogger(__name__ + ".details")
@ -56,7 +58,7 @@ def recalculate_period_metadata(periods: list[dict], *, time: TibberPricesTimeSe
"""
Recalculate period metadata after merging periods.
Updates period_position, periods_total, and periods_remaining for all periods
Updates period_position, period_count_total, and period_count_remaining for all periods
based on chronological order.
This must be called after resolve_period_overlaps() to ensure metadata
@ -78,17 +80,31 @@ def recalculate_period_metadata(periods: list[dict], *, time: TibberPricesTimeSe
for position, period in enumerate(periods, 1):
period["period_position"] = position
period["periods_total"] = total_periods
period["periods_remaining"] = total_periods - position
period["period_count_total"] = total_periods
period["period_count_remaining"] = total_periods - position
def merge_adjacent_periods(period1: dict, period2: dict) -> dict:
def _merge_adjacent_periods_from_summaries(period1: dict, period2: dict) -> dict:
"""
Merge two adjacent or overlapping periods into one.
Merge two adjacent or overlapping periods from summary data only.
The newer period's relaxation attributes override the older period's.
Takes the earliest start time and latest end time.
Price statistics are recombined from both periods so the merged period
reflects the actual span (rather than only period1's stats):
- price_min: min(period1.price_min, period2.price_min)
- price_max: max(period1.price_max, period2.price_max)
- price_spread: max - min (recomputed)
- price_mean: weighted by period_interval_count when available, else
weighted by duration_minutes (kept simple - exact mean would require
raw interval prices that aren't carried in the period dict).
Note: price_median and price_coefficient_variation_% are intentionally NOT
recomputed because they cannot be derived from summary stats. They retain
period1's values; downstream consumers must treat them as approximate for
merged periods (the `merged_from` marker indicates this).
Relaxation attributes from the newer period (period2) override those from period1:
- relaxation_active
- relaxation_level
@ -97,20 +113,13 @@ def merge_adjacent_periods(period1: dict, period2: dict) -> dict:
- period_interval_level_gap_count
- period_interval_smoothed_count
Args:
period1: First period (older baseline or relaxed period)
period2: Second period (newer relaxed period with higher flex)
Returns:
Merged period dict with combined time span and newer period's attributes
"""
# Take earliest start and latest end
merged_start = min(period1["start"], period2["start"])
merged_end = max(period1["end"], period2["end"])
merged_duration = int((merged_end - merged_start).total_seconds() / 60)
# Start with period1 as base
# Start with period1 as base (keeps period_position, period_count_*, ratings, etc.)
merged = period1.copy()
# Update time boundaries
@ -118,6 +127,39 @@ def merge_adjacent_periods(period1: dict, period2: dict) -> dict:
merged["end"] = merged_end
merged["duration_minutes"] = merged_duration
# Recombine price extremes from both periods
p1_min = period1.get("price_min")
p2_min = period2.get("price_min")
p1_max = period1.get("price_max")
p2_max = period2.get("price_max")
if p1_min is not None and p2_min is not None:
merged["price_min"] = round(min(float(p1_min), float(p2_min)), 4)
if p1_max is not None and p2_max is not None:
merged["price_max"] = round(max(float(p1_max), float(p2_max)), 4)
if merged.get("price_min") is not None and merged.get("price_max") is not None:
merged["price_spread"] = round(float(merged["price_max"]) - float(merged["price_min"]), 4)
# Weighted mean: prefer interval count, fall back to duration
p1_mean = period1.get("price_mean")
p2_mean = period2.get("price_mean")
if p1_mean is not None and p2_mean is not None:
p1_weight = period1.get("period_interval_count") or period1.get("duration_minutes") or 1
p2_weight = period2.get("period_interval_count") or period2.get("duration_minutes") or 1
total_weight = p1_weight + p2_weight
if total_weight > 0:
merged["price_mean"] = round(
(float(p1_mean) * p1_weight + float(p2_mean) * p2_weight) / total_weight,
4,
)
# Combine interval count if both have it (overlaps will overcount slightly,
# which is acceptable for the weighted-mean use case above)
p1_iv = period1.get("period_interval_count")
p2_iv = period2.get("period_interval_count")
if p1_iv is not None and p2_iv is not None:
merged["period_interval_count"] = int(p1_iv) + int(p2_iv)
# Override with period2's relaxation attributes (newer/higher flex wins)
relaxation_attrs = [
"relaxation_active",
@ -132,7 +174,8 @@ def merge_adjacent_periods(period1: dict, period2: dict) -> dict:
if attr in period2:
merged[attr] = period2[attr]
# Mark as merged (for debugging)
# Mark as merged (for debugging) - downstream consumers can detect that
# price_median / price_coefficient_variation_% are approximate.
merged["merged_from"] = {
"period1_start": period1["start"].isoformat(),
"period1_end": period1["end"].isoformat(),
@ -141,7 +184,7 @@ def merge_adjacent_periods(period1: dict, period2: dict) -> dict:
}
_LOGGER_DETAILS.debug(
"%sMerged periods: %s-%s + %s-%s%s-%s (duration: %d min)",
"%sMerged periods: %s-%s + %s-%s%s-%s (duration: %d min, mean: %s)",
INDENT_L2,
period1["start"].strftime("%H:%M"),
period1["end"].strftime("%H:%M"),
@ -150,11 +193,242 @@ def merge_adjacent_periods(period1: dict, period2: dict) -> dict:
merged_start.strftime("%H:%M"),
merged_end.strftime("%H:%M"),
merged_duration,
merged.get("price_mean"),
)
return merged
def _build_raw_merge_context(
all_prices: list[dict],
config: TibberPricesPeriodConfig,
*,
time: TibberPricesTimeService,
) -> dict[str, Any] | None:
"""Build reusable context for raw-interval merge recomputation."""
from .period_building import calculate_reference_prices, split_intervals_by_day # noqa: PLC0415
from .types import TibberPricesThresholdConfig # noqa: PLC0415
sorted_prices = sorted(
all_prices,
key=lambda price_data: time.get_interval_time(price_data) or time.now(),
)
interval_lookup: dict[Any, dict] = {}
for price_data in sorted_prices:
if (interval_start := time.get_interval_time(price_data)) is not None:
interval_lookup[interval_start] = price_data
if not interval_lookup:
return None
intervals_by_day, avg_price_by_day = split_intervals_by_day(sorted_prices, time=time)
ref_prices = calculate_reference_prices(intervals_by_day, reverse_sort=config.reverse_sort)
thresholds = TibberPricesThresholdConfig(
threshold_low=config.threshold_low,
threshold_high=config.threshold_high,
threshold_volatility_moderate=config.threshold_volatility_moderate,
threshold_volatility_high=config.threshold_volatility_high,
threshold_volatility_very_high=config.threshold_volatility_very_high,
reverse_sort=config.reverse_sort,
)
return {
"interval_duration": time.get_interval_duration(),
"interval_lookup": interval_lookup,
"price_context": {
"ref_prices": ref_prices,
"avg_prices": avg_price_by_day,
"intervals_by_day": intervals_by_day,
},
"thresholds": thresholds,
}
def _collect_period_price_data(
merged_start: Any,
merged_end: Any,
merge_context: dict[str, Any],
) -> list[dict] | None:
"""Collect the contiguous raw intervals for a merged period span."""
interval_lookup = merge_context["interval_lookup"]
interval_duration = merge_context["interval_duration"]
period_price_data: list[dict] = []
cursor = merged_start
while cursor < merged_end:
if (price_data := interval_lookup.get(cursor)) is None:
return None
period_price_data.append(price_data)
cursor += interval_duration
return period_price_data
def _rebuild_merged_period_from_raw(
period1: dict,
period2: dict,
merge_context: dict[str, Any],
) -> dict | None:
"""Rebuild merged period statistics from the raw interval union."""
from custom_components.tibber_prices.utils.price import ( # noqa: PLC0415
aggregate_period_levels,
aggregate_period_ratings,
calculate_coefficient_of_variation,
calculate_volatility_level,
)
from .period_statistics import ( # noqa: PLC0415
build_period_summary_dict,
calculate_aggregated_rating_difference,
calculate_period_price_diff,
calculate_period_price_statistics,
)
from .types import TibberPricesPeriodData, TibberPricesPeriodStatistics # noqa: PLC0415
merged_start = min(period1["start"], period2["start"])
merged_end = max(period1["end"], period2["end"])
period_price_data = _collect_period_price_data(merged_start, merged_end, merge_context)
if not period_price_data:
return None
thresholds = merge_context["thresholds"]
price_context = merge_context["price_context"]
aggregated_level = aggregate_period_levels(period_price_data)
aggregated_rating = None
if thresholds.threshold_low is not None and thresholds.threshold_high is not None:
aggregated_rating, _ = aggregate_period_ratings(
period_price_data,
thresholds.threshold_low,
thresholds.threshold_high,
)
price_stats = calculate_period_price_statistics(period_price_data)
period_price_diff, period_price_diff_pct = calculate_period_price_diff(
price_stats["price_mean"],
merged_start,
price_context,
)
prices_for_volatility = [float(price_data["total"]) for price_data in period_price_data if "total" in price_data]
period_cv = calculate_coefficient_of_variation(prices_for_volatility)
volatility = calculate_volatility_level(
prices_for_volatility,
threshold_moderate=thresholds.threshold_volatility_moderate,
threshold_high=thresholds.threshold_volatility_high,
threshold_very_high=thresholds.threshold_volatility_very_high,
).lower()
rating_difference_pct = calculate_aggregated_rating_difference(period_price_data)
merged = build_period_summary_dict(
TibberPricesPeriodData(
start_time=merged_start,
end_time=merged_end,
period_length=len(period_price_data),
period_idx=1,
total_periods=1,
),
TibberPricesPeriodStatistics(
aggregated_level=aggregated_level,
aggregated_rating=aggregated_rating,
rating_difference_pct=rating_difference_pct,
price_mean=price_stats["price_mean"],
price_median=price_stats["price_median"],
price_min=price_stats["price_min"],
price_max=price_stats["price_max"],
price_spread=price_stats["price_spread"],
volatility=volatility,
coefficient_of_variation=round(period_cv, 1) if period_cv is not None else None,
period_price_diff=period_price_diff,
period_price_diff_pct=period_price_diff_pct,
),
reverse_sort=thresholds.reverse_sort,
price_context=price_context,
)
if period1.get("relaxation_active") or period2.get("relaxation_active"):
merged["relaxation_active"] = True
for attr in (
"relaxation_level",
"relaxation_threshold_original_%",
"relaxation_threshold_applied_%",
"duration_fallback_active",
"duration_fallback_min_length",
):
if attr in period2:
merged[attr] = period2[attr]
elif attr in period1:
merged[attr] = period1[attr]
for attr in (
"period_interval_level_gap_count",
"period_interval_smoothed_count",
):
total = 0
has_value = False
for period in (period1, period2):
if (value := period.get(attr)) is not None:
total += int(value)
has_value = True
if has_value:
merged[attr] = total
merged["merged_from"] = {
"period1_start": period1["start"].isoformat(),
"period1_end": period1["end"].isoformat(),
"period2_start": period2["start"].isoformat(),
"period2_end": period2["end"].isoformat(),
}
_LOGGER_DETAILS.debug(
"%sMerged periods from raw intervals: %s-%s + %s-%s%s-%s (intervals: %d, mean: %s)",
INDENT_L2,
period1["start"].strftime("%H:%M"),
period1["end"].strftime("%H:%M"),
period2["start"].strftime("%H:%M"),
period2["end"].strftime("%H:%M"),
merged_start.strftime("%H:%M"),
merged_end.strftime("%H:%M"),
merged.get("period_interval_count"),
merged.get("price_mean"),
)
return merged
def merge_adjacent_periods(
period1: dict,
period2: dict,
*,
merge_context: dict[str, Any] | None = None,
) -> dict:
"""
Merge two adjacent or overlapping periods into one.
When raw interval data is available, rebuild the merged summary from the
underlying interval union so medians, CV, ratings, and interval counts stay
exact after overlap resolution. Falls back to the previous summary-based
approximation if the raw slice cannot be recovered.
"""
if merge_context is not None and (recomputed := _rebuild_merged_period_from_raw(period1, period2, merge_context)):
return recomputed
if merge_context is not None:
_LOGGER.debug(
"Falling back to summary-based merge for %s-%s + %s-%s",
period1["start"].strftime("%H:%M"),
period1["end"].strftime("%H:%M"),
period2["start"].strftime("%H:%M"),
period2["end"].strftime("%H:%M"),
)
return _merge_adjacent_periods_from_summaries(period1, period2)
def _check_merge_quality_gate(periods_to_merge: list[tuple[int, dict]], relaxed: dict) -> bool:
"""
Check if merging would create a period that's too heterogeneous.
@ -286,6 +560,10 @@ def _find_adjacent_or_overlapping(relaxed: dict, existing_periods: list[dict]) -
def resolve_period_overlaps(
existing_periods: list[dict],
new_relaxed_periods: list[dict],
*,
all_prices: list[dict] | None = None,
config: TibberPricesPeriodConfig | None = None,
time: TibberPricesTimeService | None = None,
) -> tuple[list[dict], int]:
"""
Resolve overlaps between existing periods and newly found relaxed periods.
@ -305,6 +583,9 @@ def resolve_period_overlaps(
Args:
existing_periods: All previously found periods (baseline + earlier relaxation phases)
new_relaxed_periods: Periods found in current relaxation phase (will be merged if adjacent)
all_prices: Optional raw interval data for exact merged-summary recomputation
config: Optional period config used to rebuild merged summaries from raw data
time: Optional time service for interval alignment during raw recomputation
Returns:
Tuple of (merged_periods, new_periods_count):
@ -328,6 +609,10 @@ def resolve_period_overlaps(
merged = existing_periods.copy()
periods_added = 0
merge_context = None
if all_prices is not None and config is not None and time is not None:
merge_context = _build_raw_merge_context(all_prices, config, time=time)
for relaxed in new_relaxed_periods:
relaxed_start = relaxed["start"]
@ -378,7 +663,7 @@ def resolve_period_overlaps(
# Remove old periods (in reverse order to maintain indices)
for idx, existing in reversed(periods_to_merge):
merged_period = merge_adjacent_periods(existing, merged_period)
merged_period = merge_adjacent_periods(existing, merged_period, merge_context=merge_context)
merged.pop(idx)
# Add the merged result

View file

@ -9,11 +9,7 @@ if TYPE_CHECKING:
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
from .types import (
TibberPricesPeriodData,
TibberPricesPeriodStatistics,
TibberPricesThresholdConfig,
)
from .types import TibberPricesPeriodData, TibberPricesPeriodStatistics, TibberPricesThresholdConfig
from custom_components.tibber_prices.utils.average import calculate_median
from custom_components.tibber_prices.utils.price import (
@ -23,6 +19,8 @@ from custom_components.tibber_prices.utils.price import (
calculate_volatility_level,
)
from .types import LOW_PRICE_QUALITY_BYPASS_THRESHOLD, PERIOD_MAX_CV
def calculate_period_price_diff(
price_mean: float,
@ -176,8 +174,8 @@ def build_period_summary_dict(
# 5. Detail information (additional context)
"period_interval_count": period_data.period_length,
"period_position": period_data.period_idx,
"periods_total": period_data.total_periods,
"periods_remaining": period_data.total_periods - period_data.period_idx,
"period_count_total": period_data.total_periods,
"period_count_remaining": period_data.total_periods - period_data.period_idx,
}
# Add period price difference attributes based on sensor type (step 4)
@ -208,8 +206,10 @@ def build_period_summary_dict(
day_span = day_max - day_min
day_avg = avg_prices.get(period_start_date, sum(day_prices) / len(day_prices))
# Calculate volatility percentage (span / avg * 100)
day_volatility_pct = round((day_span / day_avg * 100), 1) if day_avg > 0 else 0.0
# Calculate volatility percentage relative to the day's absolute average.
# Negative-average days remain meaningful, while true zero-average days
# cannot produce a truthful percentage and therefore return None.
day_volatility_pct = round((day_span / abs(day_avg) * 100), 1) if day_avg != 0 else None
# Convert to minor units (ct/øre) for consistency with other price attributes
summary["day_volatility_%"] = day_volatility_pct
@ -220,6 +220,56 @@ def build_period_summary_dict(
return summary
def _strip_geo_from_edges(period: list[dict]) -> list[dict]:
"""
Remove geo-bonus intervals from leading and trailing edges of a period.
Used by Phase 3 CV gate: when a period with geometric extension fails the CV quality
gate, the edge intervals that were included only via geo-bonus flex are stripped to
restore the period's unextended (tighter) boundaries.
Geo-bonus intervals in the MIDDLE of a period are preserved (they represent
intervals genuinely inside the valley/peak zone, not boundary extensions).
Returns an empty list only when all intervals are geo-bonus (degenerate case).
"""
start = 0
end = len(period)
while start < end and period[start].get("geometric_bonus_applied", False):
start += 1
while end > start and period[end - 1].get("geometric_bonus_applied", False):
end -= 1
return period[start:end]
def _add_interval_flag_counts(summary: dict, period: list[dict], *, geo_extension_status: str | None = None) -> None:
"""
Add optional interval flag counts to period summary.
Args:
summary: Period summary dict to augment in-place.
period: Raw interval list (may already be stripped of geo-bonus edges).
geo_extension_status: "active" if geometric extension passed the CV gate,
"attempted" if it was tried but CV gate failed and period was reverted.
"""
if (count := sum(1 for i in period if i.get("smoothing_was_impactful", False))) > 0:
summary["period_interval_smoothed_count"] = count
if (count := sum(1 for i in period if i.get("is_level_gap", False))) > 0:
summary["period_interval_level_gap_count"] = count
# Geometric extension: distinguish "active" (CV passed) from "attempted" (CV failed → reverted)
if geo_extension_status == "active":
count = sum(1 for i in period if i.get("geometric_bonus_applied", False))
summary["geometric_extension_active"] = True
summary["geometric_extension_intervals"] = count
elif geo_extension_status == "attempted":
# CV gate failed: geo extension was tried but period was reverted to base boundaries.
# The summary uses unextended (stripped) boundaries; this flag marks the attempt.
summary["geometric_extension_attempted"] = True
if any(i.get("segment_forced", False) for i in period):
summary["segment_forced"] = True
def extract_period_summaries(
periods: list[list[dict]],
all_prices: list[dict],
@ -250,10 +300,7 @@ def extract_period_summaries(
time: TibberPricesTimeService instance (required).
"""
from .types import ( # noqa: PLC0415 - Avoid circular import
TibberPricesPeriodData,
TibberPricesPeriodStatistics,
)
from .types import TibberPricesPeriodData, TibberPricesPeriodStatistics # noqa: PLC0415 - Avoid circular import
# Build lookup dictionary for full price data by timestamp
price_lookup: dict[str, dict] = {}
@ -269,6 +316,34 @@ def extract_period_summaries(
if not period:
continue
# Phase 3: Geometric extension CV gate check
# If this period contains geo-bonus intervals, pre-check whether the full period
# passes the CV quality gate. If it fails, revert to base boundaries by stripping
# geo-bonus intervals from the edges and mark with geometric_extension_attempted.
geo_extension_status: str | None = None
if any(iv.get("geometric_bonus_applied", False) for iv in period):
full_prices: list[float] = []
for iv in period:
start_iv = iv.get("interval_start")
if start_iv:
p = price_lookup.get(start_iv.isoformat())
if p:
full_prices.append(float(p["total"]))
if full_prices:
full_cv = calculate_coefficient_of_variation(full_prices)
cv_fails = (
full_cv is not None
and sum(full_prices) / len(full_prices) >= LOW_PRICE_QUALITY_BYPASS_THRESHOLD
and full_cv > PERIOD_MAX_CV
)
if cv_fails:
base_period = _strip_geo_from_edges(period)
if base_period:
period = base_period
geo_extension_status = "attempted"
else:
geo_extension_status = "active"
first_interval = period[0]
last_interval = period[-1]
@ -328,12 +403,6 @@ def extract_period_summaries(
).lower()
rating_difference_pct = calculate_aggregated_rating_difference(period_price_data)
# Count how many intervals in this period benefited from smoothing (i.e., would have been excluded)
smoothed_impactful_count = sum(1 for interval in period if interval.get("smoothing_was_impactful", False))
# Count how many intervals were kept due to level filter gap tolerance
level_gap_count = sum(1 for interval in period if interval.get("is_level_gap", False))
# Build period data and statistics objects
period_data = TibberPricesPeriodData(
start_time=start_time,
@ -363,13 +432,8 @@ def extract_period_summaries(
period_data, stats, reverse_sort=thresholds.reverse_sort, price_context=price_context
)
# Add smoothing information if any intervals benefited from smoothing
if smoothed_impactful_count > 0:
summary["period_interval_smoothed_count"] = smoothed_impactful_count
# Add level gap tolerance information if any intervals were kept as gaps
if level_gap_count > 0:
summary["period_interval_level_gap_count"] = level_gap_count
# Add optional interval flag counts (smoothing, level gaps, geometric extension)
_add_interval_flag_counts(summary, period, geo_extension_status=geo_extension_status)
summaries.append(summary)

View file

@ -2,27 +2,26 @@
from __future__ import annotations
import logging
from datetime import timedelta
import logging
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Callable
from datetime import date
from datetime import date, datetime
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
from custom_components.tibber_prices.utils.price import calculate_coefficient_of_variation
from custom_components.tibber_prices.utils.price import calculate_coefficient_of_variation, calculate_iqr_stats
from .period_overlap import (
recalculate_period_metadata,
resolve_period_overlaps,
)
from .period_overlap import recalculate_period_metadata, resolve_period_overlaps
from .types import (
INDENT_L0,
INDENT_L1,
INDENT_L2,
LOW_PRICE_QUALITY_BYPASS_THRESHOLD,
PERIOD_MAX_CV,
RELAXATION_FLEX_INCREMENT,
TibberPricesPeriodConfig,
)
@ -41,12 +40,6 @@ FLEX_HIGH_THRESHOLD_RELAXATION = 0.30 # 30% - WARNING: base flex too high for r
MIN_DURATION_FALLBACK_MINIMUM = 30 # Minimum period length to try (30 min = 2 intervals)
MIN_DURATION_FALLBACK_STEP = 15 # Reduce by 15 min (1 interval) each step
# Low absolute price threshold for quality gate bypass (in major currency unit, e.g. EUR/NOK)
# When the MEAN price of a period is below this level, the CV quality gate is bypassed.
# Relative CV is unreliable at very low absolute prices: a range of 1-4 ct shows CV≈50%
# but is practically homogeneous from a cost perspective.
# Value: LOW_PRICE_AVG_THRESHOLD (subunit) / 100 = 10 ct / 100 = 0.10 EUR/NOK
LOW_PRICE_QUALITY_BYPASS_THRESHOLD = 0.10 # EUR/NOK major unit (= 10 ct/øre)
# Span-to-ref ratio threshold for suppressing flex warnings on V-shape days.
# When span / ref_price < this on ANY available day, the warning is shown.
@ -56,7 +49,10 @@ FLEX_WARNING_VSHAPE_RATIO = 0.5 # span/ref_price ratio below which a day is con
# On flat price days (low variation), it is unrealistic to require multiple distinct
# best/peak price periods. Requiring 2+ periods would force relaxation to create
# artificial periods that don't represent genuine price structure.
LOW_CV_FLAT_DAY_THRESHOLD = 10.0 # %: days with CV ≤ this need only 1 period
LOW_CV_FLAT_DAY_THRESHOLD = 10.0 # %: fallback when IQR% not available (near-zero or negative median)
# IQR% ≤ 15% ≈ CV ≤ 10% for clean data, but also catches "flat + isolated spike" days correctly:
# a single spike inflates CV to 15-25% while leaving IQR% near 0-5%.
LOW_IQR_PCT_FLAT_DAY_THRESHOLD = 15.0 # %: days with IQR% ≤ this need only 1 period
def _check_period_quality(
@ -287,8 +283,11 @@ def _try_min_duration_fallback(
*,
config: TibberPricesPeriodConfig,
existing_periods: list[dict],
all_prices: list[dict],
prices_by_day: dict[date, list[dict]],
time: TibberPricesTimeService,
max_relaxation_attempts: int = 0,
day_patterns_by_date: dict | None = None,
) -> tuple[dict[str, Any] | None, dict[str, Any]]:
"""
Try reducing min_period_length to find periods when relaxation is exhausted.
@ -308,6 +307,8 @@ def _try_min_duration_fallback(
existing_periods: Periods found so far (from relaxation)
prices_by_day: Price intervals grouped by day
time: Time service instance
day_patterns_by_date: Optional dict mapping date day pattern dict. Used for
geometric flex bonus in period detection.
Returns:
Tuple of (result dict with periods, metadata dict) or (None, empty metadata)
@ -353,20 +354,35 @@ def _try_min_duration_fallback(
current_min_duration,
)
# Create modified config with shorter min_period_length
# Use maxed-out flex (50%) since we're in fallback mode
# Create modified config with shorter min_period_length.
# IMPORTANT: We deliberately do NOT max out flex/min_distance here.
# Going to MAX_FLEX_HARD_LIMIT (50%) and disabling min_distance + level filter
# made every interval qualify on flat-price days, producing phantom periods that
# don't represent any real "best/peak" structure. Instead we keep the relaxation's
# final flex (the highest the user accepted via max_relaxation_attempts) and
# only:
# - drop the level filter (it was already dropped during the last relaxation step)
# - halve min_distance_from_avg (instead of zeroing it) so genuinely flat days
# still surface no period rather than a misleading one.
# The shorter min_period_length is what actually unlocks new candidates.
relaxation_final_flex = min(
abs(config.flex) + max(1, max_relaxation_attempts) * RELAXATION_FLEX_INCREMENT,
MAX_FLEX_HARD_LIMIT,
)
fallback_config = TibberPricesPeriodConfig(
reverse_sort=config.reverse_sort,
flex=MAX_FLEX_HARD_LIMIT, # Max flex
min_distance_from_avg=0, # Disable min_distance in fallback
flex=relaxation_final_flex,
min_distance_from_avg=config.min_distance_from_avg * 0.5,
min_period_length=current_min_duration,
threshold_low=config.threshold_low,
threshold_high=config.threshold_high,
threshold_volatility_moderate=config.threshold_volatility_moderate,
threshold_volatility_high=config.threshold_volatility_high,
threshold_volatility_very_high=config.threshold_volatility_very_high,
level_filter=None, # Disable level filter
level_filter="any", # Already effectively any after relaxation; keeps gap logic intact
gap_count=config.gap_count,
extend_to_extreme=config.extend_to_extreme,
max_extension_intervals=config.max_extension_intervals,
)
# Try to find periods for days with zero periods
@ -380,6 +396,7 @@ def _try_min_duration_fallback(
day_prices,
config=fallback_config,
time=time,
day_patterns_by_date=day_patterns_by_date,
)
day_periods = day_result.get("periods", [])
@ -422,6 +439,9 @@ def _try_min_duration_fallback(
merged_periods, _new_count = resolve_period_overlaps(
existing_periods,
fallback_periods,
all_prices=all_prices,
config=config,
time=time,
)
recalculate_period_metadata(merged_periods, time=time)
@ -453,22 +473,24 @@ def _compute_day_effective_min(
"""
Compute per-day effective min_periods with flat-day adaptation.
On days with very low price variation (CV LOW_CV_FLAT_DAY_THRESHOLD),
On days with very low price variation (IQR% LOW_IQR_PCT_FLAT_DAY_THRESHOLD),
requiring multiple distinct cheapest/peak periods is unrealistic. Finding
ONE period is sufficient because there is no meaningful price structure that
would create natural multiple periods.
This applies ONLY to BEST PRICE periods (reverse_sort=False). For PEAK PRICE
periods, full relaxation should run even on flat days because identifying the
genuinely most expensive window requires the complete filter evaluation.
(Design decision: if the user explicitly disabled relaxation, honour the
configured min_periods exactly regardless.)
Uses IQR% as primary metric (robust to isolated price spikes) with CV as
fallback when IQR% is undefined (near-zero or negative median prices).
This applies to both BEST PRICE and PEAK PRICE periods. On flat days,
forcing 2+ peaks via relaxation creates cross-day boundary artifacts
where overnight prices barely qualify as "peak" only because they are
the second-highest block relative to that day's maximum.
Args:
prices_by_day: Dict of date list of price dicts
min_periods: Configured minimum periods per day
enable_relaxation: Whether relaxation is enabled
reverse_sort: True for peak price (no adaptation), False for best price
reverse_sort: True for peak price, False for best price
Returns:
Tuple of (dict of date effective min_periods for that day, count of flat days detected)
@ -476,47 +498,62 @@ def _compute_day_effective_min(
"""
day_effective_min = {}
flat_day_count = 0
min_prices_for_cv = 2 # Need at least 2 prices to calculate CV
for day, day_prices in prices_by_day.items():
if not enable_relaxation or min_periods <= 1 or reverse_sort:
# Relaxation disabled, already 1, or peak price: no adaptation
if not enable_relaxation or min_periods <= 1:
# Relaxation disabled or already 1: no adaptation
day_effective_min[day] = min_periods
continue
price_values = [float(p["total"]) for p in day_prices if p.get("total") is not None]
if len(price_values) < min_prices_for_cv:
if len(price_values) < 2:
day_effective_min[day] = min_periods
continue
day_cv = calculate_coefficient_of_variation(price_values)
# Primary flat-day metric: IQR% is robust to isolated price spikes.
# A single spike inflates CV to 15-25% while leaving IQR% near 0-5%,
# so IQR correctly identifies "flat core + spike" days as flat.
iqr_stats = calculate_iqr_stats(price_values)
iqr_pct = iqr_stats["iqr_pct"] if iqr_stats else None
if day_cv is not None and day_cv <= LOW_CV_FLAT_DAY_THRESHOLD:
is_flat = False
flat_metric = ""
if iqr_pct is not None:
is_flat = iqr_pct <= LOW_IQR_PCT_FLAT_DAY_THRESHOLD
flat_metric = f"IQR%={iqr_pct:.1f}% ≤ {LOW_IQR_PCT_FLAT_DAY_THRESHOLD:.0f}%"
else:
# IQR% undefined (near-zero or negative median): fall back to CV
day_cv = calculate_coefficient_of_variation(price_values)
if day_cv is not None:
is_flat = day_cv <= LOW_CV_FLAT_DAY_THRESHOLD
flat_metric = f"CV={day_cv:.1f}% ≤ {LOW_CV_FLAT_DAY_THRESHOLD:.0f}% (IQR% N/A)"
if is_flat:
day_effective_min[day] = 1
flat_day_count += 1
_LOGGER_DETAILS.debug(
"%sDay %s: flat price profile (CV=%.1f%%%.1f%%) → min_periods relaxed to 1",
"%sDay %s: flat price profile (%s) → min_periods relaxed to 1",
INDENT_L1,
day,
day_cv,
LOW_CV_FLAT_DAY_THRESHOLD,
flat_metric,
)
else:
day_effective_min[day] = min_periods
if flat_day_count > 0:
_LOGGER.info(
"Adaptive min_periods: %d flat day(s) (CV%.0f%%) need only 1 period instead of %d",
"Adaptive min_periods: %d flat day(s) (IQR%%%.0f%%) need only 1 period instead of %d",
flat_day_count,
LOW_CV_FLAT_DAY_THRESHOLD,
LOW_IQR_PCT_FLAT_DAY_THRESHOLD,
min_periods,
)
return day_effective_min, flat_day_count
def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-day relaxation requires many parameters and branches
def calculate_periods_with_relaxation(
all_prices: list[dict],
*,
config: TibberPricesPeriodConfig,
@ -526,18 +563,29 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
should_show_callback: Callable[[str | None], bool],
time: TibberPricesTimeService,
config_entry: Any, # ConfigEntry type
day_patterns_by_date: dict | None = None,
time_range: tuple[datetime, datetime] | None = None,
) -> dict[str, Any]:
"""
Calculate periods with optional per-day filter relaxation.
Calculate periods with optional global filter relaxation and per-day target tracking.
NEW: Each day gets its own independent relaxation loop. Today can be in Phase 1
while tomorrow is in Phase 3, ensuring each day finds enough periods.
Strategy: a single global relaxation loop iterates flex levels (3% steps from
the configured base flex up to MAX_FLEX_HARD_LIMIT). At each flex level we
first re-run period detection with the configured level filter still intact.
Only if that is still insufficient do we retry the same flex with
`level_filter="any"`. After every attempt we check, per day, how many quality
periods (CV PERIOD_MAX_CV) have accumulated. Days that already meet the target
(`min_periods`) are not re-processed; the loop exits as soon as **all** days meet
their target. Days with very flat prices automatically need only 1 period
(see `_compute_day_effective_min`).
If min_periods is not reached with normal filters, this function gradually
relaxes filters in multiple phases FOR EACH DAY SEPARATELY:
If after all flex levels some days still have ZERO periods, a last-resort
`min_period_length` fallback is attempted (see `_try_min_duration_fallback`).
Phase 1: Increase flex threshold step-by-step (up to max_relaxation_attempts)
Phase 2: Disable level filter (set to "any")
Phase 1: Increase flex threshold step-by-step while preserving the configured
level filter.
Phase 2: Retry the same flex with `level_filter="any"` when a concrete level
filter is configured.
Args:
all_prices: All price data points
@ -552,6 +600,11 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
to use original configured filter values.
time: TibberPricesTimeService instance (required).
config_entry: Config entry to get display unit configuration.
day_patterns_by_date: Optional dict mapping date day pattern dict. Used for
geometric flex bonus in period detection. Passed through to calculate_periods().
time_range: Optional (start_inclusive, end_exclusive) datetime window. When set,
only intervals within [start, end) are considered as period candidates.
Passed through to calculate_periods(). Used by Phase 4 segment forcing.
Returns:
Dict with same format as calculate_periods() output:
@ -561,12 +614,8 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
"""
# Import here to avoid circular dependency
from .core import ( # noqa: PLC0415
calculate_periods,
)
from .period_building import ( # noqa: PLC0415
filter_superseded_periods,
)
from .core import calculate_periods # noqa: PLC0415
from .period_building import filter_superseded_periods # noqa: PLC0415
# Compact INFO-level summary
period_type = "PEAK PRICE" if config.reverse_sort else "BEST PRICE"
@ -637,7 +686,6 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
"relaxation_active": False,
"relaxation_attempted": False,
"min_periods_requested": min_periods if enable_relaxation else 0,
"periods_found": 0,
},
},
"reference_data": {},
@ -669,7 +717,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
any_normal_day = False
for day_prices in prices_by_day.values():
prices = [float(p["total"]) for p in day_prices if p.get("total") is not None]
if len(prices) >= 2: # noqa: PLR2004
if len(prices) >= 2:
day_min = min(prices)
day_avg = sum(prices) / len(prices)
span = abs(day_avg - day_min)
@ -709,7 +757,9 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
# === BASELINE CALCULATION (process ALL prices together, including yesterday) ===
# Periods that ended before yesterday will be filtered out later by filter_periods_by_end_date()
# This keeps yesterday/today/tomorrow periods in the cache
baseline_result = calculate_periods(all_prices, config=config, time=time)
baseline_result = calculate_periods(
all_prices, config=config, time=time, day_patterns_by_date=day_patterns_by_date, time_range=time_range
)
all_periods = baseline_result["periods"]
# Count periods per day for min_periods check
@ -765,6 +815,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
baseline_periods=all_periods,
time=time,
config_entry=config_entry,
day_patterns_by_date=day_patterns_by_date,
)
all_periods = relaxed_result["periods"]
@ -793,8 +844,11 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
fallback_result, fallback_metadata = _try_min_duration_fallback(
config=config,
existing_periods=all_periods,
all_prices=all_prices,
prices_by_day=prices_by_day,
time=time,
max_relaxation_attempts=max_relaxation_attempts,
day_patterns_by_date=day_patterns_by_date,
)
if fallback_result:
@ -811,10 +865,12 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
days_meeting_requirement += 1
elif enable_relaxation:
filter_combination_count = 2 if config.level_filter not in (None, "any") else 1
_LOGGER_DETAILS.debug(
"%sAll %d days met target with baseline - no relaxation needed",
"%sRelaxation strategy: 3%% fixed flex increment per step (%d flex levels x %d filter combinations)",
INDENT_L1,
total_days,
filter_combination_count,
)
# Sort periods by start time
@ -835,8 +891,6 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
final_result = baseline_result.copy()
final_result["periods"] = all_periods
total_periods = len(all_periods)
# Add relaxation info to metadata
if "metadata" not in final_result:
final_result["metadata"] = {}
@ -844,7 +898,6 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
"relaxation_active": relaxation_was_needed,
"relaxation_attempted": relaxation_was_needed,
"min_periods_requested": min_periods,
"periods_found": total_periods,
"phases_used": list(set(all_phases_used)), # Unique phases used across all days
"days_processed": total_days,
"days_meeting_requirement": days_meeting_requirement,
@ -855,7 +908,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
return final_result
def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation requires many parameters and statements
def relax_all_prices(
all_prices: list[dict],
config: TibberPricesPeriodConfig,
min_periods: int,
@ -865,14 +918,16 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
*,
time: TibberPricesTimeService,
config_entry: Any, # ConfigEntry type
day_patterns_by_date: dict | None = None,
) -> tuple[dict[str, Any], dict[str, Any]]:
"""
Relax filters for all prices until min_periods per day is reached.
Strategy: Try increasing flex by 3% increments, then relax level filter.
Processes all prices together (yesterday+today+tomorrow), allowing periods
to cross midnight boundaries. Returns when ALL days have min_periods
(or max attempts exhausted).
Strategy: Try increasing flex by 3% increments while keeping the configured
level filter. For each flex level, optionally retry with `level_filter="any"`
when a concrete level filter is configured. Processes all prices together
(yesterday+today+tomorrow), allowing periods to cross midnight boundaries.
Returns when ALL days have min_periods (or max attempts exhausted).
Args:
all_prices: All price intervals (yesterday+today+tomorrow).
@ -883,22 +938,26 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
baseline_periods: Baseline periods (before relaxation).
time: TibberPricesTimeService instance.
config_entry: Config entry to get display unit configuration.
day_patterns_by_date: Optional dict mapping date day pattern dict. Used for
geometric flex bonus in period detection. Passed through to calculate_periods().
Returns:
Tuple of (result_dict, metadata_dict)
"""
# Import here to avoid circular dependency
from .core import ( # noqa: PLC0415
calculate_periods,
)
from .core import calculate_periods # noqa: PLC0415
flex_increment = 0.03 # 3% per step (hard-coded for reliability)
flex_increment = RELAXATION_FLEX_INCREMENT # 3% per step (see types.py for rationale)
base_flex = abs(config.flex)
original_level_filter = config.level_filter
existing_periods = list(baseline_periods) # Start with baseline
phases_used = []
filter_variants: list[tuple[str | None, str | None]] = [(None, original_level_filter)]
if original_level_filter not in (None, "any"):
filter_variants.append(("any", "any"))
# Get available days from prices for checking
prices_by_day = group_prices_by_day(all_prices, time=time)
total_days = len(prices_by_day)
@ -916,14 +975,17 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
)
break
for level_override, applied_level_filter in filter_variants:
phase_label = f"flex={current_flex * 100:.1f}%"
phase_label_full = phase_label
if applied_level_filter is not None:
phase_label_full = f"{phase_label} +level_{applied_level_filter}"
# Skip this flex level if callback says not to show it
if not should_show_callback(phase_label):
# The callback expects a level override (e.g. None or "any"), not a flex label.
if not should_show_callback(level_override):
continue
# Try current flex with level="any" (in relaxation mode)
if original_level_filter != "any":
if level_override == "any" and original_level_filter not in (None, "any"):
_LOGGER_DETAILS.debug(
"%s Flex=%.1f%%: OVERRIDING level_filter: %s → ANY",
INDENT_L2,
@ -934,10 +996,9 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
# NOTE: config.flex is already normalized to positive by get_period_config()
relaxed_config = config._replace(
flex=current_flex, # Already positive from normalization
level_filter="any",
level_filter=applied_level_filter,
)
phase_label_full = f"flex={current_flex * 100:.1f}% +level_any"
_LOGGER_DETAILS.debug(
"%s Trying %s: config has %d intervals (all days together), level_filter=%s",
INDENT_L2,
@ -947,7 +1008,12 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
)
# Process ALL prices together (allows midnight crossing)
result = calculate_periods(all_prices, config=relaxed_config, time=time)
result = calculate_periods(
all_prices,
config=relaxed_config,
time=time,
day_patterns_by_date=day_patterns_by_date,
)
new_periods = result["periods"]
_LOGGER_DETAILS.debug(
@ -970,6 +1036,9 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
combined, standalone_count = resolve_period_overlaps(
existing_periods=existing_periods,
new_relaxed_periods=new_periods,
all_prices=all_prices,
config=config,
time=time,
)
# Count periods per day with QUALITY GATE check
@ -1002,6 +1071,9 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
)
break
if days_meeting_requirement >= total_days:
break
# Build final result
final_result = (
result.copy() if "result" in locals() else {"periods": baseline_periods, "metadata": {}, "reference_data": {}}
@ -1010,5 +1082,4 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
return final_result, {
"phases_used": phases_used,
"periods_found": len(existing_periods),
}

View file

@ -0,0 +1,425 @@
"""
Shape-based period extension: extend periods into adjacent cheap/expensive intervals.
After periods are identified by the core algorithm, this module optionally extends
each period's boundaries to include any directly-adjacent intervals that carry a
favourable price level relevant to the period type:
- Best price periods extend into VERY_CHEAP neighbours; fall back to CHEAP
on each side where no VERY_CHEAP neighbour exists.
- Peak price periods extend into VERY_EXPENSIVE neighbours; fall back to
EXPENSIVE on each side where no VERY_EXPENSIVE exists.
The fallback is evaluated **per side independently**: one side may extend via
VERY_CHEAP while the other side falls back to CHEAP.
Extension is purely additive and opt-in (disabled by default). It does not affect
the core period-finding logic; periods that would not normally be found are not
created by this step.
"""
from __future__ import annotations
from datetime import timedelta
import statistics
from typing import TYPE_CHECKING, Any
from custom_components.tibber_prices.const import (
PRICE_LEVEL_CHEAP,
PRICE_LEVEL_EXPENSIVE,
PRICE_LEVEL_MAPPING,
PRICE_LEVEL_VERY_CHEAP,
PRICE_LEVEL_VERY_EXPENSIVE,
)
from custom_components.tibber_prices.utils.price import aggregate_period_levels, aggregate_period_ratings
from .period_statistics import (
calculate_aggregated_rating_difference,
calculate_period_price_diff,
calculate_period_price_statistics,
)
if TYPE_CHECKING:
from datetime import datetime
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
from .types import TibberPricesThresholdConfig
_INTERVAL_DURATION = timedelta(minutes=15)
NEGATIVE_CORE_DISABLE_EXTENSION_INTERVALS = 1
def extend_periods_for_shape(
periods: list[dict[str, Any]],
all_prices: list[dict[str, Any]],
price_context: dict[str, Any],
*,
reverse_sort: bool,
max_extension_intervals: int,
thresholds: TibberPricesThresholdConfig,
time: TibberPricesTimeService,
) -> list[dict[str, Any]]:
"""
Extend each period into adjacent cheap/expensive intervals.
For best price periods (reverse_sort=False):
Primary: extend into VERY_CHEAP neighbours.
Fallback: extend into CHEAP neighbours (per side, only if no VERY_CHEAP found).
For peak price periods (reverse_sort=True):
Primary: extend into VERY_EXPENSIVE neighbours.
Fallback: extend into EXPENSIVE neighbours (per side, only if no VERY_EXPENSIVE found).
Only intervals that are directly contiguous with the period and carry the
required level are added. At most *max_extension_intervals* are consumed on
each side independently. Period statistics are fully recalculated after
any extension.
Args:
periods: Period summary dicts from ``extract_period_summaries``.
all_prices: All enriched price intervals (yesterday + today + tomorrow).
price_context: Dict with ``ref_prices`` and ``avg_prices`` per calendar day.
reverse_sort: ``True`` for peak price, ``False`` for best price.
max_extension_intervals: Maximum extra intervals that may be added per side.
thresholds: Threshold configuration for level / rating aggregation.
time: Time-service instance used to resolve ``startsAt`` timestamps.
Returns:
Updated list of period dicts, potentially with extended boundaries and
recalculated statistics. Unmodified periods are returned as-is.
"""
if not periods or max_extension_intervals <= 0:
return periods
if reverse_sort:
primary_level = PRICE_LEVEL_VERY_EXPENSIVE
fallback_level = PRICE_LEVEL_EXPENSIVE
else:
primary_level = PRICE_LEVEL_VERY_CHEAP
fallback_level = PRICE_LEVEL_CHEAP
# Build a lookup dict: local datetime → full interval dict
interval_index: dict[datetime, dict[str, Any]] = {}
for iv in all_prices:
t = time.get_interval_time(iv)
if t is not None:
interval_index[t] = iv
return [
_extend_period_edges(
period,
interval_index,
primary_level=primary_level,
fallback_level=fallback_level,
max_intervals=max_extension_intervals,
thresholds=thresholds,
price_context=price_context,
)
for period in periods
]
# ── private helpers ────────────────────────────────────────────────────────────
def _walk_contiguous(
interval_index: dict[datetime, dict[str, Any]],
start_cursor: datetime,
step: timedelta,
target_level: str,
max_intervals: int,
) -> list[dict[str, Any]]:
"""
Walk contiguously from *start_cursor* in direction *step*, collecting intervals.
Stops when the next interval is missing from the index, does not carry
*target_level*, or the *max_intervals* cap is reached.
Args:
interval_index: Lookup map of ``{starts_at_datetime: interval_dict}``.
start_cursor: First position to check (already offset from the period edge).
step: ``+_INTERVAL_DURATION`` for rightward, ``-_INTERVAL_DURATION`` for leftward.
target_level: Required ``level`` value (e.g. ``"VERY_CHEAP"``).
max_intervals: Maximum intervals to collect.
Returns:
Collected intervals in chronological order (reversed for leftward walks).
"""
additions: list[dict[str, Any]] = []
cursor = start_cursor
for _ in range(max_intervals):
iv = interval_index.get(cursor)
if iv is None or iv.get("level") != target_level:
break
additions.append(iv)
cursor += step
# For leftward walks the list was built newest-first; reverse to chronological
if step < timedelta(0):
additions.reverse()
return additions
def _fallback_blocked_by_majority(
intervals: list[dict[str, Any]],
primary_level: str,
fallback_level: str,
) -> bool:
"""Return ``True`` when fallback extension should be suppressed.
If *primary_level* intervals strictly outnumber *fallback_level* intervals
in the existing period, the period's character is predominantly primary.
Extending with *fallback_level* would dilute that character; the geometric
flex bonus of the core algorithm provides a better boundary in that case.
Args:
intervals: Existing period interval list.
primary_level: Preferred level (``VERY_CHEAP`` / ``VERY_EXPENSIVE``).
fallback_level: Extension candidate level (``CHEAP`` / ``EXPENSIVE``).
Returns:
``True`` if fallback extension should be blocked.
"""
primary_count = sum(1 for iv in intervals if iv.get("level") == primary_level)
fallback_count = sum(1 for iv in intervals if iv.get("level") == fallback_level)
return primary_count > fallback_count
def _is_spike_adjacent(
beyond_iv: dict[str, Any] | None,
fallback_level: str,
reverse_sort: bool,
) -> bool:
"""Return ``True`` when the interval just outside the extension is a spike.
If the interval immediately beyond the last collected fallback extension is
"worse" than *fallback_level* (more expensive for best-price, cheaper for
peak-price), the extension intervals form a ramp leading into a spike and
should be discarded.
Args:
beyond_iv: Interval dict just outside the collected extension, or ``None``.
fallback_level: The level used for the fallback extension.
reverse_sort: ``True`` for peak-price, ``False`` for best-price.
Returns:
``True`` if the extension should be dropped.
"""
if beyond_iv is None:
return False
beyond_level = beyond_iv.get("level")
if beyond_level is None:
return False
fallback_value = PRICE_LEVEL_MAPPING.get(fallback_level, 0)
beyond_value = PRICE_LEVEL_MAPPING.get(beyond_level, 0)
if reverse_sort:
# Peak: "worse" means cheaper than the extension level
return beyond_value < fallback_value
# Best: "worse" means more expensive than the extension level
return beyond_value > fallback_value
def _extend_period_edges(
period: dict[str, Any],
interval_index: dict[datetime, dict[str, Any]],
*,
primary_level: str,
fallback_level: str,
max_intervals: int,
thresholds: TibberPricesThresholdConfig,
price_context: dict[str, Any],
) -> dict[str, Any]:
"""
Consume adjacent intervals on both edges of a period.
Each side is evaluated independently:
1. Try extending into *primary_level* neighbours (VERY_CHEAP / VERY_EXPENSIVE).
2. If no primary-level neighbours were found on that side, fall back to
*fallback_level* neighbours (CHEAP / EXPENSIVE).
The original period dict is never mutated; a new dict is returned.
If no extension is possible on either side, the original dict is returned.
Args:
period: Period summary dict with ``start`` and ``end`` datetime keys.
interval_index: Lookup map of ``{starts_at_datetime: interval_dict}``.
primary_level: Preferred level (``"VERY_CHEAP"`` or ``"VERY_EXPENSIVE"``).
fallback_level: Fallback level (``"CHEAP"`` or ``"EXPENSIVE"``).
max_intervals: Maximum intervals that may be added on each side.
thresholds: Threshold config for aggregation helpers.
price_context: Reference prices / averages per calendar day.
Returns:
Extended (or original) period summary dict.
"""
start: datetime = period["start"]
end: datetime = period["end"]
# ``end`` is the exclusive boundary: the last included interval starts at
# ``end - _INTERVAL_DURATION``.
reverse_sort = primary_level == PRICE_LEVEL_VERY_EXPENSIVE
backward_step = -_INTERVAL_DURATION
forward_step = _INTERVAL_DURATION
# Collect original intervals early needed for the majority gate below.
original_intervals = _collect_original_intervals(start, end, interval_index)
# Negative-price best-price periods use dedicated core/shoulder handling earlier
# in the pipeline. Do not widen them again here just because adjacent intervals
# are labelled VERY_CHEAP/CHEAP.
if not reverse_sort and _contains_negative_core(original_intervals):
return period
# ── walk LEFT (earlier than period start) ─────────────────────────────────
left_cursor = start - _INTERVAL_DURATION
left_additions = _walk_contiguous(interval_index, left_cursor, backward_step, primary_level, max_intervals)
left_used_fallback = False
if not left_additions:
# Fallback: only if the period interior is not predominantly primary_level.
# When primary_level (e.g. VERY_CHEAP) strictly outnumbers fallback_level
# (e.g. CHEAP) inside the period, adding fallback edges dilutes the
# period's character. Rely on the geometric flex bonus instead.
if not _fallback_blocked_by_majority(original_intervals, primary_level, fallback_level):
left_additions = _walk_contiguous(interval_index, left_cursor, backward_step, fallback_level, max_intervals)
left_used_fallback = bool(left_additions)
# Look-beyond guard (fallback only): if the interval immediately outside the
# collected extensions is worse than fallback_level (e.g. a price spike just
# before a run of CHEAP intervals), those intervals form a ramp into the spike
# and should not be included.
if left_used_fallback:
one_beyond_left = start - _INTERVAL_DURATION * (len(left_additions) + 1)
if _is_spike_adjacent(interval_index.get(one_beyond_left), fallback_level, reverse_sort):
left_additions = []
# ── walk RIGHT (later than period end) ────────────────────────────────────
right_additions = _walk_contiguous(interval_index, end, forward_step, primary_level, max_intervals)
right_used_fallback = False
if not right_additions:
# Fallback: same majority gate as left side.
if not _fallback_blocked_by_majority(original_intervals, primary_level, fallback_level):
right_additions = _walk_contiguous(interval_index, end, forward_step, fallback_level, max_intervals)
right_used_fallback = bool(right_additions)
# Look-beyond guard (fallback only).
if right_used_fallback:
one_beyond_right = end + _INTERVAL_DURATION * len(right_additions)
if _is_spike_adjacent(interval_index.get(one_beyond_right), fallback_level, reverse_sort):
right_additions = []
total_added = len(left_additions) + len(right_additions)
if total_added == 0:
return period
# ── rebuild full interval list for the extended period ────────────────────
all_period_intervals = left_additions + original_intervals + right_additions
# ── recalculate boundaries ────────────────────────────────────────────────
new_start = start - _INTERVAL_DURATION * len(left_additions)
new_end = end + _INTERVAL_DURATION * len(right_additions)
new_duration_minutes = int((new_end - new_start).total_seconds() // 60)
new_interval_count = len(all_period_intervals)
# ── recalculate price statistics ──────────────────────────────────────────
price_stats = calculate_period_price_statistics(all_period_intervals)
period_price_diff, period_price_diff_pct = calculate_period_price_diff(
price_stats["price_mean"], new_start, price_context
)
rating_diff_pct = calculate_aggregated_rating_difference(all_period_intervals)
# ── recalculate level / rating aggregates ─────────────────────────────────
new_level = aggregate_period_levels(all_period_intervals)
new_rating: str | None = None
if thresholds.threshold_low is not None and thresholds.threshold_high is not None:
new_rating, _ = aggregate_period_ratings(
all_period_intervals,
thresholds.threshold_low,
thresholds.threshold_high,
)
# ── recalculate volatility (coefficient of variation) ────────────────────
prices_for_vol = [float(p["total"]) for p in all_period_intervals if "total" in p]
cv_pct: float | None = None
if len(prices_for_vol) >= 2:
mean_p = statistics.mean(prices_for_vol)
if mean_p > 0:
cv_pct = round(statistics.stdev(prices_for_vol) / mean_p * 100, 1)
# ── assemble updated period dict (keep structural fields, update statistics) ─
updated: dict[str, Any] = {
**period,
# Time fields
"start": new_start,
"end": new_end,
"duration_minutes": new_duration_minutes,
# Core decision attributes
"level": new_level,
"rating_level": new_rating,
"rating_difference_%": rating_diff_pct,
# Price statistics
"price_mean": price_stats["price_mean"],
"price_median": price_stats["price_median"],
"price_min": price_stats["price_min"],
"price_max": price_stats["price_max"],
"price_spread": price_stats["price_spread"],
"price_coefficient_variation_%": cv_pct,
# Detail
"period_interval_count": new_interval_count,
# Extension metadata
"extension_intervals_added": total_added,
}
# Refresh period price diff (replaces old value from base period)
if reverse_sort:
updated.pop("period_price_diff_from_daily_min", None)
updated.pop("period_price_diff_from_daily_min_%", None)
if period_price_diff is not None:
updated["period_price_diff_from_daily_max"] = period_price_diff
if period_price_diff_pct is not None:
updated["period_price_diff_from_daily_max_%"] = period_price_diff_pct
else:
updated.pop("period_price_diff_from_daily_max", None)
updated.pop("period_price_diff_from_daily_max_%", None)
if period_price_diff is not None:
updated["period_price_diff_from_daily_min"] = period_price_diff
if period_price_diff_pct is not None:
updated["period_price_diff_from_daily_min_%"] = period_price_diff_pct
return updated
def _collect_original_intervals(
start: datetime,
end: datetime,
interval_index: dict[datetime, dict[str, Any]],
) -> list[dict[str, Any]]:
"""Reconstruct the ordered interval list for an existing period from the index."""
result: list[dict[str, Any]] = []
cursor = start
while cursor < end:
iv = interval_index.get(cursor)
if iv is not None:
result.append(iv)
cursor += _INTERVAL_DURATION
return result
def _contains_negative_core(intervals: list[dict[str, Any]]) -> bool:
"""Return True when the period contains at least one negative/zero-price interval."""
negative_run = 0
for interval in intervals:
if float(interval.get("total", 0.0)) <= 0:
negative_run += 1
if negative_run >= NEGATIVE_CORE_DISABLE_EXTENSION_INTERVALS:
return True
else:
negative_run = 0
return False

View file

@ -2,7 +2,7 @@
from __future__ import annotations
from typing import TYPE_CHECKING, NamedTuple
from typing import TYPE_CHECKING, NamedTuple, TypedDict
if TYPE_CHECKING:
from datetime import datetime
@ -22,17 +22,49 @@ from custom_components.tibber_prices.const import (
# Period with prices 0.5-1.0 kr has ~30% CV which would be rejected
PERIOD_MAX_CV = 25.0 # 25% max coefficient of variation within a period
# Cross-Day Extension: Time window constants
# When a period ends late in the day and tomorrow data is available,
# we can extend it past midnight if prices remain favorable
CROSS_DAY_LATE_PERIOD_START_HOUR = 20 # Consider periods starting at 20:00 or later for extension
CROSS_DAY_MAX_EXTENSION_HOUR = 8 # Don't extend beyond 08:00 next day (covers typical night low)
# Low absolute price threshold for quality gate bypass (in major currency unit, e.g. EUR/NOK)
# When the MEAN price of a period is below this level, the CV quality gate is bypassed.
# Relative CV is unreliable at very low absolute prices: a range of 1-4 ct shows CV≈50%
# but is practically homogeneous from a cost perspective.
# Value: 10 ct / 100 = 0.10 EUR/NOK
LOW_PRICE_QUALITY_BYPASS_THRESHOLD = 0.10 # EUR/NOK major unit (= 10 ct/øre)
# Cross-Day Bridging: Merge periods separated by the midnight boundary
# When two independently qualifying periods exist on both sides of midnight,
# separated only by a small gap (artifact of per-day reference price changes),
# merge them into a single period.
# Key principle: requires periods on BOTH sides — a period ending at 21:30
# will not be bridged because it ended naturally, not due to midnight.
CROSS_DAY_MAX_BRIDGE_GAP_INTERVALS = 4 # Max gap: 4 intervals (1 hour) to bridge across midnight
CROSS_DAY_EARLY_MORNING_HOUR = 8 # Don't extend beyond 08:00 next day (covers typical night low)
# Cross-Day Supersession: When tomorrow data arrives, late-night periods that are
# worse than early-morning tomorrow periods become obsolete
# A today period is "superseded" if tomorrow has a significantly better alternative
# worse than early-morning tomorrow periods become obsolete.
# A today period is "superseded" if tomorrow has a significantly better alternative.
# Uses START hour (not end hour) because we want to catch periods starting late evening.
CROSS_DAY_SUPERSESSION_START_HOUR = 20 # Periods starting at 20:00+ can be superseded by tomorrow
SUPERSESSION_PRICE_IMPROVEMENT_PCT = 10.0 # Tomorrow must be at least 10% cheaper to supersede
# Peak Price Quality: Minimum premium above daily average to qualify as genuine peak
# A peak period whose mean price is barely above the daily average is likely a
# cross-day artifact rather than a genuine high-price window.
# Example: daily_avg=28ct, premium=10% → peak must average ≥ 30.8ct
PEAK_MIN_PREMIUM_ABOVE_AVG_PCT = 10.0 # Peak mean must be ≥ 10% above daily average
# Cross-Day Boundary Validation: overnight intervals must pass dual-day check
# For peak periods, intervals between 00:00 and this hour must ALSO qualify
# against the previous day's reference price. This prevents artifacts where
# overnight prices (e.g., 30ct) become "peak" against tomorrow's lower max
# but weren't peak against today's higher max.
CROSS_DAY_OVERNIGHT_VALIDATION_HOUR = 6 # Validate 00:00-05:59 against previous day too
# Relaxation flex increment per step (decimal, e.g. 0.03 = 3% per step).
# Hard-coded for reliability and predictability across all callers (see
# docs/developer/docs/period-calculation-theory.md). Keeps escalation moderate
# even when the user configures a high base flex (a high base would otherwise
# cause runaway escalation, e.g. base 40% × 1.25 → 50% in a single step).
RELAXATION_FLEX_INCREMENT = 0.03
# Log indentation levels for visual hierarchy
INDENT_L0 = "" # Top level (calculate_periods_with_relaxation)
INDENT_L1 = " " # Per-day loop
@ -56,6 +88,11 @@ class TibberPricesPeriodConfig(NamedTuple):
threshold_volatility_very_high: float = DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH
level_filter: str | None = None # "any", "cheap", "expensive", etc. or None
gap_count: int = 0 # Number of allowed consecutive deviating intervals
extend_to_extreme: bool = False # Extend periods into adjacent VERY_CHEAP/VERY_EXPENSIVE intervals
max_extension_intervals: int = 0 # Max intervals this extension may add per side (0 = disabled)
geometric_extra_flex: float = 0.0 # Extra flex (decimal) for intervals inside the valley/peak zone (0.0 = disabled)
segment_forcing: bool = False # Force at least segment_min_periods in each W/M-shape segment
segment_min_periods: int = 1 # Minimum periods required per segment when segment_forcing is True
class TibberPricesPeriodData(NamedTuple):
@ -104,3 +141,60 @@ class TibberPricesIntervalCriteria(NamedTuple):
flex: float
min_distance_from_avg: float
reverse_sort: bool
# ─── Day pattern constants ─────────────────────────────────────────────────────
DAY_PATTERN_VALLEY = "valley" # Single price minimum (U/V-shape)
DAY_PATTERN_PEAK = "peak" # Single price maximum (Λ-shape)
DAY_PATTERN_DOUBLE_DIP = "double_dip" # Two minima, W-shape
DAY_PATTERN_DUCK_CURVE = "duck_curve" # Two peaks with midday valley (solar duck curve)
DAY_PATTERN_FLAT = "flat" # No significant variation
DAY_PATTERN_RISING = "rising" # Persistently rising throughout the day
DAY_PATTERN_FALLING = "falling" # Persistently falling throughout the day
DAY_PATTERN_MIXED = "mixed" # Multiple extrema with no clear pattern
# Ordered list used to populate SensorDeviceClass.ENUM options=
ALL_DAY_PATTERNS: list[str] = [
DAY_PATTERN_VALLEY,
DAY_PATTERN_PEAK,
DAY_PATTERN_DOUBLE_DIP,
DAY_PATTERN_DUCK_CURVE,
DAY_PATTERN_FLAT,
DAY_PATTERN_RISING,
DAY_PATTERN_FALLING,
DAY_PATTERN_MIXED,
]
# Segment type constants
DAY_SEGMENT_RISING = "rising"
DAY_SEGMENT_FALLING = "falling"
DAY_SEGMENT_FLAT = "flat"
# ─── Day pattern TypedDicts ────────────────────────────────────────────────────
class SegmentDict(TypedDict):
"""One monotone price segment within a calendar day."""
type: str # "rising" | "falling" | "flat"
start: str | None # ISO datetime of first interval in segment
end: str | None # ISO datetime of last interval in segment
price_min: float # Minimum price in segment
price_max: float # Maximum price in segment
price_mean: float # Mean price in segment
class DayPatternDict(TypedDict):
"""Detected price pattern for one calendar day."""
pattern: str # One of the DAY_PATTERN_* constants
confidence: float # 0.0 - 1.0
day_cv_percent: float # Coefficient of variation for the day (%)
segments: list[SegmentDict] # Monotone segments
extreme_time: str | None # ISO datetime of primary extremum (valley/peak)
valley_start: str | None # ISO datetime of left knee (VALLEY pattern only)
valley_end: str | None # ISO datetime of right knee (VALLEY pattern only)
peak_start: str | None # ISO datetime of left knee (PEAK pattern only)
peak_end: str | None # ISO datetime of right knee (PEAK pattern only)

View file

@ -7,6 +7,7 @@ gap tolerance, and coordination of the period_handlers calculation functions.
from __future__ import annotations
from datetime import date, timedelta
import logging
from typing import TYPE_CHECKING, Any
@ -19,10 +20,7 @@ if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
from .helpers import get_intervals_for_day_offsets
from .period_handlers import (
TibberPricesPeriodConfig,
calculate_periods_with_relaxation,
)
from .period_handlers import TibberPricesPeriodConfig, calculate_periods_with_relaxation
_LOGGER = logging.getLogger(__name__)
@ -76,6 +74,54 @@ class TibberPricesPeriodCalculator:
section = self.config_entry.options.get(config_section, {})
return section.get(config_key, default)
def _normalize_float_option(
self,
value: Any,
default: float,
*,
option_name: str,
absolute: bool = False,
divisor: float = 1.0,
) -> float:
"""Normalize numeric config values and fall back cleanly on invalid input."""
try:
normalized = float(value)
except TypeError, ValueError:
self._log("warning", "Invalid numeric option %s=%r, using default %s", option_name, value, default)
normalized = float(default)
if absolute:
normalized = abs(normalized)
return normalized / divisor
def _normalize_int_option(
self,
value: Any,
default: int,
*,
option_name: str,
minimum: int | None = None,
) -> int:
"""Normalize integer config values and fall back cleanly on invalid input."""
try:
normalized = int(value)
except TypeError, ValueError:
self._log("warning", "Invalid integer option %s=%r, using default %s", option_name, value, default)
return default
if minimum is not None and normalized < minimum:
self._log(
"warning",
"Out-of-range integer option %s=%r, using default %s",
option_name,
value,
default,
)
return default
return normalized
def _log(self, level: str, message: str, *args: object, **kwargs: object) -> None:
"""Log with calculator-specific prefix."""
prefixed_message = f"{self._log_prefix} {message}"
@ -90,12 +136,12 @@ class TibberPricesPeriodCalculator:
self._last_periods_hash = None
self._log("debug", "Period config cache and calculation cache invalidated")
def _compute_periods_hash(self, price_info: dict[str, Any]) -> str:
def _compute_periods_hash(self, price_info: list[dict[str, Any]]) -> str:
"""
Compute hash of price data and config for period calculation caching.
Only includes data that affects period calculation:
- All interval timestamps and enriched rating levels (yesterday/today/tomorrow)
- Today/tomorrow interval content (timestamps, totals, levels, ratings, differences)
- Period calculation config (flex, min_distance, min_period_length)
- Level filter overrides
@ -103,20 +149,42 @@ class TibberPricesPeriodCalculator:
Hash string for cache key comparison.
"""
# Get today and tomorrow intervals for hash calculation
# CRITICAL: Only today+tomorrow needed in hash because:
# 1. Mitternacht: "today" startsAt changes → cache invalidates
# 2. Tomorrow arrival: "tomorrow" startsAt changes from None → cache invalidates
# 3. Yesterday/day-before-yesterday are static (rating_levels don't change retroactively)
# 4. Using first startsAt as representative (changes → entire day changed)
# Get today and tomorrow intervals for hash calculation.
# Hash full interval signatures instead of only the first startsAt so we also
# invalidate when prices or enriched levels change within the same calendar day.
coordinator_data = {"priceInfo": price_info}
today_intervals = get_intervals_for_day_offsets(coordinator_data, [0])
tomorrow_intervals = get_intervals_for_day_offsets(coordinator_data, [1])
# Use first startsAt of each day as representative for entire day's data
# If day is empty, use None (detects data availability changes)
today_start = today_intervals[0].get("startsAt") if today_intervals else None
tomorrow_start = tomorrow_intervals[0].get("startsAt") if tomorrow_intervals else None
def _build_interval_signature(intervals: list[dict[str, Any]]) -> tuple[tuple[Any, Any, Any, Any, Any], ...]:
signature: list[tuple[Any, Any, Any, Any, Any]] = []
for interval in intervals:
starts_at = interval.get("startsAt")
starts_at_key = (
starts_at.isoformat() if starts_at is not None and hasattr(starts_at, "isoformat") else starts_at
)
total = interval.get("total")
total_key = round(float(total), 6) if total is not None else None
difference = interval.get("difference")
difference_key = round(float(difference), 6) if difference is not None else None
signature.append(
(
starts_at_key,
total_key,
interval.get("level"),
interval.get("rating_level"),
difference_key,
)
)
return tuple(signature)
today_signature = _build_interval_signature(today_intervals)
tomorrow_signature = _build_interval_signature(tomorrow_intervals)
# Get period configs (both best and peak)
best_config = self.get_period_config(reverse_sort=False)
@ -130,8 +198,8 @@ class TibberPricesPeriodCalculator:
# Compute hash from all relevant data
hash_data = (
today_start, # Representative for today's data (changes at midnight)
tomorrow_start, # Representative for tomorrow's data (changes when data arrives)
today_signature,
tomorrow_signature,
tuple(best_config.items()),
tuple(peak_config.items()),
best_level_filter,
@ -197,34 +265,159 @@ class TibberPricesPeriodCalculator:
_const.DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH,
)
# Convert flex from percentage to decimal (e.g., 5 -> 0.05)
# CRITICAL: Normalize to absolute value for internal calculations
# User-facing values use sign convention:
# - Best price: positive (e.g., +15% above minimum)
# - Peak price: negative (e.g., -20% below maximum)
# Internal calculations always use positive values with reverse_sort flag
try:
flex = abs(float(flex)) / 100 # Always positive internally
except (TypeError, ValueError):
flex = (
abs(_const.DEFAULT_BEST_PRICE_FLEX) / 100
if not reverse_sort
else abs(_const.DEFAULT_PEAK_PRICE_FLEX) / 100
default_flex = _const.DEFAULT_PEAK_PRICE_FLEX if reverse_sort else _const.DEFAULT_BEST_PRICE_FLEX
default_min_distance = (
_const.DEFAULT_PEAK_PRICE_MIN_DISTANCE_FROM_AVG
if reverse_sort
else _const.DEFAULT_BEST_PRICE_MIN_DISTANCE_FROM_AVG
)
default_min_period_length = (
_const.DEFAULT_PEAK_PRICE_MIN_PERIOD_LENGTH if reverse_sort else _const.DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH
)
# CRITICAL: Normalize min_distance_from_avg to absolute value
# User-facing values use sign convention:
# - Best price: negative (e.g., -5% below average)
# - Peak price: positive (e.g., +5% above average)
# Internal calculations always use positive values with reverse_sort flag
min_distance_from_avg_normalized = abs(float(min_distance_from_avg))
# Convert flex from percentage to decimal (e.g., 5 -> 0.05)
# and normalize sign conventions to positive internal values.
flex = self._normalize_float_option(
flex,
default_flex,
option_name=_const.CONF_PEAK_PRICE_FLEX if reverse_sort else _const.CONF_BEST_PRICE_FLEX,
absolute=True,
divisor=100,
)
# CRITICAL: Normalize min_distance_from_avg to absolute value.
min_distance_from_avg_normalized = self._normalize_float_option(
min_distance_from_avg,
default_min_distance,
option_name=(
_const.CONF_PEAK_PRICE_MIN_DISTANCE_FROM_AVG
if reverse_sort
else _const.CONF_BEST_PRICE_MIN_DISTANCE_FROM_AVG
),
absolute=True,
)
config = {
"flex": flex,
"min_distance_from_avg": min_distance_from_avg_normalized,
"min_period_length": int(min_period_length),
"min_period_length": self._normalize_int_option(
min_period_length,
default_min_period_length,
option_name=(
_const.CONF_PEAK_PRICE_MIN_PERIOD_LENGTH
if reverse_sort
else _const.CONF_BEST_PRICE_MIN_PERIOD_LENGTH
),
minimum=1,
),
}
# Extension settings (stored in 'extension_settings' nested section)
if reverse_sort:
extend_to_extreme = bool(
self._get_option(
_const.CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE,
"extension_settings",
_const.DEFAULT_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE,
)
)
max_extension_intervals = self._normalize_int_option(
self._get_option(
_const.CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
"extension_settings",
_const.DEFAULT_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
),
_const.DEFAULT_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
option_name=_const.CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
minimum=0,
)
else:
extend_to_extreme = bool(
self._get_option(
_const.CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP,
"extension_settings",
_const.DEFAULT_BEST_PRICE_EXTEND_TO_VERY_CHEAP,
)
)
max_extension_intervals = self._normalize_int_option(
self._get_option(
_const.CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS,
"extension_settings",
_const.DEFAULT_BEST_PRICE_MAX_EXTENSION_INTERVALS,
),
_const.DEFAULT_BEST_PRICE_MAX_EXTENSION_INTERVALS,
option_name=_const.CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS,
minimum=0,
)
config["extend_to_extreme"] = extend_to_extreme
config["max_extension_intervals"] = max_extension_intervals
# Geometric flex bonus (intervals inside valley/peak zone get extra flex)
if reverse_sort:
geometric_flex_pct = self._normalize_int_option(
self._get_option(
_const.CONF_PEAK_PRICE_GEOMETRIC_FLEX,
"extension_settings",
_const.DEFAULT_PEAK_PRICE_GEOMETRIC_FLEX,
),
_const.DEFAULT_PEAK_PRICE_GEOMETRIC_FLEX,
option_name=_const.CONF_PEAK_PRICE_GEOMETRIC_FLEX,
minimum=0,
)
else:
geometric_flex_pct = self._normalize_int_option(
self._get_option(
_const.CONF_BEST_PRICE_GEOMETRIC_FLEX,
"extension_settings",
_const.DEFAULT_BEST_PRICE_GEOMETRIC_FLEX,
),
_const.DEFAULT_BEST_PRICE_GEOMETRIC_FLEX,
option_name=_const.CONF_BEST_PRICE_GEOMETRIC_FLEX,
minimum=0,
)
config["geometric_extra_flex"] = geometric_flex_pct / 100
# Segment forcing (force at least segment_min_periods per W/M-shape segment)
if reverse_sort:
segment_forcing = bool(
self._get_option(
_const.CONF_PEAK_PRICE_SEGMENT_FORCING,
"extension_settings",
_const.DEFAULT_PEAK_PRICE_SEGMENT_FORCING,
)
)
segment_min_periods = self._normalize_int_option(
self._get_option(
_const.CONF_PEAK_PRICE_SEGMENT_MIN_PERIODS,
"extension_settings",
_const.DEFAULT_PEAK_PRICE_SEGMENT_MIN_PERIODS,
),
_const.DEFAULT_PEAK_PRICE_SEGMENT_MIN_PERIODS,
option_name=_const.CONF_PEAK_PRICE_SEGMENT_MIN_PERIODS,
minimum=1,
)
else:
segment_forcing = bool(
self._get_option(
_const.CONF_BEST_PRICE_SEGMENT_FORCING,
"extension_settings",
_const.DEFAULT_BEST_PRICE_SEGMENT_FORCING,
)
)
segment_min_periods = self._normalize_int_option(
self._get_option(
_const.CONF_BEST_PRICE_SEGMENT_MIN_PERIODS,
"extension_settings",
_const.DEFAULT_BEST_PRICE_SEGMENT_MIN_PERIODS,
),
_const.DEFAULT_BEST_PRICE_SEGMENT_MIN_PERIODS,
option_name=_const.CONF_BEST_PRICE_SEGMENT_MIN_PERIODS,
minimum=1,
)
config["segment_forcing"] = segment_forcing
config["segment_min_periods"] = segment_min_periods
# Cache the result
self._config_cache[cache_key] = config
self._config_cache_valid = True
@ -232,7 +425,7 @@ class TibberPricesPeriodCalculator:
def should_show_periods(
self,
price_info: dict[str, Any],
price_info: list[dict[str, Any]],
*,
reverse_sort: bool,
level_override: str | None = None,
@ -241,7 +434,7 @@ class TibberPricesPeriodCalculator:
Check if periods should be shown based on level filter only.
Args:
price_info: Price information dict with today/yesterday/tomorrow data
price_info: Flat list of price intervals (yesterday/today/tomorrow)
reverse_sort: If False (best_price), checks max_level filter.
If True (peak_price), checks min_level filter.
level_override: Optional override for level filter ("any" to disable)
@ -400,16 +593,27 @@ class TibberPricesPeriodCalculator:
# Normal check failed - try splitting at gap clusters as fallback
# Get minimum period length from config (convert minutes to intervals)
period_settings = self.config_entry.options.get("period_settings", {})
if reverse_sort:
min_period_minutes = period_settings.get(
min_period_minutes = self._normalize_int_option(
self._get_option(
_const.CONF_PEAK_PRICE_MIN_PERIOD_LENGTH,
"period_settings",
_const.DEFAULT_PEAK_PRICE_MIN_PERIOD_LENGTH,
),
_const.DEFAULT_PEAK_PRICE_MIN_PERIOD_LENGTH,
option_name=_const.CONF_PEAK_PRICE_MIN_PERIOD_LENGTH,
minimum=1,
)
else:
min_period_minutes = period_settings.get(
min_period_minutes = self._normalize_int_option(
self._get_option(
_const.CONF_BEST_PRICE_MIN_PERIOD_LENGTH,
"period_settings",
_const.DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH,
),
_const.DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH,
option_name=_const.CONF_BEST_PRICE_MIN_PERIOD_LENGTH,
minimum=1,
)
min_period_intervals = self.time.minutes_to_intervals(min_period_minutes)
@ -504,7 +708,7 @@ class TibberPricesPeriodCalculator:
def check_level_filter(
self,
price_info: dict[str, Any],
price_info: list[dict[str, Any]],
*,
reverse_sort: bool,
override: str | None = None,
@ -516,7 +720,7 @@ class TibberPricesPeriodCalculator:
to deviate by one level step (e.g., CHEAP allows NORMAL, but not EXPENSIVE).
Args:
price_info: Price information dict with today data
price_info: Flat list of price intervals used for today's level check
reverse_sort: If False (best_price), checks max_level (upper bound filter).
If True (peak_price), checks min_level (lower bound filter).
override: Optional override value (e.g., "any" to disable filter)
@ -558,16 +762,27 @@ class TibberPricesPeriodCalculator:
return True # If no data, don't filter
# Get gap tolerance configuration
period_settings = self.config_entry.options.get("period_settings", {})
if reverse_sort:
max_gap_count = period_settings.get(
max_gap_count = self._normalize_int_option(
self._get_option(
_const.CONF_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
"period_settings",
_const.DEFAULT_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
),
_const.DEFAULT_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
option_name=_const.CONF_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
minimum=0,
)
else:
max_gap_count = period_settings.get(
max_gap_count = self._normalize_int_option(
self._get_option(
_const.CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
"period_settings",
_const.DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
),
_const.DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
option_name=_const.CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
minimum=0,
)
# Note: level_config is lowercase from selector, but _const.PRICE_LEVEL_MAPPING uses uppercase
@ -597,7 +812,8 @@ class TibberPricesPeriodCalculator:
def calculate_periods_for_price_info(
self,
price_info: dict[str, Any],
price_info: list[dict[str, Any]],
day_patterns: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""
Calculate periods (best price and peak price) for the given price info.
@ -622,30 +838,63 @@ class TibberPricesPeriodCalculator:
coordinator_data = {"priceInfo": price_info}
all_prices = get_intervals_for_day_offsets(coordinator_data, [-2, -1, 0, 1])
# Convert day_patterns (keyed by "yesterday"/"today"/"tomorrow") to date-keyed dict
# Needed for geometric valley/peak zone flex bonus in period calculation
today_date = self.time.now().date()
day_patterns_by_date: dict[date, dict[str, Any]] | None = (
{
today_date + timedelta(days=ofs): pat
for ofs, lbl in ((-1, "yesterday"), (0, "today"), (1, "tomorrow"))
if (pat := day_patterns.get(lbl)) is not None
}
if day_patterns
else None
)
# Get rating thresholds from config (flat in options, not in sections)
# CRITICAL: Price rating thresholds are stored FLAT in options (no sections)
threshold_low = self.config_entry.options.get(
threshold_low = self._normalize_float_option(
self.config_entry.options.get(
_const.CONF_PRICE_RATING_THRESHOLD_LOW,
_const.DEFAULT_PRICE_RATING_THRESHOLD_LOW,
),
_const.DEFAULT_PRICE_RATING_THRESHOLD_LOW,
option_name=_const.CONF_PRICE_RATING_THRESHOLD_LOW,
)
threshold_high = self.config_entry.options.get(
threshold_high = self._normalize_float_option(
self.config_entry.options.get(
_const.CONF_PRICE_RATING_THRESHOLD_HIGH,
_const.DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
),
_const.DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
option_name=_const.CONF_PRICE_RATING_THRESHOLD_HIGH,
)
# Get volatility thresholds from config (flat in options, not in sections)
# CRITICAL: Volatility thresholds are stored FLAT in options (no sections)
threshold_volatility_moderate = self.config_entry.options.get(
threshold_volatility_moderate = self._normalize_float_option(
self.config_entry.options.get(
_const.CONF_VOLATILITY_THRESHOLD_MODERATE,
_const.DEFAULT_VOLATILITY_THRESHOLD_MODERATE,
),
_const.DEFAULT_VOLATILITY_THRESHOLD_MODERATE,
option_name=_const.CONF_VOLATILITY_THRESHOLD_MODERATE,
)
threshold_volatility_high = self.config_entry.options.get(
threshold_volatility_high = self._normalize_float_option(
self.config_entry.options.get(
_const.CONF_VOLATILITY_THRESHOLD_HIGH,
_const.DEFAULT_VOLATILITY_THRESHOLD_HIGH,
),
_const.DEFAULT_VOLATILITY_THRESHOLD_HIGH,
option_name=_const.CONF_VOLATILITY_THRESHOLD_HIGH,
)
threshold_volatility_very_high = self.config_entry.options.get(
threshold_volatility_very_high = self._normalize_float_option(
self.config_entry.options.get(
_const.CONF_VOLATILITY_THRESHOLD_VERY_HIGH,
_const.DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH,
),
_const.DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH,
option_name=_const.CONF_VOLATILITY_THRESHOLD_VERY_HIGH,
)
# Get relaxation configuration for best price
@ -658,21 +907,32 @@ class TibberPricesPeriodCalculator:
)
# Check if best price periods should be shown
# If relaxation is enabled, always calculate (relaxation will try "any" filter)
# If relaxation is disabled, apply level filter check
# If relaxation is enabled, always calculate (relaxation tries configured level filter
# first, then falls back to "any" per flex step if still insufficient)
# If relaxation is disabled, apply level filter check upfront
if enable_relaxation_best:
show_best_price = bool(all_prices)
else:
show_best_price = self.should_show_periods(price_info, reverse_sort=False) if all_prices else False
min_periods_best = self._get_option(
min_periods_best = self._normalize_int_option(
self._get_option(
_const.CONF_MIN_PERIODS_BEST,
"relaxation_and_target_periods",
_const.DEFAULT_MIN_PERIODS_BEST,
),
_const.DEFAULT_MIN_PERIODS_BEST,
option_name=_const.CONF_MIN_PERIODS_BEST,
minimum=1,
)
relaxation_attempts_best = self._get_option(
relaxation_attempts_best = self._normalize_int_option(
self._get_option(
_const.CONF_RELAXATION_ATTEMPTS_BEST,
"relaxation_and_target_periods",
_const.DEFAULT_RELAXATION_ATTEMPTS_BEST,
),
_const.DEFAULT_RELAXATION_ATTEMPTS_BEST,
option_name=_const.CONF_RELAXATION_ATTEMPTS_BEST,
minimum=1,
)
# Calculate best price periods (or return empty if filtered)
@ -685,10 +945,15 @@ class TibberPricesPeriodCalculator:
"period_settings",
_const.DEFAULT_BEST_PRICE_MAX_LEVEL,
)
gap_count_best = self._get_option(
gap_count_best = self._normalize_int_option(
self._get_option(
_const.CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
"period_settings",
_const.DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
),
_const.DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
option_name=_const.CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
minimum=0,
)
best_period_config = TibberPricesPeriodConfig(
reverse_sort=False,
@ -702,6 +967,11 @@ class TibberPricesPeriodCalculator:
threshold_volatility_very_high=threshold_volatility_very_high,
level_filter=max_level_best,
gap_count=gap_count_best,
extend_to_extreme=best_config["extend_to_extreme"],
max_extension_intervals=best_config["max_extension_intervals"],
geometric_extra_flex=best_config["geometric_extra_flex"],
segment_forcing=best_config["segment_forcing"],
segment_min_periods=best_config["segment_min_periods"],
)
best_periods = calculate_periods_with_relaxation(
all_prices,
@ -716,6 +986,7 @@ class TibberPricesPeriodCalculator:
),
time=self.time,
config_entry=self.config_entry,
day_patterns_by_date=day_patterns_by_date,
)
else:
best_periods = {
@ -739,21 +1010,32 @@ class TibberPricesPeriodCalculator:
)
# Check if peak price periods should be shown
# If relaxation is enabled, always calculate (relaxation will try "any" filter)
# If relaxation is disabled, apply level filter check
# If relaxation is enabled, always calculate (relaxation tries configured level filter
# first, then falls back to "any" per flex step if still insufficient)
# If relaxation is disabled, apply level filter check upfront
if enable_relaxation_peak:
show_peak_price = bool(all_prices)
else:
show_peak_price = self.should_show_periods(price_info, reverse_sort=True) if all_prices else False
min_periods_peak = self._get_option(
min_periods_peak = self._normalize_int_option(
self._get_option(
_const.CONF_MIN_PERIODS_PEAK,
"relaxation_and_target_periods",
_const.DEFAULT_MIN_PERIODS_PEAK,
),
_const.DEFAULT_MIN_PERIODS_PEAK,
option_name=_const.CONF_MIN_PERIODS_PEAK,
minimum=1,
)
relaxation_attempts_peak = self._get_option(
relaxation_attempts_peak = self._normalize_int_option(
self._get_option(
_const.CONF_RELAXATION_ATTEMPTS_PEAK,
"relaxation_and_target_periods",
_const.DEFAULT_RELAXATION_ATTEMPTS_PEAK,
),
_const.DEFAULT_RELAXATION_ATTEMPTS_PEAK,
option_name=_const.CONF_RELAXATION_ATTEMPTS_PEAK,
minimum=1,
)
# Calculate peak price periods (or return empty if filtered)
@ -766,10 +1048,15 @@ class TibberPricesPeriodCalculator:
"period_settings",
_const.DEFAULT_PEAK_PRICE_MIN_LEVEL,
)
gap_count_peak = self._get_option(
gap_count_peak = self._normalize_int_option(
self._get_option(
_const.CONF_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
"period_settings",
_const.DEFAULT_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
),
_const.DEFAULT_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
option_name=_const.CONF_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
minimum=0,
)
peak_period_config = TibberPricesPeriodConfig(
reverse_sort=True,
@ -783,6 +1070,11 @@ class TibberPricesPeriodCalculator:
threshold_volatility_very_high=threshold_volatility_very_high,
level_filter=min_level_peak,
gap_count=gap_count_peak,
extend_to_extreme=peak_config["extend_to_extreme"],
max_extension_intervals=peak_config["max_extension_intervals"],
geometric_extra_flex=peak_config["geometric_extra_flex"],
segment_forcing=peak_config["segment_forcing"],
segment_min_periods=peak_config["segment_min_periods"],
)
peak_periods = calculate_periods_with_relaxation(
all_prices,
@ -797,6 +1089,7 @@ class TibberPricesPeriodCalculator:
),
time=self.time,
config_entry=self.config_entry,
day_patterns_by_date=day_patterns_by_date,
)
else:
peak_periods = {

View file

@ -26,8 +26,8 @@ source of truth. This module only caches user_data for daily refresh cycle.
from __future__ import annotations
import logging
from datetime import timedelta
import logging
from typing import TYPE_CHECKING, Any
from custom_components.tibber_prices.api import (
@ -71,7 +71,7 @@ class TibberPricesPriceDataManager:
This class orchestrates WHEN to fetch and processes the results.
"""
def __init__( # noqa: PLR0913
def __init__(
self,
api: TibberPricesApiClient,
store: Any,
@ -178,7 +178,7 @@ class TibberPricesPriceDataManager:
)
await cache.save_cache(self._store, cache_data, self._log_prefix)
def _validate_user_data(self, user_data: dict, home_id: str) -> bool: # noqa: PLR0911
def _validate_user_data(self, user_data: dict, home_id: str) -> bool:
"""
Validate user data completeness.

View file

@ -57,7 +57,7 @@ class TibberPricesRepairManager:
async def check_tomorrow_data_availability(
self,
has_tomorrow_data: bool, # noqa: FBT001 - Clear meaning in context
has_tomorrow_data: bool,
current_time: datetime,
) -> None:
"""

View file

@ -43,8 +43,8 @@ scheduling delays. It is NOT used for Timer #1's offset tracking.
from __future__ import annotations
import math
from datetime import datetime, timedelta
import math
from typing import TYPE_CHECKING
from homeassistant.util import dt as dt_util

View file

@ -309,13 +309,13 @@
},
"next_price_trend_change": {
"description": "Wann die nächste bedeutende Preistrend-Änderung eintreten wird",
"long_description": "Scannt die nächsten 24 Stunden (96 Intervalle), um zu finden, wann sich die Preistrend-Richtung ändern wird. Nur Richtungswechsel zählen: steigend/stark steigend bilden eine Gruppe, fallend/stark fallend eine andere, stabil ist eigenständig. Ein Wechsel von steigend zu stark steigend ist KEIN Trendwechsel. Verwendet volatilitätsadaptive Schwellwerte (Standard: ±3%/±9%) mit Hysterese (Standard: 3 aufeinanderfolgende Intervalle). Gibt den Zeitstempel zurück, wann die Änderung erwartet wird.",
"usage_tips": "Ereignisbasierte Automation: Aktionen WENN Trend wechselt auslösen, nicht IN X Stunden. Beispiel: 'E-Auto laden wenn nächste Trendänderung fallende Preise zeigt' oder 'Spülmaschine vor Preisanstieg starten'. Ergänzt Zeitfenster-Sensoren (price_trend_Xh), die beantworten 'WERDEN Preise in X Stunden höher sein?'"
"long_description": "Scannt die nächsten 24 Stunden (96 Intervalle), um zu finden, wann sich die Preistrend-Richtung ändern wird. Nur Richtungswechsel zählen: steigend/stark steigend bilden eine Gruppe, fallend/stark fallend eine andere, stabil ist eigenständig. Ein Wechsel von steigend zu stark steigend ist KEIN Trendwechsel. Verwendet volatilitätsadaptive Schwellwerte (Standard: ±3%/±9%) mit Hysterese (Standard: 3 aufeinanderfolgende Intervalle). Gibt den Zeitstempel zurück, wann die Änderung erwartet wird.\n\nWICHTIG — Wie die Erkennung funktioniert: Bei jedem zukünftigen Intervall vergleicht der Sensor den Preis dieses Intervalls mit dem DURCHSCHNITT der folgenden 3 Stunden (dem 3h-Vorschau-Mittelwert). Das bedeutet: Der Sensor erkennt, wann die durchschnittlichen Kosten der nächsten 3 Stunden die Richtung gewechselt haben — nicht wann das exakte Preisminimum oder -maximum erreicht ist.\n\nAn V-förmigen Preistagen: Beim Preisrückgang in Richtung eines Minimums beginnt das 3h-Vorschaufenster bereits Preise der steigenden Flanke einzubeziehen, bevor das eigentliche Minimum erreicht ist. Sobald diese steigenden Preise den 3h-Mittelwert über den aktuellen Preis treiben, meldet der Sensor 'Trend ändert sich jetzt'. Das passiert typischerweise 3060 Minuten vor dem exakten Preisminimum. Dies ist beabsichtigt — der Sensor beantwortet 'wann ändert sich die grundlegende RICHTUNG?' und nicht 'wann ist der exakte Wendepunkt?'.",
"usage_tips": "Ereignisbasierte Automation: Aktionen WENN Trend wechselt auslösen, nicht IN X Stunden. Beispiel: 'E-Auto laden wenn nächste Trendänderung fallende Preise zeigt' oder 'Spülmaschine vor Preisanstieg starten'. Ergänzt Zeitfenster-Sensoren (price_trend_Xh), die beantworten 'WERDEN Preise in X Stunden höher sein?'\n\nHinweis: An stark V-förmigen Preistagen kann dieser Sensor 3060 Minuten vor dem exakten Preisminimum auslösen. Wenn du einen präzisen Wendepunkt benötigst, vergleiche ihn mit dem Start der Günstigsten-Preis-Periode — diese startet beim eigentlichen günstigsten Fenster. Dieser Sensor eignet sich besser für Automations-Trigger nach dem Motto 'auf eine Änderung vorbereiten', wo eine kurze Frühwarnung akzeptabel ist."
},
"trend_change_in_minutes": {
"next_price_trend_change_in": {
"description": "Zeit bis zur nächsten Preistrend-Änderung",
"long_description": "Zeigt an, wie lange es bis zur nächsten bedeutenden Preistrend-Änderung dauert. Der Wert wird in Stunden angezeigt (z.B. 2,25 h) für Dashboards. Teilt die gleiche Analyse wie der Zeitstempel-Sensor 'Nächste Trendänderung', stellt sie aber als Countdown-Dauer dar. Aktualisiert sich jede Minute für präzise Countdowns. Zeigt 'Unbekannt' wenn keine Trendänderung in den nächsten 24 Stunden erwartet wird.",
"usage_tips": "Dashboard-Countdown: Zeige 'Trendänderung in 1,5 h' als Live-Countdown. Für Automationen: 'Wenn trend_change_in_minutes < 0,25 (15 Min), auf Preisrichtungswechsel vorbereiten'. Ergänzt 'Nächste Trendänderung' (Zeitstempel) — verwende den Zeitstempel für 'WANN' und diesen Sensor für 'WIE LANGE'."
"long_description": "Zeigt an, wie lange es bis zur nächsten bedeutenden Preistrend-Änderung dauert. Der Wert wird in Stunden angezeigt (z.B. 2,25 h) für Dashboards. Teilt die gleiche Analyse wie der Zeitstempel-Sensor 'Nächste Trendänderung', stellt sie aber als Countdown-Dauer dar. Aktualisiert sich jede Minute für präzise Countdowns. Zeigt 'Unbekannt' wenn keine Trendänderung in den nächsten 24 Stunden erwartet wird. Siehe 'Nächste Preistrend-Änderung' für eine Erklärung des 3h-Vorschau-Erkennungsmechanismus und seinem Verhalten an V-förmigen Preistagen.",
"usage_tips": "Dashboard-Countdown: Zeige 'Trendänderung in 1,5 h' als Live-Countdown. Für Automationen: 'Wenn next_price_trend_change_in < 0,25 (15 Min), auf Preisrichtungswechsel vorbereiten'. Ergänzt 'Nächste Trendänderung' (Zeitstempel) — verwende den Zeitstempel für 'WANN' und diesen Sensor für 'WIE LANGE'."
},
"daily_rating": {
"description": "Wie sich die heutigen Preise mit historischen Daten vergleichen",
@ -486,6 +486,81 @@
"long_description": "Zeigt, ob dein Tibber-Abonnement derzeit aktiv ist, beendet wurde oder auf Aktivierung wartet. Ein Status 'Aktiv' bedeutet, dass du aktiv Strom über Tibber beziehst.",
"usage_tips": "Nutze dies zur Überwachung deines Abonnementstatus. Richte Benachrichtigungen ein, wenn sich der Status von 'Aktiv' ändert, um einen unterbrechungsfreien Service sicherzustellen."
},
"day_pattern_yesterday": {
"description": "Erkanntes Preismuster der gestrigen Strompreise",
"long_description": "Klassifiziert gestern in ein Preismuster: Tal (günstig in der Mitte), Gipfel (teuer in der Mitte), Doppeltal (zwei günstige Perioden), Doppelgipfel (zwei teure Perioden), Flach (geringe Variation), Steigend, Fallend oder Gemischt. Die Konfidenz- und CV-Attribute zeigen, wie verlässlich das Muster erkannt wurde.",
"usage_tips": "Nutze das gestrige Muster für Automationen: Ein Tal-Tag wiederholt sich oft am nächsten Tag und deutet darauf hin, dass Verbraucher auf die günstigen Mittagsstunden verschoben werden sollten."
},
"day_pattern_today": {
"description": "Erkanntes Preismuster der heutigen Strompreise",
"long_description": "Klassifiziert heute in ein Preismuster: Tal (günstig mittags), Gipfel (teuer mittags), Doppeltal (W-Form), Doppelgipfel (M-Form), Flach, Steigend, Fallend oder Gemischt. Attribute enthalten Konfidenz (01), Variationskoeffizient, Knickpunktzeiten und Tagessegmente.",
"usage_tips": "Nutze das Tagesmuster, um Verbraucher zu verschieben. Tal-Tag: Spülmaschine, Waschmaschine oder E-Auto-Laden in die günstige Mittagszeit legen. Gipfel-Tag: früh morgens oder spät abends waschen. Die Attribute valley_start und valley_end ermöglichen minutengenaue Automationen."
},
"day_pattern_tomorrow": {
"description": "Erkanntes Preismuster der morgigen Strompreise",
"long_description": "Klassifiziert morgen (sobald Daten verfügbar sind, typisch nach 13 Uhr) in ein Preismuster mit demselben Algorithmus wie heute. Die Attribute valley_start/valley_end oder peak_start/peak_end geben Knickpunktzeiten für das primäre Extremum an.",
"usage_tips": "Richte Abendautomationen ein, die das morgige Muster lesen und Wärmepumpe, Autolader oder Warmwasserbereiter für den nächsten Tag vorkonfigurieren. Kombiniere mit dem tomorrow_data_available Binärsensor."
},
"current_price_phase": {
"description": "Ob die Strompreise aktuell steigen, fallen oder stabil sind innerhalb der tageszeitlichen Preisform",
"long_description": "Zeigt die Preisbewegungsrichtung zum aktuellen Zeitpunkt, indem das aktive monotone Segment der heutigen Preiskurve ermittelt wird. Der Tagesverlauf wird in aufeinanderfolgende steigende, fallende oder flache Abschnitte (Phasen) unterteilt. Dieser Sensor zeigt, in welcher Phase du dich gerade befindest. Attribute: Startzeit und Endzeit der Phase, Preisspanne (min/max/mean), Position im Tagesverlauf (segment_index und segment_count) sowie die vollständige Liste aller heutigen Phasen (all_segments). Aktualisierung alle 15 Minuten.",
"usage_tips": "Nutzen in Automationen: 'Wenn current_price_phase = fallend, flexible Lasten verschieben, bis der Preis seinen Tiefpunkt erreicht hat'. Kombiniere mit dem Sensor Heutiges Preismuster, um sowohl die Gesamtform des Tages als auch deine aktuelle Position darin zu sehen. Prüfe segment_index und segment_count: z. B. segment_index=0 und Phase=fallend bedeutet, die Preise fallen seit Mitternacht. Nutze all_segments in Templates oder Dashboards, um den vollständigen Tagesverlauf anzuzeigen."
},
"next_price_phase": {
"description": "Die nächste tageszeitliche Preisphase was nach der aktuellen Preisbewegung kommt",
"long_description": "Zeigt die monotone Preisphase, die nach der aktuell aktiven Phase folgt. Das Attribut start zeigt genau, wann die nächste Phase beginnt ideal für zeitgenaue Automationen. Wenn die aktuelle Phase die letzte des Tages ist (z.B. der abendliche Abwärtstrend), wird dieser Sensor nicht verfügbar. Attribute: start (wann sie beginnt), end, Preisspanne (min/max/mean), segment_index, segment_count. Aktualisierung alle 15 Minuten.",
"usage_tips": "Nutzen in Automationen: 'Wenn next_price_phase = steigend und next_price_phase.start in weniger als 1 Stunde, Waschmaschine jetzt starten'. Oder kombiniere mit current_price_phase: 'Wenn current_price_phase = fallend und next_price_phase = flach, nähern wir uns dem Tagestiefpunkt guter Zeitpunkt für flexible Lasten'. Das Attribut start ist besonders wertvoll: Automationen können exakt dann ausgelöst werden, wenn die nächste Phase beginnt."
},
"current_price_phase_end_time": {
"description": "When the current intra-day price phase ends",
"long_description": "Shows the exact timestamp when the currently active rising, falling, or flat price phase will end and transition to the next phase.",
"usage_tips": "Use in automations to schedule tasks that must finish before prices change."
},
"current_price_phase_remaining_minutes": {
"description": "Minutes remaining in the current price phase",
"long_description": "Shows how many minutes are left in the current intra-day price phase. Updates every minute.",
"usage_tips": "Use in automations: 'If current_price_phase = falling and remaining < 30, start the dishwasher now'."
},
"current_price_phase_duration": {
"description": "Total duration of the current price phase",
"long_description": "Shows the total length of the currently active price phase in hours.",
"usage_tips": "Combine with remaining minutes to understand how far through the phase you are."
},
"current_price_phase_progress": {
"description": "How far through the current price phase we are",
"long_description": "Shows the percentage of the current intra-day price phase that has elapsed (0100%). Updates every minute.",
"usage_tips": "Use in dashboard cards to display a visual progress bar for the current price phase."
},
"next_rising_phase_start_time": {
"description": "When the next rising price phase begins",
"long_description": "Shows the timestamp of the next upcoming rising price segment across today's remaining phases and tomorrow's phases.",
"usage_tips": "Use to schedule loads before prices start rising again."
},
"next_falling_phase_start_time": {
"description": "When the next falling price phase begins",
"long_description": "Shows the timestamp of the next upcoming falling price segment across today's remaining phases and tomorrow's phases.",
"usage_tips": "Use to delay flexible loads until the next price drop starts."
},
"next_flat_phase_start_time": {
"description": "When the next flat (stable) price phase begins",
"long_description": "Shows the timestamp of the next upcoming flat price segment across today's remaining phases and tomorrow's phases.",
"usage_tips": "Use for scheduling loads that need predictable costs over time."
},
"next_rising_phase_in_minutes": {
"description": "Minutes until the next rising price phase begins",
"long_description": "Shows how many minutes until the next rising price phase starts. Updates every minute.",
"usage_tips": "Use in countdown automations to alert before the next price rise."
},
"next_falling_phase_in_minutes": {
"description": "Minutes until the next falling price phase begins",
"long_description": "Shows how many minutes until the next falling price phase starts. Updates every minute.",
"usage_tips": "Use to time flexible loads: delay until the upcoming price drop."
},
"next_flat_phase_in_minutes": {
"description": "Minutes until the next flat (stable) price phase begins",
"long_description": "Shows how many minutes until the next flat price phase starts. Updates every minute.",
"usage_tips": "Use to anticipate price stabilisation after a volatile phase."
},
"chart_data_export": {
"description": "Datenexport für Dashboard-Integrationen",
"long_description": "Dieser Sensor ruft den get_chartdata-Service mit deiner konfigurierten YAML-Konfiguration auf und stellt das Ergebnis als Entity-Attribute bereit. Der Status zeigt 'ready' wenn Daten verfügbar sind, 'error' bei Fehlern, oder 'pending' vor dem ersten Aufruf. Perfekt für Dashboard-Integrationen wie ApexCharts, die Preisdaten aus Entity-Attributen lesen.",
@ -495,6 +570,61 @@
"description": "Leichtgewichtige Metadaten für Diagrammkonfiguration",
"long_description": "Liefert wesentliche Diagrammkonfigurationswerte als Sensor-Attribute. Nützlich für jede Diagrammkarte, die Y-Achsen-Grenzen benötigt. Der Sensor ruft get_chartdata im Nur-Metadaten-Modus auf (keine Datenverarbeitung) und extrahiert: yaxis_min, yaxis_max (vorgeschlagener Y-Achsenbereich für optimale Skalierung). Der Status spiegelt das Service-Call-Ergebnis wider: 'ready' bei Erfolg, 'error' bei Fehler, 'pending' während der Initialisierung.",
"usage_tips": "Konfiguriere über configuration.yaml unter tibber_prices.chart_metadata_config (optional: day, subunit_currency, resolution). Der Sensor aktualisiert sich automatisch bei Preisdatenänderungen. Greife auf Metadaten aus Attributen zu: yaxis_min, yaxis_max. Verwende mit config-template-card oder jedem Tool, das Entity-Attribute liest - perfekt für dynamische Diagrammkonfiguration ohne manuelle Berechnungen."
},
"current_interval_price_rank_today": {
"description": "Position des aktuellen Intervallpreises in der heutigen Rangliste — Perzentilrang (0 % = günstigster Moment)",
"long_description": "Zeigt, wie günstig oder teuer der aktuelle Viertelstunden-Intervallpreis im Vergleich zu allen 96 heutigen Slots ist. 0 % bedeutet: Dieser Moment ist der günstigste des Tages. 50 % bedeutet: Die Hälfte der Slots ist günstiger. ca. 99 % bedeutet: Dieser Slot ist der teuerste des Tages. Formel (Perzentilrang): Anzahl günstigerer Slots ÷ Gesamtanzahl × 100. Attribute: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Ideal für Automationen: 'Wenn current_interval_price_rank_today < 25, Spülmaschine starten' (günstigstes Viertel des Tages). Oder 'Wenn current_interval_price_rank_today > 75, Wärmepumpe pausieren'. Ein Wert von 0 garantiert den günstigsten Slot des Tages."
},
"current_interval_price_rank_tomorrow": {
"description": "Perzentilrang des aktuellen Intervallpreises in der morgigen Rangliste (0 % = günstigster von morgen)",
"long_description": "Zeigt, wie der aktuelle Intervallpreis im Vergleich zu allen 96 morgigen Viertelstunden-Slots abschneidet. Nützlich, um zu entscheiden, ob man bis morgen warten soll. 0 % bedeutet: Der aktuelle Preis ist günstiger als jeder morgige Slot. Gibt 'Unbekannt' zurück, bis die morgigen Daten verfügbar sind (typischerweise nach 13:00 Uhr). Attribute: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Warten lohnt sich? 'Wenn current_interval_price_rank_tomorrow < 10, gibt es morgen noch günstigere Slots — Aufgabe verschieben'. Am besten mit einem Binärsensor kombinieren."
},
"current_interval_price_rank_today_tomorrow": {
"description": "Perzentilrang des aktuellen Intervallpreises über heute+morgen zusammen (0 % = günstigstes des Zweitages-Fensters)",
"long_description": "Zeigt, wie günstig oder teuer der aktuelle Intervallpreis im Vergleich zu allen Slots über heute und morgen zusammen ist (bis zu 192 Viertelstunden-Slots). Fällt auf nur heute zurück, wenn morgige Daten noch nicht verfügbar sind. 0 % = günstigster Slot des kombinierten Zweitages-Fensters. Attribute: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Das breiteste Signal für 'Ist jetzt ein guter Zeitpunkt?'. Verwende 'Wenn current_interval_price_rank_today_tomorrow < 20, energieintensive Aufgabe jetzt starten'."
},
"next_interval_price_rank_today": {
"description": "Perzentilrang des nächsten Intervallpreises in der heutigen Rangliste (0 % = günstigster Moment heute)",
"long_description": "Zeigt den Perzentilrang des nächsten Viertelstunden-Intervalls innerhalb der 96 heutigen Slots. Ermöglicht einen Blick voraus, bevor das nächste Intervall beginnt. Attribute: `next_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Für Vorbereitung: 'Wenn next_interval_price_rank_today < 15, jetzt vorheizen, damit das Gerät im nächsten günstigen Slot läuft'."
},
"next_interval_price_rank_today_tomorrow": {
"description": "Perzentilrang des nächsten Intervallpreises über heute+morgen zusammen (0 % = günstigstes des Zweitages-Fensters)",
"long_description": "Zeigt den Perzentilrang des nächsten Viertelstunden-Intervalls innerhalb des kombinierten heute+morgen-Pools (bis zu 192 Slots). Fällt auf nur heute zurück, wenn morgige Daten nicht verfügbar sind. Attribute: `next_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Weitester Vorausblick: 'Wenn next_interval_price_rank_today_tomorrow < 10, ist das nächste Intervall eines der günstigsten im Zweitages-Fenster'."
},
"previous_interval_price_rank_today": {
"description": "Perzentilrang des vorherigen Intervallpreises in der heutigen Rangliste (0 % = günstigster Moment heute)",
"long_description": "Zeigt den Perzentilrang des gerade beendeten Viertelstunden-Intervalls innerhalb der 96 heutigen Slots. Nützlich für Protokollierung. Attribute: `previous_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Für retrospektive Automationen: 'Preisniveau des vorherigen Intervalls für Energieberichte aufzeichnen'."
},
"previous_interval_price_rank_today_tomorrow": {
"description": "Perzentilrang des vorherigen Intervallpreises über heute+morgen zusammen (0 % = günstigstes des Zweitages-Fensters)",
"long_description": "Zeigt den Perzentilrang des gerade beendeten Viertelstunden-Intervalls innerhalb des kombinierten heute+morgen-Pools (bis zu 192 Slots). Fällt auf nur heute zurück. Attribute: `previous_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Für retrospektive Vergleiche über ein Zweitages-Fenster."
},
"current_hour_price_rank_today": {
"description": "Perzentilrang des gleitenden Stunden-Durchschnittspreises in der heutigen Rangliste (0 % = günstigste Stunde heute)",
"long_description": "Zeigt, wo der gleitende 5-Intervall-Durchschnitt (2 Intervalle vor + aktuell + 2 danach, ca. 1 Stunde) in der heutigen Preisrangliste liegt. Glättet kurze Preisspitzen für eine breitere Einschätzung. Attribute: `current_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Für Aufgaben, die ca. eine Stunde dauern: 'Wenn current_hour_price_rank_today < 20, ist jetzt eine günstige Stunde — Waschmaschine starten'."
},
"current_hour_price_rank_today_tomorrow": {
"description": "Gleitender Stunden-Durchschnittspreisrang über heute+morgen zusammen (0 % = günstigste Stunde im Zweitages-Fenster)",
"long_description": "Zeigt, wo der gleitende 5-Intervall-Durchschnitt (±2 Intervalle, ca. 1 Stunde) in der kombinierten heute+morgen-Rangliste liegt (bis zu 192 Slots). Fällt auf nur heute zurück. Attribute: `current_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Weitestes Stundensignal: 'Wenn current_hour_price_rank_today_tomorrow < 15, ist dies eine der günstigsten Stunden im Zweitages-Fenster'."
},
"next_hour_price_rank_today": {
"description": "Perzentilrang des nächsten gleitenden Stunden-Durchschnittspreises in der heutigen Rangliste (0 % = günstigste Stunde heute)",
"long_description": "Zeigt, wo der auf das nächste Intervall zentrierte gleitende 5-Intervall-Durchschnitt in der heutigen Preisrangliste liegt. Ermöglicht Planung eine Stunde im Voraus. Attribute: `next_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Eine Stunde vorausplanen: 'Wenn next_hour_price_rank_today < 20, ist die kommende Stunde günstig — Aufgabe jetzt starten'."
},
"next_hour_price_rank_today_tomorrow": {
"description": "Nächster gleitender Stunden-Durchschnittspreisrang über heute+morgen zusammen (0 % = günstigste Stunde im Zweitages-Fenster)",
"long_description": "Zeigt, wo der auf das nächste Intervall zentrierte gleitende 5-Intervall-Durchschnitt in der kombinierten heute+morgen-Rangliste liegt (bis zu 192 Slots). Fällt auf nur heute zurück. Attribute: `next_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Weitester Stundenvorausblick: 'Wenn next_hour_price_rank_today_tomorrow < 10, ist die kommende Stunde eine der günstigsten im Zweitages-Fenster'."
}
},
"binary_sensor": {
@ -513,6 +643,21 @@
"long_description": "Wird aktiviert, wenn der aktuelle Preis in den unteren 20% der heutigen Preise liegt",
"usage_tips": "Nutze dies, um Geräte mit hohem Verbrauch während der günstigsten Intervalle zu betreiben"
},
"in_rising_price_phase": {
"description": "Whether prices are currently in a rising phase",
"long_description": "Turns ON when the current intra-day price phase is rising.",
"usage_tips": "Use in automations to delay or avoid running flexible loads during rising prices."
},
"in_falling_price_phase": {
"description": "Whether prices are currently in a falling phase",
"long_description": "Turns ON when the current intra-day price phase is falling.",
"usage_tips": "Use in automations to take advantage of falling prices for flexible loads."
},
"in_flat_price_phase": {
"description": "Whether prices are currently in a flat (stable) phase",
"long_description": "Turns ON when the current intra-day price phase is flat and prices are relatively stable.",
"usage_tips": "Use for loads that prefer price stability rather than the lowest price."
},
"connection": {
"description": "Ob die Verbindung zur Tibber API funktioniert",
"long_description": "Zeigt an, ob die Integration erfolgreich eine Verbindung zur Tibber API herstellen kann",
@ -541,7 +686,7 @@
"usage_tips": "Erhöhe den Wert, wenn du strengere Bestpreis-Kriterien möchtest. Verringere ihn, wenn zu wenige Perioden erkannt werden."
},
"best_price_min_period_length_override": {
"description": "Minimale Periodenl\u00e4nge in 15-Minuten-Intervallen. Perioden kürzer als diese werden nicht gemeldet. Beispiel: 2 = mindestens 30 Minuten.",
"description": "Minimale Periodenlänge in 15-Minuten-Intervallen. Perioden kürzer als diese werden nicht gemeldet. Beispiel: 2 = mindestens 30 Minuten.",
"long_description": "Wenn diese Entität aktiviert ist, überschreibt ihr Wert die Einstellung 'Mindestperiodenlänge' aus dem Optionen-Dialog für die Bestpreis-Periodenberechnung.",
"usage_tips": "Passe an die typische Laufzeit deiner Geräte an: 2 (30 Min) für Schnellprogramme, 4-8 (1-2 Std) für normale Zyklen, 8+ für lange ECO-Programme."
},
@ -571,7 +716,7 @@
"usage_tips": "Erhöhe den Wert, um nur extreme Preisspitzen zu erfassen. Verringere ihn, um mehr Hochpreiszeiten einzubeziehen."
},
"peak_price_min_period_length_override": {
"description": "Minimale Periodenl\u00e4nge in 15-Minuten-Intervallen für Spitzenpreise. Kürzere Preisspitzen werden nicht als Perioden gemeldet.",
"description": "Minimale Periodenlänge in 15-Minuten-Intervallen für Spitzenpreise. Kürzere Preisspitzen werden nicht als Perioden gemeldet.",
"long_description": "Wenn diese Entität aktiviert ist, überschreibt ihr Wert die Einstellung 'Mindestperiodenlänge' aus dem Optionen-Dialog für die Spitzenpreis-Periodenberechnung.",
"usage_tips": "Kürzere Werte erfassen kurze Preisspitzen. Längere Werte fokussieren auf anhaltende Hochpreisphasen."
},

View file

@ -309,13 +309,13 @@
},
"next_price_trend_change": {
"description": "When the next significant price trend change will occur",
"long_description": "Scans the next 24 hours (96 intervals) to find when the price trend direction will change. Only direction changes count: rising/strongly_rising are one group, falling/strongly_falling another, stable is its own. A change from rising to strongly_rising is NOT a trend change. Uses volatility-adaptive thresholds (default: ±3%/±9%) with hysteresis (default: 3 consecutive intervals). Returns the timestamp when the change is expected.",
"usage_tips": "Event-based automation: Trigger actions WHEN trend changes, not IN X hours. Example: 'Charge EV when next trend change shows falling prices' or 'Run dishwasher before prices start rising'. More accurate than simple future comparison because it knows if you're already in a trend. Complements time-window sensors (price_trend_Xh) which answer 'WILL prices be higher in X hours?'"
"long_description": "Scans the next 24 hours (96 intervals) to find when the price trend direction will change. Only direction changes count: rising/strongly_rising are one group, falling/strongly_falling another, stable is its own. A change from rising to strongly_rising is NOT a trend change. Uses volatility-adaptive thresholds (default: ±3%/±9%) with hysteresis (default: 3 consecutive intervals). Returns the timestamp when the change is expected.\n\nIMPORTANT — How the detection works: At each future interval, the sensor compares that interval's price to the AVERAGE of the following 3 hours (the 3h lookahead mean). This means the sensor detects when the average cost of the next 3 hours has already flipped direction — not when the exact price minimum or maximum is reached.\n\nOn V-shaped price days: During a price drop toward a minimum, the 3h lookahead window starts including prices on the rising flank before the actual minimum is reached. Once those rising prices push the 3h average above the current price, the sensor reports 'trend is now changing'. This typically fires 3060 minutes before the exact price minimum. This is intentional — the sensor answers 'when will the broad DIRECTION change?' rather than 'when is the exact turning point?'.",
"usage_tips": "Event-based automation: Trigger actions WHEN trend changes, not IN X hours. Example: 'Charge EV when next trend change shows falling prices' or 'Run dishwasher before prices start rising'. More accurate than simple future comparison because it knows if you're already in a trend. Complements time-window sensors (price_trend_Xh) which answer 'WILL prices be higher in X hours?'\n\nNote: On sharp V-shape days, this sensor may fire 3060 minutes before the exact price minimum. If you need a precise turning point, compare it to the Best Price period start — the period starts at the actual cheapest window. This sensor is better suited to 'prepare for a change' automation triggers where a brief early warning is acceptable."
},
"trend_change_in_minutes": {
"next_price_trend_change_in": {
"description": "Time until the next price trend change",
"long_description": "Shows how long until the next significant price trend change occurs. The state displays in hours (e.g., 2.25 h) for dashboards. Shares the same analysis as the Next Price Trend Change timestamp sensor but presents it as a countdown duration. Updates every minute for accurate countdowns. Returns unknown when no trend change is expected in the next 24 hours.",
"usage_tips": "Dashboard countdown: Show 'Trend changes in 1.5 h' as a live countdown. For automations: 'If trend_change_in_minutes state < 0.25 (15 min), prepare for price direction change'. Pairs with Next Price Trend Change (timestamp) — use the timestamp for 'WHEN' and this sensor for 'HOW LONG'."
"long_description": "Shows how long until the next significant price trend change occurs. The state displays in hours (e.g., 2.25 h) for dashboards. Shares the same analysis as the Next Price Trend Change timestamp sensor but presents it as a countdown duration. Updates every minute for accurate countdowns. Returns unknown when no trend change is expected in the next 24 hours.\n\nSee 'Next Price Trend Change' for an explanation of the 3h-lookahead detection mechanism and its behaviour on V-shaped price days.",
"usage_tips": "Dashboard countdown: Show 'Trend changes in 1.5 h' as a live countdown. For automations: 'If next_price_trend_change_in state < 0.25 (15 min), prepare for price direction change'. Pairs with Next Price Trend Change (timestamp) — use the timestamp for 'WHEN' and this sensor for 'HOW LONG'."
},
"daily_rating": {
"description": "How today's prices compare to historical data",
@ -486,6 +486,81 @@
"long_description": "Shows whether your Tibber subscription is currently running, has ended, or is pending activation. A status of 'running' means you're actively receiving electricity through Tibber.",
"usage_tips": "Use this to monitor your subscription status. Set up alerts if status changes from 'running' to ensure uninterrupted service."
},
"day_pattern_yesterday": {
"description": "Detected price shape of yesterday's electricity prices",
"long_description": "Classifies yesterday into a price shape: Valley (cheap in the middle), Peak (expensive in the middle), Double Valley (two cheap periods), Double Peak (two expensive periods), Flat (little variation), Rising, Falling, or Mixed. The confidence and CV attributes indicate how reliably the pattern was detected.",
"usage_tips": "Use yesterday's pattern to refine automations: a Valley day often repeats the next day, suggesting you should pre-schedule cheap-hour loads. Pair with the confidence attribute to filter unreliable detections."
},
"day_pattern_today": {
"description": "Detected price shape of today's electricity prices",
"long_description": "Classifies today into a price shape: Valley (cheap in the middle of the day), Peak (expensive in the middle), Double Valley (W-shape, two cheap windows), Double Peak (M-shape, two expensive peaks), Flat (prices barely move), Rising (prices climb through the day), Falling (prices drop through the day), or Mixed. Attributes include confidence (01), coefficient of variation, knee-point times, and intra-day segments.",
"usage_tips": "Use today's pattern to decide when to shift loads. A Valley day means cheap prices around midday — ideal for running the dishwasher, washing machine, or charging the EV. A Peak day means expensive midday — run appliances early morning or late evening. Use valley_start and valley_end attributes to schedule automations precisely."
},
"day_pattern_tomorrow": {
"description": "Detected price shape of tomorrow's electricity prices",
"long_description": "Classifies tomorrow (once data is available, typically after 13:00) into a price shape using the same algorithm as today. The valley_start / valley_end or peak_start / peak_end attributes give knee-point times for the primary extremum so you can pre-schedule loads the evening before.",
"usage_tips": "Set up evening automations that read tomorrow's pattern and pre-configure heat pump schedules, car charging timers, or water heater settings for the following day. Pair with the tomorrow_data_available binary sensor to trigger the automation only when data is ready."
},
"current_price_phase": {
"description": "Whether electricity prices are currently rising, falling, or flat within today's intra-day price shape",
"long_description": "Shows the direction of price movement at the current time by identifying which monotone segment of today's price curve you are in. Today's prices are split into consecutively rising, falling, or flat stretches (phases). This sensor tells you which phase is active right now. Attributes include the phase's start and end times, its price range (min/max/mean), its position among all phases of the day (segment_index and segment_count), and the full list of all today's phases (all_segments). Updates every 15 minutes.",
"usage_tips": "Use in automations: 'If current_price_phase = falling, delay flexible loads until prices bottom out'. Pair with the Today's Price Pattern sensor to see both the overall shape of the day and your current position in it. Check segment_index and segment_count to understand how far through the intra-day movement you are — e.g. if segment_index=0 and the phase is already falling, prices have been declining since midnight. Use all_segments in templates or dashboards to display the full day ahead."
},
"next_price_phase": {
"description": "The next intra-day price phase — what comes after the current price movement",
"long_description": "Shows the monotone price phase that will follow after the currently active phase ends. The start attribute tells you exactly when the next phase begins, making it easy to schedule automations. When the current phase is the last one of the day (e.g. the final evening fall), this sensor becomes unavailable. Attributes: start (when it begins), end, price range (min/max/mean), segment_index, segment_count. Updates every 15 minutes.",
"usage_tips": "Use in automations: 'If next_price_phase = rising and next_price_phase.start is within 1 hour, start the washing machine now'. Or combine with current_price_phase: 'If current_price_phase = falling and next_price_phase = flat, we are approaching the daily low — good time for flexible loads'. The start attribute is particularly valuable: trigger automations precisely when the next phase begins."
},
"current_price_phase_end_time": {
"description": "When the current intra-day price phase ends",
"long_description": "Shows the exact timestamp when the currently active rising, falling, or flat price phase will end and transition to the next phase. Becomes unavailable when no segment data is available. Updates at interval boundaries (every 15 minutes). Pair with current_price_phase to know both what phase you are in and when it ends.",
"usage_tips": "Use in automations to schedule tasks that must finish before prices change: 'Start washing machine if current_price_phase = falling and current_price_phase_end_time is more than 2 hours away'. Or to alert when a cheap phase is about to end."
},
"current_price_phase_remaining_minutes": {
"description": "Minutes remaining in the current price phase",
"long_description": "Shows how many minutes are left in the current intra-day price phase (rising, falling, or flat). Updates every minute for countdown precision. Returns 0 when no segment data is available. The remaining_minutes attribute mirrors the sensor value in integer minutes for simpler automation templates.",
"usage_tips": "Use in automations: 'If current_price_phase = falling and current_price_phase_remaining_minutes < 30, start the dishwasher now before prices level out'. Also useful for dashboard cards showing a countdown bar until the phase transition."
},
"current_price_phase_duration": {
"description": "Total duration of the current price phase",
"long_description": "Shows the total length of the currently active price phase in hours (expressed in minutes internally). Updates at interval boundaries. This tells you how long the current trend segment lasts in total — useful for understanding whether it is a brief fluctuation or an extended period of rising or falling prices.",
"usage_tips": "Combine with current_price_phase_remaining_minutes to estimate how far through the phase you are, or compare with current_price_phase_progress to understand the time profile of the current trend."
},
"current_price_phase_progress": {
"description": "How far through the current price phase we are",
"long_description": "Shows the percentage of the current intra-day price phase that has elapsed (0100%). Updates every minute. A value near 0% means the phase just started; near 100% means it is about to end. Returns 0 when no segment data is available.",
"usage_tips": "Use in dashboard cards to display a visual progress bar for the current price phase. In automations: 'If current_price_phase = falling and current_price_phase_progress > 80, the cheapest prices are close — prepare flexible loads now'."
},
"next_rising_phase_start_time": {
"description": "When the next rising price phase begins",
"long_description": "Shows the timestamp of the next upcoming rising price segment across today's remaining phases and tomorrow's phases. Returns unavailable when no more rising phases exist in today's or tomorrow's data. Updates at interval boundaries (every 15 minutes).",
"usage_tips": "Use with in_falling_price_phase and next_rising_phase_start_time to schedule flexible loads: run them now while prices fall, and wrap up before the next rise starts. 'If in_falling_price_phase is ON and next_rising_phase_start_time is less than 1 hour away, start the washing machine immediately'."
},
"next_falling_phase_start_time": {
"description": "When the next falling price phase begins",
"long_description": "Shows the timestamp of the next upcoming falling price segment across today's remaining phases and tomorrow's phases. Returns unavailable when no more falling phases exist in today's or tomorrow's data. Updates at interval boundaries (every 15 minutes).",
"usage_tips": "Use to delay flexible loads until the next price drop starts. 'If next_falling_phase_start_time is within 2 hours, consider waiting before starting the dishwasher or heat pump'."
},
"next_flat_phase_start_time": {
"description": "When the next flat (stable) price phase begins",
"long_description": "Shows the timestamp of the next upcoming flat price segment (where prices show little variation) across today's remaining phases and tomorrow's phases. Returns unavailable when no more flat phases exist in the available data. Updates at interval boundaries (every 15 minutes).",
"usage_tips": "Flat phases indicate price stability — useful for scheduling loads that need a predictable cost over time. 'If next_flat_phase_start_time is within 30 minutes and current_price_phase = rising, the price peak is nearly over'."
},
"next_rising_phase_in_minutes": {
"description": "Minutes until the next rising price phase begins",
"long_description": "Shows how many minutes until the next rising price phase starts. Updates every minute. Returns unavailable if no more rising phases exist in today's or tomorrow's data. The next_in_minutes attribute mirrors the sensor value in integer minutes for automation templates.",
"usage_tips": "Use in countdown automations: 'Alert me 15 minutes before the next price rise so I can delay flexible loads'. Combine with in_falling_price_phase: if currently falling and a rise is imminent, act before prices start climbing."
},
"next_falling_phase_in_minutes": {
"description": "Minutes until the next falling price phase begins",
"long_description": "Shows how many minutes until the next falling price phase starts. Updates every minute. Returns unavailable if no more falling phases exist in today's or tomorrow's data. The next_in_minutes attribute mirrors the sensor value in integer minutes for automation templates.",
"usage_tips": "Use to time flexible loads optimally: 'If next_falling_phase_in_minutes < 60, delay the washing machine start to benefit from the upcoming price drop'."
},
"next_flat_phase_in_minutes": {
"description": "Minutes until the next flat (stable) price phase begins",
"long_description": "Shows how many minutes until the next flat price phase starts. Updates every minute. Returns unavailable if no more flat phases exist in today's or tomorrow's data. The next_in_minutes attribute mirrors the sensor value in integer minutes.",
"usage_tips": "Use to anticipate price stabilisation after a volatile phase. 'If next_flat_phase_in_minutes < 30 and current_price_phase = rising, the price peak will soon level off — consider delaying loads until then'."
},
"chart_data_export": {
"description": "Data export for dashboard integrations",
"long_description": "This binary sensor calls the get_chartdata service with your configured YAML parameters and exposes the result as entity attributes. The state is 'on' when the service call succeeds and data is available, 'off' when the call fails or no configuration is set. Perfect for dashboard integrations like ApexCharts that need to read price data from entity attributes.",
@ -495,6 +570,61 @@
"description": "Lightweight metadata for chart configuration",
"long_description": "Provides essential chart configuration values as sensor attributes. Useful for any chart card that needs Y-axis bounds. The sensor calls get_chartdata with metadata-only mode (no data processing) and extracts: yaxis_min, yaxis_max (suggested Y-axis range for optimal scaling). The state reflects the service call result: 'ready' when successful, 'error' on failure, 'pending' during initialization.",
"usage_tips": "Configure via configuration.yaml under tibber_prices.chart_metadata_config (optional: day, subunit_currency, resolution). The sensor automatically refreshes when price data updates. Access metadata from attributes: yaxis_min, yaxis_max. Use with config-template-card or any tool that reads entity attributes - perfect for dynamic chart configuration without manual calculations."
},
"current_interval_price_rank_today": {
"description": "Where the current interval's price sits in today's ranking — its percentile rank (0% = cheapest moment)",
"long_description": "Shows how cheap or expensive the current quarter-hour interval's price is compared to all of today's 96 quarter-hour slots. 0% means this is the cheapest moment of the day — every other slot costs more. 50% means half of today's slots are cheaper. ~99% means it's the most expensive slot of the day. Formula (percentile rank): how many slots are cheaper ÷ total slots × 100. Attributes: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Ideal for automations: 'If current_interval_price_rank_today < 25, start dishwasher' (cheapest quarter of the day). Or 'If current_interval_price_rank_today > 75, pause heat pump' (most expensive quarter). A value of 0 guarantees you're at the cheapest slot of the day."
},
"current_interval_price_rank_tomorrow": {
"description": "Where the current interval's price sits in tomorrow's percentile ranking (0% = cheapest of tomorrow)",
"long_description": "Shows how the current interval's price compares to all of tomorrow's 96 quarter-hour slots — its percentile rank within tomorrow's distribution. Useful for deciding whether to wait until tomorrow. 0% means the current price is cheaper than every slot tomorrow. Returns 'Unknown' until tomorrow's data arrives (typically after 13:00). Attributes: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Use to decide whether to wait: 'If current_interval_price_rank_tomorrow < 10, tomorrow has even cheaper slots — postpone the task'. Best combined with a binary sensor to confirm the task can actually run tomorrow."
},
"current_interval_price_rank_today_tomorrow": {
"description": "Current interval's percentile rank across today and tomorrow combined (0% = cheapest of the two-day window)",
"long_description": "Shows how cheap or expensive the current interval's price is compared to all slots across today and tomorrow together (up to 192 quarter-hour slots when both days are available) — the percentile rank within the two-day distribution. Gives the broadest view for flexible tasks. Falls back to today-only when tomorrow's data isn't available yet. 0% = cheapest of the combined two-day window. Attributes: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "The broadest signal for 'is now a good time?'. Use 'If current_interval_price_rank_today_tomorrow < 20, run energy-intensive task now'. Especially valuable when tasks can wait a full day — a value near 0 across two days is a genuinely exceptional price."
},
"next_interval_price_rank_today": {
"description": "Where the next interval's price sits in today's ranking (0% = cheapest moment of today)",
"long_description": "Shows the percentile rank of the upcoming quarter-hour interval's price within today's 96 slots. Lets you see at a glance how the next interval compares to the rest of the day before it starts. Attributes: `next_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Use to prepare for the next interval: 'If next_interval_price_rank_today < 15, start pre-heating now so the device runs during the next cheap slot'."
},
"next_interval_price_rank_today_tomorrow": {
"description": "Next interval's percentile rank across today and tomorrow combined (0% = cheapest of the two-day window)",
"long_description": "Shows the percentile rank of the upcoming quarter-hour interval's price within the combined today+tomorrow pool (up to 192 slots). Falls back to today-only when tomorrow's data isn't available. Attributes: `next_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Broadest look-ahead: 'If next_interval_price_rank_today_tomorrow < 10, the next interval is among the cheapest slots of the two-day window — optimal time to start long tasks'."
},
"previous_interval_price_rank_today": {
"description": "Where the previous interval's price sat in today's ranking (0% = cheapest moment of today)",
"long_description": "Shows the percentile rank of the just-ended quarter-hour interval's price within today's 96 slots. Useful for logging how cheap/expensive the previous interval was. Attributes: `previous_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Useful for retrospective automations or logging: 'Record the cost tier of the previous interval for energy reports'."
},
"previous_interval_price_rank_today_tomorrow": {
"description": "Previous interval's percentile rank across today and tomorrow combined (0% = cheapest of the two-day window)",
"long_description": "Shows the percentile rank of the just-ended quarter-hour interval's price within the combined today+tomorrow pool (up to 192 slots). Falls back to today-only when tomorrow's data isn't available. Attributes: `previous_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Useful for retrospective comparisons across a two-day window."
},
"current_hour_price_rank_today": {
"description": "Percentile rank of the current rolling hour's average price within today's distribution (0% = cheapest hour)",
"long_description": "Shows where the 5-interval rolling average (2 intervals before + current + 2 after, ~1 hour) sits in today's price ranking. Smooths out short spikes and gives a broader view of whether this hour is cheap or expensive relative to the day. Attributes: `current_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "For tasks that take about an hour: 'If current_hour_price_rank_today < 20, this is a cheap hour — run the washing machine'."
},
"current_hour_price_rank_today_tomorrow": {
"description": "Current rolling hour's average price rank across today and tomorrow combined (0% = cheapest hour of the two-day window)",
"long_description": "Shows where the 5-interval rolling average (±2 intervals, ~1 hour) sits in the combined today+tomorrow price ranking (up to 192 slots). Falls back to today-only when tomorrow's data isn't available. Attributes: `current_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Broadest hourly signal: 'If current_hour_price_rank_today_tomorrow < 15, this is one of the cheapest hours across two days — ideal for long flexible tasks'."
},
"next_hour_price_rank_today": {
"description": "Percentile rank of the next rolling hour's average price within today's distribution (0% = cheapest hour of today)",
"long_description": "Shows where the 5-interval rolling average centered on the next interval sits in today's price ranking. Lets you plan one hour ahead — is the upcoming hour cheap or expensive relative to today? Attributes: `next_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Plan one hour ahead: 'If next_hour_price_rank_today < 20, the upcoming hour is cheap — start a task now to run through it'."
},
"next_hour_price_rank_today_tomorrow": {
"description": "Next rolling hour's average price rank across today and tomorrow combined (0% = cheapest hour of the two-day window)",
"long_description": "Shows where the 5-interval rolling average centered on the next interval sits in the combined today+tomorrow price ranking (up to 192 slots). Falls back to today-only when tomorrow's data isn't available. Attributes: `next_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Broadest hourly look-ahead: 'If next_hour_price_rank_today_tomorrow < 10, the upcoming hour is among the cheapest of the two-day window'."
}
},
"binary_sensor": {
@ -513,6 +643,21 @@
"long_description": "Turns on when the current price is in the bottom 20% of today's prices",
"usage_tips": "Use this to run high-consumption appliances during the cheapest intervals"
},
"in_rising_price_phase": {
"description": "Whether prices are currently in a rising phase",
"long_description": "Turns ON when the current intra-day price phase is rising — i.e. prices have been moving upward since the last phase transition. Exactly one of in_rising_price_phase, in_falling_price_phase, and in_flat_price_phase is ON at any time. Becomes unavailable when no segment data is available.",
"usage_tips": "Use in automations to delay or avoid running flexible loads: 'If in_rising_price_phase is ON, postpone the dishwasher'. Pair with next_falling_phase_start_time to know when prices will start dropping again."
},
"in_falling_price_phase": {
"description": "Whether prices are currently in a falling phase",
"long_description": "Turns ON when the current intra-day price phase is falling — i.e. prices have been dropping since the last phase transition. This is often a good window to start flexible loads. Exactly one of in_rising_price_phase, in_falling_price_phase, and in_flat_price_phase is ON at any time.",
"usage_tips": "Use in automations to take advantage of falling prices: 'If in_falling_price_phase is ON and current_price_phase_remaining_minutes > 60, start the washing machine'. Combine with next_rising_phase_start_time to avoid starting a load that won't finish before prices rise."
},
"in_flat_price_phase": {
"description": "Whether prices are currently in a flat (stable) phase",
"long_description": "Turns ON when the current intra-day price phase is flat — i.e. prices are relatively stable with no significant rise or fall. Flat phases indicate predictable costs, making them suitable for loads with uncertain or variable duration. Exactly one of in_rising_price_phase, in_falling_price_phase, and in_flat_price_phase is ON at any time.",
"usage_tips": "Use for loads that are indifferent to price direction but prefer stability: heat pumps in steady-state mode, background charging, or water heater top-ups. Combine with current_price_phase_end_time to know how long the stable window lasts."
},
"connection": {
"description": "Whether the connection to the Tibber API is working",
"long_description": "Indicates if the integration can successfully connect to the Tibber API",

View file

@ -309,13 +309,13 @@
},
"next_price_trend_change": {
"description": "Når neste betydelige pristrendendring vil skje",
"long_description": "Skanner de neste 24 timene (96 intervaller) for å finne når pristrend-retningen vil endre seg. Kun retningsendringer teller: stigende/sterkt stigende er én gruppe, fallende/sterkt fallende en annen, stabil er egen. En endring fra stigende til sterkt stigende er IKKE en trendendring. Bruker volatilitetstilpassede terskelverdier (standard: ±3%/±9%) med hysterese (standard: 3 påfølgende intervaller). Returnerer tidsstempelet når endringen forventes.",
"usage_tips": "Hendelsesbasert automatisering: Utløs handlinger NÅR trenden endres, ikke OM X timer. Eksempel: 'Lad EV når neste trendendring viser synkende priser' eller 'Start oppvaskmaskin før prisene stiger'. Kompletterer tidsvindu-sensorer (price_trend_Xh) som svarer på 'VIL prisene være høyere om X timer?'"
"long_description": "Skanner de neste 24 timene (96 intervaller) for å finne når pristrend-retningen vil endre seg. Kun retningsendringer teller: stigende/sterkt stigende er én gruppe, fallende/sterkt fallende en annen, stabil er egen. En endring fra stigende til sterkt stigende er IKKE en trendendring. Bruker volatilitetstilpassede terskelverdier (standard: ±3%/±9%) med hysterese (standard: 3 påfølgende intervaller). Returnerer tidsstempelet når endringen forventes.\n\nVIKTIG — Hvordan deteksjonen fungerer: For hvert fremtidig intervall sammenligner sensoren prisen for det intervallet med GJENNOMSNITTET av de følgende 3 timene (3h forhåndsvisnings-gjennomsnittet). Dette betyr at sensoren oppdager når de gjennomsnittlige kostnadene for de neste 3 timene allerede har skiftet retning — ikke når det nøyaktige prisminimum eller -maksimum er nådd.\n\nPå V-formede prisdager: Under et prisfall mot et minimum begynner 3h-forhåndsvinduet å inkludere priser på den stigende flanken før det faktiske minimumet er nådd. Når disse stigende prisene dytter 3h-gjennomsnittet over den nåværende prisen, rapporterer sensoren 'trenden endrer seg nå'. Dette skjer typisk 3060 minutter før det eksakte prisminimum. Dette er tilsiktet — sensoren besvarer 'når vil den generelle RETNINGEN endre seg?' snarere enn 'når er det eksakte vendepunktet?'.",
"usage_tips": "Hendelsesbasert automatisering: Utløs handlinger NÅR trenden endres, ikke OM X timer. Eksempel: 'Lad EV når neste trendendring viser synkende priser' eller 'Start oppvaskmaskin før prisene stiger'. Kompletterer tidsvindu-sensorer (price_trend_Xh) som svarer på 'VIL prisene være høyere om X timer?'\n\nMerk: På skarpe V-formede prisdager kan denne sensoren utløse 3060 minutter før det eksakte prisminimum. Hvis du trenger et presist vendepunkt, sammenlign det med starten av Beste Pris-perioden — perioden starter ved det faktisk billigste vinduet. Denne sensoren egner seg bedre for 'forbered deg på en endring'-automatiseringsutløsere der en kort forhåndsvarsel er akseptabelt."
},
"trend_change_in_minutes": {
"next_price_trend_change_in": {
"description": "Tid til neste pristrendendring",
"long_description": "Viser hvor lenge det er til neste betydelige pristrendendring inntreffer. Verdien vises i timer (f.eks. 2,25 t) for dashboards. Deler samme analyse som tidsstempel-sensoren 'Neste trendendring', men presenterer den som en nedtellingsvarighet. Oppdateres hvert minutt for nøyaktige nedtellinger. Viser 'Ukjent' når ingen trendendring forventes i løpet av de neste 24 timene.",
"usage_tips": "Dashboard-nedtelling: Vis 'Trendendring om 1,5 t' som live nedtelling. For automatiseringer: 'Hvis trend_change_in_minutes < 0,25 (15 min), forbered på prisretningsendring'. Kompletterer 'Neste trendendring' (tidsstempel) — bruk tidsstempelet for 'NÅR' og denne sensoren for 'HVOR LENGE'."
"long_description": "Viser hvor lenge det er til neste betydelige pristrendendring inntreffer. Verdien vises i timer (f.eks. 2,25 t) for dashboards. Deler samme analyse som tidsstempel-sensoren 'Neste trendendring', men presenterer den som en nedtellingsvarighet. Oppdateres hvert minutt for nøyaktige nedtellinger. Viser 'Ukjent' når ingen trendendring forventes i løpet av de neste 24 timene. Se 'Neste trendendring' for en forklaring av 3h-forhånds-deteksjonsmekanismen og dens oppførsel på V-formede prisdager.",
"usage_tips": "Dashboard-nedtelling: Vis 'Trendendring om 1,5 t' som live nedtelling. For automatiseringer: 'Hvis next_price_trend_change_in < 0,25 (15 min), forbered på prisretningsendring'. Kompletterer 'Neste trendendring' (tidsstempel) — bruk tidsstempelet for 'NÅR' og denne sensoren for 'HVOR LENGE'."
},
"daily_rating": {
"description": "Hvordan dagens priser sammenlignes med historiske data",
@ -486,6 +486,81 @@
"long_description": "Viser om Tibber-abonnementet ditt for øyeblikket er aktivt, avsluttet eller venter på aktivering. En status 'Aktiv' betyr at du aktivt mottar strøm gjennom Tibber.",
"usage_tips": "Bruk dette til å overvåke abonnementsstatusen din. Sett opp varsler hvis statusen endres fra 'Aktiv' for å sikre uavbrutt tjeneste."
},
"day_pattern_yesterday": {
"description": "Oppdaget prismønster for gårsdagens strømpriser",
"long_description": "Klassifiserer i går i et prismønster: Dal (billig midt på dagen), Topp (dyrt midt på dagen), Dobbel dal (to billige perioder), Dobbel topp (to dyre perioder), Flat (liten variasjon), Stigende, Fallende eller Blandet. Konfidensen og CV-attributtene viser hvor pålitelig mønsteret ble oppdaget.",
"usage_tips": "Bruk gårsdagens mønster til å forbedre automatikaene dine: et Dalmønster gjentar seg ofte neste dag og antyder at du bør forhåndsplanlegge billige middagstimer."
},
"day_pattern_today": {
"description": "Oppdaget prismønster for dagens strømpriser",
"long_description": "Klassifiserer i dag i et prismønster: Dal (billig midt på dagen), Topp (dyrt midt på dagen), Dobbel dal (W-form), Dobbel topp (M-form), Flat, Stigende, Fallende eller Blandet. Attributter inkluderer konfidensverdi (01), variasjonskoeffisient, knepunktstider og dagsegmenter.",
"usage_tips": "Bruk dagens mønster til å flytte forbruk. Daldag: kjør oppvaskmaskin, vaskemaskin eller lad elbilen rundt billige middagstimer. Toppdag: kjør apparater tidlig morgen eller sent kveld. Bruk valley_start og valley_end for presise automatikaer."
},
"day_pattern_tomorrow": {
"description": "Oppdaget prismønster for morgendagens strømpriser",
"long_description": "Klassifiserer i morgen (når data er tilgjengelig, typisk etter kl. 13) i et prismønster med samme algoritme som i dag. Attributtene valley_start/valley_end eller peak_start/peak_end gir knepunktstider.",
"usage_tips": "Sett opp kveldsautomasjonar som leser morgendagens mønster og forhåndskonfigurerer varmepumpe, billader eller varmtvannsberedere. Kombiner med tomorrow_data_available-binærsensoren."
},
"current_price_phase": {
"description": "Om strømprisene nå stiger, faller eller er stabile innenfor dagens intra-dag prisform",
"long_description": "Viser retningen på prisbevegelsen nå ved å identifisere det aktive monotone segmentet i dagens priskurve. Dagens priser deles inn i fortløpende stigende, fallende eller flate strekninger (faser). Denne sensoren viser hvilken fase som er aktivt akkurat nå. Attributter inkluderer fasens start- og sluttid, prisområde (min/maks/gjennomsnitt), posisjon blant dagens faser (segment_index og segment_count) og den fullstendige listen over alle dagens faser (all_segments). Oppdateres hvert 15. minutt.",
"usage_tips": "Bruk i automasjonar: 'Hvis current_price_phase = fallende, vent med fleksible laster til prisene når bunnen'. Kombiner med Dagens Prismønster for å se både den overordnede dagformen og din nåværende posisjon i den. Sjekk segment_index og segment_count for å forstå hvor langt inn i bevegelsen du er. Bruk all_segments i maler eller dashbord for å vise hele dagsforløpet."
},
"next_price_phase": {
"description": "Den neste intra-dag prisfasen hva som kommer etter den nåværende prisbevegelsen",
"long_description": "Viser det monotone prissegmentet som følger etter den for øyeblikket aktive fasen. Attributtet start viser nøyaktig når neste fase begynner, noe som gjør det enkelt å planlegge automasjonar. Når den nåværende fasen er den siste for dagen (f.eks. det siste kveldsfall), blir denne sensoren utilgjengelig. Attributter: start (når den begynner), end, prisområde (min/maks/gjennomsnitt), segment_index, segment_count. Oppdateres hvert 15. minutt.",
"usage_tips": "Bruk i automasjonar: 'Hvis next_price_phase = stigende og next_price_phase.start er innen 1 time, start vaskemaskin nå'. Eller kombiner med current_price_phase: 'Hvis current_price_phase = fallende og next_price_phase = flat, nærmer vi oss daglig lavpunkt godt tidspunkt for fleksible laster'. Attributtet start er spesielt verdifullt: utløs automasjonar nøyaktig når neste fase begynner."
},
"current_price_phase_end_time": {
"description": "When the current intra-day price phase ends",
"long_description": "Shows the exact timestamp when the currently active rising, falling, or flat price phase will end and transition to the next phase.",
"usage_tips": "Use in automations to schedule tasks that must finish before prices change."
},
"current_price_phase_remaining_minutes": {
"description": "Minutes remaining in the current price phase",
"long_description": "Shows how many minutes are left in the current intra-day price phase. Updates every minute.",
"usage_tips": "Use in automations: 'If current_price_phase = falling and remaining < 30, start the dishwasher now'."
},
"current_price_phase_duration": {
"description": "Total duration of the current price phase",
"long_description": "Shows the total length of the currently active price phase in hours.",
"usage_tips": "Combine with remaining minutes to understand how far through the phase you are."
},
"current_price_phase_progress": {
"description": "How far through the current price phase we are",
"long_description": "Shows the percentage of the current intra-day price phase that has elapsed (0100%). Updates every minute.",
"usage_tips": "Use in dashboard cards to display a visual progress bar for the current price phase."
},
"next_rising_phase_start_time": {
"description": "When the next rising price phase begins",
"long_description": "Shows the timestamp of the next upcoming rising price segment across today's remaining phases and tomorrow's phases.",
"usage_tips": "Use to schedule loads before prices start rising again."
},
"next_falling_phase_start_time": {
"description": "When the next falling price phase begins",
"long_description": "Shows the timestamp of the next upcoming falling price segment across today's remaining phases and tomorrow's phases.",
"usage_tips": "Use to delay flexible loads until the next price drop starts."
},
"next_flat_phase_start_time": {
"description": "When the next flat (stable) price phase begins",
"long_description": "Shows the timestamp of the next upcoming flat price segment across today's remaining phases and tomorrow's phases.",
"usage_tips": "Use for scheduling loads that need predictable costs over time."
},
"next_rising_phase_in_minutes": {
"description": "Minutes until the next rising price phase begins",
"long_description": "Shows how many minutes until the next rising price phase starts. Updates every minute.",
"usage_tips": "Use in countdown automations to alert before the next price rise."
},
"next_falling_phase_in_minutes": {
"description": "Minutes until the next falling price phase begins",
"long_description": "Shows how many minutes until the next falling price phase starts. Updates every minute.",
"usage_tips": "Use to time flexible loads: delay until the upcoming price drop."
},
"next_flat_phase_in_minutes": {
"description": "Minutes until the next flat (stable) price phase begins",
"long_description": "Shows how many minutes until the next flat price phase starts. Updates every minute.",
"usage_tips": "Use to anticipate price stabilisation after a volatile phase."
},
"chart_data_export": {
"description": "Dataeksport for dashboardintegrasjoner",
"long_description": "Denne sensoren kaller get_chartdata-tjenesten med din konfigurerte YAML-konfigurasjon og eksponerer resultatet som entitetsattributter. Status viser 'ready' når data er tilgjengelig, 'error' ved feil, eller 'pending' før første kall. Perfekt for dashboardintegrasjoner som ApexCharts som trenger å lese prisdata fra entitetsattributter.",
@ -495,6 +570,61 @@
"description": "Lettvekts metadata for diagramkonfigurasjon",
"long_description": "Gir essensielle diagramkonfigurasjonsverdier som sensorattributter. Nyttig for ethvert diagramkort som trenger Y-aksegrenser. Sensoren kaller get_chartdata med kun-metadata-modus (ingen databehandling) og trekker ut: yaxis_min, yaxis_max (foreslått Y-akseområde for optimal skalering). Status reflekterer tjenestekallresultatet: 'ready' ved suksess, 'error' ved feil, 'pending' under initialisering.",
"usage_tips": "Konfigurer via configuration.yaml under tibber_prices.chart_metadata_config (valgfritt: day, subunit_currency, resolution). Sensoren oppdateres automatisk når prisdata endres. Få tilgang til metadata fra attributter: yaxis_min, yaxis_max. Bruk med config-template-card eller ethvert verktøy som leser entitetsattributter - perfekt for dynamisk diagramkonfigurasjon uten manuelle beregninger."
},
"current_interval_price_rank_today": {
"description": "Hvor nåværende intervallpris plasserer seg i dagens rangering — som prosentilrang (0 % = billigste øyeblikk)",
"long_description": "Viser hvor billig eller dyr prisen for det gjældende kvarter er sammenlignet med alle 96 kvarterstimer i dag. 0 % betyr at dette er det billigste øyeblikket i dag. 50 % betyr at halvparten av dagens tidsluker er billigere. ca. 99 % betyr det dyreste tidssluket i dag. Formel: antall billigere tidsluker ÷ totalt antall × 100. Attributter: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Ideelt for automatiseringer: 'Hvis current_interval_price_rank_today < 25, start oppvaskmaskinen'. A value of 0 garanterer at du er på det billigste tidssluket i dag."
},
"current_interval_price_rank_tomorrow": {
"description": "Prosentilrang for gjældende intervallpris i morgendagens rangering (0 % = billigste av i morgen)",
"long_description": "Viser hvordan gjældende intervallpris sammenlignes med alle 96 kvarterstimer i morgen. 0 % betyr at gjældende pris er billigere enn alle morgendagens tidsluker. Returnerer 'Ukjent' til morgendagens data er tilgjengelig (vanligvis etter kl. 13:00). Attributter: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Bruk for å avgjøre om det er verdt å vente: 'Hvis current_interval_price_rank_tomorrow < 10, finnes det enda billigere tidsluker i morgen — utsett oppgaven'."
},
"current_interval_price_rank_today_tomorrow": {
"description": "Prosentilrang for gjældende intervallpris over i dag+i morgen samlet (0 % = billigste i to-dagers-vinduet)",
"long_description": "Viser hvor billig eller dyr gjældende intervallpris er sammenlignet med alle tidsluker over i dag og i morgen samlet (opptil 192 kvarterstimer). Fæller tilbake til kun i dag når morgendagens data ikke er tilgjengelig. Attributter: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Det bredeste signalet for 'er det nå et godt tidspunkt?'. Bruk 'Hvis current_interval_price_rank_today_tomorrow < 20, kjør energikrevende oppgave nå'."
},
"next_interval_price_rank_today": {
"description": "Prosentilrang for neste intervalls pris i dagens rangering (0 % = billigste øyeblikk i dag)",
"long_description": "Viser prosentilrangen for det kommende kvarter innenfor dagens 96 tidsluker. Gir forhåndsvisning før neste intervall begynner. Attributter: `next_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "For forberedelse: 'Hvis next_interval_price_rank_today < 15, start forvarming nå så enheten kjører i neste billige tidsluke'."
},
"next_interval_price_rank_today_tomorrow": {
"description": "Prosentilrang for neste intervalls pris over i dag+i morgen samlet (0 % = billigste i to-dagers-vinduet)",
"long_description": "Viser prosentilrangen for det kommende kvarter innenfor det kombinerte i dag+i morgen-bassenget (opptil 192 tidsluker). Fæller tilbake til kun i dag. Attributter: `next_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Bredeste fremtidsvisning: 'Hvis next_interval_price_rank_today_tomorrow < 10, er neste intervall blant de billigste i to-dagers-vinduet'."
},
"previous_interval_price_rank_today": {
"description": "Prosentilrang for forrige intervalls pris i dagens rangering (0 % = billigste øyeblikk i dag)",
"long_description": "Viser prosentilrangen for det nettopp avsluttede kvarteret innenfor dagens 96 tidsluker. Nyttig for å logge hvor billig eller dyrt det forrige intervallet var. Attributter: `previous_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "For retrospektive automatiseringer: 'Registrer prisnivået for forrige intervall i energirapporter'."
},
"previous_interval_price_rank_today_tomorrow": {
"description": "Prosentilrang for forrige intervalls pris over i dag+i morgen samlet (0 % = billigste i to-dagers-vinduet)",
"long_description": "Viser prosentilrangen for det nettopp avsluttede kvarteret innenfor det kombinerte i dag+i morgen-bassenget (opptil 192 tidsluker). Faller tilbake til kun i dag når morgendagens data ikke er tilgjengelig. Attributter: `previous_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "For retrospektive sammenligninger over et to-dagers-vindu."
},
"current_hour_price_rank_today": {
"description": "Prosentilrang for glidende timegjennomsnittpris i dagens rangering (0 % = billigste time i dag)",
"long_description": "Viser plasseringen til det glidende 5-intervall-gjennomsnittet (2 intervaller før + gjældende + 2 etter, ca. 1 time) i dagens prisrangering. Jevner ut korte pristopper. Attributter: `current_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "For oppgaver som tar omtrent en time: 'Hvis current_hour_price_rank_today < 20, er dette en billig time — start vaskemaskinen'."
},
"current_hour_price_rank_today_tomorrow": {
"description": "Glidende timegjennomsnittprisrang over i dag+i morgen samlet (0 % = billigste time i to-dagers-vinduet)",
"long_description": "Viser plasseringen til det glidende 5-intervall-gjennomsnittet (±2 intervaller, ca. 1 time) i den kombinerte i dag+i morgen-rangeringen (opptil 192 tidsluker). Fæller tilbake til kun i dag. Attributter: `current_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Bredeste timesignal: 'Hvis current_hour_price_rank_today_tomorrow < 15, er dette en av de billigste timene i to-dagers-vinduet'."
},
"next_hour_price_rank_today": {
"description": "Prosentilrang for neste glidende timegjennomsnittpris i dagens rangering (0 % = billigste time i dag)",
"long_description": "Viser plasseringen til det 5-intervall-gjennomsnittet sentrert på neste intervall i dagens prisrangering. Muliggjør planlegging en time frem i tid. Attributter: `next_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Forutse en time frem: 'Hvis next_hour_price_rank_today < 20, er den kommende timen billig — start en oppgave nå'."
},
"next_hour_price_rank_today_tomorrow": {
"description": "Neste glidende timegjennomsnittprisrang over i dag+i morgen samlet (0 % = billigste time i to-dagers-vinduet)",
"long_description": "Viser plasseringen til det 5-intervall-gjennomsnittet sentrert på neste intervall i den kombinerte i dag+i morgen-rangeringen (opptil 192 tidsluker). Fæller tilbake til kun i dag. Attributter: `next_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Bredeste timefremtidsvisning: 'Hvis next_hour_price_rank_today_tomorrow < 10, er den kommende timen blant de billigste i to-dagers-vinduet'."
}
},
"binary_sensor": {
@ -513,6 +643,21 @@
"long_description": "Slår seg på når nåværende pris er i bunn 20% av dagens priser",
"usage_tips": "Bruk dette til å kjøre høyforbruksapparater i de billigste intervallene"
},
"in_rising_price_phase": {
"description": "Whether prices are currently in a rising phase",
"long_description": "Turns ON when the current intra-day price phase is rising.",
"usage_tips": "Use in automations to delay or avoid running flexible loads during rising prices."
},
"in_falling_price_phase": {
"description": "Whether prices are currently in a falling phase",
"long_description": "Turns ON when the current intra-day price phase is falling.",
"usage_tips": "Use in automations to take advantage of falling prices for flexible loads."
},
"in_flat_price_phase": {
"description": "Whether prices are currently in a flat (stable) phase",
"long_description": "Turns ON when the current intra-day price phase is flat and prices are relatively stable.",
"usage_tips": "Use for loads that prefer price stability rather than the lowest price."
},
"connection": {
"description": "Om tilkoblingen til Tibber API fungerer",
"long_description": "Indikerer om integrasjonen kan koble til Tibber API",

View file

@ -309,13 +309,13 @@
},
"next_price_trend_change": {
"description": "Wanneer de volgende significante prijstrendwijziging zal plaatsvinden",
"long_description": "Scant de komende 24 uur (96 intervallen) om te vinden wanneer de prijstrend-richting zal veranderen. Alleen richtingswijzigingen tellen: stijgend/sterk stijgend vormen één groep, dalend/sterk dalend een andere, stabiel is apart. Een verandering van stijgend naar sterk stijgend is GEEN trendwijziging. Gebruikt volatiliteit-adaptieve drempelwaarden (standaard: ±3%/±9%) met hysterese (standaard: 3 opeenvolgende intervallen). Retourneert het tijdstempel wanneer de wijziging wordt verwacht.",
"usage_tips": "Gebeurtenisgestuurde automatisering: Trigger acties WANNEER trend wijzigt, niet OVER X uur. Voorbeeld: 'Laad EV wanneer volgende trendwijziging dalende prijzen toont' of 'Start vaatwasser voordat prijzen stijgen'. Vult tijdvenster-sensors aan (price_outlook_Xh) die beantwoorden 'ZULLEN prijzen over X uur hoger zijn?'"
"long_description": "Scant de komende 24 uur (96 intervallen) om te vinden wanneer de prijstrend-richting zal veranderen. Alleen richtingswijzigingen tellen: stijgend/sterk stijgend vormen één groep, dalend/sterk dalend een andere, stabiel is apart. Een verandering van stijgend naar sterk stijgend is GEEN trendwijziging. Gebruikt volatiliteit-adaptieve drempelwaarden (standaard: ±3%/±9%) met hysterese (standaard: 3 opeenvolgende intervallen). Retourneert het tijdstempel wanneer de wijziging wordt verwacht.\n\nBELANGRIJK — Hoe de detectie werkt: Bij elk toekomstig interval vergelijkt de sensor de prijs van dat interval met het GEMIDDELDE van de volgende 3 uur (het 3u vooruitkijkgemiddelde). Dit betekent dat de sensor detecteert wanneer de gemiddelde kosten van de komende 3 uur al van richting zijn veranderd — niet wanneer het exacte prijsminimum of -maximum is bereikt.\n\nOp V-vormige prisdagen: Tijdens een prijsdaling naar een minimum begint het 3u vooruitkijkvenster al prijzen van de stijgende flank te bevatten voordat het eigenlijke minimum is bereikt. Zodra die stijgende prijzen het 3u-gemiddelde boven de huidige prijs duwen, rapporteert de sensor 'trend verandert nu'. Dit treedt typisch 3060 minuten voor het exacte prijsminimum op. Dit is opzettelijk — de sensor beantwoordt 'wanneer verandert de algemene RICHTING?' in plaats van 'wanneer is het exacte keerpunt?'.",
"usage_tips": "Gebeurtenisgestuurde automatisering: Trigger acties WANNEER trend wijzigt, niet OVER X uur. Voorbeeld: 'Laad EV wanneer volgende trendwijziging dalende prijzen toont' of 'Start vaatwasser voordat prijzen stijgen'. Vult tijdvenster-sensors aan (price_outlook_Xh) die beantwoorden 'ZULLEN prijzen over X uur hoger zijn?'\n\nOpmerking: Op scherpe V-vormige prisdagen kan deze sensor 3060 minuten voor het exacte prijsminimum afgaan. Als je een precies keerpunt nodig hebt, vergelijk het met de start van de Beste Prijs-periode — de periode start bij het eigenlijke goedkoopste venster. Deze sensor is beter geschikt voor 'bereid je voor op een verandering' automatiserings-triggers waarbij een korte vroege waarschuwing acceptabel is."
},
"trend_change_in_minutes": {
"next_price_trend_change_in": {
"description": "Tijd tot de volgende prijstrendwijziging",
"long_description": "Toont hoe lang het duurt tot de volgende significante prijstrendwijziging plaatsvindt. De waarde wordt weergegeven in uren (bijv. 2,25 u) voor dashboards. Deelt dezelfde analyse als de tijdstempel-sensor 'Volgende Prijstrend Wijziging', maar presenteert het als een aftelduur. Wordt elke minuut bijgewerkt voor nauwkeurige aftellingen. Toont 'Onbekend' wanneer geen trendwijziging wordt verwacht in de komende 24 uur.",
"usage_tips": "Dashboard-aftelling: Toon 'Trendwijziging over 1,5 u' als live aftelling. Voor automatiseringen: 'Als trend_change_in_minutes < 0,25 (15 min), bereid je voor op prijsrichtingswijziging'. Vult 'Volgende Prijstrend Wijziging' (tijdstempel) aan — gebruik het tijdstempel voor 'WANNEER' en deze sensor voor 'HOE LANG'."
"long_description": "Toont hoe lang het duurt tot de volgende significante prijstrendwijziging plaatsvindt. De waarde wordt weergegeven in uren (bijv. 2,25 u) voor dashboards. Deelt dezelfde analyse als de tijdstempel-sensor 'Volgende Prijstrend Wijziging', maar presenteert het als een aftelduur. Wordt elke minuut bijgewerkt voor nauwkeurige aftellingen. Toont 'Onbekend' wanneer geen trendwijziging wordt verwacht in de komende 24 uur. Zie 'Volgende Prijstrend Wijziging' voor een uitleg van het 3u-vooruitkijkdetectiemechanisme en het gedrag op V-vormige prisdagen.",
"usage_tips": "Dashboard-aftelling: Toon 'Trendwijziging over 1,5 u' als live aftelling. Voor automatiseringen: 'Als next_price_trend_change_in < 0,25 (15 min), bereid je voor op prijsrichtingswijziging'. Vult 'Volgende Prijstrend Wijziging' (tijdstempel) aan — gebruik het tijdstempel voor 'WANNEER' en deze sensor voor 'HOE LANG'."
},
"daily_rating": {
"description": "Hoe de prijzen van vandaag zich verhouden tot historische gegevens",
@ -486,6 +486,81 @@
"long_description": "Geeft aan of je Tibber-abonnement momenteel actief is, beëindigd of wacht op activering. Een 'Actief'-status betekent dat je actief elektriciteit via Tibber afneemt.",
"usage_tips": "Gebruik dit om je abonnementsstatus te monitoren. Stel meldingen in als de status verandert van 'Actief' om ononderbroken service te waarborgen."
},
"day_pattern_yesterday": {
"description": "Gedetecteerd prijspatroon van gisterens elektriciteitsprijzen",
"long_description": "Classificeert gisteren in een prijspatroon: Dal (goedkoop in het midden), Piek (duur in het midden), Dubbel Dal (twee goedkope perioden), Dubbele Piek (twee dure perioden), Vlak (weinig variatie), Stijgend, Dalend of Gemengd. De confidence- en CV-attributen tonen hoe betrouwbaar het patroon is gedetecteerd.",
"usage_tips": "Gebruik het patroon van gisteren om automations te verfijnen: een Daldag herhaalt zich vaak de volgende dag en suggereert om goedkope middaguren in te plannen."
},
"day_pattern_today": {
"description": "Gedetecteerd prijspatroon van de huidige elektriciteitsprijzen",
"long_description": "Classificeert vandaag in een prijspatroon: Dal (goedkoop 's middags), Piek (duur 's middags), Dubbel Dal (W-vorm), Dubbele Piek (M-vorm), Vlak, Stijgend, Dalend of Gemengd. Attributen omvatten confidence (01), variatiecoëfficiënt, kniepunttijden en dagsegmenten.",
"usage_tips": "Gebruik het dagpatroon om verbruik te verschuiven. Daldag: draai vaatwasser, wasmachine of laad de EV 's middags. Piekdag: gebruik apparaten vroeg in de ochtend of laat in de avond. Gebruik valley_start en valley_end voor precieze automations."
},
"day_pattern_tomorrow": {
"description": "Gedetecteerd prijspatroon van de elektriciteitsprijzen van morgen",
"long_description": "Classificeert morgen (zodra data beschikbaar is, doorgaans na 13:00) in een prijspatroon met hetzelfde algoritme als vandaag. De attributen valley_start/valley_end of peak_start/peak_end geven kniepunttijden voor het primaire extremum.",
"usage_tips": "Stel avondautomations in die het patroon van morgen lezen en warmtepomp, autolader of boiler vooraf configureren. Combineer met de tomorrow_data_available binaire sensor."
},
"current_price_phase": {
"description": "Of elektriciteitsprijzen momenteel stijgen, dalen of stabiel zijn binnen de intra-dag prijsvorm van vandaag",
"long_description": "Toont de richting van de prijsbeweging op dit moment door het actieve monotone segment van de prijscurve van vandaag te bepalen. De dagprijzen worden opgesplitst in opeenvolgende stijgende, dalende of vlakke stukken (fasen). Deze sensor toont in welke fase je je nu bevindt. Attributen zijn onder andere de start- en eindtijd van de fase, het prijsbereik (min/max/gemiddelde), de positie binnen de dagelijkse fasen (segment_index en segment_count) en de volledige lijst van alle fasen van vandaag (all_segments). Elke 15 minuten bijgewerkt.",
"usage_tips": "Gebruik in automations: 'Als current_price_phase = dalend, wacht met flexibele lasten totdat de prijzen de bodem bereiken'. Combineer met de Prijspatroon Vandaag-sensor om zowel de algemene dagvorm als je huidige positie daarin te zien. Controleer segment_index en segment_count om te begrijpen hoe ver je bent in de intra-dag beweging. Gebruik all_segments in sjablonen of dashboards om het volledige dagverloop te tonen."
},
"next_price_phase": {
"description": "De volgende intra-dag prijsfase wat er na de huidige prijsbeweging komt",
"long_description": "Toont het monotone prijssegment dat volgt na de momenteel actieve fase. Het attribuut start geeft precies aan wanneer de volgende fase begint, wat het eenvoudig maakt om automations nauwkeurig te plannen. Wanneer de huidige fase de laatste van de dag is (bijv. de laatste avonddaling), wordt deze sensor niet beschikbaar. Attributen: start (wanneer het begint), end, prijsbereik (min/max/gemiddelde), segment_index, segment_count. Elke 15 minuten bijgewerkt.",
"usage_tips": "Gebruik in automations: 'Als next_price_phase = stijgend en next_price_phase.start binnen 1 uur is, start de wasmachine nu'. Of combineer met current_price_phase: 'Als current_price_phase = dalend en next_price_phase = vlak, naderen we het dagdieptepunt goed moment voor flexibele lasten'. Het attribuut start is bijzonder waardevol: activeer automations precies wanneer de volgende fase begint."
},
"current_price_phase_end_time": {
"description": "When the current intra-day price phase ends",
"long_description": "Shows the exact timestamp when the currently active rising, falling, or flat price phase will end and transition to the next phase.",
"usage_tips": "Use in automations to schedule tasks that must finish before prices change."
},
"current_price_phase_remaining_minutes": {
"description": "Minutes remaining in the current price phase",
"long_description": "Shows how many minutes are left in the current intra-day price phase. Updates every minute.",
"usage_tips": "Use in automations: 'If current_price_phase = falling and remaining < 30, start the dishwasher now'."
},
"current_price_phase_duration": {
"description": "Total duration of the current price phase",
"long_description": "Shows the total length of the currently active price phase in hours.",
"usage_tips": "Combine with remaining minutes to understand how far through the phase you are."
},
"current_price_phase_progress": {
"description": "How far through the current price phase we are",
"long_description": "Shows the percentage of the current intra-day price phase that has elapsed (0100%). Updates every minute.",
"usage_tips": "Use in dashboard cards to display a visual progress bar for the current price phase."
},
"next_rising_phase_start_time": {
"description": "When the next rising price phase begins",
"long_description": "Shows the timestamp of the next upcoming rising price segment across today's remaining phases and tomorrow's phases.",
"usage_tips": "Use to schedule loads before prices start rising again."
},
"next_falling_phase_start_time": {
"description": "When the next falling price phase begins",
"long_description": "Shows the timestamp of the next upcoming falling price segment across today's remaining phases and tomorrow's phases.",
"usage_tips": "Use to delay flexible loads until the next price drop starts."
},
"next_flat_phase_start_time": {
"description": "When the next flat (stable) price phase begins",
"long_description": "Shows the timestamp of the next upcoming flat price segment across today's remaining phases and tomorrow's phases.",
"usage_tips": "Use for scheduling loads that need predictable costs over time."
},
"next_rising_phase_in_minutes": {
"description": "Minutes until the next rising price phase begins",
"long_description": "Shows how many minutes until the next rising price phase starts. Updates every minute.",
"usage_tips": "Use in countdown automations to alert before the next price rise."
},
"next_falling_phase_in_minutes": {
"description": "Minutes until the next falling price phase begins",
"long_description": "Shows how many minutes until the next falling price phase starts. Updates every minute.",
"usage_tips": "Use to time flexible loads: delay until the upcoming price drop."
},
"next_flat_phase_in_minutes": {
"description": "Minutes until the next flat (stable) price phase begins",
"long_description": "Shows how many minutes until the next flat price phase starts. Updates every minute.",
"usage_tips": "Use to anticipate price stabilisation after a volatile phase."
},
"chart_data_export": {
"description": "Data-export voor dashboard-integraties",
"long_description": "Deze sensor roept de get_chartdata-service aan met jouw geconfigureerde YAML-configuratie en stelt het resultaat beschikbaar als entiteitsattributen. De status toont 'ready' wanneer data beschikbaar is, 'error' bij fouten, of 'pending' voor de eerste aanroep. Perfekt voor dashboard-integraties zoals ApexCharts die prijsgegevens uit entiteitsattributen moeten lezen.",
@ -495,6 +570,61 @@
"description": "Lichtgewicht metadata voor diagramconfiguratie",
"long_description": "Biedt essentiële diagramconfiguratiewaarden als sensorattributen. Nuttig voor elke grafiekkaart die Y-as-grenzen nodig heeft. De sensor roept get_chartdata aan in alleen-metadata-modus (geen dataverwerking) en extraheert: yaxis_min, yaxis_max (gesuggereerd Y-asbereik voor optimale schaling). De status weerspiegelt het service-aanroepresultaat: 'ready' bij succes, 'error' bij fouten, 'pending' tijdens initialisatie.",
"usage_tips": "Configureer via configuration.yaml onder tibber_prices.chart_metadata_config (optioneel: day, subunit_currency, resolution). De sensor wordt automatisch bijgewerkt bij prijsgegevenswijzigingen. Krijg toegang tot metadata vanuit attributen: yaxis_min, yaxis_max. Gebruik met config-template-card of elk hulpmiddel dat entiteitsattributen leest - perfect voor dynamische diagramconfiguratie zonder handmatige berekeningen."
},
"current_interval_price_rank_today": {
"description": "Waar de huidige intervalprijs staat in de ranglijst van vandaag — percentielrang (0% = goedkoopste moment)",
"long_description": "Toont hoe goedkoop of duur de prijs van het huidige kwartier is vergeleken met alle 96 kwartierslots van vandaag. 0% betekent dat dit het goedkoopste moment van de dag is. 50% betekent dat de helft van de slots goedkoper is. ca. 99% betekent het duurste slot van de dag. Formule: aantal goedkopere slots ÷ totaal slots × 100. Attributen: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Ideaal voor automatiseringen: 'Als current_interval_price_rank_today < 25, start de vaatwasser'. Een waarde van 0 garandeert het goedkoopste slot van de dag."
},
"current_interval_price_rank_tomorrow": {
"description": "Percentielrang van de huidige intervalprijs in de ranglijst van morgen (0% = goedkoopste van morgen)",
"long_description": "Toont hoe de huidige intervalprijs zich verhoudt tot alle 96 kwartierslots van morgen. 0% betekent dat de huidige prijs goedkoper is dan elk slot van morgen. Geeft 'Onbekend' terug totdat de data van morgen beschikbaar is (doorgaans na 13:00). Attributen: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Gebruik om te beslissen of wachten loont: 'Als current_interval_price_rank_tomorrow < 10, zijn er morgen nog goedkopere slots — stel de taak uit'."
},
"current_interval_price_rank_today_tomorrow": {
"description": "Percentielrang van de huidige intervalprijs over vandaag+morgen samen (0% = goedkoopste van het twee-dagenvenster)",
"long_description": "Toont hoe goedkoop of duur de huidige intervalprijs is vergeleken met alle slots over vandaag en morgen samen (tot 192 kwartierslots). Valt terug op alleen vandaag als de data van morgen nog niet beschikbaar is. Attributen: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Het breedste signaal voor 'is dit nu een goed moment?'. Gebruik 'Als current_interval_price_rank_today_tomorrow < 20, voer energieintensieve taak nu uit'."
},
"next_interval_price_rank_today": {
"description": "Percentielrang van de volgende intervalprijs in de ranglijst van vandaag (0% = goedkoopste moment van vandaag)",
"long_description": "Toont de percentielrang van het komende kwartier binnen de 96 slots van vandaag. Biedt een vooruitblik voordat het volgende interval begint. Attributen: `next_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Voor voorbereiding: 'Als next_interval_price_rank_today < 15, begin nu met voorverwarmen zodat het apparaat in het volgende goedkope slot draait'."
},
"next_interval_price_rank_today_tomorrow": {
"description": "Percentielrang van de volgende intervalprijs over vandaag+morgen samen (0% = goedkoopste van het twee-dagenvenster)",
"long_description": "Toont de percentielrang van het komende kwartier binnen de gecombineerde pool van vandaag+morgen (tot 192 slots). Valt terug op alleen vandaag. Attributen: `next_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Breedste vooruitblik: 'Als next_interval_price_rank_today_tomorrow < 10, is het volgende interval één van de goedkoopste van het twee-dagenvenster'."
},
"previous_interval_price_rank_today": {
"description": "Percentielrang van de vorige intervalprijs in de ranglijst van vandaag (0% = goedkoopste moment van vandaag)",
"long_description": "Toont de percentielrang van het zojuist afgelopen kwartier binnen de 96 slots van vandaag. Handig om te loggen hoe goedkoop of duur het vorige interval was. Attributen: `previous_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Nuttig voor retrospectieve automatiseringen of logging: 'Leg de kostencategorie van het vorige interval vast voor energierapporten'."
},
"previous_interval_price_rank_today_tomorrow": {
"description": "Percentielrang van de vorige intervalprijs over vandaag+morgen samen (0% = goedkoopste van het twee-dagenvenster)",
"long_description": "Toont de percentielrang van het zojuist afgelopen kwartier binnen de gecombineerde pool van vandaag+morgen (tot 192 slots). Valt terug op alleen vandaag. Attributen: `previous_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Voor retrospectieve vergelijkingen over een twee-dagenvenster."
},
"current_hour_price_rank_today": {
"description": "Percentielrang van het huidige voortschrijdend uurgemiddelde in de ranglijst van vandaag (0% = goedkoopste uur vandaag)",
"long_description": "Toont waar het voortschrijdend gemiddelde van 5 intervallen (2 voor + huidig + 2 na, ca. 1 uur) staat in de prijsranglijst van vandaag. Egaliseer prijspieken voor een bredere inschatting. Attributen: `current_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Voor taken van ongeveer een uur: 'Als current_hour_price_rank_today < 20, is dit een goedkoop uur — start de wasmachine'."
},
"current_hour_price_rank_today_tomorrow": {
"description": "Voortschrijdend uurgemiddelde prijsrang over vandaag+morgen samen (0% = goedkoopste uur van het twee-dagenvenster)",
"long_description": "Toont waar het voortschrijdend gemiddelde van 5 intervallen (±2 intervallen, ca. 1 uur) staat in de gecombineerde ranglijst vandaag+morgen (tot 192 slots). Valt terug op alleen vandaag. Attributen: `current_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Breedste uursignaal: 'Als current_hour_price_rank_today_tomorrow < 15, is dit één van de goedkoopste uren van het twee-dagenvenster'."
},
"next_hour_price_rank_today": {
"description": "Percentielrang van het volgende voortschrijdend uurgemiddelde in de ranglijst van vandaag (0% = goedkoopste uur vandaag)",
"long_description": "Toont waar het voortschrijdend gemiddelde van 5 intervallen gecentreerd op het volgende interval staat in de prijsranglijst van vandaag. Maakt planning een uur vooruit mogelijk. Attributen: `next_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Plan een uur vooruit: 'Als next_hour_price_rank_today < 20, is het komende uur goedkoop — start nu een taak'."
},
"next_hour_price_rank_today_tomorrow": {
"description": "Volgend voortschrijdend uurgemiddelde prijsrang over vandaag+morgen samen (0% = goedkoopste uur van het twee-dagenvenster)",
"long_description": "Toont waar het voortschrijdend gemiddelde van 5 intervallen gecentreerd op het volgende interval staat in de gecombineerde ranglijst vandaag+morgen (tot 192 slots). Valt terug op alleen vandaag. Attributen: `next_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Breedste uurvooruitblik: 'Als next_hour_price_rank_today_tomorrow < 10, is het komende uur één van de goedkoopste van het twee-dagenvenster'."
}
},
"binary_sensor": {
@ -513,6 +643,21 @@
"long_description": "Wordt geactiveerd wanneer de huidige prijs in de onderste 20% van de prijzen van vandaag ligt",
"usage_tips": "Gebruik dit om apparaten met hoog verbruik te laten draaien tijdens de goedkoopste intervallen"
},
"in_rising_price_phase": {
"description": "Whether prices are currently in a rising phase",
"long_description": "Turns ON when the current intra-day price phase is rising.",
"usage_tips": "Use in automations to delay or avoid running flexible loads during rising prices."
},
"in_falling_price_phase": {
"description": "Whether prices are currently in a falling phase",
"long_description": "Turns ON when the current intra-day price phase is falling.",
"usage_tips": "Use in automations to take advantage of falling prices for flexible loads."
},
"in_flat_price_phase": {
"description": "Whether prices are currently in a flat (stable) phase",
"long_description": "Turns ON when the current intra-day price phase is flat and prices are relatively stable.",
"usage_tips": "Use for loads that prefer price stability rather than the lowest price."
},
"connection": {
"description": "Of de verbinding met de Tibber API werkt",
"long_description": "Geeft aan of de integratie succesvol verbinding kan maken met de Tibber API",

View file

@ -309,13 +309,13 @@
},
"next_price_trend_change": {
"description": "När nästa betydande pristrendändring kommer att inträffa",
"long_description": "Skannar de nästa 24 timmarna (96 intervaller) för att hitta när pristrend-riktningen kommer att ändras. Bara riktningsändringar räknas: stigande/kraftigt stigande är en grupp, fallande/kraftigt fallande en annan, stabil är egen. En ändring från stigande till kraftigt stigande är INTE en trendändring. Använder volatilitetsadaptiva tröskelvärden (standard: ±3%/±9%) med hysteres (standard: 3 på varandra följande intervaller). Returnerar tidstämpeln när ändringen förväntas.",
"usage_tips": "Händelsestyrd automatisering: Utlös åtgärder NÄR trenden ändras, inte OM X timmar. Exempel: 'Ladda EV när nästa trendändring visar fallande priser' eller 'Starta diskmaskin innan priserna stiger'. Kompletterar tidsfönster-sensorer (price_outlook_Xh) som svarar på 'KOMMER priserna att vara högre om X timmar?'"
"long_description": "Skannar de nästa 24 timmarna (96 intervaller) för att hitta när pristrend-riktningen kommer att ändras. Bara riktningsändringar räknas: stigande/kraftigt stigande är en grupp, fallande/kraftigt fallande en annan, stabil är egen. En ändring från stigande till kraftigt stigande är INTE en trendändring. Använder volatilitetsadaptiva tröskelvärden (standard: ±3%/±9%) med hysteres (standard: 3 på varandra följande intervaller). Returnerar tidstämpeln när ändringen förväntas.\n\nVIKTIGT — Hur detektionen fungerar: Vid varje framtida intervall jämför sensorn priset för det intervallet med MEDELVÄRDET av de följande 3 timmarna (3h framåtblickande medelvärde). Det innebär att sensorn upptäcker när de genomsnittliga kostnaderna för de nästa 3 timmarna redan har bytt riktning — inte när det exakta prisminimumet eller -maximumet nås.\n\nPå V-formade prisdagar: Under ett prisfall mot ett minimum börjar 3h-framåtfönstret inkludera priser på den stigande flanken innan det faktiska minimumet nås. När dessa stigande priser drar upp 3h-medelvärdet över det aktuella priset rapporterar sensorn 'trenden ändras nu'. Detta inträffar typiskt 3060 minuter före det exakta prisminimumet. Det är avsiktligt — sensorn svarar på 'när kommer den övergripande RIKTNINGEN att ändras?' snarare än 'när är den exakta vändpunkten?'.",
"usage_tips": "Händelsestyrd automatisering: Utlös åtgärder NÄR trenden ändras, inte OM X timmar. Exempel: 'Ladda EV när nästa trendändring visar fallande priser' eller 'Starta diskmaskin innan priserna stiger'. Kompletterar tidsfönster-sensorer (price_outlook_Xh) som svarar på 'KOMMER priserna att vara högre om X timmar?'\n\nObservera: På skarpa V-formade prisdagar kan den här sensorn aktiveras 3060 minuter före det exakta prisminimumet. Om du behöver en exakt vändpunkt, jämför den med starten av Bästa Pris-perioden — perioden startar vid det faktiskt billigaste fönstret. Den här sensorn lämpar sig bättre för automatiseringsutlösare av typen 'förbered för en förändring' där en kort tidig varning är acceptabel."
},
"trend_change_in_minutes": {
"next_price_trend_change_in": {
"description": "Tid till nästa pristrendändring",
"long_description": "Visar hur lång tid det är kvar till nästa betydande pristrendändring inträffar. Värdet visas i timmar (t.ex. 2,25 h) för dashboards. Delar samma analys som tidstämpel-sensorn 'Nästa pristrendändring' men presenterar det som en nedtellningsvaraktighet. Uppdateras varje minut för noggranna nedtellningar. Visar 'Okänd' när ingen trendändring förväntas inom de närmaste 24 timmarna.",
"usage_tips": "Dashboard-nedtellning: Visa 'Trendändring om 1,5 h' som live nedtellning. För automatiseringar: 'Om trend_change_in_minutes < 0,25 (15 min), förbered för prisriktningsändring'. Kompletterar 'Nästa pristrendändring' (tidstämpel) — använd tidstämpeln för 'NÄR' och denna sensor för 'HUR LÄNGE'."
"long_description": "Visar hur lång tid det är kvar till nästa betydande pristrendändring inträffar. Värdet visas i timmar (t.ex. 2,25 h) för dashboards. Delar samma analys som tidstämpel-sensorn 'Nästa pristrendändring' men presenterar det som en nedtellningsvaraktighet. Uppdateras varje minut för noggranna nedtellningar. Visar 'Okänd' när ingen trendändring förväntas inom de närmaste 24 timmarna. Se 'Nästa pristrendändring' för en förklaring av 3h-framåtblickande detektionsmekanismen och dess beteende på V-formade prisdagar.",
"usage_tips": "Dashboard-nedtellning: Visa 'Trendändring om 1,5 h' som live nedtellning. För automatiseringar: 'Om next_price_trend_change_in < 0,25 (15 min), förbered för prisriktningsändring'. Kompletterar 'Nästa pristrendändring' (tidstämpel) — använd tidstämpeln för 'NÄR' och denna sensor för 'HUR LÄNGE'."
},
"daily_rating": {
"description": "Hur dagens priser jämförs med historiska data",
@ -486,6 +486,81 @@
"long_description": "Visar om ditt Tibber-abonnemang för närvarande är aktivt, har avslutats eller väntar på aktivering. En status 'Aktiv' betyder att du aktivt tar emot elektricitet genom Tibber.",
"usage_tips": "Använd detta för att övervaka din abonnemangsstatus. Ställ in varningar om statusen ändras från 'Aktiv' för att säkerställa oavbruten service."
},
"day_pattern_yesterday": {
"description": "Detekterat prismönster för gårdagens elpriser",
"long_description": "Klassificerar igår i ett prismönster: Dal (billigt på mitten), Topp (dyrt på mitten), Dubbeldal (W-form, två billiga perioder), Dubbeltopp (M-form, två dyra toppar), Flat (liten variation), Stigande, Fallande eller Blandad. Konfidensen och CV-attributen visar hur tillförlitligt mönstret detekterades.",
"usage_tips": "Använd gårdagens mönster för att förfina automationer: ett Dalmönster upprepas ofta nästa dag och tyder på att du bör förplanera billiga middagstimmar."
},
"day_pattern_today": {
"description": "Detekterat prismönster för dagens elpriser",
"long_description": "Klassificerar idag i ett prismönster: Dal (billigt på middagen), Topp (dyrt på middagen), Dubbeldal (W-form), Dubbeltopp (M-form), Flat, Stigande, Fallande eller Blandad. Attributen inkluderar konfidenspoäng (01), variationskoefficient, knäpunkttider och dagsegment.",
"usage_tips": "Använd dagens mönster för att flytta förbrukning. Daldag: kör diskmaskinen, tvättmaskinen eller ladda elbilen på middagen. Toppdag: kör apparater tidigt på morgonen eller sent på kvällen. Använd valley_start och valley_end för precisa automationer."
},
"day_pattern_tomorrow": {
"description": "Detekterat prismönster för morgondagens elpriser",
"long_description": "Klassificerar imorgon (när data finns tillgänglig, vanligtvis efter 13:00) i ett prismönster med samma algoritm som idag. Attributen valley_start/valley_end eller peak_start/peak_end ger knäpunkttider för det primära extremvärdet.",
"usage_tips": "Ställ in kvällsautomationer som läser morgondagens mönster och förkonfigurerar värmepump, billaddare eller varmvattenberedare. Kombinera med tomorrow_data_available-binärsensorn."
},
"current_price_phase": {
"description": "Om elpriserna för närvarande stiger, faller eller är stabila inom dagens intra-dag prisform",
"long_description": "Visar riktningen på priserörelsen just nu genom att identifiera det aktiva monotona segmentet i dagens priskurva. Dagens priser delas upp i på varandra följande stigande, fallande eller flata sträckor (faser). Denna sensor visar vilken fas som är aktiv just nu. Attribut inkluderar fasens start- och sluttid, prisintervall (min/max/medel), position bland dagens faser (segment_index och segment_count) samt den fullständiga listan över alla dagens faser (all_segments). Uppdateras var 15:e minut.",
"usage_tips": "Använd i automationer: 'Om current_price_phase = fallande, vänta med flexibla laster tills priserna når botten'. Kombinera med Dagens Prismönster för att se både den övergripande dagformen och din aktuella position i den. Kontrollera segment_index och segment_count för att förstå hur långt in i rörelsen du är. Använd all_segments i mallar eller dashboards för att visa hela dagsförloppet."
},
"next_price_phase": {
"description": "Nästa intra-dag prisfas vad som kommer efter den aktuella prisbevegelsen",
"long_description": "Visar det monotona prissegment som följer efter den för närvarande aktiva fasen. Attributet start visar exakt när nästa fas börjar, vilket gör det enkelt att schemalägga automationer. När den aktuella fasen är den sista för dagen (t.ex. den sista kvällsminskningen), blir denna sensor otillgänglig. Attribut: start (när den börjar), end, prisintervall (min/max/medel), segment_index, segment_count. Uppdateras var 15:e minut.",
"usage_tips": "Använd i automationer: 'Om next_price_phase = stigande och next_price_phase.start är inom 1 timme, starta tvättmaskinen nu'. Eller kombinera med current_price_phase: 'Om current_price_phase = fallande och next_price_phase = flat, närmar vi oss dagens lågpunkt bra tid för flexibla laster'. Attributet start är särskilt värdefull: utlös automationer precis när nästa fas börjar."
},
"current_price_phase_end_time": {
"description": "When the current intra-day price phase ends",
"long_description": "Shows the exact timestamp when the currently active rising, falling, or flat price phase will end and transition to the next phase.",
"usage_tips": "Use in automations to schedule tasks that must finish before prices change."
},
"current_price_phase_remaining_minutes": {
"description": "Minutes remaining in the current price phase",
"long_description": "Shows how many minutes are left in the current intra-day price phase. Updates every minute.",
"usage_tips": "Use in automations: 'If current_price_phase = falling and remaining < 30, start the dishwasher now'."
},
"current_price_phase_duration": {
"description": "Total duration of the current price phase",
"long_description": "Shows the total length of the currently active price phase in hours.",
"usage_tips": "Combine with remaining minutes to understand how far through the phase you are."
},
"current_price_phase_progress": {
"description": "How far through the current price phase we are",
"long_description": "Shows the percentage of the current intra-day price phase that has elapsed (0100%). Updates every minute.",
"usage_tips": "Use in dashboard cards to display a visual progress bar for the current price phase."
},
"next_rising_phase_start_time": {
"description": "When the next rising price phase begins",
"long_description": "Shows the timestamp of the next upcoming rising price segment across today's remaining phases and tomorrow's phases.",
"usage_tips": "Use to schedule loads before prices start rising again."
},
"next_falling_phase_start_time": {
"description": "When the next falling price phase begins",
"long_description": "Shows the timestamp of the next upcoming falling price segment across today's remaining phases and tomorrow's phases.",
"usage_tips": "Use to delay flexible loads until the next price drop starts."
},
"next_flat_phase_start_time": {
"description": "When the next flat (stable) price phase begins",
"long_description": "Shows the timestamp of the next upcoming flat price segment across today's remaining phases and tomorrow's phases.",
"usage_tips": "Use for scheduling loads that need predictable costs over time."
},
"next_rising_phase_in_minutes": {
"description": "Minutes until the next rising price phase begins",
"long_description": "Shows how many minutes until the next rising price phase starts. Updates every minute.",
"usage_tips": "Use in countdown automations to alert before the next price rise."
},
"next_falling_phase_in_minutes": {
"description": "Minutes until the next falling price phase begins",
"long_description": "Shows how many minutes until the next falling price phase starts. Updates every minute.",
"usage_tips": "Use to time flexible loads: delay until the upcoming price drop."
},
"next_flat_phase_in_minutes": {
"description": "Minutes until the next flat (stable) price phase begins",
"long_description": "Shows how many minutes until the next flat price phase starts. Updates every minute.",
"usage_tips": "Use to anticipate price stabilisation after a volatile phase."
},
"chart_data_export": {
"description": "Dataexport för dashboard-integrationer",
"long_description": "Denna sensor anropar get_chartdata-tjänsten med din konfigurerade YAML-konfiguration och exponerar resultatet som entitetsattribut. Statusen visar 'ready' när data är tillgänglig, 'error' vid fel, eller 'pending' före första anropet. Perfekt för dashboard-integrationer som ApexCharts som behöver läsa prisdata från entitetsattribut.",
@ -495,6 +570,61 @@
"description": "Lättviktig metadata för diagramkonfiguration",
"long_description": "Tillhandahåller väsentliga diagramkonfigurationsvärden som sensorattribut. Användbart för vilket diagramkort som helst som behöver Y-axelgränser. Sensorn anropar get_chartdata med endast-metadata-läge (ingen databehandling) och extraherar: yaxis_min, yaxis_max (föreslagen Y-axelomfång för optimal skalning). Statusen återspeglar tjänstanropsresultatet: 'ready' vid framgång, 'error' vid fel, 'pending' under initialisering.",
"usage_tips": "Konfigurera via configuration.yaml under tibber_prices.chart_metadata_config (valfritt: day, subunit_currency, resolution). Sensorn uppdateras automatiskt vid pris dataändringar. Få tillgång till metadata från attribut: yaxis_min, yaxis_max. Använd med config-template-card eller vilket verktyg som helst som läser entitetsattribut - perfekt för dynamisk diagramkonfiguration utan manuella beräkningar."
},
"current_interval_price_rank_today": {
"description": "Var det aktuella intervallpriset placerar sig i dagens rangordning — percentilrang (0 % = billigaste tillfället)",
"long_description": "Visar hur billigt eller dyrt det aktuella kvartspriset är jämfört med alla 96 kvartsslotar idag. 0 % innebär att detta är det billigaste tillfället under dagen. 50 % innebär att hälften av dagens slotar är billigare. ca. 99 % innebär det dyraste slottet. Formel: antal billigare slotar ÷ totalt antal × 100. Attribut: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Idealiskt för automatiseringar: 'Om current_interval_price_rank_today < 25, starta diskmaskinen'. Ett värde på 0 garanterar att du är på det billigaste slottet under dagen."
},
"current_interval_price_rank_tomorrow": {
"description": "Percentilrang för aktuellt intervallpris i morgondagens rangordning (0 % = billigaste imorgon)",
"long_description": "Visar hur det aktuella intervallpriset jämförs med alla 96 kvartslotar imorgon. 0 % innebär att det aktuella priset är billigare än varje slot imorgon. Returnerar 'Okänd' tills morgondagens data är tillgänglig (vanligtvis efter kl. 13:00). Attribut: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Används för att avgöra om väntan lönar sig: 'Om current_interval_price_rank_tomorrow < 10, finns det ännu billigare slotar imorgon — skjut upp uppgiften'."
},
"current_interval_price_rank_today_tomorrow": {
"description": "Percentilrang för aktuellt intervallpris över idag+imorgon sammantaget (0 % = billigaste i tvådagarsperioden)",
"long_description": "Visar hur billigt eller dyrt det aktuella intervallpriset är jämfört med alla slotar idag och imorgon tillsammans (upp till 192 kvartsslotar). Faller tillbaka på enbart idag när morgondagens data inte är tillgänglig. Attribut: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Den bredaste signalen för 'är det ett bra tillfälle nu?'. Använd 'Om current_interval_price_rank_today_tomorrow < 20, kör energikrävande uppgift nu'."
},
"next_interval_price_rank_today": {
"description": "Percentilrang för nästa intervalls pris i dagens rangordning (0 % = billigaste tillfället idag)",
"long_description": "Visar percentilrangen för det kommande kvartalet inom dagens 96 slotar. Ger en förhandstitt innan nästa intervall börjar. Attribut: `next_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "För förberedelse: 'Om next_interval_price_rank_today < 15, börja förvärmningen nu så att enheten körs under nästa billiga slot'."
},
"next_interval_price_rank_today_tomorrow": {
"description": "Percentilrang för nästa intervalls pris över idag+imorgon sammantaget (0 % = billigaste i tvådagarsperioden)",
"long_description": "Visar percentilrangen för det kommande kvartalet inom den kombinerade idag+imorgon-poolen (upp till 192 slotar). Faller tillbaka på enbart idag. Attribut: `next_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Bredaste framtidsskick: 'Om next_interval_price_rank_today_tomorrow < 10, är nästa intervall bland de billigaste i tvådagarsfönstret'."
},
"previous_interval_price_rank_today": {
"description": "Percentilrang för föregående intervalls pris i dagens rangordning (0 % = billigaste tillfället idag)",
"long_description": "Visar percentilrangen för det nyligen avslutade kvartalet inom dagens 96 slotar. Användbart för att logga hur billigt eller dyrt det föregående intervallet var. Attribut: `previous_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Användbart för retrospektiva automatiseringar eller loggning: 'Registrera kostnadsnivån för det föregående intervallet i energirapporter'."
},
"previous_interval_price_rank_today_tomorrow": {
"description": "Percentilrang för föregående intervalls pris över idag+imorgon sammantaget (0 % = billigaste i tvådagarsperioden)",
"long_description": "Visar percentilrangen för det nyligen avslutade kvartalet inom den kombinerade idag+imorgon-poolen (upp till 192 slotar). Faller tillbaka på enbart idag. Attribut: `previous_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "För retrospektiva jämförelser inom ett tvådagarsfönster."
},
"current_hour_price_rank_today": {
"description": "Percentilrang för aktuellt rullande timgenom­snittspris i dagens rangordning (0 % = billigaste timmen idag)",
"long_description": "Visar var det rullande 5-intervallets genomsnitt (2 intervall före + aktuellt + 2 efter, ca. 1 timme) placerar sig i dagens prisrangordning. Jämnar ut korta pristoppar. Attribut: `current_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "För uppgifter som tar ungefär en timme: 'Om current_hour_price_rank_today < 20, är detta en billig timme — starta tvättmaskinen'."
},
"current_hour_price_rank_today_tomorrow": {
"description": "Rullande timgenomsnittsprisrang över idag+imorgon sammantaget (0 % = billigaste timmen i tvådagarsfönstret)",
"long_description": "Visar var det rullande 5-intervallets genomsnitt (±2 intervall, ca. 1 timme) placerar sig i den kombinerade idag+imorgon-rangordningen (upp till 192 slotar). Faller tillbaka på enbart idag. Attribut: `current_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Bredaste timsignal: 'Om current_hour_price_rank_today_tomorrow < 15, är detta en av de billigaste timmarna i tvådagarsfönstret'."
},
"next_hour_price_rank_today": {
"description": "Percentilrang för nästa rullande timgenom­snittspris i dagens rangordning (0 % = billigaste timmen idag)",
"long_description": "Visar var det 5-intervallsgenomsnitt centrerat på nästa intervall placerar sig i dagens prisrangordning. Möjliggör planering en timme framåt. Attribut: `next_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Planera en timme framåt: 'Om next_hour_price_rank_today < 20, är den kommande timmen billig — starta en uppgift nu'."
},
"next_hour_price_rank_today_tomorrow": {
"description": "Nästa rullande timgenom­snittsprisrang över idag+imorgon sammantaget (0 % = billigaste timmen i tvådagarsfönstret)",
"long_description": "Visar var det 5-intervallsgenomsnitt centrerat på nästa intervall placerar sig i den kombinerade idag+imorgon-rangordningen (upp till 192 slotar). Faller tillbaka på enbart idag. Attribut: `next_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
"usage_tips": "Bredaste timframtidsskick: 'Om next_hour_price_rank_today_tomorrow < 10, är den kommande timmen bland de billigaste i tvådagarsfönstret'."
}
},
"binary_sensor": {
@ -513,6 +643,21 @@
"long_description": "Aktiveras när nuvarande pris ligger i botten 20% av dagens priser",
"usage_tips": "Använd detta för att köra högkonsumtionsapparater under de billigaste intervallerna"
},
"in_rising_price_phase": {
"description": "Whether prices are currently in a rising phase",
"long_description": "Turns ON when the current intra-day price phase is rising.",
"usage_tips": "Use in automations to delay or avoid running flexible loads during rising prices."
},
"in_falling_price_phase": {
"description": "Whether prices are currently in a falling phase",
"long_description": "Turns ON when the current intra-day price phase is falling.",
"usage_tips": "Use in automations to take advantage of falling prices for flexible loads."
},
"in_flat_price_phase": {
"description": "Whether prices are currently in a flat (stable) phase",
"long_description": "Turns ON when the current intra-day price phase is flat and prices are relatively stable.",
"usage_tips": "Use for loads that prefer price stability rather than the lowest price."
},
"connection": {
"description": "Om anslutningen till Tibber API fungerar",
"long_description": "Indikerar om integrationen framgångsrikt kan ansluta till Tibber API",

View file

@ -16,7 +16,7 @@ if TYPE_CHECKING:
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, # noqa: ARG001
hass: HomeAssistant,
entry: TibberPricesConfigEntry,
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""

View file

@ -5,7 +5,7 @@ from __future__ import annotations
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ATTRIBUTION, DOMAIN, get_home_type_translation, get_translation
from .const import ATTRIBUTION, DOMAIN, INTEGRATION_VERSION, get_home_type_translation, get_translation
from .coordinator import TibberPricesDataUpdateCoordinator
@ -41,6 +41,7 @@ class TibberPricesEntity(CoordinatorEntity[TibberPricesDataUpdateCoordinator]):
manufacturer="Tibber",
model=translated_model,
serial_number=home_id or None,
sw_version=INTEGRATION_VERSION,
configuration_url="https://developer.tibber.com/explorer",
)
@ -134,7 +135,7 @@ class TibberPricesEntity(CoordinatorEntity[TibberPricesDataUpdateCoordinator]):
home_name = f"{home_name}, {city}"
else:
home_name = "Tibber Home"
except (KeyError, IndexError, TypeError):
except KeyError, IndexError, TypeError:
return "Tibber Home", None
else:
return home_name, home_type

View file

@ -18,19 +18,9 @@ For pure data transformation (no HA dependencies), see utils/ package.
from __future__ import annotations
from .attributes import (
add_description_attributes,
async_add_description_attributes,
build_period_attributes,
build_timestamp_attribute,
)
from .attributes import add_description_attributes, async_add_description_attributes
from .colors import add_icon_color_attribute, get_icon_color
from .helpers import (
find_rolling_hour_center_index,
get_price_value,
translate_level,
translate_rating_level,
)
from .helpers import find_rolling_hour_center_index, get_price_value
from .icons import (
get_binary_sensor_icon,
get_dynamic_icon,
@ -46,8 +36,6 @@ __all__ = [
"add_description_attributes",
"add_icon_color_attribute",
"async_add_description_attributes",
"build_period_attributes",
"build_timestamp_attribute",
"find_rolling_hour_center_index",
"get_binary_sensor_icon",
"get_dynamic_icon",
@ -59,6 +47,4 @@ __all__ = [
"get_rating_sensor_icon",
"get_trend_icon",
"get_volatility_sensor_icon",
"translate_level",
"translate_rating_level",
]

View file

@ -10,46 +10,7 @@ if TYPE_CHECKING:
from ..data import TibberPricesConfigEntry # noqa: TID252
def build_timestamp_attribute(interval_data: dict | None) -> str | None:
"""
Build timestamp attribute from interval data.
Extracts startsAt field consistently across all sensors.
Args:
interval_data: Interval data dictionary containing startsAt field
Returns:
ISO format timestamp string or None
"""
if not interval_data:
return None
return interval_data.get("startsAt")
def build_period_attributes(period_data: dict) -> dict:
"""
Build common period attributes (start, end, duration, timestamp).
Used by binary sensors for period-based entities.
Args:
period_data: Period data dictionary
Returns:
Dictionary with common period attributes
"""
return {
"start": period_data.get("start"),
"end": period_data.get("end"),
"duration_minutes": period_data.get("duration_minutes"),
"timestamp": period_data.get("start"), # Timestamp = period start
}
def add_description_attributes( # noqa: PLR0913, PLR0912
def add_description_attributes(
attributes: dict,
platform: str,
translation_key: str | None,
@ -152,7 +113,7 @@ def add_description_attributes( # noqa: PLR0913, PLR0912
attributes[key] = value
async def async_add_description_attributes( # noqa: PLR0913, PLR0912
async def async_add_description_attributes(
attributes: dict,
platform: str,
translation_key: str | None,

View file

@ -65,10 +65,14 @@ def get_icon_color(
return BINARY_SENSOR_COLOR_MAPPING[key].get(state_key)
# Trend sensor colors (based on trend state)
if key.startswith("price_trend_") and isinstance(state_value, str):
if (
key.startswith(("price_trend_", "price_outlook_", "price_trajectory_")) or key == "current_price_trend"
) and isinstance(state_value, str):
trend_colors = {
"strongly_rising": "var(--error-color)",
"rising": "var(--error-color)", # Red/Orange for rising prices
"falling": "var(--success-color)", # Green for falling prices
"strongly_falling": "var(--success-color)",
"stable": "var(--state-icon-color)", # Default gray for stable
}
return trend_colors.get(state_value)

View file

@ -3,7 +3,7 @@ Common helper functions for entities across platforms.
This module provides utility functions used by both sensor and binary_sensor platforms:
- Price value conversion (major/subunit currency units)
- Translation helpers (price levels, ratings)
- Time-based calculations (rolling hour center index)
These functions operate on entity-level concepts (states, translations) but are
@ -14,7 +14,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from custom_components.tibber_prices.const import get_display_unit_factor, get_price_level_translation
from custom_components.tibber_prices.const import get_display_precision, get_display_unit_factor
if TYPE_CHECKING:
from datetime import datetime
@ -22,7 +22,6 @@ if TYPE_CHECKING:
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
from custom_components.tibber_prices.data import TibberPricesConfigEntry
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
def get_price_value(
@ -56,60 +55,13 @@ def get_price_value(
# New mode: use config_entry
if config_entry is not None:
factor = get_display_unit_factor(config_entry)
return round(price * factor, 2)
precision = get_display_precision(config_entry)
return round(price * factor, precision)
# Fallback: default to subunit currency (backward compatibility)
return round(price * 100, 2)
def translate_level(hass: HomeAssistant, level: str) -> str:
"""
Translate price level to the user's language.
Args:
hass: HomeAssistant instance for language configuration
level: Price level to translate (e.g., VERY_CHEAP, NORMAL, etc.)
Returns:
Translated level string, or original level if translation not found
"""
if not hass:
return level
language = hass.config.language or "en"
translated = get_price_level_translation(level, language)
if translated:
return translated
if language != "en":
fallback = get_price_level_translation(level, "en")
if fallback:
return fallback
return level
def translate_rating_level(rating: str) -> str:
"""
Translate price rating level to the user's language.
Args:
rating: Price rating to translate (e.g., LOW, NORMAL, HIGH)
Returns:
Translated rating string, or original rating if translation not found
Note:
Currently returns the rating as-is. Translation mapping for ratings
can be added here when needed, similar to translate_level().
"""
# For now, ratings are returned as-is
# Add translation mapping here when needed
return rating
def find_rolling_hour_center_index(
all_prices: list[dict],
current_time: datetime,

View file

@ -35,6 +35,7 @@ class TibberPricesIconContext:
has_future_periods_callback: Callable[[], bool] | None = None
period_is_active_callback: Callable[[], bool] | None = None
time: TibberPricesTimeService | None = None
trend_change_direction: str | None = None # For next_price_trend_change icon lookup
if TYPE_CHECKING:
@ -74,7 +75,7 @@ def get_dynamic_icon(
# Try various icon sources in order
return (
get_trend_icon(key, value)
get_trend_icon(key, value, context=ctx)
or get_timing_sensor_icon(key, value, period_is_active_callback=ctx.period_is_active_callback)
or get_price_sensor_icon(key, ctx.coordinator_data, time=ctx.time)
or get_level_sensor_icon(key, value)
@ -84,28 +85,32 @@ def get_dynamic_icon(
)
def get_trend_icon(key: str, value: Any) -> str | None:
"""Get icon for trend sensors using 5-level trend scale."""
# Handle next_price_trend_change TIMESTAMP sensor differently
# (icon based on attributes, not value which is a timestamp)
if key == "next_price_trend_change":
return None # Will be handled by sensor's icon property using attributes
# 5-level trend icons: strongly uses double arrows, normal uses single
_TREND_ICONS = {
"strongly_rising": "mdi:chevron-double-up",
"rising": "mdi:trending-up",
"stable": "mdi:trending-neutral",
"falling": "mdi:trending-down",
"strongly_falling": "mdi:chevron-double-down",
}
if not key.startswith("price_trend_") and key != "current_price_trend":
def get_trend_icon(key: str, value: Any, *, context: TibberPricesIconContext | None = None) -> str | None:
"""Get icon for trend sensors using 5-level trend scale."""
# next_price_trend_change is a TIMESTAMP sensor — icon comes from direction attribute
if key == "next_price_trend_change":
direction = context.trend_change_direction if context else None
if isinstance(direction, str):
return _TREND_ICONS.get(direction, "mdi:help-circle-outline")
return "mdi:help-circle-outline"
if not key.startswith(("price_trend_", "price_outlook_", "price_trajectory_")) and key != "current_price_trend":
return None
if not isinstance(value, str):
return None
# 5-level trend icons: strongly uses double arrows, normal uses single
trend_icons = {
"strongly_rising": "mdi:chevron-double-up", # Strong upward movement
"rising": "mdi:trending-up", # Normal upward trend
"stable": "mdi:trending-neutral", # No significant change
"falling": "mdi:trending-down", # Normal downward trend
"strongly_falling": "mdi:chevron-double-down", # Strong downward movement
}
return trend_icons.get(value)
return _TREND_ICONS.get(value, "mdi:help-circle-outline")
def get_timing_sensor_icon(

View file

@ -16,18 +16,78 @@
}
},
"get_apexcharts_yaml": {
"service": "mdi:chart-line",
"sections": {
"entry_id": "mdi:identifier",
"day": "mdi:calendar-range",
"level_type": "mdi:format-list-bulleted-type",
"resolution": "mdi:timer-sand",
"highlight_best_price": "mdi:battery-charging-low",
"highlight_peak_price": "mdi:battery-alert"
}
"service": "mdi:chart-line"
},
"refresh_user_data": {
"service": "mdi:refresh"
},
"find_cheapest_block": {
"service": "mdi:washing-machine",
"sections": {
"search_range": "mdi:calendar-search",
"time_alternatives": "mdi:clock-time-eight-outline",
"price_filter": "mdi:filter-variant",
"search_tuning": "mdi:cog-outline",
"cost_estimation": "mdi:lightning-bolt",
"output": "mdi:tune-variant"
}
},
"find_most_expensive_block": {
"service": "mdi:lightning-bolt-circle",
"sections": {
"search_range": "mdi:calendar-search",
"time_alternatives": "mdi:clock-time-eight-outline",
"price_filter": "mdi:filter-variant",
"search_tuning": "mdi:cog-outline",
"cost_estimation": "mdi:lightning-bolt",
"output": "mdi:tune-variant"
}
},
"find_cheapest_hours": {
"service": "mdi:ev-station",
"sections": {
"search_range": "mdi:calendar-search",
"time_alternatives": "mdi:clock-time-eight-outline",
"price_filter": "mdi:filter-variant",
"search_tuning": "mdi:cog-outline",
"cost_estimation": "mdi:lightning-bolt",
"output": "mdi:tune-variant"
}
},
"find_most_expensive_hours": {
"service": "mdi:flash-alert",
"sections": {
"search_range": "mdi:calendar-search",
"time_alternatives": "mdi:clock-time-eight-outline",
"price_filter": "mdi:filter-variant",
"search_tuning": "mdi:cog-outline",
"cost_estimation": "mdi:lightning-bolt",
"output": "mdi:tune-variant"
}
},
"find_cheapest_schedule": {
"service": "mdi:calendar-check",
"sections": {
"search_range": "mdi:calendar-search",
"time_alternatives": "mdi:clock-time-eight-outline",
"price_filter": "mdi:filter-variant",
"search_tuning": "mdi:cog-outline",
"output": "mdi:tune-variant"
}
},
"plan_charging": {
"service": "mdi:battery-charging",
"sections": {
"battery": "mdi:battery",
"charging": "mdi:ev-station",
"search_range": "mdi:calendar-search",
"deadline": "mdi:calendar-clock",
"time_alternatives": "mdi:clock-time-eight-outline",
"price_filter": "mdi:filter-variant",
"search_tuning": "mdi:cog-outline",
"economics": "mdi:cash-multiple",
"output": "mdi:tune-variant"
}
}
}
}

View file

@ -2,16 +2,14 @@
from __future__ import annotations
import logging
from datetime import datetime, timedelta
import logging
from typing import TYPE_CHECKING, Any
from homeassistant.util import dt as dt_utils
from homeassistant.util import dt as dt_util
if TYPE_CHECKING:
from custom_components.tibber_prices.coordinator.time_service import (
TibberPricesTimeService,
)
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
_LOGGER = logging.getLogger(__name__)
_LOGGER_DETAILS = logging.getLogger(__name__ + ".details")
@ -114,7 +112,7 @@ class TibberPricesIntervalPoolFetchGroupCache:
"""
# Use TimeService if available (Time Machine support), else real time
now = self._time_service.now() if self._time_service else dt_utils.now()
now = self._time_service.now() if self._time_service else dt_util.now()
today_date_str = now.date().isoformat()
# Check cache validity (invalidate daily)

View file

@ -2,11 +2,11 @@
from __future__ import annotations
import logging
from datetime import UTC, datetime, timedelta
import logging
from typing import TYPE_CHECKING, Any
from homeassistant.util import dt as dt_utils
from homeassistant.util import dt as dt_util
if TYPE_CHECKING:
from collections.abc import Callable
@ -268,8 +268,10 @@ class TibberPricesIntervalPoolFetcher:
"""
Fetch missing intervals from API.
Makes one API call per missing range. Uses routing logic to select
the optimal endpoint (PRICE_INFO vs PRICE_INFO_RANGE).
Makes API calls per missing range, but skips redundant calls when a
previous fetch already returned intervals covering subsequent ranges.
This is common for the PRICE_INFO endpoint which returns ALL available
intervals (~384) regardless of the requested range.
Args:
api_client: TibberPricesApiClient instance for API calls.
@ -287,14 +289,29 @@ class TibberPricesIntervalPoolFetcher:
"""
# Import here to avoid circular dependency
from custom_components.tibber_prices.interval_pool.routing import ( # noqa: PLC0415
get_price_intervals_for_range,
)
from custom_components.tibber_prices.interval_pool.routing import get_price_intervals_for_range # noqa: PLC0415
fetch_time_iso = dt_utils.now().isoformat()
all_fetched_intervals = []
fetch_time_iso = dt_util.now().isoformat()
all_fetched_intervals: list[list[dict[str, Any]]] = []
# Collect startsAt values from all fetched intervals to detect overlap
fetched_starts_at: set[str] = set()
for idx, (missing_start_iso, missing_end_iso) in enumerate(missing_ranges, start=1):
# Check if a previous fetch already covered this range
if fetched_starts_at and self._range_covered_by_fetched(
missing_start_iso, missing_end_iso, fetched_starts_at
):
_LOGGER_DETAILS.debug(
"Range %s to %s already covered by previous fetch for home %s, skipping API call (%d/%d)",
missing_start_iso,
missing_end_iso,
self._home_id,
idx,
len(missing_ranges),
)
continue
_LOGGER_DETAILS.debug(
"Fetching from Tibber API (%d/%d) for home %s: range %s to %s",
idx,
@ -319,6 +336,10 @@ class TibberPricesIntervalPoolFetcher:
all_fetched_intervals.append(fetched_intervals)
# Track which timestamps we've fetched for overlap detection
for interval in fetched_intervals:
fetched_starts_at.add(interval["startsAt"][:19])
_LOGGER_DETAILS.debug(
"Received %d intervals from Tibber API for home %s",
len(fetched_intervals),
@ -330,3 +351,30 @@ class TibberPricesIntervalPoolFetcher:
on_intervals_fetched(fetched_intervals, fetch_time_iso)
return all_fetched_intervals
@staticmethod
def _range_covered_by_fetched(
start_iso: str,
end_iso: str,
fetched_starts_at: set[str],
) -> bool:
"""
Check if a missing range is already covered by previously fetched intervals.
A range is considered covered if at least one fetched interval falls within
[start, end). This is a conservative check even partial overlap means the
API response likely included data for this range.
Args:
start_iso: Start of the missing range (ISO format).
end_iso: End of the missing range (ISO format).
fetched_starts_at: Set of normalized startsAt strings from previous fetches.
Returns:
True if the range is already covered.
"""
start_normalized = start_iso[:19]
end_normalized = end_iso[:19]
return any(start_normalized <= ts < end_normalized for ts in fetched_starts_at)

View file

@ -2,8 +2,8 @@
from __future__ import annotations
import logging
from datetime import datetime
import logging
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
@ -93,6 +93,19 @@ class TibberPricesIntervalPoolGarbageCollector:
empty_removed,
self._home_id,
)
elif dead_count > 0:
# _cleanup_dead_intervals compacted group["intervals"] lists in-place,
# shifting the positions of surviving intervals. _remove_empty_groups
# only rebuilds the index when it removes completely-empty groups.
# If no groups became empty, the index still holds stale interval_index
# values that now point past the end of the compacted lists, causing
# an IndexError in _get_cached_intervals. Rebuild the index here to
# keep it consistent with the compacted groups.
self._index.rebuild(fetch_groups)
_LOGGER_DETAILS.debug(
"GC rebuilt index after dead interval cleanup for home %s",
self._home_id,
)
# Phase 2: Count total intervals after cleanup
total_intervals = self._cache.count_total_intervals()

View file

@ -4,13 +4,16 @@ from __future__ import annotations
import asyncio
import contextlib
import logging
from datetime import UTC, datetime, timedelta
import logging
from typing import TYPE_CHECKING, Any
from zoneinfo import ZoneInfo
from custom_components.tibber_prices.api.exceptions import TibberPricesApiClientError
from homeassistant.util import dt as dt_utils
from custom_components.tibber_prices.api.exceptions import (
TibberPricesApiClientCommunicationError,
TibberPricesApiClientError,
)
from homeassistant.util import dt as dt_util
from .cache import TibberPricesIntervalPoolFetchGroupCache
from .fetcher import TibberPricesIntervalPoolFetcher
@ -20,9 +23,7 @@ from .storage import async_save_pool_state
if TYPE_CHECKING:
from custom_components.tibber_prices.api.client import TibberPricesApiClient
from custom_components.tibber_prices.coordinator.time_service import (
TibberPricesTimeService,
)
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
_LOGGER = logging.getLogger(__name__)
_LOGGER_DETAILS = logging.getLogger(__name__ + ".details")
@ -98,7 +99,7 @@ class TibberPricesIntervalPool:
hass: HomeAssistant instance for auto-save (optional).
entry_id: Config entry ID for auto-save (optional).
time_service: TimeService for time-travel support (optional).
If None, uses real time (dt_utils.now()).
If None, uses real time (dt_util.now()).
"""
self._home_id = home_id
@ -201,9 +202,11 @@ class TibberPricesIntervalPool:
)
# Fetch missing ranges from API
api_fetch_failed = False
if missing_ranges:
fetch_time_iso = dt_utils.now().isoformat()
fetch_time_iso = dt_util.now().isoformat()
try:
# Fetch with callback for immediate caching
await self._fetcher.fetch_missing_ranges(
api_client=api_client,
@ -211,13 +214,29 @@ class TibberPricesIntervalPool:
missing_ranges=missing_ranges,
on_intervals_fetched=lambda intervals, _: self._add_intervals(intervals, fetch_time_iso),
)
except TibberPricesApiClientCommunicationError as err:
if cached_intervals:
# Transient API error (e.g. 503) but we have cached data - use it as
# fallback so the coordinator can finish initializing. The next regular
# update cycle will retry the API automatically.
_LOGGER.warning(
"API temporarily unavailable for home %s (%s) - using %d cached intervals as fallback",
self._home_id,
err,
len(cached_intervals),
)
api_fetch_failed = True
else:
# No cached data at all - re-raise so the caller can decide
raise
# After caching all API responses, read from cache again to get final result
# This ensures we return exactly what user requested, filtering out extra intervals
final_result = self._get_cached_intervals(start_time_iso, end_time_iso)
# Track if API was called (True if any missing ranges were fetched)
api_called = len(missing_ranges) > 0
# Track if API was called (True if any missing ranges were attempted)
# If fetch failed but we fell back to cache, treat as "no API call succeeded"
api_called = len(missing_ranges) > 0 and not api_fetch_failed
_LOGGER_DETAILS.debug(
"Pool returning %d intervals for home %s (from cache: %d, fetched from API: %d ranges, api_called=%s)",
@ -280,7 +299,7 @@ class TibberPricesIntervalPool:
# Calculate range in home's timezone
tz = ZoneInfo(tz_str) if tz_str else None
now = self._time_service.now() if self._time_service else dt_utils.now()
now = self._time_service.now() if self._time_service else dt_util.now()
now_local = now.astimezone(tz) if tz else now
# Day before yesterday 00:00 (start) - same for both fetch and return
@ -577,7 +596,7 @@ class TibberPricesIntervalPool:
result = []
# Determine interval step (15 min post-2025-10-01, 60 min pre)
resolution_change_naive = datetime(2025, 10, 1) # noqa: DTZ001
resolution_change_naive = datetime(2025, 10, 1)
interval_minutes = INTERVAL_QUARTER_HOURLY if current_naive >= resolution_change_naive else INTERVAL_HOURLY
fetch_groups = self._cache.get_fetch_groups()
@ -706,13 +725,20 @@ class TibberPricesIntervalPool:
if intervals_to_touch:
self._touch_intervals(intervals_to_touch, fetch_time_dt)
if not new_intervals:
if intervals_to_touch:
# Run GC after touch even if no new intervals — touching creates dead
# intervals in old fetch groups that should be cleaned up promptly.
if intervals_to_touch and not new_intervals:
gc_changed_data = self._gc.run_gc()
_LOGGER_DETAILS.debug(
"All %d intervals already cached for home %s (touched only)",
"All %d intervals already cached for home %s (touched only, GC ran: %s)",
len(intervals),
self._home_id,
gc_changed_data,
)
if (intervals_to_touch or gc_changed_data) and self._hass is not None and self._entry_id is not None:
self._schedule_debounced_save()
return
# Sort new intervals by startsAt

View file

@ -7,7 +7,7 @@ This module handles intelligent routing between different Tibber API endpoints:
- PRICE_INFO_RANGE: Historical data (before "day before yesterday midnight")
- Automatic splitting and merging when range spans the boundary
CRITICAL: Uses REAL TIME (dt_utils.now()) for API boundary calculation,
CRITICAL: Uses REAL TIME (dt_util.now()) for API boundary calculation,
NOT TimeService.now() which may be shifted for internal simulation.
"""
@ -17,7 +17,7 @@ import logging
from typing import TYPE_CHECKING, Any
from custom_components.tibber_prices.api.exceptions import TibberPricesApiClientError
from homeassistant.util import dt as dt_utils
from homeassistant.util import dt as dt_util
if TYPE_CHECKING:
from datetime import datetime
@ -43,7 +43,7 @@ async def get_price_intervals_for_range(
- PRICE_INFO: For intervals from "day before yesterday midnight" onwards
- Both: If range spans across the boundary, splits the request
CRITICAL: Uses REAL TIME (dt_utils.now()) for API boundary calculation,
CRITICAL: Uses REAL TIME (dt_util.now()) for API boundary calculation,
NOT TimeService.now() which may be shifted for internal simulation.
This ensures predictable API responses.
@ -173,7 +173,7 @@ def _parse_timestamp(timestamp_str: str) -> datetime:
ValueError: If timestamp string cannot be parsed.
"""
result = dt_utils.parse_datetime(timestamp_str)
result = dt_util.parse_datetime(timestamp_str)
if result is None:
msg = f"Failed to parse timestamp: {timestamp_str}"
raise ValueError(msg)

View file

@ -11,5 +11,5 @@
"requirements": [
"aiofiles>=23.2.1"
],
"version": "0.30.0"
"version": "0.31.0b4"
}

View file

@ -0,0 +1,146 @@
"""
Entity migration checks for Tibber Prices integration.
Detects obsolete entity keys in the entity registry after upgrades and
performs automatic migration where possible. Creates repair issues to
notify users about breaking changes that require manual action.
Separation of concerns:
- This module: One-time upgrade migrations (entity renames, breaking changes)
- coordinator/repairs.py: Runtime repairs (API issues, missing data)
- __init__.py _migrate_config_options(): Config option format changes
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from homeassistant.core import callback
from homeassistant.helpers import entity_registry as er, issue_registry as ir
from .const import DOMAIN
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
_LOGGER = logging.getLogger(__name__)
# ============================================================================
# ENTITY KEY RENAMES
# Add entries here when renaming sensors in future releases.
# old_entity_key -> new_entity_key (auto-migrated, entity_id preserved)
# ============================================================================
ENTITY_KEY_RENAMES: dict[str, str] = {
"trend_change_in_minutes": "next_price_trend_change_in",
}
@callback
def check_entity_migrations(
hass: HomeAssistant,
entry: ConfigEntry,
) -> None:
"""
Check for entity migrations and create repairs if needed.
Called during async_setup_entry, before platform forwarding.
Performs auto-migration of renamed entities and creates
informational repairs about breaking changes.
"""
ent_reg = er.async_get(hass)
# Auto-migrate renamed entity keys
migrated = _auto_migrate_entity_keys(ent_reg, entry)
# Create persistent repair about breaking changes
issue_id = f"entity_migration_{entry.entry_id}"
if migrated:
rename_lines = [f"- `{old_key}` → `{new_key}`" for old_key, new_key, _ in migrated]
entity_list = "\n".join(rename_lines)
_LOGGER.info(
"Auto-migrated %d entity key(s) for '%s': %s",
len(migrated),
entry.title,
", ".join(f"{old}{new}" for old, new, _ in migrated),
)
ir.async_create_issue(
hass,
DOMAIN,
issue_id,
is_fixable=False,
is_persistent=True,
severity=ir.IssueSeverity.WARNING,
translation_key="entity_migration",
translation_placeholders={
"home_name": entry.title,
"entity_list": entity_list,
"count": str(len(migrated)),
},
learn_more_url="https://github.com/jpawlowski/hass.tibber_prices/releases",
)
@callback
def _auto_migrate_entity_keys(
ent_reg: er.EntityRegistry,
entry: ConfigEntry,
) -> list[tuple[str, str, str]]:
"""
Auto-migrate renamed entity keys in the entity registry.
Updates unique_ids for renamed entities while preserving entity_id
and all user customizations (history, dashboard references, etc.).
Returns:
List of (old_key, new_key, entity_id) tuples for migrated entities
"""
migrated: list[tuple[str, str, str]] = []
prefix = f"{entry.entry_id}_"
# Get all entities for this config entry
entry_entities = er.async_entries_for_config_entry(ent_reg, entry.entry_id)
for entity_entry in entry_entities:
if not entity_entry.unique_id.startswith(prefix):
continue
entity_key = entity_entry.unique_id[len(prefix) :]
if entity_key not in ENTITY_KEY_RENAMES:
continue
new_key = ENTITY_KEY_RENAMES[entity_key]
new_unique_id = f"{prefix}{new_key}"
# Check if new entity already exists (e.g., from a partial migration)
new_entity_id = ent_reg.async_get_entity_id(entity_entry.domain, DOMAIN, new_unique_id)
if new_entity_id:
# New entity already exists — remove the obsolete old one
_LOGGER.debug(
"Removing obsolete entity '%s' (new entity '%s' already exists)",
entity_entry.entity_id,
new_entity_id,
)
ent_reg.async_remove(entity_entry.entity_id)
else:
# Migrate: update unique_id (preserves entity_id and history)
_LOGGER.debug(
"Migrating entity '%s': unique_id '%s''%s'",
entity_entry.entity_id,
entity_entry.unique_id,
new_unique_id,
)
ent_reg.async_update_entity(
entity_entry.entity_id,
new_unique_id=new_unique_id,
)
migrated.append((entity_key, new_key, entity_entry.entity_id))
return migrated

View file

@ -11,19 +11,13 @@ from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from custom_components.tibber_prices.const import (
DOMAIN,
get_home_type_translation,
get_translation,
)
from custom_components.tibber_prices.const import DOMAIN, get_home_type_translation, get_translation
from homeassistant.components.number import NumberEntity, RestoreNumber
from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
if TYPE_CHECKING:
from custom_components.tibber_prices.coordinator import (
TibberPricesDataUpdateCoordinator,
)
from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator
from .definitions import TibberPricesNumberEntityDescription

View file

@ -13,10 +13,7 @@ from __future__ import annotations
from dataclasses import dataclass
from homeassistant.components.number import (
NumberEntityDescription,
NumberMode,
)
from homeassistant.components.number import NumberEntityDescription, NumberMode
from homeassistant.const import PERCENTAGE, EntityCategory

View file

@ -17,10 +17,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from custom_components.tibber_prices.const import (
CONF_CURRENCY_DISPLAY_MODE,
DISPLAY_MODE_BASE,
)
from custom_components.tibber_prices.const import CONF_CURRENCY_DISPLAY_MODE, DISPLAY_MODE_BASE
from .core import TibberPricesSensor
from .definitions import ENTITY_DESCRIPTIONS

View file

@ -10,10 +10,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
from custom_components.tibber_prices.entity_utils import (
add_description_attributes,
add_icon_color_attribute,
)
from custom_components.tibber_prices.entity_utils import add_description_attributes, add_icon_color_attribute
from custom_components.tibber_prices.sensor.types import (
DailyStatPriceAttributes,
DailyStatRatingAttributes,
@ -32,9 +29,7 @@ from custom_components.tibber_prices.sensor.types import (
)
if TYPE_CHECKING:
from custom_components.tibber_prices.coordinator.core import (
TibberPricesDataUpdateCoordinator,
)
from custom_components.tibber_prices.coordinator.core import TibberPricesDataUpdateCoordinator
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
from custom_components.tibber_prices.data import TibberPricesConfigEntry
from homeassistant.core import HomeAssistant
@ -44,9 +39,10 @@ from .daily_stat import add_statistics_attributes
from .future import add_next_avg_attributes, get_future_prices
from .interval import add_current_interval_price_attributes
from .lifecycle import build_lifecycle_attributes
from .metadata import get_current_price_phase_attributes, get_day_pattern_attributes, get_next_price_phase_attributes
from .timing import _is_timing_or_volatility_sensor
from .trend import _add_cached_trend_attributes, _add_timing_or_volatility_attributes
from .volatility import add_volatility_type_attributes, get_prices_for_volatility
from .volatility import add_percentile_rank_attributes, add_volatility_type_attributes, get_prices_for_volatility
from .window_24h import add_average_price_attributes
__all__ = [
@ -64,6 +60,7 @@ __all__ = [
"TrendAttributes",
"VolatilityAttributes",
"Window24hAttributes",
"add_percentile_rank_attributes",
"add_volatility_type_attributes",
"build_extra_state_attributes",
"build_sensor_attributes",
@ -189,6 +186,25 @@ def build_sensor_attributes(
elif _is_timing_or_volatility_sensor(key):
_add_timing_or_volatility_attributes(attributes, key, cached_data, native_value, time=time)
elif "_price_rank_" in key:
add_percentile_rank_attributes(attributes, cached_data, time=time)
elif key in ("day_pattern_yesterday", "day_pattern_today", "day_pattern_tomorrow"):
day = key.removeprefix("day_pattern_")
day_attrs = get_day_pattern_attributes(coordinator, day)
if day_attrs:
attributes.update(day_attrs)
elif key == "current_price_phase":
phase_attrs = get_current_price_phase_attributes(coordinator, time=time)
if phase_attrs:
attributes.update(phase_attrs)
elif key == "next_price_phase":
next_phase_attrs = get_next_price_phase_attributes(coordinator, time=time)
if next_phase_attrs:
attributes.update(next_phase_attrs)
# For current_interval_price_level, add the original level as attribute
if key == "current_interval_price_level" and cached_data.get("last_price_level") is not None:
attributes["level_id"] = cached_data["last_price_level"]
@ -217,7 +233,7 @@ def build_sensor_attributes(
return attributes or None
def build_extra_state_attributes( # noqa: PLR0913
def build_extra_state_attributes(
entity_key: str,
translation_key: str | None,
hass: HomeAssistant,

View file

@ -4,13 +4,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from custom_components.tibber_prices.const import (
PRICE_RATING_MAPPING,
get_display_unit_factor,
)
from custom_components.tibber_prices.coordinator.helpers import (
get_intervals_for_day_offsets,
)
from custom_components.tibber_prices.const import PRICE_RATING_MAPPING, get_display_precision, get_display_unit_factor
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
from homeassistant.const import PERCENTAGE
if TYPE_CHECKING:
@ -30,12 +25,13 @@ def _add_energy_tax_from_interval(
) -> None:
"""Add energy_price and tax from a single interval dict."""
factor = get_display_unit_factor(config_entry)
precision = get_display_precision(config_entry)
energy = interval_data.get("energy")
if energy is not None:
attributes["energy_price"] = round(float(energy) * factor, 2)
attributes["energy_price"] = round(float(energy) * factor, precision)
tax = interval_data.get("tax")
if tax is not None:
attributes["tax"] = round(float(tax) * factor, 2)
attributes["tax"] = round(float(tax) * factor, precision)
def _add_energy_tax_averages_from_cache(
@ -49,14 +45,15 @@ def _add_energy_tax_averages_from_cache(
"last_energy_tax_averages", (None, None, None, None)
)
factor = get_display_unit_factor(config_entry)
precision = get_display_precision(config_entry)
if energy_mean is not None:
attributes["energy_price_mean"] = round(float(energy_mean) * factor, 2)
attributes["energy_price_mean"] = round(float(energy_mean) * factor, precision)
if energy_median is not None:
attributes["energy_price_median"] = round(float(energy_median) * factor, 2)
attributes["energy_price_median"] = round(float(energy_median) * factor, precision)
if tax_mean is not None:
attributes["tax_mean"] = round(float(tax_mean) * factor, 2)
attributes["tax_mean"] = round(float(tax_mean) * factor, precision)
if tax_median is not None:
attributes["tax_median"] = round(float(tax_median) * factor, 2)
attributes["tax_median"] = round(float(tax_median) * factor, precision)
def _get_day_midnight_timestamp(key: str, *, time: TibberPricesTimeService) -> datetime:

View file

@ -4,13 +4,11 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from custom_components.tibber_prices.const import get_display_unit_factor
from custom_components.tibber_prices.const import get_display_precision, get_display_unit_factor
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
if TYPE_CHECKING:
from custom_components.tibber_prices.coordinator.core import (
TibberPricesDataUpdateCoordinator,
)
from custom_components.tibber_prices.coordinator.core import TibberPricesDataUpdateCoordinator
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
from custom_components.tibber_prices.data import TibberPricesConfigEntry
@ -20,7 +18,7 @@ from .helpers import add_alternate_average_attribute
MAX_FORECAST_INTERVALS = 8 # Show up to 8 future intervals (2 hours with 15-min intervals)
def add_next_avg_attributes( # noqa: PLR0913
def add_next_avg_attributes(
attributes: dict,
key: str,
coordinator: TibberPricesDataUpdateCoordinator,
@ -44,7 +42,7 @@ def add_next_avg_attributes( # noqa: PLR0913
# Extract hours from sensor key (e.g., "next_avg_3h" -> 3)
try:
hours = int(key.rsplit("_", maxsplit=1)[-1].replace("h", ""))
except (ValueError, AttributeError):
except ValueError, AttributeError:
return
# Use TimeService to get the N-hour window starting from next interval
@ -142,7 +140,8 @@ def get_future_prices(
# Convert to display currency unit based on configuration
price_major = float(price_data["total"])
factor = get_display_unit_factor(config_entry)
price_display = round(price_major * factor, 2)
precision = get_display_precision(config_entry)
price_display = round(price_major * factor, precision)
future_prices.append(
{

Some files were not shown because too many files have changed in this diff Show more