Compare commits

...

205 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
Julian Pawlowski
c1ffcfd559 chore(release): bump version to 0.30.0 2026-04-09 19:05:27 +00:00
Julian Pawlowski
55e02e3b54 feat(brand): update dark icons for improved visual consistency 2026-04-09 19:04:26 +00:00
Julian Pawlowski
459d6762c7 perf(sensors): add call-avoidance for timer-based state updates
Skip expensive async_write_ha_state() when native_value hasn't changed
since last write. HA's state machine has built-in change detection, but
it only runs AFTER all properties and attributes are evaluated — the
expensive part we now avoid entirely.

Sensor platform (Timer #2 + #3):
- New _write_if_changed() method compares native_value before writing
- Timer #3 (30s, 7 entities): Skips all writes when no period active
- Timer #2 (15min, ~45 entities): Skips enum levels/ratings that stay
  constant across quarter-hour intervals
- Replaces data_lifecycle_status-only pattern with unified approach

Binary sensor platform (Timer #2):
- Period sensors only write at actual period boundaries, not every 15min

Coordinator push updates always write (sentinel reset ensures freshness).

Impact: Eliminates asyncio "Executing TimerHandle took 1.4s" warnings
caused by redundant property evaluation in Timer #3 callbacks. Reduces
event loop blocking from ~1.4s to microseconds when values unchanged.
2026-04-09 19:04:04 +00:00
Julian Pawlowski
ac09e5f235 fix(docs): avoid mdx/ssg runtime error in energy-tax formula
Replace the inline KaTeX block in the Energy Price & Tax Breakdown
section with a plain inline equation string.

The previous expression caused a Docusaurus SSG runtime failure on the
sensors page (ReferenceError during static rendering).

Impact: User docs build now succeeds consistently for the sensors page.
2026-04-09 18:41:46 +00:00
Julian Pawlowski
aee1920292 chore(pre-commit): add docusaurus build guard for changed docs sites
Add a local pre-commit hook that builds Docusaurus when files under
docs/user or docs/developer are staged.

Introduced scripts/docs/build-changed-sites to detect which docs site
was touched and run only the required npm build(s).

Impact: Prevents broken MDX/Docusaurus changes from being committed by
failing fast in pre-commit before CI.
2026-04-09 18:41:41 +00:00
Julian Pawlowski
06eedee410 docs: update links for energy and tax fields in actions documentation 2026-04-09 18:35:05 +00:00
Julian Pawlowski
9c4ac6bce4 docs(user): document energy price and tax breakdown
Add new section 'Energy Price & Tax Breakdown' to sensors.md:
- Attribute overview table (interval, min/max, daily avg sensors)
- Use cases: solar feed-in/net metering, price composition analysis,
  dashboard cost breakdown with example YAML templates
- Cache transition note for gradual data availability after update

Add 'Energy & Tax Fields in get_chartdata' section to actions.md:
- Parameter documentation with defaults
- Example service call YAML
- ApexCharts integration example with custom field names

Impact: Users can discover and utilize the new energy/tax attributes
with ready-to-use automation and dashboard examples.
2026-04-09 18:28:06 +00:00
Julian Pawlowski
d1b25e9cfe feat(services): add energy/tax fields to get_chartdata action
Add four optional parameters to the get_chartdata service:
- include_energy: Include raw energy/spot price (default: false)
- include_tax: Include tax component (default: false)
- energy_field: Custom field name (default: energy_price)
- tax_field: Custom field name (default: tax)

Custom field names allow direct compatibility with ApexCharts
and other charting tools without post-processing.

All code paths (all/segments/none insert_nulls modes) and the
last-interval handler include energy/tax when enabled.

Added translations for all 5 languages (en, de, nl, nb, sv).

Impact: Users can include price composition data in chart exports,
enabling visual breakdowns of energy cost vs. taxes in dashboards.
2026-04-09 18:27:53 +00:00
Julian Pawlowski
edabb49309 feat(sensors): expose energy/tax breakdown as sensor attributes
Add energy_price and tax attributes to interval and daily stat sensors:

- Interval sensors (current/next/previous): energy_price and tax from
  the specific 15-minute interval
- Daily min/max sensors: energy_price and tax from the extreme interval
- Daily average sensors: energy_price_mean, energy_price_median,
  tax_mean, tax_median — matching the existing mean/median pattern
  used for the main price attribute

Calculator caches both mean and median for energy/tax using
calculate_median() from utils/average. All new attributes are
excluded from Recorder to prevent database bloat.

Impact: Users can see price composition (spot price vs. taxes) on
all major price sensors. Enables solar feed-in and net metering
automations based on raw energy prices.
2026-04-09 18:27:36 +00:00
Julian Pawlowski
f5dcf04aab feat(api): add energy and tax fields to Tibber GraphQL queries
Request `energy` and `tax` fields alongside `total` in both
quarter-hourly price queries. These represent the raw spot price and
the tax/fee component that together make up the total consumer price.

Updated hourly aggregation in formatters.py to carry energy/tax
values through to aggregated output.

Impact: Enables downstream consumers (sensors, services) to expose
price composition data. Useful for solar feed-in compensation and
net metering (saldering) calculations where the raw energy price
is needed separately from taxes.
2026-04-09 18:27:21 +00:00
Julian Pawlowski
e1da4cfa89 chroe(dev): update Home Assistant version in bootstrap script to 2026.4.1
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-09 17:13:03 +00:00
Julian Pawlowski
50dc874274 feat(brand): add local brand images for HA brands proxy API
Added brand/ directory to custom_components/tibber_prices/ with all
8 supported PNG variants, generated from existing SVGs in images/:

- icon.png / dark_icon.png (256×256)
- icon@2x.png / dark_icon@2x.png (512×512)
- logo.png / dark_logo.png (500×128)
- logo@2x.png / dark_logo@2x.png (1000×256)

Local brand images automatically take priority over CDN images and
are served via the HA brands proxy API (/api/brands/integration/).
Silently ignored on HA < 2026.3, no changes to manifest.json needed.

Updated AGENTS.md to document the brand/ directory under "ALLOWED in root".

Impact: Integration icon and logo now display correctly in HA ≥ 2026.3
without requiring a separate submission to the HA brands repository.
2026-04-09 17:06:13 +00:00
Julian Pawlowski
124824a2ea test(sensors): update timer assignment tests for renamed/new trend sensors
test_trend_sensors_use_quarter_hour_timer():
  - Replaced price_trend_Xh keys with price_outlook_Xh
  - Added 7 price_trajectory_Xh keys to the assertion list
  - Updated docstring from "price trend" to "price outlook/trajectory"

Impact: Test suite passes with renamed and new sensor keys.
2026-04-09 16:09:14 +00:00
Julian Pawlowski
cbf5e1a3fe docs(sensors): rename trend→outlook, document price_trajectory sensors
Updated user documentation to reflect renamed and new sensors.

sensors.md:
  - Section renamed "Simple Trend Sensors" → "Price Outlook Sensors (1h–12h)"
  - All price_trend_Xh entity references → price_outlook_Xh
  - Callout box updated: explains that outlook sensors can mislead at a
    price minimum and recommends combining with trajectory sensors
  - New section "Price Trajectory Sensors (2h–12h)" added before
    "Current Price Trend":
    - Table showing which halves are compared per window
    - Callout box with the 4 outlook+trajectory combination patterns
      (falling+rising = AT the minimum, etc.)
    - Key attributes table (first_half_avg, second_half_avg, half_diff_%)
  - "Trend Sensors vs Average Sensors" → "Outlook & Trajectory Sensors vs
    Average Sensors"

icon-colors.md:
  - "Price trend sensors (e.g., price_trend_3h)" → "Price outlook sensors
    (e.g., price_outlook_3h)"
  - Example entity updated to sensor.<home_name>_price_outlook_3h

automation-examples.md:
  - All price_trend_1h/2h/3h/4h/6h references → price_outlook_Xh
  - current_price_trend and next_price_trend_change unchanged (correct names)

Impact: Documentation matches actual entity names. New trajectory section
helps users understand when to use outlook vs trajectory sensors together.
2026-04-09 16:09:04 +00:00
Julian Pawlowski
2b96ccc650 feat(translations): add price_outlook_Xh and price_trajectory_Xh strings
Renamed 8 price_trend_Xh entries to price_outlook_Xh and added 15 new
price_trajectory_Xh entries (2h–12h) in all 5 languages (de, en, nb, nl, sv).

translations/ (HA-native: name + 5 states per sensor):
  - EN: "Price Outlook (Xh)" / "Price Trajectory (Xh)"
  - DE: "Preisausblick (Xh)" / "Preisverlauf (Xh)"
  - NB: "Prisutblikk (Xt)" / "Prisforløp (Xt)"
  - NL: "Prijsvooruitzicht (Xu)" / "Prijstrajectorie (Xu)"
  - SV: "Prisöversikt (Xh)" / "Prisutveckling (Xh)"

custom_translations/ (description + long_description + usage_tips):
  - Outlook descriptions updated to explain window-average comparison
    semantics (not price direction)
  - Trajectory descriptions explain first-half vs second-half logic and
    the "outlook: falling + trajectory: rising = you're AT the minimum" pattern
  - Trajectory long_description and usage_tips in English for all languages;
    description field in native language

Impact: Entity display names update to reflect the corrected semantic meaning.
2026-04-09 16:08:54 +00:00
Julian Pawlowski
33f57ff077 feat(sensors)!: rename price_trend_Xh → price_outlook_Xh, add price_trajectory_Xh
Renamed 8 sensors to clarify what they actually measure, and added 7 new
sensors for a different (and often more useful) calculation.

--- WHY THE RENAME ---

The old name "price_trend_Xh" implied the sensor shows where prices are
heading. It doesn't — it compares CURRENT price vs the FUTURE WINDOW AVERAGE.
At a price minimum, it shows "strongly_falling" (because the cheap minimum
pulls the average below your current high price), which is the opposite of
intuitive. The name "price_outlook_Xh" correctly conveys: "is now cheaper
or more expensive than the next Nh on average?"

--- NEW: price_trajectory_Xh ---

These sensors compare FIRST HALF vs SECOND HALF of the window, revealing
actual price direction within the window:

  price_trajectory_2h: avg(hour 1) vs avg(hour 2)
  price_trajectory_3h: avg(first 1.5h) vs avg(second 1.5h)
  price_trajectory_4h: avg(first 2h) vs avg(second 2h)
  price_trajectory_5h: avg(first 2.5h) vs avg(second 2.5h)
  price_trajectory_6h: avg(first 3h) vs avg(second 3h)
  price_trajectory_8h: avg(first 4h) vs avg(second 4h)
  price_trajectory_12h: avg(first 6h) vs avg(second 6h)

The key use case: at a price minimum, price_outlook_Xh shows "strongly_falling"
but price_trajectory_Xh shows "rising" — correctly revealing the upcoming
reversal. "outlook: falling + trajectory: rising" = you're AT the minimum.

--- IMPLEMENTATION ---

sensor/calculators/trend.py:
  - get_price_outlook_value() (was: get_price_trend_value())
  - New: get_price_trajectory_value(*, hours: int)
  - New: _calculate_first_half_average(hours, next_interval_start)
  - New: get_trajectory_attributes() → first_half_avg, second_half_avg, half_diff_%
  - clear_trend_cache() also resets _trajectory_attributes

sensor/definitions.py:
  - 8 SensorEntityDescription entries: key/translation_key price_trend_Xh → price_outlook_Xh
  - New PRICE_TRAJECTORY_SENSORS tuple (2h–5h enabled by default, 6h/8h/12h disabled)

sensor/value_getters.py:
  - 8 lambda entries renamed
  - 7 new trajectory lambda entries added

sensor/attributes/trend.py:
  - startswith("price_trend_") → startswith("price_outlook_")
  - New elif branch routing price_trajectory_* to cached trajectory_attributes

sensor/core.py:
  - startswith checks updated for both prefix families
  - cached_data dict extended with "trajectory_attributes"

coordinator/constants.py:
  - TIME_SENSITIVE_ENTITY_KEYS: 8 renamed + 7 new trajectory keys added

config_flow_handlers/entity_check.py:
  - volatility + price_trend affected-entity lists: 8 renamed + 7 new

BREAKING CHANGE: Sensors price_trend_1h, price_trend_2h, price_trend_3h,
price_trend_4h, price_trend_5h, price_trend_6h, price_trend_8h,
price_trend_12h have been removed without a deprecation period.

Migration:
  Replace price_trend_Xh → price_outlook_Xh everywhere (automations,
  dashboards, templates). Behavior is identical — only the entity name
  changed. If you want to detect actual price direction within the window
  (e.g. "are prices rising or falling right now?"), use the new
  price_trajectory_Xh sensors instead.

Impact: Users must update automations and dashboards. Entity IDs change from
sensor.<home>_price_trend_Xh to sensor.<home>_price_outlook_Xh. New
price_trajectory_Xh sensors provide complementary direction information.
2026-04-09 16:08:42 +00:00
Julian Pawlowski
d0b6ea0e1a fix(sensors)!: fix DURATION sensors displaying in minutes instead of hours
Some checks failed
Validate / HACS validation (push) Has been cancelled
Lint / Ruff (push) Has been cancelled
Validate / Hassfest validation (push) Has been cancelled
Added `suggested_unit_of_measurement=UnitOfTime.HOURS` to all 7 DURATION
sensors to prevent HA from auto-selecting minutes as the display unit.
Without this, HA would pick "min" for small values (e.g., 0.75 h) and then
display large values as "1238 Min." instead of the intended "20 Std. 38 Min."

Affected sensors:
- trend_change_in_minutes
- best_price_period_duration / peak_price_period_duration
- best_price_remaining_minutes / peak_price_remaining_minutes
- best_price_next_in_minutes / peak_price_next_in_minutes

BREAKING CHANGE: Sensor state unit changes from minutes to hours for users
whose entity registry stored "min" as the display unit (the previous default).
Automations using the raw state value (e.g., `state < 60` for "less than 60
minutes") must be updated to use hours (e.g., `state < 1`).
The state attributes `remaining_minutes` and `next_in_minutes` continue to
provide integer minutes and are unaffected.

Impact: Duration sensors now display dynamically as "X h Y min" (e.g.,
"1 h 15 min") instead of a large minutes value like "1238 Min.". Users who
manually customized the unit in HA settings are not affected.
2026-04-08 08:01:16 +00:00
Julian Pawlowski
aa0f543ec5 fix: ensure SVG backgrounds are solid to prevent transparency issues
Some checks failed
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
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Has been cancelled
2026-04-07 15:32:52 +00:00
Julian Pawlowski
a4a43e3d34 fix: update default Copilot model version to 4.6 2026-04-07 15:22:29 +00:00
Julian Pawlowski
4efd6b7267 feat: add versioned sidebars for user and developer documentation 2026-04-07 15:19:10 +00:00
Julian Pawlowski
bb176135f6 fix(ci): ensure release notes end with newline before heredoc delimiter
cliff.toml has trim=true which strips git-cliff's trailing newline.
When written to GITHUB_OUTPUT via heredoc, the closing delimiter was
appended to the last content line instead of its own line, causing
"Matching delimiter not found" error.

Added printf '\n' in the workflow and echo "" in generate-notes to
guarantee a newline before the heredoc closing delimiter.

Impact: Release workflow no longer fails when generating release notes.
2026-04-07 15:16:03 +00:00
Julian Pawlowski
c160063067 Merge branch 'main' of https://github.com/jpawlowski/hass.tibber_prices 2026-04-07 15:09:58 +00:00
Julian Pawlowski
8bd3ef85c0 fix(ci): use random heredoc delimiter in release workflow
$GITHUB_OUTPUT heredoc blocks used literal 'EOF' as delimiter, which
breaks parsing if generated release notes contain 'EOF' on its own line.

Replace static 'EOF' with openssl rand -hex 16 random delimiter in
both the version-warning and release-notes output blocks.

Impact: Release workflow no longer fails when commit message bodies
contain 'EOF'.
2026-04-07 15:09:56 +00:00
github-actions[bot]
48d088281b docs: add version snapshot v0.29.0 and cleanup old versions [skip ci] 2026-04-07 15:09:08 +00:00
Julian Pawlowski
070905e880 chore(release): bump version to 0.29.0 2026-04-07 15:07:08 +00:00
Julian Pawlowski
b7cf4442bd feat: enhance mermaid lightbox interaction with improved accessibility and hover hints 2026-04-07 15:06:23 +00:00
Julian Pawlowski
dba96e38e0 fix(css): enhance mermaid lightbox styling for better theme integration 2026-04-07 14:58:10 +00:00
Julian Pawlowski
ebc3c38007 Add @docusaurus/faster dependency to package.json 2026-04-07 14:50:57 +00:00
Julian Pawlowski
86c5db179e fix(css): adjust width of mermaid lightbox inner card for better responsiveness 2026-04-07 14:46:51 +00:00
dependabot[bot]
c7bd0b7a93
chore(deps): bump the react group in /docs/developer with 2 updates (#103)
Bumps the react group in /docs/developer with 2 updates: [react](https://github.com/facebook/react/tree/HEAD/packages/react) and [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom).


Updates `react` from 19.2.1 to 19.2.4
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.4/packages/react)

Updates `react-dom` from 19.2.1 to 19.2.4
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.4/packages/react-dom)

---
updated-dependencies:
- dependency-name: react
  dependency-version: 19.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: react
- dependency-name: react-dom
  dependency-version: 19.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: react
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 16:39:58 +02:00
dependabot[bot]
688cf0d5a3
chore(deps-dev): bump typescript from 5.6.3 to 6.0.2 in /docs/user (#102)
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.6.3 to 6.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.6.3...v6.0.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 16:38:53 +02:00
dependabot[bot]
de577c83a6
chore(deps-dev): bump typescript from 5.6.3 to 6.0.2 in /docs/developer (#104)
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.6.3 to 6.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.6.3...v6.0.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 16:37:15 +02:00
dependabot[bot]
fd3c949e90
chore(deps): bump the react group in /docs/user with 2 updates (#101)
Bumps the react group in /docs/user with 2 updates: [react](https://github.com/facebook/react/tree/HEAD/packages/react) and [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom).


Updates `react` from 19.2.1 to 19.2.4
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.4/packages/react)

Updates `react-dom` from 19.2.1 to 19.2.4
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.4/packages/react-dom)

---
updated-dependencies:
- dependency-name: react
  dependency-version: 19.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: react
- dependency-name: react-dom
  dependency-version: 19.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: react
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 16:36:34 +02:00
dependabot[bot]
fc8ec33f11
chore(deps): bump home-assistant/actions (#100)
Bumps [home-assistant/actions](https://github.com/home-assistant/actions) from 5752577ea7cc5aefb064b0b21432f18fe4d6ba90 to f6f29a7ee3fa0eccadf3620a7b9ee00ab54ec03b.
- [Release notes](https://github.com/home-assistant/actions/releases)
- [Commits](5752577ea7...f6f29a7ee3)

---
updated-dependencies:
- dependency-name: home-assistant/actions
  dependency-version: f6f29a7ee3fa0eccadf3620a7b9ee00ab54ec03b
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 16:36:07 +02:00
Julian Pawlowski
f4fecc3ee0 feat(deps): add npm package updates for user and developer documentation directories 2026-04-07 14:32:12 +00:00
Julian Pawlowski
5f52dd2524 chore: update dependencies to version 3.10.0 in package.json 2026-04-07 14:31:25 +00:00
Julian Pawlowski
db900c2a4b fix(docs): update links in automation examples and intro documentation 2026-04-07 14:25:30 +00:00
Julian Pawlowski
3aa8a43e3a fix(docs): commit missing versioned_sidebars for all existing versions
versioned_docs/ was already tracked but versioned_sidebars/ was never
committed. Docusaurus requires both to render sidebar navigation for
old versions — without the sidebar files, all versioned pages show
no navigation.

Adds sidebar snapshots for user and developer docs:
  v0.21.0, v0.22.0, v0.22.1, v0.23.0, v0.23.1, v0.24.0, v0.27.0, v0.28.0

Future versions: CI (docusaurus.yml) runs docs:version on each stable
tag push, which generates both versioned_docs/ and versioned_sidebars/.
The workflow should be updated to commit these files back, or they need
to be added manually after each release.

Impact: Sidebar navigation now appears correctly for all existing
versioned documentation pages.
2026-04-07 14:16:53 +00:00
Julian Pawlowski
9fea34b8b4 fix(deps): remove unnecessary peer dependencies from package-lock.json 2026-04-07 14:09:27 +00:00
Julian Pawlowski
552db6ef7d feat(docs): add click-to-zoom lightbox for Mermaid diagrams
Swizzled @docusaurus/theme-mermaid's Mermaid component to wrap
every diagram with a portal-based lightbox overlay.

An expand icon appears on diagram hover. Clicking opens the SVG
in a full-screen overlay (90vw × 85vh, scrollable). Closes via
backdrop click, Escape key, or close button. SSR-safe, no
external dependencies. Matches Tibber electric color theme.

Impact: Users can inspect complex flowcharts (e.g. the Options
Flow wizard) without squinting at small embedded diagrams.
2026-04-07 14:09:19 +00:00
Julian Pawlowski
1c25ac1fb0 feat(docs): improve Giscus comment UX for maintainer clarity
Switched Giscus mapping from 'pathname' to 'og:title' with strict=1.
Discussions are now titled after the readable page title (e.g.
'Chart Examples | Tibber Prices Integration') instead of the URL path,
making them immediately identifiable in the GitHub Discussions list.

Added a small hint above every comment box pointing users to open
a dedicated Discussion on GitHub for new questions/ideas, so
page-specific comments don't accumulate unrelated threads.

Note: Existing Discussion #94 (pathname-mapped) will no longer appear
on chart-examples — a new og:title-mapped discussion will be created
on the next comment. #94 remains visible on GitHub.

Impact: Maintainer can identify discussion origin at a glance.
Users are guided toward proper Discussion threads for new topics.
2026-04-07 14:01:37 +00:00
Julian Pawlowski
d8f005d3bb docs: explain how to find the config entry_id
Added a dedicated 'Finding Your Entry ID' section to actions.md
explaining the two workflows: dropdown in the Action UI vs.
'Copy Config Entry ID' from the integration's three-dot menu in YAML.

Added matching :::info callouts to chart-examples.md and
automation-examples.md where entry_id: YOUR_ENTRY_ID appears in code
examples, and a new 'Config Entry ID' entry in the glossary.

Addresses user confusion reported in GitHub Discussions #94.

Impact: Users no longer get stuck on YOUR_ENTRY_ID placeholders.
Both GUI and YAML workflows are clearly explained at the point of need.
2026-04-07 14:01:33 +00:00
Julian Pawlowski
190c979e9c docs(user): comprehensive trend decision model and automation guides
sensors.md:
- Added "How to Use Trend Sensors for Decisions" section with :::danger
  "Common Misconception — Don't Wait for Stable!" box
- Comparison table (rising/falling/stable → what it means → action)
- Basic automation YAML pattern
- Multi-window combination table (1h + 6h → interpretation)
- Dashboard quick-glance guide
- Documented trend_change_in_minutes sensor

automation-examples.md:
- Replaced placeholder with full automation examples
- V-shaped price days explanation with mermaid timeline
- "Ride the Full Cheap Wave" heat pump automation
- "Pre-Emptive Start Before Best Price" pattern
- "Protect Against Rising Prices" EV charging automation
- "Multi-Window Trend Strategy" (1h + 6h + best_price_period)
- Sensor Combination Quick Reference table
- Volatility-based automation examples
- Best hour detection automation
- ApexCharts card configuration guide

Other docs: trend-related updates to concepts, configuration,
glossary, installation, period-calculation, troubleshooting.

Impact: Users have comprehensive guides for trend-based automations
with real YAML examples and clear decision frameworks.
2026-04-07 13:45:40 +00:00
Julian Pawlowski
5d673e65b4 feat(translations): add trend sensor descriptions and decision model tips
Standard translations (5 languages):
- Added config flow labels/descriptions for trend change confirmation,
  min price change, and min price change strongly
- Updated strongly threshold descriptions (6% → 9%)
- Added trend_change_in_minutes sensor name

Custom translations (5 languages):
- Rewritten usage_tips for all 8 trend sensors (1h-12h) with action-
  oriented decision guide: "rising = ACT NOW", "falling = WAIT"
- Addresses common misconception ("rising" ≠ "too late")
- Added trend_change_in_minutes description and tips
- Updated long_descriptions: clarified shared-base behavior, corrected
  threshold references from >5% to ±3%/±9%
- Updated next_price_trend_change: direction-group explanation

Impact: Users understand trend sensors as decision tools, not trajectory
indicators. All 5 languages (en/de/nb/nl/sv) updated consistently.
2026-04-07 13:45:13 +00:00
Julian Pawlowski
798de5946d feat(config_flow): add trend confirmation and noise floor settings
Added 3 new config fields to price trend options step:
- Trend Change Confirmation (2-6 intervals slider)
- Min Price Change for trend (display-unit-aware slider)
- Min Price Change for strong trend (display-unit-aware slider)

Price change sliders scale between base currency (EUR/NOK) storage and
display unit (ct/øre) presentation using get_display_unit_factor().
Added migration in __init__.py to convert old display-unit values to
base currency format.

Impact: Users can tune trend sensitivity: higher confirmation = fewer
false changes, higher min price change = no trends from tiny fluctuations.
2026-04-07 13:44:47 +00:00
Julian Pawlowski
91efeed90f feat(sensors): add trend_change_in_minutes countdown sensor
New duration sensor showing time until next price trend change as hours
(e.g., 2.25 h). Registered in MINUTE_UPDATE_ENTITY_KEYS for per-minute
updates. Shares cached attributes with next_price_trend_change timestamp
sensor.

Added trend attributes to _unrecorded_attributes (threshold/volatility/diff
attributes excluded from recorder). Updated timer group size test expectation
from 6 to 7.

Impact: Users can display a live countdown to the next trend change on
dashboards and use it in automations (e.g., "if < 0.25 h, prepare").
2026-04-07 13:44:22 +00:00
Julian Pawlowski
90e2c3c1dc feat(trend): add direction-group detection, noise floor, and confirmation hysteresis
Refactored trend calculator with direction-group-based trend change detection
(rising/strongly_rising treated as same group, falling/strongly_falling as same
group). Added minimum absolute price change thresholds (noise floor) to prevent
spurious trends at low price levels. Both percentage AND absolute conditions
must now be met.

Updated strongly threshold defaults from ±6% to ±9% (3x base for perceptual
scaling). Added missing strongly thresholds and new config keys to
get_default_options(). calculate_price_trend() now returns volatility_factor
as 4th tuple element for threshold transparency.

Added CONF_PRICE_TREND_CHANGE_CONFIRMATION (default: 3 intervals = 45min)
and CONF_PRICE_TREND_MIN_PRICE_CHANGE / _STRONGLY with validation limits.

Updated tests for new 4-tuple return value.

Impact: More stable trend detection — fewer false trend changes during low-price
periods. Direction-group logic prevents noise from "rising ↔ strongly_rising"
oscillations. Users can fine-tune noise floor for their market.
2026-04-07 13:44:01 +00:00
Julian Pawlowski
4d9b1545b0 feat(release): enhance AI prompt for generating user-focused release notes
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 / Hassfest validation (push) Waiting to run
Validate / HACS validation (push) Waiting to run
2026-04-06 15:28:17 +00:00
Julian Pawlowski
a978b645cf feat: add footer to release notes with coffee donation link 2026-04-06 15:18:07 +00:00
github-actions[bot]
da3aa3bf1e docs: add version snapshot v0.28.0 and cleanup old versions [skip ci] 2026-04-06 14:40:08 +00:00
623 changed files with 101940 additions and 25019 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"
]
"recommendations": [],
"unwantedRecommendations": ["ms-python.pylint"]
}

View file

@ -1,145 +1,173 @@
{
"name": "jpawlowski/hass.tibber_prices",
"image": "mcr.microsoft.com/devcontainers/python:3.14",
"postCreateCommand": "bash .devcontainer/setup-git.sh && scripts/setup/setup",
"postStartCommand": "scripts/motd",
"containerEnv": {
"PYTHONASYNCIODEBUG": "1",
"TIBBER_PRICES_DEV": "1"
"name": "jpawlowski/hass.tibber_prices",
"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],
"portsAttributes": {
"8123": {
"label": "Home Assistant",
"onAutoForward": "notify"
},
"forwardPorts": [
8123,
3000,
3001
],
"portsAttributes": {
"8123": {
"label": "Home Assistant",
"onAutoForward": "notify"
},
"3000": {
"label": "Docusaurus User Docs",
"onAutoForward": "notify"
},
"3001": {
"label": "Docusaurus Developer Docs",
"onAutoForward": "notify"
}
"3000": {
"label": "Docusaurus User Docs",
"onAutoForward": "notify"
},
"customizations": {
"vscode": {
"extensions": [
"charliermarsh.ruff",
"EditorConfig.EditorConfig",
"esbenp.prettier-vscode",
"github.copilot",
"github.vscode-pull-request-github",
"ms-python.python",
"ms-python.vscode-pylance",
"ms-vscode-remote.remote-containers",
"redhat.vscode-yaml",
"ryanluker.vscode-coverage-gutters"
],
"settings": {
"editor.tabSize": 4,
"editor.formatOnSave": true,
"editor.formatOnType": false,
"extensions.ignoreRecommendations": false,
"files.eol": "\n",
"files.trimTrailingWhitespace": true,
"python.analysis.typeCheckingMode": "basic",
"python.analysis.autoImportCompletions": true,
"python.analysis.diagnosticMode": "workspace",
"python.analysis.diagnosticSeverityOverrides": {
"reportUnusedImport": "none",
"reportUnusedVariable": "none",
"reportUnusedCoroutine": "none",
"reportMissingTypeStubs": "none"
},
"python.analysis.include": [
"custom_components/tibber_prices"
],
"python.analysis.exclude": [
"**/.venv/**",
"**/venv/**",
"**/__pycache__/**",
"**/.git/**",
"**/.github/**",
"**/docs/**",
"**/node_modules/**"
],
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
"python.analysis.extraPaths": [
"${workspaceFolder}/.venv/lib/python3.14/site-packages"
],
"python.terminal.activateEnvironment": true,
"python.terminal.activateEnvInCurrentTerminal": true,
"python.testing.pytestArgs": [
"--no-cov"
],
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.ruff": "explicit",
"source.organizeImports.ruff": "explicit"
}
},
"[markdown]": {
"editor.wordWrap": "on"
},
"yaml.customTags": [
"!secret scalar",
"!include scalar",
"!include_dir_list scalar",
"!include_dir_merge_list scalar",
"!include_dir_named scalar",
"!include_dir_merge_named scalar",
"!input scalar"
],
"markdown.validate.enabled": false,
"markdown.validate.fileLinks.enabled": "ignore",
"markdown.validate.fragmentLinks.enabled": "ignore",
"json.schemas": [
{
"fileMatch": [
"homeassistant/components/*/manifest.json"
],
"url": "${containerWorkspaceFolder}/schemas/json/manifest_schema.json"
},
{
"fileMatch": [
"homeassistant/components/*/translations/*.json"
],
"url": "${containerWorkspaceFolder}/schemas/json/translation_schema.json"
}
],
"git.useConfigOnly": false
}
}
},
"mounts": [
"source=${localEnv:HOME}${localEnv:USERPROFILE}/.gitconfig,target=/home/vscode/.gitconfig.host,type=bind,consistency=cached"
],
"remoteUser": "vscode",
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {},
"ghcr.io/flexwie/devcontainer-features/op:1": {
"version": "latest"
},
"ghcr.io/devcontainers/features/node:1": {
"version": "24"
},
"ghcr.io/devcontainers/features/rust:1": {
"version": "latest",
"profile": "minimal"
},
"ghcr.io/devcontainers-extra/features/apt-packages:1": {
"packages": [
"ffmpeg",
"libturbojpeg0",
"libpcap-dev"
]
}
"3001": {
"label": "Docusaurus Developer Docs",
"onAutoForward": "notify"
}
},
"customizations": {
"vscode": {
"extensions": [
"charliermarsh.ruff",
"EditorConfig.EditorConfig",
"esbenp.prettier-vscode",
"github.copilot",
"github.vscode-pull-request-github",
"ms-python.python",
"ms-python.vscode-pylance",
"ms-vscode-remote.remote-containers",
"redhat.vscode-yaml",
"ryanluker.vscode-coverage-gutters",
"MermaidChart.vscode-mermaid-chart"
],
"settings": {
"editor.tabSize": 4,
"editor.formatOnSave": true,
"editor.formatOnType": false,
"extensions.ignoreRecommendations": false,
"files.eol": "\n",
"files.trimTrailingWhitespace": true,
"python.analysis.typeCheckingMode": "basic",
"python.analysis.autoImportCompletions": true,
"python.analysis.diagnosticMode": "workspace",
"python.analysis.diagnosticSeverityOverrides": {
"reportUnusedImport": "none",
"reportUnusedVariable": "none",
"reportUnusedCoroutine": "none",
"reportMissingTypeStubs": "none"
},
"python.analysis.include": ["custom_components/tibber_prices"],
"python.analysis.exclude": [
"**/.venv/**",
"**/venv/**",
"**/__pycache__/**",
"**/.git/**",
"**/.github/**",
"**/docs/**",
"**/node_modules/**"
],
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
"python.analysis.extraPaths": ["${workspaceFolder}/.venv/lib/python3.14/site-packages"],
"python.terminal.activateEnvironment": true,
"python.terminal.activateEnvInCurrentTerminal": true,
"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,
"editor.codeActionsOnSave": {
"source.fixAll.ruff": "explicit",
"source.organizeImports.ruff": "explicit"
}
},
"[markdown]": {
"editor.wordWrap": "on"
},
"yaml.customTags": [
"!secret scalar",
"!include scalar",
"!include_dir_list scalar",
"!include_dir_merge_list scalar",
"!include_dir_named scalar",
"!include_dir_merge_named scalar",
"!input scalar"
],
"markdown.validate.enabled": false,
"markdown.validate.fileLinks.enabled": "ignore",
"markdown.validate.fragmentLinks.enabled": "ignore",
"json.schemas": [
{
"fileMatch": ["homeassistant/components/*/manifest.json"],
"url": "${containerWorkspaceFolder}/schemas/json/manifest_schema.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
}
}
},
"mounts": [
"source=${localEnv:HOME}${localEnv:USERPROFILE}/.gitconfig,target=/home/vscode/.gitconfig.host,type=bind,consistency=cached"
],
"remoteUser": "vscode",
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {},
"ghcr.io/flexwie/devcontainer-features/op:1": {
"version": "latest"
},
"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",
"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,69 +3,69 @@ name: "Bug report"
description: "Report a bug with the custom integration"
labels: ["bug"]
body:
- 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
attributes:
label: "Home Assistant version"
description: "The version of Home Assistant you are using"
placeholder: "2025.1.0"
validations:
required: true
- 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
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
attributes:
label: Checklist
options:
- label: I have enabled debug logging for my installation.
required: true
- label: I have filled out the issue template to the best of my ability.
required: true
- label: This issue only contains 1 issue (if you have multiple issues, open one issue for each issue).
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
attributes:
label: "Describe the issue"
description: "A clear and concise description of what the issue is."
validations:
required: true
- 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."
value: |
1.
2.
3.
...
validations:
required: true
- 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."
render: text
validations:
required: true
- 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
attributes:
label: "Home Assistant version"
description: "The version of Home Assistant you are using"
placeholder: "2025.1.0"
validations:
required: true
- 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
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
attributes:
label: Checklist
options:
- label: I have enabled debug logging for my installation.
required: true
- label: I have filled out the issue template to the best of my ability.
required: true
- label: This issue only contains 1 issue (if you have multiple issues, open one issue for each issue).
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
attributes:
label: "Describe the issue"
description: "A clear and concise description of what the issue is."
validations:
required: true
- 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."
value: |
1.
2.
3.
...
validations:
required: true
- 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."
render: text
validations:
required: true
- type: textarea
attributes:
label: "Diagnostics dump"
description: "Drag the diagnostics dump file here. (see https://www.home-assistant.io/integrations/diagnostics/ for info)"
validations:
required: false
- type: textarea
attributes:
label: "Diagnostics dump"
description: "Drag the diagnostics dump file here. (see https://www.home-assistant.io/integrations/diagnostics/ for info)"
validations:
required: false

View file

@ -3,45 +3,45 @@ name: "Feature request"
description: "Suggest an idea for this custom integration"
labels: ["Feature request"]
body:
- 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
attributes:
label: Checklist
options:
- label: I have filled out the template to the best of my ability.
required: true
- label: This only contains 1 feature request (if you have multiple feature requests, open one feature request for each feature request).
required: true
- 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: 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
attributes:
label: Checklist
options:
- label: I have filled out the template to the best of my ability.
required: true
- label: This only contains 1 feature request (if you have multiple feature requests, open one feature request for each feature request).
required: true
- 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
attributes:
label: "Is your feature request related to a problem? Please describe."
description: "A clear and concise description of what the problem is."
placeholder: "I'm always frustrated when [...]"
validations:
required: true
- 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."
placeholder: "I'm always frustrated when [...]"
validations:
required: true
- 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
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
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
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
attributes:
label: "Additional context"
description: "Add any other context or screenshots about the feature request here."
validations:
required: true
- type: textarea
attributes:
label: "Additional context"
description: "Add any other context or screenshots about the feature request here."
validations:
required: true

View file

@ -11,6 +11,32 @@ updates:
schedule:
interval: "daily"
- package-ecosystem: "npm"
directory: "/docs/user"
schedule:
interval: "weekly"
groups:
docusaurus:
patterns:
- "@docusaurus/*"
react:
patterns:
- "react"
- "react-dom"
- package-ecosystem: "npm"
directory: "/docs/developer"
schedule:
interval: "weekly"
groups:
docusaurus:
patterns:
- "@docusaurus/*"
react:
patterns:
- "react"
- "react-dom"
- package-ecosystem: "pip"
directory: "/"
schedule:

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
@ -22,7 +22,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0 # Need full history for git describe
fetch-depth: 0 # Need full history for git describe
- name: Extract version from manifest.json
id: manifest

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
@ -31,7 +31,7 @@ jobs:
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0 # Needed for version timestamps
fetch-depth: 0 # Needed for version timestamps
- name: Detect prerelease tag (beta/rc)
id: taginfo
@ -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
@ -137,8 +146,8 @@ jobs:
git config user.email "github-actions[bot]@users.noreply.github.com"
# Add version files from both docs
git add docs/user/versioned_docs/ docs/user/versions.json 2>/dev/null || true
git add docs/developer/versioned_docs/ docs/developer/versions.json 2>/dev/null || true
git add docs/user/versioned_docs/ docs/user/versioned_sidebars/ docs/user/versions.json 2>/dev/null || true
git add docs/developer/versioned_docs/ docs/developer/versioned_sidebars/ docs/developer/versions.json 2>/dev/null || true
# Commit if there are changes
if git diff --staged --quiet; then
@ -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,16 +3,16 @@ 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
permissions:
contents: write # Needed to create/update releases and push commits
contents: write # Needed to create/update releases and push commits
jobs:
# Note: We trust that validate.yml and lint.yml have already run on the
@ -103,13 +103,13 @@ jobs:
release-notes:
name: Generate and publish release notes
runs-on: ubuntu-latest
needs: sync-manifest # Wait for manifest sync to complete
needs: sync-manifest # Wait for manifest sync to complete
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0 # Fetch all history for git-cliff
ref: main # Use updated main branch if manifest was synced
fetch-depth: 0 # Fetch all history for git-cliff
ref: main # Use updated main branch if manifest was synced
- name: Get previous tag
id: previoustag
@ -181,8 +181,10 @@ jobs:
echo "Commits analyzed: Breaking=$BREAKING, Features=$FEAT, Fixes=$FIX"
# Set output for later steps (using heredoc for multi-line)
# Use random delimiter to avoid collision if content contains 'EOF'
WARN_DELIM=$(openssl rand -hex 16)
{
echo "warning<<EOF"
echo "warning<<${WARN_DELIM}"
echo "$WARNING"
echo ""
echo "$SUGGESTION"
@ -195,7 +197,7 @@ jobs:
echo "3. Push the corrected tag: \`git push origin v<suggested-version>\`"
echo ""
echo "**This tag will be automatically deleted in the next step.**"
echo "EOF"
echo "${WARN_DELIM}"
} >> $GITHUB_OUTPUT
else
echo "✓ Version bump looks appropriate for the changes"
@ -242,21 +244,24 @@ jobs:
echo "title=$TITLE" >> $GITHUB_OUTPUT
# Output for GitHub Actions
# Use random delimiter to avoid collision if release notes contain 'EOF'
NOTES_DELIM=$(openssl rand -hex 16)
{
echo 'notes<<EOF'
echo "notes<<${NOTES_DELIM}"
cat release-notes.md
echo EOF
printf '\n' # Ensure content ends with newline (git-cliff trim=true removes it)
echo "${NOTES_DELIM}"
} >> $GITHUB_OUTPUT
- 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 }}
draft: false
prerelease: ${{ contains(github.ref, 'b') }}
generate_release_notes: false # We provide our own
generate_release_notes: false # We provide our own
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

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: {}
@ -32,7 +32,7 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Run hassfest validation
uses: home-assistant/actions/hassfest@5752577ea7cc5aefb064b0b21432f18fe4d6ba90 # master
uses: home-assistant/actions/hassfest@f6f29a7ee3fa0eccadf3620a7b9ee00ab54ec03b # master
hacs: # https://github.com/hacs/action
name: HACS validation

View file

@ -1,9 +1,20 @@
{
"default": true,
"MD013": false,
"MD033": false,
"MD041": false,
"no-inline-html": false,
"line-length": false,
"first-line-heading": false
"default": true,
"MD004": false,
"MD013": false,
"MD036": false,
"MD041": 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

@ -21,3 +21,12 @@ repos:
language: system
types: [python]
require_serial: true
# Build changed Docusaurus site(s) to catch MDX/build errors early
- id: docusaurus-build-changed-sites
name: docusaurus build (changed sites)
entry: bash scripts/docs/build-changed-sites
language: system
files: ^docs/(user|developer)/
pass_filenames: true
require_serial: true

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)

1732
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

415
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"
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
target:
entity_id: switch.dishwasher
- alias: "Start Dishwasher During Best Price Period"
trigger:
- platform: state
entity_id: binary_sensor.tibber_best_price_period
to: "on"
action:
- 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."
- 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:
- action: climate.set_temperature
target:
entity_id: climate.living_room
data:
temperature: 19
```
### Temperature Control Based on Price Ratings
📖 **[More automations →](https://jpawlowski.github.io/hass.tibber_prices/user/automation-examples)** — EV charging, heat pump control, price notifications, and more
Adjust heating/cooling when current prices are significantly above the 24h average:
## 📈 Chart Visualizations
```yaml
automation:
- alias: "Reduce Heating During High Price Ratings"
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
target:
entity_id: climate.living_room
data:
temperature: 19 # Lower target temperature
```
Generate beautiful price charts with a single action call — dynamic Y-axis, color-coded price levels, and multiple chart modes included.
### Smart EV Charging Based on Tomorrow's Prices
<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">
Start charging when tomorrow's prices drop below today's average:
📖 **[Chart examples & setup →](https://jpawlowski.github.io/hass.tibber_prices/user/chart-examples)** | **[Actions reference →](https://jpawlowski.github.io/hass.tibber_prices/user/actions)**
```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
```
## ❓ Help & Support
## Troubleshooting
### No data appearing
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`
### 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,21 +5,34 @@
# 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 %}
---
If this release saved you some money on your electricity bill, a coffee would be much appreciated!
[![Buy Me A Coffee](https://img.buymeacoffee.com/button-api/?text=Buy%20me%20a%20coffee&emoji=&slug=jpawlowski&button_colour=FFDD00&font_colour=000000&font_family=Cookie&outline_colour=000000&coffee_colour=ffffff)](https://www.buymeacoffee.com/jpawlowski)
"""
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
@ -27,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
@ -50,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
@ -21,11 +23,15 @@ from homeassistant.loader import async_get_loaded_integration
from .api import TibberPricesApiClient
from .const import (
CONF_CURRENCY_DISPLAY_MODE,
CONF_PRICE_TREND_MIN_PRICE_CHANGE,
CONF_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY,
DATA_CHART_CONFIG,
DATA_CHART_METADATA_CONFIG,
DISPLAY_MODE_SUBUNIT,
DOMAIN,
LOGGER,
MAX_PRICE_TREND_MIN_PRICE_CHANGE,
MAX_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY,
async_load_standard_translations,
async_load_translations,
)
@ -37,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:
@ -88,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
@ -115,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
@ -141,6 +198,29 @@ async def _migrate_config_options(hass: HomeAssistant, entry: ConfigEntry) -> No
DISPLAY_MODE_SUBUNIT,
)
# Migration: Convert min_price_change from display currency (ct/øre) to base currency (EUR/NOK)
# Before this change, values were stored in display units. Now always stored in base currency.
# Detection: If either value exceeds its new max, both are in old format and need conversion.
# Old range: 0-5.0 ct / 0-10.0 ct, New range: 0-0.05 EUR / 0-0.10 EUR
normal_val = migrated.get(CONF_PRICE_TREND_MIN_PRICE_CHANGE)
strongly_val = migrated.get(CONF_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY)
old_format_detected = (normal_val is not None and normal_val > MAX_PRICE_TREND_MIN_PRICE_CHANGE) or (
strongly_val is not None and strongly_val > MAX_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY
)
if old_format_detected:
for key in (CONF_PRICE_TREND_MIN_PRICE_CHANGE, CONF_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY):
if key in migrated and migrated[key] > 0:
old_val = migrated[key]
migrated[key] = round(old_val / 100, 6)
migration_performed = True
LOGGER.info(
"[%s] Migrated config: %s = %s -> %s (converted to base currency)",
entry.title,
key,
old_val,
migrated[key],
)
# Save migrated options if any changes were made
if migration_performed:
hass.config_entries.async_update_entry(entry, options=migrated)
@ -195,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")
@ -335,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,
@ -230,12 +223,12 @@ class TibberPricesApiClient:
priceInfoRange(resolution:QUARTER_HOURLY, first:192, after: "{cursor}") {{
pageInfo{{ count }}
edges{{node{{
startsAt total level
startsAt total energy tax level
}}}}
}}
priceInfo(resolution:QUARTER_HOURLY) {{
today{{startsAt total level}}
tomorrow{{startsAt total level}}
today{{startsAt total energy tax level}}
tomorrow{{startsAt total energy tax level}}
}}
}}
}}
@ -500,7 +493,7 @@ class TibberPricesApiClient:
edges{{
cursor
node{{
startsAt total level
startsAt total energy tax level
}}
}}
}}
@ -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,12 +26,14 @@ 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
# Sentinel for _last_written_state: forces first write after init or coordinator update
_SENTINEL = object()
class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEntity):
"""tibber_prices binary_sensor class with state restoration."""
@ -60,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
@ -69,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",
}
)
@ -85,6 +85,8 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{entity_description.key}"
self._state_getter: Callable | None = self._get_value_getter()
self._time_sensitive_remove_listener: Callable | None = None
# State change detection for call-avoidance optimization (see sensor/core.py for rationale)
self._last_written_state: bool | None | object = _SENTINEL
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
@ -122,7 +124,12 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
# Store TimeService from Timer #2 for calculations during this update cycle
self.coordinator.time = time_service
self.async_write_ha_state()
# Call-avoidance: period binary sensors only change at period boundaries,
# not every 15 minutes. Skip expensive async_write_ha_state() when unchanged.
current_state = self.is_on
if current_state != self._last_written_state:
self._last_written_state = current_state
self.async_write_ha_state()
def _get_value_getter(self) -> Callable | None:
"""Return the appropriate value getter method based on the sensor type."""
@ -131,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,
@ -177,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
@ -196,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:
@ -301,17 +317,17 @@ 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
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
# All binary sensors get push updates when coordinator has new data:
# - tomorrow_data_available: Reflects new data availability immediately after API fetch
# - connection: Reflects connection state changes immediately
# - chart_data_export: Updates chart data when price data changes
# - peak_price_period, best_price_period: Update when periods change (also get Timer #2 updates)
# - data_lifecycle_status: Gets both push and Timer #2 updates
# Coordinator updates bring new API data — always write to ensure fresh state.
# Reset _last_written_state so timer-based handlers also write next cycle.
self._last_written_state = _SENTINEL
self.async_write_ha_state()
@property

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 }}"

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

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

@ -69,14 +69,21 @@ STEP_TO_SENSOR_KEYS: dict[str, list[str]] = {
# Also affects trend sensors (adaptive thresholds)
"current_price_trend",
"next_price_trend_change",
"price_trend_1h",
"price_trend_2h",
"price_trend_3h",
"price_trend_4h",
"price_trend_5h",
"price_trend_6h",
"price_trend_8h",
"price_trend_12h",
"price_outlook_1h",
"price_outlook_2h",
"price_outlook_3h",
"price_outlook_4h",
"price_outlook_5h",
"price_outlook_6h",
"price_outlook_8h",
"price_outlook_12h",
"price_trajectory_2h",
"price_trajectory_3h",
"price_trajectory_4h",
"price_trajectory_5h",
"price_trajectory_6h",
"price_trajectory_8h",
"price_trajectory_12h",
],
# Best Price settings affect best price binary sensor and timing sensors
"best_price": [
@ -106,14 +113,21 @@ STEP_TO_SENSOR_KEYS: dict[str, list[str]] = {
"price_trend": [
"current_price_trend",
"next_price_trend_change",
"price_trend_1h",
"price_trend_2h",
"price_trend_3h",
"price_trend_4h",
"price_trend_5h",
"price_trend_6h",
"price_trend_8h",
"price_trend_12h",
"price_outlook_1h",
"price_outlook_2h",
"price_outlook_3h",
"price_outlook_4h",
"price_outlook_5h",
"price_outlook_6h",
"price_outlook_8h",
"price_outlook_12h",
"price_trajectory_2h",
"price_trajectory_3h",
"price_trajectory_4h",
"price_trajectory_5h",
"price_trajectory_6h",
"price_trajectory_8h",
"price_trajectory_12h",
],
}

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,
@ -60,6 +61,8 @@ from custom_components.tibber_prices.const import (
CONF_PEAK_PRICE_MIN_PERIOD_LENGTH,
CONF_PRICE_RATING_THRESHOLD_HIGH,
CONF_PRICE_RATING_THRESHOLD_LOW,
CONF_PRICE_TREND_MIN_PRICE_CHANGE,
CONF_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY,
CONF_PRICE_TREND_THRESHOLD_FALLING,
CONF_PRICE_TREND_THRESHOLD_RISING,
CONF_PRICE_TREND_THRESHOLD_STRONGLY_FALLING,
@ -74,10 +77,13 @@ from custom_components.tibber_prices.const import (
DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH,
DOMAIN,
async_get_translation,
format_price_unit_base,
format_price_unit_subunit,
get_default_options,
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__)
@ -373,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
@ -497,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()
@ -640,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",
@ -712,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",
@ -730,6 +759,9 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
"""Configure price trend thresholds."""
errors: dict[str, str] = {}
# Get display factor for currency conversion
display_factor = get_display_unit_factor(self.config_entry)
if user_input is not None:
# Schema is now flattened - fields come directly in user_input
# Store them flat in options (no nested structure)
@ -775,6 +807,15 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
)
if not errors:
# Convert min_price_change values from display unit to base currency for storage
# (dividing by 1 is a no-op for base currency mode)
user_input[CONF_PRICE_TREND_MIN_PRICE_CHANGE] = round(
user_input[CONF_PRICE_TREND_MIN_PRICE_CHANGE] / display_factor, 6
)
user_input[CONF_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY] = round(
user_input[CONF_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY] / display_factor, 6
)
# Store flat data directly in options (no section wrapping)
self._options.update(user_input)
# async_create_entry automatically handles change detection and listener triggering
@ -782,9 +823,20 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
# Return to menu for more changes
return await self.async_step_init()
# Get currency code for unit label on sliders
currency_code = self.config_entry.data.get("currency", None)
if display_factor > 1:
price_unit = format_price_unit_subunit(currency_code)
else:
price_unit = format_price_unit_base(currency_code)
return self.async_show_form(
step_id="price_trend",
data_schema=get_price_trend_schema(self.config_entry.options),
data_schema=get_price_trend_schema(
self.config_entry.options,
display_factor=display_factor,
price_unit=price_unit,
),
errors=errors,
description_placeholders=self._get_entity_warning_placeholders("price_trend"),
)

View file

@ -12,27 +12,40 @@ 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,
CONF_PRICE_RATING_THRESHOLD_HIGH,
CONF_PRICE_RATING_THRESHOLD_LOW,
CONF_PRICE_TREND_CHANGE_CONFIRMATION,
CONF_PRICE_TREND_MIN_PRICE_CHANGE,
CONF_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY,
CONF_PRICE_TREND_THRESHOLD_FALLING,
CONF_PRICE_TREND_THRESHOLD_RISING,
CONF_PRICE_TREND_THRESHOLD_STRONGLY_FALLING,
@ -46,26 +59,39 @@ 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,
DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
DEFAULT_PRICE_RATING_THRESHOLD_LOW,
DEFAULT_PRICE_TREND_CHANGE_CONFIRMATION,
DEFAULT_PRICE_TREND_MIN_PRICE_CHANGE,
DEFAULT_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY,
DEFAULT_PRICE_TREND_THRESHOLD_FALLING,
DEFAULT_PRICE_TREND_THRESHOLD_RISING,
DEFAULT_PRICE_TREND_THRESHOLD_STRONGLY_FALLING,
@ -80,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,
@ -88,11 +116,15 @@ from custom_components.tibber_prices.const import (
MAX_PRICE_RATING_HYSTERESIS,
MAX_PRICE_RATING_THRESHOLD_HIGH,
MAX_PRICE_RATING_THRESHOLD_LOW,
MAX_PRICE_TREND_CHANGE_CONFIRMATION,
MAX_PRICE_TREND_FALLING,
MAX_PRICE_TREND_MIN_PRICE_CHANGE,
MAX_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY,
MAX_PRICE_TREND_RISING,
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,
@ -103,7 +135,10 @@ from custom_components.tibber_prices.const import (
MIN_PRICE_RATING_HYSTERESIS,
MIN_PRICE_RATING_THRESHOLD_HIGH,
MIN_PRICE_RATING_THRESHOLD_LOW,
MIN_PRICE_TREND_CHANGE_CONFIRMATION,
MIN_PRICE_TREND_FALLING,
MIN_PRICE_TREND_MIN_PRICE_CHANGE,
MIN_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY,
MIN_PRICE_TREND_RISING,
MIN_PRICE_TREND_STRONGLY_FALLING,
MIN_PRICE_TREND_STRONGLY_RISING,
@ -139,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:
"""
@ -606,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(
@ -621,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 {}
@ -742,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},
),
}
)
@ -767,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(
@ -782,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 {}
@ -903,12 +1015,69 @@ 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},
),
}
)
def get_price_trend_schema(options: Mapping[str, Any]) -> vol.Schema:
def get_price_trend_schema(
options: Mapping[str, Any],
*,
display_factor: int = 1,
price_unit: str = "",
) -> vol.Schema:
"""Return schema for price trend thresholds configuration."""
# Scale min_price_change values for display (stored in base currency, shown in display unit)
step = 0.1 if display_factor > 1 else 0.001
return vol.Schema(
{
vol.Optional(
@ -979,6 +1148,64 @@ def get_price_trend_schema(options: Mapping[str, Any]) -> vol.Schema:
mode=NumberSelectorMode.SLIDER,
),
),
vol.Optional(
CONF_PRICE_TREND_CHANGE_CONFIRMATION,
default=int(
options.get(
CONF_PRICE_TREND_CHANGE_CONFIRMATION,
DEFAULT_PRICE_TREND_CHANGE_CONFIRMATION,
)
),
): NumberSelector(
NumberSelectorConfig(
min=MIN_PRICE_TREND_CHANGE_CONFIRMATION,
max=MAX_PRICE_TREND_CHANGE_CONFIRMATION,
step=1,
mode=NumberSelectorMode.SLIDER,
),
),
vol.Optional(
CONF_PRICE_TREND_MIN_PRICE_CHANGE,
default=round(
float(
options.get(
CONF_PRICE_TREND_MIN_PRICE_CHANGE,
DEFAULT_PRICE_TREND_MIN_PRICE_CHANGE,
)
)
* display_factor,
3,
),
): NumberSelector(
NumberSelectorConfig(
min=MIN_PRICE_TREND_MIN_PRICE_CHANGE * display_factor,
max=MAX_PRICE_TREND_MIN_PRICE_CHANGE * display_factor,
step=step,
unit_of_measurement=price_unit,
mode=NumberSelectorMode.SLIDER,
),
),
vol.Optional(
CONF_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY,
default=round(
float(
options.get(
CONF_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY,
DEFAULT_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY,
)
)
* display_factor,
3,
),
): NumberSelector(
NumberSelectorConfig(
min=MIN_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY * display_factor,
max=MAX_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY * display_factor,
step=step,
unit_of_measurement=price_unit,
mode=NumberSelectorMode.SLIDER,
),
),
}
)

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 = (
@ -52,6 +51,9 @@ CONF_PRICE_TREND_THRESHOLD_RISING = "price_trend_threshold_rising"
CONF_PRICE_TREND_THRESHOLD_FALLING = "price_trend_threshold_falling"
CONF_PRICE_TREND_THRESHOLD_STRONGLY_RISING = "price_trend_threshold_strongly_rising"
CONF_PRICE_TREND_THRESHOLD_STRONGLY_FALLING = "price_trend_threshold_strongly_falling"
CONF_PRICE_TREND_CHANGE_CONFIRMATION = "price_trend_change_confirmation"
CONF_PRICE_TREND_MIN_PRICE_CHANGE = "price_trend_min_price_change"
CONF_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY = "price_trend_min_price_change_strongly"
CONF_VOLATILITY_THRESHOLD_MODERATE = "volatility_threshold_moderate"
CONF_VOLATILITY_THRESHOLD_HIGH = "volatility_threshold_high"
CONF_VOLATILITY_THRESHOLD_VERY_HIGH = "volatility_threshold_very_high"
@ -65,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"
@ -103,10 +114,15 @@ DEFAULT_PRICE_LEVEL_GAP_TOLERANCE = 1 # Max consecutive intervals to smooth out
DEFAULT_AVERAGE_SENSOR_DISPLAY = "median" # Default: show median in state, mean in attributes
DEFAULT_PRICE_TREND_THRESHOLD_RISING = 3 # Default trend threshold for rising prices (%)
DEFAULT_PRICE_TREND_THRESHOLD_FALLING = -3 # Default trend threshold for falling prices (%, negative value)
# Strong trend thresholds default to 2x the base threshold.
# These are independently configurable to allow fine-tuning of "strongly" detection.
DEFAULT_PRICE_TREND_THRESHOLD_STRONGLY_RISING = 6 # Default strong rising threshold (%)
DEFAULT_PRICE_TREND_THRESHOLD_STRONGLY_FALLING = -6 # Default strong falling threshold (%, negative value)
# Strong trend thresholds default to 3x the base threshold for perceptual scaling.
# The non-linear ratio (3% → 9%) ensures "strongly" feels significantly different from "rising/falling".
DEFAULT_PRICE_TREND_THRESHOLD_STRONGLY_RISING = 9 # Default strong rising threshold (%)
DEFAULT_PRICE_TREND_THRESHOLD_STRONGLY_FALLING = -9 # Default strong falling threshold (%, negative value)
DEFAULT_PRICE_TREND_CHANGE_CONFIRMATION = 3 # Default consecutive intervals to confirm trend change (3 x 15min = 45min)
DEFAULT_PRICE_TREND_MIN_PRICE_CHANGE = 0.005 # Minimum absolute price change for trend (in base currency, e.g. EUR/NOK)
DEFAULT_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY = (
0.015 # Minimum absolute price change for strong trend (in base currency)
)
# Default volatility thresholds (relative values using coefficient of variation)
# Coefficient of variation = (standard_deviation / mean) * 100%
# These thresholds are unitless and work across different price levels
@ -124,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
@ -132,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)
@ -172,6 +201,14 @@ MIN_PRICE_TREND_STRONGLY_RISING = 2 # Minimum strongly rising threshold (must b
MAX_PRICE_TREND_STRONGLY_RISING = 100 # Maximum strongly rising threshold
MIN_PRICE_TREND_STRONGLY_FALLING = -100 # Minimum strongly falling threshold (negative)
MAX_PRICE_TREND_STRONGLY_FALLING = -2 # Maximum strongly falling threshold (must be < falling)
# Trend change confirmation limits (consecutive 15-min intervals)
MIN_PRICE_TREND_CHANGE_CONFIRMATION = 2 # Minimum: 2 intervals (30 min) - fast but more noise
MAX_PRICE_TREND_CHANGE_CONFIRMATION = 6 # Maximum: 6 intervals (90 min) - very stable but slow
# Minimum absolute price change thresholds (noise floor, in base currency e.g. EUR/NOK)
MIN_PRICE_TREND_MIN_PRICE_CHANGE = 0.0 # 0 = disabled (pure percentage mode)
MAX_PRICE_TREND_MIN_PRICE_CHANGE = 0.05 # 5 ct / 5 øre equivalent
MIN_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY = 0.0 # 0 = disabled
MAX_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY = 0.10 # 10 ct / 10 øre equivalent
# Gap count and relaxation limits
MIN_GAP_COUNT = 0 # Minimum gap count
@ -362,6 +399,11 @@ def get_default_options(currency_code: str | None) -> dict[str, Any]:
# Price trend thresholds (flat - single-section step)
CONF_PRICE_TREND_THRESHOLD_RISING: DEFAULT_PRICE_TREND_THRESHOLD_RISING,
CONF_PRICE_TREND_THRESHOLD_FALLING: DEFAULT_PRICE_TREND_THRESHOLD_FALLING,
CONF_PRICE_TREND_THRESHOLD_STRONGLY_RISING: DEFAULT_PRICE_TREND_THRESHOLD_STRONGLY_RISING,
CONF_PRICE_TREND_THRESHOLD_STRONGLY_FALLING: DEFAULT_PRICE_TREND_THRESHOLD_STRONGLY_FALLING,
CONF_PRICE_TREND_CHANGE_CONFIRMATION: DEFAULT_PRICE_TREND_CHANGE_CONFIRMATION,
CONF_PRICE_TREND_MIN_PRICE_CHANGE: DEFAULT_PRICE_TREND_MIN_PRICE_CHANGE,
CONF_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY: DEFAULT_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY,
# Nested section: Period settings (shared by best/peak price)
"period_settings": {
CONF_BEST_PRICE_MIN_PERIOD_LENGTH: DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH,
@ -387,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,
},
}
@ -406,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.
@ -466,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 = [
@ -998,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

@ -62,14 +62,22 @@ TIME_SENSITIVE_ENTITY_KEYS = frozenset(
"current_price_trend",
"next_price_trend_change",
# Price trend sensors
"price_trend_1h",
"price_trend_2h",
"price_trend_3h",
"price_trend_4h",
"price_trend_5h",
"price_trend_6h",
"price_trend_8h",
"price_trend_12h",
"price_outlook_1h",
"price_outlook_2h",
"price_outlook_3h",
"price_outlook_4h",
"price_outlook_5h",
"price_outlook_6h",
"price_outlook_8h",
"price_outlook_12h",
# Price trajectory sensors (first-half vs second-half window comparison)
"price_trajectory_2h",
"price_trajectory_3h",
"price_trajectory_4h",
"price_trajectory_5h",
"price_trajectory_6h",
"price_trajectory_8h",
"price_trajectory_12h",
# Trailing/leading 24h calculations (based on current interval)
"trailing_price_average",
"leading_price_average",
@ -80,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)
@ -108,5 +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)
"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

