Compare commits
205 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec2b29e814 | ||
|
|
ab5bf92729 | ||
|
|
0df089cc11 | ||
|
|
1f74451adf | ||
|
|
c2ff9cd2f2 | ||
|
|
95d0278241 | ||
|
|
b93eedf00e | ||
|
|
ba08bd34c6 | ||
|
|
9cb5b35184 | ||
|
|
dc4933ec5c | ||
|
|
75d7e20a22 | ||
|
|
fa6342cf72 | ||
|
|
5d80dc7df4 | ||
|
|
92a53991d9 | ||
|
|
1b2a74f812 | ||
|
|
bb8f5aa8cc | ||
|
|
bbcfdd4443 | ||
|
|
10c83d6720 | ||
|
|
c8f40e0b8a | ||
|
|
870b716681 | ||
|
|
1ffc8bd426 | ||
|
|
e4c805c508 | ||
|
|
f79c8b9e05 | ||
|
|
807098f93e | ||
|
|
d535dd110a | ||
|
|
f3b2d8e6ab | ||
|
|
df746bf892 | ||
|
|
96f36a3339 | ||
|
|
093e904329 | ||
|
|
e75e0ed1dc | ||
|
|
2d2873f75f | ||
|
|
e01cc5d447 | ||
|
|
a8d1519a26 | ||
|
|
31fca73ccd | ||
|
|
0162394263 | ||
|
|
3057642cba | ||
|
|
60b2de0379 | ||
|
|
303a7c7835 | ||
|
|
63c3404fbd | ||
|
|
7783a0b629 | ||
|
|
63a187fe5c | ||
|
|
9a4ee04cfa | ||
|
|
d66b3f4ec0 | ||
|
|
2b63440933 | ||
|
|
4f2bea6720 | ||
|
|
8ebff9bc9a | ||
|
|
75da094c81 | ||
|
|
ba3e127ac7 | ||
|
|
2092d28ece | ||
|
|
432eb6502c | ||
|
|
db02f262b6 | ||
|
|
361498b7f5 | ||
|
|
ebcb9cfe77 | ||
|
|
6b4c46a305 | ||
|
|
c85f4991ab | ||
|
|
c3173a16d6 | ||
|
|
752a0c5dbc | ||
|
|
2adb64e5a0 | ||
|
|
7629c0f628 | ||
|
|
09edcdb9a3 | ||
|
|
e6ec54d8c5 | ||
|
|
5b5d5e73b0 | ||
|
|
e5474d50ec | ||
|
|
aa3f909814 | ||
|
|
76a3a0f1fd | ||
|
|
ee9adce9d5 | ||
|
|
240acac00a | ||
|
|
33fa536198 | ||
|
|
1d065b11cd | ||
|
|
07788a57ea | ||
|
|
ccf1d6185d | ||
|
|
061b42b8f3 | ||
|
|
a4ad506e01 | ||
|
|
6d22ea7151 | ||
|
|
91147bd79c | ||
|
|
2e7ccc36c5 | ||
|
|
9efa7809d0 | ||
|
|
ff08df24e7 | ||
|
|
707e1d47da | ||
|
|
236a15bea4 | ||
|
|
f2a8cd6777 | ||
|
|
9af252fb61 | ||
|
|
4b0aa4a93b | ||
|
|
a54c1353e1 | ||
|
|
27ab58bbf5 | ||
|
|
bf95dc5efc | ||
|
|
f4313485cd | ||
|
|
729bf307ca | ||
|
|
9042ea6efb | ||
|
|
71696380a6 | ||
|
|
4b7001b731 | ||
|
|
6f0b7aa837 | ||
|
|
4ba159d815 | ||
|
|
4a72cde62a | ||
|
|
adf85792d5 | ||
|
|
1706bd7c0e | ||
|
|
b1e0245a60 | ||
|
|
51a62d712f | ||
|
|
dd59c687e3 | ||
|
|
3ba8e91958 | ||
|
|
a2fe572dc2 | ||
|
|
aa9a1200b8 | ||
|
|
e163a47d57 | ||
|
|
a93ad1ac96 | ||
|
|
a957334990 | ||
|
|
0ca52f8d3c | ||
|
|
7b477cd4c7 | ||
|
|
6f5261785b | ||
|
|
c89248d493 | ||
|
|
32b080d178 | ||
|
|
6e990564b9 | ||
|
|
b2d63c2b6d | ||
|
|
3fda932442 | ||
|
|
a240393911 | ||
|
|
1d3c55097d | ||
|
|
779e22a84e | ||
|
|
9e1ba10f0b | ||
|
|
a8d5230531 | ||
|
|
796eb4b422 | ||
|
|
4ddd19b132 | ||
|
|
e44f639b41 | ||
|
|
b7f1efce1f | ||
|
|
447dc907e6 | ||
|
|
999ecd358f | ||
|
|
f6a49d9cf3 | ||
|
|
4cc150df6f | ||
|
|
83ec3910bd | ||
|
|
9142f87abd | ||
|
|
6e0613c055 | ||
|
|
8aa5769784 | ||
|
|
c7af02f7c2 | ||
|
|
2f704a35a3 | ||
|
|
d6bd933e90 | ||
|
|
07117801d2 | ||
|
|
1db86d1766 | ||
|
|
c610dbe1a3 | ||
|
|
ac7cd5b572 | ||
|
|
f2f0d296d1 | ||
|
|
cbbfadbf4f | ||
|
|
c494d0e39d | ||
|
|
c892d7376c | ||
|
|
0e699ae142 | ||
|
|
cd59834277 | ||
|
|
89de3dcadf | ||
|
|
40a80247a0 | ||
|
|
16f74d6419 | ||
|
|
5314454a26 | ||
|
|
6e7b7b3ceb | ||
|
|
565397b8ca | ||
|
|
2a08515ba0 | ||
|
|
faa3b2b71a | ||
|
|
b1b41be9aa | ||
|
|
74cca1857a | ||
|
|
112b169f26 | ||
|
|
84deafbdf5 | ||
|
|
c1ffcfd559 | ||
|
|
55e02e3b54 | ||
|
|
459d6762c7 | ||
|
|
ac09e5f235 | ||
|
|
aee1920292 | ||
|
|
06eedee410 | ||
|
|
9c4ac6bce4 | ||
|
|
d1b25e9cfe | ||
|
|
edabb49309 | ||
|
|
f5dcf04aab | ||
|
|
e1da4cfa89 | ||
|
|
50dc874274 | ||
|
|
124824a2ea | ||
|
|
cbf5e1a3fe | ||
|
|
2b96ccc650 | ||
|
|
33f57ff077 | ||
|
|
d0b6ea0e1a | ||
|
|
aa0f543ec5 | ||
|
|
a4a43e3d34 | ||
|
|
4efd6b7267 | ||
|
|
bb176135f6 | ||
|
|
c160063067 | ||
|
|
8bd3ef85c0 | ||
|
|
48d088281b | ||
|
|
070905e880 | ||
|
|
b7cf4442bd | ||
|
|
dba96e38e0 | ||
|
|
ebc3c38007 | ||
|
|
86c5db179e | ||
|
|
c7bd0b7a93 | ||
|
|
688cf0d5a3 | ||
|
|
de577c83a6 | ||
|
|
fd3c949e90 | ||
|
|
fc8ec33f11 | ||
|
|
f4fecc3ee0 | ||
|
|
5f52dd2524 | ||
|
|
db900c2a4b | ||
|
|
3aa8a43e3a | ||
|
|
9fea34b8b4 | ||
|
|
552db6ef7d | ||
|
|
1c25ac1fb0 | ||
|
|
d8f005d3bb | ||
|
|
190c979e9c | ||
|
|
5d673e65b4 | ||
|
|
798de5946d | ||
|
|
91efeed90f | ||
|
|
90e2c3c1dc | ||
|
|
4d9b1545b0 | ||
|
|
a978b645cf | ||
|
|
da3aa3bf1e |
11
.claude/settings.local.json
Normal 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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,4 @@
|
|||
{
|
||||
"recommendations": [],
|
||||
"unwantedRecommendations": [
|
||||
"ms-python.pylint"
|
||||
]
|
||||
"unwantedRecommendations": ["ms-python.pylint"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,13 @@
|
|||
{
|
||||
"name": "jpawlowski/hass.tibber_prices",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:3.14",
|
||||
"image": "mcr.microsoft.com/devcontainers/base:debian",
|
||||
"postCreateCommand": "bash .devcontainer/setup-git.sh && scripts/setup/setup",
|
||||
"postStartCommand": "scripts/motd",
|
||||
"containerEnv": {
|
||||
"PYTHONASYNCIODEBUG": "1",
|
||||
"TIBBER_PRICES_DEV": "1"
|
||||
},
|
||||
"forwardPorts": [
|
||||
8123,
|
||||
3000,
|
||||
3001
|
||||
],
|
||||
"forwardPorts": [8123, 3000, 3001],
|
||||
"portsAttributes": {
|
||||
"8123": {
|
||||
"label": "Home Assistant",
|
||||
|
|
@ -38,7 +34,8 @@
|
|||
"ms-python.vscode-pylance",
|
||||
"ms-vscode-remote.remote-containers",
|
||||
"redhat.vscode-yaml",
|
||||
"ryanluker.vscode-coverage-gutters"
|
||||
"ryanluker.vscode-coverage-gutters",
|
||||
"MermaidChart.vscode-mermaid-chart"
|
||||
],
|
||||
"settings": {
|
||||
"editor.tabSize": 4,
|
||||
|
|
@ -56,9 +53,7 @@
|
|||
"reportUnusedCoroutine": "none",
|
||||
"reportMissingTypeStubs": "none"
|
||||
},
|
||||
"python.analysis.include": [
|
||||
"custom_components/tibber_prices"
|
||||
],
|
||||
"python.analysis.include": ["custom_components/tibber_prices"],
|
||||
"python.analysis.exclude": [
|
||||
"**/.venv/**",
|
||||
"**/venv/**",
|
||||
|
|
@ -69,14 +64,20 @@
|
|||
"**/node_modules/**"
|
||||
],
|
||||
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
|
||||
"python.analysis.extraPaths": [
|
||||
"${workspaceFolder}/.venv/lib/python3.14/site-packages"
|
||||
],
|
||||
"python.analysis.extraPaths": ["${workspaceFolder}/.venv/lib/python3.14/site-packages"],
|
||||
"python.terminal.activateEnvironment": true,
|
||||
"python.terminal.activateEnvInCurrentTerminal": true,
|
||||
"python.testing.pytestArgs": [
|
||||
"--no-cov"
|
||||
],
|
||||
"python.testing.pytestArgs": ["--no-cov"],
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.tabSize": 2
|
||||
},
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.tabSize": 2
|
||||
},
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "charliermarsh.ruff",
|
||||
"editor.formatOnSave": true,
|
||||
|
|
@ -102,18 +103,19 @@
|
|||
"markdown.validate.fragmentLinks.enabled": "ignore",
|
||||
"json.schemas": [
|
||||
{
|
||||
"fileMatch": [
|
||||
"homeassistant/components/*/manifest.json"
|
||||
],
|
||||
"fileMatch": ["homeassistant/components/*/manifest.json"],
|
||||
"url": "${containerWorkspaceFolder}/schemas/json/manifest_schema.json"
|
||||
},
|
||||
{
|
||||
"fileMatch": [
|
||||
"homeassistant/components/*/translations/*.json"
|
||||
],
|
||||
"fileMatch": ["homeassistant/components/*/translations/*.json"],
|
||||
"url": "${containerWorkspaceFolder}/schemas/json/translation_schema.json"
|
||||
}
|
||||
],
|
||||
"github.copilot.chat.commitMessageGeneration.instructions": [
|
||||
{
|
||||
"file": ".github/instructions/commit-messages.instructions.md"
|
||||
}
|
||||
],
|
||||
"git.useConfigOnly": false
|
||||
}
|
||||
}
|
||||
|
|
@ -127,18 +129,44 @@
|
|||
"ghcr.io/flexwie/devcontainer-features/op:1": {
|
||||
"version": "latest"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"ghcr.io/devcontainers/features/node:2": {
|
||||
"version": "24"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/rust:1": {
|
||||
"version": "latest",
|
||||
"profile": "minimal"
|
||||
},
|
||||
"ghcr.io/devcontainer-community/devcontainer-features/yq:1": {
|
||||
"version": "latest"
|
||||
},
|
||||
"ghcr.io/devcontainers-extra/features/apt-packages:1": {
|
||||
"packages": [
|
||||
"autoconf",
|
||||
"automake",
|
||||
"bat",
|
||||
"eza",
|
||||
"fd-find",
|
||||
"ffmpeg",
|
||||
"fzf",
|
||||
"git-delta",
|
||||
"httpie",
|
||||
"hyperfine",
|
||||
"ipython3",
|
||||
"jo",
|
||||
"jq",
|
||||
"libpcap-dev",
|
||||
"libssl-dev",
|
||||
"libtool",
|
||||
"libturbojpeg0",
|
||||
"libpcap-dev"
|
||||
"miller",
|
||||
"moreutils",
|
||||
"pipx",
|
||||
"ripgrep",
|
||||
"shellcheck",
|
||||
"shfmt",
|
||||
"sqlite3",
|
||||
"tree",
|
||||
"yamllint"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -1,4 +1,4 @@
|
|||
# These are supported funding model platforms
|
||||
|
||||
github: [ jpawlowski ]
|
||||
github: [jpawlowski]
|
||||
buy_me_a_coffee: jpawlowski
|
||||
|
|
|
|||
18
.github/ISSUE_TEMPLATE/bug.yml
vendored
|
|
@ -3,30 +3,30 @@ name: "Bug report"
|
|||
description: "Report a bug with the custom integration"
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: Before you open a new issue, search through the existing issues to see if others have had the same problem.
|
||||
- type: input
|
||||
- type: input
|
||||
attributes:
|
||||
label: "Home Assistant version"
|
||||
description: "The version of Home Assistant you are using"
|
||||
placeholder: "2025.1.0"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
- type: input
|
||||
attributes:
|
||||
label: "Integration version"
|
||||
description: "The version of this custom integration you are using"
|
||||
placeholder: "1.0.0"
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "System Health details"
|
||||
description: "Paste the data from the System Health card in Home Assistant (https://www.home-assistant.io/more-info/system-health#github-issues)"
|
||||
validations:
|
||||
required: false
|
||||
- type: checkboxes
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
|
|
@ -38,13 +38,13 @@ body:
|
|||
required: true
|
||||
- label: This issue is not a duplicate issue of any [previous issues](https://github.com/jpawlowski/hass.tibber_prices/issues?q=is%3Aissue+label%3A%22Bug%22+)..
|
||||
required: true
|
||||
- type: textarea
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "Describe the issue"
|
||||
description: "A clear and concise description of what the issue is."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Reproduction steps
|
||||
description: "Without steps to reproduce, it will be hard to fix. It is very important that you fill out this part. Issues without it will be closed."
|
||||
|
|
@ -55,7 +55,7 @@ body:
|
|||
...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "Debug logs"
|
||||
description: "To enable debug logs check this https://www.home-assistant.io/integrations/logger/, this **needs** to include _everything_ from startup of Home Assistant to the point where you encounter the issue."
|
||||
|
|
@ -63,7 +63,7 @@ body:
|
|||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "Diagnostics dump"
|
||||
description: "Drag the diagnostics dump file here. (see https://www.home-assistant.io/integrations/diagnostics/ for info)"
|
||||
|
|
|
|||
12
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
|
|
@ -3,10 +3,10 @@ name: "Feature request"
|
|||
description: "Suggest an idea for this custom integration"
|
||||
labels: ["Feature request"]
|
||||
body:
|
||||
- type: markdown
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: Before you open a new feature request, search through the existing feature requests to see if others have had the same idea.
|
||||
- type: checkboxes
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
|
|
@ -17,7 +17,7 @@ body:
|
|||
- label: This issue is not a duplicate feature request of [previous feature requests](https://github.com/jpawlowski/hass.tibber_prices/issues?q=is%3Aissue+label%3A%22Feature+Request%22+).
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "Is your feature request related to a problem? Please describe."
|
||||
description: "A clear and concise description of what the problem is."
|
||||
|
|
@ -25,21 +25,21 @@ body:
|
|||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "Describe the solution you'd like"
|
||||
description: "A clear and concise description of what you want to happen."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "Describe alternatives you've considered"
|
||||
description: "A clear and concise description of any alternative solutions or features you've considered."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "Additional context"
|
||||
description: "Add any other context or screenshots about the feature request here."
|
||||
|
|
|
|||
26
.github/dependabot.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
95
.github/instructions/commit-messages.instructions.md
vendored
Normal 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
|
|
@ -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],
|
||||
});
|
||||
2
.github/workflows/auto-tag.yml
vendored
|
|
@ -5,7 +5,7 @@ on:
|
|||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'custom_components/tibber_prices/manifest.json'
|
||||
- "custom_components/tibber_prices/manifest.json"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
|
|
|||
23
.github/workflows/docusaurus.yml
vendored
|
|
@ -4,10 +4,10 @@ on:
|
|||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- '.github/workflows/docusaurus.yml'
|
||||
- "docs/**"
|
||||
- ".github/workflows/docusaurus.yml"
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
- "v*.*.*"
|
||||
workflow_dispatch:
|
||||
|
||||
# Concurrency control: cancel in-progress deployments
|
||||
|
|
@ -47,11 +47,20 @@ jobs:
|
|||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: 'npm'
|
||||
cache: "npm"
|
||||
cache-dependency-path: |
|
||||
docs/user/package-lock.json
|
||||
docs/developer/package-lock.json
|
||||
|
||||
# VERIFY GENERATED DOCS
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Verify sensor reference is up-to-date
|
||||
run: python3 scripts/docs/generate-sensor-reference --check
|
||||
|
||||
# USER DOCS BUILD
|
||||
- name: Install user docs dependencies
|
||||
working-directory: docs/user
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
10
.github/workflows/lint.yml
vendored
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
19
.github/workflows/release.yml
vendored
|
|
@ -3,11 +3,11 @@ name: Generate Release Notes
|
|||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*' # Triggers on version tags like v1.0.0, v2.1.3, etc.
|
||||
- "v*.*.*" # Triggers on version tags like v1.0.0, v2.1.3, etc.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Tag version to release (e.g., v0.3.0)'
|
||||
description: "Tag version to release (e.g., v0.3.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
|
|
@ -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,15 +244,18 @@ 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 }}
|
||||
|
|
|
|||
10
.github/workflows/validate.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,9 +1,20 @@
|
|||
{
|
||||
"default": true,
|
||||
"MD004": false,
|
||||
"MD013": false,
|
||||
"MD033": false,
|
||||
"MD036": false,
|
||||
"MD041": false,
|
||||
"no-inline-html": false,
|
||||
"line-length": false,
|
||||
"first-line-heading": false
|
||||
"no-trailing-punctuation": false,
|
||||
"no-inline-html": {
|
||||
"allowed_elements": ["br", "details", "summary", "img", "a", "kbd"]
|
||||
},
|
||||
"code-block-style": {
|
||||
"style": "fenced"
|
||||
},
|
||||
"emphasis-style": {
|
||||
"style": "underscore"
|
||||
},
|
||||
"strong-style": {
|
||||
"style": "asterisk"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
11
CODEOWNERS
Normal 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
|
||||
|
|
@ -72,7 +72,18 @@ Impact: <user-visible effects>
|
|||
|
||||
**Types:** `feat`, `fix`, `docs`, `refactor`, `chore`, `test`
|
||||
|
||||
For full commit-message rules (including release-note skip trailers for internal/unreleased fixes), see:
|
||||
|
||||
- `.github/instructions/commit-messages.instructions.md`
|
||||
|
||||
Important trailers for commits that should NOT appear in release notes:
|
||||
|
||||
- `Release-Notes: skip`
|
||||
- `User-Impact: none`
|
||||
- `Released-Bug: no`
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(sensors): add daily average price sensor
|
||||
|
||||
|
|
@ -81,7 +92,7 @@ Added new sensor that calculates average price for the entire day.
|
|||
Impact: Users can now track daily average prices for cost analysis."
|
||||
```
|
||||
|
||||
See [`AGENTS.md`](AGENTS.md) section "Git Workflow Guidance" for detailed guidelines.
|
||||
See `.github/instructions/commit-messages.instructions.md` for detailed commit-message guidelines.
|
||||
|
||||
## Submitting Changes
|
||||
|
||||
|
|
@ -111,6 +122,7 @@ See [`AGENTS.md`](AGENTS.md) section "Git Workflow Guidance" for detailed guidel
|
|||
- **Python version**: 3.13+
|
||||
|
||||
Always run before committing:
|
||||
|
||||
```bash
|
||||
./scripts/lint
|
||||
```
|
||||
|
|
@ -136,6 +148,7 @@ Documentation is organized in two Docusaurus sites:
|
|||
- Navigation via `docs/developer/sidebars.ts`
|
||||
|
||||
**When adding new documentation:**
|
||||
|
||||
1. Place file in appropriate `docs/*/docs/` directory
|
||||
2. Add to corresponding `sidebars.ts` for navigation
|
||||
3. Update translations when changing `translations/en.json` (update ALL language files)
|
||||
|
|
@ -145,6 +158,7 @@ Documentation is organized in two Docusaurus sites:
|
|||
Report bugs via [GitHub Issues](../../issues/new/choose).
|
||||
|
||||
**Great bug reports include:**
|
||||
|
||||
- Quick summary and background
|
||||
- Steps to reproduce (be specific!)
|
||||
- Expected vs. actual behavior
|
||||
|
|
|
|||
385
README.md
|
|
@ -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.
|
||||
|
||||
[](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
|
||||
|
||||
[](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 1h–12h average prices, price outlook & trajectory sensors | 20+ |
|
||||
| **📈 Trends** | Current trend direction, next trend change time & countdown | 3 |
|
||||
| **📉 Volatility** | Today, tomorrow, next 24h & combined volatility levels | 4 |
|
||||
| **🏷️ Levels & Ratings** | 5-level (API) and 3-level (computed) classification per interval, hour & day | 12+ |
|
||||
| **⏰ Period Timing** | Best/peak: end time, duration, remaining, progress, next start | 10+ |
|
||||
| **🔌 Binary Sensors** | Best price period, peak price period, tomorrow data available, API connection | 4+ |
|
||||
| **🎛️ Runtime Config** | Switches & numbers to adjust period detection live — no restart needed | 14 |
|
||||
| **🔧 Diagnostics** | Data lifecycle status, home metadata, grid info, subscription status | 15+ |
|
||||
|
||||
### Core Price Sensors (Enabled by Default)
|
||||
> **Every sensor includes rich attributes** — timestamps, detailed descriptions, and context data. Enable **Extended Descriptions** in the integration options to get `long_description` and `usage_tips` on every entity.
|
||||
|
||||
| Entity | Description |
|
||||
| -------------------------- | ------------------------------------------------- |
|
||||
| Current Electricity Price | Current 15-minute interval price |
|
||||
| Next Interval Price | Price for the next 15-minute interval |
|
||||
| Current Hour Average Price | Average of current hour's 4 intervals |
|
||||
| Next Hour Average Price | Average of next hour's 4 intervals |
|
||||
| Current Price Level | API classification (VERY_CHEAP to VERY_EXPENSIVE) |
|
||||
| Next Interval Price Level | Price level for next interval |
|
||||
| Current Hour Price Level | Price level for current hour average |
|
||||
| Next Hour Price Level | Price level for next hour average |
|
||||
📖 **[Complete Sensor Reference →](https://jpawlowski.github.io/hass.tibber_prices/user/sensor-reference)** — All entities with descriptions, attributes, and multi-language lookup
|
||||
|
||||
### Statistical Sensors (Enabled by Default)
|
||||
## 🤖 Automation Sneak Peek
|
||||
|
||||
| Entity | Description |
|
||||
| ------------------------- | ------------------------------------------- |
|
||||
| Today's Lowest Price | Minimum price for today |
|
||||
| Today's Highest Price | Maximum price for today |
|
||||
| Today's Average Price | Mean price across today's intervals |
|
||||
| Tomorrow's Lowest Price | Minimum price for tomorrow (when available) |
|
||||
| Tomorrow's Highest Price | Maximum price for tomorrow (when available) |
|
||||
| Tomorrow's Average Price | Mean price for tomorrow (when available) |
|
||||
| Leading 24h Average Price | Average of next 24 hours from now |
|
||||
| Leading 24h Minimum Price | Lowest price in next 24 hours |
|
||||
| Leading 24h Maximum Price | Highest price in next 24 hours |
|
||||
> See the **[full automation examples guide](https://jpawlowski.github.io/hass.tibber_prices/user/automation-examples)** for more recipes.
|
||||
|
||||
### Price Rating Sensors (Enabled by Default)
|
||||
|
||||
| Entity | Description |
|
||||
| -------------------------- | --------------------------------------------------------- |
|
||||
| Current Price Rating | % difference from 24h trailing average (current interval) |
|
||||
| Next Interval Price Rating | % difference from 24h trailing average (next interval) |
|
||||
| Current Hour Price Rating | % difference for current hour average |
|
||||
| Next Hour Price Rating | % difference for next hour average |
|
||||
|
||||
> **How ratings work**: Compares each interval to the average of the previous 96 intervals (24 hours). Positive values mean prices are above average, negative means below average.
|
||||
|
||||
### Binary Sensors (Enabled by Default)
|
||||
|
||||
| Entity | Description |
|
||||
| ------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| Peak Price Period | ON when in a detected peak price period ([how it works](https://jpawlowski.github.io/hass.tibber_prices/user/period-calculation)) |
|
||||
| Best Price Period | ON when in a detected best price period ([how it works](https://jpawlowski.github.io/hass.tibber_prices/user/period-calculation)) |
|
||||
| Tibber API Connection | Connection status to Tibber API |
|
||||
| Tomorrow's Data Available | Whether tomorrow's price data is available |
|
||||
|
||||
### Diagnostic Sensors (Enabled by Default)
|
||||
|
||||
| Entity | Description |
|
||||
| --------------- | ------------------------------------------ |
|
||||
| Data Expiration | Timestamp when current data expires |
|
||||
| Price Forecast | Formatted list of upcoming price intervals |
|
||||
|
||||
### Additional Sensors (Disabled by Default)
|
||||
|
||||
The following sensors are available but disabled by default. Enable them in `Settings > Devices & Services > Tibber Price Information & Ratings > Entities`:
|
||||
|
||||
- **Previous Interval Price** & **Previous Interval Price Level**: Historical data for the last 15-minute interval
|
||||
- **Previous Interval Price Rating**: Rating for the previous interval
|
||||
- **Trailing 24h Average Price**: Average of the past 24 hours from now
|
||||
- **Trailing 24h Minimum/Maximum Price**: Min/max in the past 24 hours
|
||||
|
||||
> **Note**: Currency display is configurable during setup. Choose between:
|
||||
> - **Base currency** (€/kWh, kr/kWh) - decimal values, differences visible from 3rd-4th decimal
|
||||
> - **Subunit** (ct/kWh, øre/kWh) - larger values, differences visible from 1st decimal
|
||||
>
|
||||
> Smart defaults: EUR → subunit (German/Dutch preference), NOK/SEK/DKK → base (Scandinavian preference). Supported currencies: EUR, NOK, SEK, DKK, USD, GBP.
|
||||
|
||||
## Automation Examples> **Note:** See the [full automation examples guide](https://jpawlowski.github.io/hass.tibber_prices/user/automation-examples) for more advanced recipes.
|
||||
|
||||
### Run Appliances During Cheap Hours
|
||||
|
||||
Use the `binary_sensor.tibber_best_price_period` to automatically start appliances during detected best price periods:
|
||||
**Run appliances when electricity is cheapest:**
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Run Dishwasher During Cheap Hours"
|
||||
- alias: "Start Dishwasher During Best Price Period"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.tibber_best_price_period
|
||||
to: "on"
|
||||
condition:
|
||||
- condition: time
|
||||
after: "21:00:00"
|
||||
before: "06:00:00"
|
||||
action:
|
||||
- service: switch.turn_on
|
||||
- action: switch.turn_on
|
||||
target:
|
||||
entity_id: switch.dishwasher
|
||||
```
|
||||
|
||||
> **Learn more:** The [period calculation guide](https://jpawlowski.github.io/hass.tibber_prices/user/period-calculation) explains how Best/Peak Price periods are identified and how you can configure filters (flexibility, minimum distance from average, price level filters with gap tolerance).
|
||||
|
||||
### Notify on Extremely High Prices
|
||||
|
||||
Get notified when prices reach the VERY_EXPENSIVE level:
|
||||
**Reduce heating when prices spike above average:**
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Notify on Very Expensive Electricity"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: sensor.tibber_current_interval_price_level
|
||||
to: "VERY_EXPENSIVE"
|
||||
action:
|
||||
- service: notify.mobile_app
|
||||
data:
|
||||
title: "⚠️ High Electricity Prices"
|
||||
message: "Current electricity price is in the VERY EXPENSIVE range. Consider reducing consumption."
|
||||
```
|
||||
|
||||
### Temperature Control Based on Price Ratings
|
||||
|
||||
Adjust heating/cooling when current prices are significantly above the 24h average:
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Reduce Heating During High Price Ratings"
|
||||
- alias: "Reduce Heating During High Prices"
|
||||
trigger:
|
||||
- platform: numeric_state
|
||||
entity_id: sensor.tibber_current_interval_price_rating
|
||||
above: 20 # More than 20% above 24h average
|
||||
action:
|
||||
- service: climate.set_temperature
|
||||
- action: climate.set_temperature
|
||||
target:
|
||||
entity_id: climate.living_room
|
||||
data:
|
||||
temperature: 19 # Lower target temperature
|
||||
temperature: 19
|
||||
```
|
||||
|
||||
### Smart EV Charging Based on Tomorrow's Prices
|
||||
📖 **[More automations →](https://jpawlowski.github.io/hass.tibber_prices/user/automation-examples)** — EV charging, heat pump control, price notifications, and more
|
||||
|
||||
Start charging when tomorrow's prices drop below today's average:
|
||||
## 📈 Chart Visualizations
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Smart EV Charging"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.tibber_best_price_interval
|
||||
to: "on"
|
||||
condition:
|
||||
- condition: numeric_state
|
||||
entity_id: sensor.tibber_current_interval_price_rating
|
||||
below: -15 # At least 15% below average
|
||||
- condition: numeric_state
|
||||
entity_id: sensor.ev_battery_level
|
||||
below: 80
|
||||
action:
|
||||
- service: switch.turn_on
|
||||
target:
|
||||
entity_id: switch.ev_charger
|
||||
```
|
||||
Generate beautiful price charts with a single action call — dynamic Y-axis, color-coded price levels, and multiple chart modes included.
|
||||
|
||||
## Troubleshooting
|
||||
<img src="https://raw.githubusercontent.com/jpawlowski/hass.tibber_prices/main/docs/user/static/img/charts/rolling-window.jpg" width="600" alt="Dynamic 48h rolling window chart with color-coded price levels">
|
||||
|
||||
### No data appearing
|
||||
📖 **[Chart examples & setup →](https://jpawlowski.github.io/hass.tibber_prices/user/chart-examples)** | **[Actions reference →](https://jpawlowski.github.io/hass.tibber_prices/user/actions)**
|
||||
|
||||
1. Check your API token is valid at [developer.tibber.com](https://developer.tibber.com/settings/access-token)
|
||||
2. Verify you have an active Tibber subscription
|
||||
3. Check the Home Assistant logs for detailed error messages (`Settings > System > Logs`)
|
||||
4. Restart the integration: `Settings > Devices & Services > Tibber Price Information & Ratings > ⋮ > Reload`
|
||||
## ❓ Help & Support
|
||||
|
||||
### Missing tomorrow's price data
|
||||
|
||||
- Tomorrow's price data typically becomes available between **13:00 and 15:00** each day (Nordic time)
|
||||
- The integration automatically checks more frequently during this window
|
||||
- Check `binary_sensor.tibber_tomorrows_data_available` to see if data is available
|
||||
- If data is unavailable after 15:00, verify it's available in the Tibber app first
|
||||
|
||||
### Prices not updating at quarter-hour boundaries
|
||||
|
||||
- Entities automatically refresh at 00/15/30/45-minute marks without waiting for API polls
|
||||
- Check `sensor.tibber_data_expiration` to verify data freshness
|
||||
- The integration caches data intelligently and survives Home Assistant restarts
|
||||
|
||||
### Currency or units showing incorrectly
|
||||
|
||||
- Currency is automatically detected from your Tibber account
|
||||
- Display mode (base currency vs. subunit) can be configured in integration options: `Settings > Devices & Services > Tibber Price Information & Ratings > Configure`
|
||||
- Supported currencies: EUR, NOK, SEK, DKK, USD, and GBP
|
||||
- Smart defaults apply: EUR users get subunit (ct), Scandinavian users get base currency (kr)
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Sensor Attributes
|
||||
|
||||
Every sensor includes rich attributes beyond just the state value. These attributes provide context, timestamps, and additional data useful for automations and templates.
|
||||
|
||||
**Standard attributes available on most sensors:**
|
||||
|
||||
- `timestamp` - ISO 8601 timestamp for the data point
|
||||
- `description` - Brief explanation of what the sensor represents
|
||||
- `level_id` and `level_value` - For price level sensors (e.g., `VERY_CHEAP` = -2)
|
||||
|
||||
**Extended descriptions** (enable in integration options):
|
||||
|
||||
- `long_description` - Detailed explanation of the sensor's purpose
|
||||
- `usage_tips` - Practical suggestions for using the sensor in automations
|
||||
|
||||
**Example - Current Price sensor attributes:**
|
||||
|
||||
```yaml
|
||||
timestamp: "2025-11-03T14:15:00+01:00"
|
||||
description: "The current electricity price per kWh"
|
||||
long_description: "Shows the current price per kWh from your Tibber subscription"
|
||||
usage_tips: "Use this to track prices or to create automations that run when electricity is cheap"
|
||||
```
|
||||
|
||||
**Example template using attributes:**
|
||||
|
||||
```yaml
|
||||
template:
|
||||
- sensor:
|
||||
- name: "Price Status"
|
||||
state: >
|
||||
{% set price = states('sensor.tibber_current_electricity_price') | float %}
|
||||
{% set timestamp = state_attr('sensor.tibber_current_electricity_price', 'timestamp') %}
|
||||
Price at {{ timestamp }}: {{ price }} ct/kWh
|
||||
```
|
||||
|
||||
📖 **[View all sensors and attributes →](https://jpawlowski.github.io/hass.tibber_prices/user/sensors)**
|
||||
|
||||
### Dynamic Icons & Visual Indicators
|
||||
|
||||
All sensors feature dynamic icons that change based on price levels, providing instant visual feedback in your dashboards.
|
||||
|
||||
<img src="docs/user/static/img/entities-overview.jpg" width="400" alt="Entity list showing dynamic icons for different price states">
|
||||
|
||||
_Dynamic icons adapt to price levels, trends, and period states - showing CHEAP prices, FALLING trend, and active Best Price Period_
|
||||
|
||||
📖 **[Dynamic Icons Guide →](https://jpawlowski.github.io/hass.tibber_prices/user/dynamic-icons)** | **[Icon Colors Guide →](https://jpawlowski.github.io/hass.tibber_prices/user/icon-colors)**
|
||||
|
||||
### Custom Actions
|
||||
|
||||
The integration provides custom actions (they still appear as services under the hood) for advanced use cases. These actions show up in Home Assistant under **Developer Tools → Actions**.
|
||||
|
||||
- `tibber_prices.get_chartdata` - Get price data in chart-friendly formats for any visualization card
|
||||
- `tibber_prices.get_apexcharts_yaml` - Generate complete ApexCharts configurations
|
||||
- `tibber_prices.refresh_user_data` - Manually refresh account information
|
||||
|
||||
📖 **[Action documentation and examples →](https://jpawlowski.github.io/hass.tibber_prices/user/actions)**
|
||||
|
||||
### Chart Visualizations (Optional)
|
||||
|
||||
The integration includes built-in support for creating price visualization cards with automatic Y-axis scaling and color-coded series.
|
||||
|
||||
<img src="docs/user/static/img/charts/rolling-window.jpg" width="600" alt="Example: Dynamic 48h rolling window chart">
|
||||
|
||||
_Optional: Dynamic 48h chart with automatic Y-axis scaling - generated via `get_apexcharts_yaml` action_
|
||||
|
||||
📖 **[Chart examples and setup guide →](https://jpawlowski.github.io/hass.tibber_prices/user/chart-examples)**
|
||||
- 📖 **[FAQ](https://jpawlowski.github.io/hass.tibber_prices/user/faq)** — Common questions answered
|
||||
- 🔧 **[Troubleshooting](https://jpawlowski.github.io/hass.tibber_prices/user/troubleshooting)** — Solving common issues
|
||||
- 🐛 **[Report an Issue](https://github.com/jpawlowski/hass.tibber_prices/issues/new)** — Found a bug? Let us know
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Please read the [Contributing Guidelines](CONTRIBUTING.md) and [Developer Documentation](https://jpawlowski.github.io/hass.tibber_prices/developer/) before submitting pull requests.
|
||||
Contributions are welcome! See the [Contributing Guidelines](CONTRIBUTING.md) and [Developer Documentation](https://jpawlowski.github.io/hass.tibber_prices/developer/) to get started.
|
||||
|
||||
### For Contributors
|
||||
|
||||
- **[Developer Setup](https://jpawlowski.github.io/hass.tibber_prices/developer/setup)** - Get started with DevContainer
|
||||
- **[Architecture Guide](https://jpawlowski.github.io/hass.tibber_prices/developer/architecture)** - Understand the codebase
|
||||
- **[Release Management](https://jpawlowski.github.io/hass.tibber_prices/developer/release-management)** - Release process and versioning
|
||||
- **[Developer Setup](https://jpawlowski.github.io/hass.tibber_prices/developer/setup)** — DevContainer-based development environment
|
||||
- **[Architecture Guide](https://jpawlowski.github.io/hass.tibber_prices/developer/architecture)** — Understand the codebase
|
||||
- **[Release Management](https://jpawlowski.github.io/hass.tibber_prices/developer/release-management)** — Release process and versioning
|
||||
|
||||
## 🤖 Development Note
|
||||
|
||||
This integration is developed with extensive AI assistance (GitHub Copilot, Claude, and other AI tools). While AI enables rapid development and helps implement complex features, it's possible that some edge cases or subtle bugs may exist that haven't been discovered yet. If you encounter any issues, please [open an issue](https://github.com/jpawlowski/hass.tibber_prices/issues/new) - we'll work on fixing them (with AI help, of course! 😊).
|
||||
This integration is developed with extensive AI assistance (GitHub Copilot, Claude, and other AI tools). While AI enables rapid development, it's possible that some edge cases haven't been discovered yet. If you encounter any issues, please [open an issue](https://github.com/jpawlowski/hass.tibber_prices/issues/new) — we'll fix them (with AI help, of course! 😊).
|
||||
|
||||
The integration is actively maintained and benefits from AI's ability to quickly understand and implement Home Assistant patterns, maintain consistency across the codebase, and handle complex data transformations. Quality is ensured through automated linting (Ruff), Home Assistant's type checking, and real-world testing.
|
||||
Quality is ensured through automated linting (Ruff), static type checking (Pyright), and real-world testing.
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
This project is licensed under the MIT License — see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -386,7 +198,6 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
|
|||
[commits]: https://github.com/jpawlowski/hass.tibber_prices/commits/main
|
||||
[hacs]: https://github.com/hacs/integration
|
||||
[hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge
|
||||
[exampleimg]: https://raw.githubusercontent.com/jpawlowski/hass.tibber_prices/main/images/example.png
|
||||
[license-shield]: https://img.shields.io/github/license/jpawlowski/hass.tibber_prices.svg?style=for-the-badge
|
||||
[maintenance-shield]: https://img.shields.io/badge/maintainer-%40jpawlowski-blue.svg?style=for-the-badge
|
||||
[user_profile]: https://github.com/jpawlowski
|
||||
|
|
|
|||
63
cliff.toml
|
|
@ -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! ☕
|
||||
|
||||
[](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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,6 +124,11 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
|
|||
# Store TimeService from Timer #2 for calculations during this update cycle
|
||||
self.coordinator.time = time_service
|
||||
|
||||
# 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:
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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 }}"
|
||||
|
|
@ -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 }}"
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 }}"
|
||||
|
|
@ -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 }}"
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 1–2 hours, but prices remain favorable for 4–6 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.
|
||||
|
|
@ -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 (0–100%). 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.
|
||||
|
|
@ -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 1–5 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 (1–5) 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 }}"
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 }}"
|
||||
|
|
@ -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 }}"
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 }}"
|
||||
BIN
custom_components/tibber_prices/brand/dark_icon.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
custom_components/tibber_prices/brand/dark_icon@2x.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
custom_components/tibber_prices/brand/dark_logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
custom_components/tibber_prices/brand/dark_logo@2x.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
custom_components/tibber_prices/brand/icon.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
custom_components/tibber_prices/brand/icon@2x.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
custom_components/tibber_prices/brand/logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
custom_components/tibber_prices/brand/logo@2x.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
],
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2,26 +2,24 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from custom_components.tibber_prices.const import PRICE_LEVEL_MAPPING
|
||||
from custom_components.tibber_prices.const import PRICE_LEVEL_CHEAP, PRICE_LEVEL_MAPPING, PRICE_LEVEL_VERY_CHEAP
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||
|
||||
from .level_filtering import (
|
||||
apply_level_filter,
|
||||
check_interval_criteria,
|
||||
)
|
||||
from .types import TibberPricesIntervalCriteria
|
||||
from .level_filtering import apply_level_filter, check_interval_criteria, compute_geometric_flex_bonus
|
||||
from .types import CROSS_DAY_OVERNIGHT_VALIDATION_HOUR, TibberPricesIntervalCriteria
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_LOGGER_DETAILS = logging.getLogger(__name__ + ".details")
|
||||
|
||||
# Module-local log indentation (each module starts at level 0)
|
||||
INDENT_L0 = "" # Entry point / main function
|
||||
NEGATIVE_CORE_NO_SHOULDER_INTERVALS = 8 # 2 hours at 15-min resolution
|
||||
|
||||
|
||||
def split_intervals_by_day(
|
||||
|
|
@ -53,7 +51,199 @@ def calculate_reference_prices(intervals_by_day: dict[date, list[dict]], *, reve
|
|||
return ref_prices
|
||||
|
||||
|
||||
def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building logic requires many arguments, statements, and branches
|
||||
def _trim_trailing_gaps(period: list[dict]) -> list[dict]:
|
||||
"""Remove trailing gap-tolerance intervals from a period.
|
||||
|
||||
Gap-tolerance intervals at the trailing edge of a period represent
|
||||
the transition out of the period's price level (e.g., the first
|
||||
NORMAL interval after a sequence of EXPENSIVE ones). Keeping them
|
||||
shifts the reported period end by up to gap_count intervals.
|
||||
Interior gaps (surrounded by qualifying intervals on both sides)
|
||||
are kept because they represent brief dips within an otherwise
|
||||
continuous period.
|
||||
"""
|
||||
while period and period[-1].get("is_level_gap", False):
|
||||
period = period[:-1]
|
||||
return period
|
||||
|
||||
|
||||
def _build_period_interval(price_data: dict, *, time: TibberPricesTimeService) -> dict | None:
|
||||
"""Build the internal interval representation used by raw periods."""
|
||||
starts_at = time.get_interval_time(price_data)
|
||||
if starts_at is None:
|
||||
return None
|
||||
|
||||
price_original = float(price_data.get("_original_price", price_data["total"]))
|
||||
return {
|
||||
"interval_hour": starts_at.hour,
|
||||
"interval_minute": starts_at.minute,
|
||||
"interval_time": f"{starts_at.hour:02d}:{starts_at.minute:02d}",
|
||||
"price": price_original,
|
||||
"interval_start": starts_at,
|
||||
"smoothing_was_impactful": False,
|
||||
"is_level_gap": False,
|
||||
"geometric_bonus_applied": False,
|
||||
}
|
||||
|
||||
|
||||
def _get_longest_negative_core_length(period: list[dict]) -> int:
|
||||
"""Return the longest contiguous run of intervals with price <= 0."""
|
||||
longest = 0
|
||||
current = 0
|
||||
|
||||
for interval in period:
|
||||
if float(interval.get("price", 0.0)) <= 0:
|
||||
current += 1
|
||||
longest = max(longest, current)
|
||||
else:
|
||||
current = 0
|
||||
|
||||
return longest
|
||||
|
||||
|
||||
def _collect_contiguous_best_price_side(
|
||||
interval_index: dict[datetime, dict],
|
||||
start_cursor: datetime,
|
||||
step: timedelta,
|
||||
*,
|
||||
max_intervals: int,
|
||||
time: TibberPricesTimeService,
|
||||
) -> list[dict]:
|
||||
"""Collect directly adjacent favourable intervals on one side of a negative core."""
|
||||
for target_level in (PRICE_LEVEL_VERY_CHEAP, PRICE_LEVEL_CHEAP):
|
||||
additions: list[dict] = []
|
||||
cursor = start_cursor
|
||||
|
||||
for _ in range(max_intervals):
|
||||
price_data = interval_index.get(cursor)
|
||||
if price_data is None or price_data.get("level") != target_level:
|
||||
break
|
||||
|
||||
period_interval = _build_period_interval(price_data, time=time)
|
||||
if period_interval is None:
|
||||
break
|
||||
|
||||
additions.append(period_interval)
|
||||
cursor += step
|
||||
|
||||
if additions:
|
||||
if step < timedelta(0):
|
||||
additions.reverse()
|
||||
return additions
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _select_nearest_extensions(
|
||||
left_candidates: list[dict],
|
||||
right_candidates: list[dict],
|
||||
*,
|
||||
max_total_additions: int,
|
||||
) -> tuple[list[dict], list[dict]]:
|
||||
"""Select the nearest left/right additions until the target length is reached."""
|
||||
left_nearest = list(reversed(left_candidates))
|
||||
right_nearest = right_candidates.copy()
|
||||
selected_left_nearest: list[dict] = []
|
||||
selected_right: list[dict] = []
|
||||
prefer_left = bool(left_nearest)
|
||||
|
||||
while max_total_additions > 0 and (left_nearest or right_nearest):
|
||||
if prefer_left and left_nearest:
|
||||
selected_left_nearest.append(left_nearest.pop(0))
|
||||
max_total_additions -= 1
|
||||
elif not prefer_left and right_nearest:
|
||||
selected_right.append(right_nearest.pop(0))
|
||||
max_total_additions -= 1
|
||||
elif left_nearest:
|
||||
selected_left_nearest.append(left_nearest.pop(0))
|
||||
max_total_additions -= 1
|
||||
elif right_nearest:
|
||||
selected_right.append(right_nearest.pop(0))
|
||||
max_total_additions -= 1
|
||||
|
||||
prefer_left = not prefer_left
|
||||
|
||||
return list(reversed(selected_left_nearest)), selected_right
|
||||
|
||||
|
||||
def extend_negative_core_periods_for_min_length(
|
||||
periods: list[list[dict]],
|
||||
all_prices: list[dict],
|
||||
min_period_length: int,
|
||||
*,
|
||||
time: TibberPricesTimeService,
|
||||
) -> list[list[dict]]:
|
||||
"""Locally extend short negative best-price cores into directly adjacent cheap shoulders.
|
||||
|
||||
This rescue step is intentionally narrow:
|
||||
- only periods that already contain a negative/zero core are considered
|
||||
- only periods shorter than the configured minimum length are extended
|
||||
- only directly adjacent VERY_CHEAP/CHEAP intervals may be added
|
||||
- multi-hour negative blocks stay untouched to preserve a strict negative-only period
|
||||
"""
|
||||
if not periods:
|
||||
return periods
|
||||
|
||||
min_intervals = time.minutes_to_intervals(min_period_length)
|
||||
if min_intervals <= 0:
|
||||
return periods
|
||||
|
||||
interval_index: dict[datetime, dict] = {}
|
||||
for price_data in all_prices:
|
||||
starts_at = time.get_interval_time(price_data)
|
||||
if starts_at is not None:
|
||||
interval_index[starts_at] = price_data
|
||||
|
||||
interval_duration = time.get_interval_duration()
|
||||
extended_periods: list[list[dict]] = []
|
||||
|
||||
for period in periods:
|
||||
negative_core_length = _get_longest_negative_core_length(period)
|
||||
if (
|
||||
negative_core_length == 0
|
||||
or negative_core_length >= NEGATIVE_CORE_NO_SHOULDER_INTERVALS
|
||||
or len(period) >= min_intervals
|
||||
):
|
||||
extended_periods.append(period)
|
||||
continue
|
||||
|
||||
period_start = period[0].get("interval_start")
|
||||
period_end = period[-1].get("interval_start")
|
||||
if period_start is None or period_end is None:
|
||||
extended_periods.append(period)
|
||||
continue
|
||||
|
||||
needed_intervals = min_intervals - len(period)
|
||||
left_candidates = _collect_contiguous_best_price_side(
|
||||
interval_index,
|
||||
period_start - interval_duration,
|
||||
-interval_duration,
|
||||
max_intervals=needed_intervals,
|
||||
time=time,
|
||||
)
|
||||
right_candidates = _collect_contiguous_best_price_side(
|
||||
interval_index,
|
||||
period_end + interval_duration,
|
||||
interval_duration,
|
||||
max_intervals=needed_intervals,
|
||||
time=time,
|
||||
)
|
||||
|
||||
selected_left, selected_right = _select_nearest_extensions(
|
||||
left_candidates,
|
||||
right_candidates,
|
||||
max_total_additions=needed_intervals,
|
||||
)
|
||||
|
||||
if selected_left or selected_right:
|
||||
extended_periods.append([*selected_left, *period, *selected_right])
|
||||
else:
|
||||
extended_periods.append(period)
|
||||
|
||||
return extended_periods
|
||||
|
||||
|
||||
def build_periods(
|
||||
all_prices: list[dict],
|
||||
price_context: dict[str, Any],
|
||||
*,
|
||||
|
|
@ -61,6 +251,7 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
|
|||
level_filter: str | None = None,
|
||||
gap_count: int = 0,
|
||||
time: TibberPricesTimeService,
|
||||
time_range: tuple[datetime, datetime] | None = None,
|
||||
) -> list[list[dict]]:
|
||||
"""
|
||||
Build periods, allowing periods to cross midnight (day boundary).
|
||||
|
|
@ -77,12 +268,18 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
|
|||
level_filter: Level filter string ("cheap", "expensive", "any", None)
|
||||
gap_count: Number of allowed consecutive intervals deviating by exactly 1 level step
|
||||
time: TibberPricesTimeService instance (required)
|
||||
time_range: Optional (start_inclusive, end_exclusive) window. When set, only intervals
|
||||
within [start, end) are considered as period candidates. Reference prices
|
||||
(from price_context) remain day-wide and are unaffected by this filter.
|
||||
Used by Phase 4 segment forcing to restrict detection to one segment side.
|
||||
|
||||
"""
|
||||
ref_prices = price_context["ref_prices"]
|
||||
avg_prices = price_context["avg_prices"]
|
||||
flex = price_context["flex"]
|
||||
min_distance_from_avg = price_context["min_distance_from_avg"]
|
||||
geometric_extra_flex: float = float(price_context.get("geometric_extra_flex", 0.0))
|
||||
day_patterns_by_date: dict[date, dict[str, Any]] | None = price_context.get("day_patterns_by_date")
|
||||
|
||||
# Calculate level_order if level_filter is active
|
||||
level_order = None
|
||||
|
|
@ -124,11 +321,15 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
|
|||
)
|
||||
for day in ref_prices
|
||||
}
|
||||
|
||||
for price_data in all_prices:
|
||||
starts_at = time.get_interval_time(price_data)
|
||||
if starts_at is None:
|
||||
continue
|
||||
|
||||
# Filter by time range if specified (Phase 4 segment forcing)
|
||||
if time_range is not None and not (time_range[0] <= starts_at < time_range[1]):
|
||||
continue
|
||||
|
||||
date_key = starts_at.date()
|
||||
|
||||
# Use smoothed price for criteria checks (flex/distance)
|
||||
|
|
@ -147,7 +348,52 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
|
|||
|
||||
# Check flex and minimum distance criteria (using smoothed price and interval's own day reference)
|
||||
criteria = criteria_by_day[ref_date]
|
||||
in_flex, meets_min_distance = check_interval_criteria(price_for_criteria, criteria)
|
||||
|
||||
# Compute geometric flex bonus if pattern-aware expansion is enabled.
|
||||
# Best-price days with a negative daily minimum are handled by the dedicated
|
||||
# negative-core logic; applying a day-wide geometric valley bonus there would
|
||||
# reintroduce broad positive shoulders around a negative core.
|
||||
geo_bonus = 0.0
|
||||
if (
|
||||
geometric_extra_flex > 0
|
||||
and day_patterns_by_date is not None
|
||||
and not (not reverse_sort and criteria.ref_price < 0)
|
||||
):
|
||||
day_pattern_for_date = day_patterns_by_date.get(ref_date)
|
||||
geo_bonus = compute_geometric_flex_bonus(
|
||||
starts_at,
|
||||
day_pattern_for_date,
|
||||
extra_flex=geometric_extra_flex,
|
||||
reverse_sort=reverse_sort,
|
||||
)
|
||||
|
||||
effective_criteria = criteria._replace(flex=criteria.flex + geo_bonus) if geo_bonus > 0 else criteria
|
||||
in_flex, meets_min_distance = check_interval_criteria(price_for_criteria, effective_criteria)
|
||||
|
||||
# Cross-day boundary validation (symmetric for best AND peak periods):
|
||||
# Overnight intervals (00:00-05:59) must ALSO qualify against the previous
|
||||
# day's reference price. This prevents day-boundary artifacts in BOTH directions:
|
||||
#
|
||||
# PEAK example: A 30ct interval becomes "peak" against tomorrow's lower max (35ct)
|
||||
# but wasn't peak against today's higher max (39ct).
|
||||
# BEST example: An 8ct interval becomes "best" against today's lower min (5ct, flex
|
||||
# allows ≤7.5ct) but actually a 7ct interval qualifying today wouldn't have
|
||||
# qualified yesterday when min was 4ct (flex allows ≤6ct).
|
||||
#
|
||||
# In both cases the apparent "extreme" is just a relative shift between adjacent
|
||||
# days, not a genuine outlier worth reporting.
|
||||
if in_flex and starts_at.hour < CROSS_DAY_OVERNIGHT_VALIDATION_HOUR:
|
||||
prev_day = date_key - timedelta(days=1)
|
||||
prev_criteria = criteria_by_day.get(prev_day)
|
||||
if prev_criteria is not None:
|
||||
prev_effective = (
|
||||
prev_criteria._replace(flex=prev_criteria.flex + geo_bonus) if geo_bonus > 0 else prev_criteria
|
||||
)
|
||||
in_prev_flex, _ = check_interval_criteria(price_for_criteria, prev_effective)
|
||||
if not in_prev_flex:
|
||||
# Fails against previous day → boundary artifact, treat as not in flex
|
||||
in_flex = False
|
||||
intervals_filtered_by_flex += 1
|
||||
|
||||
# Track why intervals are filtered
|
||||
if not in_flex:
|
||||
|
|
@ -159,7 +405,7 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
|
|||
smoothing_was_impactful = False
|
||||
if price_data.get("_smoothed", False):
|
||||
# Check if original price would have passed the same criteria
|
||||
in_flex_original, meets_min_distance_original = check_interval_criteria(price_original, criteria)
|
||||
in_flex_original, meets_min_distance_original = check_interval_criteria(price_original, effective_criteria)
|
||||
# Smoothing was impactful if original would have failed but smoothed passed
|
||||
smoothing_was_impactful = (in_flex and meets_min_distance) and not (
|
||||
in_flex_original and meets_min_distance_original
|
||||
|
|
@ -184,15 +430,23 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
|
|||
# Only True if smoothing changed whether the interval qualified for period inclusion
|
||||
"smoothing_was_impactful": smoothing_was_impactful,
|
||||
"is_level_gap": is_level_gap, # Track if kept due to level gap tolerance
|
||||
"geometric_bonus_applied": geo_bonus > 0, # True if interval is in geometric zone
|
||||
}
|
||||
)
|
||||
elif current_period:
|
||||
# Criteria no longer met, end current period
|
||||
# Criteria no longer met, end current period.
|
||||
# Trim trailing gap-tolerance intervals: these sit at the boundary
|
||||
# between the period's price level and the next, and would shift
|
||||
# the period end by up to gap_count intervals.
|
||||
current_period = _trim_trailing_gaps(current_period)
|
||||
if current_period:
|
||||
periods.append(current_period)
|
||||
current_period = []
|
||||
consecutive_gaps = 0 # Reset gap counter
|
||||
|
||||
# Add final period if exists
|
||||
if current_period:
|
||||
current_period = _trim_trailing_gaps(current_period)
|
||||
if current_period:
|
||||
periods.append(current_period)
|
||||
|
||||
|
|
@ -368,6 +622,86 @@ def _filter_superseded_today_periods(
|
|||
return kept
|
||||
|
||||
|
||||
def _filter_best_superseded_periods(
|
||||
today_late: list[dict],
|
||||
tomorrow_early: list[dict],
|
||||
other: list[dict],
|
||||
improvement_threshold: float,
|
||||
) -> list[dict]:
|
||||
"""Filter best-price today-late periods superseded by cheaper tomorrow alternatives."""
|
||||
if not tomorrow_early:
|
||||
return other + today_late + tomorrow_early
|
||||
|
||||
# Find the cheapest tomorrow early period
|
||||
best_tomorrow = min(tomorrow_early, key=lambda p: p.get("price_mean", float("inf")))
|
||||
best_tomorrow_price = best_tomorrow.get("price_mean")
|
||||
|
||||
if best_tomorrow_price is None:
|
||||
return other + today_late + tomorrow_early
|
||||
|
||||
kept_today = _filter_superseded_today_periods(
|
||||
today_late,
|
||||
best_tomorrow,
|
||||
best_tomorrow_price,
|
||||
improvement_threshold,
|
||||
)
|
||||
|
||||
return other + kept_today + tomorrow_early
|
||||
|
||||
|
||||
def _filter_peak_superseded_periods(
|
||||
today_late: list[dict],
|
||||
tomorrow_early: list[dict],
|
||||
other: list[dict],
|
||||
improvement_threshold: float,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Filter peak-price tomorrow-early periods that are artifacts of day-boundary reclassification.
|
||||
|
||||
If today has a genuine late-night peak and tomorrow's early-morning "peak" is
|
||||
significantly LOWER in price, the tomorrow period is a cross-day artifact:
|
||||
the same overnight prices are classified differently because they sit near
|
||||
a different day's maximum.
|
||||
|
||||
"""
|
||||
if not today_late or not tomorrow_early:
|
||||
return other + today_late + tomorrow_early
|
||||
|
||||
# Find the strongest today late peak (highest mean price)
|
||||
best_today_peak = max(today_late, key=lambda p: p.get("price_mean", 0))
|
||||
best_today_price = best_today_peak.get("price_mean")
|
||||
|
||||
if best_today_price is None or best_today_price <= 0:
|
||||
return other + today_late + tomorrow_early
|
||||
|
||||
kept_tomorrow: list[dict] = []
|
||||
for tomorrow_period in tomorrow_early:
|
||||
tomorrow_price = tomorrow_period.get("price_mean")
|
||||
|
||||
if tomorrow_price is None:
|
||||
kept_tomorrow.append(tomorrow_period)
|
||||
continue
|
||||
|
||||
# How much LOWER is tomorrow's peak vs today's peak? (as percentage)
|
||||
price_drop_pct = ((best_today_price - tomorrow_price) / best_today_price * 100) if best_today_price > 0 else 0
|
||||
|
||||
if price_drop_pct >= improvement_threshold:
|
||||
_LOGGER.info(
|
||||
"Peak supersession: Tomorrow %s-%s (%.2f) is %.1f%% below today's peak %s-%s (%.2f) → filtered as artifact",
|
||||
tomorrow_period["start"].strftime("%H:%M"),
|
||||
tomorrow_period["end"].strftime("%H:%M"),
|
||||
tomorrow_price,
|
||||
price_drop_pct,
|
||||
best_today_peak["start"].strftime("%H:%M"),
|
||||
best_today_peak["end"].strftime("%H:%M"),
|
||||
best_today_price,
|
||||
)
|
||||
else:
|
||||
kept_tomorrow.append(tomorrow_period)
|
||||
|
||||
return other + today_late + kept_tomorrow
|
||||
|
||||
|
||||
def filter_superseded_periods(
|
||||
period_summaries: list[dict],
|
||||
*,
|
||||
|
|
@ -375,24 +709,23 @@ def filter_superseded_periods(
|
|||
reverse_sort: bool,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Filter out late-night today periods that are superseded by better tomorrow periods.
|
||||
Filter out cross-day periods that are artifacts of day-boundary price reclassification.
|
||||
|
||||
For BEST PRICE (reverse_sort=False):
|
||||
When tomorrow's data becomes available, some late-night periods that were found
|
||||
through relaxation may no longer make sense. If tomorrow has a significantly
|
||||
better period in the early morning, the late-night today period is obsolete.
|
||||
better (cheaper) period in the early morning, the late-night today period is obsolete.
|
||||
|
||||
Example:
|
||||
- Today 23:30-00:00 at 0.70 kr (found via relaxation, was best available)
|
||||
- Tomorrow 04:00-05:30 at 0.50 kr (much better alternative)
|
||||
→ The today period is superseded and should be filtered out
|
||||
|
||||
This only applies to best-price periods (reverse_sort=False).
|
||||
Peak-price periods are not filtered this way.
|
||||
For PEAK PRICE (reverse_sort=True):
|
||||
Inverted logic: tomorrow's early-morning periods that are significantly LOWER
|
||||
than today's late-night peak are cross-day artifacts. Overnight prices often
|
||||
qualify as "peak" against tomorrow's (lower) daily max, but don't represent
|
||||
genuine high-price windows when viewed across the day boundary.
|
||||
|
||||
"""
|
||||
from .types import ( # noqa: PLC0415
|
||||
CROSS_DAY_LATE_PERIOD_START_HOUR,
|
||||
CROSS_DAY_MAX_EXTENSION_HOUR,
|
||||
CROSS_DAY_EARLY_MORNING_HOUR,
|
||||
CROSS_DAY_SUPERSESSION_START_HOUR,
|
||||
SUPERSESSION_PRICE_IMPROVEMENT_PCT,
|
||||
)
|
||||
|
||||
|
|
@ -402,8 +735,7 @@ def filter_superseded_periods(
|
|||
reverse_sort,
|
||||
)
|
||||
|
||||
# Only filter for best-price periods
|
||||
if reverse_sort or not period_summaries:
|
||||
if not period_summaries:
|
||||
return period_summaries
|
||||
|
||||
now = time.now()
|
||||
|
|
@ -415,8 +747,8 @@ def filter_superseded_periods(
|
|||
period_summaries,
|
||||
today,
|
||||
tomorrow,
|
||||
CROSS_DAY_LATE_PERIOD_START_HOUR,
|
||||
CROSS_DAY_MAX_EXTENSION_HOUR,
|
||||
CROSS_DAY_SUPERSESSION_START_HOUR,
|
||||
CROSS_DAY_EARLY_MORNING_HOUR,
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
|
|
@ -426,103 +758,164 @@ def filter_superseded_periods(
|
|||
len(other),
|
||||
)
|
||||
|
||||
# If no tomorrow early periods, nothing to compare against
|
||||
if not tomorrow_early:
|
||||
_LOGGER.debug("No tomorrow early periods - skipping supersession check")
|
||||
return period_summaries
|
||||
|
||||
# Find the best tomorrow early period (lowest mean price)
|
||||
best_tomorrow = min(tomorrow_early, key=lambda p: p.get("price_mean", float("inf")))
|
||||
best_tomorrow_price = best_tomorrow.get("price_mean")
|
||||
|
||||
if best_tomorrow_price is None:
|
||||
return period_summaries
|
||||
|
||||
# Filter superseded today periods
|
||||
kept_today = _filter_superseded_today_periods(
|
||||
if reverse_sort:
|
||||
# PEAK: Filter tomorrow-early periods superseded by today-late peaks
|
||||
result = _filter_peak_superseded_periods(
|
||||
today_late,
|
||||
best_tomorrow,
|
||||
best_tomorrow_price,
|
||||
tomorrow_early,
|
||||
other,
|
||||
SUPERSESSION_PRICE_IMPROVEMENT_PCT,
|
||||
)
|
||||
else:
|
||||
# BEST: Filter today-late periods superseded by cheaper tomorrow alternatives
|
||||
result = _filter_best_superseded_periods(
|
||||
today_late,
|
||||
tomorrow_early,
|
||||
other,
|
||||
SUPERSESSION_PRICE_IMPROVEMENT_PCT,
|
||||
)
|
||||
|
||||
# Reconstruct and sort by start time
|
||||
result = other + kept_today + tomorrow_early
|
||||
result.sort(key=lambda p: p.get("start") or time.now())
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _is_period_eligible_for_extension(
|
||||
period: dict,
|
||||
today: date,
|
||||
late_hour_threshold: int,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a period is eligible for cross-day extension.
|
||||
|
||||
Eligibility criteria:
|
||||
- Period has valid start and end times
|
||||
- Period ends on today (not yesterday or tomorrow)
|
||||
- Period ends late (after late_hour_threshold, e.g. 20:00)
|
||||
|
||||
"""
|
||||
period_end = period.get("end")
|
||||
period_start = period.get("start")
|
||||
|
||||
if not period_end or not period_start:
|
||||
return False
|
||||
|
||||
if period_end.date() != today:
|
||||
return False
|
||||
|
||||
return period_end.hour >= late_hour_threshold
|
||||
|
||||
|
||||
def _find_extension_intervals(
|
||||
period_end: datetime,
|
||||
price_lookup: dict[str, dict],
|
||||
criteria: Any,
|
||||
max_extension_time: datetime,
|
||||
interval_duration: timedelta,
|
||||
def filter_weak_peak_periods(
|
||||
period_summaries: list[dict],
|
||||
avg_prices: dict,
|
||||
*,
|
||||
time: TibberPricesTimeService,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Find consecutive intervals after period_end that meet criteria.
|
||||
Filter peak periods whose mean price is barely above the daily average.
|
||||
|
||||
Iterates forward from period_end, adding intervals while they
|
||||
meet the flex and min_distance criteria. Stops at first failure
|
||||
or when reaching max_extension_time.
|
||||
A genuine peak period should have prices meaningfully above the daily average.
|
||||
Periods that are only marginally above average are typically cross-day artifacts
|
||||
where overnight prices qualify as "peak" against a low daily maximum.
|
||||
|
||||
Safety: At least one period per day is always preserved (the one with the
|
||||
highest premium above average). This prevents removing all peaks on flat days.
|
||||
|
||||
Only applies to peak periods. Best-price filtering is not needed because
|
||||
cheap periods near the daily average are still useful for scheduling.
|
||||
|
||||
"""
|
||||
from .level_filtering import check_interval_criteria # noqa: PLC0415
|
||||
from .types import CROSS_DAY_OVERNIGHT_VALIDATION_HOUR, PEAK_MIN_PREMIUM_ABOVE_AVG_PCT # noqa: PLC0415
|
||||
|
||||
extension_intervals: list[dict] = []
|
||||
check_time = period_end
|
||||
if not period_summaries:
|
||||
return period_summaries
|
||||
|
||||
while check_time < max_extension_time:
|
||||
price_data = price_lookup.get(check_time.isoformat())
|
||||
if not price_data:
|
||||
break # No more data
|
||||
# Calculate premium for each period and group by day
|
||||
period_premiums: list[tuple[dict, float, date]] = []
|
||||
for period in period_summaries:
|
||||
period_mean = period.get("price_mean")
|
||||
period_start = period.get("start")
|
||||
|
||||
price = float(price_data["total"])
|
||||
in_flex, meets_min_distance = check_interval_criteria(price, criteria)
|
||||
if period_mean is None or period_start is None:
|
||||
period_premiums.append((period, float("inf"), date.min))
|
||||
continue
|
||||
|
||||
if not (in_flex and meets_min_distance):
|
||||
break # Criteria no longer met
|
||||
day_key = period_start.date()
|
||||
daily_avg = avg_prices.get(day_key) or avg_prices.get(str(day_key))
|
||||
|
||||
extension_intervals.append(price_data)
|
||||
check_time = check_time + interval_duration
|
||||
if daily_avg is None or daily_avg <= 0:
|
||||
period_premiums.append((period, float("inf"), day_key))
|
||||
continue
|
||||
|
||||
return extension_intervals
|
||||
# For overnight/morning periods (before 06:00), use the HIGHER of
|
||||
# current day and previous day averages. This prevents overnight prices
|
||||
# from appearing as "peaks" when tomorrow's average is lower due to
|
||||
# midday valleys (e.g., solar surplus). A genuine peak must be high
|
||||
# relative to BOTH days' price landscape.
|
||||
effective_avg = daily_avg
|
||||
if period_start.hour < CROSS_DAY_OVERNIGHT_VALIDATION_HOUR:
|
||||
prev_day = day_key - timedelta(days=1)
|
||||
prev_avg = avg_prices.get(prev_day) or avg_prices.get(str(prev_day))
|
||||
if prev_avg is not None and prev_avg > daily_avg:
|
||||
effective_avg = prev_avg
|
||||
_LOGGER_DETAILS.debug(
|
||||
"%sWeak peak check: Period %s uses prev-day avg %.4f instead of %.4f (overnight cross-day)",
|
||||
INDENT_L0,
|
||||
period_start.strftime("%H:%M"),
|
||||
prev_avg,
|
||||
daily_avg,
|
||||
)
|
||||
|
||||
premium_pct = ((period_mean - effective_avg) / effective_avg) * 100
|
||||
period_premiums.append((period, premium_pct, day_key))
|
||||
|
||||
# Find the best (highest premium) period per day
|
||||
best_per_day: dict[date, float] = {}
|
||||
for _period, premium, day in period_premiums:
|
||||
if day not in best_per_day or premium > best_per_day[day]:
|
||||
best_per_day[day] = premium
|
||||
|
||||
# Filter: keep periods that pass threshold OR are the best for their day
|
||||
kept: list[dict] = []
|
||||
removed = 0
|
||||
for period, premium, day in period_premiums:
|
||||
is_best_for_day = premium >= best_per_day.get(day, float("-inf"))
|
||||
|
||||
if premium >= PEAK_MIN_PREMIUM_ABOVE_AVG_PCT:
|
||||
kept.append(period)
|
||||
elif is_best_for_day:
|
||||
# Preserve at least one period per day even if below threshold
|
||||
kept.append(period)
|
||||
_LOGGER_DETAILS.debug(
|
||||
"%sWeak peak preserved (best for day %s): premium=%.1f%% < threshold=%.1f%%",
|
||||
INDENT_L0,
|
||||
day,
|
||||
premium,
|
||||
PEAK_MIN_PREMIUM_ABOVE_AVG_PCT,
|
||||
)
|
||||
else:
|
||||
period_start = period.get("start")
|
||||
_LOGGER.info(
|
||||
"Weak peak filtered: Period %s-%s mean=%.2f is only %.1f%% above daily avg (need ≥%.1f%%)",
|
||||
period_start.strftime("%H:%M") if period_start else "?",
|
||||
period["end"].strftime("%H:%M") if period.get("end") else "?",
|
||||
period.get("price_mean", 0),
|
||||
premium,
|
||||
PEAK_MIN_PREMIUM_ABOVE_AVG_PCT,
|
||||
)
|
||||
removed += 1
|
||||
|
||||
if removed > 0:
|
||||
_LOGGER.info(
|
||||
"Weak peak filter: %d/%d periods kept (removed %d below %.0f%% premium threshold)",
|
||||
len(kept),
|
||||
len(period_summaries),
|
||||
removed,
|
||||
PEAK_MIN_PREMIUM_ABOVE_AVG_PCT,
|
||||
)
|
||||
|
||||
return kept
|
||||
|
||||
|
||||
def _collect_original_period_prices(
|
||||
def _gap_spans_midnight(a_end: datetime, b_start: datetime) -> bool:
|
||||
"""
|
||||
Check if the gap between two periods spans a midnight boundary.
|
||||
|
||||
Uses the last covered moment of period A (end - 1 minute, since end is
|
||||
exclusive) to determine the calendar day. Returns True when A's last
|
||||
interval is on a different (earlier) date than B's first interval.
|
||||
|
||||
Examples:
|
||||
A ends 00:00 (last interval 23:45 same day), B starts 00:15 → True
|
||||
A ends 23:30, B starts 00:00 next day → True
|
||||
A ends 21:30, B starts 22:00 same day → False
|
||||
|
||||
"""
|
||||
a_last_moment = a_end - timedelta(minutes=1)
|
||||
return a_last_moment.date() < b_start.date()
|
||||
|
||||
|
||||
def _collect_period_prices(
|
||||
period_start: datetime,
|
||||
period_end: datetime,
|
||||
price_lookup: dict[str, dict],
|
||||
interval_duration: timedelta,
|
||||
) -> list[float]:
|
||||
"""Collect prices from original period for CV calculation."""
|
||||
"""Collect prices within a time range from the price lookup."""
|
||||
prices: list[float] = []
|
||||
current = period_start
|
||||
while current < period_end:
|
||||
|
|
@ -533,33 +926,29 @@ def _collect_original_period_prices(
|
|||
return prices
|
||||
|
||||
|
||||
def _build_extended_period(
|
||||
period: dict,
|
||||
extension_intervals: list[dict],
|
||||
def _build_bridged_period(
|
||||
period_a: dict,
|
||||
period_b: dict,
|
||||
combined_prices: list[float],
|
||||
combined_cv: float,
|
||||
interval_duration: timedelta,
|
||||
gap_intervals: int,
|
||||
) -> dict:
|
||||
"""Create extended period dict with updated statistics."""
|
||||
period_start = period["start"]
|
||||
period_end = period["end"]
|
||||
new_end = period_end + (interval_duration * len(extension_intervals))
|
||||
"""Create a merged period dict from two bridged periods with updated statistics."""
|
||||
bridged = period_a.copy()
|
||||
bridged["end"] = period_b["end"]
|
||||
bridged["duration_minutes"] = int((period_b["end"] - period_a["start"]).total_seconds() / 60)
|
||||
bridged["period_interval_count"] = len(combined_prices)
|
||||
bridged["cross_day_bridged"] = True
|
||||
bridged["cross_day_bridge_gap_intervals"] = gap_intervals
|
||||
|
||||
extended = period.copy()
|
||||
extended["end"] = new_end
|
||||
extended["duration_minutes"] = int((new_end - period_start).total_seconds() / 60)
|
||||
extended["period_interval_count"] = len(combined_prices)
|
||||
extended["cross_day_extended"] = True
|
||||
extended["cross_day_extension_intervals"] = len(extension_intervals)
|
||||
# Recalculate price statistics for the combined period
|
||||
bridged["price_min"] = min(combined_prices)
|
||||
bridged["price_max"] = max(combined_prices)
|
||||
bridged["price_mean"] = sum(combined_prices) / len(combined_prices)
|
||||
bridged["price_spread"] = bridged["price_max"] - bridged["price_min"]
|
||||
bridged["price_coefficient_variation_%"] = round(combined_cv, 1)
|
||||
|
||||
# Recalculate price statistics
|
||||
extended["price_min"] = min(combined_prices)
|
||||
extended["price_max"] = max(combined_prices)
|
||||
extended["price_mean"] = sum(combined_prices) / len(combined_prices)
|
||||
extended["price_spread"] = extended["price_max"] - extended["price_min"]
|
||||
extended["price_coefficient_variation_%"] = round(combined_cv, 1)
|
||||
|
||||
return extended
|
||||
return bridged
|
||||
|
||||
|
||||
def extend_periods_across_midnight(
|
||||
|
|
@ -571,20 +960,24 @@ def extend_periods_across_midnight(
|
|||
reverse_sort: bool,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Extend late-night periods across midnight if favorable prices continue.
|
||||
Bridge periods across midnight when separated by a small gap.
|
||||
|
||||
When a period ends close to midnight and tomorrow's data shows continued
|
||||
favorable prices, extend the period into the next day. This prevents
|
||||
artificial period breaks at midnight when it's actually better to continue.
|
||||
When two independently qualifying periods exist on either side of midnight,
|
||||
separated only by a few non-qualifying intervals (typically caused by per-day
|
||||
reference price changes at the day boundary), merge them into a single period.
|
||||
|
||||
Example: Best price period 22:00-23:45 today could extend to 04:00 tomorrow
|
||||
if prices remain low overnight.
|
||||
Key principle: requires evidence on BOTH sides of midnight.
|
||||
A period ending at 21:30 will NOT be bridged — it ended because prices
|
||||
changed, not because of midnight. Only genuine midnight-split periods
|
||||
(where favorable conditions continue on both sides) are merged.
|
||||
|
||||
Example: Best price period 22:00-23:45 today + period 00:15-03:00 tomorrow
|
||||
→ Bridged into 22:00-03:00 (if gap ≤ 4 intervals and CV passes).
|
||||
|
||||
Rules:
|
||||
- Only extends periods ending after CROSS_DAY_LATE_PERIOD_START_HOUR (20:00)
|
||||
- Won't extend beyond CROSS_DAY_MAX_EXTENSION_HOUR (08:00) next day
|
||||
- Extension must pass same flex criteria as original period
|
||||
- Quality Gate (CV check) applies to extended period
|
||||
- Requires periods on BOTH sides of the midnight boundary
|
||||
- Gap between periods must be ≤ CROSS_DAY_MAX_BRIDGE_GAP_INTERVALS (4 = 1 hour)
|
||||
- Quality Gate (CV check) applies to the merged period
|
||||
|
||||
Args:
|
||||
period_summaries: List of period summary dicts (already processed)
|
||||
|
|
@ -594,19 +987,14 @@ def extend_periods_across_midnight(
|
|||
reverse_sort: True for peak price, False for best price
|
||||
|
||||
Returns:
|
||||
Updated list of period summaries with extensions applied
|
||||
Updated list of period summaries with bridges applied
|
||||
|
||||
"""
|
||||
from custom_components.tibber_prices.utils.price import calculate_coefficient_of_variation # noqa: PLC0415
|
||||
|
||||
from .types import ( # noqa: PLC0415
|
||||
CROSS_DAY_LATE_PERIOD_START_HOUR,
|
||||
CROSS_DAY_MAX_EXTENSION_HOUR,
|
||||
PERIOD_MAX_CV,
|
||||
TibberPricesIntervalCriteria,
|
||||
)
|
||||
from .types import CROSS_DAY_MAX_BRIDGE_GAP_INTERVALS, PERIOD_MAX_CV # noqa: PLC0415
|
||||
|
||||
if not period_summaries or not all_prices:
|
||||
if not period_summaries or len(period_summaries) < 2 or not all_prices:
|
||||
return period_summaries
|
||||
|
||||
# Build price lookup by timestamp
|
||||
|
|
@ -616,99 +1004,77 @@ def extend_periods_across_midnight(
|
|||
if interval_time:
|
||||
price_lookup[interval_time.isoformat()] = price_data
|
||||
|
||||
ref_prices = price_context.get("ref_prices", {})
|
||||
avg_prices = price_context.get("avg_prices", {})
|
||||
flex = price_context.get("flex", 0.15)
|
||||
min_distance = price_context.get("min_distance_from_avg", 0)
|
||||
|
||||
now = time.now()
|
||||
today = now.date()
|
||||
tomorrow = today + timedelta(days=1)
|
||||
interval_duration = time.get_interval_duration()
|
||||
|
||||
# Max extension time (e.g., 08:00 tomorrow)
|
||||
max_extension_time = time.start_of_local_day(now) + timedelta(days=1, hours=CROSS_DAY_MAX_EXTENSION_HOUR)
|
||||
# Sort periods by start time for pairwise comparison
|
||||
sorted_periods = sorted(period_summaries, key=lambda p: p.get("start") or now)
|
||||
|
||||
extended_summaries = []
|
||||
result: list[dict] = []
|
||||
skip_indices: set[int] = set()
|
||||
|
||||
for period in period_summaries:
|
||||
# Check eligibility for extension
|
||||
if not _is_period_eligible_for_extension(period, today, CROSS_DAY_LATE_PERIOD_START_HOUR):
|
||||
extended_summaries.append(period)
|
||||
for i, period_a in enumerate(sorted_periods):
|
||||
if i in skip_indices:
|
||||
continue
|
||||
|
||||
# Get tomorrow's reference prices
|
||||
tomorrow_ref = ref_prices.get(tomorrow) or ref_prices.get(str(tomorrow))
|
||||
tomorrow_avg = avg_prices.get(tomorrow) or avg_prices.get(str(tomorrow))
|
||||
# Try to bridge with the next period
|
||||
if i + 1 < len(sorted_periods):
|
||||
period_b = sorted_periods[i + 1]
|
||||
a_end = period_a.get("end")
|
||||
b_start = period_b.get("start")
|
||||
|
||||
if tomorrow_ref is None or tomorrow_avg is None:
|
||||
extended_summaries.append(period)
|
||||
continue
|
||||
if (
|
||||
a_end and b_start and _gap_spans_midnight(a_end, b_start) and b_start >= a_end # No overlap
|
||||
):
|
||||
gap = b_start - a_end
|
||||
gap_intervals = int(gap.total_seconds() / interval_duration.total_seconds())
|
||||
|
||||
# Set up criteria for extension check
|
||||
criteria = TibberPricesIntervalCriteria(
|
||||
ref_price=tomorrow_ref,
|
||||
avg_price=tomorrow_avg,
|
||||
flex=flex,
|
||||
min_distance_from_avg=min_distance,
|
||||
reverse_sort=reverse_sort,
|
||||
)
|
||||
|
||||
# Find extension intervals
|
||||
extension_intervals = _find_extension_intervals(
|
||||
period["end"],
|
||||
price_lookup,
|
||||
criteria,
|
||||
max_extension_time,
|
||||
interval_duration,
|
||||
)
|
||||
|
||||
if not extension_intervals:
|
||||
extended_summaries.append(period)
|
||||
continue
|
||||
|
||||
# Collect all prices for CV check
|
||||
original_prices = _collect_original_period_prices(
|
||||
period["start"],
|
||||
period["end"],
|
||||
if gap_intervals <= CROSS_DAY_MAX_BRIDGE_GAP_INTERVALS:
|
||||
# Collect all prices from A.start through B.end (including gap)
|
||||
combined_prices = _collect_period_prices(
|
||||
period_a["start"],
|
||||
period_b["end"],
|
||||
price_lookup,
|
||||
interval_duration,
|
||||
)
|
||||
extension_prices = [float(p["total"]) for p in extension_intervals]
|
||||
combined_prices = original_prices + extension_prices
|
||||
|
||||
# Quality Gate: Check CV of extended period
|
||||
if combined_prices:
|
||||
combined_cv = calculate_coefficient_of_variation(combined_prices)
|
||||
|
||||
if combined_cv is not None and combined_cv <= PERIOD_MAX_CV:
|
||||
# Extension passes quality gate
|
||||
extended_period = _build_extended_period(
|
||||
period,
|
||||
extension_intervals,
|
||||
bridged = _build_bridged_period(
|
||||
period_a,
|
||||
period_b,
|
||||
combined_prices,
|
||||
combined_cv,
|
||||
interval_duration,
|
||||
gap_intervals,
|
||||
)
|
||||
|
||||
_LOGGER.info(
|
||||
"Cross-day extension: Period %s-%s extended to %s (+%d intervals, CV=%.1f%%)",
|
||||
period["start"].strftime("%H:%M"),
|
||||
period["end"].strftime("%H:%M"),
|
||||
extended_period["end"].strftime("%H:%M"),
|
||||
len(extension_intervals),
|
||||
"Cross-day bridge: Merged %s-%s + %s-%s → %s-%s (gap=%d intervals, CV=%.1f%%)",
|
||||
period_a["start"].strftime("%H:%M"),
|
||||
period_a["end"].strftime("%H:%M"),
|
||||
period_b["start"].strftime("%H:%M"),
|
||||
period_b["end"].strftime("%H:%M"),
|
||||
bridged["start"].strftime("%H:%M"),
|
||||
bridged["end"].strftime("%H:%M"),
|
||||
gap_intervals,
|
||||
combined_cv,
|
||||
)
|
||||
extended_summaries.append(extended_period)
|
||||
else:
|
||||
# Extension would exceed quality gate
|
||||
result.append(bridged)
|
||||
skip_indices.add(i + 1)
|
||||
continue
|
||||
|
||||
_LOGGER_DETAILS.debug(
|
||||
"%sCross-day extension rejected for period %s-%s: CV=%.1f%% > %.1f%%",
|
||||
"%sCross-day bridge rejected %s-%s + %s-%s: CV=%.1f%% > %.1f%%",
|
||||
INDENT_L0,
|
||||
period["start"].strftime("%H:%M"),
|
||||
period["end"].strftime("%H:%M"),
|
||||
period_a["start"].strftime("%H:%M"),
|
||||
period_a["end"].strftime("%H:%M"),
|
||||
period_b["start"].strftime("%H:%M"),
|
||||
period_b["end"].strftime("%H:%M"),
|
||||
combined_cv or 0,
|
||||
PERIOD_MAX_CV,
|
||||
)
|
||||
extended_summaries.append(period)
|
||||
|
||||
return extended_summaries
|
||||
result.append(period_a)
|
||||
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,27 +2,26 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from datetime import date
|
||||
from datetime import date, datetime
|
||||
|
||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||
|
||||
from custom_components.tibber_prices.utils.price import calculate_coefficient_of_variation
|
||||
from custom_components.tibber_prices.utils.price import calculate_coefficient_of_variation, calculate_iqr_stats
|
||||
|
||||
from .period_overlap import (
|
||||
recalculate_period_metadata,
|
||||
resolve_period_overlaps,
|
||||
)
|
||||
from .period_overlap import recalculate_period_metadata, resolve_period_overlaps
|
||||
from .types import (
|
||||
INDENT_L0,
|
||||
INDENT_L1,
|
||||
INDENT_L2,
|
||||
LOW_PRICE_QUALITY_BYPASS_THRESHOLD,
|
||||
PERIOD_MAX_CV,
|
||||
RELAXATION_FLEX_INCREMENT,
|
||||
TibberPricesPeriodConfig,
|
||||
)
|
||||
|
||||
|
|
@ -41,12 +40,6 @@ FLEX_HIGH_THRESHOLD_RELAXATION = 0.30 # 30% - WARNING: base flex too high for r
|
|||
MIN_DURATION_FALLBACK_MINIMUM = 30 # Minimum period length to try (30 min = 2 intervals)
|
||||
MIN_DURATION_FALLBACK_STEP = 15 # Reduce by 15 min (1 interval) each step
|
||||
|
||||
# Low absolute price threshold for quality gate bypass (in major currency unit, e.g. EUR/NOK)
|
||||
# When the MEAN price of a period is below this level, the CV quality gate is bypassed.
|
||||
# Relative CV is unreliable at very low absolute prices: a range of 1-4 ct shows CV≈50%
|
||||
# but is practically homogeneous from a cost perspective.
|
||||
# Value: LOW_PRICE_AVG_THRESHOLD (subunit) / 100 = 10 ct / 100 = 0.10 EUR/NOK
|
||||
LOW_PRICE_QUALITY_BYPASS_THRESHOLD = 0.10 # EUR/NOK major unit (= 10 ct/øre)
|
||||
|
||||
# Span-to-ref ratio threshold for suppressing flex warnings on V-shape days.
|
||||
# When span / ref_price < this on ANY available day, the warning is shown.
|
||||
|
|
@ -56,7 +49,10 @@ FLEX_WARNING_VSHAPE_RATIO = 0.5 # span/ref_price ratio below which a day is con
|
|||
# On flat price days (low variation), it is unrealistic to require multiple distinct
|
||||
# best/peak price periods. Requiring 2+ periods would force relaxation to create
|
||||
# artificial periods that don't represent genuine price structure.
|
||||
LOW_CV_FLAT_DAY_THRESHOLD = 10.0 # %: days with CV ≤ this need only 1 period
|
||||
LOW_CV_FLAT_DAY_THRESHOLD = 10.0 # %: fallback when IQR% not available (near-zero or negative median)
|
||||
# IQR% ≤ 15% ≈ CV ≤ 10% for clean data, but also catches "flat + isolated spike" days correctly:
|
||||
# a single spike inflates CV to 15-25% while leaving IQR% near 0-5%.
|
||||
LOW_IQR_PCT_FLAT_DAY_THRESHOLD = 15.0 # %: days with IQR% ≤ this need only 1 period
|
||||
|
||||
|
||||
def _check_period_quality(
|
||||
|
|
@ -287,8 +283,11 @@ def _try_min_duration_fallback(
|
|||
*,
|
||||
config: TibberPricesPeriodConfig,
|
||||
existing_periods: list[dict],
|
||||
all_prices: list[dict],
|
||||
prices_by_day: dict[date, list[dict]],
|
||||
time: TibberPricesTimeService,
|
||||
max_relaxation_attempts: int = 0,
|
||||
day_patterns_by_date: dict | None = None,
|
||||
) -> tuple[dict[str, Any] | None, dict[str, Any]]:
|
||||
"""
|
||||
Try reducing min_period_length to find periods when relaxation is exhausted.
|
||||
|
|
@ -308,6 +307,8 @@ def _try_min_duration_fallback(
|
|||
existing_periods: Periods found so far (from relaxation)
|
||||
prices_by_day: Price intervals grouped by day
|
||||
time: Time service instance
|
||||
day_patterns_by_date: Optional dict mapping date → day pattern dict. Used for
|
||||
geometric flex bonus in period detection.
|
||||
|
||||
Returns:
|
||||
Tuple of (result dict with periods, metadata dict) or (None, empty metadata)
|
||||
|
|
@ -353,20 +354,35 @@ def _try_min_duration_fallback(
|
|||
current_min_duration,
|
||||
)
|
||||
|
||||
# Create modified config with shorter min_period_length
|
||||
# Use maxed-out flex (50%) since we're in fallback mode
|
||||
# Create modified config with shorter min_period_length.
|
||||
# IMPORTANT: We deliberately do NOT max out flex/min_distance here.
|
||||
# Going to MAX_FLEX_HARD_LIMIT (50%) and disabling min_distance + level filter
|
||||
# made every interval qualify on flat-price days, producing phantom periods that
|
||||
# don't represent any real "best/peak" structure. Instead we keep the relaxation's
|
||||
# final flex (the highest the user accepted via max_relaxation_attempts) and
|
||||
# only:
|
||||
# - drop the level filter (it was already dropped during the last relaxation step)
|
||||
# - halve min_distance_from_avg (instead of zeroing it) so genuinely flat days
|
||||
# still surface no period rather than a misleading one.
|
||||
# The shorter min_period_length is what actually unlocks new candidates.
|
||||
relaxation_final_flex = min(
|
||||
abs(config.flex) + max(1, max_relaxation_attempts) * RELAXATION_FLEX_INCREMENT,
|
||||
MAX_FLEX_HARD_LIMIT,
|
||||
)
|
||||
fallback_config = TibberPricesPeriodConfig(
|
||||
reverse_sort=config.reverse_sort,
|
||||
flex=MAX_FLEX_HARD_LIMIT, # Max flex
|
||||
min_distance_from_avg=0, # Disable min_distance in fallback
|
||||
flex=relaxation_final_flex,
|
||||
min_distance_from_avg=config.min_distance_from_avg * 0.5,
|
||||
min_period_length=current_min_duration,
|
||||
threshold_low=config.threshold_low,
|
||||
threshold_high=config.threshold_high,
|
||||
threshold_volatility_moderate=config.threshold_volatility_moderate,
|
||||
threshold_volatility_high=config.threshold_volatility_high,
|
||||
threshold_volatility_very_high=config.threshold_volatility_very_high,
|
||||
level_filter=None, # Disable level filter
|
||||
level_filter="any", # Already effectively any after relaxation; keeps gap logic intact
|
||||
gap_count=config.gap_count,
|
||||
extend_to_extreme=config.extend_to_extreme,
|
||||
max_extension_intervals=config.max_extension_intervals,
|
||||
)
|
||||
|
||||
# Try to find periods for days with zero periods
|
||||
|
|
@ -380,6 +396,7 @@ def _try_min_duration_fallback(
|
|||
day_prices,
|
||||
config=fallback_config,
|
||||
time=time,
|
||||
day_patterns_by_date=day_patterns_by_date,
|
||||
)
|
||||
|
||||
day_periods = day_result.get("periods", [])
|
||||
|
|
@ -422,6 +439,9 @@ def _try_min_duration_fallback(
|
|||
merged_periods, _new_count = resolve_period_overlaps(
|
||||
existing_periods,
|
||||
fallback_periods,
|
||||
all_prices=all_prices,
|
||||
config=config,
|
||||
time=time,
|
||||
)
|
||||
recalculate_period_metadata(merged_periods, time=time)
|
||||
|
||||
|
|
@ -453,22 +473,24 @@ def _compute_day_effective_min(
|
|||
"""
|
||||
Compute per-day effective min_periods with flat-day adaptation.
|
||||
|
||||
On days with very low price variation (CV ≤ LOW_CV_FLAT_DAY_THRESHOLD),
|
||||
On days with very low price variation (IQR% ≤ LOW_IQR_PCT_FLAT_DAY_THRESHOLD),
|
||||
requiring multiple distinct cheapest/peak periods is unrealistic. Finding
|
||||
ONE period is sufficient because there is no meaningful price structure that
|
||||
would create natural multiple periods.
|
||||
|
||||
This applies ONLY to BEST PRICE periods (reverse_sort=False). For PEAK PRICE
|
||||
periods, full relaxation should run even on flat days because identifying the
|
||||
genuinely most expensive window requires the complete filter evaluation.
|
||||
(Design decision: if the user explicitly disabled relaxation, honour the
|
||||
configured min_periods exactly regardless.)
|
||||
Uses IQR% as primary metric (robust to isolated price spikes) with CV as
|
||||
fallback when IQR% is undefined (near-zero or negative median prices).
|
||||
|
||||
This applies to both BEST PRICE and PEAK PRICE periods. On flat days,
|
||||
forcing 2+ peaks via relaxation creates cross-day boundary artifacts
|
||||
where overnight prices barely qualify as "peak" only because they are
|
||||
the second-highest block relative to that day's maximum.
|
||||
|
||||
Args:
|
||||
prices_by_day: Dict of date → list of price dicts
|
||||
min_periods: Configured minimum periods per day
|
||||
enable_relaxation: Whether relaxation is enabled
|
||||
reverse_sort: True for peak price (no adaptation), False for best price
|
||||
reverse_sort: True for peak price, False for best price
|
||||
|
||||
Returns:
|
||||
Tuple of (dict of date → effective min_periods for that day, count of flat days detected)
|
||||
|
|
@ -476,47 +498,62 @@ def _compute_day_effective_min(
|
|||
"""
|
||||
day_effective_min = {}
|
||||
flat_day_count = 0
|
||||
min_prices_for_cv = 2 # Need at least 2 prices to calculate CV
|
||||
|
||||
for day, day_prices in prices_by_day.items():
|
||||
if not enable_relaxation or min_periods <= 1 or reverse_sort:
|
||||
# Relaxation disabled, already 1, or peak price: no adaptation
|
||||
if not enable_relaxation or min_periods <= 1:
|
||||
# Relaxation disabled or already 1: no adaptation
|
||||
day_effective_min[day] = min_periods
|
||||
continue
|
||||
|
||||
price_values = [float(p["total"]) for p in day_prices if p.get("total") is not None]
|
||||
|
||||
if len(price_values) < min_prices_for_cv:
|
||||
if len(price_values) < 2:
|
||||
day_effective_min[day] = min_periods
|
||||
continue
|
||||
|
||||
day_cv = calculate_coefficient_of_variation(price_values)
|
||||
# Primary flat-day metric: IQR% is robust to isolated price spikes.
|
||||
# A single spike inflates CV to 15-25% while leaving IQR% near 0-5%,
|
||||
# so IQR correctly identifies "flat core + spike" days as flat.
|
||||
iqr_stats = calculate_iqr_stats(price_values)
|
||||
iqr_pct = iqr_stats["iqr_pct"] if iqr_stats else None
|
||||
|
||||
if day_cv is not None and day_cv <= LOW_CV_FLAT_DAY_THRESHOLD:
|
||||
is_flat = False
|
||||
flat_metric = ""
|
||||
|
||||
if iqr_pct is not None:
|
||||
is_flat = iqr_pct <= LOW_IQR_PCT_FLAT_DAY_THRESHOLD
|
||||
flat_metric = f"IQR%={iqr_pct:.1f}% ≤ {LOW_IQR_PCT_FLAT_DAY_THRESHOLD:.0f}%"
|
||||
else:
|
||||
# IQR% undefined (near-zero or negative median): fall back to CV
|
||||
day_cv = calculate_coefficient_of_variation(price_values)
|
||||
if day_cv is not None:
|
||||
is_flat = day_cv <= LOW_CV_FLAT_DAY_THRESHOLD
|
||||
flat_metric = f"CV={day_cv:.1f}% ≤ {LOW_CV_FLAT_DAY_THRESHOLD:.0f}% (IQR% N/A)"
|
||||
|
||||
if is_flat:
|
||||
day_effective_min[day] = 1
|
||||
flat_day_count += 1
|
||||
_LOGGER_DETAILS.debug(
|
||||
"%sDay %s: flat price profile (CV=%.1f%% ≤ %.1f%%) → min_periods relaxed to 1",
|
||||
"%sDay %s: flat price profile (%s) → min_periods relaxed to 1",
|
||||
INDENT_L1,
|
||||
day,
|
||||
day_cv,
|
||||
LOW_CV_FLAT_DAY_THRESHOLD,
|
||||
flat_metric,
|
||||
)
|
||||
else:
|
||||
day_effective_min[day] = min_periods
|
||||
|
||||
if flat_day_count > 0:
|
||||
_LOGGER.info(
|
||||
"Adaptive min_periods: %d flat day(s) (CV ≤ %.0f%%) need only 1 period instead of %d",
|
||||
"Adaptive min_periods: %d flat day(s) (IQR%% ≤ %.0f%%) need only 1 period instead of %d",
|
||||
flat_day_count,
|
||||
LOW_CV_FLAT_DAY_THRESHOLD,
|
||||
LOW_IQR_PCT_FLAT_DAY_THRESHOLD,
|
||||
min_periods,
|
||||
)
|
||||
|
||||
return day_effective_min, flat_day_count
|
||||
|
||||
|
||||
def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-day relaxation requires many parameters and branches
|
||||
def calculate_periods_with_relaxation(
|
||||
all_prices: list[dict],
|
||||
*,
|
||||
config: TibberPricesPeriodConfig,
|
||||
|
|
@ -526,18 +563,29 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
|
|||
should_show_callback: Callable[[str | None], bool],
|
||||
time: TibberPricesTimeService,
|
||||
config_entry: Any, # ConfigEntry type
|
||||
day_patterns_by_date: dict | None = None,
|
||||
time_range: tuple[datetime, datetime] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Calculate periods with optional per-day filter relaxation.
|
||||
Calculate periods with optional global filter relaxation and per-day target tracking.
|
||||
|
||||
NEW: Each day gets its own independent relaxation loop. Today can be in Phase 1
|
||||
while tomorrow is in Phase 3, ensuring each day finds enough periods.
|
||||
Strategy: a single global relaxation loop iterates flex levels (3% steps from
|
||||
the configured base flex up to MAX_FLEX_HARD_LIMIT). At each flex level we
|
||||
first re-run period detection with the configured level filter still intact.
|
||||
Only if that is still insufficient do we retry the same flex with
|
||||
`level_filter="any"`. After every attempt we check, per day, how many quality
|
||||
periods (CV ≤ PERIOD_MAX_CV) have accumulated. Days that already meet the target
|
||||
(`min_periods`) are not re-processed; the loop exits as soon as **all** days meet
|
||||
their target. Days with very flat prices automatically need only 1 period
|
||||
(see `_compute_day_effective_min`).
|
||||
|
||||
If min_periods is not reached with normal filters, this function gradually
|
||||
relaxes filters in multiple phases FOR EACH DAY SEPARATELY:
|
||||
If after all flex levels some days still have ZERO periods, a last-resort
|
||||
`min_period_length` fallback is attempted (see `_try_min_duration_fallback`).
|
||||
|
||||
Phase 1: Increase flex threshold step-by-step (up to max_relaxation_attempts)
|
||||
Phase 2: Disable level filter (set to "any")
|
||||
Phase 1: Increase flex threshold step-by-step while preserving the configured
|
||||
level filter.
|
||||
Phase 2: Retry the same flex with `level_filter="any"` when a concrete level
|
||||
filter is configured.
|
||||
|
||||
Args:
|
||||
all_prices: All price data points
|
||||
|
|
@ -552,6 +600,11 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
|
|||
to use original configured filter values.
|
||||
time: TibberPricesTimeService instance (required).
|
||||
config_entry: Config entry to get display unit configuration.
|
||||
day_patterns_by_date: Optional dict mapping date → day pattern dict. Used for
|
||||
geometric flex bonus in period detection. Passed through to calculate_periods().
|
||||
time_range: Optional (start_inclusive, end_exclusive) datetime window. When set,
|
||||
only intervals within [start, end) are considered as period candidates.
|
||||
Passed through to calculate_periods(). Used by Phase 4 segment forcing.
|
||||
|
||||
Returns:
|
||||
Dict with same format as calculate_periods() output:
|
||||
|
|
@ -561,12 +614,8 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
|
|||
|
||||
"""
|
||||
# Import here to avoid circular dependency
|
||||
from .core import ( # noqa: PLC0415
|
||||
calculate_periods,
|
||||
)
|
||||
from .period_building import ( # noqa: PLC0415
|
||||
filter_superseded_periods,
|
||||
)
|
||||
from .core import calculate_periods # noqa: PLC0415
|
||||
from .period_building import filter_superseded_periods # noqa: PLC0415
|
||||
|
||||
# Compact INFO-level summary
|
||||
period_type = "PEAK PRICE" if config.reverse_sort else "BEST PRICE"
|
||||
|
|
@ -637,7 +686,6 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
|
|||
"relaxation_active": False,
|
||||
"relaxation_attempted": False,
|
||||
"min_periods_requested": min_periods if enable_relaxation else 0,
|
||||
"periods_found": 0,
|
||||
},
|
||||
},
|
||||
"reference_data": {},
|
||||
|
|
@ -669,7 +717,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
|
|||
any_normal_day = False
|
||||
for day_prices in prices_by_day.values():
|
||||
prices = [float(p["total"]) for p in day_prices if p.get("total") is not None]
|
||||
if len(prices) >= 2: # noqa: PLR2004
|
||||
if len(prices) >= 2:
|
||||
day_min = min(prices)
|
||||
day_avg = sum(prices) / len(prices)
|
||||
span = abs(day_avg - day_min)
|
||||
|
|
@ -709,7 +757,9 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
|
|||
# === BASELINE CALCULATION (process ALL prices together, including yesterday) ===
|
||||
# Periods that ended before yesterday will be filtered out later by filter_periods_by_end_date()
|
||||
# This keeps yesterday/today/tomorrow periods in the cache
|
||||
baseline_result = calculate_periods(all_prices, config=config, time=time)
|
||||
baseline_result = calculate_periods(
|
||||
all_prices, config=config, time=time, day_patterns_by_date=day_patterns_by_date, time_range=time_range
|
||||
)
|
||||
all_periods = baseline_result["periods"]
|
||||
|
||||
# Count periods per day for min_periods check
|
||||
|
|
@ -765,6 +815,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
|
|||
baseline_periods=all_periods,
|
||||
time=time,
|
||||
config_entry=config_entry,
|
||||
day_patterns_by_date=day_patterns_by_date,
|
||||
)
|
||||
|
||||
all_periods = relaxed_result["periods"]
|
||||
|
|
@ -793,8 +844,11 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
|
|||
fallback_result, fallback_metadata = _try_min_duration_fallback(
|
||||
config=config,
|
||||
existing_periods=all_periods,
|
||||
all_prices=all_prices,
|
||||
prices_by_day=prices_by_day,
|
||||
time=time,
|
||||
max_relaxation_attempts=max_relaxation_attempts,
|
||||
day_patterns_by_date=day_patterns_by_date,
|
||||
)
|
||||
|
||||
if fallback_result:
|
||||
|
|
@ -811,10 +865,12 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
|
|||
days_meeting_requirement += 1
|
||||
|
||||
elif enable_relaxation:
|
||||
filter_combination_count = 2 if config.level_filter not in (None, "any") else 1
|
||||
_LOGGER_DETAILS.debug(
|
||||
"%sAll %d days met target with baseline - no relaxation needed",
|
||||
"%sRelaxation strategy: 3%% fixed flex increment per step (%d flex levels x %d filter combinations)",
|
||||
INDENT_L1,
|
||||
total_days,
|
||||
filter_combination_count,
|
||||
)
|
||||
|
||||
# Sort periods by start time
|
||||
|
|
@ -835,8 +891,6 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
|
|||
final_result = baseline_result.copy()
|
||||
final_result["periods"] = all_periods
|
||||
|
||||
total_periods = len(all_periods)
|
||||
|
||||
# Add relaxation info to metadata
|
||||
if "metadata" not in final_result:
|
||||
final_result["metadata"] = {}
|
||||
|
|
@ -844,7 +898,6 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
|
|||
"relaxation_active": relaxation_was_needed,
|
||||
"relaxation_attempted": relaxation_was_needed,
|
||||
"min_periods_requested": min_periods,
|
||||
"periods_found": total_periods,
|
||||
"phases_used": list(set(all_phases_used)), # Unique phases used across all days
|
||||
"days_processed": total_days,
|
||||
"days_meeting_requirement": days_meeting_requirement,
|
||||
|
|
@ -855,7 +908,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
|
|||
return final_result
|
||||
|
||||
|
||||
def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation requires many parameters and statements
|
||||
def relax_all_prices(
|
||||
all_prices: list[dict],
|
||||
config: TibberPricesPeriodConfig,
|
||||
min_periods: int,
|
||||
|
|
@ -865,14 +918,16 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
|
|||
*,
|
||||
time: TibberPricesTimeService,
|
||||
config_entry: Any, # ConfigEntry type
|
||||
day_patterns_by_date: dict | None = None,
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""
|
||||
Relax filters for all prices until min_periods per day is reached.
|
||||
|
||||
Strategy: Try increasing flex by 3% increments, then relax level filter.
|
||||
Processes all prices together (yesterday+today+tomorrow), allowing periods
|
||||
to cross midnight boundaries. Returns when ALL days have min_periods
|
||||
(or max attempts exhausted).
|
||||
Strategy: Try increasing flex by 3% increments while keeping the configured
|
||||
level filter. For each flex level, optionally retry with `level_filter="any"`
|
||||
when a concrete level filter is configured. Processes all prices together
|
||||
(yesterday+today+tomorrow), allowing periods to cross midnight boundaries.
|
||||
Returns when ALL days have min_periods (or max attempts exhausted).
|
||||
|
||||
Args:
|
||||
all_prices: All price intervals (yesterday+today+tomorrow).
|
||||
|
|
@ -883,22 +938,26 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
|
|||
baseline_periods: Baseline periods (before relaxation).
|
||||
time: TibberPricesTimeService instance.
|
||||
config_entry: Config entry to get display unit configuration.
|
||||
day_patterns_by_date: Optional dict mapping date → day pattern dict. Used for
|
||||
geometric flex bonus in period detection. Passed through to calculate_periods().
|
||||
|
||||
Returns:
|
||||
Tuple of (result_dict, metadata_dict)
|
||||
|
||||
"""
|
||||
# Import here to avoid circular dependency
|
||||
from .core import ( # noqa: PLC0415
|
||||
calculate_periods,
|
||||
)
|
||||
from .core import calculate_periods # noqa: PLC0415
|
||||
|
||||
flex_increment = 0.03 # 3% per step (hard-coded for reliability)
|
||||
flex_increment = RELAXATION_FLEX_INCREMENT # 3% per step (see types.py for rationale)
|
||||
base_flex = abs(config.flex)
|
||||
original_level_filter = config.level_filter
|
||||
existing_periods = list(baseline_periods) # Start with baseline
|
||||
phases_used = []
|
||||
|
||||
filter_variants: list[tuple[str | None, str | None]] = [(None, original_level_filter)]
|
||||
if original_level_filter not in (None, "any"):
|
||||
filter_variants.append(("any", "any"))
|
||||
|
||||
# Get available days from prices for checking
|
||||
prices_by_day = group_prices_by_day(all_prices, time=time)
|
||||
total_days = len(prices_by_day)
|
||||
|
|
@ -916,14 +975,17 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
|
|||
)
|
||||
break
|
||||
|
||||
for level_override, applied_level_filter in filter_variants:
|
||||
phase_label = f"flex={current_flex * 100:.1f}%"
|
||||
phase_label_full = phase_label
|
||||
if applied_level_filter is not None:
|
||||
phase_label_full = f"{phase_label} +level_{applied_level_filter}"
|
||||
|
||||
# Skip this flex level if callback says not to show it
|
||||
if not should_show_callback(phase_label):
|
||||
# The callback expects a level override (e.g. None or "any"), not a flex label.
|
||||
if not should_show_callback(level_override):
|
||||
continue
|
||||
|
||||
# Try current flex with level="any" (in relaxation mode)
|
||||
if original_level_filter != "any":
|
||||
if level_override == "any" and original_level_filter not in (None, "any"):
|
||||
_LOGGER_DETAILS.debug(
|
||||
"%s Flex=%.1f%%: OVERRIDING level_filter: %s → ANY",
|
||||
INDENT_L2,
|
||||
|
|
@ -934,10 +996,9 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
|
|||
# NOTE: config.flex is already normalized to positive by get_period_config()
|
||||
relaxed_config = config._replace(
|
||||
flex=current_flex, # Already positive from normalization
|
||||
level_filter="any",
|
||||
level_filter=applied_level_filter,
|
||||
)
|
||||
|
||||
phase_label_full = f"flex={current_flex * 100:.1f}% +level_any"
|
||||
_LOGGER_DETAILS.debug(
|
||||
"%s Trying %s: config has %d intervals (all days together), level_filter=%s",
|
||||
INDENT_L2,
|
||||
|
|
@ -947,7 +1008,12 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
|
|||
)
|
||||
|
||||
# Process ALL prices together (allows midnight crossing)
|
||||
result = calculate_periods(all_prices, config=relaxed_config, time=time)
|
||||
result = calculate_periods(
|
||||
all_prices,
|
||||
config=relaxed_config,
|
||||
time=time,
|
||||
day_patterns_by_date=day_patterns_by_date,
|
||||
)
|
||||
new_periods = result["periods"]
|
||||
|
||||
_LOGGER_DETAILS.debug(
|
||||
|
|
@ -970,6 +1036,9 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
|
|||
combined, standalone_count = resolve_period_overlaps(
|
||||
existing_periods=existing_periods,
|
||||
new_relaxed_periods=new_periods,
|
||||
all_prices=all_prices,
|
||||
config=config,
|
||||
time=time,
|
||||
)
|
||||
|
||||
# Count periods per day with QUALITY GATE check
|
||||
|
|
@ -1002,6 +1071,9 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
|
|||
)
|
||||
break
|
||||
|
||||
if days_meeting_requirement >= total_days:
|
||||
break
|
||||
|
||||
# Build final result
|
||||
final_result = (
|
||||
result.copy() if "result" in locals() else {"periods": baseline_periods, "metadata": {}, "reference_data": {}}
|
||||
|
|
@ -1010,5 +1082,4 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
|
|||
|
||||
return final_result, {
|
||||
"phases_used": phases_used,
|
||||
"periods_found": len(existing_periods),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ gap tolerance, and coordination of the period_handlers calculation functions.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
|
|
@ -19,10 +20,7 @@ if TYPE_CHECKING:
|
|||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
from .helpers import get_intervals_for_day_offsets
|
||||
from .period_handlers import (
|
||||
TibberPricesPeriodConfig,
|
||||
calculate_periods_with_relaxation,
|
||||
)
|
||||
from .period_handlers import TibberPricesPeriodConfig, calculate_periods_with_relaxation
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -76,6 +74,54 @@ class TibberPricesPeriodCalculator:
|
|||
section = self.config_entry.options.get(config_section, {})
|
||||
return section.get(config_key, default)
|
||||
|
||||
def _normalize_float_option(
|
||||
self,
|
||||
value: Any,
|
||||
default: float,
|
||||
*,
|
||||
option_name: str,
|
||||
absolute: bool = False,
|
||||
divisor: float = 1.0,
|
||||
) -> float:
|
||||
"""Normalize numeric config values and fall back cleanly on invalid input."""
|
||||
try:
|
||||
normalized = float(value)
|
||||
except TypeError, ValueError:
|
||||
self._log("warning", "Invalid numeric option %s=%r, using default %s", option_name, value, default)
|
||||
normalized = float(default)
|
||||
|
||||
if absolute:
|
||||
normalized = abs(normalized)
|
||||
|
||||
return normalized / divisor
|
||||
|
||||
def _normalize_int_option(
|
||||
self,
|
||||
value: Any,
|
||||
default: int,
|
||||
*,
|
||||
option_name: str,
|
||||
minimum: int | None = None,
|
||||
) -> int:
|
||||
"""Normalize integer config values and fall back cleanly on invalid input."""
|
||||
try:
|
||||
normalized = int(value)
|
||||
except TypeError, ValueError:
|
||||
self._log("warning", "Invalid integer option %s=%r, using default %s", option_name, value, default)
|
||||
return default
|
||||
|
||||
if minimum is not None and normalized < minimum:
|
||||
self._log(
|
||||
"warning",
|
||||
"Out-of-range integer option %s=%r, using default %s",
|
||||
option_name,
|
||||
value,
|
||||
default,
|
||||
)
|
||||
return default
|
||||
|
||||
return normalized
|
||||
|
||||
def _log(self, level: str, message: str, *args: object, **kwargs: object) -> None:
|
||||
"""Log with calculator-specific prefix."""
|
||||
prefixed_message = f"{self._log_prefix} {message}"
|
||||
|
|
@ -90,12 +136,12 @@ class TibberPricesPeriodCalculator:
|
|||
self._last_periods_hash = None
|
||||
self._log("debug", "Period config cache and calculation cache invalidated")
|
||||
|
||||
def _compute_periods_hash(self, price_info: dict[str, Any]) -> str:
|
||||
def _compute_periods_hash(self, price_info: list[dict[str, Any]]) -> str:
|
||||
"""
|
||||
Compute hash of price data and config for period calculation caching.
|
||||
|
||||
Only includes data that affects period calculation:
|
||||
- All interval timestamps and enriched rating levels (yesterday/today/tomorrow)
|
||||
- Today/tomorrow interval content (timestamps, totals, levels, ratings, differences)
|
||||
- Period calculation config (flex, min_distance, min_period_length)
|
||||
- Level filter overrides
|
||||
|
||||
|
|
@ -103,20 +149,42 @@ class TibberPricesPeriodCalculator:
|
|||
Hash string for cache key comparison.
|
||||
|
||||
"""
|
||||
# Get today and tomorrow intervals for hash calculation
|
||||
# CRITICAL: Only today+tomorrow needed in hash because:
|
||||
# 1. Mitternacht: "today" startsAt changes → cache invalidates
|
||||
# 2. Tomorrow arrival: "tomorrow" startsAt changes from None → cache invalidates
|
||||
# 3. Yesterday/day-before-yesterday are static (rating_levels don't change retroactively)
|
||||
# 4. Using first startsAt as representative (changes → entire day changed)
|
||||
# Get today and tomorrow intervals for hash calculation.
|
||||
# Hash full interval signatures instead of only the first startsAt so we also
|
||||
# invalidate when prices or enriched levels change within the same calendar day.
|
||||
coordinator_data = {"priceInfo": price_info}
|
||||
today_intervals = get_intervals_for_day_offsets(coordinator_data, [0])
|
||||
tomorrow_intervals = get_intervals_for_day_offsets(coordinator_data, [1])
|
||||
|
||||
# Use first startsAt of each day as representative for entire day's data
|
||||
# If day is empty, use None (detects data availability changes)
|
||||
today_start = today_intervals[0].get("startsAt") if today_intervals else None
|
||||
tomorrow_start = tomorrow_intervals[0].get("startsAt") if tomorrow_intervals else None
|
||||
def _build_interval_signature(intervals: list[dict[str, Any]]) -> tuple[tuple[Any, Any, Any, Any, Any], ...]:
|
||||
signature: list[tuple[Any, Any, Any, Any, Any]] = []
|
||||
|
||||
for interval in intervals:
|
||||
starts_at = interval.get("startsAt")
|
||||
starts_at_key = (
|
||||
starts_at.isoformat() if starts_at is not None and hasattr(starts_at, "isoformat") else starts_at
|
||||
)
|
||||
|
||||
total = interval.get("total")
|
||||
total_key = round(float(total), 6) if total is not None else None
|
||||
|
||||
difference = interval.get("difference")
|
||||
difference_key = round(float(difference), 6) if difference is not None else None
|
||||
|
||||
signature.append(
|
||||
(
|
||||
starts_at_key,
|
||||
total_key,
|
||||
interval.get("level"),
|
||||
interval.get("rating_level"),
|
||||
difference_key,
|
||||
)
|
||||
)
|
||||
|
||||
return tuple(signature)
|
||||
|
||||
today_signature = _build_interval_signature(today_intervals)
|
||||
tomorrow_signature = _build_interval_signature(tomorrow_intervals)
|
||||
|
||||
# Get period configs (both best and peak)
|
||||
best_config = self.get_period_config(reverse_sort=False)
|
||||
|
|
@ -130,8 +198,8 @@ class TibberPricesPeriodCalculator:
|
|||
|
||||
# Compute hash from all relevant data
|
||||
hash_data = (
|
||||
today_start, # Representative for today's data (changes at midnight)
|
||||
tomorrow_start, # Representative for tomorrow's data (changes when data arrives)
|
||||
today_signature,
|
||||
tomorrow_signature,
|
||||
tuple(best_config.items()),
|
||||
tuple(peak_config.items()),
|
||||
best_level_filter,
|
||||
|
|
@ -197,34 +265,159 @@ class TibberPricesPeriodCalculator:
|
|||
_const.DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH,
|
||||
)
|
||||
|
||||
# Convert flex from percentage to decimal (e.g., 5 -> 0.05)
|
||||
# CRITICAL: Normalize to absolute value for internal calculations
|
||||
# User-facing values use sign convention:
|
||||
# - Best price: positive (e.g., +15% above minimum)
|
||||
# - Peak price: negative (e.g., -20% below maximum)
|
||||
# Internal calculations always use positive values with reverse_sort flag
|
||||
try:
|
||||
flex = abs(float(flex)) / 100 # Always positive internally
|
||||
except (TypeError, ValueError):
|
||||
flex = (
|
||||
abs(_const.DEFAULT_BEST_PRICE_FLEX) / 100
|
||||
if not reverse_sort
|
||||
else abs(_const.DEFAULT_PEAK_PRICE_FLEX) / 100
|
||||
default_flex = _const.DEFAULT_PEAK_PRICE_FLEX if reverse_sort else _const.DEFAULT_BEST_PRICE_FLEX
|
||||
default_min_distance = (
|
||||
_const.DEFAULT_PEAK_PRICE_MIN_DISTANCE_FROM_AVG
|
||||
if reverse_sort
|
||||
else _const.DEFAULT_BEST_PRICE_MIN_DISTANCE_FROM_AVG
|
||||
)
|
||||
default_min_period_length = (
|
||||
_const.DEFAULT_PEAK_PRICE_MIN_PERIOD_LENGTH if reverse_sort else _const.DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH
|
||||
)
|
||||
|
||||
# CRITICAL: Normalize min_distance_from_avg to absolute value
|
||||
# User-facing values use sign convention:
|
||||
# - Best price: negative (e.g., -5% below average)
|
||||
# - Peak price: positive (e.g., +5% above average)
|
||||
# Internal calculations always use positive values with reverse_sort flag
|
||||
min_distance_from_avg_normalized = abs(float(min_distance_from_avg))
|
||||
# Convert flex from percentage to decimal (e.g., 5 -> 0.05)
|
||||
# and normalize sign conventions to positive internal values.
|
||||
flex = self._normalize_float_option(
|
||||
flex,
|
||||
default_flex,
|
||||
option_name=_const.CONF_PEAK_PRICE_FLEX if reverse_sort else _const.CONF_BEST_PRICE_FLEX,
|
||||
absolute=True,
|
||||
divisor=100,
|
||||
)
|
||||
|
||||
# CRITICAL: Normalize min_distance_from_avg to absolute value.
|
||||
min_distance_from_avg_normalized = self._normalize_float_option(
|
||||
min_distance_from_avg,
|
||||
default_min_distance,
|
||||
option_name=(
|
||||
_const.CONF_PEAK_PRICE_MIN_DISTANCE_FROM_AVG
|
||||
if reverse_sort
|
||||
else _const.CONF_BEST_PRICE_MIN_DISTANCE_FROM_AVG
|
||||
),
|
||||
absolute=True,
|
||||
)
|
||||
|
||||
config = {
|
||||
"flex": flex,
|
||||
"min_distance_from_avg": min_distance_from_avg_normalized,
|
||||
"min_period_length": int(min_period_length),
|
||||
"min_period_length": self._normalize_int_option(
|
||||
min_period_length,
|
||||
default_min_period_length,
|
||||
option_name=(
|
||||
_const.CONF_PEAK_PRICE_MIN_PERIOD_LENGTH
|
||||
if reverse_sort
|
||||
else _const.CONF_BEST_PRICE_MIN_PERIOD_LENGTH
|
||||
),
|
||||
minimum=1,
|
||||
),
|
||||
}
|
||||
|
||||
# Extension settings (stored in 'extension_settings' nested section)
|
||||
if reverse_sort:
|
||||
extend_to_extreme = bool(
|
||||
self._get_option(
|
||||
_const.CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE,
|
||||
"extension_settings",
|
||||
_const.DEFAULT_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE,
|
||||
)
|
||||
)
|
||||
max_extension_intervals = self._normalize_int_option(
|
||||
self._get_option(
|
||||
_const.CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
|
||||
"extension_settings",
|
||||
_const.DEFAULT_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
|
||||
),
|
||||
_const.DEFAULT_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
|
||||
option_name=_const.CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
|
||||
minimum=0,
|
||||
)
|
||||
else:
|
||||
extend_to_extreme = bool(
|
||||
self._get_option(
|
||||
_const.CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP,
|
||||
"extension_settings",
|
||||
_const.DEFAULT_BEST_PRICE_EXTEND_TO_VERY_CHEAP,
|
||||
)
|
||||
)
|
||||
max_extension_intervals = self._normalize_int_option(
|
||||
self._get_option(
|
||||
_const.CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS,
|
||||
"extension_settings",
|
||||
_const.DEFAULT_BEST_PRICE_MAX_EXTENSION_INTERVALS,
|
||||
),
|
||||
_const.DEFAULT_BEST_PRICE_MAX_EXTENSION_INTERVALS,
|
||||
option_name=_const.CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS,
|
||||
minimum=0,
|
||||
)
|
||||
|
||||
config["extend_to_extreme"] = extend_to_extreme
|
||||
config["max_extension_intervals"] = max_extension_intervals
|
||||
|
||||
# Geometric flex bonus (intervals inside valley/peak zone get extra flex)
|
||||
if reverse_sort:
|
||||
geometric_flex_pct = self._normalize_int_option(
|
||||
self._get_option(
|
||||
_const.CONF_PEAK_PRICE_GEOMETRIC_FLEX,
|
||||
"extension_settings",
|
||||
_const.DEFAULT_PEAK_PRICE_GEOMETRIC_FLEX,
|
||||
),
|
||||
_const.DEFAULT_PEAK_PRICE_GEOMETRIC_FLEX,
|
||||
option_name=_const.CONF_PEAK_PRICE_GEOMETRIC_FLEX,
|
||||
minimum=0,
|
||||
)
|
||||
else:
|
||||
geometric_flex_pct = self._normalize_int_option(
|
||||
self._get_option(
|
||||
_const.CONF_BEST_PRICE_GEOMETRIC_FLEX,
|
||||
"extension_settings",
|
||||
_const.DEFAULT_BEST_PRICE_GEOMETRIC_FLEX,
|
||||
),
|
||||
_const.DEFAULT_BEST_PRICE_GEOMETRIC_FLEX,
|
||||
option_name=_const.CONF_BEST_PRICE_GEOMETRIC_FLEX,
|
||||
minimum=0,
|
||||
)
|
||||
config["geometric_extra_flex"] = geometric_flex_pct / 100
|
||||
|
||||
# Segment forcing (force at least segment_min_periods per W/M-shape segment)
|
||||
if reverse_sort:
|
||||
segment_forcing = bool(
|
||||
self._get_option(
|
||||
_const.CONF_PEAK_PRICE_SEGMENT_FORCING,
|
||||
"extension_settings",
|
||||
_const.DEFAULT_PEAK_PRICE_SEGMENT_FORCING,
|
||||
)
|
||||
)
|
||||
segment_min_periods = self._normalize_int_option(
|
||||
self._get_option(
|
||||
_const.CONF_PEAK_PRICE_SEGMENT_MIN_PERIODS,
|
||||
"extension_settings",
|
||||
_const.DEFAULT_PEAK_PRICE_SEGMENT_MIN_PERIODS,
|
||||
),
|
||||
_const.DEFAULT_PEAK_PRICE_SEGMENT_MIN_PERIODS,
|
||||
option_name=_const.CONF_PEAK_PRICE_SEGMENT_MIN_PERIODS,
|
||||
minimum=1,
|
||||
)
|
||||
else:
|
||||
segment_forcing = bool(
|
||||
self._get_option(
|
||||
_const.CONF_BEST_PRICE_SEGMENT_FORCING,
|
||||
"extension_settings",
|
||||
_const.DEFAULT_BEST_PRICE_SEGMENT_FORCING,
|
||||
)
|
||||
)
|
||||
segment_min_periods = self._normalize_int_option(
|
||||
self._get_option(
|
||||
_const.CONF_BEST_PRICE_SEGMENT_MIN_PERIODS,
|
||||
"extension_settings",
|
||||
_const.DEFAULT_BEST_PRICE_SEGMENT_MIN_PERIODS,
|
||||
),
|
||||
_const.DEFAULT_BEST_PRICE_SEGMENT_MIN_PERIODS,
|
||||
option_name=_const.CONF_BEST_PRICE_SEGMENT_MIN_PERIODS,
|
||||
minimum=1,
|
||||
)
|
||||
config["segment_forcing"] = segment_forcing
|
||||
config["segment_min_periods"] = segment_min_periods
|
||||
|
||||
# Cache the result
|
||||
self._config_cache[cache_key] = config
|
||||
self._config_cache_valid = True
|
||||
|
|
@ -232,7 +425,7 @@ class TibberPricesPeriodCalculator:
|
|||
|
||||
def should_show_periods(
|
||||
self,
|
||||
price_info: dict[str, Any],
|
||||
price_info: list[dict[str, Any]],
|
||||
*,
|
||||
reverse_sort: bool,
|
||||
level_override: str | None = None,
|
||||
|
|
@ -241,7 +434,7 @@ class TibberPricesPeriodCalculator:
|
|||
Check if periods should be shown based on level filter only.
|
||||
|
||||
Args:
|
||||
price_info: Price information dict with today/yesterday/tomorrow data
|
||||
price_info: Flat list of price intervals (yesterday/today/tomorrow)
|
||||
reverse_sort: If False (best_price), checks max_level filter.
|
||||
If True (peak_price), checks min_level filter.
|
||||
level_override: Optional override for level filter ("any" to disable)
|
||||
|
|
@ -400,16 +593,27 @@ class TibberPricesPeriodCalculator:
|
|||
|
||||
# Normal check failed - try splitting at gap clusters as fallback
|
||||
# Get minimum period length from config (convert minutes to intervals)
|
||||
period_settings = self.config_entry.options.get("period_settings", {})
|
||||
if reverse_sort:
|
||||
min_period_minutes = period_settings.get(
|
||||
min_period_minutes = self._normalize_int_option(
|
||||
self._get_option(
|
||||
_const.CONF_PEAK_PRICE_MIN_PERIOD_LENGTH,
|
||||
"period_settings",
|
||||
_const.DEFAULT_PEAK_PRICE_MIN_PERIOD_LENGTH,
|
||||
),
|
||||
_const.DEFAULT_PEAK_PRICE_MIN_PERIOD_LENGTH,
|
||||
option_name=_const.CONF_PEAK_PRICE_MIN_PERIOD_LENGTH,
|
||||
minimum=1,
|
||||
)
|
||||
else:
|
||||
min_period_minutes = period_settings.get(
|
||||
min_period_minutes = self._normalize_int_option(
|
||||
self._get_option(
|
||||
_const.CONF_BEST_PRICE_MIN_PERIOD_LENGTH,
|
||||
"period_settings",
|
||||
_const.DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH,
|
||||
),
|
||||
_const.DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH,
|
||||
option_name=_const.CONF_BEST_PRICE_MIN_PERIOD_LENGTH,
|
||||
minimum=1,
|
||||
)
|
||||
|
||||
min_period_intervals = self.time.minutes_to_intervals(min_period_minutes)
|
||||
|
|
@ -504,7 +708,7 @@ class TibberPricesPeriodCalculator:
|
|||
|
||||
def check_level_filter(
|
||||
self,
|
||||
price_info: dict[str, Any],
|
||||
price_info: list[dict[str, Any]],
|
||||
*,
|
||||
reverse_sort: bool,
|
||||
override: str | None = None,
|
||||
|
|
@ -516,7 +720,7 @@ class TibberPricesPeriodCalculator:
|
|||
to deviate by one level step (e.g., CHEAP allows NORMAL, but not EXPENSIVE).
|
||||
|
||||
Args:
|
||||
price_info: Price information dict with today data
|
||||
price_info: Flat list of price intervals used for today's level check
|
||||
reverse_sort: If False (best_price), checks max_level (upper bound filter).
|
||||
If True (peak_price), checks min_level (lower bound filter).
|
||||
override: Optional override value (e.g., "any" to disable filter)
|
||||
|
|
@ -558,16 +762,27 @@ class TibberPricesPeriodCalculator:
|
|||
return True # If no data, don't filter
|
||||
|
||||
# Get gap tolerance configuration
|
||||
period_settings = self.config_entry.options.get("period_settings", {})
|
||||
if reverse_sort:
|
||||
max_gap_count = period_settings.get(
|
||||
max_gap_count = self._normalize_int_option(
|
||||
self._get_option(
|
||||
_const.CONF_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
"period_settings",
|
||||
_const.DEFAULT_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
),
|
||||
_const.DEFAULT_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
option_name=_const.CONF_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
minimum=0,
|
||||
)
|
||||
else:
|
||||
max_gap_count = period_settings.get(
|
||||
max_gap_count = self._normalize_int_option(
|
||||
self._get_option(
|
||||
_const.CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
"period_settings",
|
||||
_const.DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
),
|
||||
_const.DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
option_name=_const.CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
minimum=0,
|
||||
)
|
||||
|
||||
# Note: level_config is lowercase from selector, but _const.PRICE_LEVEL_MAPPING uses uppercase
|
||||
|
|
@ -597,7 +812,8 @@ class TibberPricesPeriodCalculator:
|
|||
|
||||
def calculate_periods_for_price_info(
|
||||
self,
|
||||
price_info: dict[str, Any],
|
||||
price_info: list[dict[str, Any]],
|
||||
day_patterns: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Calculate periods (best price and peak price) for the given price info.
|
||||
|
|
@ -622,30 +838,63 @@ class TibberPricesPeriodCalculator:
|
|||
coordinator_data = {"priceInfo": price_info}
|
||||
all_prices = get_intervals_for_day_offsets(coordinator_data, [-2, -1, 0, 1])
|
||||
|
||||
# Convert day_patterns (keyed by "yesterday"/"today"/"tomorrow") to date-keyed dict
|
||||
# Needed for geometric valley/peak zone flex bonus in period calculation
|
||||
today_date = self.time.now().date()
|
||||
day_patterns_by_date: dict[date, dict[str, Any]] | None = (
|
||||
{
|
||||
today_date + timedelta(days=ofs): pat
|
||||
for ofs, lbl in ((-1, "yesterday"), (0, "today"), (1, "tomorrow"))
|
||||
if (pat := day_patterns.get(lbl)) is not None
|
||||
}
|
||||
if day_patterns
|
||||
else None
|
||||
)
|
||||
|
||||
# Get rating thresholds from config (flat in options, not in sections)
|
||||
# CRITICAL: Price rating thresholds are stored FLAT in options (no sections)
|
||||
threshold_low = self.config_entry.options.get(
|
||||
threshold_low = self._normalize_float_option(
|
||||
self.config_entry.options.get(
|
||||
_const.CONF_PRICE_RATING_THRESHOLD_LOW,
|
||||
_const.DEFAULT_PRICE_RATING_THRESHOLD_LOW,
|
||||
),
|
||||
_const.DEFAULT_PRICE_RATING_THRESHOLD_LOW,
|
||||
option_name=_const.CONF_PRICE_RATING_THRESHOLD_LOW,
|
||||
)
|
||||
threshold_high = self.config_entry.options.get(
|
||||
threshold_high = self._normalize_float_option(
|
||||
self.config_entry.options.get(
|
||||
_const.CONF_PRICE_RATING_THRESHOLD_HIGH,
|
||||
_const.DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
|
||||
),
|
||||
_const.DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
|
||||
option_name=_const.CONF_PRICE_RATING_THRESHOLD_HIGH,
|
||||
)
|
||||
|
||||
# Get volatility thresholds from config (flat in options, not in sections)
|
||||
# CRITICAL: Volatility thresholds are stored FLAT in options (no sections)
|
||||
threshold_volatility_moderate = self.config_entry.options.get(
|
||||
threshold_volatility_moderate = self._normalize_float_option(
|
||||
self.config_entry.options.get(
|
||||
_const.CONF_VOLATILITY_THRESHOLD_MODERATE,
|
||||
_const.DEFAULT_VOLATILITY_THRESHOLD_MODERATE,
|
||||
),
|
||||
_const.DEFAULT_VOLATILITY_THRESHOLD_MODERATE,
|
||||
option_name=_const.CONF_VOLATILITY_THRESHOLD_MODERATE,
|
||||
)
|
||||
threshold_volatility_high = self.config_entry.options.get(
|
||||
threshold_volatility_high = self._normalize_float_option(
|
||||
self.config_entry.options.get(
|
||||
_const.CONF_VOLATILITY_THRESHOLD_HIGH,
|
||||
_const.DEFAULT_VOLATILITY_THRESHOLD_HIGH,
|
||||
),
|
||||
_const.DEFAULT_VOLATILITY_THRESHOLD_HIGH,
|
||||
option_name=_const.CONF_VOLATILITY_THRESHOLD_HIGH,
|
||||
)
|
||||
threshold_volatility_very_high = self.config_entry.options.get(
|
||||
threshold_volatility_very_high = self._normalize_float_option(
|
||||
self.config_entry.options.get(
|
||||
_const.CONF_VOLATILITY_THRESHOLD_VERY_HIGH,
|
||||
_const.DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH,
|
||||
),
|
||||
_const.DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH,
|
||||
option_name=_const.CONF_VOLATILITY_THRESHOLD_VERY_HIGH,
|
||||
)
|
||||
|
||||
# Get relaxation configuration for best price
|
||||
|
|
@ -658,21 +907,32 @@ class TibberPricesPeriodCalculator:
|
|||
)
|
||||
|
||||
# Check if best price periods should be shown
|
||||
# If relaxation is enabled, always calculate (relaxation will try "any" filter)
|
||||
# If relaxation is disabled, apply level filter check
|
||||
# If relaxation is enabled, always calculate (relaxation tries configured level filter
|
||||
# first, then falls back to "any" per flex step if still insufficient)
|
||||
# If relaxation is disabled, apply level filter check upfront
|
||||
if enable_relaxation_best:
|
||||
show_best_price = bool(all_prices)
|
||||
else:
|
||||
show_best_price = self.should_show_periods(price_info, reverse_sort=False) if all_prices else False
|
||||
min_periods_best = self._get_option(
|
||||
min_periods_best = self._normalize_int_option(
|
||||
self._get_option(
|
||||
_const.CONF_MIN_PERIODS_BEST,
|
||||
"relaxation_and_target_periods",
|
||||
_const.DEFAULT_MIN_PERIODS_BEST,
|
||||
),
|
||||
_const.DEFAULT_MIN_PERIODS_BEST,
|
||||
option_name=_const.CONF_MIN_PERIODS_BEST,
|
||||
minimum=1,
|
||||
)
|
||||
relaxation_attempts_best = self._get_option(
|
||||
relaxation_attempts_best = self._normalize_int_option(
|
||||
self._get_option(
|
||||
_const.CONF_RELAXATION_ATTEMPTS_BEST,
|
||||
"relaxation_and_target_periods",
|
||||
_const.DEFAULT_RELAXATION_ATTEMPTS_BEST,
|
||||
),
|
||||
_const.DEFAULT_RELAXATION_ATTEMPTS_BEST,
|
||||
option_name=_const.CONF_RELAXATION_ATTEMPTS_BEST,
|
||||
minimum=1,
|
||||
)
|
||||
|
||||
# Calculate best price periods (or return empty if filtered)
|
||||
|
|
@ -685,10 +945,15 @@ class TibberPricesPeriodCalculator:
|
|||
"period_settings",
|
||||
_const.DEFAULT_BEST_PRICE_MAX_LEVEL,
|
||||
)
|
||||
gap_count_best = self._get_option(
|
||||
gap_count_best = self._normalize_int_option(
|
||||
self._get_option(
|
||||
_const.CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
"period_settings",
|
||||
_const.DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
),
|
||||
_const.DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
option_name=_const.CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
minimum=0,
|
||||
)
|
||||
best_period_config = TibberPricesPeriodConfig(
|
||||
reverse_sort=False,
|
||||
|
|
@ -702,6 +967,11 @@ class TibberPricesPeriodCalculator:
|
|||
threshold_volatility_very_high=threshold_volatility_very_high,
|
||||
level_filter=max_level_best,
|
||||
gap_count=gap_count_best,
|
||||
extend_to_extreme=best_config["extend_to_extreme"],
|
||||
max_extension_intervals=best_config["max_extension_intervals"],
|
||||
geometric_extra_flex=best_config["geometric_extra_flex"],
|
||||
segment_forcing=best_config["segment_forcing"],
|
||||
segment_min_periods=best_config["segment_min_periods"],
|
||||
)
|
||||
best_periods = calculate_periods_with_relaxation(
|
||||
all_prices,
|
||||
|
|
@ -716,6 +986,7 @@ class TibberPricesPeriodCalculator:
|
|||
),
|
||||
time=self.time,
|
||||
config_entry=self.config_entry,
|
||||
day_patterns_by_date=day_patterns_by_date,
|
||||
)
|
||||
else:
|
||||
best_periods = {
|
||||
|
|
@ -739,21 +1010,32 @@ class TibberPricesPeriodCalculator:
|
|||
)
|
||||
|
||||
# Check if peak price periods should be shown
|
||||
# If relaxation is enabled, always calculate (relaxation will try "any" filter)
|
||||
# If relaxation is disabled, apply level filter check
|
||||
# If relaxation is enabled, always calculate (relaxation tries configured level filter
|
||||
# first, then falls back to "any" per flex step if still insufficient)
|
||||
# If relaxation is disabled, apply level filter check upfront
|
||||
if enable_relaxation_peak:
|
||||
show_peak_price = bool(all_prices)
|
||||
else:
|
||||
show_peak_price = self.should_show_periods(price_info, reverse_sort=True) if all_prices else False
|
||||
min_periods_peak = self._get_option(
|
||||
min_periods_peak = self._normalize_int_option(
|
||||
self._get_option(
|
||||
_const.CONF_MIN_PERIODS_PEAK,
|
||||
"relaxation_and_target_periods",
|
||||
_const.DEFAULT_MIN_PERIODS_PEAK,
|
||||
),
|
||||
_const.DEFAULT_MIN_PERIODS_PEAK,
|
||||
option_name=_const.CONF_MIN_PERIODS_PEAK,
|
||||
minimum=1,
|
||||
)
|
||||
relaxation_attempts_peak = self._get_option(
|
||||
relaxation_attempts_peak = self._normalize_int_option(
|
||||
self._get_option(
|
||||
_const.CONF_RELAXATION_ATTEMPTS_PEAK,
|
||||
"relaxation_and_target_periods",
|
||||
_const.DEFAULT_RELAXATION_ATTEMPTS_PEAK,
|
||||
),
|
||||
_const.DEFAULT_RELAXATION_ATTEMPTS_PEAK,
|
||||
option_name=_const.CONF_RELAXATION_ATTEMPTS_PEAK,
|
||||
minimum=1,
|
||||
)
|
||||
|
||||
# Calculate peak price periods (or return empty if filtered)
|
||||
|
|
@ -766,10 +1048,15 @@ class TibberPricesPeriodCalculator:
|
|||
"period_settings",
|
||||
_const.DEFAULT_PEAK_PRICE_MIN_LEVEL,
|
||||
)
|
||||
gap_count_peak = self._get_option(
|
||||
gap_count_peak = self._normalize_int_option(
|
||||
self._get_option(
|
||||
_const.CONF_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
"period_settings",
|
||||
_const.DEFAULT_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
),
|
||||
_const.DEFAULT_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
option_name=_const.CONF_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
minimum=0,
|
||||
)
|
||||
peak_period_config = TibberPricesPeriodConfig(
|
||||
reverse_sort=True,
|
||||
|
|
@ -783,6 +1070,11 @@ class TibberPricesPeriodCalculator:
|
|||
threshold_volatility_very_high=threshold_volatility_very_high,
|
||||
level_filter=min_level_peak,
|
||||
gap_count=gap_count_peak,
|
||||
extend_to_extreme=peak_config["extend_to_extreme"],
|
||||
max_extension_intervals=peak_config["max_extension_intervals"],
|
||||
geometric_extra_flex=peak_config["geometric_extra_flex"],
|
||||
segment_forcing=peak_config["segment_forcing"],
|
||||
segment_min_periods=peak_config["segment_min_periods"],
|
||||
)
|
||||
peak_periods = calculate_periods_with_relaxation(
|
||||
all_prices,
|
||||
|
|
@ -797,6 +1089,7 @@ class TibberPricesPeriodCalculator:
|
|||
),
|
||||
time=self.time,
|
||||
config_entry=self.config_entry,
|
||||
day_patterns_by_date=day_patterns_by_date,
|
||||
)
|
||||
else:
|
||||
peak_periods = {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -227,45 +227,80 @@
|
|||
"long_description": "Zeigt den Durchschnittspreis für die nächsten 48 Intervalle (12 Stunden) beginnend ab dem nächsten 15-Minuten-Intervall.",
|
||||
"usage_tips": "Absolute Preisschwelle: Strategische Entscheidungen mit Preisobergrenzen. Fahre nur fort, wenn der 12h-Durchschnitt unter deinem maximal akzeptablen Preis liegt. Gut für verschiebbare Großlasten."
|
||||
},
|
||||
"price_trend_1h": {
|
||||
"description": "Preistrend für die nächste Stunde",
|
||||
"long_description": "Vergleicht aktuellen Intervallpreis mit Durchschnitt der nächsten 1 Stunde (4 Intervalle). Steigend wenn Zukunft >5% höher, fallend wenn >5% niedriger, sonst stabil.",
|
||||
"usage_tips": "Relative Optimierung: 'fallend' = warten, Preise sinken. 'steigend' = jetzt handeln oder du zahlst mehr. 'stabil' = Preis spielt gerade keine große Rolle. Funktioniert unabhängig vom absoluten Preisniveau."
|
||||
"price_outlook_1h": {
|
||||
"description": "Preisausblick für die nächste Stunde",
|
||||
"long_description": "Vergleicht deinen aktuellen Preis mit dem Durchschnitt aller Intervalle der nächsten Stunde (4 Intervalle). Alle Ausblick-Sensoren (1h–12h) haben dieselbe Basis: dein aktueller Preis — sie unterscheiden sich nur im Zeitfenster. Größere Fenster umfassen mehr Zukunftsstunden und glätten kurzfristige Spitzen. Steigend/fallend ab ±3%, stark ab ±9% (konfigurierbar, volatilitätsadaptiv).",
|
||||
"usage_tips": "Entscheidungshilfe: 'steigend' = JETZT HANDELN, dein aktueller Preis ist günstiger als der Stunden-Durchschnitt. 'fallend' = WARTEN, der Fenster-Durchschnitt ist günstiger als jetzt. 'stabil' = Timing egal. Häufiges Missverständnis: 'steigend' bedeutet NICHT 'zu spät' — es heißt, jetzt ist gerade ein guter Preis! Funktioniert unabhängig vom absoluten Preisniveau."
|
||||
},
|
||||
"price_trend_2h": {
|
||||
"description": "Preistrend für die nächsten 2 Stunden",
|
||||
"long_description": "Vergleicht aktuellen Intervallpreis mit Durchschnitt der nächsten 2 Stunden (8 Intervalle). Steigend wenn Zukunft >5% höher, fallend wenn >5% niedriger, sonst stabil.",
|
||||
"usage_tips": "Relative Optimierung: Ideal für Haushaltsgeräte. 'fallend' bedeutet bessere Preise kommen in 2h - verschiebe wenn möglich. Findet bestes Timing in deinem verfügbaren Zeitfenster, unabhängig von der Saison."
|
||||
"price_outlook_2h": {
|
||||
"description": "Preisausblick für die nächsten 2 Stunden",
|
||||
"long_description": "Vergleicht deinen aktuellen Preis mit dem Durchschnitt aller Intervalle der nächsten 2 Stunden (8 Intervalle). 'steigend' = aktueller Preis liegt unter dem 2h-Fenster-Durchschnitt; 'fallend' = Fenster-Durchschnitt ist günstiger als jetzt. Steigend/fallend ab ±3%, stark ab ±9% (konfigurierbar, volatilitätsadaptiv).",
|
||||
"usage_tips": "Haushaltsgeräte: 'steigend' = jetzt starten, du hast gerade einen guten Preis. 'fallend' = bessere Preise kommen im 2h-Fenster, verschiebe wenn möglich. 'stabil' = egal, starte nach Bedarf. Kombiniere mit price_trajectory_2h um zu unterscheiden ob Preise noch fallen oder schon steigen."
|
||||
},
|
||||
"price_trend_3h": {
|
||||
"description": "Preistrend für die nächsten 3 Stunden",
|
||||
"long_description": "Vergleicht aktuellen Intervallpreis mit Durchschnitt der nächsten 3 Stunden (12 Intervalle). Steigend wenn Zukunft >5% höher, fallend wenn >5% niedriger, sonst stabil.",
|
||||
"usage_tips": "Relative Optimierung: Für Eco-Programme. 'fallend' bedeutet Preise sinken >5% - lohnt sich zu warten. Funktioniert in jeder Saison. Kombiniere mit avg-Sensor für Preisobergrenze: nur wenn avg < dein Limit UND Trend nicht 'fallend'."
|
||||
"price_outlook_3h": {
|
||||
"description": "Preisausblick für die nächsten 3 Stunden",
|
||||
"long_description": "Vergleicht deinen aktuellen Preis mit dem Durchschnitt aller Intervalle der nächsten 3 Stunden (12 Intervalle). 'steigend' = aktueller Preis liegt unter dem 3h-Fenster-Durchschnitt; 'fallend' = Fenster-Durchschnitt ist günstiger als jetzt. Steigend/fallend ab ±3%, stark ab ±9% (konfigurierbar, volatilitätsadaptiv).",
|
||||
"usage_tips": "Eco-Programme: 'steigend' = Eco-Zyklus jetzt starten, Preise steigen im Fenster-Durchschnitt. 'fallend' = warten, günstigeres Fenster kommt. Kombiniere mit avg-Sensor: starte wenn Ausblick 'steigend' oder 'stabil' UND avg < dein Limit."
|
||||
},
|
||||
"price_trend_4h": {
|
||||
"description": "Preistrend für die nächsten 4 Stunden",
|
||||
"long_description": "Vergleicht aktuellen Intervallpreis mit Durchschnitt der nächsten 4 Stunden (16 Intervalle). Steigend wenn Zukunft >5% höher, fallend wenn >5% niedriger, sonst stabil.",
|
||||
"usage_tips": "Relative Optimierung: Wärmepumpen/Batterie-Entscheidungen. 'fallend' bedeutet besseres Ladefenster kommt. Findet immer relative beste Zeit - ob Preise 10 Cent oder 50 Cent sind. Nutze avg-Sensor für absolute Grenze."
|
||||
"price_outlook_4h": {
|
||||
"description": "Preisausblick für die nächsten 4 Stunden",
|
||||
"long_description": "Vergleicht deinen aktuellen Preis mit dem Durchschnitt aller Intervalle der nächsten 4 Stunden (16 Intervalle). 'steigend' = aktueller Preis liegt unter dem 4h-Fenster-Durchschnitt; 'fallend' = Fenster-Durchschnitt ist günstiger als jetzt. Steigend/fallend ab ±3%, stark ab ±9% (konfigurierbar, volatilitätsadaptiv).",
|
||||
"usage_tips": "Wärmepumpe/Batterie: 'steigend' = jetzt laden, du bist an einem relativen Tiefpunkt. 'fallend' = warten auf besseres Fenster im Durchschnitt. Wichtig: 'stark fallend' an einem Preistief bedeutet das Fenster ist im Schnitt günstiger — kombiniere mit price_trajectory_4h um zu sehen ob die Preise noch fallen oder schon aufsteigen."
|
||||
},
|
||||
"price_trend_5h": {
|
||||
"description": "Preistrend für die nächsten 5 Stunden",
|
||||
"long_description": "Vergleicht aktuellen Intervallpreis mit Durchschnitt der nächsten 5 Stunden (20 Intervalle). Steigend wenn Zukunft >5% höher, fallend wenn >5% niedriger, sonst stabil.",
|
||||
"usage_tips": "Relative Optimierung: Erweiterte Betriebszyklen. Passt sich dem Markt an - findet bestes relatives Timing in jedem Preisumfeld. 'stabil/steigend' = guter Zeitpunkt zum Starten in deinem Planungsfenster."
|
||||
"price_outlook_5h": {
|
||||
"description": "Preisausblick für die nächsten 5 Stunden",
|
||||
"long_description": "Vergleicht deinen aktuellen Preis mit dem Durchschnitt aller Intervalle der nächsten 5 Stunden (20 Intervalle). 'steigend' = aktueller Preis liegt unter dem 5h-Fenster-Durchschnitt; 'fallend' = Fenster-Durchschnitt ist günstiger als jetzt. Steigend/fallend ab ±3%, stark ab ±9% (konfigurierbar, volatilitätsadaptiv).",
|
||||
"usage_tips": "Erweiterte Zyklen: 'steigend' oder 'stabil' = guter Zeitpunkt zum Starten, Preise werden im Fenster-Schnitt nicht günstiger. 'fallend' = warten wenn dein Zeitplan es erlaubt."
|
||||
},
|
||||
"price_trend_6h": {
|
||||
"description": "Preistrend für die nächsten 6 Stunden",
|
||||
"long_description": "Vergleicht aktuellen Intervallpreis mit Durchschnitt der nächsten 6 Stunden (24 Intervalle). Steigend wenn Zukunft >5% höher, fallend wenn >5% niedriger, sonst stabil.",
|
||||
"usage_tips": "Relative Optimierung: Abendentscheidungen. 'fallend' = Preise verbessern sich deutlich wenn du wartest. Keine festen Schwellenwerte nötig - passt sich automatisch an Winter/Sommer-Preisniveaus an."
|
||||
"price_outlook_6h": {
|
||||
"description": "Preisausblick für die nächsten 6 Stunden",
|
||||
"long_description": "Vergleicht deinen aktuellen Preis mit dem Durchschnitt aller Intervalle der nächsten 6 Stunden (24 Intervalle). 'steigend' = aktueller Preis liegt unter dem 6h-Fenster-Durchschnitt; 'fallend' = Fenster-Durchschnitt ist günstiger als jetzt. Steigend/fallend ab ±3%, stark ab ±9% (konfigurierbar, volatilitätsadaptiv).",
|
||||
"usage_tips": "Abendentscheidungen: 'steigend' = Strom jetzt nutzen, solange er relativ günstig ist. 'fallend' = Abend-/Nachtpreise werden im Durchschnitt besser, warte wenn möglich."
|
||||
},
|
||||
"price_trend_8h": {
|
||||
"description": "Preistrend für die nächsten 8 Stunden",
|
||||
"long_description": "Vergleicht aktuellen Intervallpreis mit Durchschnitt der nächsten 8 Stunden (32 Intervalle). Steigend wenn Zukunft >5% höher, fallend wenn >5% niedriger, sonst stabil.",
|
||||
"usage_tips": "Relative Optimierung: Nachtplanung. 'fallend' bedeutet Warten auf Nacht lohnt sich (>5% günstiger). Funktioniert ganzjährig ohne manuelle Schwellenwert-Anpassungen. Starte wenn 'stabil' oder 'steigend'."
|
||||
"price_outlook_8h": {
|
||||
"description": "Preisausblick für die nächsten 8 Stunden",
|
||||
"long_description": "Vergleicht deinen aktuellen Preis mit dem Durchschnitt aller Intervalle der nächsten 8 Stunden (32 Intervalle). 'steigend' = aktueller Preis liegt unter dem 8h-Fenster-Durchschnitt; 'fallend' = Fenster-Durchschnitt ist günstiger als jetzt. Steigend/fallend ab ±3%, stark ab ±9% (konfigurierbar, volatilitätsadaptiv).",
|
||||
"usage_tips": "Nachtplanung: 'steigend' = heute Nacht/morgen wird im Schnitt teurer, Strom jetzt nutzen. 'fallend' = Nachtpreise werden im Schnitt günstiger, Warten lohnt sich."
|
||||
},
|
||||
"price_trend_12h": {
|
||||
"description": "Preistrend für die nächsten 12 Stunden",
|
||||
"long_description": "Vergleicht aktuellen Intervallpreis mit Durchschnitt der nächsten 12 Stunden (48 Intervalle). Steigend wenn Zukunft >5% höher, fallend wenn >5% niedriger, sonst stabil.",
|
||||
"usage_tips": "Relative Optimierung: Langfristige strategische Entscheidungen. 'fallend' = deutlich bessere Preise kommen heute Nacht/morgen. Findet optimales Timing in jeder Marktsituation. Am besten kombiniert mit avg-Sensor Preisobergrenze."
|
||||
"price_outlook_12h": {
|
||||
"description": "Preisausblick für die nächsten 12 Stunden",
|
||||
"long_description": "Vergleicht deinen aktuellen Preis mit dem Durchschnitt aller Intervalle der nächsten 12 Stunden (48 Intervalle). 'steigend' = aktueller Preis liegt unter dem 12h-Fenster-Durchschnitt; 'fallend' = Fenster-Durchschnitt ist günstiger als jetzt. Steigend/fallend ab ±3%, stark ab ±9% (konfigurierbar, volatilitätsadaptiv).",
|
||||
"usage_tips": "Strategische Entscheidungen: 'steigend' = du bist an einem Tiefpunkt relativ zu den nächsten 12h, guter Zeitpunkt für stromintensive Aufgaben. 'fallend' = deutlich bessere Preise kommen im Schnitt, warte wenn möglich."
|
||||
},
|
||||
"price_trajectory_2h": {
|
||||
"description": "Preisverlauf innerhalb des nächsten 2-Stunden-Fensters",
|
||||
"long_description": "Compares the average of the first hour (4 intervals) with the average of the second hour (4 intervals) within the next 2-hour window. 'rising' = second half more expensive than first half — prices are climbing within the window. 'falling' = second half cheaper — prices are dropping within the window. Rising/falling at ±3%, strongly at ±9% (configurable, volatility-adaptive).",
|
||||
"usage_tips": "At a price minimum: price_outlook_2h may show 'falling' (window average is below current), but price_trajectory_2h shows 'rising' (second half more expensive than first) — revealing the upcoming reversal. Power combination: 'outlook: falling + trajectory: rising' = you're AT the minimum, act now."
|
||||
},
|
||||
"price_trajectory_3h": {
|
||||
"description": "Preisverlauf innerhalb des nächsten 3-Stunden-Fensters",
|
||||
"long_description": "Compares the average of the first 1.5 hours with the average of the second 1.5 hours within the next 3-hour window. 'rising' = prices are climbing over the 3h window; 'falling' = prices are dropping. Reveals the direction of price movement independent of the current price level. Rising/falling at ±3%, strongly at ±9% (configurable, volatility-adaptive).",
|
||||
"usage_tips": "Appliance timing: 'outlook: strongly_falling + trajectory: rising' = you're at or past the minimum, the window average is low but prices are already recovering — start now."
|
||||
},
|
||||
"price_trajectory_4h": {
|
||||
"description": "Preisverlauf innerhalb des nächsten 4-Stunden-Fensters",
|
||||
"long_description": "Compares the average of the first 2 hours with the average of the second 2 hours within the next 4-hour window. 'rising' = prices are climbing over the 4h window; 'falling' = prices are dropping. Complementary to price_outlook_4h: outlook answers 'is NOW cheap vs the window average?', trajectory answers 'are prices rising or falling within the window?'. Rising/falling at ±3%, strongly at ±9% (configurable, volatility-adaptive).",
|
||||
"usage_tips": "Heat pump charging: 'rising' = first half window is cheaper, charge now before prices climb. 'falling' = second half window is cheaper, wait if you can. Combine with outlook: if both rising, very strong signal to act now."
|
||||
},
|
||||
"price_trajectory_5h": {
|
||||
"description": "Preisverlauf innerhalb des nächsten 5-Stunden-Fensters",
|
||||
"long_description": "Compares the average of the first 2.5 hours with the average of the second 2.5 hours within the next 5-hour window. 'rising' = prices are climbing over the 5h window; 'falling' = prices are dropping. Rising/falling at ±3%, strongly at ±9% (configurable, volatility-adaptive).",
|
||||
"usage_tips": "Eco/long cycles: 'outlook: rising + trajectory: rising' = clear signal to start now. 'outlook: falling + trajectory: rising' = you're near the bottom, good time to start before costs climb."
|
||||
},
|
||||
"price_trajectory_6h": {
|
||||
"description": "Preisverlauf innerhalb des nächsten 6-Stunden-Fensters",
|
||||
"long_description": "Compares the average of the first 3 hours with the average of the second 3 hours within the next 6-hour window. 'rising' = prices are climbing over the 6h window; 'falling' = prices are dropping. Rising/falling at ±3%, strongly at ±9% (configurable, volatility-adaptive).",
|
||||
"usage_tips": "Evening/overnight planning: 'falling' at evening peak = overnight will be cheaper, postpone. 'rising' in the morning = current morning prices are the day's low — good time for high consumption."
|
||||
},
|
||||
"price_trajectory_8h": {
|
||||
"description": "Preisverlauf innerhalb des nächsten 8-Stunden-Fensters",
|
||||
"long_description": "Compares the average of the first 4 hours with the average of the second 4 hours within the next 8-hour window. 'rising' = prices are climbing over the 8h window; 'falling' = prices are dropping. Rising/falling at ±3%, strongly at ±9% (configurable, volatility-adaptive).",
|
||||
"usage_tips": "Overnight charging: 'rising' during evening = first half of night is cheapest, start charging earlier. 'falling' = second half of night will be cheapest, delay start."
|
||||
},
|
||||
"price_trajectory_12h": {
|
||||
"description": "Preisverlauf innerhalb des nächsten 12-Stunden-Fensters",
|
||||
"long_description": "Compares the average of the first 6 hours with the average of the second 6 hours within the next 12-hour window. 'rising' = prices are climbing over the 12h window; 'falling' = prices are dropping. Rising/falling at ±3%, strongly at ±9% (configurable, volatility-adaptive).",
|
||||
"usage_tips": "Day-ahead planning: 'rising' at midnight = first half of day is cheaper, schedule morning loads. 'falling' = prices drop in the second part of the day, afternoon/evening scheduling is better."
|
||||
},
|
||||
"current_price_trend": {
|
||||
"description": "Aktuelle Preistrend-Richtung und wie lange sie anhält",
|
||||
|
|
@ -274,8 +309,13 @@
|
|||
},
|
||||
"next_price_trend_change": {
|
||||
"description": "Wann die nächste bedeutende Preistrend-Änderung eintreten wird",
|
||||
"long_description": "Scannt die nächsten 24 Stunden (96 Intervalle), um zu finden, wann sich der Preistrend (steigend/fallend/stabil) vom aktuellen Momentum ändern wird. Bestimmt zuerst den aktuellen Trend mit gewichtetem 1h-Rückblick (erkennt laufende Trends), dann findet es die Umkehr. Verwendet volatilitätsadaptive Schwellwerte (3% Momentum-Erkennung, marktangepasster Zukunftsvergleich). Gibt den Zeitstempel zurück, wann die Änderung erwartet wird.",
|
||||
"usage_tips": "Ereignisbasierte Automation: Aktionen WENN Trend wechselt auslösen, nicht IN X Stunden. Beispiel: 'E-Auto laden wenn nächste Trendänderung fallende Preise zeigt' oder 'Spülmaschine vor Preisanstieg starten'. Ergänzt Zeitfenster-Sensoren (price_trend_Xh), die beantworten 'WERDEN Preise in X Stunden höher sein?'"
|
||||
"long_description": "Scannt die nächsten 24 Stunden (96 Intervalle), um zu finden, wann sich die Preistrend-Richtung ändern wird. Nur Richtungswechsel zählen: steigend/stark steigend bilden eine Gruppe, fallend/stark fallend eine andere, stabil ist eigenständig. Ein Wechsel von steigend zu stark steigend ist KEIN Trendwechsel. Verwendet volatilitätsadaptive Schwellwerte (Standard: ±3%/±9%) mit Hysterese (Standard: 3 aufeinanderfolgende Intervalle). Gibt den Zeitstempel zurück, wann die Änderung erwartet wird.\n\nWICHTIG — Wie die Erkennung funktioniert: Bei jedem zukünftigen Intervall vergleicht der Sensor den Preis dieses Intervalls mit dem DURCHSCHNITT der folgenden 3 Stunden (dem 3h-Vorschau-Mittelwert). Das bedeutet: Der Sensor erkennt, wann die durchschnittlichen Kosten der nächsten 3 Stunden die Richtung gewechselt haben — nicht wann das exakte Preisminimum oder -maximum erreicht ist.\n\nAn V-förmigen Preistagen: Beim Preisrückgang in Richtung eines Minimums beginnt das 3h-Vorschaufenster bereits Preise der steigenden Flanke einzubeziehen, bevor das eigentliche Minimum erreicht ist. Sobald diese steigenden Preise den 3h-Mittelwert über den aktuellen Preis treiben, meldet der Sensor 'Trend ändert sich jetzt'. Das passiert typischerweise 30–60 Minuten vor dem exakten Preisminimum. Dies ist beabsichtigt — der Sensor beantwortet 'wann ändert sich die grundlegende RICHTUNG?' und nicht 'wann ist der exakte Wendepunkt?'.",
|
||||
"usage_tips": "Ereignisbasierte Automation: Aktionen WENN Trend wechselt auslösen, nicht IN X Stunden. Beispiel: 'E-Auto laden wenn nächste Trendänderung fallende Preise zeigt' oder 'Spülmaschine vor Preisanstieg starten'. Ergänzt Zeitfenster-Sensoren (price_trend_Xh), die beantworten 'WERDEN Preise in X Stunden höher sein?'\n\nHinweis: An stark V-förmigen Preistagen kann dieser Sensor 30–60 Minuten vor dem exakten Preisminimum auslösen. Wenn du einen präzisen Wendepunkt benötigst, vergleiche ihn mit dem Start der Günstigsten-Preis-Periode — diese startet beim eigentlichen günstigsten Fenster. Dieser Sensor eignet sich besser für Automations-Trigger nach dem Motto 'auf eine Änderung vorbereiten', wo eine kurze Frühwarnung akzeptabel ist."
|
||||
},
|
||||
"next_price_trend_change_in": {
|
||||
"description": "Zeit bis zur nächsten Preistrend-Änderung",
|
||||
"long_description": "Zeigt an, wie lange es bis zur nächsten bedeutenden Preistrend-Änderung dauert. Der Wert wird in Stunden angezeigt (z.B. 2,25 h) für Dashboards. Teilt die gleiche Analyse wie der Zeitstempel-Sensor 'Nächste Trendänderung', stellt sie aber als Countdown-Dauer dar. Aktualisiert sich jede Minute für präzise Countdowns. Zeigt 'Unbekannt' wenn keine Trendänderung in den nächsten 24 Stunden erwartet wird. Siehe 'Nächste Preistrend-Änderung' für eine Erklärung des 3h-Vorschau-Erkennungsmechanismus und seinem Verhalten an V-förmigen Preistagen.",
|
||||
"usage_tips": "Dashboard-Countdown: Zeige 'Trendänderung in 1,5 h' als Live-Countdown. Für Automationen: 'Wenn next_price_trend_change_in < 0,25 (15 Min), auf Preisrichtungswechsel vorbereiten'. Ergänzt 'Nächste Trendänderung' (Zeitstempel) — verwende den Zeitstempel für 'WANN' und diesen Sensor für 'WIE LANGE'."
|
||||
},
|
||||
"daily_rating": {
|
||||
"description": "Wie sich die heutigen Preise mit historischen Daten vergleichen",
|
||||
|
|
@ -446,6 +486,81 @@
|
|||
"long_description": "Zeigt, ob dein Tibber-Abonnement derzeit aktiv ist, beendet wurde oder auf Aktivierung wartet. Ein Status 'Aktiv' bedeutet, dass du aktiv Strom über Tibber beziehst.",
|
||||
"usage_tips": "Nutze dies zur Überwachung deines Abonnementstatus. Richte Benachrichtigungen ein, wenn sich der Status von 'Aktiv' ändert, um einen unterbrechungsfreien Service sicherzustellen."
|
||||
},
|
||||
"day_pattern_yesterday": {
|
||||
"description": "Erkanntes Preismuster der gestrigen Strompreise",
|
||||
"long_description": "Klassifiziert gestern in ein Preismuster: Tal (günstig in der Mitte), Gipfel (teuer in der Mitte), Doppeltal (zwei günstige Perioden), Doppelgipfel (zwei teure Perioden), Flach (geringe Variation), Steigend, Fallend oder Gemischt. Die Konfidenz- und CV-Attribute zeigen, wie verlässlich das Muster erkannt wurde.",
|
||||
"usage_tips": "Nutze das gestrige Muster für Automationen: Ein Tal-Tag wiederholt sich oft am nächsten Tag und deutet darauf hin, dass Verbraucher auf die günstigen Mittagsstunden verschoben werden sollten."
|
||||
},
|
||||
"day_pattern_today": {
|
||||
"description": "Erkanntes Preismuster der heutigen Strompreise",
|
||||
"long_description": "Klassifiziert heute in ein Preismuster: Tal (günstig mittags), Gipfel (teuer mittags), Doppeltal (W-Form), Doppelgipfel (M-Form), Flach, Steigend, Fallend oder Gemischt. Attribute enthalten Konfidenz (0–1), Variationskoeffizient, Knickpunktzeiten und Tagessegmente.",
|
||||
"usage_tips": "Nutze das Tagesmuster, um Verbraucher zu verschieben. Tal-Tag: Spülmaschine, Waschmaschine oder E-Auto-Laden in die günstige Mittagszeit legen. Gipfel-Tag: früh morgens oder spät abends waschen. Die Attribute valley_start und valley_end ermöglichen minutengenaue Automationen."
|
||||
},
|
||||
"day_pattern_tomorrow": {
|
||||
"description": "Erkanntes Preismuster der morgigen Strompreise",
|
||||
"long_description": "Klassifiziert morgen (sobald Daten verfügbar sind, typisch nach 13 Uhr) in ein Preismuster mit demselben Algorithmus wie heute. Die Attribute valley_start/valley_end oder peak_start/peak_end geben Knickpunktzeiten für das primäre Extremum an.",
|
||||
"usage_tips": "Richte Abendautomationen ein, die das morgige Muster lesen und Wärmepumpe, Autolader oder Warmwasserbereiter für den nächsten Tag vorkonfigurieren. Kombiniere mit dem tomorrow_data_available Binärsensor."
|
||||
},
|
||||
"current_price_phase": {
|
||||
"description": "Ob die Strompreise aktuell steigen, fallen oder stabil sind – innerhalb der tageszeitlichen Preisform",
|
||||
"long_description": "Zeigt die Preisbewegungsrichtung zum aktuellen Zeitpunkt, indem das aktive monotone Segment der heutigen Preiskurve ermittelt wird. Der Tagesverlauf wird in aufeinanderfolgende steigende, fallende oder flache Abschnitte (Phasen) unterteilt. Dieser Sensor zeigt, in welcher Phase du dich gerade befindest. Attribute: Startzeit und Endzeit der Phase, Preisspanne (min/max/mean), Position im Tagesverlauf (segment_index und segment_count) sowie die vollständige Liste aller heutigen Phasen (all_segments). Aktualisierung alle 15 Minuten.",
|
||||
"usage_tips": "Nutzen in Automationen: 'Wenn current_price_phase = fallend, flexible Lasten verschieben, bis der Preis seinen Tiefpunkt erreicht hat'. Kombiniere mit dem Sensor Heutiges Preismuster, um sowohl die Gesamtform des Tages als auch deine aktuelle Position darin zu sehen. Prüfe segment_index und segment_count: z. B. segment_index=0 und Phase=fallend bedeutet, die Preise fallen seit Mitternacht. Nutze all_segments in Templates oder Dashboards, um den vollständigen Tagesverlauf anzuzeigen."
|
||||
},
|
||||
"next_price_phase": {
|
||||
"description": "Die nächste tageszeitliche Preisphase – was nach der aktuellen Preisbewegung kommt",
|
||||
"long_description": "Zeigt die monotone Preisphase, die nach der aktuell aktiven Phase folgt. Das Attribut start zeigt genau, wann die nächste Phase beginnt – ideal für zeitgenaue Automationen. Wenn die aktuelle Phase die letzte des Tages ist (z.B. der abendliche Abwärtstrend), wird dieser Sensor nicht verfügbar. Attribute: start (wann sie beginnt), end, Preisspanne (min/max/mean), segment_index, segment_count. Aktualisierung alle 15 Minuten.",
|
||||
"usage_tips": "Nutzen in Automationen: 'Wenn next_price_phase = steigend und next_price_phase.start in weniger als 1 Stunde, Waschmaschine jetzt starten'. Oder kombiniere mit current_price_phase: 'Wenn current_price_phase = fallend und next_price_phase = flach, nähern wir uns dem Tagestiefpunkt – guter Zeitpunkt für flexible Lasten'. Das Attribut start ist besonders wertvoll: Automationen können exakt dann ausgelöst werden, wenn die nächste Phase beginnt."
|
||||
},
|
||||
"current_price_phase_end_time": {
|
||||
"description": "When the current intra-day price phase ends",
|
||||
"long_description": "Shows the exact timestamp when the currently active rising, falling, or flat price phase will end and transition to the next phase.",
|
||||
"usage_tips": "Use in automations to schedule tasks that must finish before prices change."
|
||||
},
|
||||
"current_price_phase_remaining_minutes": {
|
||||
"description": "Minutes remaining in the current price phase",
|
||||
"long_description": "Shows how many minutes are left in the current intra-day price phase. Updates every minute.",
|
||||
"usage_tips": "Use in automations: 'If current_price_phase = falling and remaining < 30, start the dishwasher now'."
|
||||
},
|
||||
"current_price_phase_duration": {
|
||||
"description": "Total duration of the current price phase",
|
||||
"long_description": "Shows the total length of the currently active price phase in hours.",
|
||||
"usage_tips": "Combine with remaining minutes to understand how far through the phase you are."
|
||||
},
|
||||
"current_price_phase_progress": {
|
||||
"description": "How far through the current price phase we are",
|
||||
"long_description": "Shows the percentage of the current intra-day price phase that has elapsed (0–100%). Updates every minute.",
|
||||
"usage_tips": "Use in dashboard cards to display a visual progress bar for the current price phase."
|
||||
},
|
||||
"next_rising_phase_start_time": {
|
||||
"description": "When the next rising price phase begins",
|
||||
"long_description": "Shows the timestamp of the next upcoming rising price segment across today's remaining phases and tomorrow's phases.",
|
||||
"usage_tips": "Use to schedule loads before prices start rising again."
|
||||
},
|
||||
"next_falling_phase_start_time": {
|
||||
"description": "When the next falling price phase begins",
|
||||
"long_description": "Shows the timestamp of the next upcoming falling price segment across today's remaining phases and tomorrow's phases.",
|
||||
"usage_tips": "Use to delay flexible loads until the next price drop starts."
|
||||
},
|
||||
"next_flat_phase_start_time": {
|
||||
"description": "When the next flat (stable) price phase begins",
|
||||
"long_description": "Shows the timestamp of the next upcoming flat price segment across today's remaining phases and tomorrow's phases.",
|
||||
"usage_tips": "Use for scheduling loads that need predictable costs over time."
|
||||
},
|
||||
"next_rising_phase_in_minutes": {
|
||||
"description": "Minutes until the next rising price phase begins",
|
||||
"long_description": "Shows how many minutes until the next rising price phase starts. Updates every minute.",
|
||||
"usage_tips": "Use in countdown automations to alert before the next price rise."
|
||||
},
|
||||
"next_falling_phase_in_minutes": {
|
||||
"description": "Minutes until the next falling price phase begins",
|
||||
"long_description": "Shows how many minutes until the next falling price phase starts. Updates every minute.",
|
||||
"usage_tips": "Use to time flexible loads: delay until the upcoming price drop."
|
||||
},
|
||||
"next_flat_phase_in_minutes": {
|
||||
"description": "Minutes until the next flat (stable) price phase begins",
|
||||
"long_description": "Shows how many minutes until the next flat price phase starts. Updates every minute.",
|
||||
"usage_tips": "Use to anticipate price stabilisation after a volatile phase."
|
||||
},
|
||||
"chart_data_export": {
|
||||
"description": "Datenexport für Dashboard-Integrationen",
|
||||
"long_description": "Dieser Sensor ruft den get_chartdata-Service mit deiner konfigurierten YAML-Konfiguration auf und stellt das Ergebnis als Entity-Attribute bereit. Der Status zeigt 'ready' wenn Daten verfügbar sind, 'error' bei Fehlern, oder 'pending' vor dem ersten Aufruf. Perfekt für Dashboard-Integrationen wie ApexCharts, die Preisdaten aus Entity-Attributen lesen.",
|
||||
|
|
@ -455,6 +570,61 @@
|
|||
"description": "Leichtgewichtige Metadaten für Diagrammkonfiguration",
|
||||
"long_description": "Liefert wesentliche Diagrammkonfigurationswerte als Sensor-Attribute. Nützlich für jede Diagrammkarte, die Y-Achsen-Grenzen benötigt. Der Sensor ruft get_chartdata im Nur-Metadaten-Modus auf (keine Datenverarbeitung) und extrahiert: yaxis_min, yaxis_max (vorgeschlagener Y-Achsenbereich für optimale Skalierung). Der Status spiegelt das Service-Call-Ergebnis wider: 'ready' bei Erfolg, 'error' bei Fehler, 'pending' während der Initialisierung.",
|
||||
"usage_tips": "Konfiguriere über configuration.yaml unter tibber_prices.chart_metadata_config (optional: day, subunit_currency, resolution). Der Sensor aktualisiert sich automatisch bei Preisdatenänderungen. Greife auf Metadaten aus Attributen zu: yaxis_min, yaxis_max. Verwende mit config-template-card oder jedem Tool, das Entity-Attribute liest - perfekt für dynamische Diagrammkonfiguration ohne manuelle Berechnungen."
|
||||
},
|
||||
"current_interval_price_rank_today": {
|
||||
"description": "Position des aktuellen Intervallpreises in der heutigen Rangliste — Perzentilrang (0 % = günstigster Moment)",
|
||||
"long_description": "Zeigt, wie günstig oder teuer der aktuelle Viertelstunden-Intervallpreis im Vergleich zu allen 96 heutigen Slots ist. 0 % bedeutet: Dieser Moment ist der günstigste des Tages. 50 % bedeutet: Die Hälfte der Slots ist günstiger. ca. 99 % bedeutet: Dieser Slot ist der teuerste des Tages. Formel (Perzentilrang): Anzahl günstigerer Slots ÷ Gesamtanzahl × 100. Attribute: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Ideal für Automationen: 'Wenn current_interval_price_rank_today < 25, Spülmaschine starten' (günstigstes Viertel des Tages). Oder 'Wenn current_interval_price_rank_today > 75, Wärmepumpe pausieren'. Ein Wert von 0 garantiert den günstigsten Slot des Tages."
|
||||
},
|
||||
"current_interval_price_rank_tomorrow": {
|
||||
"description": "Perzentilrang des aktuellen Intervallpreises in der morgigen Rangliste (0 % = günstigster von morgen)",
|
||||
"long_description": "Zeigt, wie der aktuelle Intervallpreis im Vergleich zu allen 96 morgigen Viertelstunden-Slots abschneidet. Nützlich, um zu entscheiden, ob man bis morgen warten soll. 0 % bedeutet: Der aktuelle Preis ist günstiger als jeder morgige Slot. Gibt 'Unbekannt' zurück, bis die morgigen Daten verfügbar sind (typischerweise nach 13:00 Uhr). Attribute: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Warten lohnt sich? 'Wenn current_interval_price_rank_tomorrow < 10, gibt es morgen noch günstigere Slots — Aufgabe verschieben'. Am besten mit einem Binärsensor kombinieren."
|
||||
},
|
||||
"current_interval_price_rank_today_tomorrow": {
|
||||
"description": "Perzentilrang des aktuellen Intervallpreises über heute+morgen zusammen (0 % = günstigstes des Zweitages-Fensters)",
|
||||
"long_description": "Zeigt, wie günstig oder teuer der aktuelle Intervallpreis im Vergleich zu allen Slots über heute und morgen zusammen ist (bis zu 192 Viertelstunden-Slots). Fällt auf nur heute zurück, wenn morgige Daten noch nicht verfügbar sind. 0 % = günstigster Slot des kombinierten Zweitages-Fensters. Attribute: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Das breiteste Signal für 'Ist jetzt ein guter Zeitpunkt?'. Verwende 'Wenn current_interval_price_rank_today_tomorrow < 20, energieintensive Aufgabe jetzt starten'."
|
||||
},
|
||||
"next_interval_price_rank_today": {
|
||||
"description": "Perzentilrang des nächsten Intervallpreises in der heutigen Rangliste (0 % = günstigster Moment heute)",
|
||||
"long_description": "Zeigt den Perzentilrang des nächsten Viertelstunden-Intervalls innerhalb der 96 heutigen Slots. Ermöglicht einen Blick voraus, bevor das nächste Intervall beginnt. Attribute: `next_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Für Vorbereitung: 'Wenn next_interval_price_rank_today < 15, jetzt vorheizen, damit das Gerät im nächsten günstigen Slot läuft'."
|
||||
},
|
||||
"next_interval_price_rank_today_tomorrow": {
|
||||
"description": "Perzentilrang des nächsten Intervallpreises über heute+morgen zusammen (0 % = günstigstes des Zweitages-Fensters)",
|
||||
"long_description": "Zeigt den Perzentilrang des nächsten Viertelstunden-Intervalls innerhalb des kombinierten heute+morgen-Pools (bis zu 192 Slots). Fällt auf nur heute zurück, wenn morgige Daten nicht verfügbar sind. Attribute: `next_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Weitester Vorausblick: 'Wenn next_interval_price_rank_today_tomorrow < 10, ist das nächste Intervall eines der günstigsten im Zweitages-Fenster'."
|
||||
},
|
||||
"previous_interval_price_rank_today": {
|
||||
"description": "Perzentilrang des vorherigen Intervallpreises in der heutigen Rangliste (0 % = günstigster Moment heute)",
|
||||
"long_description": "Zeigt den Perzentilrang des gerade beendeten Viertelstunden-Intervalls innerhalb der 96 heutigen Slots. Nützlich für Protokollierung. Attribute: `previous_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Für retrospektive Automationen: 'Preisniveau des vorherigen Intervalls für Energieberichte aufzeichnen'."
|
||||
},
|
||||
"previous_interval_price_rank_today_tomorrow": {
|
||||
"description": "Perzentilrang des vorherigen Intervallpreises über heute+morgen zusammen (0 % = günstigstes des Zweitages-Fensters)",
|
||||
"long_description": "Zeigt den Perzentilrang des gerade beendeten Viertelstunden-Intervalls innerhalb des kombinierten heute+morgen-Pools (bis zu 192 Slots). Fällt auf nur heute zurück. Attribute: `previous_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Für retrospektive Vergleiche über ein Zweitages-Fenster."
|
||||
},
|
||||
"current_hour_price_rank_today": {
|
||||
"description": "Perzentilrang des gleitenden Stunden-Durchschnittspreises in der heutigen Rangliste (0 % = günstigste Stunde heute)",
|
||||
"long_description": "Zeigt, wo der gleitende 5-Intervall-Durchschnitt (2 Intervalle vor + aktuell + 2 danach, ca. 1 Stunde) in der heutigen Preisrangliste liegt. Glättet kurze Preisspitzen für eine breitere Einschätzung. Attribute: `current_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Für Aufgaben, die ca. eine Stunde dauern: 'Wenn current_hour_price_rank_today < 20, ist jetzt eine günstige Stunde — Waschmaschine starten'."
|
||||
},
|
||||
"current_hour_price_rank_today_tomorrow": {
|
||||
"description": "Gleitender Stunden-Durchschnittspreisrang über heute+morgen zusammen (0 % = günstigste Stunde im Zweitages-Fenster)",
|
||||
"long_description": "Zeigt, wo der gleitende 5-Intervall-Durchschnitt (±2 Intervalle, ca. 1 Stunde) in der kombinierten heute+morgen-Rangliste liegt (bis zu 192 Slots). Fällt auf nur heute zurück. Attribute: `current_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Weitestes Stundensignal: 'Wenn current_hour_price_rank_today_tomorrow < 15, ist dies eine der günstigsten Stunden im Zweitages-Fenster'."
|
||||
},
|
||||
"next_hour_price_rank_today": {
|
||||
"description": "Perzentilrang des nächsten gleitenden Stunden-Durchschnittspreises in der heutigen Rangliste (0 % = günstigste Stunde heute)",
|
||||
"long_description": "Zeigt, wo der auf das nächste Intervall zentrierte gleitende 5-Intervall-Durchschnitt in der heutigen Preisrangliste liegt. Ermöglicht Planung eine Stunde im Voraus. Attribute: `next_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Eine Stunde vorausplanen: 'Wenn next_hour_price_rank_today < 20, ist die kommende Stunde günstig — Aufgabe jetzt starten'."
|
||||
},
|
||||
"next_hour_price_rank_today_tomorrow": {
|
||||
"description": "Nächster gleitender Stunden-Durchschnittspreisrang über heute+morgen zusammen (0 % = günstigste Stunde im Zweitages-Fenster)",
|
||||
"long_description": "Zeigt, wo der auf das nächste Intervall zentrierte gleitende 5-Intervall-Durchschnitt in der kombinierten heute+morgen-Rangliste liegt (bis zu 192 Slots). Fällt auf nur heute zurück. Attribute: `next_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Weitester Stundenvorausblick: 'Wenn next_hour_price_rank_today_tomorrow < 10, ist die kommende Stunde eine der günstigsten im Zweitages-Fenster'."
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
|
|
@ -473,6 +643,21 @@
|
|||
"long_description": "Wird aktiviert, wenn der aktuelle Preis in den unteren 20% der heutigen Preise liegt",
|
||||
"usage_tips": "Nutze dies, um Geräte mit hohem Verbrauch während der günstigsten Intervalle zu betreiben"
|
||||
},
|
||||
"in_rising_price_phase": {
|
||||
"description": "Whether prices are currently in a rising phase",
|
||||
"long_description": "Turns ON when the current intra-day price phase is rising.",
|
||||
"usage_tips": "Use in automations to delay or avoid running flexible loads during rising prices."
|
||||
},
|
||||
"in_falling_price_phase": {
|
||||
"description": "Whether prices are currently in a falling phase",
|
||||
"long_description": "Turns ON when the current intra-day price phase is falling.",
|
||||
"usage_tips": "Use in automations to take advantage of falling prices for flexible loads."
|
||||
},
|
||||
"in_flat_price_phase": {
|
||||
"description": "Whether prices are currently in a flat (stable) phase",
|
||||
"long_description": "Turns ON when the current intra-day price phase is flat and prices are relatively stable.",
|
||||
"usage_tips": "Use for loads that prefer price stability rather than the lowest price."
|
||||
},
|
||||
"connection": {
|
||||
"description": "Ob die Verbindung zur Tibber API funktioniert",
|
||||
"long_description": "Zeigt an, ob die Integration erfolgreich eine Verbindung zur Tibber API herstellen kann",
|
||||
|
|
@ -501,7 +686,7 @@
|
|||
"usage_tips": "Erhöhe den Wert, wenn du strengere Bestpreis-Kriterien möchtest. Verringere ihn, wenn zu wenige Perioden erkannt werden."
|
||||
},
|
||||
"best_price_min_period_length_override": {
|
||||
"description": "Minimale Periodenl\u00e4nge in 15-Minuten-Intervallen. Perioden kürzer als diese werden nicht gemeldet. Beispiel: 2 = mindestens 30 Minuten.",
|
||||
"description": "Minimale Periodenlänge in 15-Minuten-Intervallen. Perioden kürzer als diese werden nicht gemeldet. Beispiel: 2 = mindestens 30 Minuten.",
|
||||
"long_description": "Wenn diese Entität aktiviert ist, überschreibt ihr Wert die Einstellung 'Mindestperiodenlänge' aus dem Optionen-Dialog für die Bestpreis-Periodenberechnung.",
|
||||
"usage_tips": "Passe an die typische Laufzeit deiner Geräte an: 2 (30 Min) für Schnellprogramme, 4-8 (1-2 Std) für normale Zyklen, 8+ für lange ECO-Programme."
|
||||
},
|
||||
|
|
@ -531,7 +716,7 @@
|
|||
"usage_tips": "Erhöhe den Wert, um nur extreme Preisspitzen zu erfassen. Verringere ihn, um mehr Hochpreiszeiten einzubeziehen."
|
||||
},
|
||||
"peak_price_min_period_length_override": {
|
||||
"description": "Minimale Periodenl\u00e4nge in 15-Minuten-Intervallen für Spitzenpreise. Kürzere Preisspitzen werden nicht als Perioden gemeldet.",
|
||||
"description": "Minimale Periodenlänge in 15-Minuten-Intervallen für Spitzenpreise. Kürzere Preisspitzen werden nicht als Perioden gemeldet.",
|
||||
"long_description": "Wenn diese Entität aktiviert ist, überschreibt ihr Wert die Einstellung 'Mindestperiodenlänge' aus dem Optionen-Dialog für die Spitzenpreis-Periodenberechnung.",
|
||||
"usage_tips": "Kürzere Werte erfassen kurze Preisspitzen. Längere Werte fokussieren auf anhaltende Hochpreisphasen."
|
||||
},
|
||||
|
|
|
|||
|
|
@ -227,45 +227,80 @@
|
|||
"long_description": "Shows the average price for the next 48 intervals (12 hours) starting from the next 15-minute interval.",
|
||||
"usage_tips": "Absolute price threshold: Strategic decisions with price caps. Only proceed if 12h average is below your maximum acceptable price. Good for postponable large loads."
|
||||
},
|
||||
"price_trend_1h": {
|
||||
"description": "Price trend for the next hour",
|
||||
"long_description": "Compares current interval price with average of next 1 hour (4 intervals). Rising if future is >5% higher, falling if >5% lower, stable otherwise.",
|
||||
"usage_tips": "Relative optimization: 'falling' = wait, prices dropping. 'rising' = act now or you'll pay more. 'stable' = price doesn't matter much now. Works independent of absolute price level."
|
||||
"price_outlook_1h": {
|
||||
"description": "Price outlook for the next hour",
|
||||
"long_description": "Compares your current price with the average of all intervals in the next hour (4 intervals). All outlook sensors (1h–12h) share the same base: your current price — they differ only in window size. Larger windows include more future hours and smooth out short-term spikes. Rising/falling at ±3%, strongly at ±9% (configurable, volatility-adaptive).",
|
||||
"usage_tips": "Decision guide: 'rising' = ACT NOW, your current price is cheaper than the next hour average. 'falling' = WAIT, the next hour average is cheaper than now. 'stable' = timing doesn't matter. Common misconception: 'rising' does NOT mean 'too late' — it means right now is a good price! Works regardless of absolute price level."
|
||||
},
|
||||
"price_trend_2h": {
|
||||
"description": "Price trend for the next 2 hours",
|
||||
"long_description": "Compares current interval price with average of next 2 hours (8 intervals). Rising if future is >5% higher, falling if >5% lower, stable otherwise.",
|
||||
"usage_tips": "Relative optimization: Ideal for appliances. 'falling' means better prices coming in 2h - postpone if possible. Finds best timing within your available window, regardless of season."
|
||||
"price_outlook_2h": {
|
||||
"description": "Price outlook for the next 2 hours",
|
||||
"long_description": "Compares your current price with the average of all intervals in the next 2 hours (8 intervals). 'rising' = current price is below the 2h window average; 'falling' = window average is cheaper than now. Rising/falling at ±3%, strongly at ±9% (configurable, volatility-adaptive).",
|
||||
"usage_tips": "Appliances: 'rising' = start now, you're at a good price relative to the next 2h. 'falling' = better prices ahead, postpone if possible. 'stable' = doesn't matter, start when convenient. Pair with price_trajectory_2h to distinguish 'falling now, rising later' from 'falling throughout'."
|
||||
},
|
||||
"price_trend_3h": {
|
||||
"description": "Price trend for the next 3 hours",
|
||||
"long_description": "Compares current interval price with average of next 3 hours (12 intervals). Rising if future is >5% higher, falling if >5% lower, stable otherwise.",
|
||||
"usage_tips": "Relative optimization: For Eco programs. 'falling' means prices dropping >5% - worth waiting. Works in any season. Combine with avg sensor for price cap: only when avg < your limit AND trend not 'falling'."
|
||||
"price_outlook_3h": {
|
||||
"description": "Price outlook for the next 3 hours",
|
||||
"long_description": "Compares your current price with the average of all intervals in the next 3 hours (12 intervals). 'rising' = current price is below the 3h window average; 'falling' = window average is cheaper than now. Rising/falling at ±3%, strongly at ±9% (configurable, volatility-adaptive).",
|
||||
"usage_tips": "Eco programs: 'rising' = start the eco cycle now, prices go up from here on average. 'falling' = wait, cheaper overall window coming. Combine with avg sensor for price cap: start when outlook is 'rising' or 'stable' AND avg < your limit."
|
||||
},
|
||||
"price_trend_4h": {
|
||||
"description": "Price trend for the next 4 hours",
|
||||
"long_description": "Compares current interval price with average of next 4 hours (16 intervals). Rising if future is >5% higher, falling if >5% lower, stable otherwise.",
|
||||
"usage_tips": "Relative optimization: Heat pump/battery decisions. 'falling' means better charging window coming. Always finds relative best time - whether prices are 10 cents or 50 cents. Use avg sensor for absolute limit."
|
||||
"price_outlook_4h": {
|
||||
"description": "Price outlook for the next 4 hours",
|
||||
"long_description": "Compares your current price with the average of all intervals in the next 4 hours (16 intervals). 'rising' = current price is below the 4h window average; 'falling' = window average is cheaper than now. Rising/falling at ±3%, strongly at ±9% (configurable, volatility-adaptive).",
|
||||
"usage_tips": "Heat pump/battery: 'rising' = charge now, you're at a relative low. 'falling' = wait for better window on average. Important: 'strongly_falling' at a price minimum means the 4h average is much cheaper — but combine with price_trajectory_4h to tell if prices are still dropping or already bouncing back."
|
||||
},
|
||||
"price_trend_5h": {
|
||||
"description": "Price trend for the next 5 hours",
|
||||
"long_description": "Compares current interval price with average of next 5 hours (20 intervals). Rising if future is >5% higher, falling if >5% lower, stable otherwise.",
|
||||
"usage_tips": "Relative optimization: Extended operations. Adapts to market - finds best relative timing in any price environment. 'stable/rising' = good time to start within your planning window."
|
||||
"price_outlook_5h": {
|
||||
"description": "Price outlook for the next 5 hours",
|
||||
"long_description": "Compares your current price with the average of all intervals in the next 5 hours (20 intervals). 'rising' = current price is below the 5h window average; 'falling' = window average is cheaper than now. Rising/falling at ±3%, strongly at ±9% (configurable, volatility-adaptive).",
|
||||
"usage_tips": "Extended operations: 'rising' or 'stable' = good time to start, prices won't be cheaper on average. 'falling' = wait if your schedule allows. Adapts to market conditions — finds best relative timing in any price environment."
|
||||
},
|
||||
"price_trend_6h": {
|
||||
"description": "Price trend for the next 6 hours",
|
||||
"long_description": "Compares current interval price with average of next 6 hours (24 intervals). Rising if future is >5% higher, falling if >5% lower, stable otherwise.",
|
||||
"usage_tips": "Relative optimization: Evening decisions. 'falling' = prices improve significantly if you wait. No fixed thresholds needed - automatically adjusts to winter/summer price levels."
|
||||
"price_outlook_6h": {
|
||||
"description": "Price outlook for the next 6 hours",
|
||||
"long_description": "Compares your current price with the average of all intervals in the next 6 hours (24 intervals). 'rising' = current price is below the 6h window average; 'falling' = window average is cheaper than now. Rising/falling at ±3%, strongly at ±9% (configurable, volatility-adaptive).",
|
||||
"usage_tips": "Evening decisions: 'rising' = use electricity now while it's relatively cheap. 'falling' = evening/night prices will be better on average, wait if possible. Automatically adjusts to winter/summer price levels — no fixed thresholds needed."
|
||||
},
|
||||
"price_trend_8h": {
|
||||
"description": "Price trend for the next 8 hours",
|
||||
"long_description": "Compares current interval price with average of next 8 hours (32 intervals). Rising if future is >5% higher, falling if >5% lower, stable otherwise.",
|
||||
"usage_tips": "Relative optimization: Night planning. 'falling' means waiting for night pays off (>5% cheaper). Works year-round without manual threshold adjustments. Start when 'stable' or 'rising'."
|
||||
"price_outlook_8h": {
|
||||
"description": "Price outlook for the next 8 hours",
|
||||
"long_description": "Compares your current price with the average of all intervals in the next 8 hours (32 intervals). 'rising' = current price is below the 8h window average; 'falling' = window average is cheaper than now. Rising/falling at ±3%, strongly at ±9% (configurable, volatility-adaptive).",
|
||||
"usage_tips": "Night planning: 'rising' = tonight/tomorrow will be more expensive on average, use power now. 'falling' = night prices will be cheaper, worth waiting. 'stable' = start when convenient. Works year-round without manual threshold adjustments."
|
||||
},
|
||||
"price_trend_12h": {
|
||||
"description": "Price trend for the next 12 hours",
|
||||
"long_description": "Compares current interval price with average of next 12 hours (48 intervals). Rising if future is >5% higher, falling if >5% lower, stable otherwise.",
|
||||
"usage_tips": "Relative optimization: Long-term strategic decisions. 'falling' = significantly better prices coming tonight/tomorrow. Finds optimal timing in any market condition. Best combined with avg sensor price cap."
|
||||
"price_outlook_12h": {
|
||||
"description": "Price outlook for the next 12 hours",
|
||||
"long_description": "Compares your current price with the average of all intervals in the next 12 hours (48 intervals). 'rising' = current price is below the 12h window average; 'falling' = window average is cheaper than now. Rising/falling at ±3%, strongly at ±9% (configurable, volatility-adaptive).",
|
||||
"usage_tips": "Strategic decisions: 'rising' = you're at a low point relative to the next 12h, good time for high-consumption tasks. 'falling' = significantly better prices coming on average, wait if possible. Best combined with avg sensor price cap for absolute limits."
|
||||
},
|
||||
"price_trajectory_2h": {
|
||||
"description": "Price direction within the next 2-hour window",
|
||||
"long_description": "Compares the average of the first hour (4 intervals) with the average of the second hour (4 intervals) within the next 2-hour window. 'rising' = second half more expensive than first half — prices are climbing within the window. 'falling' = second half cheaper — prices are dropping within the window. Reveals the direction of price movement inside the outlook window. Rising/falling at ±3%, strongly at ±9% (configurable, volatility-adaptive).",
|
||||
"usage_tips": "At a price minimum: price_outlook_2h may show 'falling' (because window average is below current), but price_trajectory_2h shows 'rising' (second half more expensive than first) — revealing the upcoming reversal. Power combination: 'outlook: falling + trajectory: rising' = you're AT the minimum, act now. 'outlook: falling + trajectory: falling' = prices still dropping, keep waiting."
|
||||
},
|
||||
"price_trajectory_3h": {
|
||||
"description": "Price direction within the next 3-hour window",
|
||||
"long_description": "Compares the average of the first 1.5 hours with the average of the second 1.5 hours within the next 3-hour window. 'rising' = prices are climbing over the 3h window; 'falling' = prices are dropping. Reveals the direction of price movement independent of the current price level. Rising/falling at ±3%, strongly at ±9% (configurable, volatility-adaptive).",
|
||||
"usage_tips": "Appliance timing: 'outlook: strongly_falling + trajectory: rising' = you're at or past the minimum, the window average is low but prices are already recovering — start now. 'outlook: stable + trajectory: rising' = quiet market with an upward drift — no urgency but slight favor to starting now."
|
||||
},
|
||||
"price_trajectory_4h": {
|
||||
"description": "Price direction within the next 4-hour window",
|
||||
"long_description": "Compares the average of the first 2 hours with the average of the second 2 hours within the next 4-hour window. 'rising' = prices are climbing over the 4h window; 'falling' = prices are dropping. Complementary to price_outlook_4h: outlook answers 'is NOW cheap vs the window average?', trajectory answers 'are prices rising or falling within the window?'. Rising/falling at ±3%, strongly at ±9% (configurable, volatility-adaptive).",
|
||||
"usage_tips": "Heat pump charging: 'rising' = first half window is cheaper, charge now before prices climb. 'falling' = second half window is cheaper, wait if you can. 'stable' = prices flat throughout 4h — charge when convenient. Combine with outlook: if both rising, very strong signal to act now."
|
||||
},
|
||||
"price_trajectory_5h": {
|
||||
"description": "Price direction within the next 5-hour window",
|
||||
"long_description": "Compares the average of the first 2.5 hours with the average of the second 2.5 hours within the next 5-hour window. 'rising' = prices are climbing over the 5h window; 'falling' = prices are dropping. Rising/falling at ±3%, strongly at ±9% (configurable, volatility-adaptive).",
|
||||
"usage_tips": "Eco/long cycles: combine both sensors — 'outlook: rising + trajectory: rising' = clear signal to start the long program now. 'outlook: falling + trajectory: rising' = you're near the bottom, good time to start before costs climb. 'outlook: falling + trajectory: falling' = wait, still getting cheaper."
|
||||
},
|
||||
"price_trajectory_6h": {
|
||||
"description": "Price direction within the next 6-hour window",
|
||||
"long_description": "Compares the average of the first 3 hours with the average of the second 3 hours within the next 6-hour window. 'rising' = prices are climbing over the 6h window; 'falling' = prices are dropping. Rising/falling at ±3%, strongly at ±9% (configurable, volatility-adaptive).",
|
||||
"usage_tips": "Evening/overnight planning: 'falling' at evening peak = overnight will be cheaper, postpone. 'rising' in the morning = current morning prices are the day's low — good time for high consumption. Pairs well with daily volatility sensors to decide if 6h window optimization is worthwhile."
|
||||
},
|
||||
"price_trajectory_8h": {
|
||||
"description": "Price direction within the next 8-hour window",
|
||||
"long_description": "Compares the average of the first 4 hours with the average of the second 4 hours within the next 8-hour window. 'rising' = prices are climbing over the 8h window; 'falling' = prices are dropping. Rising/falling at ±3%, strongly at ±9% (configurable, volatility-adaptive).",
|
||||
"usage_tips": "Overnight charging: 'rising' during evening = first half of night is cheapest, start charging earlier. 'falling' = second half of night will be cheapest, delay start. Useful for EV smart charging where you only need half the night's window."
|
||||
},
|
||||
"price_trajectory_12h": {
|
||||
"description": "Price direction within the next 12-hour window",
|
||||
"long_description": "Compares the average of the first 6 hours with the average of the second 6 hours within the next 12-hour window. 'rising' = prices are climbing over the 12h window; 'falling' = prices are dropping. Rising/falling at ±3%, strongly at ±9% (configurable, volatility-adaptive).",
|
||||
"usage_tips": "Day-ahead planning: 'rising' at midnight = first half of day is cheaper, schedule morning loads. 'falling' = prices drop in the second part of the day, afternoon/evening scheduling is better. Useful for large scale decisions: hot water systems, home battery charge scheduling."
|
||||
},
|
||||
"current_price_trend": {
|
||||
"description": "Current price trend direction and how long it will last",
|
||||
|
|
@ -274,8 +309,13 @@
|
|||
},
|
||||
"next_price_trend_change": {
|
||||
"description": "When the next significant price trend change will occur",
|
||||
"long_description": "Scans the next 24 hours (96 intervals) to find when the price trend (rising/falling/stable) will change from the current momentum. First determines current trend using weighted 1h lookback (recognizes ongoing trends), then finds when that trend reverses. Uses volatility-adaptive thresholds (3% momentum detection, market-adjusted future comparison). Returns the timestamp when the change is expected.",
|
||||
"usage_tips": "Event-based automation: Trigger actions WHEN trend changes, not IN X hours. Example: 'Charge EV when next trend change shows falling prices' or 'Run dishwasher before prices start rising'. More accurate than simple future comparison because it knows if you're already in a trend. Complements time-window sensors (price_trend_Xh) which answer 'WILL prices be higher in X hours?'"
|
||||
"long_description": "Scans the next 24 hours (96 intervals) to find when the price trend direction will change. Only direction changes count: rising/strongly_rising are one group, falling/strongly_falling another, stable is its own. A change from rising to strongly_rising is NOT a trend change. Uses volatility-adaptive thresholds (default: ±3%/±9%) with hysteresis (default: 3 consecutive intervals). Returns the timestamp when the change is expected.\n\nIMPORTANT — How the detection works: At each future interval, the sensor compares that interval's price to the AVERAGE of the following 3 hours (the 3h lookahead mean). This means the sensor detects when the average cost of the next 3 hours has already flipped direction — not when the exact price minimum or maximum is reached.\n\nOn V-shaped price days: During a price drop toward a minimum, the 3h lookahead window starts including prices on the rising flank before the actual minimum is reached. Once those rising prices push the 3h average above the current price, the sensor reports 'trend is now changing'. This typically fires 30–60 minutes before the exact price minimum. This is intentional — the sensor answers 'when will the broad DIRECTION change?' rather than 'when is the exact turning point?'.",
|
||||
"usage_tips": "Event-based automation: Trigger actions WHEN trend changes, not IN X hours. Example: 'Charge EV when next trend change shows falling prices' or 'Run dishwasher before prices start rising'. More accurate than simple future comparison because it knows if you're already in a trend. Complements time-window sensors (price_trend_Xh) which answer 'WILL prices be higher in X hours?'\n\nNote: On sharp V-shape days, this sensor may fire 30–60 minutes before the exact price minimum. If you need a precise turning point, compare it to the Best Price period start — the period starts at the actual cheapest window. This sensor is better suited to 'prepare for a change' automation triggers where a brief early warning is acceptable."
|
||||
},
|
||||
"next_price_trend_change_in": {
|
||||
"description": "Time until the next price trend change",
|
||||
"long_description": "Shows how long until the next significant price trend change occurs. The state displays in hours (e.g., 2.25 h) for dashboards. Shares the same analysis as the Next Price Trend Change timestamp sensor but presents it as a countdown duration. Updates every minute for accurate countdowns. Returns unknown when no trend change is expected in the next 24 hours.\n\nSee 'Next Price Trend Change' for an explanation of the 3h-lookahead detection mechanism and its behaviour on V-shaped price days.",
|
||||
"usage_tips": "Dashboard countdown: Show 'Trend changes in 1.5 h' as a live countdown. For automations: 'If next_price_trend_change_in state < 0.25 (15 min), prepare for price direction change'. Pairs with Next Price Trend Change (timestamp) — use the timestamp for 'WHEN' and this sensor for 'HOW LONG'."
|
||||
},
|
||||
"daily_rating": {
|
||||
"description": "How today's prices compare to historical data",
|
||||
|
|
@ -446,6 +486,81 @@
|
|||
"long_description": "Shows whether your Tibber subscription is currently running, has ended, or is pending activation. A status of 'running' means you're actively receiving electricity through Tibber.",
|
||||
"usage_tips": "Use this to monitor your subscription status. Set up alerts if status changes from 'running' to ensure uninterrupted service."
|
||||
},
|
||||
"day_pattern_yesterday": {
|
||||
"description": "Detected price shape of yesterday's electricity prices",
|
||||
"long_description": "Classifies yesterday into a price shape: Valley (cheap in the middle), Peak (expensive in the middle), Double Valley (two cheap periods), Double Peak (two expensive periods), Flat (little variation), Rising, Falling, or Mixed. The confidence and CV attributes indicate how reliably the pattern was detected.",
|
||||
"usage_tips": "Use yesterday's pattern to refine automations: a Valley day often repeats the next day, suggesting you should pre-schedule cheap-hour loads. Pair with the confidence attribute to filter unreliable detections."
|
||||
},
|
||||
"day_pattern_today": {
|
||||
"description": "Detected price shape of today's electricity prices",
|
||||
"long_description": "Classifies today into a price shape: Valley (cheap in the middle of the day), Peak (expensive in the middle), Double Valley (W-shape, two cheap windows), Double Peak (M-shape, two expensive peaks), Flat (prices barely move), Rising (prices climb through the day), Falling (prices drop through the day), or Mixed. Attributes include confidence (0–1), coefficient of variation, knee-point times, and intra-day segments.",
|
||||
"usage_tips": "Use today's pattern to decide when to shift loads. A Valley day means cheap prices around midday — ideal for running the dishwasher, washing machine, or charging the EV. A Peak day means expensive midday — run appliances early morning or late evening. Use valley_start and valley_end attributes to schedule automations precisely."
|
||||
},
|
||||
"day_pattern_tomorrow": {
|
||||
"description": "Detected price shape of tomorrow's electricity prices",
|
||||
"long_description": "Classifies tomorrow (once data is available, typically after 13:00) into a price shape using the same algorithm as today. The valley_start / valley_end or peak_start / peak_end attributes give knee-point times for the primary extremum so you can pre-schedule loads the evening before.",
|
||||
"usage_tips": "Set up evening automations that read tomorrow's pattern and pre-configure heat pump schedules, car charging timers, or water heater settings for the following day. Pair with the tomorrow_data_available binary sensor to trigger the automation only when data is ready."
|
||||
},
|
||||
"current_price_phase": {
|
||||
"description": "Whether electricity prices are currently rising, falling, or flat within today's intra-day price shape",
|
||||
"long_description": "Shows the direction of price movement at the current time by identifying which monotone segment of today's price curve you are in. Today's prices are split into consecutively rising, falling, or flat stretches (phases). This sensor tells you which phase is active right now. Attributes include the phase's start and end times, its price range (min/max/mean), its position among all phases of the day (segment_index and segment_count), and the full list of all today's phases (all_segments). Updates every 15 minutes.",
|
||||
"usage_tips": "Use in automations: 'If current_price_phase = falling, delay flexible loads until prices bottom out'. Pair with the Today's Price Pattern sensor to see both the overall shape of the day and your current position in it. Check segment_index and segment_count to understand how far through the intra-day movement you are — e.g. if segment_index=0 and the phase is already falling, prices have been declining since midnight. Use all_segments in templates or dashboards to display the full day ahead."
|
||||
},
|
||||
"next_price_phase": {
|
||||
"description": "The next intra-day price phase — what comes after the current price movement",
|
||||
"long_description": "Shows the monotone price phase that will follow after the currently active phase ends. The start attribute tells you exactly when the next phase begins, making it easy to schedule automations. When the current phase is the last one of the day (e.g. the final evening fall), this sensor becomes unavailable. Attributes: start (when it begins), end, price range (min/max/mean), segment_index, segment_count. Updates every 15 minutes.",
|
||||
"usage_tips": "Use in automations: 'If next_price_phase = rising and next_price_phase.start is within 1 hour, start the washing machine now'. Or combine with current_price_phase: 'If current_price_phase = falling and next_price_phase = flat, we are approaching the daily low — good time for flexible loads'. The start attribute is particularly valuable: trigger automations precisely when the next phase begins."
|
||||
},
|
||||
"current_price_phase_end_time": {
|
||||
"description": "When the current intra-day price phase ends",
|
||||
"long_description": "Shows the exact timestamp when the currently active rising, falling, or flat price phase will end and transition to the next phase. Becomes unavailable when no segment data is available. Updates at interval boundaries (every 15 minutes). Pair with current_price_phase to know both what phase you are in and when it ends.",
|
||||
"usage_tips": "Use in automations to schedule tasks that must finish before prices change: 'Start washing machine if current_price_phase = falling and current_price_phase_end_time is more than 2 hours away'. Or to alert when a cheap phase is about to end."
|
||||
},
|
||||
"current_price_phase_remaining_minutes": {
|
||||
"description": "Minutes remaining in the current price phase",
|
||||
"long_description": "Shows how many minutes are left in the current intra-day price phase (rising, falling, or flat). Updates every minute for countdown precision. Returns 0 when no segment data is available. The remaining_minutes attribute mirrors the sensor value in integer minutes for simpler automation templates.",
|
||||
"usage_tips": "Use in automations: 'If current_price_phase = falling and current_price_phase_remaining_minutes < 30, start the dishwasher now before prices level out'. Also useful for dashboard cards showing a countdown bar until the phase transition."
|
||||
},
|
||||
"current_price_phase_duration": {
|
||||
"description": "Total duration of the current price phase",
|
||||
"long_description": "Shows the total length of the currently active price phase in hours (expressed in minutes internally). Updates at interval boundaries. This tells you how long the current trend segment lasts in total — useful for understanding whether it is a brief fluctuation or an extended period of rising or falling prices.",
|
||||
"usage_tips": "Combine with current_price_phase_remaining_minutes to estimate how far through the phase you are, or compare with current_price_phase_progress to understand the time profile of the current trend."
|
||||
},
|
||||
"current_price_phase_progress": {
|
||||
"description": "How far through the current price phase we are",
|
||||
"long_description": "Shows the percentage of the current intra-day price phase that has elapsed (0–100%). Updates every minute. A value near 0% means the phase just started; near 100% means it is about to end. Returns 0 when no segment data is available.",
|
||||
"usage_tips": "Use in dashboard cards to display a visual progress bar for the current price phase. In automations: 'If current_price_phase = falling and current_price_phase_progress > 80, the cheapest prices are close — prepare flexible loads now'."
|
||||
},
|
||||
"next_rising_phase_start_time": {
|
||||
"description": "When the next rising price phase begins",
|
||||
"long_description": "Shows the timestamp of the next upcoming rising price segment across today's remaining phases and tomorrow's phases. Returns unavailable when no more rising phases exist in today's or tomorrow's data. Updates at interval boundaries (every 15 minutes).",
|
||||
"usage_tips": "Use with in_falling_price_phase and next_rising_phase_start_time to schedule flexible loads: run them now while prices fall, and wrap up before the next rise starts. 'If in_falling_price_phase is ON and next_rising_phase_start_time is less than 1 hour away, start the washing machine immediately'."
|
||||
},
|
||||
"next_falling_phase_start_time": {
|
||||
"description": "When the next falling price phase begins",
|
||||
"long_description": "Shows the timestamp of the next upcoming falling price segment across today's remaining phases and tomorrow's phases. Returns unavailable when no more falling phases exist in today's or tomorrow's data. Updates at interval boundaries (every 15 minutes).",
|
||||
"usage_tips": "Use to delay flexible loads until the next price drop starts. 'If next_falling_phase_start_time is within 2 hours, consider waiting before starting the dishwasher or heat pump'."
|
||||
},
|
||||
"next_flat_phase_start_time": {
|
||||
"description": "When the next flat (stable) price phase begins",
|
||||
"long_description": "Shows the timestamp of the next upcoming flat price segment (where prices show little variation) across today's remaining phases and tomorrow's phases. Returns unavailable when no more flat phases exist in the available data. Updates at interval boundaries (every 15 minutes).",
|
||||
"usage_tips": "Flat phases indicate price stability — useful for scheduling loads that need a predictable cost over time. 'If next_flat_phase_start_time is within 30 minutes and current_price_phase = rising, the price peak is nearly over'."
|
||||
},
|
||||
"next_rising_phase_in_minutes": {
|
||||
"description": "Minutes until the next rising price phase begins",
|
||||
"long_description": "Shows how many minutes until the next rising price phase starts. Updates every minute. Returns unavailable if no more rising phases exist in today's or tomorrow's data. The next_in_minutes attribute mirrors the sensor value in integer minutes for automation templates.",
|
||||
"usage_tips": "Use in countdown automations: 'Alert me 15 minutes before the next price rise so I can delay flexible loads'. Combine with in_falling_price_phase: if currently falling and a rise is imminent, act before prices start climbing."
|
||||
},
|
||||
"next_falling_phase_in_minutes": {
|
||||
"description": "Minutes until the next falling price phase begins",
|
||||
"long_description": "Shows how many minutes until the next falling price phase starts. Updates every minute. Returns unavailable if no more falling phases exist in today's or tomorrow's data. The next_in_minutes attribute mirrors the sensor value in integer minutes for automation templates.",
|
||||
"usage_tips": "Use to time flexible loads optimally: 'If next_falling_phase_in_minutes < 60, delay the washing machine start to benefit from the upcoming price drop'."
|
||||
},
|
||||
"next_flat_phase_in_minutes": {
|
||||
"description": "Minutes until the next flat (stable) price phase begins",
|
||||
"long_description": "Shows how many minutes until the next flat price phase starts. Updates every minute. Returns unavailable if no more flat phases exist in today's or tomorrow's data. The next_in_minutes attribute mirrors the sensor value in integer minutes.",
|
||||
"usage_tips": "Use to anticipate price stabilisation after a volatile phase. 'If next_flat_phase_in_minutes < 30 and current_price_phase = rising, the price peak will soon level off — consider delaying loads until then'."
|
||||
},
|
||||
"chart_data_export": {
|
||||
"description": "Data export for dashboard integrations",
|
||||
"long_description": "This binary sensor calls the get_chartdata service with your configured YAML parameters and exposes the result as entity attributes. The state is 'on' when the service call succeeds and data is available, 'off' when the call fails or no configuration is set. Perfect for dashboard integrations like ApexCharts that need to read price data from entity attributes.",
|
||||
|
|
@ -455,6 +570,61 @@
|
|||
"description": "Lightweight metadata for chart configuration",
|
||||
"long_description": "Provides essential chart configuration values as sensor attributes. Useful for any chart card that needs Y-axis bounds. The sensor calls get_chartdata with metadata-only mode (no data processing) and extracts: yaxis_min, yaxis_max (suggested Y-axis range for optimal scaling). The state reflects the service call result: 'ready' when successful, 'error' on failure, 'pending' during initialization.",
|
||||
"usage_tips": "Configure via configuration.yaml under tibber_prices.chart_metadata_config (optional: day, subunit_currency, resolution). The sensor automatically refreshes when price data updates. Access metadata from attributes: yaxis_min, yaxis_max. Use with config-template-card or any tool that reads entity attributes - perfect for dynamic chart configuration without manual calculations."
|
||||
},
|
||||
"current_interval_price_rank_today": {
|
||||
"description": "Where the current interval's price sits in today's ranking — its percentile rank (0% = cheapest moment)",
|
||||
"long_description": "Shows how cheap or expensive the current quarter-hour interval's price is compared to all of today's 96 quarter-hour slots. 0% means this is the cheapest moment of the day — every other slot costs more. 50% means half of today's slots are cheaper. ~99% means it's the most expensive slot of the day. Formula (percentile rank): how many slots are cheaper ÷ total slots × 100. Attributes: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Ideal for automations: 'If current_interval_price_rank_today < 25, start dishwasher' (cheapest quarter of the day). Or 'If current_interval_price_rank_today > 75, pause heat pump' (most expensive quarter). A value of 0 guarantees you're at the cheapest slot of the day."
|
||||
},
|
||||
"current_interval_price_rank_tomorrow": {
|
||||
"description": "Where the current interval's price sits in tomorrow's percentile ranking (0% = cheapest of tomorrow)",
|
||||
"long_description": "Shows how the current interval's price compares to all of tomorrow's 96 quarter-hour slots — its percentile rank within tomorrow's distribution. Useful for deciding whether to wait until tomorrow. 0% means the current price is cheaper than every slot tomorrow. Returns 'Unknown' until tomorrow's data arrives (typically after 13:00). Attributes: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Use to decide whether to wait: 'If current_interval_price_rank_tomorrow < 10, tomorrow has even cheaper slots — postpone the task'. Best combined with a binary sensor to confirm the task can actually run tomorrow."
|
||||
},
|
||||
"current_interval_price_rank_today_tomorrow": {
|
||||
"description": "Current interval's percentile rank across today and tomorrow combined (0% = cheapest of the two-day window)",
|
||||
"long_description": "Shows how cheap or expensive the current interval's price is compared to all slots across today and tomorrow together (up to 192 quarter-hour slots when both days are available) — the percentile rank within the two-day distribution. Gives the broadest view for flexible tasks. Falls back to today-only when tomorrow's data isn't available yet. 0% = cheapest of the combined two-day window. Attributes: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "The broadest signal for 'is now a good time?'. Use 'If current_interval_price_rank_today_tomorrow < 20, run energy-intensive task now'. Especially valuable when tasks can wait a full day — a value near 0 across two days is a genuinely exceptional price."
|
||||
},
|
||||
"next_interval_price_rank_today": {
|
||||
"description": "Where the next interval's price sits in today's ranking (0% = cheapest moment of today)",
|
||||
"long_description": "Shows the percentile rank of the upcoming quarter-hour interval's price within today's 96 slots. Lets you see at a glance how the next interval compares to the rest of the day before it starts. Attributes: `next_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Use to prepare for the next interval: 'If next_interval_price_rank_today < 15, start pre-heating now so the device runs during the next cheap slot'."
|
||||
},
|
||||
"next_interval_price_rank_today_tomorrow": {
|
||||
"description": "Next interval's percentile rank across today and tomorrow combined (0% = cheapest of the two-day window)",
|
||||
"long_description": "Shows the percentile rank of the upcoming quarter-hour interval's price within the combined today+tomorrow pool (up to 192 slots). Falls back to today-only when tomorrow's data isn't available. Attributes: `next_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Broadest look-ahead: 'If next_interval_price_rank_today_tomorrow < 10, the next interval is among the cheapest slots of the two-day window — optimal time to start long tasks'."
|
||||
},
|
||||
"previous_interval_price_rank_today": {
|
||||
"description": "Where the previous interval's price sat in today's ranking (0% = cheapest moment of today)",
|
||||
"long_description": "Shows the percentile rank of the just-ended quarter-hour interval's price within today's 96 slots. Useful for logging how cheap/expensive the previous interval was. Attributes: `previous_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Useful for retrospective automations or logging: 'Record the cost tier of the previous interval for energy reports'."
|
||||
},
|
||||
"previous_interval_price_rank_today_tomorrow": {
|
||||
"description": "Previous interval's percentile rank across today and tomorrow combined (0% = cheapest of the two-day window)",
|
||||
"long_description": "Shows the percentile rank of the just-ended quarter-hour interval's price within the combined today+tomorrow pool (up to 192 slots). Falls back to today-only when tomorrow's data isn't available. Attributes: `previous_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Useful for retrospective comparisons across a two-day window."
|
||||
},
|
||||
"current_hour_price_rank_today": {
|
||||
"description": "Percentile rank of the current rolling hour's average price within today's distribution (0% = cheapest hour)",
|
||||
"long_description": "Shows where the 5-interval rolling average (2 intervals before + current + 2 after, ~1 hour) sits in today's price ranking. Smooths out short spikes and gives a broader view of whether this hour is cheap or expensive relative to the day. Attributes: `current_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "For tasks that take about an hour: 'If current_hour_price_rank_today < 20, this is a cheap hour — run the washing machine'."
|
||||
},
|
||||
"current_hour_price_rank_today_tomorrow": {
|
||||
"description": "Current rolling hour's average price rank across today and tomorrow combined (0% = cheapest hour of the two-day window)",
|
||||
"long_description": "Shows where the 5-interval rolling average (±2 intervals, ~1 hour) sits in the combined today+tomorrow price ranking (up to 192 slots). Falls back to today-only when tomorrow's data isn't available. Attributes: `current_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Broadest hourly signal: 'If current_hour_price_rank_today_tomorrow < 15, this is one of the cheapest hours across two days — ideal for long flexible tasks'."
|
||||
},
|
||||
"next_hour_price_rank_today": {
|
||||
"description": "Percentile rank of the next rolling hour's average price within today's distribution (0% = cheapest hour of today)",
|
||||
"long_description": "Shows where the 5-interval rolling average centered on the next interval sits in today's price ranking. Lets you plan one hour ahead — is the upcoming hour cheap or expensive relative to today? Attributes: `next_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Plan one hour ahead: 'If next_hour_price_rank_today < 20, the upcoming hour is cheap — start a task now to run through it'."
|
||||
},
|
||||
"next_hour_price_rank_today_tomorrow": {
|
||||
"description": "Next rolling hour's average price rank across today and tomorrow combined (0% = cheapest hour of the two-day window)",
|
||||
"long_description": "Shows where the 5-interval rolling average centered on the next interval sits in the combined today+tomorrow price ranking (up to 192 slots). Falls back to today-only when tomorrow's data isn't available. Attributes: `next_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Broadest hourly look-ahead: 'If next_hour_price_rank_today_tomorrow < 10, the upcoming hour is among the cheapest of the two-day window'."
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
|
|
@ -473,6 +643,21 @@
|
|||
"long_description": "Turns on when the current price is in the bottom 20% of today's prices",
|
||||
"usage_tips": "Use this to run high-consumption appliances during the cheapest intervals"
|
||||
},
|
||||
"in_rising_price_phase": {
|
||||
"description": "Whether prices are currently in a rising phase",
|
||||
"long_description": "Turns ON when the current intra-day price phase is rising — i.e. prices have been moving upward since the last phase transition. Exactly one of in_rising_price_phase, in_falling_price_phase, and in_flat_price_phase is ON at any time. Becomes unavailable when no segment data is available.",
|
||||
"usage_tips": "Use in automations to delay or avoid running flexible loads: 'If in_rising_price_phase is ON, postpone the dishwasher'. Pair with next_falling_phase_start_time to know when prices will start dropping again."
|
||||
},
|
||||
"in_falling_price_phase": {
|
||||
"description": "Whether prices are currently in a falling phase",
|
||||
"long_description": "Turns ON when the current intra-day price phase is falling — i.e. prices have been dropping since the last phase transition. This is often a good window to start flexible loads. Exactly one of in_rising_price_phase, in_falling_price_phase, and in_flat_price_phase is ON at any time.",
|
||||
"usage_tips": "Use in automations to take advantage of falling prices: 'If in_falling_price_phase is ON and current_price_phase_remaining_minutes > 60, start the washing machine'. Combine with next_rising_phase_start_time to avoid starting a load that won't finish before prices rise."
|
||||
},
|
||||
"in_flat_price_phase": {
|
||||
"description": "Whether prices are currently in a flat (stable) phase",
|
||||
"long_description": "Turns ON when the current intra-day price phase is flat — i.e. prices are relatively stable with no significant rise or fall. Flat phases indicate predictable costs, making them suitable for loads with uncertain or variable duration. Exactly one of in_rising_price_phase, in_falling_price_phase, and in_flat_price_phase is ON at any time.",
|
||||
"usage_tips": "Use for loads that are indifferent to price direction but prefer stability: heat pumps in steady-state mode, background charging, or water heater top-ups. Combine with current_price_phase_end_time to know how long the stable window lasts."
|
||||
},
|
||||
"connection": {
|
||||
"description": "Whether the connection to the Tibber API is working",
|
||||
"long_description": "Indicates if the integration can successfully connect to the Tibber API",
|
||||
|
|
|
|||
|
|
@ -227,45 +227,80 @@
|
|||
"long_description": "Viser gjennomsnittsprisen for de neste 48 intervallene (12 timer) fra og med neste 15-minutters intervall.",
|
||||
"usage_tips": "Absolutt pristerskel: Strategiske beslutninger med pristak. Fortsett kun hvis 12t gjennomsnitt er under din maksimalt akseptable pris. Bra for utsettbare store belastninger."
|
||||
},
|
||||
"price_trend_1h": {
|
||||
"description": "Pristrend for neste time",
|
||||
"long_description": "Sammenligner nåværende intervallpris med gjennomsnitt av neste 1 time (4 intervaller). Stigende hvis fremtiden er >5% høyere, fallende hvis >5% lavere, ellers stabil.",
|
||||
"usage_tips": "Relativ optimalisering: 'fallende' = vent, prisene faller. 'stigende' = handle nå eller du betaler mer. 'stabil' = prisen spiller ikke så stor rolle nå. Fungerer uavhengig av absolutt prisnivå."
|
||||
"price_outlook_1h": {
|
||||
"description": "Prisutblikk for neste time",
|
||||
"long_description": "Sammenligner din nåværende pris med gjennomsnittet av alle intervaller den neste timen (4 intervaller). Alle utblikk-sensorer (1t–12t) har samme utgangspunkt: din nåværende pris — de skiller seg bare i vindustørrelse. Større vinduer dekker flere fremtidige timer og jevner ut kortsiktige topper. Stigende/fallende ved ±3%, sterkt ved ±9% (konfigurerbart, volatilitetstilpasset).",
|
||||
"usage_tips": "Beslutningshjelp: 'stigende' = HANDLE NÅ, din nåværende pris er gunstigere enn vindusgjennomsnittet. 'fallende' = VENT, vindusgjennomsnittet er billigere enn nå. 'stabil' = timing spiller ingen rolle. Vanlig misforståelse: 'stigende' betyr IKKE 'for sent' — det betyr at nå er en god pris!"
|
||||
},
|
||||
"price_trend_2h": {
|
||||
"description": "Pristrend for neste 2 timer",
|
||||
"long_description": "Sammenligner nåværende intervallpris med gjennomsnitt av neste 2 timer (8 intervaller). Stigende hvis fremtiden er >5% høyere, fallende hvis >5% lavere, ellers stabil.",
|
||||
"usage_tips": "Relativ optimalisering: Ideelt for apparater. 'fallende' betyr bedre priser kommer om 2t - utsett hvis mulig. Finner beste timing innenfor ditt tilgjengelige vindu, uavhengig av sesong."
|
||||
"price_outlook_2h": {
|
||||
"description": "Prisutblikk for neste 2 timer",
|
||||
"long_description": "Sammenligner din nåværende pris med gjennomsnittet av alle intervaller de neste 2 timene (8 intervaller). 'stigende' = nåværende pris er under 2t-vindusgjennomsnittet; 'fallende' = vindusgjennomsnittet er billigere enn nå. Stigende/fallende ved ±3%, sterkt ved ±9% (konfigurerbart, volatilitetstilpasset).",
|
||||
"usage_tips": "Apparater: 'stigende' = start nå, du har en god pris i forhold til neste 2t. 'fallende' = bedre priser venter, utsett hvis mulig. Kombiner med price_trajectory_2h for å skille 'faller nå, stiger senere' fra 'faller gjennom hele vinduet'."
|
||||
},
|
||||
"price_trend_3h": {
|
||||
"description": "Pristrend for neste 3 timer",
|
||||
"long_description": "Sammenligner nåværende intervallpris med gjennomsnitt av neste 3 timer (12 intervaller). Stigende hvis fremtiden er >5% høyere, fallende hvis >5% lavere, ellers stabil.",
|
||||
"usage_tips": "Relativ optimalisering: For Eco-programmer. 'fallende' betyr priser faller >5% - verdt å vente. Fungerer i enhver sesong. Kombiner med avg-sensor for pristak: kun når avg < din grense OG trend ikke 'fallende'."
|
||||
"price_outlook_3h": {
|
||||
"description": "Prisutblikk for neste 3 timer",
|
||||
"long_description": "Sammenligner din nåværende pris med gjennomsnittet av alle intervaller de neste 3 timene (12 intervaller). 'stigende' = nåværende pris er under 3t-vindusgjennomsnittet; 'fallende' = vindusgjennomsnittet er billigere enn nå. Stigende/fallende ved ±3%, sterkt ved ±9% (konfigurerbart, volatilitetstilpasset).",
|
||||
"usage_tips": "Eco-programmer: 'stigende' = start eco-syklusen nå, prisene er i snitt høyere fremover. 'fallende' = vent, billigere gjennomsnitt kommer. Kombiner med avg-sensor: start når utblikk er 'stigende' eller 'stabil' OG avg < din grense."
|
||||
},
|
||||
"price_trend_4h": {
|
||||
"description": "Pristrend for neste 4 timer",
|
||||
"long_description": "Sammenligner nåværende intervallpris med gjennomsnitt av neste 4 timer (16 intervaller). Stigende hvis fremtiden er >5% høyere, fallende hvis >5% lavere, ellers stabil.",
|
||||
"usage_tips": "Relativ optimalisering: Varmepumpe/batteribeslutninger. 'fallende' betyr bedre ladevindu kommer. Finner alltid relativ beste tid - enten prisene er 10 cent eller 50 cent. Bruk avg-sensor for absolutt grense."
|
||||
"price_outlook_4h": {
|
||||
"description": "Prisutblikk for neste 4 timer",
|
||||
"long_description": "Sammenligner din nåværende pris med gjennomsnittet av alle intervaller de neste 4 timene (16 intervaller). 'stigende' = nåværende pris er under 4t-vindusgjennomsnittet; 'fallende' = vindusgjennomsnittet er billigere enn nå. Stigende/fallende ved ±3%, sterkt ved ±9% (konfigurerbart, volatilitetstilpasset).",
|
||||
"usage_tips": "Varmepumpe/batteri: 'stigende' = lad nå, du er på et relativt lavpunkt. 'fallende' = vent på bedre gjennomsnittsvindu. Kombiner med price_trajectory_4h for å se om prisene fortsatt faller eller allerede stiger igjen."
|
||||
},
|
||||
"price_trend_5h": {
|
||||
"description": "Pristrend for neste 5 timer",
|
||||
"long_description": "Sammenligner nåværende intervallpris med gjennomsnitt av neste 5 timer (20 intervaller). Stigende hvis fremtiden er >5% høyere, fallende hvis >5% lavere, ellers stabil.",
|
||||
"usage_tips": "Relativ optimalisering: Utvidede operasjoner. Tilpasser seg markedet - finner beste relative timing i ethvert prismiljø. 'stabil/stigende' = godt tidspunkt å starte innenfor ditt planleggingsvindu."
|
||||
"price_outlook_5h": {
|
||||
"description": "Prisutblikk for neste 5 timer",
|
||||
"long_description": "Sammenligner din nåværende pris med gjennomsnittet av alle intervaller de neste 5 timene (20 intervaller). 'stigende' = nåværende pris er under 5t-vindusgjennomsnittet; 'fallende' = vindusgjennomsnittet er billigere enn nå. Stigende/fallende ved ±3%, sterkt ved ±9% (konfigurerbart, volatilitetstilpasset).",
|
||||
"usage_tips": "Utvidede sykluser: 'stigende' eller 'stabil' = godt tidspunkt å starte. 'fallende' = vent hvis planen din tillater det."
|
||||
},
|
||||
"price_trend_6h": {
|
||||
"description": "Pristrend for neste 6 timer",
|
||||
"long_description": "Sammenligner nåværende intervallpris med gjennomsnitt av neste 6 timer (24 intervaller). Stigende hvis fremtiden er >5% høyere, fallende hvis >5% lavere, ellers stabil.",
|
||||
"usage_tips": "Relativ optimalisering: Kveldsbeslutninger. 'fallende' = prisene forbedres betydelig hvis du venter. Ingen faste terskler nødvendig - justerer automatisk til vinter/sommer prisnivåer."
|
||||
"price_outlook_6h": {
|
||||
"description": "Prisutblikk for neste 6 timer",
|
||||
"long_description": "Sammenligner din nåværende pris med gjennomsnittet av alle intervaller de neste 6 timene (24 intervaller). 'stigende' = nåværende pris er under 6t-vindusgjennomsnittet; 'fallende' = vindusgjennomsnittet er billigere enn nå. Stigende/fallende ved ±3%, sterkt ved ±9% (konfigurerbart, volatilitetstilpasset).",
|
||||
"usage_tips": "Kveldsbeslutninger: 'stigende' = bruk strøm nå mens den er relativt billig. 'fallende' = kvelds-/nattprisene blir bedre i snitt, vent hvis mulig."
|
||||
},
|
||||
"price_trend_8h": {
|
||||
"description": "Pristrend for neste 8 timer",
|
||||
"long_description": "Sammenligner nåværende intervallpris med gjennomsnitt av neste 8 timer (32 intervaller). Stigende hvis fremtiden er >5% høyere, fallende hvis >5% lavere, ellers stabil.",
|
||||
"usage_tips": "Relativ optimalisering: Nattplanlegging. 'fallende' betyr at å vente til natten lønner seg (>5% billigere). Fungerer hele året uten manuelle terskeljusteringer. Start når 'stabil' eller 'stigende'."
|
||||
"price_outlook_8h": {
|
||||
"description": "Prisutblikk for neste 8 timer",
|
||||
"long_description": "Sammenligner din nåværende pris med gjennomsnittet av alle intervaller de neste 8 timene (32 intervaller). 'stigende' = nåværende pris er under 8t-vindusgjennomsnittet; 'fallende' = vindusgjennomsnittet er billigere enn nå. Stigende/fallende ved ±3%, sterkt ved ±9% (konfigurerbart, volatilitetstilpasset).",
|
||||
"usage_tips": "Nattplanlegging: 'stigende' = i natt/i morgen blir dyrere i snitt, bruk strøm nå. 'fallende' = nattprisene blir billigere, verdt å vente."
|
||||
},
|
||||
"price_trend_12h": {
|
||||
"description": "Pristrend for de neste 12 timene",
|
||||
"long_description": "Sammenligner nåværende intervallpris med gjennomsnittet av de neste 12 timene (48 intervaller). Økende hvis framtidig pris er >5% høyere, synkende hvis >5% lavere, ellers stabil.",
|
||||
"usage_tips": "Relativ optimalisering: Langsiktige strategiske beslutninger. 'synkende' = betydelig bedre priser kommer i natt/i morgen. Finner optimal timing i enhver markedssituasjon. Best kombinert med prisgrense fra avg-sensor."
|
||||
"price_outlook_12h": {
|
||||
"description": "Prisutblikk for de neste 12 timene",
|
||||
"long_description": "Sammenligner din nåværende pris med gjennomsnittet av alle intervaller de neste 12 timene (48 intervaller). 'stigende' = nåværende pris er under 12t-vindusgjennomsnittet; 'fallende' = vindusgjennomsnittet er billigere enn nå. Stigende/fallende ved ±3%, sterkt ved ±9% (konfigurerbart, volatilitetstilpasset).",
|
||||
"usage_tips": "Strategiske beslutninger: 'stigende' = du er på et lavpunkt relativt til neste 12t, godt tidspunkt for strømkrevende oppgaver. 'fallende' = betydelig bedre priser i snitt venter, vent hvis mulig."
|
||||
},
|
||||
"price_trajectory_2h": {
|
||||
"description": "Prisretning innenfor neste 2-timers vindu",
|
||||
"long_description": "Compares the average of the first hour (4 intervals) with the average of the second hour (4 intervals) within the next 2-hour window. 'rising' = second half more expensive than first half — prices are climbing within the window. 'falling' = second half cheaper — prices are dropping. Rising/falling at ±3%, strongly at ±9% (configurable, volatility-adaptive).",
|
||||
"usage_tips": "At a price minimum: price_outlook_2h may show 'falling' (window average is below current), but price_trajectory_2h shows 'rising' (second half more expensive than first) — revealing the upcoming reversal. 'outlook: falling + trajectory: rising' = you're AT the minimum, act now."
|
||||
},
|
||||
"price_trajectory_3h": {
|
||||
"description": "Prisretning innenfor neste 3-timers vindu",
|
||||
"long_description": "Compares the average of the first 1.5 hours with the average of the second 1.5 hours within the next 3-hour window. 'rising' = prices are climbing over the 3h window; 'falling' = prices are dropping. Reveals direction of movement independent of current price level. Rising/falling at ±3%, strongly at ±9% (configurable, volatility-adaptive).",
|
||||
"usage_tips": "Appliance timing: 'outlook: strongly_falling + trajectory: rising' = you're at or past the minimum, prices are already recovering — start now."
|
||||
},
|
||||
"price_trajectory_4h": {
|
||||
"description": "Prisretning innenfor neste 4-timers vindu",
|
||||
"long_description": "Compares the average of the first 2 hours with the average of the second 2 hours within the next 4-hour window. 'rising' = prices are climbing; 'falling' = prices are dropping. Complements price_outlook_4h: outlook answers 'is NOW cheap vs window average?', trajectory answers 'are prices rising or falling within the window?'. Rising/falling at ±3%, strongly at ±9%.",
|
||||
"usage_tips": "Heat pump charging: 'rising' = first half is cheaper, charge now. 'falling' = second half is cheaper, wait. Combine with outlook: if both rising, very strong signal to act now."
|
||||
},
|
||||
"price_trajectory_5h": {
|
||||
"description": "Prisretning innenfor neste 5-timers vindu",
|
||||
"long_description": "Compares the average of the first 2.5 hours with the average of the second 2.5 hours within the next 5-hour window. 'rising' = prices are climbing; 'falling' = prices are dropping. Rising/falling at ±3%, strongly at ±9% (configurable, volatility-adaptive).",
|
||||
"usage_tips": "Long cycles: 'outlook: rising + trajectory: rising' = clear signal to start now. 'outlook: falling + trajectory: rising' = you're near the bottom, good time to start before costs climb."
|
||||
},
|
||||
"price_trajectory_6h": {
|
||||
"description": "Prisretning innenfor neste 6-timers vindu",
|
||||
"long_description": "Compares the average of the first 3 hours with the average of the second 3 hours within the next 6-hour window. 'rising' = prices are climbing; 'falling' = prices are dropping. Rising/falling at ±3%, strongly at ±9% (configurable, volatility-adaptive).",
|
||||
"usage_tips": "Evening/overnight planning: 'falling' at evening peak = overnight will be cheaper, postpone. 'rising' in the morning = current morning prices are the low — good time for high consumption."
|
||||
},
|
||||
"price_trajectory_8h": {
|
||||
"description": "Prisretning innenfor neste 8-timers vindu",
|
||||
"long_description": "Compares the average of the first 4 hours with the average of the second 4 hours within the next 8-hour window. 'rising' = prices are climbing; 'falling' = prices are dropping. Rising/falling at ±3%, strongly at ±9% (configurable, volatility-adaptive).",
|
||||
"usage_tips": "Overnight charging: 'rising' during evening = first half of night is cheapest, start charging earlier. 'falling' = second half of night will be cheapest, delay start."
|
||||
},
|
||||
"price_trajectory_12h": {
|
||||
"description": "Prisretning innenfor neste 12-timers vindu",
|
||||
"long_description": "Compares the average of the first 6 hours with the average of the second 6 hours within the next 12-hour window. 'rising' = prices are climbing; 'falling' = prices are dropping. Rising/falling at ±3%, strongly at ±9% (configurable, volatility-adaptive).",
|
||||
"usage_tips": "Day-ahead planning: 'rising' at midnight = first half of day is cheaper, schedule morning loads. 'falling' = prices drop in second part of the day, afternoon/evening scheduling is better."
|
||||
},
|
||||
"current_price_trend": {
|
||||
"description": "Nåværende pristrend-retning og hvor lenge den varer",
|
||||
|
|
@ -274,8 +309,13 @@
|
|||
},
|
||||
"next_price_trend_change": {
|
||||
"description": "Når neste betydelige pristrendendring vil skje",
|
||||
"long_description": "Skanner de neste 24 timene (96 intervaller) for å finne når pristrenden (økende/synkende/stabil) vil endre seg fra nåværende momentum. Bestemmer først nåværende trend med vektet 1t tilbakeblikk (gjenkjenner pågående trender), deretter finner den reverseringen. Bruker volatilitetsadaptive terskelverdier (3 % momentum-deteksjon, markedsjustert fremtidssammenligning). Returnerer tidsstempelet når endringen forventes.",
|
||||
"usage_tips": "Hendelsesbasert automatisering: Utløs handlinger NÅR trenden endres, ikke OM X timer. Eksempel: 'Lad EV når neste trendendring viser synkende priser' eller 'Start oppvaskmaskin før prisene stiger'. Kompletterer tidsvindu-sensorer (price_trend_Xh) som svarer på 'VIL prisene være høyere om X timer?'"
|
||||
"long_description": "Skanner de neste 24 timene (96 intervaller) for å finne når pristrend-retningen vil endre seg. Kun retningsendringer teller: stigende/sterkt stigende er én gruppe, fallende/sterkt fallende en annen, stabil er egen. En endring fra stigende til sterkt stigende er IKKE en trendendring. Bruker volatilitetstilpassede terskelverdier (standard: ±3%/±9%) med hysterese (standard: 3 påfølgende intervaller). Returnerer tidsstempelet når endringen forventes.\n\nVIKTIG — Hvordan deteksjonen fungerer: For hvert fremtidig intervall sammenligner sensoren prisen for det intervallet med GJENNOMSNITTET av de følgende 3 timene (3h forhåndsvisnings-gjennomsnittet). Dette betyr at sensoren oppdager når de gjennomsnittlige kostnadene for de neste 3 timene allerede har skiftet retning — ikke når det nøyaktige prisminimum eller -maksimum er nådd.\n\nPå V-formede prisdager: Under et prisfall mot et minimum begynner 3h-forhåndsvinduet å inkludere priser på den stigende flanken før det faktiske minimumet er nådd. Når disse stigende prisene dytter 3h-gjennomsnittet over den nåværende prisen, rapporterer sensoren 'trenden endrer seg nå'. Dette skjer typisk 30–60 minutter før det eksakte prisminimum. Dette er tilsiktet — sensoren besvarer 'når vil den generelle RETNINGEN endre seg?' snarere enn 'når er det eksakte vendepunktet?'.",
|
||||
"usage_tips": "Hendelsesbasert automatisering: Utløs handlinger NÅR trenden endres, ikke OM X timer. Eksempel: 'Lad EV når neste trendendring viser synkende priser' eller 'Start oppvaskmaskin før prisene stiger'. Kompletterer tidsvindu-sensorer (price_trend_Xh) som svarer på 'VIL prisene være høyere om X timer?'\n\nMerk: På skarpe V-formede prisdager kan denne sensoren utløse 30–60 minutter før det eksakte prisminimum. Hvis du trenger et presist vendepunkt, sammenlign det med starten av Beste Pris-perioden — perioden starter ved det faktisk billigste vinduet. Denne sensoren egner seg bedre for 'forbered deg på en endring'-automatiseringsutløsere der en kort forhåndsvarsel er akseptabelt."
|
||||
},
|
||||
"next_price_trend_change_in": {
|
||||
"description": "Tid til neste pristrendendring",
|
||||
"long_description": "Viser hvor lenge det er til neste betydelige pristrendendring inntreffer. Verdien vises i timer (f.eks. 2,25 t) for dashboards. Deler samme analyse som tidsstempel-sensoren 'Neste trendendring', men presenterer den som en nedtellingsvarighet. Oppdateres hvert minutt for nøyaktige nedtellinger. Viser 'Ukjent' når ingen trendendring forventes i løpet av de neste 24 timene. Se 'Neste trendendring' for en forklaring av 3h-forhånds-deteksjonsmekanismen og dens oppførsel på V-formede prisdager.",
|
||||
"usage_tips": "Dashboard-nedtelling: Vis 'Trendendring om 1,5 t' som live nedtelling. For automatiseringer: 'Hvis next_price_trend_change_in < 0,25 (15 min), forbered på prisretningsendring'. Kompletterer 'Neste trendendring' (tidsstempel) — bruk tidsstempelet for 'NÅR' og denne sensoren for 'HVOR LENGE'."
|
||||
},
|
||||
"daily_rating": {
|
||||
"description": "Hvordan dagens priser sammenlignes med historiske data",
|
||||
|
|
@ -446,6 +486,81 @@
|
|||
"long_description": "Viser om Tibber-abonnementet ditt for øyeblikket er aktivt, avsluttet eller venter på aktivering. En status 'Aktiv' betyr at du aktivt mottar strøm gjennom Tibber.",
|
||||
"usage_tips": "Bruk dette til å overvåke abonnementsstatusen din. Sett opp varsler hvis statusen endres fra 'Aktiv' for å sikre uavbrutt tjeneste."
|
||||
},
|
||||
"day_pattern_yesterday": {
|
||||
"description": "Oppdaget prismønster for gårsdagens strømpriser",
|
||||
"long_description": "Klassifiserer i går i et prismønster: Dal (billig midt på dagen), Topp (dyrt midt på dagen), Dobbel dal (to billige perioder), Dobbel topp (to dyre perioder), Flat (liten variasjon), Stigende, Fallende eller Blandet. Konfidensen og CV-attributtene viser hvor pålitelig mønsteret ble oppdaget.",
|
||||
"usage_tips": "Bruk gårsdagens mønster til å forbedre automatikaene dine: et Dalmønster gjentar seg ofte neste dag og antyder at du bør forhåndsplanlegge billige middagstimer."
|
||||
},
|
||||
"day_pattern_today": {
|
||||
"description": "Oppdaget prismønster for dagens strømpriser",
|
||||
"long_description": "Klassifiserer i dag i et prismønster: Dal (billig midt på dagen), Topp (dyrt midt på dagen), Dobbel dal (W-form), Dobbel topp (M-form), Flat, Stigende, Fallende eller Blandet. Attributter inkluderer konfidensverdi (0–1), variasjonskoeffisient, knepunktstider og dagsegmenter.",
|
||||
"usage_tips": "Bruk dagens mønster til å flytte forbruk. Daldag: kjør oppvaskmaskin, vaskemaskin eller lad elbilen rundt billige middagstimer. Toppdag: kjør apparater tidlig morgen eller sent kveld. Bruk valley_start og valley_end for presise automatikaer."
|
||||
},
|
||||
"day_pattern_tomorrow": {
|
||||
"description": "Oppdaget prismønster for morgendagens strømpriser",
|
||||
"long_description": "Klassifiserer i morgen (når data er tilgjengelig, typisk etter kl. 13) i et prismønster med samme algoritme som i dag. Attributtene valley_start/valley_end eller peak_start/peak_end gir knepunktstider.",
|
||||
"usage_tips": "Sett opp kveldsautomasjonar som leser morgendagens mønster og forhåndskonfigurerer varmepumpe, billader eller varmtvannsberedere. Kombiner med tomorrow_data_available-binærsensoren."
|
||||
},
|
||||
"current_price_phase": {
|
||||
"description": "Om strømprisene nå stiger, faller eller er stabile – innenfor dagens intra-dag prisform",
|
||||
"long_description": "Viser retningen på prisbevegelsen nå ved å identifisere det aktive monotone segmentet i dagens priskurve. Dagens priser deles inn i fortløpende stigende, fallende eller flate strekninger (faser). Denne sensoren viser hvilken fase som er aktivt akkurat nå. Attributter inkluderer fasens start- og sluttid, prisområde (min/maks/gjennomsnitt), posisjon blant dagens faser (segment_index og segment_count) og den fullstendige listen over alle dagens faser (all_segments). Oppdateres hvert 15. minutt.",
|
||||
"usage_tips": "Bruk i automasjonar: 'Hvis current_price_phase = fallende, vent med fleksible laster til prisene når bunnen'. Kombiner med Dagens Prismønster for å se både den overordnede dagformen og din nåværende posisjon i den. Sjekk segment_index og segment_count for å forstå hvor langt inn i bevegelsen du er. Bruk all_segments i maler eller dashbord for å vise hele dagsforløpet."
|
||||
},
|
||||
"next_price_phase": {
|
||||
"description": "Den neste intra-dag prisfasen – hva som kommer etter den nåværende prisbevegelsen",
|
||||
"long_description": "Viser det monotone prissegmentet som følger etter den for øyeblikket aktive fasen. Attributtet start viser nøyaktig når neste fase begynner, noe som gjør det enkelt å planlegge automasjonar. Når den nåværende fasen er den siste for dagen (f.eks. det siste kveldsfall), blir denne sensoren utilgjengelig. Attributter: start (når den begynner), end, prisområde (min/maks/gjennomsnitt), segment_index, segment_count. Oppdateres hvert 15. minutt.",
|
||||
"usage_tips": "Bruk i automasjonar: 'Hvis next_price_phase = stigende og next_price_phase.start er innen 1 time, start vaskemaskin nå'. Eller kombiner med current_price_phase: 'Hvis current_price_phase = fallende og next_price_phase = flat, nærmer vi oss daglig lavpunkt – godt tidspunkt for fleksible laster'. Attributtet start er spesielt verdifullt: utløs automasjonar nøyaktig når neste fase begynner."
|
||||
},
|
||||
"current_price_phase_end_time": {
|
||||
"description": "When the current intra-day price phase ends",
|
||||
"long_description": "Shows the exact timestamp when the currently active rising, falling, or flat price phase will end and transition to the next phase.",
|
||||
"usage_tips": "Use in automations to schedule tasks that must finish before prices change."
|
||||
},
|
||||
"current_price_phase_remaining_minutes": {
|
||||
"description": "Minutes remaining in the current price phase",
|
||||
"long_description": "Shows how many minutes are left in the current intra-day price phase. Updates every minute.",
|
||||
"usage_tips": "Use in automations: 'If current_price_phase = falling and remaining < 30, start the dishwasher now'."
|
||||
},
|
||||
"current_price_phase_duration": {
|
||||
"description": "Total duration of the current price phase",
|
||||
"long_description": "Shows the total length of the currently active price phase in hours.",
|
||||
"usage_tips": "Combine with remaining minutes to understand how far through the phase you are."
|
||||
},
|
||||
"current_price_phase_progress": {
|
||||
"description": "How far through the current price phase we are",
|
||||
"long_description": "Shows the percentage of the current intra-day price phase that has elapsed (0–100%). Updates every minute.",
|
||||
"usage_tips": "Use in dashboard cards to display a visual progress bar for the current price phase."
|
||||
},
|
||||
"next_rising_phase_start_time": {
|
||||
"description": "When the next rising price phase begins",
|
||||
"long_description": "Shows the timestamp of the next upcoming rising price segment across today's remaining phases and tomorrow's phases.",
|
||||
"usage_tips": "Use to schedule loads before prices start rising again."
|
||||
},
|
||||
"next_falling_phase_start_time": {
|
||||
"description": "When the next falling price phase begins",
|
||||
"long_description": "Shows the timestamp of the next upcoming falling price segment across today's remaining phases and tomorrow's phases.",
|
||||
"usage_tips": "Use to delay flexible loads until the next price drop starts."
|
||||
},
|
||||
"next_flat_phase_start_time": {
|
||||
"description": "When the next flat (stable) price phase begins",
|
||||
"long_description": "Shows the timestamp of the next upcoming flat price segment across today's remaining phases and tomorrow's phases.",
|
||||
"usage_tips": "Use for scheduling loads that need predictable costs over time."
|
||||
},
|
||||
"next_rising_phase_in_minutes": {
|
||||
"description": "Minutes until the next rising price phase begins",
|
||||
"long_description": "Shows how many minutes until the next rising price phase starts. Updates every minute.",
|
||||
"usage_tips": "Use in countdown automations to alert before the next price rise."
|
||||
},
|
||||
"next_falling_phase_in_minutes": {
|
||||
"description": "Minutes until the next falling price phase begins",
|
||||
"long_description": "Shows how many minutes until the next falling price phase starts. Updates every minute.",
|
||||
"usage_tips": "Use to time flexible loads: delay until the upcoming price drop."
|
||||
},
|
||||
"next_flat_phase_in_minutes": {
|
||||
"description": "Minutes until the next flat (stable) price phase begins",
|
||||
"long_description": "Shows how many minutes until the next flat price phase starts. Updates every minute.",
|
||||
"usage_tips": "Use to anticipate price stabilisation after a volatile phase."
|
||||
},
|
||||
"chart_data_export": {
|
||||
"description": "Dataeksport for dashboardintegrasjoner",
|
||||
"long_description": "Denne sensoren kaller get_chartdata-tjenesten med din konfigurerte YAML-konfigurasjon og eksponerer resultatet som entitetsattributter. Status viser 'ready' når data er tilgjengelig, 'error' ved feil, eller 'pending' før første kall. Perfekt for dashboardintegrasjoner som ApexCharts som trenger å lese prisdata fra entitetsattributter.",
|
||||
|
|
@ -455,6 +570,61 @@
|
|||
"description": "Lettvekts metadata for diagramkonfigurasjon",
|
||||
"long_description": "Gir essensielle diagramkonfigurasjonsverdier som sensorattributter. Nyttig for ethvert diagramkort som trenger Y-aksegrenser. Sensoren kaller get_chartdata med kun-metadata-modus (ingen databehandling) og trekker ut: yaxis_min, yaxis_max (foreslått Y-akseområde for optimal skalering). Status reflekterer tjenestekallresultatet: 'ready' ved suksess, 'error' ved feil, 'pending' under initialisering.",
|
||||
"usage_tips": "Konfigurer via configuration.yaml under tibber_prices.chart_metadata_config (valgfritt: day, subunit_currency, resolution). Sensoren oppdateres automatisk når prisdata endres. Få tilgang til metadata fra attributter: yaxis_min, yaxis_max. Bruk med config-template-card eller ethvert verktøy som leser entitetsattributter - perfekt for dynamisk diagramkonfigurasjon uten manuelle beregninger."
|
||||
},
|
||||
"current_interval_price_rank_today": {
|
||||
"description": "Hvor nåværende intervallpris plasserer seg i dagens rangering — som prosentilrang (0 % = billigste øyeblikk)",
|
||||
"long_description": "Viser hvor billig eller dyr prisen for det gjældende kvarter er sammenlignet med alle 96 kvarterstimer i dag. 0 % betyr at dette er det billigste øyeblikket i dag. 50 % betyr at halvparten av dagens tidsluker er billigere. ca. 99 % betyr det dyreste tidssluket i dag. Formel: antall billigere tidsluker ÷ totalt antall × 100. Attributter: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Ideelt for automatiseringer: 'Hvis current_interval_price_rank_today < 25, start oppvaskmaskinen'. A value of 0 garanterer at du er på det billigste tidssluket i dag."
|
||||
},
|
||||
"current_interval_price_rank_tomorrow": {
|
||||
"description": "Prosentilrang for gjældende intervallpris i morgendagens rangering (0 % = billigste av i morgen)",
|
||||
"long_description": "Viser hvordan gjældende intervallpris sammenlignes med alle 96 kvarterstimer i morgen. 0 % betyr at gjældende pris er billigere enn alle morgendagens tidsluker. Returnerer 'Ukjent' til morgendagens data er tilgjengelig (vanligvis etter kl. 13:00). Attributter: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Bruk for å avgjøre om det er verdt å vente: 'Hvis current_interval_price_rank_tomorrow < 10, finnes det enda billigere tidsluker i morgen — utsett oppgaven'."
|
||||
},
|
||||
"current_interval_price_rank_today_tomorrow": {
|
||||
"description": "Prosentilrang for gjældende intervallpris over i dag+i morgen samlet (0 % = billigste i to-dagers-vinduet)",
|
||||
"long_description": "Viser hvor billig eller dyr gjældende intervallpris er sammenlignet med alle tidsluker over i dag og i morgen samlet (opptil 192 kvarterstimer). Fæller tilbake til kun i dag når morgendagens data ikke er tilgjengelig. Attributter: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Det bredeste signalet for 'er det nå et godt tidspunkt?'. Bruk 'Hvis current_interval_price_rank_today_tomorrow < 20, kjør energikrevende oppgave nå'."
|
||||
},
|
||||
"next_interval_price_rank_today": {
|
||||
"description": "Prosentilrang for neste intervalls pris i dagens rangering (0 % = billigste øyeblikk i dag)",
|
||||
"long_description": "Viser prosentilrangen for det kommende kvarter innenfor dagens 96 tidsluker. Gir forhåndsvisning før neste intervall begynner. Attributter: `next_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "For forberedelse: 'Hvis next_interval_price_rank_today < 15, start forvarming nå så enheten kjører i neste billige tidsluke'."
|
||||
},
|
||||
"next_interval_price_rank_today_tomorrow": {
|
||||
"description": "Prosentilrang for neste intervalls pris over i dag+i morgen samlet (0 % = billigste i to-dagers-vinduet)",
|
||||
"long_description": "Viser prosentilrangen for det kommende kvarter innenfor det kombinerte i dag+i morgen-bassenget (opptil 192 tidsluker). Fæller tilbake til kun i dag. Attributter: `next_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Bredeste fremtidsvisning: 'Hvis next_interval_price_rank_today_tomorrow < 10, er neste intervall blant de billigste i to-dagers-vinduet'."
|
||||
},
|
||||
"previous_interval_price_rank_today": {
|
||||
"description": "Prosentilrang for forrige intervalls pris i dagens rangering (0 % = billigste øyeblikk i dag)",
|
||||
"long_description": "Viser prosentilrangen for det nettopp avsluttede kvarteret innenfor dagens 96 tidsluker. Nyttig for å logge hvor billig eller dyrt det forrige intervallet var. Attributter: `previous_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "For retrospektive automatiseringer: 'Registrer prisnivået for forrige intervall i energirapporter'."
|
||||
},
|
||||
"previous_interval_price_rank_today_tomorrow": {
|
||||
"description": "Prosentilrang for forrige intervalls pris over i dag+i morgen samlet (0 % = billigste i to-dagers-vinduet)",
|
||||
"long_description": "Viser prosentilrangen for det nettopp avsluttede kvarteret innenfor det kombinerte i dag+i morgen-bassenget (opptil 192 tidsluker). Faller tilbake til kun i dag når morgendagens data ikke er tilgjengelig. Attributter: `previous_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "For retrospektive sammenligninger over et to-dagers-vindu."
|
||||
},
|
||||
"current_hour_price_rank_today": {
|
||||
"description": "Prosentilrang for glidende timegjennomsnittpris i dagens rangering (0 % = billigste time i dag)",
|
||||
"long_description": "Viser plasseringen til det glidende 5-intervall-gjennomsnittet (2 intervaller før + gjældende + 2 etter, ca. 1 time) i dagens prisrangering. Jevner ut korte pristopper. Attributter: `current_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "For oppgaver som tar omtrent en time: 'Hvis current_hour_price_rank_today < 20, er dette en billig time — start vaskemaskinen'."
|
||||
},
|
||||
"current_hour_price_rank_today_tomorrow": {
|
||||
"description": "Glidende timegjennomsnittprisrang over i dag+i morgen samlet (0 % = billigste time i to-dagers-vinduet)",
|
||||
"long_description": "Viser plasseringen til det glidende 5-intervall-gjennomsnittet (±2 intervaller, ca. 1 time) i den kombinerte i dag+i morgen-rangeringen (opptil 192 tidsluker). Fæller tilbake til kun i dag. Attributter: `current_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Bredeste timesignal: 'Hvis current_hour_price_rank_today_tomorrow < 15, er dette en av de billigste timene i to-dagers-vinduet'."
|
||||
},
|
||||
"next_hour_price_rank_today": {
|
||||
"description": "Prosentilrang for neste glidende timegjennomsnittpris i dagens rangering (0 % = billigste time i dag)",
|
||||
"long_description": "Viser plasseringen til det 5-intervall-gjennomsnittet sentrert på neste intervall i dagens prisrangering. Muliggjør planlegging en time frem i tid. Attributter: `next_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Forutse en time frem: 'Hvis next_hour_price_rank_today < 20, er den kommende timen billig — start en oppgave nå'."
|
||||
},
|
||||
"next_hour_price_rank_today_tomorrow": {
|
||||
"description": "Neste glidende timegjennomsnittprisrang over i dag+i morgen samlet (0 % = billigste time i to-dagers-vinduet)",
|
||||
"long_description": "Viser plasseringen til det 5-intervall-gjennomsnittet sentrert på neste intervall i den kombinerte i dag+i morgen-rangeringen (opptil 192 tidsluker). Fæller tilbake til kun i dag. Attributter: `next_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Bredeste timefremtidsvisning: 'Hvis next_hour_price_rank_today_tomorrow < 10, er den kommende timen blant de billigste i to-dagers-vinduet'."
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
|
|
@ -473,6 +643,21 @@
|
|||
"long_description": "Slår seg på når nåværende pris er i bunn 20% av dagens priser",
|
||||
"usage_tips": "Bruk dette til å kjøre høyforbruksapparater i de billigste intervallene"
|
||||
},
|
||||
"in_rising_price_phase": {
|
||||
"description": "Whether prices are currently in a rising phase",
|
||||
"long_description": "Turns ON when the current intra-day price phase is rising.",
|
||||
"usage_tips": "Use in automations to delay or avoid running flexible loads during rising prices."
|
||||
},
|
||||
"in_falling_price_phase": {
|
||||
"description": "Whether prices are currently in a falling phase",
|
||||
"long_description": "Turns ON when the current intra-day price phase is falling.",
|
||||
"usage_tips": "Use in automations to take advantage of falling prices for flexible loads."
|
||||
},
|
||||
"in_flat_price_phase": {
|
||||
"description": "Whether prices are currently in a flat (stable) phase",
|
||||
"long_description": "Turns ON when the current intra-day price phase is flat and prices are relatively stable.",
|
||||
"usage_tips": "Use for loads that prefer price stability rather than the lowest price."
|
||||
},
|
||||
"connection": {
|
||||
"description": "Om tilkoblingen til Tibber API fungerer",
|
||||
"long_description": "Indikerer om integrasjonen kan koble til Tibber API",
|
||||
|
|
|
|||
|
|
@ -227,45 +227,80 @@
|
|||
"long_description": "Toont de gemiddelde prijs voor de volgende 48 intervallen (12 uur) vanaf het volgende 15-minuten interval.",
|
||||
"usage_tips": "Absolute prijsdrempel: Strategische beslissingen met prijslimieten. Ga alleen door als 12u gemiddelde onder je maximaal acceptabele prijs is. Goed voor uitgestelde grote belastingen."
|
||||
},
|
||||
"price_trend_1h": {
|
||||
"description": "Prijstrend voor het volgende uur",
|
||||
"long_description": "Vergelijkt huidige intervalprijs met gemiddelde van volgend 1 uur (4 intervallen). Stijgend als toekomst >5% hoger is, dalend als >5% lager, anders stabiel.",
|
||||
"usage_tips": "Relatieve optimalisatie: 'dalend' = wacht, prijzen dalen. 'stijgend' = handel nu of je betaalt meer. 'stabiel' = prijs maakt nu niet veel uit. Werkt onafhankelijk van absoluut prijsniveau."
|
||||
"price_outlook_1h": {
|
||||
"description": "Prijsvooruitzicht voor het volgende uur",
|
||||
"long_description": "Vergelijkt je huidige prijs met het gemiddelde van alle intervallen in het volgende uur (4 intervallen). Alle vooruitzicht-sensoren (1u–12u) delen hetzelfde uitgangspunt: je huidige prijs — ze verschillen alleen in venstergrootte. Stijgend/dalend bij ±3%, sterk bij ±9% (configureerbaar, volatiliteitsadaptief).",
|
||||
"usage_tips": "Beslissingshulp: 'stijgend' = HANDEL NU, je huidige prijs is goedkoper dan het venstergemiddelde. 'dalend' = WACHT, het venstergemiddelde is goedkoper dan nu. 'stabiel' = timing maakt niet uit."
|
||||
},
|
||||
"price_trend_2h": {
|
||||
"description": "Prijstrend voor de volgende 2 uur",
|
||||
"long_description": "Vergelijkt huidige intervalprijs met gemiddelde van volgende 2 uur (8 intervallen). Stijgend als toekomst >5% hoger is, dalend als >5% lager, anders stabiel.",
|
||||
"usage_tips": "Relatieve optimalisatie: Ideaal voor apparaten. 'dalend' betekent betere prijzen komen over 2u - stel uit indien mogelijk. Vindt beste timing binnen je beschikbare venster, ongeacht seizoen."
|
||||
"price_outlook_2h": {
|
||||
"description": "Prijsvooruitzicht voor de volgende 2 uur",
|
||||
"long_description": "Vergelijkt je huidige prijs met het gemiddelde van alle intervallen in de volgende 2 uur (8 intervallen). 'stijgend' = huidige prijs is onder het 2u-venstergemiddelde; 'dalend' = venstergemiddelde is goedkoper dan nu. Stijgend/dalend bij ±3%, sterk bij ±9% (configureerbaar, volatiliteitsadaptief).",
|
||||
"usage_tips": "Apparaten: 'stijgend' = start nu, je hebt een goede prijs t.o.v. de volgende 2u. 'dalend' = betere gemiddelde prijzen komen, stel uit indien mogelijk. Combineer met price_trajectory_2h om te onderscheiden of prijzen nog dalen of al stijgen."
|
||||
},
|
||||
"price_trend_3h": {
|
||||
"description": "Prijstrend voor de volgende 3 uur",
|
||||
"long_description": "Vergelijkt huidige intervalprijs met gemiddelde van volgende 3 uur (12 intervallen). Stijgend als toekomst >5% hoger is, dalend als >5% lager, anders stabiel.",
|
||||
"usage_tips": "Relatieve optimalisatie: Voor Eco-programma's. 'dalend' betekent prijzen dalen >5% - het wachten waard. Werkt in elk seizoen. Combineer met avg-sensor voor prijslimiet: alleen wanneer avg < je limiet EN trend niet 'dalend'."
|
||||
"price_outlook_3h": {
|
||||
"description": "Prijsvooruitzicht voor de volgende 3 uur",
|
||||
"long_description": "Vergelijkt je huidige prijs met het gemiddelde van alle intervallen in de volgende 3 uur (12 intervallen). 'stijgend' = huidige prijs is onder het 3u-venstergemiddelde; 'dalend' = venstergemiddelde is goedkoper dan nu. Stijgend/dalend bij ±3%, sterk bij ±9%.",
|
||||
"usage_tips": "Eco-programma's: 'stijgend' = start de eco-cyclus nu, prijzen zijn gemiddeld hoger in het venster. 'dalend' = wacht, goedkoper gemiddeld venster komt."
|
||||
},
|
||||
"price_trend_4h": {
|
||||
"description": "Prijstrend voor de volgende 4 uur",
|
||||
"long_description": "Vergelijkt huidige intervalprijs met gemiddelde van volgende 4 uur (16 intervallen). Stijgend als toekomst >5% hoger is, dalend als >5% lager, anders stabiel.",
|
||||
"usage_tips": "Relatieve optimalisatie: Warmtepomp/batterij beslissingen. 'dalend' betekent beter laadvenster komt. Vindt altijd relatief beste tijd - of prijzen nu 10 cent of 50 cent zijn. Gebruik avg-sensor voor absolute limiet."
|
||||
"price_outlook_4h": {
|
||||
"description": "Prijsvooruitzicht voor de volgende 4 uur",
|
||||
"long_description": "Vergelijkt je huidige prijs met het gemiddelde van alle intervallen in de volgende 4 uur (16 intervallen). 'stijgend' = huidige prijs is onder het 4u-venstergemiddelde; 'dalend' = venstergemiddelde is goedkoper dan nu. Stijgend/dalend bij ±3%, sterk bij ±9%.",
|
||||
"usage_tips": "Warmtepomp/batterij: 'stijgend' = laad nu, je zit op een relatief dieptepunt. Combineer met price_trajectory_4h om te zien of prijzen nog dalen of al stijgen."
|
||||
},
|
||||
"price_trend_5h": {
|
||||
"description": "Prijstrend voor de volgende 5 uur",
|
||||
"long_description": "Vergelijkt huidige intervalprijs met gemiddelde van volgende 5 uur (20 intervallen). Stijgend als toekomst >5% hoger is, dalend als >5% lager, anders stabiel.",
|
||||
"usage_tips": "Relatieve optimalisatie: Uitgebreide operaties. Past zich aan de markt aan - vindt beste relatieve timing in elke prijsomgeving. 'stabiel/stijgend' = goed moment om te starten binnen je planningsvenster."
|
||||
"price_outlook_5h": {
|
||||
"description": "Prijsvooruitzicht voor de volgende 5 uur",
|
||||
"long_description": "Vergelijkt je huidige prijs met het gemiddelde van alle intervallen in de volgende 5 uur (20 intervallen). 'stijgend' = huidige prijs is onder het 5u-venstergemiddelde; 'dalend' = venstergemiddelde is goedkoper dan nu. Stijgend/dalend bij ±3%, sterk bij ±9%.",
|
||||
"usage_tips": "Uitgebreide cycli: 'stijgend' of 'stabiel' = goed moment om te starten. 'dalend' = wacht als je planning het toelaat."
|
||||
},
|
||||
"price_trend_6h": {
|
||||
"description": "Prijstrend voor de volgende 6 uur",
|
||||
"long_description": "Vergelijkt huidige intervalprijs met gemiddelde van volgende 6 uur (24 intervallen). Stijgend als toekomst >5% hoger is, dalend als >5% lager, anders stabiel.",
|
||||
"usage_tips": "Relatieve optimalisatie: Avandbeslissingen. 'dalend' = prijzen verbeteren aanzienlijk als je wacht. Geen vaste drempels nodig - past automatisch aan winter/zomer prijsniveaus."
|
||||
"price_outlook_6h": {
|
||||
"description": "Prijsvooruitzicht voor de volgende 6 uur",
|
||||
"long_description": "Vergelijkt je huidige prijs met het gemiddelde van alle intervallen in de volgende 6 uur (24 intervallen). 'stijgend' = huidige prijs is onder het 6u-venstergemiddelde; 'dalend' = venstergemiddelde is goedkoper dan nu. Stijgend/dalend bij ±3%, sterk bij ±9%.",
|
||||
"usage_tips": "Avandbeslissingen: 'stijgend' = gebruik stroom nu terwijl het relatief goedkoop is. 'dalend' = avond-/nachtprijzen worden gemiddeld beter, wacht indien mogelijk."
|
||||
},
|
||||
"price_trend_8h": {
|
||||
"description": "Prijstrend voor de volgende 8 uur",
|
||||
"long_description": "Vergelijkt huidige intervalprijs met gemiddelde van volgende 8 uur (32 intervallen). Stijgend als toekomst >5% hoger is, dalend als >5% lager, anders stabiel.",
|
||||
"usage_tips": "Relatieve optimalisatie: Nachtplanning. 'dalend' betekent wachten tot de nacht loont (>5% goedkoper). Werkt het hele jaar door zonder handmatige drempelaanpassingen. Start wanneer 'stabiel' of 'stijgend'."
|
||||
"price_outlook_8h": {
|
||||
"description": "Prijsvooruitzicht voor de volgende 8 uur",
|
||||
"long_description": "Vergelijkt je huidige prijs met het gemiddelde van alle intervallen in de volgende 8 uur (32 intervallen). 'stijgend' = huidige prijs is onder het 8u-venstergemiddelde; 'dalend' = venstergemiddelde is goedkoper dan nu. Stijgend/dalend bij ±3%, sterk bij ±9%.",
|
||||
"usage_tips": "Nachtplanning: 'stijgend' = vanavond/morgen wordt gemiddeld duurder, gebruik stroom nu. 'dalend' = nachtprijzen worden goedkoper, wachten loont."
|
||||
},
|
||||
"price_trend_12h": {
|
||||
"description": "Prijstrend voor de komende 12 uur",
|
||||
"long_description": "Vergelijkt huidige intervalprijs met gemiddelde van de komende 12 uur (48 intervallen). Stijgend als toekomst >5% hoger is, dalend als >5% lager, anders stabiel.",
|
||||
"usage_tips": "Relatieve optimalisatie: Lange termijn strategische beslissingen. 'dalend' = aanzienlijk betere prijzen komen vanavond/morgen. Vindt optimale timing in elke marktsituatie. Het beste gecombineerd met prijslimiet van avg-sensor."
|
||||
"price_outlook_12h": {
|
||||
"description": "Prijsvooruitzicht voor de komende 12 uur",
|
||||
"long_description": "Vergelijkt je huidige prijs met het gemiddelde van alle intervallen in de komende 12 uur (48 intervallen). 'stijgend' = huidige prijs is onder het 12u-venstergemiddelde; 'dalend' = venstergemiddelde is goedkoper dan nu. Stijgend/dalend bij ±3%, sterk bij ±9%.",
|
||||
"usage_tips": "Strategische beslissingen: 'stijgend' = je zit op een dieptepunt t.o.v. de volgende 12u, goed moment voor stroomintensieve taken. 'dalend' = aanzienlijk betere gemiddelde prijzen komen."
|
||||
},
|
||||
"price_trajectory_2h": {
|
||||
"description": "Prijsrichting binnen het volgende 2-uurs venster",
|
||||
"long_description": "Compares the average of the first hour with the average of the second hour within the next 2-hour window. 'rising' = second half more expensive than first half. 'falling' = second half cheaper. Rising/falling at ±3%, strongly at ±9% (configurable, volatility-adaptive).",
|
||||
"usage_tips": "At a price minimum: price_outlook_2h may show 'falling' (window average is below current), but price_trajectory_2h shows 'rising' (second half more expensive) — revealing the upcoming reversal. 'outlook: falling + trajectory: rising' = you're AT the minimum, act now."
|
||||
},
|
||||
"price_trajectory_3h": {
|
||||
"description": "Prijsrichting binnen het volgende 3-uurs venster",
|
||||
"long_description": "Compares the average of the first 1.5 hours with the average of the second 1.5 hours within the next 3-hour window. 'rising' = prices are climbing; 'falling' = prices are dropping. Rising/falling at ±3%, strongly at ±9%.",
|
||||
"usage_tips": "Appliance timing: 'outlook: strongly_falling + trajectory: rising' = you're at or past the minimum, prices are already recovering — start now."
|
||||
},
|
||||
"price_trajectory_4h": {
|
||||
"description": "Prijsrichting binnen het volgende 4-uurs venster",
|
||||
"long_description": "Compares the average of the first 2 hours with the average of the second 2 hours within the next 4-hour window. 'rising' = prices are climbing; 'falling' = prices are dropping. Complements price_outlook_4h. Rising/falling at ±3%, strongly at ±9%.",
|
||||
"usage_tips": "Heat pump charging: 'rising' = first half is cheaper, charge now. 'falling' = second half is cheaper, wait. Combine with outlook: if both rising, very strong signal to act now."
|
||||
},
|
||||
"price_trajectory_5h": {
|
||||
"description": "Prijsrichting binnen het volgende 5-uurs venster",
|
||||
"long_description": "Compares the average of the first 2.5 hours with the average of the second 2.5 hours within the next 5-hour window. 'rising' = prices are climbing; 'falling' = prices are dropping. Rising/falling at ±3%, strongly at ±9%.",
|
||||
"usage_tips": "Long cycles: 'outlook: rising + trajectory: rising' = clear signal to start now. 'outlook: falling + trajectory: rising' = you're near the bottom, good time to start."
|
||||
},
|
||||
"price_trajectory_6h": {
|
||||
"description": "Prijsrichting binnen het volgende 6-uurs venster",
|
||||
"long_description": "Compares the average of the first 3 hours with the average of the second 3 hours within the next 6-hour window. 'rising' = prices are climbing; 'falling' = prices are dropping. Rising/falling at ±3%, strongly at ±9%.",
|
||||
"usage_tips": "Evening/overnight planning: 'falling' at evening peak = overnight will be cheaper, postpone. 'rising' in the morning = current morning prices are the low."
|
||||
},
|
||||
"price_trajectory_8h": {
|
||||
"description": "Prijsrichting binnen het volgende 8-uurs venster",
|
||||
"long_description": "Compares the average of the first 4 hours with the average of the second 4 hours within the next 8-hour window. 'rising' = prices are climbing; 'falling' = prices are dropping. Rising/falling at ±3%, strongly at ±9%.",
|
||||
"usage_tips": "Overnight charging: 'rising' during evening = first half of night is cheapest, start charging earlier. 'falling' = second half will be cheapest, delay start."
|
||||
},
|
||||
"price_trajectory_12h": {
|
||||
"description": "Prijsrichting binnen het volgende 12-uurs venster",
|
||||
"long_description": "Compares the average of the first 6 hours with the average of the second 6 hours within the next 12-hour window. 'rising' = prices are climbing; 'falling' = prices are dropping. Rising/falling at ±3%, strongly at ±9%.",
|
||||
"usage_tips": "Day-ahead planning: 'rising' at midnight = first half of day is cheaper, schedule morning loads. 'falling' = afternoon/evening scheduling is better."
|
||||
},
|
||||
"current_price_trend": {
|
||||
"description": "Huidige prijstrend-richting en hoe lang deze aanhoudt",
|
||||
|
|
@ -274,8 +309,13 @@
|
|||
},
|
||||
"next_price_trend_change": {
|
||||
"description": "Wanneer de volgende significante prijstrendwijziging zal plaatsvinden",
|
||||
"long_description": "Scant de komende 24 uur (96 intervallen) om te vinden wanneer de prijstrend (stijgend/dalend/stabiel) zal veranderen ten opzichte van het huidige momentum. Bepaalt eerst de huidige trend met gewogen 1u terugblik (herkent lopende trends), vindt dan de omkering. Gebruikt volatiliteit-adaptieve drempelwaarden (3% momentum-detectie, marktaangepaste toekomstvergelijking). Retourneert het tijdstempel wanneer de wijziging wordt verwacht.",
|
||||
"usage_tips": "Gebeurtenisgestuurde automatisering: Trigger acties WANNEER trend wijzigt, niet OVER X uur. Voorbeeld: 'Laad EV wanneer volgende trendwijziging dalende prijzen toont' of 'Start vaatwasser voordat prijzen stijgen'. Vult tijdvenster-sensors aan (price_trend_Xh) die beantwoorden 'ZULLEN prijzen over X uur hoger zijn?'"
|
||||
"long_description": "Scant de komende 24 uur (96 intervallen) om te vinden wanneer de prijstrend-richting zal veranderen. Alleen richtingswijzigingen tellen: stijgend/sterk stijgend vormen één groep, dalend/sterk dalend een andere, stabiel is apart. Een verandering van stijgend naar sterk stijgend is GEEN trendwijziging. Gebruikt volatiliteit-adaptieve drempelwaarden (standaard: ±3%/±9%) met hysterese (standaard: 3 opeenvolgende intervallen). Retourneert het tijdstempel wanneer de wijziging wordt verwacht.\n\nBELANGRIJK — Hoe de detectie werkt: Bij elk toekomstig interval vergelijkt de sensor de prijs van dat interval met het GEMIDDELDE van de volgende 3 uur (het 3u vooruitkijkgemiddelde). Dit betekent dat de sensor detecteert wanneer de gemiddelde kosten van de komende 3 uur al van richting zijn veranderd — niet wanneer het exacte prijsminimum of -maximum is bereikt.\n\nOp V-vormige prisdagen: Tijdens een prijsdaling naar een minimum begint het 3u vooruitkijkvenster al prijzen van de stijgende flank te bevatten voordat het eigenlijke minimum is bereikt. Zodra die stijgende prijzen het 3u-gemiddelde boven de huidige prijs duwen, rapporteert de sensor 'trend verandert nu'. Dit treedt typisch 30–60 minuten voor het exacte prijsminimum op. Dit is opzettelijk — de sensor beantwoordt 'wanneer verandert de algemene RICHTING?' in plaats van 'wanneer is het exacte keerpunt?'.",
|
||||
"usage_tips": "Gebeurtenisgestuurde automatisering: Trigger acties WANNEER trend wijzigt, niet OVER X uur. Voorbeeld: 'Laad EV wanneer volgende trendwijziging dalende prijzen toont' of 'Start vaatwasser voordat prijzen stijgen'. Vult tijdvenster-sensors aan (price_outlook_Xh) die beantwoorden 'ZULLEN prijzen over X uur hoger zijn?'\n\nOpmerking: Op scherpe V-vormige prisdagen kan deze sensor 30–60 minuten voor het exacte prijsminimum afgaan. Als je een precies keerpunt nodig hebt, vergelijk het met de start van de Beste Prijs-periode — de periode start bij het eigenlijke goedkoopste venster. Deze sensor is beter geschikt voor 'bereid je voor op een verandering' automatiserings-triggers waarbij een korte vroege waarschuwing acceptabel is."
|
||||
},
|
||||
"next_price_trend_change_in": {
|
||||
"description": "Tijd tot de volgende prijstrendwijziging",
|
||||
"long_description": "Toont hoe lang het duurt tot de volgende significante prijstrendwijziging plaatsvindt. De waarde wordt weergegeven in uren (bijv. 2,25 u) voor dashboards. Deelt dezelfde analyse als de tijdstempel-sensor 'Volgende Prijstrend Wijziging', maar presenteert het als een aftelduur. Wordt elke minuut bijgewerkt voor nauwkeurige aftellingen. Toont 'Onbekend' wanneer geen trendwijziging wordt verwacht in de komende 24 uur. Zie 'Volgende Prijstrend Wijziging' voor een uitleg van het 3u-vooruitkijkdetectiemechanisme en het gedrag op V-vormige prisdagen.",
|
||||
"usage_tips": "Dashboard-aftelling: Toon 'Trendwijziging over 1,5 u' als live aftelling. Voor automatiseringen: 'Als next_price_trend_change_in < 0,25 (15 min), bereid je voor op prijsrichtingswijziging'. Vult 'Volgende Prijstrend Wijziging' (tijdstempel) aan — gebruik het tijdstempel voor 'WANNEER' en deze sensor voor 'HOE LANG'."
|
||||
},
|
||||
"daily_rating": {
|
||||
"description": "Hoe de prijzen van vandaag zich verhouden tot historische gegevens",
|
||||
|
|
@ -446,6 +486,81 @@
|
|||
"long_description": "Geeft aan of je Tibber-abonnement momenteel actief is, beëindigd of wacht op activering. Een 'Actief'-status betekent dat je actief elektriciteit via Tibber afneemt.",
|
||||
"usage_tips": "Gebruik dit om je abonnementsstatus te monitoren. Stel meldingen in als de status verandert van 'Actief' om ononderbroken service te waarborgen."
|
||||
},
|
||||
"day_pattern_yesterday": {
|
||||
"description": "Gedetecteerd prijspatroon van gisterens elektriciteitsprijzen",
|
||||
"long_description": "Classificeert gisteren in een prijspatroon: Dal (goedkoop in het midden), Piek (duur in het midden), Dubbel Dal (twee goedkope perioden), Dubbele Piek (twee dure perioden), Vlak (weinig variatie), Stijgend, Dalend of Gemengd. De confidence- en CV-attributen tonen hoe betrouwbaar het patroon is gedetecteerd.",
|
||||
"usage_tips": "Gebruik het patroon van gisteren om automations te verfijnen: een Daldag herhaalt zich vaak de volgende dag en suggereert om goedkope middaguren in te plannen."
|
||||
},
|
||||
"day_pattern_today": {
|
||||
"description": "Gedetecteerd prijspatroon van de huidige elektriciteitsprijzen",
|
||||
"long_description": "Classificeert vandaag in een prijspatroon: Dal (goedkoop 's middags), Piek (duur 's middags), Dubbel Dal (W-vorm), Dubbele Piek (M-vorm), Vlak, Stijgend, Dalend of Gemengd. Attributen omvatten confidence (0–1), variatiecoëfficiënt, kniepunttijden en dagsegmenten.",
|
||||
"usage_tips": "Gebruik het dagpatroon om verbruik te verschuiven. Daldag: draai vaatwasser, wasmachine of laad de EV 's middags. Piekdag: gebruik apparaten vroeg in de ochtend of laat in de avond. Gebruik valley_start en valley_end voor precieze automations."
|
||||
},
|
||||
"day_pattern_tomorrow": {
|
||||
"description": "Gedetecteerd prijspatroon van de elektriciteitsprijzen van morgen",
|
||||
"long_description": "Classificeert morgen (zodra data beschikbaar is, doorgaans na 13:00) in een prijspatroon met hetzelfde algoritme als vandaag. De attributen valley_start/valley_end of peak_start/peak_end geven kniepunttijden voor het primaire extremum.",
|
||||
"usage_tips": "Stel avondautomations in die het patroon van morgen lezen en warmtepomp, autolader of boiler vooraf configureren. Combineer met de tomorrow_data_available binaire sensor."
|
||||
},
|
||||
"current_price_phase": {
|
||||
"description": "Of elektriciteitsprijzen momenteel stijgen, dalen of stabiel zijn – binnen de intra-dag prijsvorm van vandaag",
|
||||
"long_description": "Toont de richting van de prijsbeweging op dit moment door het actieve monotone segment van de prijscurve van vandaag te bepalen. De dagprijzen worden opgesplitst in opeenvolgende stijgende, dalende of vlakke stukken (fasen). Deze sensor toont in welke fase je je nu bevindt. Attributen zijn onder andere de start- en eindtijd van de fase, het prijsbereik (min/max/gemiddelde), de positie binnen de dagelijkse fasen (segment_index en segment_count) en de volledige lijst van alle fasen van vandaag (all_segments). Elke 15 minuten bijgewerkt.",
|
||||
"usage_tips": "Gebruik in automations: 'Als current_price_phase = dalend, wacht met flexibele lasten totdat de prijzen de bodem bereiken'. Combineer met de Prijspatroon Vandaag-sensor om zowel de algemene dagvorm als je huidige positie daarin te zien. Controleer segment_index en segment_count om te begrijpen hoe ver je bent in de intra-dag beweging. Gebruik all_segments in sjablonen of dashboards om het volledige dagverloop te tonen."
|
||||
},
|
||||
"next_price_phase": {
|
||||
"description": "De volgende intra-dag prijsfase – wat er na de huidige prijsbeweging komt",
|
||||
"long_description": "Toont het monotone prijssegment dat volgt na de momenteel actieve fase. Het attribuut start geeft precies aan wanneer de volgende fase begint, wat het eenvoudig maakt om automations nauwkeurig te plannen. Wanneer de huidige fase de laatste van de dag is (bijv. de laatste avonddaling), wordt deze sensor niet beschikbaar. Attributen: start (wanneer het begint), end, prijsbereik (min/max/gemiddelde), segment_index, segment_count. Elke 15 minuten bijgewerkt.",
|
||||
"usage_tips": "Gebruik in automations: 'Als next_price_phase = stijgend en next_price_phase.start binnen 1 uur is, start de wasmachine nu'. Of combineer met current_price_phase: 'Als current_price_phase = dalend en next_price_phase = vlak, naderen we het dagdieptepunt – goed moment voor flexibele lasten'. Het attribuut start is bijzonder waardevol: activeer automations precies wanneer de volgende fase begint."
|
||||
},
|
||||
"current_price_phase_end_time": {
|
||||
"description": "When the current intra-day price phase ends",
|
||||
"long_description": "Shows the exact timestamp when the currently active rising, falling, or flat price phase will end and transition to the next phase.",
|
||||
"usage_tips": "Use in automations to schedule tasks that must finish before prices change."
|
||||
},
|
||||
"current_price_phase_remaining_minutes": {
|
||||
"description": "Minutes remaining in the current price phase",
|
||||
"long_description": "Shows how many minutes are left in the current intra-day price phase. Updates every minute.",
|
||||
"usage_tips": "Use in automations: 'If current_price_phase = falling and remaining < 30, start the dishwasher now'."
|
||||
},
|
||||
"current_price_phase_duration": {
|
||||
"description": "Total duration of the current price phase",
|
||||
"long_description": "Shows the total length of the currently active price phase in hours.",
|
||||
"usage_tips": "Combine with remaining minutes to understand how far through the phase you are."
|
||||
},
|
||||
"current_price_phase_progress": {
|
||||
"description": "How far through the current price phase we are",
|
||||
"long_description": "Shows the percentage of the current intra-day price phase that has elapsed (0–100%). Updates every minute.",
|
||||
"usage_tips": "Use in dashboard cards to display a visual progress bar for the current price phase."
|
||||
},
|
||||
"next_rising_phase_start_time": {
|
||||
"description": "When the next rising price phase begins",
|
||||
"long_description": "Shows the timestamp of the next upcoming rising price segment across today's remaining phases and tomorrow's phases.",
|
||||
"usage_tips": "Use to schedule loads before prices start rising again."
|
||||
},
|
||||
"next_falling_phase_start_time": {
|
||||
"description": "When the next falling price phase begins",
|
||||
"long_description": "Shows the timestamp of the next upcoming falling price segment across today's remaining phases and tomorrow's phases.",
|
||||
"usage_tips": "Use to delay flexible loads until the next price drop starts."
|
||||
},
|
||||
"next_flat_phase_start_time": {
|
||||
"description": "When the next flat (stable) price phase begins",
|
||||
"long_description": "Shows the timestamp of the next upcoming flat price segment across today's remaining phases and tomorrow's phases.",
|
||||
"usage_tips": "Use for scheduling loads that need predictable costs over time."
|
||||
},
|
||||
"next_rising_phase_in_minutes": {
|
||||
"description": "Minutes until the next rising price phase begins",
|
||||
"long_description": "Shows how many minutes until the next rising price phase starts. Updates every minute.",
|
||||
"usage_tips": "Use in countdown automations to alert before the next price rise."
|
||||
},
|
||||
"next_falling_phase_in_minutes": {
|
||||
"description": "Minutes until the next falling price phase begins",
|
||||
"long_description": "Shows how many minutes until the next falling price phase starts. Updates every minute.",
|
||||
"usage_tips": "Use to time flexible loads: delay until the upcoming price drop."
|
||||
},
|
||||
"next_flat_phase_in_minutes": {
|
||||
"description": "Minutes until the next flat (stable) price phase begins",
|
||||
"long_description": "Shows how many minutes until the next flat price phase starts. Updates every minute.",
|
||||
"usage_tips": "Use to anticipate price stabilisation after a volatile phase."
|
||||
},
|
||||
"chart_data_export": {
|
||||
"description": "Data-export voor dashboard-integraties",
|
||||
"long_description": "Deze sensor roept de get_chartdata-service aan met jouw geconfigureerde YAML-configuratie en stelt het resultaat beschikbaar als entiteitsattributen. De status toont 'ready' wanneer data beschikbaar is, 'error' bij fouten, of 'pending' voor de eerste aanroep. Perfekt voor dashboard-integraties zoals ApexCharts die prijsgegevens uit entiteitsattributen moeten lezen.",
|
||||
|
|
@ -455,6 +570,61 @@
|
|||
"description": "Lichtgewicht metadata voor diagramconfiguratie",
|
||||
"long_description": "Biedt essentiële diagramconfiguratiewaarden als sensorattributen. Nuttig voor elke grafiekkaart die Y-as-grenzen nodig heeft. De sensor roept get_chartdata aan in alleen-metadata-modus (geen dataverwerking) en extraheert: yaxis_min, yaxis_max (gesuggereerd Y-asbereik voor optimale schaling). De status weerspiegelt het service-aanroepresultaat: 'ready' bij succes, 'error' bij fouten, 'pending' tijdens initialisatie.",
|
||||
"usage_tips": "Configureer via configuration.yaml onder tibber_prices.chart_metadata_config (optioneel: day, subunit_currency, resolution). De sensor wordt automatisch bijgewerkt bij prijsgegevenswijzigingen. Krijg toegang tot metadata vanuit attributen: yaxis_min, yaxis_max. Gebruik met config-template-card of elk hulpmiddel dat entiteitsattributen leest - perfect voor dynamische diagramconfiguratie zonder handmatige berekeningen."
|
||||
},
|
||||
"current_interval_price_rank_today": {
|
||||
"description": "Waar de huidige intervalprijs staat in de ranglijst van vandaag — percentielrang (0% = goedkoopste moment)",
|
||||
"long_description": "Toont hoe goedkoop of duur de prijs van het huidige kwartier is vergeleken met alle 96 kwartierslots van vandaag. 0% betekent dat dit het goedkoopste moment van de dag is. 50% betekent dat de helft van de slots goedkoper is. ca. 99% betekent het duurste slot van de dag. Formule: aantal goedkopere slots ÷ totaal slots × 100. Attributen: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Ideaal voor automatiseringen: 'Als current_interval_price_rank_today < 25, start de vaatwasser'. Een waarde van 0 garandeert het goedkoopste slot van de dag."
|
||||
},
|
||||
"current_interval_price_rank_tomorrow": {
|
||||
"description": "Percentielrang van de huidige intervalprijs in de ranglijst van morgen (0% = goedkoopste van morgen)",
|
||||
"long_description": "Toont hoe de huidige intervalprijs zich verhoudt tot alle 96 kwartierslots van morgen. 0% betekent dat de huidige prijs goedkoper is dan elk slot van morgen. Geeft 'Onbekend' terug totdat de data van morgen beschikbaar is (doorgaans na 13:00). Attributen: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Gebruik om te beslissen of wachten loont: 'Als current_interval_price_rank_tomorrow < 10, zijn er morgen nog goedkopere slots — stel de taak uit'."
|
||||
},
|
||||
"current_interval_price_rank_today_tomorrow": {
|
||||
"description": "Percentielrang van de huidige intervalprijs over vandaag+morgen samen (0% = goedkoopste van het twee-dagenvenster)",
|
||||
"long_description": "Toont hoe goedkoop of duur de huidige intervalprijs is vergeleken met alle slots over vandaag en morgen samen (tot 192 kwartierslots). Valt terug op alleen vandaag als de data van morgen nog niet beschikbaar is. Attributen: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Het breedste signaal voor 'is dit nu een goed moment?'. Gebruik 'Als current_interval_price_rank_today_tomorrow < 20, voer energieintensieve taak nu uit'."
|
||||
},
|
||||
"next_interval_price_rank_today": {
|
||||
"description": "Percentielrang van de volgende intervalprijs in de ranglijst van vandaag (0% = goedkoopste moment van vandaag)",
|
||||
"long_description": "Toont de percentielrang van het komende kwartier binnen de 96 slots van vandaag. Biedt een vooruitblik voordat het volgende interval begint. Attributen: `next_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Voor voorbereiding: 'Als next_interval_price_rank_today < 15, begin nu met voorverwarmen zodat het apparaat in het volgende goedkope slot draait'."
|
||||
},
|
||||
"next_interval_price_rank_today_tomorrow": {
|
||||
"description": "Percentielrang van de volgende intervalprijs over vandaag+morgen samen (0% = goedkoopste van het twee-dagenvenster)",
|
||||
"long_description": "Toont de percentielrang van het komende kwartier binnen de gecombineerde pool van vandaag+morgen (tot 192 slots). Valt terug op alleen vandaag. Attributen: `next_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Breedste vooruitblik: 'Als next_interval_price_rank_today_tomorrow < 10, is het volgende interval één van de goedkoopste van het twee-dagenvenster'."
|
||||
},
|
||||
"previous_interval_price_rank_today": {
|
||||
"description": "Percentielrang van de vorige intervalprijs in de ranglijst van vandaag (0% = goedkoopste moment van vandaag)",
|
||||
"long_description": "Toont de percentielrang van het zojuist afgelopen kwartier binnen de 96 slots van vandaag. Handig om te loggen hoe goedkoop of duur het vorige interval was. Attributen: `previous_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Nuttig voor retrospectieve automatiseringen of logging: 'Leg de kostencategorie van het vorige interval vast voor energierapporten'."
|
||||
},
|
||||
"previous_interval_price_rank_today_tomorrow": {
|
||||
"description": "Percentielrang van de vorige intervalprijs over vandaag+morgen samen (0% = goedkoopste van het twee-dagenvenster)",
|
||||
"long_description": "Toont de percentielrang van het zojuist afgelopen kwartier binnen de gecombineerde pool van vandaag+morgen (tot 192 slots). Valt terug op alleen vandaag. Attributen: `previous_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Voor retrospectieve vergelijkingen over een twee-dagenvenster."
|
||||
},
|
||||
"current_hour_price_rank_today": {
|
||||
"description": "Percentielrang van het huidige voortschrijdend uurgemiddelde in de ranglijst van vandaag (0% = goedkoopste uur vandaag)",
|
||||
"long_description": "Toont waar het voortschrijdend gemiddelde van 5 intervallen (2 voor + huidig + 2 na, ca. 1 uur) staat in de prijsranglijst van vandaag. Egaliseer prijspieken voor een bredere inschatting. Attributen: `current_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Voor taken van ongeveer een uur: 'Als current_hour_price_rank_today < 20, is dit een goedkoop uur — start de wasmachine'."
|
||||
},
|
||||
"current_hour_price_rank_today_tomorrow": {
|
||||
"description": "Voortschrijdend uurgemiddelde prijsrang over vandaag+morgen samen (0% = goedkoopste uur van het twee-dagenvenster)",
|
||||
"long_description": "Toont waar het voortschrijdend gemiddelde van 5 intervallen (±2 intervallen, ca. 1 uur) staat in de gecombineerde ranglijst vandaag+morgen (tot 192 slots). Valt terug op alleen vandaag. Attributen: `current_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Breedste uursignaal: 'Als current_hour_price_rank_today_tomorrow < 15, is dit één van de goedkoopste uren van het twee-dagenvenster'."
|
||||
},
|
||||
"next_hour_price_rank_today": {
|
||||
"description": "Percentielrang van het volgende voortschrijdend uurgemiddelde in de ranglijst van vandaag (0% = goedkoopste uur vandaag)",
|
||||
"long_description": "Toont waar het voortschrijdend gemiddelde van 5 intervallen gecentreerd op het volgende interval staat in de prijsranglijst van vandaag. Maakt planning een uur vooruit mogelijk. Attributen: `next_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Plan een uur vooruit: 'Als next_hour_price_rank_today < 20, is het komende uur goedkoop — start nu een taak'."
|
||||
},
|
||||
"next_hour_price_rank_today_tomorrow": {
|
||||
"description": "Volgend voortschrijdend uurgemiddelde prijsrang over vandaag+morgen samen (0% = goedkoopste uur van het twee-dagenvenster)",
|
||||
"long_description": "Toont waar het voortschrijdend gemiddelde van 5 intervallen gecentreerd op het volgende interval staat in de gecombineerde ranglijst vandaag+morgen (tot 192 slots). Valt terug op alleen vandaag. Attributen: `next_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Breedste uurvooruitblik: 'Als next_hour_price_rank_today_tomorrow < 10, is het komende uur één van de goedkoopste van het twee-dagenvenster'."
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
|
|
@ -473,6 +643,21 @@
|
|||
"long_description": "Wordt geactiveerd wanneer de huidige prijs in de onderste 20% van de prijzen van vandaag ligt",
|
||||
"usage_tips": "Gebruik dit om apparaten met hoog verbruik te laten draaien tijdens de goedkoopste intervallen"
|
||||
},
|
||||
"in_rising_price_phase": {
|
||||
"description": "Whether prices are currently in a rising phase",
|
||||
"long_description": "Turns ON when the current intra-day price phase is rising.",
|
||||
"usage_tips": "Use in automations to delay or avoid running flexible loads during rising prices."
|
||||
},
|
||||
"in_falling_price_phase": {
|
||||
"description": "Whether prices are currently in a falling phase",
|
||||
"long_description": "Turns ON when the current intra-day price phase is falling.",
|
||||
"usage_tips": "Use in automations to take advantage of falling prices for flexible loads."
|
||||
},
|
||||
"in_flat_price_phase": {
|
||||
"description": "Whether prices are currently in a flat (stable) phase",
|
||||
"long_description": "Turns ON when the current intra-day price phase is flat and prices are relatively stable.",
|
||||
"usage_tips": "Use for loads that prefer price stability rather than the lowest price."
|
||||
},
|
||||
"connection": {
|
||||
"description": "Of de verbinding met de Tibber API werkt",
|
||||
"long_description": "Geeft aan of de integratie succesvol verbinding kan maken met de Tibber API",
|
||||
|
|
|
|||
|
|
@ -227,45 +227,80 @@
|
|||
"long_description": "Visar genomsnittspriset för nästa 48 intervaller (12 timmar) från och med nästa 15-minuters intervall.",
|
||||
"usage_tips": "Absolut priströskel: Strategiska beslut med pristak. Fortsätt endast om 12t genomsnitt är under ditt maximalt acceptabla pris. Bra för uppskjutbara stora laster."
|
||||
},
|
||||
"price_trend_1h": {
|
||||
"description": "Pristrend för nästa timme",
|
||||
"long_description": "Jämför nuvarande intervallpris med genomsnitt av nästa 1 timme (4 intervaller). Stigande om framtid är >5% högre, fallande om >5% lägre, annars stabil.",
|
||||
"usage_tips": "Relativ optimering: 'fallande' = vänta, priser sjunker. 'stigande' = agera nu eller du betalar mer. 'stabil' = pris spelar ingen större roll nu. Fungerar oberoende av absolut prisnivå."
|
||||
"price_outlook_1h": {
|
||||
"description": "Prisöversikt för nästa timme",
|
||||
"long_description": "Jämför ditt nuvarande pris med genomsnittet av alla intervaller under nästa timme (4 intervaller). Alla översiktssensorer (1h–12h) delar samma utgångspunkt: ditt nuvarande pris — de skiljer sig bara i fönsterstorlek. Stigande/fallande vid ±3%, kraftigt vid ±9% (konfigurerbart, volatilitetsadaptivt).",
|
||||
"usage_tips": "Beslutstöd: 'stigande' = AGERA NU, ditt nuvarande pris är billigare än fönstergenomsnittet. 'fallande' = VÄNTA, fönstergenomsnittet är billigare än nu. 'stabil' = timing spelar ingen roll."
|
||||
},
|
||||
"price_trend_2h": {
|
||||
"description": "Pristrend för nästa 2 timmar",
|
||||
"long_description": "Jämför nuvarande intervallpris med genomsnitt av nästa 2 timmar (8 intervaller). Stigande om framtid är >5% högre, fallande om >5% lägre, annars stabil.",
|
||||
"usage_tips": "Relativ optimering: Idealisk för apparater. 'fallande' betyder bättre priser kommer om 2t - skjut upp om möjligt. Hittar bästa timing inom ditt tillgängliga fönster, oavsett säsong."
|
||||
"price_outlook_2h": {
|
||||
"description": "Prisöversikt för nästa 2 timmar",
|
||||
"long_description": "Jämför ditt nuvarande pris med genomsnittet av alla intervaller under de nästa 2 timmarna (8 intervaller). 'stigande' = nuvarande pris under 2h-fönstergenomsnittet; 'fallande' = fönstergenomsnittet är billigare än nu. Stigande/fallande vid ±3%, kraftigt vid ±9%.",
|
||||
"usage_tips": "Apparater: 'stigande' = starta nu, du har ett bra pris jämfört med nästa 2h. Kombinera med price_trajectory_2h för att se om priserna fortfarande faller eller redan stiger."
|
||||
},
|
||||
"price_trend_3h": {
|
||||
"description": "Pristrend för nästa 3 timmar",
|
||||
"long_description": "Jämför nuvarande intervallpris med genomsnitt av nästa 3 timmar (12 intervaller). Stigande om framtid är >5% högre, fallande om >5% lägre, annars stabil.",
|
||||
"usage_tips": "Relativ optimering: För Eco-program. 'fallande' betyder priser sjunker >5% - värt att vänta. Fungerar under alla säsonger. Kombinera med avg-sensor för prisgräns: endast när avg < din gräns OCH trend inte 'fallande'."
|
||||
"price_outlook_3h": {
|
||||
"description": "Prisöversikt för nästa 3 timmar",
|
||||
"long_description": "Jämför ditt nuvarande pris med genomsnittet av alla intervaller under de nästa 3 timmarna (12 intervaller). 'stigande' = nuvarande pris under 3h-fönstergenomsnittet; 'fallande' = fönstergenomsnittet är billigare än nu. Stigande/fallande vid ±3%, kraftigt vid ±9%.",
|
||||
"usage_tips": "Eco-program: 'stigande' = starta eco-cykeln nu, priser är i genomsnitt högre i fönstret. 'fallande' = vänta, billigare genomsnittligt fönster kommer."
|
||||
},
|
||||
"price_trend_4h": {
|
||||
"description": "Pristrend för nästa 4 timmar",
|
||||
"long_description": "Jämför nuvarande intervallpris med genomsnitt av nästa 4 timmar (16 intervaller). Stigande om framtid är >5% högre, fallande om >5% lägre, annars stabil.",
|
||||
"usage_tips": "Relativ optimering: Värmepump/batteribeslut. 'fallande' betyder bättre laddningsfönster kommer. Hittar alltid relativt bästa tid - oavsett om priserna är 10 öre eller 50 öre. Använd avg-sensor för absolut gräns."
|
||||
"price_outlook_4h": {
|
||||
"description": "Prisöversikt för nästa 4 timmar",
|
||||
"long_description": "Jämför ditt nuvarande pris med genomsnittet av alla intervaller under de nästa 4 timmarna (16 intervaller). 'stigande' = nuvarande pris under 4h-fönstergenomsnittet; 'fallande' = fönstergenomsnittet är billigare än nu. Stigande/fallande vid ±3%, kraftigt vid ±9%.",
|
||||
"usage_tips": "Värmepump/batteri: 'stigande' = ladda nu, du är på en relativ lågpunkt. Kombinera med price_trajectory_4h för att se om priserna fortfarande faller eller redan stiger."
|
||||
},
|
||||
"price_trend_5h": {
|
||||
"description": "Pristrend för nästa 5 timmar",
|
||||
"long_description": "Jämför nuvarande intervallpris med genomsnitt av nästa 5 timmar (20 intervaller). Stigande om framtid är >5% högre, fallande om >5% lägre, annars stabil.",
|
||||
"usage_tips": "Relativ optimering: Utökade operationer. Anpassar sig till marknaden - hittar bästa relativa timing i vilken prismiljö som helst. 'stabil/stigande' = bra tid att starta inom ditt planeringsfönster."
|
||||
"price_outlook_5h": {
|
||||
"description": "Prisöversikt för nästa 5 timmar",
|
||||
"long_description": "Jämför ditt nuvarande pris med genomsnittet av alla intervaller under de nästa 5 timmarna (20 intervaller). 'stigande' = nuvarande pris under 5h-fönstergenomsnittet; 'fallande' = fönstergenomsnittet är billigare än nu. Stigande/fallande vid ±3%, kraftigt vid ±9%.",
|
||||
"usage_tips": "Utökade cykler: 'stigande' eller 'stabil' = bra tid att starta. 'fallande' = vänta om din planering tillåter det."
|
||||
},
|
||||
"price_trend_6h": {
|
||||
"description": "Pristrend för nästa 6 timmar",
|
||||
"long_description": "Jämför nuvarande intervallpris med genomsnitt av nästa 6 timmar (24 intervaller). Stigande om framtid är >5% högre, fallande om >5% lägre, annars stabil.",
|
||||
"usage_tips": "Relativ optimering: Kvällsbeslut. 'fallande' = priser förbättras avsevärt om du väntar. Inga fasta trösklar behövs - justerar automatiskt till vinter/sommar prisnivåer."
|
||||
"price_outlook_6h": {
|
||||
"description": "Prisöversikt för nästa 6 timmar",
|
||||
"long_description": "Jämför ditt nuvarande pris med genomsnittet av alla intervaller under de nästa 6 timmarna (24 intervaller). 'stigande' = nuvarande pris under 6h-fönstergenomsnittet; 'fallande' = fönstergenomsnittet är billigare än nu. Stigande/fallande vid ±3%, kraftigt vid ±9%.",
|
||||
"usage_tips": "Kvällsbeslut: 'stigande' = använd el nu medan den är relativt billig. 'fallande' = kvälls-/nattpriserna blir bättre i genomsnitt, vänta om möjligt."
|
||||
},
|
||||
"price_trend_8h": {
|
||||
"description": "Pristrend för nästa 8 timmar",
|
||||
"long_description": "Jämför nuvarande intervallpris med genomsnitt av nästa 8 timmar (32 intervaller). Stigande om framtid är >5% högre, fallande om >5% lägre, annars stabil.",
|
||||
"usage_tips": "Relativ optimering: Nattplanering. 'fallande' betyder att vänta till natten lönar sig (>5% billigare). Fungerar året runt utan manuella tröskel justeringar. Starta när 'stabil' eller 'stigande'."
|
||||
"price_outlook_8h": {
|
||||
"description": "Prisöversikt för nästa 8 timmar",
|
||||
"long_description": "Jämför ditt nuvarande pris med genomsnittet av alla intervaller under de nästa 8 timmarna (32 intervaller). 'stigande' = nuvarande pris under 8h-fönstergenomsnittet; 'fallande' = fönstergenomsnittet är billigare än nu. Stigande/fallande vid ±3%, kraftigt vid ±9%.",
|
||||
"usage_tips": "Nattplanering: 'stigande' = ikväll/imorgon blir dyrare i genomsnitt, använd el nu. 'fallande' = nattpriserna blir billigare, värt att vänta."
|
||||
},
|
||||
"price_trend_12h": {
|
||||
"description": "Pristrend för nästa 12 timmar",
|
||||
"long_description": "Jämför nuvarande intervallpris med genomsnitt av nästa 12 timmar (48 intervaller). Stigande om framtid är >5% högre, fallande om >5% lägre, annars stabil.",
|
||||
"usage_tips": "Relativ optimering: Långsiktiga strategiska beslut. 'fallande' = avsevärt bättre priser kommer ikväll/imorgon. Hittar optimal timing i vilket marknadsläge som helst. Bäst kombinerad med avg-sensor prisgräns."
|
||||
"price_outlook_12h": {
|
||||
"description": "Prisöversikt för nästa 12 timmar",
|
||||
"long_description": "Jämför ditt nuvarande pris med genomsnittet av alla intervaller under de nästa 12 timmarna (48 intervaller). 'stigande' = nuvarande pris under 12h-fönstergenomsnittet; 'fallande' = fönstergenomsnittet är billigare än nu. Stigande/fallande vid ±3%, kraftigt vid ±9%.",
|
||||
"usage_tips": "Strategiska beslut: 'stigande' = du är på en lågpunkt relativt till nästa 12h, bra tid för strömkrävande uppgifter. 'fallande' = avsevärt bättre genomsnittspriser kommer."
|
||||
},
|
||||
"price_trajectory_2h": {
|
||||
"description": "Prisutveckling inom nästa 2-timmars fönster",
|
||||
"long_description": "Compares the average of the first hour with the average of the second hour within the next 2-hour window. 'rising' = second half more expensive than first half. 'falling' = second half cheaper. Rising/falling at ±3%, strongly at ±9% (configurable, volatility-adaptive).",
|
||||
"usage_tips": "At a price minimum: price_outlook_2h may show 'falling' (window average is below current), but price_trajectory_2h shows 'rising' (second half more expensive) — revealing the upcoming reversal. 'outlook: falling + trajectory: rising' = you're AT the minimum, act now."
|
||||
},
|
||||
"price_trajectory_3h": {
|
||||
"description": "Prisutveckling inom nästa 3-timmars fönster",
|
||||
"long_description": "Compares the average of the first 1.5 hours with the average of the second 1.5 hours within the next 3-hour window. 'rising' = prices are climbing; 'falling' = prices are dropping. Rising/falling at ±3%, strongly at ±9%.",
|
||||
"usage_tips": "Appliance timing: 'outlook: strongly_falling + trajectory: rising' = you're at or past the minimum, prices are already recovering — start now."
|
||||
},
|
||||
"price_trajectory_4h": {
|
||||
"description": "Prisutveckling inom nästa 4-timmars fönster",
|
||||
"long_description": "Compares the average of the first 2 hours with the average of the second 2 hours within the next 4-hour window. 'rising' = prices are climbing; 'falling' = prices are dropping. Complements price_outlook_4h. Rising/falling at ±3%, strongly at ±9%.",
|
||||
"usage_tips": "Heat pump charging: 'rising' = first half is cheaper, charge now. 'falling' = second half is cheaper, wait. Combine with outlook: if both rising, very strong signal to act now."
|
||||
},
|
||||
"price_trajectory_5h": {
|
||||
"description": "Prisutveckling inom nästa 5-timmars fönster",
|
||||
"long_description": "Compares the average of the first 2.5 hours with the average of the second 2.5 hours within the next 5-hour window. 'rising' = prices are climbing; 'falling' = prices are dropping. Rising/falling at ±3%, strongly at ±9%.",
|
||||
"usage_tips": "Long cycles: 'outlook: rising + trajectory: rising' = clear signal to start now. 'outlook: falling + trajectory: rising' = you're near the bottom, good time to start."
|
||||
},
|
||||
"price_trajectory_6h": {
|
||||
"description": "Prisutveckling inom nästa 6-timmars fönster",
|
||||
"long_description": "Compares the average of the first 3 hours with the average of the second 3 hours within the next 6-hour window. 'rising' = prices are climbing; 'falling' = prices are dropping. Rising/falling at ±3%, strongly at ±9%.",
|
||||
"usage_tips": "Evening/overnight planning: 'falling' at evening peak = overnight will be cheaper, postpone. 'rising' in the morning = current morning prices are the low."
|
||||
},
|
||||
"price_trajectory_8h": {
|
||||
"description": "Prisutveckling inom nästa 8-timmars fönster",
|
||||
"long_description": "Compares the average of the first 4 hours with the average of the second 4 hours within the next 8-hour window. 'rising' = prices are climbing; 'falling' = prices are dropping. Rising/falling at ±3%, strongly at ±9%.",
|
||||
"usage_tips": "Overnight charging: 'rising' during evening = first half of night is cheapest, start charging earlier. 'falling' = second half will be cheapest, delay start."
|
||||
},
|
||||
"price_trajectory_12h": {
|
||||
"description": "Prisutveckling inom nästa 12-timmars fönster",
|
||||
"long_description": "Compares the average of the first 6 hours with the average of the second 6 hours within the next 12-hour window. 'rising' = prices are climbing; 'falling' = prices are dropping. Rising/falling at ±3%, strongly at ±9%.",
|
||||
"usage_tips": "Day-ahead planning: 'rising' at midnight = first half of day is cheaper, schedule morning loads. 'falling' = afternoon/evening scheduling is better."
|
||||
},
|
||||
"current_price_trend": {
|
||||
"description": "Nuvarande pristrend-riktning och hur länge den varar",
|
||||
|
|
@ -274,8 +309,13 @@
|
|||
},
|
||||
"next_price_trend_change": {
|
||||
"description": "När nästa betydande pristrendändring kommer att inträffa",
|
||||
"long_description": "Skannar de nästa 24 timmarna (96 intervaller) för att hitta när pristrenden (stigande/fallande/stabil) kommer att ändras från nuvarande momentum. Bestämmer först nuvarande trend med viktad 1h tillbakablick (känner igen pågående trender), hittar sedan reverseringen. Använder volatilitetsadaptiva tröskelvärden (3 % momentum-detektering, marknadsanpassad framtidsjämförelse). Returnerar tidsstämpeln när ändringen förväntas.",
|
||||
"usage_tips": "Händelsestyrd automatisering: Utlös åtgärder NÄR trenden ändras, inte OM X timmar. Exempel: 'Ladda EV när nästa trendändring visar fallande priser' eller 'Starta diskmaskin innan priserna stiger'. Kompletterar tidsfönster-sensorer (price_trend_Xh) som svarar på 'KOMMER priserna att vara högre om X timmar?'"
|
||||
"long_description": "Skannar de nästa 24 timmarna (96 intervaller) för att hitta när pristrend-riktningen kommer att ändras. Bara riktningsändringar räknas: stigande/kraftigt stigande är en grupp, fallande/kraftigt fallande en annan, stabil är egen. En ändring från stigande till kraftigt stigande är INTE en trendändring. Använder volatilitetsadaptiva tröskelvärden (standard: ±3%/±9%) med hysteres (standard: 3 på varandra följande intervaller). Returnerar tidstämpeln när ändringen förväntas.\n\nVIKTIGT — Hur detektionen fungerar: Vid varje framtida intervall jämför sensorn priset för det intervallet med MEDELVÄRDET av de följande 3 timmarna (3h framåtblickande medelvärde). Det innebär att sensorn upptäcker när de genomsnittliga kostnaderna för de nästa 3 timmarna redan har bytt riktning — inte när det exakta prisminimumet eller -maximumet nås.\n\nPå V-formade prisdagar: Under ett prisfall mot ett minimum börjar 3h-framåtfönstret inkludera priser på den stigande flanken innan det faktiska minimumet nås. När dessa stigande priser drar upp 3h-medelvärdet över det aktuella priset rapporterar sensorn 'trenden ändras nu'. Detta inträffar typiskt 30–60 minuter före det exakta prisminimumet. Det är avsiktligt — sensorn svarar på 'när kommer den övergripande RIKTNINGEN att ändras?' snarare än 'när är den exakta vändpunkten?'.",
|
||||
"usage_tips": "Händelsestyrd automatisering: Utlös åtgärder NÄR trenden ändras, inte OM X timmar. Exempel: 'Ladda EV när nästa trendändring visar fallande priser' eller 'Starta diskmaskin innan priserna stiger'. Kompletterar tidsfönster-sensorer (price_outlook_Xh) som svarar på 'KOMMER priserna att vara högre om X timmar?'\n\nObservera: På skarpa V-formade prisdagar kan den här sensorn aktiveras 30–60 minuter före det exakta prisminimumet. Om du behöver en exakt vändpunkt, jämför den med starten av Bästa Pris-perioden — perioden startar vid det faktiskt billigaste fönstret. Den här sensorn lämpar sig bättre för automatiseringsutlösare av typen 'förbered för en förändring' där en kort tidig varning är acceptabel."
|
||||
},
|
||||
"next_price_trend_change_in": {
|
||||
"description": "Tid till nästa pristrendändring",
|
||||
"long_description": "Visar hur lång tid det är kvar till nästa betydande pristrendändring inträffar. Värdet visas i timmar (t.ex. 2,25 h) för dashboards. Delar samma analys som tidstämpel-sensorn 'Nästa pristrendändring' men presenterar det som en nedtellningsvaraktighet. Uppdateras varje minut för noggranna nedtellningar. Visar 'Okänd' när ingen trendändring förväntas inom de närmaste 24 timmarna. Se 'Nästa pristrendändring' för en förklaring av 3h-framåtblickande detektionsmekanismen och dess beteende på V-formade prisdagar.",
|
||||
"usage_tips": "Dashboard-nedtellning: Visa 'Trendändring om 1,5 h' som live nedtellning. För automatiseringar: 'Om next_price_trend_change_in < 0,25 (15 min), förbered för prisriktningsändring'. Kompletterar 'Nästa pristrendändring' (tidstämpel) — använd tidstämpeln för 'NÄR' och denna sensor för 'HUR LÄNGE'."
|
||||
},
|
||||
"daily_rating": {
|
||||
"description": "Hur dagens priser jämförs med historiska data",
|
||||
|
|
@ -446,6 +486,81 @@
|
|||
"long_description": "Visar om ditt Tibber-abonnemang för närvarande är aktivt, har avslutats eller väntar på aktivering. En status 'Aktiv' betyder att du aktivt tar emot elektricitet genom Tibber.",
|
||||
"usage_tips": "Använd detta för att övervaka din abonnemangsstatus. Ställ in varningar om statusen ändras från 'Aktiv' för att säkerställa oavbruten service."
|
||||
},
|
||||
"day_pattern_yesterday": {
|
||||
"description": "Detekterat prismönster för gårdagens elpriser",
|
||||
"long_description": "Klassificerar igår i ett prismönster: Dal (billigt på mitten), Topp (dyrt på mitten), Dubbeldal (W-form, två billiga perioder), Dubbeltopp (M-form, två dyra toppar), Flat (liten variation), Stigande, Fallande eller Blandad. Konfidensen och CV-attributen visar hur tillförlitligt mönstret detekterades.",
|
||||
"usage_tips": "Använd gårdagens mönster för att förfina automationer: ett Dalmönster upprepas ofta nästa dag och tyder på att du bör förplanera billiga middagstimmar."
|
||||
},
|
||||
"day_pattern_today": {
|
||||
"description": "Detekterat prismönster för dagens elpriser",
|
||||
"long_description": "Klassificerar idag i ett prismönster: Dal (billigt på middagen), Topp (dyrt på middagen), Dubbeldal (W-form), Dubbeltopp (M-form), Flat, Stigande, Fallande eller Blandad. Attributen inkluderar konfidenspoäng (0–1), variationskoefficient, knäpunkttider och dagsegment.",
|
||||
"usage_tips": "Använd dagens mönster för att flytta förbrukning. Daldag: kör diskmaskinen, tvättmaskinen eller ladda elbilen på middagen. Toppdag: kör apparater tidigt på morgonen eller sent på kvällen. Använd valley_start och valley_end för precisa automationer."
|
||||
},
|
||||
"day_pattern_tomorrow": {
|
||||
"description": "Detekterat prismönster för morgondagens elpriser",
|
||||
"long_description": "Klassificerar imorgon (när data finns tillgänglig, vanligtvis efter 13:00) i ett prismönster med samma algoritm som idag. Attributen valley_start/valley_end eller peak_start/peak_end ger knäpunkttider för det primära extremvärdet.",
|
||||
"usage_tips": "Ställ in kvällsautomationer som läser morgondagens mönster och förkonfigurerar värmepump, billaddare eller varmvattenberedare. Kombinera med tomorrow_data_available-binärsensorn."
|
||||
},
|
||||
"current_price_phase": {
|
||||
"description": "Om elpriserna för närvarande stiger, faller eller är stabila – inom dagens intra-dag prisform",
|
||||
"long_description": "Visar riktningen på priserörelsen just nu genom att identifiera det aktiva monotona segmentet i dagens priskurva. Dagens priser delas upp i på varandra följande stigande, fallande eller flata sträckor (faser). Denna sensor visar vilken fas som är aktiv just nu. Attribut inkluderar fasens start- och sluttid, prisintervall (min/max/medel), position bland dagens faser (segment_index och segment_count) samt den fullständiga listan över alla dagens faser (all_segments). Uppdateras var 15:e minut.",
|
||||
"usage_tips": "Använd i automationer: 'Om current_price_phase = fallande, vänta med flexibla laster tills priserna når botten'. Kombinera med Dagens Prismönster för att se både den övergripande dagformen och din aktuella position i den. Kontrollera segment_index och segment_count för att förstå hur långt in i rörelsen du är. Använd all_segments i mallar eller dashboards för att visa hela dagsförloppet."
|
||||
},
|
||||
"next_price_phase": {
|
||||
"description": "Nästa intra-dag prisfas – vad som kommer efter den aktuella prisbevegelsen",
|
||||
"long_description": "Visar det monotona prissegment som följer efter den för närvarande aktiva fasen. Attributet start visar exakt när nästa fas börjar, vilket gör det enkelt att schemalägga automationer. När den aktuella fasen är den sista för dagen (t.ex. den sista kvällsminskningen), blir denna sensor otillgänglig. Attribut: start (när den börjar), end, prisintervall (min/max/medel), segment_index, segment_count. Uppdateras var 15:e minut.",
|
||||
"usage_tips": "Använd i automationer: 'Om next_price_phase = stigande och next_price_phase.start är inom 1 timme, starta tvättmaskinen nu'. Eller kombinera med current_price_phase: 'Om current_price_phase = fallande och next_price_phase = flat, närmar vi oss dagens lågpunkt – bra tid för flexibla laster'. Attributet start är särskilt värdefull: utlös automationer precis när nästa fas börjar."
|
||||
},
|
||||
"current_price_phase_end_time": {
|
||||
"description": "When the current intra-day price phase ends",
|
||||
"long_description": "Shows the exact timestamp when the currently active rising, falling, or flat price phase will end and transition to the next phase.",
|
||||
"usage_tips": "Use in automations to schedule tasks that must finish before prices change."
|
||||
},
|
||||
"current_price_phase_remaining_minutes": {
|
||||
"description": "Minutes remaining in the current price phase",
|
||||
"long_description": "Shows how many minutes are left in the current intra-day price phase. Updates every minute.",
|
||||
"usage_tips": "Use in automations: 'If current_price_phase = falling and remaining < 30, start the dishwasher now'."
|
||||
},
|
||||
"current_price_phase_duration": {
|
||||
"description": "Total duration of the current price phase",
|
||||
"long_description": "Shows the total length of the currently active price phase in hours.",
|
||||
"usage_tips": "Combine with remaining minutes to understand how far through the phase you are."
|
||||
},
|
||||
"current_price_phase_progress": {
|
||||
"description": "How far through the current price phase we are",
|
||||
"long_description": "Shows the percentage of the current intra-day price phase that has elapsed (0–100%). Updates every minute.",
|
||||
"usage_tips": "Use in dashboard cards to display a visual progress bar for the current price phase."
|
||||
},
|
||||
"next_rising_phase_start_time": {
|
||||
"description": "When the next rising price phase begins",
|
||||
"long_description": "Shows the timestamp of the next upcoming rising price segment across today's remaining phases and tomorrow's phases.",
|
||||
"usage_tips": "Use to schedule loads before prices start rising again."
|
||||
},
|
||||
"next_falling_phase_start_time": {
|
||||
"description": "When the next falling price phase begins",
|
||||
"long_description": "Shows the timestamp of the next upcoming falling price segment across today's remaining phases and tomorrow's phases.",
|
||||
"usage_tips": "Use to delay flexible loads until the next price drop starts."
|
||||
},
|
||||
"next_flat_phase_start_time": {
|
||||
"description": "When the next flat (stable) price phase begins",
|
||||
"long_description": "Shows the timestamp of the next upcoming flat price segment across today's remaining phases and tomorrow's phases.",
|
||||
"usage_tips": "Use for scheduling loads that need predictable costs over time."
|
||||
},
|
||||
"next_rising_phase_in_minutes": {
|
||||
"description": "Minutes until the next rising price phase begins",
|
||||
"long_description": "Shows how many minutes until the next rising price phase starts. Updates every minute.",
|
||||
"usage_tips": "Use in countdown automations to alert before the next price rise."
|
||||
},
|
||||
"next_falling_phase_in_minutes": {
|
||||
"description": "Minutes until the next falling price phase begins",
|
||||
"long_description": "Shows how many minutes until the next falling price phase starts. Updates every minute.",
|
||||
"usage_tips": "Use to time flexible loads: delay until the upcoming price drop."
|
||||
},
|
||||
"next_flat_phase_in_minutes": {
|
||||
"description": "Minutes until the next flat (stable) price phase begins",
|
||||
"long_description": "Shows how many minutes until the next flat price phase starts. Updates every minute.",
|
||||
"usage_tips": "Use to anticipate price stabilisation after a volatile phase."
|
||||
},
|
||||
"chart_data_export": {
|
||||
"description": "Dataexport för dashboard-integrationer",
|
||||
"long_description": "Denna sensor anropar get_chartdata-tjänsten med din konfigurerade YAML-konfiguration och exponerar resultatet som entitetsattribut. Statusen visar 'ready' när data är tillgänglig, 'error' vid fel, eller 'pending' före första anropet. Perfekt för dashboard-integrationer som ApexCharts som behöver läsa prisdata från entitetsattribut.",
|
||||
|
|
@ -455,6 +570,61 @@
|
|||
"description": "Lättviktig metadata för diagramkonfiguration",
|
||||
"long_description": "Tillhandahåller väsentliga diagramkonfigurationsvärden som sensorattribut. Användbart för vilket diagramkort som helst som behöver Y-axelgränser. Sensorn anropar get_chartdata med endast-metadata-läge (ingen databehandling) och extraherar: yaxis_min, yaxis_max (föreslagen Y-axelomfång för optimal skalning). Statusen återspeglar tjänstanropsresultatet: 'ready' vid framgång, 'error' vid fel, 'pending' under initialisering.",
|
||||
"usage_tips": "Konfigurera via configuration.yaml under tibber_prices.chart_metadata_config (valfritt: day, subunit_currency, resolution). Sensorn uppdateras automatiskt vid pris dataändringar. Få tillgång till metadata från attribut: yaxis_min, yaxis_max. Använd med config-template-card eller vilket verktyg som helst som läser entitetsattribut - perfekt för dynamisk diagramkonfiguration utan manuella beräkningar."
|
||||
},
|
||||
"current_interval_price_rank_today": {
|
||||
"description": "Var det aktuella intervallpriset placerar sig i dagens rangordning — percentilrang (0 % = billigaste tillfället)",
|
||||
"long_description": "Visar hur billigt eller dyrt det aktuella kvartspriset är jämfört med alla 96 kvartsslotar idag. 0 % innebär att detta är det billigaste tillfället under dagen. 50 % innebär att hälften av dagens slotar är billigare. ca. 99 % innebär det dyraste slottet. Formel: antal billigare slotar ÷ totalt antal × 100. Attribut: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Idealiskt för automatiseringar: 'Om current_interval_price_rank_today < 25, starta diskmaskinen'. Ett värde på 0 garanterar att du är på det billigaste slottet under dagen."
|
||||
},
|
||||
"current_interval_price_rank_tomorrow": {
|
||||
"description": "Percentilrang för aktuellt intervallpris i morgondagens rangordning (0 % = billigaste imorgon)",
|
||||
"long_description": "Visar hur det aktuella intervallpriset jämförs med alla 96 kvartslotar imorgon. 0 % innebär att det aktuella priset är billigare än varje slot imorgon. Returnerar 'Okänd' tills morgondagens data är tillgänglig (vanligtvis efter kl. 13:00). Attribut: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Används för att avgöra om väntan lönar sig: 'Om current_interval_price_rank_tomorrow < 10, finns det ännu billigare slotar imorgon — skjut upp uppgiften'."
|
||||
},
|
||||
"current_interval_price_rank_today_tomorrow": {
|
||||
"description": "Percentilrang för aktuellt intervallpris över idag+imorgon sammantaget (0 % = billigaste i tvådagarsperioden)",
|
||||
"long_description": "Visar hur billigt eller dyrt det aktuella intervallpriset är jämfört med alla slotar idag och imorgon tillsammans (upp till 192 kvartsslotar). Faller tillbaka på enbart idag när morgondagens data inte är tillgänglig. Attribut: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Den bredaste signalen för 'är det ett bra tillfälle nu?'. Använd 'Om current_interval_price_rank_today_tomorrow < 20, kör energikrävande uppgift nu'."
|
||||
},
|
||||
"next_interval_price_rank_today": {
|
||||
"description": "Percentilrang för nästa intervalls pris i dagens rangordning (0 % = billigaste tillfället idag)",
|
||||
"long_description": "Visar percentilrangen för det kommande kvartalet inom dagens 96 slotar. Ger en förhandstitt innan nästa intervall börjar. Attribut: `next_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "För förberedelse: 'Om next_interval_price_rank_today < 15, börja förvärmningen nu så att enheten körs under nästa billiga slot'."
|
||||
},
|
||||
"next_interval_price_rank_today_tomorrow": {
|
||||
"description": "Percentilrang för nästa intervalls pris över idag+imorgon sammantaget (0 % = billigaste i tvådagarsperioden)",
|
||||
"long_description": "Visar percentilrangen för det kommande kvartalet inom den kombinerade idag+imorgon-poolen (upp till 192 slotar). Faller tillbaka på enbart idag. Attribut: `next_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Bredaste framtidsskick: 'Om next_interval_price_rank_today_tomorrow < 10, är nästa intervall bland de billigaste i tvådagarsfönstret'."
|
||||
},
|
||||
"previous_interval_price_rank_today": {
|
||||
"description": "Percentilrang för föregående intervalls pris i dagens rangordning (0 % = billigaste tillfället idag)",
|
||||
"long_description": "Visar percentilrangen för det nyligen avslutade kvartalet inom dagens 96 slotar. Användbart för att logga hur billigt eller dyrt det föregående intervallet var. Attribut: `previous_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Användbart för retrospektiva automatiseringar eller loggning: 'Registrera kostnadsnivån för det föregående intervallet i energirapporter'."
|
||||
},
|
||||
"previous_interval_price_rank_today_tomorrow": {
|
||||
"description": "Percentilrang för föregående intervalls pris över idag+imorgon sammantaget (0 % = billigaste i tvådagarsperioden)",
|
||||
"long_description": "Visar percentilrangen för det nyligen avslutade kvartalet inom den kombinerade idag+imorgon-poolen (upp till 192 slotar). Faller tillbaka på enbart idag. Attribut: `previous_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "För retrospektiva jämförelser inom ett tvådagarsfönster."
|
||||
},
|
||||
"current_hour_price_rank_today": {
|
||||
"description": "Percentilrang för aktuellt rullande timgenomsnittspris i dagens rangordning (0 % = billigaste timmen idag)",
|
||||
"long_description": "Visar var det rullande 5-intervallets genomsnitt (2 intervall före + aktuellt + 2 efter, ca. 1 timme) placerar sig i dagens prisrangordning. Jämnar ut korta pristoppar. Attribut: `current_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "För uppgifter som tar ungefär en timme: 'Om current_hour_price_rank_today < 20, är detta en billig timme — starta tvättmaskinen'."
|
||||
},
|
||||
"current_hour_price_rank_today_tomorrow": {
|
||||
"description": "Rullande timgenomsnittsprisrang över idag+imorgon sammantaget (0 % = billigaste timmen i tvådagarsfönstret)",
|
||||
"long_description": "Visar var det rullande 5-intervallets genomsnitt (±2 intervall, ca. 1 timme) placerar sig i den kombinerade idag+imorgon-rangordningen (upp till 192 slotar). Faller tillbaka på enbart idag. Attribut: `current_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Bredaste timsignal: 'Om current_hour_price_rank_today_tomorrow < 15, är detta en av de billigaste timmarna i tvådagarsfönstret'."
|
||||
},
|
||||
"next_hour_price_rank_today": {
|
||||
"description": "Percentilrang för nästa rullande timgenomsnittspris i dagens rangordning (0 % = billigaste timmen idag)",
|
||||
"long_description": "Visar var det 5-intervallsgenomsnitt centrerat på nästa intervall placerar sig i dagens prisrangordning. Möjliggör planering en timme framåt. Attribut: `next_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Planera en timme framåt: 'Om next_hour_price_rank_today < 20, är den kommande timmen billig — starta en uppgift nu'."
|
||||
},
|
||||
"next_hour_price_rank_today_tomorrow": {
|
||||
"description": "Nästa rullande timgenomsnittsprisrang över idag+imorgon sammantaget (0 % = billigaste timmen i tvådagarsfönstret)",
|
||||
"long_description": "Visar var det 5-intervallsgenomsnitt centrerat på nästa intervall placerar sig i den kombinerade idag+imorgon-rangordningen (upp till 192 slotar). Faller tillbaka på enbart idag. Attribut: `next_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Bredaste timframtidsskick: 'Om next_hour_price_rank_today_tomorrow < 10, är den kommande timmen bland de billigaste i tvådagarsfönstret'."
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
|
|
@ -473,6 +643,21 @@
|
|||
"long_description": "Aktiveras när nuvarande pris ligger i botten 20% av dagens priser",
|
||||
"usage_tips": "Använd detta för att köra högkonsumtionsapparater under de billigaste intervallerna"
|
||||
},
|
||||
"in_rising_price_phase": {
|
||||
"description": "Whether prices are currently in a rising phase",
|
||||
"long_description": "Turns ON when the current intra-day price phase is rising.",
|
||||
"usage_tips": "Use in automations to delay or avoid running flexible loads during rising prices."
|
||||
},
|
||||
"in_falling_price_phase": {
|
||||
"description": "Whether prices are currently in a falling phase",
|
||||
"long_description": "Turns ON when the current intra-day price phase is falling.",
|
||||
"usage_tips": "Use in automations to take advantage of falling prices for flexible loads."
|
||||
},
|
||||
"in_flat_price_phase": {
|
||||
"description": "Whether prices are currently in a flat (stable) phase",
|
||||
"long_description": "Turns ON when the current intra-day price phase is flat and prices are relatively stable.",
|
||||
"usage_tips": "Use for loads that prefer price stability rather than the lowest price."
|
||||
},
|
||||
"connection": {
|
||||
"description": "Om anslutningen till Tibber API fungerar",
|
||||
"long_description": "Indikerar om integrationen framgångsrikt kan ansluta till Tibber API",
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -16,18 +16,78 @@
|
|||
}
|
||||
},
|
||||
"get_apexcharts_yaml": {
|
||||
"service": "mdi:chart-line",
|
||||
"sections": {
|
||||
"entry_id": "mdi:identifier",
|
||||
"day": "mdi:calendar-range",
|
||||
"level_type": "mdi:format-list-bulleted-type",
|
||||
"resolution": "mdi:timer-sand",
|
||||
"highlight_best_price": "mdi:battery-charging-low",
|
||||
"highlight_peak_price": "mdi:battery-alert"
|
||||
}
|
||||
"service": "mdi:chart-line"
|
||||
},
|
||||
"refresh_user_data": {
|
||||
"service": "mdi:refresh"
|
||||
},
|
||||
"find_cheapest_block": {
|
||||
"service": "mdi:washing-machine",
|
||||
"sections": {
|
||||
"search_range": "mdi:calendar-search",
|
||||
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||
"price_filter": "mdi:filter-variant",
|
||||
"search_tuning": "mdi:cog-outline",
|
||||
"cost_estimation": "mdi:lightning-bolt",
|
||||
"output": "mdi:tune-variant"
|
||||
}
|
||||
},
|
||||
"find_most_expensive_block": {
|
||||
"service": "mdi:lightning-bolt-circle",
|
||||
"sections": {
|
||||
"search_range": "mdi:calendar-search",
|
||||
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||
"price_filter": "mdi:filter-variant",
|
||||
"search_tuning": "mdi:cog-outline",
|
||||
"cost_estimation": "mdi:lightning-bolt",
|
||||
"output": "mdi:tune-variant"
|
||||
}
|
||||
},
|
||||
"find_cheapest_hours": {
|
||||
"service": "mdi:ev-station",
|
||||
"sections": {
|
||||
"search_range": "mdi:calendar-search",
|
||||
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||
"price_filter": "mdi:filter-variant",
|
||||
"search_tuning": "mdi:cog-outline",
|
||||
"cost_estimation": "mdi:lightning-bolt",
|
||||
"output": "mdi:tune-variant"
|
||||
}
|
||||
},
|
||||
"find_most_expensive_hours": {
|
||||
"service": "mdi:flash-alert",
|
||||
"sections": {
|
||||
"search_range": "mdi:calendar-search",
|
||||
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||
"price_filter": "mdi:filter-variant",
|
||||
"search_tuning": "mdi:cog-outline",
|
||||
"cost_estimation": "mdi:lightning-bolt",
|
||||
"output": "mdi:tune-variant"
|
||||
}
|
||||
},
|
||||
"find_cheapest_schedule": {
|
||||
"service": "mdi:calendar-check",
|
||||
"sections": {
|
||||
"search_range": "mdi:calendar-search",
|
||||
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||
"price_filter": "mdi:filter-variant",
|
||||
"search_tuning": "mdi:cog-outline",
|
||||
"output": "mdi:tune-variant"
|
||||
}
|
||||
},
|
||||
"plan_charging": {
|
||||
"service": "mdi:battery-charging",
|
||||
"sections": {
|
||||
"battery": "mdi:battery",
|
||||
"charging": "mdi:ev-station",
|
||||
"search_range": "mdi:calendar-search",
|
||||
"deadline": "mdi:calendar-clock",
|
||||
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||
"price_filter": "mdi:filter-variant",
|
||||
"search_tuning": "mdi:cog-outline",
|
||||
"economics": "mdi:cash-multiple",
|
||||
"output": "mdi:tune-variant"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||