@ -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,90 +975,103 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
)
break
phase_label = f"flex={current_flex * 100:.1f}%"
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):
continue
# The callback expects a level override (e.g. None or "any"), not a flex label.
if not should_show_callback(level_override):
continue
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,
current_flex * 100,
original_level_filter,
)
# 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=applied_level_filter,
)
# Try current flex with level="any" (in relaxation mode)
if original_level_filter != "any":
_LOGGER_DETAILS.debug(
"%s Flex=%.1f%%: OVERRIDING level_filter: %s → ANY",
"%s Trying %s: config has %d intervals (all days together), level_filter=%s",
INDENT_L2,
current_flex * 100,
original_level_filter,
)
# 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",
)
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,
phase_label_full,
len(all_prices),
relaxed_config.level_filter,
)
# Process ALL prices together (allows midnight crossing)
result = calculate_periods(all_prices, config=relaxed_config, time=time)
new_periods = result["periods"]
_LOGGER_DETAILS.debug(
"%s %s: calculate_periods returned %d periods",
INDENT_L2,
phase_label_full,
len(new_periods),
)
# Mark newly found periods with relaxation metadata BEFORE merging
mark_periods_with_relaxation(
new_periods,
relaxation_level=phase_label_full,
original_threshold=base_flex,
applied_threshold=current_flex,
reverse_sort=config.reverse_sort,
)
# Resolve overlaps between existing and new periods
combined, standalone_count = resolve_period_overlaps(
existing_periods=existing_periods,
new_relaxed_periods=new_periods,
)
# Count periods per day with QUALITY GATE check
# Only periods with CV <= PERIOD_MAX_CV count towards min_periods requirement
days_meeting_requirement, quality_period_count = _count_quality_periods(
combined, all_prices, prices_by_day, min_periods, time=time
)
total_periods = len(combined)
_LOGGER_DETAILS.debug(
"%s %s: found %d periods total, %d/%d days meet requirement",
INDENT_L2,
phase_label_full,
total_periods,
days_meeting_requirement,
total_days,
)
existing_periods = combined
phases_used.append(phase_label_full)
# Check if ALL days reached target
if days_meeting_requirement >= total_days:
_LOGGER.info(
"Success with %s - all %d days have %d+ periods (%d total)",
phase_label_full,
total_days,
min_periods,
total_periods,
len(all_prices),
relaxed_config.level_filter,
)
# Process ALL prices together (allows midnight crossing)
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(
"%s %s: calculate_periods returned %d periods",
INDENT_L2,
phase_label_full,
len(new_periods),
)
# Mark newly found periods with relaxation metadata BEFORE merging
mark_periods_with_relaxation(
new_periods,
relaxation_level=phase_label_full,
original_threshold=base_flex,
applied_threshold=current_flex,
reverse_sort=config.reverse_sort,
)
# Resolve overlaps between existing and new periods
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
# Only periods with CV <= PERIOD_MAX_CV count towards min_periods requirement
days_meeting_requirement, quality_period_count = _count_quality_periods(
combined, all_prices, prices_by_day, min_periods, time=time
)
total_periods = len(combined)
_LOGGER_DETAILS.debug(
"%s %s: found %d periods total, %d/%d days meet requirement",
INDENT_L2,
phase_label_full,
total_periods,
days_meeting_requirement,
total_days,
)
existing_periods = combined
phases_used.append(phase_label_full)
# Check if ALL days reached target
if days_meeting_requirement >= total_days:
_LOGGER.info(
"Success with %s - all %d days have %d+ periods (%d total)",
phase_label_full,
total_days,
min_periods,
total_periods,
)
break
if days_meeting_requirement >= total_days:
break
# Build final result
@ -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(
_const.CONF_PEAK_PRICE_MIN_PERIOD_LENGTH,
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(
_const.CONF_BEST_PRICE_MIN_PERIOD_LENGTH,
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(
_const.CONF_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
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(
_const.CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
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(
_const.CONF_PRICE_RATING_THRESHOLD_LOW,
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(
_const.CONF_PRICE_RATING_THRESHOLD_HIGH,
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(
_const.CONF_VOLATILITY_THRESHOLD_MODERATE,
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(
_const.CONF_VOLATILITY_THRESHOLD_HIGH,
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(
_const.CONF_VOLATILITY_THRESHOLD_VERY_HIGH,
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(
_const.CONF_MIN_PERIODS_BEST,
"relaxation_and_target_periods",
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(
_const.CONF_RELAXATION_ATTEMPTS_BEST,
"relaxation_and_target_periods",
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(
_const.CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
"period_settings",
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(
_const.CONF_MIN_PERIODS_PEAK,
"relaxation_and_target_periods",
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(
_const.CONF_RELAXATION_ATTEMPTS_PEAK,
"relaxation_and_target_periods",
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(
_const.CONF_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
"period_settings",
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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

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

@ -1,33 +1,93 @@
{
"services": {
"get_price": {
"service": "mdi:table-search"
},
"get_chartdata": {
"service": "mdi:chart-bar",
"sections": {
"general": "mdi:identifier",
"selection": "mdi:calendar-range",
"filters": "mdi:filter-variant",
"transformation": "mdi:tune",
"format": "mdi:file-table",
"arrays_of_objects": "mdi:code-json",
"arrays_of_arrays": "mdi:code-brackets"
}
},
"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"
}
},
"refresh_user_data": {
"service": "mdi:refresh"
}
"services": {
"get_price": {
"service": "mdi:table-search"
},
"get_chartdata": {
"service": "mdi:chart-bar",
"sections": {
"general": "mdi:identifier",
"selection": "mdi:calendar-range",
"filters": "mdi:filter-variant",
"transformation": "mdi:tune",
"format": "mdi:file-table",
"arrays_of_objects": "mdi:code-json",
"arrays_of_arrays": "mdi:code-brackets"
}
},
"get_apexcharts_yaml": {
"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)

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