mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-05-28 18:43:40 +00:00
Compare commits
155 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 |
606 changed files with 120239 additions and 68995 deletions
11
.claude/settings.local.json
Normal file
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"
|
||||
]
|
||||
"recommendations": [],
|
||||
"unwantedRecommendations": ["ms-python.pylint"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,145 +1,173 @@
|
|||
{
|
||||
"name": "jpawlowski/hass.tibber_prices",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:3.14",
|
||||
"postCreateCommand": "bash .devcontainer/setup-git.sh && scripts/setup/setup",
|
||||
"postStartCommand": "scripts/motd",
|
||||
"containerEnv": {
|
||||
"PYTHONASYNCIODEBUG": "1",
|
||||
"TIBBER_PRICES_DEV": "1"
|
||||
"name": "jpawlowski/hass.tibber_prices",
|
||||
"image": "mcr.microsoft.com/devcontainers/base:debian",
|
||||
"postCreateCommand": "bash .devcontainer/setup-git.sh && scripts/setup/setup",
|
||||
"postStartCommand": "scripts/motd",
|
||||
"containerEnv": {
|
||||
"PYTHONASYNCIODEBUG": "1",
|
||||
"TIBBER_PRICES_DEV": "1"
|
||||
},
|
||||
"forwardPorts": [8123, 3000, 3001],
|
||||
"portsAttributes": {
|
||||
"8123": {
|
||||
"label": "Home Assistant",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"forwardPorts": [
|
||||
8123,
|
||||
3000,
|
||||
3001
|
||||
],
|
||||
"portsAttributes": {
|
||||
"8123": {
|
||||
"label": "Home Assistant",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"3000": {
|
||||
"label": "Docusaurus User Docs",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"3001": {
|
||||
"label": "Docusaurus Developer Docs",
|
||||
"onAutoForward": "notify"
|
||||
}
|
||||
"3000": {
|
||||
"label": "Docusaurus User Docs",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"charliermarsh.ruff",
|
||||
"EditorConfig.EditorConfig",
|
||||
"esbenp.prettier-vscode",
|
||||
"github.copilot",
|
||||
"github.vscode-pull-request-github",
|
||||
"ms-python.python",
|
||||
"ms-python.vscode-pylance",
|
||||
"ms-vscode-remote.remote-containers",
|
||||
"redhat.vscode-yaml",
|
||||
"ryanluker.vscode-coverage-gutters"
|
||||
],
|
||||
"settings": {
|
||||
"editor.tabSize": 4,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnType": false,
|
||||
"extensions.ignoreRecommendations": false,
|
||||
"files.eol": "\n",
|
||||
"files.trimTrailingWhitespace": true,
|
||||
"python.analysis.typeCheckingMode": "basic",
|
||||
"python.analysis.autoImportCompletions": true,
|
||||
"python.analysis.diagnosticMode": "workspace",
|
||||
"python.analysis.diagnosticSeverityOverrides": {
|
||||
"reportUnusedImport": "none",
|
||||
"reportUnusedVariable": "none",
|
||||
"reportUnusedCoroutine": "none",
|
||||
"reportMissingTypeStubs": "none"
|
||||
},
|
||||
"python.analysis.include": [
|
||||
"custom_components/tibber_prices"
|
||||
],
|
||||
"python.analysis.exclude": [
|
||||
"**/.venv/**",
|
||||
"**/venv/**",
|
||||
"**/__pycache__/**",
|
||||
"**/.git/**",
|
||||
"**/.github/**",
|
||||
"**/docs/**",
|
||||
"**/node_modules/**"
|
||||
],
|
||||
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
|
||||
"python.analysis.extraPaths": [
|
||||
"${workspaceFolder}/.venv/lib/python3.14/site-packages"
|
||||
],
|
||||
"python.terminal.activateEnvironment": true,
|
||||
"python.terminal.activateEnvInCurrentTerminal": true,
|
||||
"python.testing.pytestArgs": [
|
||||
"--no-cov"
|
||||
],
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "charliermarsh.ruff",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.ruff": "explicit",
|
||||
"source.organizeImports.ruff": "explicit"
|
||||
}
|
||||
},
|
||||
"[markdown]": {
|
||||
"editor.wordWrap": "on"
|
||||
},
|
||||
"yaml.customTags": [
|
||||
"!secret scalar",
|
||||
"!include scalar",
|
||||
"!include_dir_list scalar",
|
||||
"!include_dir_merge_list scalar",
|
||||
"!include_dir_named scalar",
|
||||
"!include_dir_merge_named scalar",
|
||||
"!input scalar"
|
||||
],
|
||||
"markdown.validate.enabled": false,
|
||||
"markdown.validate.fileLinks.enabled": "ignore",
|
||||
"markdown.validate.fragmentLinks.enabled": "ignore",
|
||||
"json.schemas": [
|
||||
{
|
||||
"fileMatch": [
|
||||
"homeassistant/components/*/manifest.json"
|
||||
],
|
||||
"url": "${containerWorkspaceFolder}/schemas/json/manifest_schema.json"
|
||||
},
|
||||
{
|
||||
"fileMatch": [
|
||||
"homeassistant/components/*/translations/*.json"
|
||||
],
|
||||
"url": "${containerWorkspaceFolder}/schemas/json/translation_schema.json"
|
||||
}
|
||||
],
|
||||
"git.useConfigOnly": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"mounts": [
|
||||
"source=${localEnv:HOME}${localEnv:USERPROFILE}/.gitconfig,target=/home/vscode/.gitconfig.host,type=bind,consistency=cached"
|
||||
],
|
||||
"remoteUser": "vscode",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {},
|
||||
"ghcr.io/flexwie/devcontainer-features/op:1": {
|
||||
"version": "latest"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "24"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/rust:1": {
|
||||
"version": "latest",
|
||||
"profile": "minimal"
|
||||
},
|
||||
"ghcr.io/devcontainers-extra/features/apt-packages:1": {
|
||||
"packages": [
|
||||
"ffmpeg",
|
||||
"libturbojpeg0",
|
||||
"libpcap-dev"
|
||||
]
|
||||
}
|
||||
"3001": {
|
||||
"label": "Docusaurus Developer Docs",
|
||||
"onAutoForward": "notify"
|
||||
}
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"charliermarsh.ruff",
|
||||
"EditorConfig.EditorConfig",
|
||||
"esbenp.prettier-vscode",
|
||||
"github.copilot",
|
||||
"github.vscode-pull-request-github",
|
||||
"ms-python.python",
|
||||
"ms-python.vscode-pylance",
|
||||
"ms-vscode-remote.remote-containers",
|
||||
"redhat.vscode-yaml",
|
||||
"ryanluker.vscode-coverage-gutters",
|
||||
"MermaidChart.vscode-mermaid-chart"
|
||||
],
|
||||
"settings": {
|
||||
"editor.tabSize": 4,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnType": false,
|
||||
"extensions.ignoreRecommendations": false,
|
||||
"files.eol": "\n",
|
||||
"files.trimTrailingWhitespace": true,
|
||||
"python.analysis.typeCheckingMode": "basic",
|
||||
"python.analysis.autoImportCompletions": true,
|
||||
"python.analysis.diagnosticMode": "workspace",
|
||||
"python.analysis.diagnosticSeverityOverrides": {
|
||||
"reportUnusedImport": "none",
|
||||
"reportUnusedVariable": "none",
|
||||
"reportUnusedCoroutine": "none",
|
||||
"reportMissingTypeStubs": "none"
|
||||
},
|
||||
"python.analysis.include": ["custom_components/tibber_prices"],
|
||||
"python.analysis.exclude": [
|
||||
"**/.venv/**",
|
||||
"**/venv/**",
|
||||
"**/__pycache__/**",
|
||||
"**/.git/**",
|
||||
"**/.github/**",
|
||||
"**/docs/**",
|
||||
"**/node_modules/**"
|
||||
],
|
||||
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
|
||||
"python.analysis.extraPaths": ["${workspaceFolder}/.venv/lib/python3.14/site-packages"],
|
||||
"python.terminal.activateEnvironment": true,
|
||||
"python.terminal.activateEnvInCurrentTerminal": true,
|
||||
"python.testing.pytestArgs": ["--no-cov"],
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.tabSize": 2
|
||||
},
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.tabSize": 2
|
||||
},
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "charliermarsh.ruff",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.ruff": "explicit",
|
||||
"source.organizeImports.ruff": "explicit"
|
||||
}
|
||||
},
|
||||
"[markdown]": {
|
||||
"editor.wordWrap": "on"
|
||||
},
|
||||
"yaml.customTags": [
|
||||
"!secret scalar",
|
||||
"!include scalar",
|
||||
"!include_dir_list scalar",
|
||||
"!include_dir_merge_list scalar",
|
||||
"!include_dir_named scalar",
|
||||
"!include_dir_merge_named scalar",
|
||||
"!input scalar"
|
||||
],
|
||||
"markdown.validate.enabled": false,
|
||||
"markdown.validate.fileLinks.enabled": "ignore",
|
||||
"markdown.validate.fragmentLinks.enabled": "ignore",
|
||||
"json.schemas": [
|
||||
{
|
||||
"fileMatch": ["homeassistant/components/*/manifest.json"],
|
||||
"url": "${containerWorkspaceFolder}/schemas/json/manifest_schema.json"
|
||||
},
|
||||
{
|
||||
"fileMatch": ["homeassistant/components/*/translations/*.json"],
|
||||
"url": "${containerWorkspaceFolder}/schemas/json/translation_schema.json"
|
||||
}
|
||||
],
|
||||
"github.copilot.chat.commitMessageGeneration.instructions": [
|
||||
{
|
||||
"file": ".github/instructions/commit-messages.instructions.md"
|
||||
}
|
||||
],
|
||||
"git.useConfigOnly": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"mounts": [
|
||||
"source=${localEnv:HOME}${localEnv:USERPROFILE}/.gitconfig,target=/home/vscode/.gitconfig.host,type=bind,consistency=cached"
|
||||
],
|
||||
"remoteUser": "vscode",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {},
|
||||
"ghcr.io/flexwie/devcontainer-features/op:1": {
|
||||
"version": "latest"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/node:2": {
|
||||
"version": "24"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/rust:1": {
|
||||
"version": "latest",
|
||||
"profile": "minimal"
|
||||
},
|
||||
"ghcr.io/devcontainer-community/devcontainer-features/yq:1": {
|
||||
"version": "latest"
|
||||
},
|
||||
"ghcr.io/devcontainers-extra/features/apt-packages:1": {
|
||||
"packages": [
|
||||
"autoconf",
|
||||
"automake",
|
||||
"bat",
|
||||
"eza",
|
||||
"fd-find",
|
||||
"ffmpeg",
|
||||
"fzf",
|
||||
"git-delta",
|
||||
"httpie",
|
||||
"hyperfine",
|
||||
"ipython3",
|
||||
"jo",
|
||||
"jq",
|
||||
"libpcap-dev",
|
||||
"libssl-dev",
|
||||
"libtool",
|
||||
"libturbojpeg0",
|
||||
"miller",
|
||||
"moreutils",
|
||||
"pipx",
|
||||
"ripgrep",
|
||||
"shellcheck",
|
||||
"shfmt",
|
||||
"sqlite3",
|
||||
"tree",
|
||||
"yamllint"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
2
.github/FUNDING.yml
vendored
|
|
@ -1,4 +1,4 @@
|
|||
# These are supported funding model platforms
|
||||
|
||||
github: [ jpawlowski ]
|
||||
github: [jpawlowski]
|
||||
buy_me_a_coffee: jpawlowski
|
||||
|
|
|
|||
130
.github/ISSUE_TEMPLATE/bug.yml
vendored
130
.github/ISSUE_TEMPLATE/bug.yml
vendored
|
|
@ -3,69 +3,69 @@ name: "Bug report"
|
|||
description: "Report a bug with the custom integration"
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: Before you open a new issue, search through the existing issues to see if others have had the same problem.
|
||||
- type: input
|
||||
attributes:
|
||||
label: "Home Assistant version"
|
||||
description: "The version of Home Assistant you are using"
|
||||
placeholder: "2025.1.0"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: "Integration version"
|
||||
description: "The version of this custom integration you are using"
|
||||
placeholder: "1.0.0"
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "System Health details"
|
||||
description: "Paste the data from the System Health card in Home Assistant (https://www.home-assistant.io/more-info/system-health#github-issues)"
|
||||
validations:
|
||||
required: false
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
- label: I have enabled debug logging for my installation.
|
||||
required: true
|
||||
- label: I have filled out the issue template to the best of my ability.
|
||||
required: true
|
||||
- label: This issue only contains 1 issue (if you have multiple issues, open one issue for each issue).
|
||||
required: true
|
||||
- label: This issue is not a duplicate issue of any [previous issues](https://github.com/jpawlowski/hass.tibber_prices/issues?q=is%3Aissue+label%3A%22Bug%22+)..
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "Describe the issue"
|
||||
description: "A clear and concise description of what the issue is."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Reproduction steps
|
||||
description: "Without steps to reproduce, it will be hard to fix. It is very important that you fill out this part. Issues without it will be closed."
|
||||
value: |
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "Debug logs"
|
||||
description: "To enable debug logs check this https://www.home-assistant.io/integrations/logger/, this **needs** to include _everything_ from startup of Home Assistant to the point where you encounter the issue."
|
||||
render: text
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: Before you open a new issue, search through the existing issues to see if others have had the same problem.
|
||||
- type: input
|
||||
attributes:
|
||||
label: "Home Assistant version"
|
||||
description: "The version of Home Assistant you are using"
|
||||
placeholder: "2025.1.0"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: "Integration version"
|
||||
description: "The version of this custom integration you are using"
|
||||
placeholder: "1.0.0"
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "System Health details"
|
||||
description: "Paste the data from the System Health card in Home Assistant (https://www.home-assistant.io/more-info/system-health#github-issues)"
|
||||
validations:
|
||||
required: false
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
- label: I have enabled debug logging for my installation.
|
||||
required: true
|
||||
- label: I have filled out the issue template to the best of my ability.
|
||||
required: true
|
||||
- label: This issue only contains 1 issue (if you have multiple issues, open one issue for each issue).
|
||||
required: true
|
||||
- label: This issue is not a duplicate issue of any [previous issues](https://github.com/jpawlowski/hass.tibber_prices/issues?q=is%3Aissue+label%3A%22Bug%22+)..
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "Describe the issue"
|
||||
description: "A clear and concise description of what the issue is."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Reproduction steps
|
||||
description: "Without steps to reproduce, it will be hard to fix. It is very important that you fill out this part. Issues without it will be closed."
|
||||
value: |
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "Debug logs"
|
||||
description: "To enable debug logs check this https://www.home-assistant.io/integrations/logger/, this **needs** to include _everything_ from startup of Home Assistant to the point where you encounter the issue."
|
||||
render: text
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "Diagnostics dump"
|
||||
description: "Drag the diagnostics dump file here. (see https://www.home-assistant.io/integrations/diagnostics/ for info)"
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "Diagnostics dump"
|
||||
description: "Drag the diagnostics dump file here. (see https://www.home-assistant.io/integrations/diagnostics/ for info)"
|
||||
validations:
|
||||
required: false
|
||||
|
|
|
|||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
|
|
@ -1 +1 @@
|
|||
blank_issues_enabled: false
|
||||
blank_issues_enabled: false
|
||||
|
|
|
|||
76
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
76
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
|
|
@ -3,45 +3,45 @@ name: "Feature request"
|
|||
description: "Suggest an idea for this custom integration"
|
||||
labels: ["Feature request"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: Before you open a new feature request, search through the existing feature requests to see if others have had the same idea.
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
- label: I have filled out the template to the best of my ability.
|
||||
required: true
|
||||
- label: This only contains 1 feature request (if you have multiple feature requests, open one feature request for each feature request).
|
||||
required: true
|
||||
- label: This issue is not a duplicate feature request of [previous feature requests](https://github.com/jpawlowski/hass.tibber_prices/issues?q=is%3Aissue+label%3A%22Feature+Request%22+).
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: Before you open a new feature request, search through the existing feature requests to see if others have had the same idea.
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
- label: I have filled out the template to the best of my ability.
|
||||
required: true
|
||||
- label: This only contains 1 feature request (if you have multiple feature requests, open one feature request for each feature request).
|
||||
required: true
|
||||
- label: This issue is not a duplicate feature request of [previous feature requests](https://github.com/jpawlowski/hass.tibber_prices/issues?q=is%3Aissue+label%3A%22Feature+Request%22+).
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "Is your feature request related to a problem? Please describe."
|
||||
description: "A clear and concise description of what the problem is."
|
||||
placeholder: "I'm always frustrated when [...]"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "Is your feature request related to a problem? Please describe."
|
||||
description: "A clear and concise description of what the problem is."
|
||||
placeholder: "I'm always frustrated when [...]"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "Describe the solution you'd like"
|
||||
description: "A clear and concise description of what you want to happen."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "Describe the solution you'd like"
|
||||
description: "A clear and concise description of what you want to happen."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "Describe alternatives you've considered"
|
||||
description: "A clear and concise description of any alternative solutions or features you've considered."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "Describe alternatives you've considered"
|
||||
description: "A clear and concise description of any alternative solutions or features you've considered."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "Additional context"
|
||||
description: "Add any other context or screenshots about the feature request here."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "Additional context"
|
||||
description: "Add any other context or screenshots about the feature request here."
|
||||
validations:
|
||||
required: true
|
||||
|
|
|
|||
95
.github/instructions/commit-messages.instructions.md
vendored
Normal file
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
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],
|
||||
});
|
||||
4
.github/workflows/auto-tag.yml
vendored
4
.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
|
||||
|
|
@ -22,7 +22,7 @@ jobs:
|
|||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # Need full history for git describe
|
||||
fetch-depth: 0 # Need full history for git describe
|
||||
|
||||
- name: Extract version from manifest.json
|
||||
id: manifest
|
||||
|
|
|
|||
21
.github/workflows/docusaurus.yml
vendored
21
.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
|
||||
|
|
@ -31,7 +31,7 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # Needed for version timestamps
|
||||
fetch-depth: 0 # Needed for version timestamps
|
||||
|
||||
- name: Detect prerelease tag (beta/rc)
|
||||
id: taginfo
|
||||
|
|
@ -47,11 +47,20 @@ jobs:
|
|||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: 'npm'
|
||||
cache: "npm"
|
||||
cache-dependency-path: |
|
||||
docs/user/package-lock.json
|
||||
docs/developer/package-lock.json
|
||||
|
||||
# VERIFY GENERATED DOCS
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Verify sensor reference is up-to-date
|
||||
run: python3 scripts/docs/generate-sensor-reference --check
|
||||
|
||||
# USER DOCS BUILD
|
||||
- name: Install user docs dependencies
|
||||
working-directory: docs/user
|
||||
|
|
@ -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
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"
|
||||
|
||||
|
|
|
|||
16
.github/workflows/release.yml
vendored
16
.github/workflows/release.yml
vendored
|
|
@ -3,16 +3,16 @@ name: Generate Release Notes
|
|||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*' # Triggers on version tags like v1.0.0, v2.1.3, etc.
|
||||
- "v*.*.*" # Triggers on version tags like v1.0.0, v2.1.3, etc.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Tag version to release (e.g., v0.3.0)'
|
||||
description: "Tag version to release (e.g., v0.3.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write # Needed to create/update releases and push commits
|
||||
contents: write # Needed to create/update releases and push commits
|
||||
|
||||
jobs:
|
||||
# Note: We trust that validate.yml and lint.yml have already run on the
|
||||
|
|
@ -103,13 +103,13 @@ jobs:
|
|||
release-notes:
|
||||
name: Generate and publish release notes
|
||||
runs-on: ubuntu-latest
|
||||
needs: sync-manifest # Wait for manifest sync to complete
|
||||
needs: sync-manifest # Wait for manifest sync to complete
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # Fetch all history for git-cliff
|
||||
ref: main # Use updated main branch if manifest was synced
|
||||
fetch-depth: 0 # Fetch all history for git-cliff
|
||||
ref: main # Use updated main branch if manifest was synced
|
||||
|
||||
- name: Get previous tag
|
||||
id: previoustag
|
||||
|
|
@ -255,13 +255,13 @@ jobs:
|
|||
|
||||
- name: Create GitHub Release
|
||||
if: steps.version_check.outputs.warning == ''
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
name: ${{ steps.release_notes.outputs.title }}
|
||||
body: ${{ steps.release_notes.outputs.notes }}
|
||||
draft: false
|
||||
prerelease: ${{ contains(github.ref, 'b') }}
|
||||
generate_release_notes: false # We provide our own
|
||||
generate_release_notes: false # We provide our own
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
|
|
|||
8
.github/workflows/validate.yml
vendored
8
.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: {}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,20 @@
|
|||
{
|
||||
"default": true,
|
||||
"MD013": false,
|
||||
"MD033": false,
|
||||
"MD041": false,
|
||||
"no-inline-html": false,
|
||||
"line-length": false,
|
||||
"first-line-heading": false
|
||||
"default": true,
|
||||
"MD004": false,
|
||||
"MD013": false,
|
||||
"MD036": false,
|
||||
"MD041": false,
|
||||
"no-trailing-punctuation": false,
|
||||
"no-inline-html": {
|
||||
"allowed_elements": ["br", "details", "summary", "img", "a", "kbd"]
|
||||
},
|
||||
"code-block-style": {
|
||||
"style": "fenced"
|
||||
},
|
||||
"emphasis-style": {
|
||||
"style": "underscore"
|
||||
},
|
||||
"strong-style": {
|
||||
"style": "asterisk"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
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
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
|
||||
|
|
|
|||
415
README.md
415
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"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.tibber_best_price_period
|
||||
to: "on"
|
||||
condition:
|
||||
- condition: time
|
||||
after: "21:00:00"
|
||||
before: "06:00:00"
|
||||
action:
|
||||
- service: switch.turn_on
|
||||
target:
|
||||
entity_id: switch.dishwasher
|
||||
- alias: "Start Dishwasher During Best Price Period"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.tibber_best_price_period
|
||||
to: "on"
|
||||
action:
|
||||
- action: switch.turn_on
|
||||
target:
|
||||
entity_id: switch.dishwasher
|
||||
```
|
||||
|
||||
> **Learn more:** The [period calculation guide](https://jpawlowski.github.io/hass.tibber_prices/user/period-calculation) explains how Best/Peak Price periods are identified and how you can configure filters (flexibility, minimum distance from average, price level filters with gap tolerance).
|
||||
|
||||
### Notify on Extremely High Prices
|
||||
|
||||
Get notified when prices reach the VERY_EXPENSIVE level:
|
||||
**Reduce heating when prices spike above average:**
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Notify on Very Expensive Electricity"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: sensor.tibber_current_interval_price_level
|
||||
to: "VERY_EXPENSIVE"
|
||||
action:
|
||||
- service: notify.mobile_app
|
||||
data:
|
||||
title: "⚠️ High Electricity Prices"
|
||||
message: "Current electricity price is in the VERY EXPENSIVE range. Consider reducing consumption."
|
||||
- alias: "Reduce Heating During High Prices"
|
||||
trigger:
|
||||
- platform: numeric_state
|
||||
entity_id: sensor.tibber_current_interval_price_rating
|
||||
above: 20 # More than 20% above 24h average
|
||||
action:
|
||||
- action: climate.set_temperature
|
||||
target:
|
||||
entity_id: climate.living_room
|
||||
data:
|
||||
temperature: 19
|
||||
```
|
||||
|
||||
### Temperature Control Based on Price Ratings
|
||||
📖 **[More automations →](https://jpawlowski.github.io/hass.tibber_prices/user/automation-examples)** — EV charging, heat pump control, price notifications, and more
|
||||
|
||||
Adjust heating/cooling when current prices are significantly above the 24h average:
|
||||
## 📈 Chart Visualizations
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Reduce Heating During High Price Ratings"
|
||||
trigger:
|
||||
- platform: numeric_state
|
||||
entity_id: sensor.tibber_current_interval_price_rating
|
||||
above: 20 # More than 20% above 24h average
|
||||
action:
|
||||
- service: climate.set_temperature
|
||||
target:
|
||||
entity_id: climate.living_room
|
||||
data:
|
||||
temperature: 19 # Lower target temperature
|
||||
```
|
||||
Generate beautiful price charts with a single action call — dynamic Y-axis, color-coded price levels, and multiple chart modes included.
|
||||
|
||||
### Smart EV Charging Based on Tomorrow's Prices
|
||||
<img src="https://raw.githubusercontent.com/jpawlowski/hass.tibber_prices/main/docs/user/static/img/charts/rolling-window.jpg" width="600" alt="Dynamic 48h rolling window chart with color-coded price levels">
|
||||
|
||||
Start charging when tomorrow's prices drop below today's average:
|
||||
📖 **[Chart examples & setup →](https://jpawlowski.github.io/hass.tibber_prices/user/chart-examples)** | **[Actions reference →](https://jpawlowski.github.io/hass.tibber_prices/user/actions)**
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Smart EV Charging"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.tibber_best_price_interval
|
||||
to: "on"
|
||||
condition:
|
||||
- condition: numeric_state
|
||||
entity_id: sensor.tibber_current_interval_price_rating
|
||||
below: -15 # At least 15% below average
|
||||
- condition: numeric_state
|
||||
entity_id: sensor.ev_battery_level
|
||||
below: 80
|
||||
action:
|
||||
- service: switch.turn_on
|
||||
target:
|
||||
entity_id: switch.ev_charger
|
||||
```
|
||||
## ❓ Help & Support
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### No data appearing
|
||||
|
||||
1. Check your API token is valid at [developer.tibber.com](https://developer.tibber.com/settings/access-token)
|
||||
2. Verify you have an active Tibber subscription
|
||||
3. Check the Home Assistant logs for detailed error messages (`Settings > System > Logs`)
|
||||
4. Restart the integration: `Settings > Devices & Services > Tibber Price Information & Ratings > ⋮ > Reload`
|
||||
|
||||
### Missing tomorrow's price data
|
||||
|
||||
- Tomorrow's price data typically becomes available between **13:00 and 15:00** each day (Nordic time)
|
||||
- The integration automatically checks more frequently during this window
|
||||
- Check `binary_sensor.tibber_tomorrows_data_available` to see if data is available
|
||||
- If data is unavailable after 15:00, verify it's available in the Tibber app first
|
||||
|
||||
### Prices not updating at quarter-hour boundaries
|
||||
|
||||
- Entities automatically refresh at 00/15/30/45-minute marks without waiting for API polls
|
||||
- Check `sensor.tibber_data_expiration` to verify data freshness
|
||||
- The integration caches data intelligently and survives Home Assistant restarts
|
||||
|
||||
### Currency or units showing incorrectly
|
||||
|
||||
- Currency is automatically detected from your Tibber account
|
||||
- Display mode (base currency vs. subunit) can be configured in integration options: `Settings > Devices & Services > Tibber Price Information & Ratings > Configure`
|
||||
- Supported currencies: EUR, NOK, SEK, DKK, USD, and GBP
|
||||
- Smart defaults apply: EUR users get subunit (ct), Scandinavian users get base currency (kr)
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Sensor Attributes
|
||||
|
||||
Every sensor includes rich attributes beyond just the state value. These attributes provide context, timestamps, and additional data useful for automations and templates.
|
||||
|
||||
**Standard attributes available on most sensors:**
|
||||
|
||||
- `timestamp` - ISO 8601 timestamp for the data point
|
||||
- `description` - Brief explanation of what the sensor represents
|
||||
- `level_id` and `level_value` - For price level sensors (e.g., `VERY_CHEAP` = -2)
|
||||
|
||||
**Extended descriptions** (enable in integration options):
|
||||
|
||||
- `long_description` - Detailed explanation of the sensor's purpose
|
||||
- `usage_tips` - Practical suggestions for using the sensor in automations
|
||||
|
||||
**Example - Current Price sensor attributes:**
|
||||
|
||||
```yaml
|
||||
timestamp: "2025-11-03T14:15:00+01:00"
|
||||
description: "The current electricity price per kWh"
|
||||
long_description: "Shows the current price per kWh from your Tibber subscription"
|
||||
usage_tips: "Use this to track prices or to create automations that run when electricity is cheap"
|
||||
```
|
||||
|
||||
**Example template using attributes:**
|
||||
|
||||
```yaml
|
||||
template:
|
||||
- sensor:
|
||||
- name: "Price Status"
|
||||
state: >
|
||||
{% set price = states('sensor.tibber_current_electricity_price') | float %}
|
||||
{% set timestamp = state_attr('sensor.tibber_current_electricity_price', 'timestamp') %}
|
||||
Price at {{ timestamp }}: {{ price }} ct/kWh
|
||||
```
|
||||
|
||||
📖 **[View all sensors and attributes →](https://jpawlowski.github.io/hass.tibber_prices/user/sensors)**
|
||||
|
||||
### Dynamic Icons & Visual Indicators
|
||||
|
||||
All sensors feature dynamic icons that change based on price levels, providing instant visual feedback in your dashboards.
|
||||
|
||||
<img src="docs/user/static/img/entities-overview.jpg" width="400" alt="Entity list showing dynamic icons for different price states">
|
||||
|
||||
_Dynamic icons adapt to price levels, trends, and period states - showing CHEAP prices, FALLING trend, and active Best Price Period_
|
||||
|
||||
📖 **[Dynamic Icons Guide →](https://jpawlowski.github.io/hass.tibber_prices/user/dynamic-icons)** | **[Icon Colors Guide →](https://jpawlowski.github.io/hass.tibber_prices/user/icon-colors)**
|
||||
|
||||
### Custom Actions
|
||||
|
||||
The integration provides custom actions (they still appear as services under the hood) for advanced use cases. These actions show up in Home Assistant under **Developer Tools → Actions**.
|
||||
|
||||
- `tibber_prices.get_chartdata` - Get price data in chart-friendly formats for any visualization card
|
||||
- `tibber_prices.get_apexcharts_yaml` - Generate complete ApexCharts configurations
|
||||
- `tibber_prices.refresh_user_data` - Manually refresh account information
|
||||
|
||||
📖 **[Action documentation and examples →](https://jpawlowski.github.io/hass.tibber_prices/user/actions)**
|
||||
|
||||
### Chart Visualizations (Optional)
|
||||
|
||||
The integration includes built-in support for creating price visualization cards with automatic Y-axis scaling and color-coded series.
|
||||
|
||||
<img src="docs/user/static/img/charts/rolling-window.jpg" width="600" alt="Example: Dynamic 48h rolling window chart">
|
||||
|
||||
_Optional: Dynamic 48h chart with automatic Y-axis scaling - generated via `get_apexcharts_yaml` action_
|
||||
|
||||
📖 **[Chart examples and setup guide →](https://jpawlowski.github.io/hass.tibber_prices/user/chart-examples)**
|
||||
- 📖 **[FAQ](https://jpawlowski.github.io/hass.tibber_prices/user/faq)** — Common questions answered
|
||||
- 🔧 **[Troubleshooting](https://jpawlowski.github.io/hass.tibber_prices/user/troubleshooting)** — Solving common issues
|
||||
- 🐛 **[Report an Issue](https://github.com/jpawlowski/hass.tibber_prices/issues/new)** — Found a bug? Let us know
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Please read the [Contributing Guidelines](CONTRIBUTING.md) and [Developer Documentation](https://jpawlowski.github.io/hass.tibber_prices/developer/) before submitting pull requests.
|
||||
Contributions are welcome! See the [Contributing Guidelines](CONTRIBUTING.md) and [Developer Documentation](https://jpawlowski.github.io/hass.tibber_prices/developer/) to get started.
|
||||
|
||||
### For Contributors
|
||||
|
||||
- **[Developer Setup](https://jpawlowski.github.io/hass.tibber_prices/developer/setup)** - Get started with DevContainer
|
||||
- **[Architecture Guide](https://jpawlowski.github.io/hass.tibber_prices/developer/architecture)** - Understand the codebase
|
||||
- **[Release Management](https://jpawlowski.github.io/hass.tibber_prices/developer/release-management)** - Release process and versioning
|
||||
- **[Developer Setup](https://jpawlowski.github.io/hass.tibber_prices/developer/setup)** — DevContainer-based development environment
|
||||
- **[Architecture Guide](https://jpawlowski.github.io/hass.tibber_prices/developer/architecture)** — Understand the codebase
|
||||
- **[Release Management](https://jpawlowski.github.io/hass.tibber_prices/developer/release-management)** — Release process and versioning
|
||||
|
||||
## 🤖 Development Note
|
||||
|
||||
This integration is developed with extensive AI assistance (GitHub Copilot, Claude, and other AI tools). While AI enables rapid development and helps implement complex features, it's possible that some edge cases or subtle bugs may exist that haven't been discovered yet. If you encounter any issues, please [open an issue](https://github.com/jpawlowski/hass.tibber_prices/issues/new) - we'll work on fixing them (with AI help, of course! 😊).
|
||||
This integration is developed with extensive AI assistance (GitHub Copilot, Claude, and other AI tools). While AI enables rapid development, it's possible that some edge cases haven't been discovered yet. If you encounter any issues, please [open an issue](https://github.com/jpawlowski/hass.tibber_prices/issues/new) — we'll fix them (with AI help, of course! 😊).
|
||||
|
||||
The integration is actively maintained and benefits from AI's ability to quickly understand and implement Home Assistant patterns, maintain consistency across the codebase, and handle complex data transformations. Quality is ensured through automated linting (Ruff), Home Assistant's type checking, and real-world testing.
|
||||
Quality is ensured through automated linting (Ruff), static type checking (Pyright), and real-world testing.
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
This project is licensed under the MIT License — see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -386,7 +198,6 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
|
|||
[commits]: https://github.com/jpawlowski/hass.tibber_prices/commits/main
|
||||
[hacs]: https://github.com/hacs/integration
|
||||
[hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge
|
||||
[exampleimg]: https://raw.githubusercontent.com/jpawlowski/hass.tibber_prices/main/images/example.png
|
||||
[license-shield]: https://img.shields.io/github/license/jpawlowski/hass.tibber_prices.svg?style=for-the-badge
|
||||
[maintenance-shield]: https://img.shields.io/badge/maintainer-%40jpawlowski-blue.svg?style=for-the-badge
|
||||
[user_profile]: https://github.com/jpawlowski
|
||||
|
|
|
|||
57
cliff.toml
57
cliff.toml
|
|
@ -5,13 +5,19 @@
|
|||
# Template for the changelog body
|
||||
header = ""
|
||||
body = """
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | striptags | trim | upper_first }}
|
||||
{% for commit in commits %}
|
||||
- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}{{ commit.message | upper_first }}\
|
||||
{% if commit.breaking %} [**BREAKING**]{% endif %} \
|
||||
([{{ commit.id | truncate(length=7, end="") }}](https://github.com/jpawlowski/hass.tibber_prices/commit/{{ commit.id }}))
|
||||
{% endfor %}
|
||||
{% for group, commits in commits | group_by(attribute="group") -%}
|
||||
### {{ group | striptags | trim | upper_first }}
|
||||
{% for commit in commits -%}
|
||||
{% set impact_text = "" -%}
|
||||
{% set footers = commit.footers | default(value=[]) -%}
|
||||
{% for footer in footers -%}
|
||||
{% if footer.token == "Impact" -%}
|
||||
{% set impact_text = footer.value -%}
|
||||
{% endif -%}
|
||||
{% endfor -%}
|
||||
- {% if impact_text %}{{ impact_text | trim | upper_first }}{% else %}{% if commit.scope %}**{{ commit.scope }}**: {% endif %}{{ commit.message | upper_first }}{% endif %}{% if commit.breaking %} [**BREAKING**]{% endif %} ([{{ commit.id | truncate(length=7, end="") }}](https://github.com/jpawlowski/hass.tibber_prices/commit/{{ commit.id }}))
|
||||
{% endfor %}
|
||||
|
||||
{% endfor %}
|
||||
|
||||
---
|
||||
|
|
@ -25,7 +31,8 @@ trim = true
|
|||
[git]
|
||||
# Parse conventional commits
|
||||
conventional_commits = true
|
||||
# Include all commits (even non-conventional)
|
||||
# Keep unconventional commits in parsing pipeline; parser rules decide what to skip.
|
||||
# This avoids noisy parse-error warnings on older commit history.
|
||||
filter_unconventional = false
|
||||
split_commits = false
|
||||
|
||||
|
|
@ -33,22 +40,28 @@ split_commits = false
|
|||
commit_parsers = [
|
||||
# Skip manifest.json version bumps (release housekeeping)
|
||||
{ message = "^chore\\(release\\): bump version", skip = true },
|
||||
# Skip explicit revert commits; final net state should drive release notes
|
||||
{ message = "^revert", skip = true },
|
||||
# Skip development environment changes (not user-relevant)
|
||||
{ message = "^(feat|fix|chore|refactor)\\((devcontainer|vscode|scripts|dev-env|environment)\\):", skip = true },
|
||||
# Skip CI/CD infrastructure changes (not user-relevant)
|
||||
{ message = "^(feat|fix|chore|ci)\\((ci|workflow|actions|github-actions)\\):", skip = true },
|
||||
# Keep dependency updates - these ARE relevant for users
|
||||
{ message = "^chore\\(deps\\):", group = "📦 Dependencies" },
|
||||
# Regular commit types
|
||||
{ message = "^feat", group = "🎉 New Features" },
|
||||
{ message = "^fix", group = "🐛 Bug Fixes" },
|
||||
{ message = "^docs?", group = "📚 Documentation" },
|
||||
{ message = "^perf", group = "⚡ Performance" },
|
||||
{ message = "^refactor", group = "🔧 Maintenance & Refactoring" },
|
||||
{ message = "^style", group = "🎨 Styling" },
|
||||
{ message = "^test", group = "🧪 Testing" },
|
||||
{ message = "^chore", group = "🔧 Maintenance & Refactoring" },
|
||||
{ message = "^build", group = "📦 Build" },
|
||||
# Skip non-user-facing fix scopes
|
||||
{ message = "^fix\\((docs|lint|types|tests?|ci|workflow|scripts|devcontainer|vscode|build|release)\\):", skip = true },
|
||||
# User-facing categories aligned with AI output style
|
||||
{ message = "^feat", group = "🎉 What's New" },
|
||||
{ message = "^fix", group = "🐛 Fixed" },
|
||||
{ message = "^perf", group = "⚡ More Reliable" },
|
||||
{ message = "^chore\\(deps\\):", group = "📦 Updated Dependencies" },
|
||||
# Skip mostly developer-facing categories
|
||||
{ message = "^docs?", skip = true },
|
||||
{ message = "^refactor", skip = true },
|
||||
{ message = "^style", skip = true },
|
||||
{ message = "^test", skip = true },
|
||||
{ message = "^build", skip = true },
|
||||
{ message = "^chore", skip = true },
|
||||
# Final fallback to avoid ungrouped commits
|
||||
{ message = ".*", skip = true },
|
||||
]
|
||||
|
||||
# Protect breaking changes
|
||||
|
|
@ -56,5 +69,5 @@ commit_preprocessors = [
|
|||
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/jpawlowski/hass.tibber_prices/issues/${2}))" },
|
||||
]
|
||||
|
||||
# Filter out commits
|
||||
filter_commits = false
|
||||
# Apply commit parser filtering rules
|
||||
filter_commits = true
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -41,6 +43,7 @@ from .interval_pool import (
|
|||
async_remove_pool_storage,
|
||||
async_save_pool_state,
|
||||
)
|
||||
from .migrations import check_entity_migrations
|
||||
from .services import async_setup_services
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -92,6 +95,53 @@ CONFIG_SCHEMA = vol.Schema(
|
|||
)
|
||||
|
||||
|
||||
def _install_blueprints(config_dir: str) -> None:
|
||||
"""Copy bundled blueprints to the HA config blueprints directory.
|
||||
|
||||
Always overwrites existing files so blueprints stay in sync with the
|
||||
integration version. Removes orphan files that are no longer shipped.
|
||||
Handles both automation and script blueprint domains.
|
||||
"""
|
||||
for bp_domain in ("automation", "script"):
|
||||
src = Path(__file__).parent / "blueprints" / bp_domain
|
||||
dst = Path(config_dir) / "blueprints" / bp_domain / DOMAIN
|
||||
|
||||
if not src.is_dir():
|
||||
LOGGER.debug("No bundled %s blueprints directory found, skipping", bp_domain)
|
||||
continue
|
||||
|
||||
dst.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
shipped: set[str] = set()
|
||||
for src_file in src.rglob("*.yaml"):
|
||||
rel = src_file.relative_to(src)
|
||||
# Only copy files from the tibber_prices sub-folder
|
||||
if rel.parts[0] != DOMAIN:
|
||||
continue
|
||||
dest_file = Path(config_dir) / "blueprints" / bp_domain / rel
|
||||
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src_file, dest_file)
|
||||
shipped.add(rel.parts[-1])
|
||||
|
||||
# Remove orphan blueprints no longer shipped with the integration
|
||||
if dst.is_dir():
|
||||
for existing in dst.glob("*.yaml"):
|
||||
if existing.name not in shipped:
|
||||
existing.unlink()
|
||||
LOGGER.info("Removed orphan %s blueprint %s", bp_domain, existing.name)
|
||||
|
||||
LOGGER.debug("Installed %d bundled %s blueprints to %s", len(shipped), bp_domain, dst)
|
||||
|
||||
|
||||
def _remove_blueprints(config_dir: str) -> None:
|
||||
"""Remove all integration-managed blueprints from the config directory."""
|
||||
for bp_domain in ("automation", "script"):
|
||||
bp_dir = Path(config_dir) / "blueprints" / bp_domain / DOMAIN
|
||||
if bp_dir.is_dir():
|
||||
shutil.rmtree(bp_dir)
|
||||
LOGGER.info("Removed bundled %s blueprints directory %s", bp_domain, bp_dir)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
||||
"""Set up the Tibber Prices component from configuration.yaml."""
|
||||
# Store chart export configuration in hass.data for sensor access
|
||||
|
|
@ -119,6 +169,9 @@ async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
|||
LOGGER.debug("No chart_metadata configuration found in configuration.yaml")
|
||||
hass.data[DOMAIN][DATA_CHART_METADATA_CONFIG] = {}
|
||||
|
||||
# Blueprints are kept in the repo but not distributed yet.
|
||||
# await hass.async_add_executor_job(_install_blueprints, hass.config.config_dir)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
|
@ -222,6 +275,9 @@ async def async_setup_entry(
|
|||
# Migrate config options if needed (e.g., set default currency display mode for existing configs)
|
||||
await _migrate_config_options(hass, entry)
|
||||
|
||||
# Check for entity migrations (renames, breaking changes) and create repairs
|
||||
check_entity_migrations(hass, entry)
|
||||
|
||||
# Preload translations to populate the cache
|
||||
await async_load_translations(hass, "en")
|
||||
await async_load_standard_translations(hass, "en")
|
||||
|
|
@ -362,6 +418,11 @@ async def async_remove_entry(
|
|||
await async_remove_pool_storage(hass, entry.entry_id)
|
||||
LOGGER.debug(f"[tibber_prices] async_remove_entry removed interval pool storage for entry_id={entry.entry_id}")
|
||||
|
||||
# Blueprints are kept in the repo but not distributed yet.
|
||||
# remaining = [e for e in hass.config_entries.async_entries(DOMAIN) if e.entry_id != entry.entry_id]
|
||||
# if not remaining:
|
||||
# await hass.async_add_executor_job(_remove_blueprints, hass.config.config_dir)
|
||||
|
||||
|
||||
async def async_reload_entry(
|
||||
hass: HomeAssistant,
|
||||
|
|
|
|||
|
|
@ -4,16 +4,16 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import base64
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import aiohttp
|
||||
|
||||
from homeassistant.util import dt as dt_utils
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .exceptions import (
|
||||
TibberPricesApiClientAuthenticationError,
|
||||
|
|
@ -21,12 +21,7 @@ from .exceptions import (
|
|||
TibberPricesApiClientError,
|
||||
TibberPricesApiClientPermissionError,
|
||||
)
|
||||
from .helpers import (
|
||||
flatten_price_info,
|
||||
prepare_headers,
|
||||
verify_graphql_response,
|
||||
verify_response_or_raise,
|
||||
)
|
||||
from .helpers import flatten_price_info, prepare_headers, verify_graphql_response, verify_response_or_raise
|
||||
from .queries import TibberPricesQueryType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -163,9 +158,7 @@ class TibberPricesApiClient:
|
|||
|
||||
"""
|
||||
# Import here to avoid circular dependency (interval_pool imports TibberPricesApiClient)
|
||||
from custom_components.tibber_prices.interval_pool import ( # noqa: PLC0415
|
||||
get_price_intervals_for_range,
|
||||
)
|
||||
from custom_components.tibber_prices.interval_pool import get_price_intervals_for_range # noqa: PLC0415
|
||||
|
||||
price_info = await get_price_intervals_for_range(
|
||||
api_client=self,
|
||||
|
|
@ -581,7 +574,7 @@ class TibberPricesApiClient:
|
|||
"""
|
||||
Calculate day before yesterday midnight in home's timezone.
|
||||
|
||||
CRITICAL: Uses REAL TIME (dt_utils.now()), NOT TimeService.now().
|
||||
CRITICAL: Uses REAL TIME (dt_util.now()), NOT TimeService.now().
|
||||
This ensures API boundary calculations are based on actual current time,
|
||||
not simulated time from TimeService.
|
||||
|
||||
|
|
@ -594,7 +587,7 @@ class TibberPricesApiClient:
|
|||
|
||||
"""
|
||||
# Get current REAL time (not TimeService)
|
||||
now = dt_utils.now()
|
||||
now = dt_util.now()
|
||||
|
||||
# Convert to home's timezone or fallback to HA system timezone
|
||||
if home_timezone:
|
||||
|
|
@ -607,10 +600,10 @@ class TibberPricesApiClient:
|
|||
home_timezone,
|
||||
error,
|
||||
)
|
||||
now_in_home_tz = dt_utils.as_local(now)
|
||||
now_in_home_tz = dt_util.as_local(now)
|
||||
else:
|
||||
# Fallback to HA system timezone
|
||||
now_in_home_tz = dt_utils.as_local(now)
|
||||
now_in_home_tz = dt_util.as_local(now)
|
||||
|
||||
# Calculate day before yesterday midnight
|
||||
return (now_in_home_tz - timedelta(days=2)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
|
@ -640,7 +633,7 @@ class TibberPricesApiClient:
|
|||
Timezone-aware datetime object.
|
||||
|
||||
"""
|
||||
return dt_utils.parse_datetime(timestamp_str) or dt_utils.now()
|
||||
return dt_util.parse_datetime(timestamp_str) or dt_util.now()
|
||||
|
||||
def _calculate_cursor_for_home(self, home_timezone: str | None) -> str:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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,9 +26,7 @@ from .attributes import (
|
|||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from custom_components.tibber_prices.coordinator import (
|
||||
TibberPricesDataUpdateCoordinator,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator
|
||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||
|
||||
|
||||
|
|
@ -64,7 +61,6 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
|
|||
"relaxation_threshold_applied_%",
|
||||
# Calculation Summary (diagnostic, changes daily → not useful in history)
|
||||
"min_periods_configured",
|
||||
"periods_found_total",
|
||||
"flat_days_detected",
|
||||
"relaxation_incomplete",
|
||||
# Redundant/Derived
|
||||
|
|
@ -73,8 +69,8 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
|
|||
"rating_difference_%",
|
||||
"period_price_diff_from_daily_min",
|
||||
"period_price_diff_from_daily_min_%",
|
||||
"periods_total",
|
||||
"periods_remaining",
|
||||
"period_count_total",
|
||||
"period_count_remaining",
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -142,6 +138,9 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
|
|||
state_getters = {
|
||||
"peak_price_period": self._peak_price_state,
|
||||
"best_price_period": self._best_price_state,
|
||||
"in_rising_price_phase": lambda: self._in_phase_state("rising"),
|
||||
"in_falling_price_phase": lambda: self._in_phase_state("falling"),
|
||||
"in_flat_price_phase": lambda: self._in_phase_state("flat"),
|
||||
"connection": lambda: get_connection_state(self.coordinator),
|
||||
"tomorrow_data_available": self._tomorrow_data_available_state,
|
||||
"has_ventilation_system": self._has_ventilation_system_state,
|
||||
|
|
@ -188,6 +187,15 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
|
|||
time = self.coordinator.time
|
||||
return time.is_time_in_period(start, end)
|
||||
|
||||
def _in_phase_state(self, phase_type: str) -> bool | None:
|
||||
"""Return True if the current intra-day price phase matches phase_type."""
|
||||
if not self.coordinator.data:
|
||||
return None
|
||||
current_type = get_current_phase_type(self.coordinator.data, time=self.coordinator.time)
|
||||
if current_type is None:
|
||||
return None
|
||||
return current_type == phase_type
|
||||
|
||||
def _tomorrow_data_available_state(self) -> bool | None:
|
||||
"""Return True if tomorrow's data is fully available, False if not, None if unknown."""
|
||||
# Auth errors: Cannot reliably check - return unknown
|
||||
|
|
@ -207,11 +215,8 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
|
|||
# Get expected intervals for tomorrow (handles DST)
|
||||
expected_intervals = self.coordinator.time.get_expected_intervals_for_day(tomorrow_date)
|
||||
|
||||
if interval_count == expected_intervals:
|
||||
return True
|
||||
if interval_count == 0:
|
||||
return False
|
||||
return False
|
||||
# True only when ALL intervals are available (partial = not available)
|
||||
return interval_count == expected_intervals
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
|
|
@ -312,6 +317,9 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
|
|||
if key == "tomorrow_data_available":
|
||||
return self._get_tomorrow_data_available_attributes()
|
||||
|
||||
if key in ("in_rising_price_phase", "in_falling_price_phase", "in_flat_price_phase"):
|
||||
return get_phase_attributes(self.coordinator.data, time=self.coordinator.time)
|
||||
|
||||
return None
|
||||
|
||||
@callback
|
||||
|
|
|
|||
|
|
@ -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 }}"
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -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 }}"
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -52,6 +52,7 @@ from custom_components.tibber_prices.const import (
|
|||
CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
CONF_BEST_PRICE_MIN_DISTANCE_FROM_AVG,
|
||||
CONF_BEST_PRICE_MIN_PERIOD_LENGTH,
|
||||
CONF_CURRENCY_DISPLAY_MODE,
|
||||
CONF_MIN_PERIODS_BEST,
|
||||
CONF_MIN_PERIODS_PEAK,
|
||||
CONF_PEAK_PRICE_FLEX,
|
||||
|
|
@ -82,7 +83,7 @@ from custom_components.tibber_prices.const import (
|
|||
get_display_unit_factor,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlowResult, OptionsFlow
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers import entity_registry as er, issue_registry as ir
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -378,7 +379,6 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
|
|||
|
||||
# Load template and connector from common section
|
||||
template = await async_get_translation(self.hass, ["common", "override_warning_template"], language)
|
||||
_LOGGER.debug("Loaded template: %s", template)
|
||||
if template:
|
||||
translations["override_warning_template"] = template
|
||||
|
||||
|
|
@ -502,10 +502,34 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
|
|||
currency_code = tibber_data.coordinator.data.get("currency")
|
||||
|
||||
if user_input is not None:
|
||||
# Detect currency display mode change before saving
|
||||
old_mode = self.config_entry.options.get(CONF_CURRENCY_DISPLAY_MODE)
|
||||
new_mode = user_input.get(CONF_CURRENCY_DISPLAY_MODE)
|
||||
|
||||
# Update options with new values
|
||||
self._options.update(user_input)
|
||||
# async_create_entry automatically handles change detection and listener triggering
|
||||
self._save_options_if_changed()
|
||||
|
||||
# Notify user of currency display mode change via Repairs
|
||||
if old_mode is not None and new_mode is not None and old_mode != new_mode:
|
||||
issue_id = f"currency_display_mode_changed_{self.config_entry.entry_id}"
|
||||
# delete + create resets dismissed_version so the issue is always visible
|
||||
# for a new mode change, even if a previous instance was dismissed.
|
||||
ir.async_delete_issue(self.hass, DOMAIN, issue_id)
|
||||
ir.async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
issue_id,
|
||||
is_fixable=False,
|
||||
is_persistent=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="currency_display_mode_changed",
|
||||
translation_placeholders={
|
||||
"home_name": self.config_entry.title,
|
||||
},
|
||||
)
|
||||
|
||||
# Return to menu for more changes
|
||||
return await self.async_step_init()
|
||||
|
||||
|
|
@ -645,8 +669,8 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
|
|||
placeholders = self._get_entity_warning_placeholders("best_price")
|
||||
placeholders.update(self._get_override_warning_placeholder("best_price", overrides))
|
||||
|
||||
# Load translations for override warnings
|
||||
override_translations = await self._get_override_translations()
|
||||
# Load translations for override warnings only when overrides are active
|
||||
override_translations = await self._get_override_translations() if overrides else {}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="best_price",
|
||||
|
|
@ -717,8 +741,8 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
|
|||
placeholders = self._get_entity_warning_placeholders("peak_price")
|
||||
placeholders.update(self._get_override_warning_placeholder("peak_price", overrides))
|
||||
|
||||
# Load translations for override warnings
|
||||
override_translations = await self._get_override_translations()
|
||||
# Load translations for override warnings only when overrides are active
|
||||
override_translations = await self._get_override_translations() if overrides else {}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="peak_price",
|
||||
|
|
|
|||
|
|
@ -12,22 +12,32 @@ import voluptuous as vol
|
|||
from custom_components.tibber_prices.const import (
|
||||
BEST_PRICE_MAX_LEVEL_OPTIONS,
|
||||
CONF_AVERAGE_SENSOR_DISPLAY,
|
||||
CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP,
|
||||
CONF_BEST_PRICE_FLEX,
|
||||
CONF_BEST_PRICE_GEOMETRIC_FLEX,
|
||||
CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS,
|
||||
CONF_BEST_PRICE_MAX_LEVEL,
|
||||
CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
CONF_BEST_PRICE_MIN_DISTANCE_FROM_AVG,
|
||||
CONF_BEST_PRICE_MIN_PERIOD_LENGTH,
|
||||
CONF_BEST_PRICE_SEGMENT_FORCING,
|
||||
CONF_BEST_PRICE_SEGMENT_MIN_PERIODS,
|
||||
CONF_CURRENCY_DISPLAY_MODE,
|
||||
CONF_ENABLE_MIN_PERIODS_BEST,
|
||||
CONF_ENABLE_MIN_PERIODS_PEAK,
|
||||
CONF_EXTENDED_DESCRIPTIONS,
|
||||
CONF_MIN_PERIODS_BEST,
|
||||
CONF_MIN_PERIODS_PEAK,
|
||||
CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE,
|
||||
CONF_PEAK_PRICE_FLEX,
|
||||
CONF_PEAK_PRICE_GEOMETRIC_FLEX,
|
||||
CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
|
||||
CONF_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
CONF_PEAK_PRICE_MIN_DISTANCE_FROM_AVG,
|
||||
CONF_PEAK_PRICE_MIN_LEVEL,
|
||||
CONF_PEAK_PRICE_MIN_PERIOD_LENGTH,
|
||||
CONF_PEAK_PRICE_SEGMENT_FORCING,
|
||||
CONF_PEAK_PRICE_SEGMENT_MIN_PERIODS,
|
||||
CONF_PRICE_LEVEL_GAP_TOLERANCE,
|
||||
CONF_PRICE_RATING_GAP_TOLERANCE,
|
||||
CONF_PRICE_RATING_HYSTERESIS,
|
||||
|
|
@ -49,21 +59,31 @@ from custom_components.tibber_prices.const import (
|
|||
CONF_VOLATILITY_THRESHOLD_MODERATE,
|
||||
CONF_VOLATILITY_THRESHOLD_VERY_HIGH,
|
||||
DEFAULT_AVERAGE_SENSOR_DISPLAY,
|
||||
DEFAULT_BEST_PRICE_EXTEND_TO_VERY_CHEAP,
|
||||
DEFAULT_BEST_PRICE_FLEX,
|
||||
DEFAULT_BEST_PRICE_GEOMETRIC_FLEX,
|
||||
DEFAULT_BEST_PRICE_MAX_EXTENSION_INTERVALS,
|
||||
DEFAULT_BEST_PRICE_MAX_LEVEL,
|
||||
DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
DEFAULT_BEST_PRICE_MIN_DISTANCE_FROM_AVG,
|
||||
DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH,
|
||||
DEFAULT_BEST_PRICE_SEGMENT_FORCING,
|
||||
DEFAULT_BEST_PRICE_SEGMENT_MIN_PERIODS,
|
||||
DEFAULT_ENABLE_MIN_PERIODS_BEST,
|
||||
DEFAULT_ENABLE_MIN_PERIODS_PEAK,
|
||||
DEFAULT_EXTENDED_DESCRIPTIONS,
|
||||
DEFAULT_MIN_PERIODS_BEST,
|
||||
DEFAULT_MIN_PERIODS_PEAK,
|
||||
DEFAULT_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE,
|
||||
DEFAULT_PEAK_PRICE_FLEX,
|
||||
DEFAULT_PEAK_PRICE_GEOMETRIC_FLEX,
|
||||
DEFAULT_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
|
||||
DEFAULT_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
DEFAULT_PEAK_PRICE_MIN_DISTANCE_FROM_AVG,
|
||||
DEFAULT_PEAK_PRICE_MIN_LEVEL,
|
||||
DEFAULT_PEAK_PRICE_MIN_PERIOD_LENGTH,
|
||||
DEFAULT_PEAK_PRICE_SEGMENT_FORCING,
|
||||
DEFAULT_PEAK_PRICE_SEGMENT_MIN_PERIODS,
|
||||
DEFAULT_PRICE_LEVEL_GAP_TOLERANCE,
|
||||
DEFAULT_PRICE_RATING_GAP_TOLERANCE,
|
||||
DEFAULT_PRICE_RATING_HYSTERESIS,
|
||||
|
|
@ -86,7 +106,9 @@ from custom_components.tibber_prices.const import (
|
|||
DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH,
|
||||
DISPLAY_MODE_BASE,
|
||||
DISPLAY_MODE_SUBUNIT,
|
||||
MAX_EXTENSION_INTERVALS,
|
||||
MAX_GAP_COUNT,
|
||||
MAX_GEOMETRIC_FLEX,
|
||||
MAX_MIN_PERIOD_LENGTH,
|
||||
MAX_MIN_PERIODS,
|
||||
MAX_PRICE_LEVEL_GAP_TOLERANCE,
|
||||
|
|
@ -102,6 +124,7 @@ from custom_components.tibber_prices.const import (
|
|||
MAX_PRICE_TREND_STRONGLY_FALLING,
|
||||
MAX_PRICE_TREND_STRONGLY_RISING,
|
||||
MAX_RELAXATION_ATTEMPTS,
|
||||
MAX_SEGMENT_MIN_PERIODS,
|
||||
MAX_VOLATILITY_THRESHOLD_HIGH,
|
||||
MAX_VOLATILITY_THRESHOLD_MODERATE,
|
||||
MAX_VOLATILITY_THRESHOLD_VERY_HIGH,
|
||||
|
|
@ -151,7 +174,7 @@ ConfigOverrides = dict[str, dict[str, Any]]
|
|||
|
||||
def is_field_overridden(
|
||||
config_key: str,
|
||||
config_section: str, # noqa: ARG001 - kept for API compatibility
|
||||
config_section: str,
|
||||
overrides: ConfigOverrides | None,
|
||||
) -> bool:
|
||||
"""
|
||||
|
|
@ -618,6 +641,7 @@ def get_best_price_schema(
|
|||
period_settings = options.get("period_settings", {})
|
||||
flexibility_settings = options.get("flexibility_settings", {})
|
||||
relaxation_settings = options.get("relaxation_and_target_periods", {})
|
||||
extension_settings = options.get("extension_settings", {})
|
||||
|
||||
# Get current values for override display
|
||||
min_period_length = int(
|
||||
|
|
@ -633,6 +657,19 @@ def get_best_price_schema(
|
|||
enable_min_periods = relaxation_settings.get(CONF_ENABLE_MIN_PERIODS_BEST, DEFAULT_ENABLE_MIN_PERIODS_BEST)
|
||||
min_periods = int(relaxation_settings.get(CONF_MIN_PERIODS_BEST, DEFAULT_MIN_PERIODS_BEST))
|
||||
relaxation_attempts = int(relaxation_settings.get(CONF_RELAXATION_ATTEMPTS_BEST, DEFAULT_RELAXATION_ATTEMPTS_BEST))
|
||||
extend_to_very_cheap = bool(
|
||||
extension_settings.get(CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP, DEFAULT_BEST_PRICE_EXTEND_TO_VERY_CHEAP)
|
||||
)
|
||||
max_extension_intervals_best = int(
|
||||
extension_settings.get(CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS, DEFAULT_BEST_PRICE_MAX_EXTENSION_INTERVALS)
|
||||
)
|
||||
geometric_flex_best = int(extension_settings.get(CONF_BEST_PRICE_GEOMETRIC_FLEX, DEFAULT_BEST_PRICE_GEOMETRIC_FLEX))
|
||||
segment_forcing_best = bool(
|
||||
extension_settings.get(CONF_BEST_PRICE_SEGMENT_FORCING, DEFAULT_BEST_PRICE_SEGMENT_FORCING)
|
||||
)
|
||||
segment_min_periods_best = int(
|
||||
extension_settings.get(CONF_BEST_PRICE_SEGMENT_MIN_PERIODS, DEFAULT_BEST_PRICE_SEGMENT_MIN_PERIODS)
|
||||
)
|
||||
|
||||
# Build section schemas with optional override warnings
|
||||
period_warning = get_section_override_warning("best_price", "period_settings", overrides, translations) or {}
|
||||
|
|
@ -754,6 +791,55 @@ def get_best_price_schema(
|
|||
vol.Schema(relaxation_fields),
|
||||
{"collapsed": True},
|
||||
),
|
||||
vol.Required("extension_settings"): section(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP,
|
||||
default=extend_to_very_cheap,
|
||||
): BooleanSelector(selector.BooleanSelectorConfig()),
|
||||
vol.Optional(
|
||||
CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS,
|
||||
default=max_extension_intervals_best,
|
||||
): NumberSelector(
|
||||
NumberSelectorConfig(
|
||||
min=1,
|
||||
max=MAX_EXTENSION_INTERVALS,
|
||||
step=1,
|
||||
mode=NumberSelectorMode.SLIDER,
|
||||
)
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_BEST_PRICE_GEOMETRIC_FLEX,
|
||||
default=geometric_flex_best,
|
||||
): NumberSelector(
|
||||
NumberSelectorConfig(
|
||||
min=0,
|
||||
max=MAX_GEOMETRIC_FLEX,
|
||||
step=1,
|
||||
unit_of_measurement="%",
|
||||
mode=NumberSelectorMode.SLIDER,
|
||||
)
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_BEST_PRICE_SEGMENT_FORCING,
|
||||
default=segment_forcing_best,
|
||||
): BooleanSelector(selector.BooleanSelectorConfig()),
|
||||
vol.Optional(
|
||||
CONF_BEST_PRICE_SEGMENT_MIN_PERIODS,
|
||||
default=segment_min_periods_best,
|
||||
): NumberSelector(
|
||||
NumberSelectorConfig(
|
||||
min=1,
|
||||
max=MAX_SEGMENT_MIN_PERIODS,
|
||||
step=1,
|
||||
mode=NumberSelectorMode.SLIDER,
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
{"collapsed": True},
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -779,6 +865,7 @@ def get_peak_price_schema(
|
|||
period_settings = options.get("period_settings", {})
|
||||
flexibility_settings = options.get("flexibility_settings", {})
|
||||
relaxation_settings = options.get("relaxation_and_target_periods", {})
|
||||
extension_settings = options.get("extension_settings", {})
|
||||
|
||||
# Get current values for override display
|
||||
min_period_length = int(
|
||||
|
|
@ -794,6 +881,19 @@ def get_peak_price_schema(
|
|||
enable_min_periods = relaxation_settings.get(CONF_ENABLE_MIN_PERIODS_PEAK, DEFAULT_ENABLE_MIN_PERIODS_PEAK)
|
||||
min_periods = int(relaxation_settings.get(CONF_MIN_PERIODS_PEAK, DEFAULT_MIN_PERIODS_PEAK))
|
||||
relaxation_attempts = int(relaxation_settings.get(CONF_RELAXATION_ATTEMPTS_PEAK, DEFAULT_RELAXATION_ATTEMPTS_PEAK))
|
||||
extend_to_very_expensive = bool(
|
||||
extension_settings.get(CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE, DEFAULT_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE)
|
||||
)
|
||||
max_extension_intervals_peak = int(
|
||||
extension_settings.get(CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS, DEFAULT_PEAK_PRICE_MAX_EXTENSION_INTERVALS)
|
||||
)
|
||||
geometric_flex_peak = int(extension_settings.get(CONF_PEAK_PRICE_GEOMETRIC_FLEX, DEFAULT_PEAK_PRICE_GEOMETRIC_FLEX))
|
||||
segment_forcing_peak = bool(
|
||||
extension_settings.get(CONF_PEAK_PRICE_SEGMENT_FORCING, DEFAULT_PEAK_PRICE_SEGMENT_FORCING)
|
||||
)
|
||||
segment_min_periods_peak = int(
|
||||
extension_settings.get(CONF_PEAK_PRICE_SEGMENT_MIN_PERIODS, DEFAULT_PEAK_PRICE_SEGMENT_MIN_PERIODS)
|
||||
)
|
||||
|
||||
# Build section schemas with optional override warnings
|
||||
period_warning = get_section_override_warning("peak_price", "period_settings", overrides, translations) or {}
|
||||
|
|
@ -915,6 +1015,55 @@ def get_peak_price_schema(
|
|||
vol.Schema(relaxation_fields),
|
||||
{"collapsed": True},
|
||||
),
|
||||
vol.Required("extension_settings"): section(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE,
|
||||
default=extend_to_very_expensive,
|
||||
): BooleanSelector(selector.BooleanSelectorConfig()),
|
||||
vol.Optional(
|
||||
CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
|
||||
default=max_extension_intervals_peak,
|
||||
): NumberSelector(
|
||||
NumberSelectorConfig(
|
||||
min=1,
|
||||
max=MAX_EXTENSION_INTERVALS,
|
||||
step=1,
|
||||
mode=NumberSelectorMode.SLIDER,
|
||||
)
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_PEAK_PRICE_GEOMETRIC_FLEX,
|
||||
default=geometric_flex_peak,
|
||||
): NumberSelector(
|
||||
NumberSelectorConfig(
|
||||
min=0,
|
||||
max=MAX_GEOMETRIC_FLEX,
|
||||
step=1,
|
||||
unit_of_measurement="%",
|
||||
mode=NumberSelectorMode.SLIDER,
|
||||
)
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_PEAK_PRICE_SEGMENT_FORCING,
|
||||
default=segment_forcing_peak,
|
||||
): BooleanSelector(selector.BooleanSelectorConfig()),
|
||||
vol.Optional(
|
||||
CONF_PEAK_PRICE_SEGMENT_MIN_PERIODS,
|
||||
default=segment_min_periods_peak,
|
||||
): NumberSelector(
|
||||
NumberSelectorConfig(
|
||||
min=1,
|
||||
max=MAX_SEGMENT_MIN_PERIODS,
|
||||
step=1,
|
||||
mode=NumberSelectorMode.SLIDER,
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
{"collapsed": True},
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
@ -68,7 +67,16 @@ CONF_RELAXATION_ATTEMPTS_BEST = "relaxation_attempts_best"
|
|||
CONF_ENABLE_MIN_PERIODS_PEAK = "enable_min_periods_peak"
|
||||
CONF_MIN_PERIODS_PEAK = "min_periods_peak"
|
||||
CONF_RELAXATION_ATTEMPTS_PEAK = "relaxation_attempts_peak"
|
||||
CONF_CHART_DATA_CONFIG = "chart_data_config" # YAML config for chart data export
|
||||
CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP = "best_price_extend_to_very_cheap"
|
||||
CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS = "best_price_max_extension_intervals"
|
||||
CONF_BEST_PRICE_GEOMETRIC_FLEX = "best_price_geometric_flex"
|
||||
CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE = "peak_price_extend_to_very_expensive"
|
||||
CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS = "peak_price_max_extension_intervals"
|
||||
CONF_PEAK_PRICE_GEOMETRIC_FLEX = "peak_price_geometric_flex"
|
||||
CONF_BEST_PRICE_SEGMENT_FORCING = "best_price_segment_forcing"
|
||||
CONF_BEST_PRICE_SEGMENT_MIN_PERIODS = "best_price_segment_min_periods"
|
||||
CONF_PEAK_PRICE_SEGMENT_FORCING = "peak_price_segment_forcing"
|
||||
CONF_PEAK_PRICE_SEGMENT_MIN_PERIODS = "peak_price_segment_min_periods"
|
||||
|
||||
ATTRIBUTION = "Data provided by Tibber"
|
||||
|
||||
|
|
@ -132,6 +140,16 @@ DEFAULT_RELAXATION_ATTEMPTS_BEST = 11 # Default: 11 steps allows escalation fro
|
|||
DEFAULT_ENABLE_MIN_PERIODS_PEAK = True # Default: minimum periods feature enabled for peak price
|
||||
DEFAULT_MIN_PERIODS_PEAK = 2 # Default: require at least 2 peak price periods (when enabled)
|
||||
DEFAULT_RELAXATION_ATTEMPTS_PEAK = 11 # Default: 11 steps allows escalation from 20% to 50% (3% increment per step)
|
||||
DEFAULT_BEST_PRICE_EXTEND_TO_VERY_CHEAP = False # Default: disabled (opt-in feature)
|
||||
DEFAULT_BEST_PRICE_MAX_EXTENSION_INTERVALS = 4 # Default: up to 4 intervals (1 hour) per side
|
||||
DEFAULT_BEST_PRICE_GEOMETRIC_FLEX = 0 # Default: 0% (disabled); positive int % (e.g. 10 = 10%)
|
||||
DEFAULT_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE = False # Default: disabled (opt-in feature)
|
||||
DEFAULT_PEAK_PRICE_MAX_EXTENSION_INTERVALS = 4 # Default: up to 4 intervals (1 hour) per side
|
||||
DEFAULT_PEAK_PRICE_GEOMETRIC_FLEX = 0 # Default: 0% (disabled); positive int % (e.g. 10 = 10%)
|
||||
DEFAULT_BEST_PRICE_SEGMENT_FORCING = False # Default: disabled (opt-in W-shape feature)
|
||||
DEFAULT_BEST_PRICE_SEGMENT_MIN_PERIODS = 1 # Default: at least 1 period required per segment
|
||||
DEFAULT_PEAK_PRICE_SEGMENT_FORCING = False # Default: disabled (opt-in M-shape feature)
|
||||
DEFAULT_PEAK_PRICE_SEGMENT_MIN_PERIODS = 1 # Default: at least 1 period required per segment
|
||||
|
||||
# Validation limits (used in GUI schemas and server-side validation)
|
||||
# These ensure consistency between frontend and backend validation
|
||||
|
|
@ -140,6 +158,9 @@ MAX_DISTANCE_PERCENTAGE = 50 # Maximum distance from average percentage (GUI sl
|
|||
MAX_GAP_COUNT = 8 # Maximum gap count for level filtering (GUI slider limit)
|
||||
MAX_MIN_PERIODS = 10 # Maximum number of minimum periods per day (GUI slider limit)
|
||||
MAX_RELAXATION_ATTEMPTS = 12 # Maximum relaxation attempts (GUI slider limit)
|
||||
MAX_EXTENSION_INTERVALS = 12 # Maximum extension intervals per side (GUI slider limit = 3 hours)
|
||||
MAX_GEOMETRIC_FLEX = 25 # Maximum geometric flex bonus percentage (GUI slider limit)
|
||||
MAX_SEGMENT_MIN_PERIODS = 5 # Maximum per-segment minimum periods (GUI slider limit)
|
||||
MIN_PERIOD_LENGTH = 15 # Minimum period length in minutes (1 quarter hour)
|
||||
MAX_MIN_PERIOD_LENGTH = 180 # Maximum for minimum period length setting (3 hours - realistic for required minimum)
|
||||
|
||||
|
|
@ -408,6 +429,19 @@ def get_default_options(currency_code: str | None) -> dict[str, Any]:
|
|||
CONF_MIN_PERIODS_PEAK: DEFAULT_MIN_PERIODS_PEAK,
|
||||
CONF_RELAXATION_ATTEMPTS_PEAK: DEFAULT_RELAXATION_ATTEMPTS_PEAK,
|
||||
},
|
||||
# Nested section: Extension settings (shared by best/peak price)
|
||||
"extension_settings": {
|
||||
CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP: DEFAULT_BEST_PRICE_EXTEND_TO_VERY_CHEAP,
|
||||
CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS: DEFAULT_BEST_PRICE_MAX_EXTENSION_INTERVALS,
|
||||
CONF_BEST_PRICE_GEOMETRIC_FLEX: DEFAULT_BEST_PRICE_GEOMETRIC_FLEX,
|
||||
CONF_BEST_PRICE_SEGMENT_FORCING: DEFAULT_BEST_PRICE_SEGMENT_FORCING,
|
||||
CONF_BEST_PRICE_SEGMENT_MIN_PERIODS: DEFAULT_BEST_PRICE_SEGMENT_MIN_PERIODS,
|
||||
CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE: DEFAULT_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE,
|
||||
CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS: DEFAULT_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
|
||||
CONF_PEAK_PRICE_GEOMETRIC_FLEX: DEFAULT_PEAK_PRICE_GEOMETRIC_FLEX,
|
||||
CONF_PEAK_PRICE_SEGMENT_FORCING: DEFAULT_PEAK_PRICE_SEGMENT_FORCING,
|
||||
CONF_PEAK_PRICE_SEGMENT_MIN_PERIODS: DEFAULT_PEAK_PRICE_SEGMENT_MIN_PERIODS,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -427,14 +461,42 @@ def get_display_unit_factor(config_entry: ConfigEntry) -> int:
|
|||
Example:
|
||||
price_base = 0.2534 # Internal: 0.2534 €/kWh
|
||||
factor = get_display_unit_factor(config_entry)
|
||||
display_value = round(price_base * factor, 2)
|
||||
# → 25.34 ct/kWh (subunit) or 0.25 €/kWh (base)
|
||||
precision = get_display_precision(config_entry)
|
||||
display_value = round(price_base * factor, precision)
|
||||
# → 25.34 ct/kWh (subunit, 2 decimals) or 0.2534 €/kWh (base, 4 decimals)
|
||||
|
||||
"""
|
||||
display_mode = config_entry.options.get(CONF_CURRENCY_DISPLAY_MODE, DISPLAY_MODE_SUBUNIT)
|
||||
return 100 if display_mode == DISPLAY_MODE_SUBUNIT else 1
|
||||
|
||||
|
||||
# Rounding precision constants for display currency
|
||||
DISPLAY_PRECISION_SUBUNIT = 2 # Decimal places for subunit currency (ct, øre)
|
||||
DISPLAY_PRECISION_BASE = 4 # Decimal places for base currency (€, kr)
|
||||
|
||||
|
||||
def get_display_precision(config_entry: ConfigEntry) -> int:
|
||||
"""
|
||||
Get decimal precision for rounding prices in the configured display currency.
|
||||
|
||||
Subunit currencies (ct, øre) use 2 decimal places (e.g., 25.34 ct/kWh).
|
||||
Base currencies (€, kr) use 4 decimal places (e.g., 0.2534 €/kWh).
|
||||
|
||||
This ensures sufficient precision for all currency modes:
|
||||
- Subunit: 2 decimals (the sub-cent level is rarely meaningful)
|
||||
- Base: 4 decimals (preserves full API precision for EUR/NOK/SEK prices)
|
||||
|
||||
Args:
|
||||
config_entry: ConfigEntry with currency_display_mode option
|
||||
|
||||
Returns:
|
||||
2 for subunit currency, 4 for base currency
|
||||
|
||||
"""
|
||||
display_mode = config_entry.options.get(CONF_CURRENCY_DISPLAY_MODE, DISPLAY_MODE_SUBUNIT)
|
||||
return DISPLAY_PRECISION_SUBUNIT if display_mode == DISPLAY_MODE_SUBUNIT else DISPLAY_PRECISION_BASE
|
||||
|
||||
|
||||
def get_display_unit_string(config_entry: ConfigEntry, currency_code: str | None) -> str:
|
||||
"""
|
||||
Get unit string for display based on configuration.
|
||||
|
|
@ -487,40 +549,6 @@ PRICE_TREND_STABLE = "stable"
|
|||
PRICE_TREND_RISING = "rising"
|
||||
PRICE_TREND_STRONGLY_RISING = "strongly_rising"
|
||||
|
||||
# Sensor options (lowercase versions for ENUM device class)
|
||||
# NOTE: These constants define the valid enum options, but they are not used directly
|
||||
# in sensor/definitions.py due to import timing issues. Instead, the options are defined inline
|
||||
# in the SensorEntityDescription objects. Keep these in sync with sensor/definitions.py!
|
||||
PRICE_LEVEL_OPTIONS = [
|
||||
PRICE_LEVEL_VERY_CHEAP.lower(),
|
||||
PRICE_LEVEL_CHEAP.lower(),
|
||||
PRICE_LEVEL_NORMAL.lower(),
|
||||
PRICE_LEVEL_EXPENSIVE.lower(),
|
||||
PRICE_LEVEL_VERY_EXPENSIVE.lower(),
|
||||
]
|
||||
|
||||
PRICE_RATING_OPTIONS = [
|
||||
PRICE_RATING_LOW.lower(),
|
||||
PRICE_RATING_NORMAL.lower(),
|
||||
PRICE_RATING_HIGH.lower(),
|
||||
]
|
||||
|
||||
VOLATILITY_OPTIONS = [
|
||||
VOLATILITY_LOW.lower(),
|
||||
VOLATILITY_MODERATE.lower(),
|
||||
VOLATILITY_HIGH.lower(),
|
||||
VOLATILITY_VERY_HIGH.lower(),
|
||||
]
|
||||
|
||||
# Trend options for enum sensors (lowercase versions for ENUM device class)
|
||||
PRICE_TREND_OPTIONS = [
|
||||
PRICE_TREND_STRONGLY_FALLING,
|
||||
PRICE_TREND_FALLING,
|
||||
PRICE_TREND_STABLE,
|
||||
PRICE_TREND_RISING,
|
||||
PRICE_TREND_STRONGLY_RISING,
|
||||
]
|
||||
|
||||
# Valid options for best price maximum level filter
|
||||
# Sorted from cheap to expensive: user selects "up to how expensive"
|
||||
BEST_PRICE_MAX_LEVEL_OPTIONS = [
|
||||
|
|
@ -1019,26 +1047,6 @@ def get_price_level_translation(
|
|||
return get_translation(["sensor", "current_interval_price_level", "price_levels", level], language)
|
||||
|
||||
|
||||
async def async_get_home_type_translation(
|
||||
hass: HomeAssistant,
|
||||
home_type: str,
|
||||
language: str = "en",
|
||||
) -> str | None:
|
||||
"""
|
||||
Get a localized translation for a home type asynchronously.
|
||||
|
||||
Args:
|
||||
hass: HomeAssistant instance
|
||||
home_type: The home type (e.g., APARTMENT, HOUSE, etc.)
|
||||
language: The language code (defaults to English)
|
||||
|
||||
Returns:
|
||||
The localized home type if found, None otherwise
|
||||
|
||||
"""
|
||||
return await async_get_translation(hass, ["home_types", home_type], language)
|
||||
|
||||
|
||||
def get_home_type_translation(
|
||||
home_type: str,
|
||||
language: str = "en",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -88,11 +88,36 @@ TIME_SENSITIVE_ENTITY_KEYS = frozenset(
|
|||
# Binary sensors that check if current time is in a period
|
||||
"peak_price_period",
|
||||
"best_price_period",
|
||||
# Binary sensors for current intra-day price phase
|
||||
"in_rising_price_phase",
|
||||
"in_falling_price_phase",
|
||||
"in_flat_price_phase",
|
||||
# Best/Peak price timestamp sensors (periods only change at interval boundaries)
|
||||
"best_price_end_time",
|
||||
"best_price_next_start_time",
|
||||
"peak_price_end_time",
|
||||
"peak_price_next_start_time",
|
||||
# Current price phase timing sensors (phase boundaries only change at interval boundaries)
|
||||
"current_price_phase_end_time",
|
||||
"current_price_phase_duration",
|
||||
"next_rising_phase_start_time",
|
||||
"next_falling_phase_start_time",
|
||||
"next_flat_phase_start_time",
|
||||
# Current/next price phase enum sensors
|
||||
"current_price_phase",
|
||||
"next_price_phase",
|
||||
# Price rank sensors (rank of current/next/previous interval within a day scope)
|
||||
"current_interval_price_rank_today",
|
||||
"current_interval_price_rank_tomorrow",
|
||||
"current_interval_price_rank_today_tomorrow",
|
||||
"current_hour_price_rank_today",
|
||||
"current_hour_price_rank_today_tomorrow",
|
||||
"next_interval_price_rank_today",
|
||||
"next_interval_price_rank_today_tomorrow",
|
||||
"next_hour_price_rank_today",
|
||||
"next_hour_price_rank_today_tomorrow",
|
||||
"previous_interval_price_rank_today",
|
||||
"previous_interval_price_rank_today_tomorrow",
|
||||
# Lifecycle sensor needs quarter-hour precision for state transitions:
|
||||
# - 23:45: turnover_pending (last interval before midnight)
|
||||
# - 00:00: turnover complete (after midnight API update)
|
||||
|
|
@ -116,7 +141,14 @@ MINUTE_UPDATE_ENTITY_KEYS = frozenset(
|
|||
"peak_price_remaining_minutes",
|
||||
"peak_price_progress",
|
||||
"peak_price_next_in_minutes",
|
||||
# Current price phase countdown/progress sensors (need minute updates)
|
||||
"current_price_phase_remaining_minutes",
|
||||
"current_price_phase_progress",
|
||||
# Next-phase countdown sensors (need minute updates)
|
||||
"next_rising_phase_in_minutes",
|
||||
"next_falling_phase_in_minutes",
|
||||
"next_flat_phase_in_minutes",
|
||||
# Trend change countdown sensor (needs minute updates)
|
||||
"trend_change_in_minutes",
|
||||
"next_price_trend_change_in",
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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,90 +975,103 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
|
|||
)
|
||||
break
|
||||
|
||||
phase_label = f"flex={current_flex * 100:.1f}%"
|
||||
for level_override, applied_level_filter in filter_variants:
|
||||
phase_label = f"flex={current_flex * 100:.1f}%"
|
||||
phase_label_full = phase_label
|
||||
if applied_level_filter is not None:
|
||||
phase_label_full = f"{phase_label} +level_{applied_level_filter}"
|
||||
|
||||
# Skip this flex level if callback says not to show it
|
||||
if not should_show_callback(phase_label):
|
||||
continue
|
||||
# The callback expects a level override (e.g. None or "any"), not a flex label.
|
||||
if not should_show_callback(level_override):
|
||||
continue
|
||||
|
||||
if level_override == "any" and original_level_filter not in (None, "any"):
|
||||
_LOGGER_DETAILS.debug(
|
||||
"%s Flex=%.1f%%: OVERRIDING level_filter: %s → ANY",
|
||||
INDENT_L2,
|
||||
current_flex * 100,
|
||||
original_level_filter,
|
||||
)
|
||||
|
||||
# NOTE: config.flex is already normalized to positive by get_period_config()
|
||||
relaxed_config = config._replace(
|
||||
flex=current_flex, # Already positive from normalization
|
||||
level_filter=applied_level_filter,
|
||||
)
|
||||
|
||||
# Try current flex with level="any" (in relaxation mode)
|
||||
if original_level_filter != "any":
|
||||
_LOGGER_DETAILS.debug(
|
||||
"%s Flex=%.1f%%: OVERRIDING level_filter: %s → ANY",
|
||||
"%s Trying %s: config has %d intervals (all days together), level_filter=%s",
|
||||
INDENT_L2,
|
||||
current_flex * 100,
|
||||
original_level_filter,
|
||||
)
|
||||
|
||||
# NOTE: config.flex is already normalized to positive by get_period_config()
|
||||
relaxed_config = config._replace(
|
||||
flex=current_flex, # Already positive from normalization
|
||||
level_filter="any",
|
||||
)
|
||||
|
||||
phase_label_full = f"flex={current_flex * 100:.1f}% +level_any"
|
||||
_LOGGER_DETAILS.debug(
|
||||
"%s Trying %s: config has %d intervals (all days together), level_filter=%s",
|
||||
INDENT_L2,
|
||||
phase_label_full,
|
||||
len(all_prices),
|
||||
relaxed_config.level_filter,
|
||||
)
|
||||
|
||||
# Process ALL prices together (allows midnight crossing)
|
||||
result = calculate_periods(all_prices, config=relaxed_config, time=time)
|
||||
new_periods = result["periods"]
|
||||
|
||||
_LOGGER_DETAILS.debug(
|
||||
"%s %s: calculate_periods returned %d periods",
|
||||
INDENT_L2,
|
||||
phase_label_full,
|
||||
len(new_periods),
|
||||
)
|
||||
|
||||
# Mark newly found periods with relaxation metadata BEFORE merging
|
||||
mark_periods_with_relaxation(
|
||||
new_periods,
|
||||
relaxation_level=phase_label_full,
|
||||
original_threshold=base_flex,
|
||||
applied_threshold=current_flex,
|
||||
reverse_sort=config.reverse_sort,
|
||||
)
|
||||
|
||||
# Resolve overlaps between existing and new periods
|
||||
combined, standalone_count = resolve_period_overlaps(
|
||||
existing_periods=existing_periods,
|
||||
new_relaxed_periods=new_periods,
|
||||
)
|
||||
|
||||
# Count periods per day with QUALITY GATE check
|
||||
# Only periods with CV <= PERIOD_MAX_CV count towards min_periods requirement
|
||||
days_meeting_requirement, quality_period_count = _count_quality_periods(
|
||||
combined, all_prices, prices_by_day, min_periods, time=time
|
||||
)
|
||||
|
||||
total_periods = len(combined)
|
||||
_LOGGER_DETAILS.debug(
|
||||
"%s %s: found %d periods total, %d/%d days meet requirement",
|
||||
INDENT_L2,
|
||||
phase_label_full,
|
||||
total_periods,
|
||||
days_meeting_requirement,
|
||||
total_days,
|
||||
)
|
||||
|
||||
existing_periods = combined
|
||||
phases_used.append(phase_label_full)
|
||||
|
||||
# Check if ALL days reached target
|
||||
if days_meeting_requirement >= total_days:
|
||||
_LOGGER.info(
|
||||
"Success with %s - all %d days have %d+ periods (%d total)",
|
||||
phase_label_full,
|
||||
total_days,
|
||||
min_periods,
|
||||
total_periods,
|
||||
len(all_prices),
|
||||
relaxed_config.level_filter,
|
||||
)
|
||||
|
||||
# Process ALL prices together (allows midnight crossing)
|
||||
result = calculate_periods(
|
||||
all_prices,
|
||||
config=relaxed_config,
|
||||
time=time,
|
||||
day_patterns_by_date=day_patterns_by_date,
|
||||
)
|
||||
new_periods = result["periods"]
|
||||
|
||||
_LOGGER_DETAILS.debug(
|
||||
"%s %s: calculate_periods returned %d periods",
|
||||
INDENT_L2,
|
||||
phase_label_full,
|
||||
len(new_periods),
|
||||
)
|
||||
|
||||
# Mark newly found periods with relaxation metadata BEFORE merging
|
||||
mark_periods_with_relaxation(
|
||||
new_periods,
|
||||
relaxation_level=phase_label_full,
|
||||
original_threshold=base_flex,
|
||||
applied_threshold=current_flex,
|
||||
reverse_sort=config.reverse_sort,
|
||||
)
|
||||
|
||||
# Resolve overlaps between existing and new periods
|
||||
combined, standalone_count = resolve_period_overlaps(
|
||||
existing_periods=existing_periods,
|
||||
new_relaxed_periods=new_periods,
|
||||
all_prices=all_prices,
|
||||
config=config,
|
||||
time=time,
|
||||
)
|
||||
|
||||
# Count periods per day with QUALITY GATE check
|
||||
# Only periods with CV <= PERIOD_MAX_CV count towards min_periods requirement
|
||||
days_meeting_requirement, quality_period_count = _count_quality_periods(
|
||||
combined, all_prices, prices_by_day, min_periods, time=time
|
||||
)
|
||||
|
||||
total_periods = len(combined)
|
||||
_LOGGER_DETAILS.debug(
|
||||
"%s %s: found %d periods total, %d/%d days meet requirement",
|
||||
INDENT_L2,
|
||||
phase_label_full,
|
||||
total_periods,
|
||||
days_meeting_requirement,
|
||||
total_days,
|
||||
)
|
||||
|
||||
existing_periods = combined
|
||||
phases_used.append(phase_label_full)
|
||||
|
||||
# Check if ALL days reached target
|
||||
if days_meeting_requirement >= total_days:
|
||||
_LOGGER.info(
|
||||
"Success with %s - all %d days have %d+ periods (%d total)",
|
||||
phase_label_full,
|
||||
total_days,
|
||||
min_periods,
|
||||
total_periods,
|
||||
)
|
||||
break
|
||||
|
||||
if days_meeting_requirement >= total_days:
|
||||
break
|
||||
|
||||
# Build final result
|
||||
|
|
@ -1010,5 +1082,4 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
|
|||
|
||||
return final_result, {
|
||||
"phases_used": phases_used,
|
||||
"periods_found": len(existing_periods),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
_const.CONF_PEAK_PRICE_MIN_PERIOD_LENGTH,
|
||||
min_period_minutes = self._normalize_int_option(
|
||||
self._get_option(
|
||||
_const.CONF_PEAK_PRICE_MIN_PERIOD_LENGTH,
|
||||
"period_settings",
|
||||
_const.DEFAULT_PEAK_PRICE_MIN_PERIOD_LENGTH,
|
||||
),
|
||||
_const.DEFAULT_PEAK_PRICE_MIN_PERIOD_LENGTH,
|
||||
option_name=_const.CONF_PEAK_PRICE_MIN_PERIOD_LENGTH,
|
||||
minimum=1,
|
||||
)
|
||||
else:
|
||||
min_period_minutes = period_settings.get(
|
||||
_const.CONF_BEST_PRICE_MIN_PERIOD_LENGTH,
|
||||
min_period_minutes = self._normalize_int_option(
|
||||
self._get_option(
|
||||
_const.CONF_BEST_PRICE_MIN_PERIOD_LENGTH,
|
||||
"period_settings",
|
||||
_const.DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH,
|
||||
),
|
||||
_const.DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH,
|
||||
option_name=_const.CONF_BEST_PRICE_MIN_PERIOD_LENGTH,
|
||||
minimum=1,
|
||||
)
|
||||
|
||||
min_period_intervals = self.time.minutes_to_intervals(min_period_minutes)
|
||||
|
|
@ -504,7 +708,7 @@ class TibberPricesPeriodCalculator:
|
|||
|
||||
def check_level_filter(
|
||||
self,
|
||||
price_info: dict[str, Any],
|
||||
price_info: list[dict[str, Any]],
|
||||
*,
|
||||
reverse_sort: bool,
|
||||
override: str | None = None,
|
||||
|
|
@ -516,7 +720,7 @@ class TibberPricesPeriodCalculator:
|
|||
to deviate by one level step (e.g., CHEAP allows NORMAL, but not EXPENSIVE).
|
||||
|
||||
Args:
|
||||
price_info: Price information dict with today data
|
||||
price_info: Flat list of price intervals used for today's level check
|
||||
reverse_sort: If False (best_price), checks max_level (upper bound filter).
|
||||
If True (peak_price), checks min_level (lower bound filter).
|
||||
override: Optional override value (e.g., "any" to disable filter)
|
||||
|
|
@ -558,16 +762,27 @@ class TibberPricesPeriodCalculator:
|
|||
return True # If no data, don't filter
|
||||
|
||||
# Get gap tolerance configuration
|
||||
period_settings = self.config_entry.options.get("period_settings", {})
|
||||
if reverse_sort:
|
||||
max_gap_count = period_settings.get(
|
||||
_const.CONF_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
max_gap_count = self._normalize_int_option(
|
||||
self._get_option(
|
||||
_const.CONF_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
"period_settings",
|
||||
_const.DEFAULT_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
),
|
||||
_const.DEFAULT_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
option_name=_const.CONF_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
minimum=0,
|
||||
)
|
||||
else:
|
||||
max_gap_count = period_settings.get(
|
||||
_const.CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
max_gap_count = self._normalize_int_option(
|
||||
self._get_option(
|
||||
_const.CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
"period_settings",
|
||||
_const.DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
),
|
||||
_const.DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
option_name=_const.CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
minimum=0,
|
||||
)
|
||||
|
||||
# Note: level_config is lowercase from selector, but _const.PRICE_LEVEL_MAPPING uses uppercase
|
||||
|
|
@ -597,7 +812,8 @@ class TibberPricesPeriodCalculator:
|
|||
|
||||
def calculate_periods_for_price_info(
|
||||
self,
|
||||
price_info: dict[str, Any],
|
||||
price_info: list[dict[str, Any]],
|
||||
day_patterns: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Calculate periods (best price and peak price) for the given price info.
|
||||
|
|
@ -622,30 +838,63 @@ class TibberPricesPeriodCalculator:
|
|||
coordinator_data = {"priceInfo": price_info}
|
||||
all_prices = get_intervals_for_day_offsets(coordinator_data, [-2, -1, 0, 1])
|
||||
|
||||
# Convert day_patterns (keyed by "yesterday"/"today"/"tomorrow") to date-keyed dict
|
||||
# Needed for geometric valley/peak zone flex bonus in period calculation
|
||||
today_date = self.time.now().date()
|
||||
day_patterns_by_date: dict[date, dict[str, Any]] | None = (
|
||||
{
|
||||
today_date + timedelta(days=ofs): pat
|
||||
for ofs, lbl in ((-1, "yesterday"), (0, "today"), (1, "tomorrow"))
|
||||
if (pat := day_patterns.get(lbl)) is not None
|
||||
}
|
||||
if day_patterns
|
||||
else None
|
||||
)
|
||||
|
||||
# Get rating thresholds from config (flat in options, not in sections)
|
||||
# CRITICAL: Price rating thresholds are stored FLAT in options (no sections)
|
||||
threshold_low = self.config_entry.options.get(
|
||||
_const.CONF_PRICE_RATING_THRESHOLD_LOW,
|
||||
threshold_low = self._normalize_float_option(
|
||||
self.config_entry.options.get(
|
||||
_const.CONF_PRICE_RATING_THRESHOLD_LOW,
|
||||
_const.DEFAULT_PRICE_RATING_THRESHOLD_LOW,
|
||||
),
|
||||
_const.DEFAULT_PRICE_RATING_THRESHOLD_LOW,
|
||||
option_name=_const.CONF_PRICE_RATING_THRESHOLD_LOW,
|
||||
)
|
||||
threshold_high = self.config_entry.options.get(
|
||||
_const.CONF_PRICE_RATING_THRESHOLD_HIGH,
|
||||
threshold_high = self._normalize_float_option(
|
||||
self.config_entry.options.get(
|
||||
_const.CONF_PRICE_RATING_THRESHOLD_HIGH,
|
||||
_const.DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
|
||||
),
|
||||
_const.DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
|
||||
option_name=_const.CONF_PRICE_RATING_THRESHOLD_HIGH,
|
||||
)
|
||||
|
||||
# Get volatility thresholds from config (flat in options, not in sections)
|
||||
# CRITICAL: Volatility thresholds are stored FLAT in options (no sections)
|
||||
threshold_volatility_moderate = self.config_entry.options.get(
|
||||
_const.CONF_VOLATILITY_THRESHOLD_MODERATE,
|
||||
threshold_volatility_moderate = self._normalize_float_option(
|
||||
self.config_entry.options.get(
|
||||
_const.CONF_VOLATILITY_THRESHOLD_MODERATE,
|
||||
_const.DEFAULT_VOLATILITY_THRESHOLD_MODERATE,
|
||||
),
|
||||
_const.DEFAULT_VOLATILITY_THRESHOLD_MODERATE,
|
||||
option_name=_const.CONF_VOLATILITY_THRESHOLD_MODERATE,
|
||||
)
|
||||
threshold_volatility_high = self.config_entry.options.get(
|
||||
_const.CONF_VOLATILITY_THRESHOLD_HIGH,
|
||||
threshold_volatility_high = self._normalize_float_option(
|
||||
self.config_entry.options.get(
|
||||
_const.CONF_VOLATILITY_THRESHOLD_HIGH,
|
||||
_const.DEFAULT_VOLATILITY_THRESHOLD_HIGH,
|
||||
),
|
||||
_const.DEFAULT_VOLATILITY_THRESHOLD_HIGH,
|
||||
option_name=_const.CONF_VOLATILITY_THRESHOLD_HIGH,
|
||||
)
|
||||
threshold_volatility_very_high = self.config_entry.options.get(
|
||||
_const.CONF_VOLATILITY_THRESHOLD_VERY_HIGH,
|
||||
threshold_volatility_very_high = self._normalize_float_option(
|
||||
self.config_entry.options.get(
|
||||
_const.CONF_VOLATILITY_THRESHOLD_VERY_HIGH,
|
||||
_const.DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH,
|
||||
),
|
||||
_const.DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH,
|
||||
option_name=_const.CONF_VOLATILITY_THRESHOLD_VERY_HIGH,
|
||||
)
|
||||
|
||||
# Get relaxation configuration for best price
|
||||
|
|
@ -658,21 +907,32 @@ class TibberPricesPeriodCalculator:
|
|||
)
|
||||
|
||||
# Check if best price periods should be shown
|
||||
# If relaxation is enabled, always calculate (relaxation will try "any" filter)
|
||||
# If relaxation is disabled, apply level filter check
|
||||
# If relaxation is enabled, always calculate (relaxation tries configured level filter
|
||||
# first, then falls back to "any" per flex step if still insufficient)
|
||||
# If relaxation is disabled, apply level filter check upfront
|
||||
if enable_relaxation_best:
|
||||
show_best_price = bool(all_prices)
|
||||
else:
|
||||
show_best_price = self.should_show_periods(price_info, reverse_sort=False) if all_prices else False
|
||||
min_periods_best = self._get_option(
|
||||
_const.CONF_MIN_PERIODS_BEST,
|
||||
"relaxation_and_target_periods",
|
||||
min_periods_best = self._normalize_int_option(
|
||||
self._get_option(
|
||||
_const.CONF_MIN_PERIODS_BEST,
|
||||
"relaxation_and_target_periods",
|
||||
_const.DEFAULT_MIN_PERIODS_BEST,
|
||||
),
|
||||
_const.DEFAULT_MIN_PERIODS_BEST,
|
||||
option_name=_const.CONF_MIN_PERIODS_BEST,
|
||||
minimum=1,
|
||||
)
|
||||
relaxation_attempts_best = self._get_option(
|
||||
_const.CONF_RELAXATION_ATTEMPTS_BEST,
|
||||
"relaxation_and_target_periods",
|
||||
relaxation_attempts_best = self._normalize_int_option(
|
||||
self._get_option(
|
||||
_const.CONF_RELAXATION_ATTEMPTS_BEST,
|
||||
"relaxation_and_target_periods",
|
||||
_const.DEFAULT_RELAXATION_ATTEMPTS_BEST,
|
||||
),
|
||||
_const.DEFAULT_RELAXATION_ATTEMPTS_BEST,
|
||||
option_name=_const.CONF_RELAXATION_ATTEMPTS_BEST,
|
||||
minimum=1,
|
||||
)
|
||||
|
||||
# Calculate best price periods (or return empty if filtered)
|
||||
|
|
@ -685,10 +945,15 @@ class TibberPricesPeriodCalculator:
|
|||
"period_settings",
|
||||
_const.DEFAULT_BEST_PRICE_MAX_LEVEL,
|
||||
)
|
||||
gap_count_best = self._get_option(
|
||||
_const.CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
"period_settings",
|
||||
gap_count_best = self._normalize_int_option(
|
||||
self._get_option(
|
||||
_const.CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
"period_settings",
|
||||
_const.DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
),
|
||||
_const.DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
option_name=_const.CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
minimum=0,
|
||||
)
|
||||
best_period_config = TibberPricesPeriodConfig(
|
||||
reverse_sort=False,
|
||||
|
|
@ -702,6 +967,11 @@ class TibberPricesPeriodCalculator:
|
|||
threshold_volatility_very_high=threshold_volatility_very_high,
|
||||
level_filter=max_level_best,
|
||||
gap_count=gap_count_best,
|
||||
extend_to_extreme=best_config["extend_to_extreme"],
|
||||
max_extension_intervals=best_config["max_extension_intervals"],
|
||||
geometric_extra_flex=best_config["geometric_extra_flex"],
|
||||
segment_forcing=best_config["segment_forcing"],
|
||||
segment_min_periods=best_config["segment_min_periods"],
|
||||
)
|
||||
best_periods = calculate_periods_with_relaxation(
|
||||
all_prices,
|
||||
|
|
@ -716,6 +986,7 @@ class TibberPricesPeriodCalculator:
|
|||
),
|
||||
time=self.time,
|
||||
config_entry=self.config_entry,
|
||||
day_patterns_by_date=day_patterns_by_date,
|
||||
)
|
||||
else:
|
||||
best_periods = {
|
||||
|
|
@ -739,21 +1010,32 @@ class TibberPricesPeriodCalculator:
|
|||
)
|
||||
|
||||
# Check if peak price periods should be shown
|
||||
# If relaxation is enabled, always calculate (relaxation will try "any" filter)
|
||||
# If relaxation is disabled, apply level filter check
|
||||
# If relaxation is enabled, always calculate (relaxation tries configured level filter
|
||||
# first, then falls back to "any" per flex step if still insufficient)
|
||||
# If relaxation is disabled, apply level filter check upfront
|
||||
if enable_relaxation_peak:
|
||||
show_peak_price = bool(all_prices)
|
||||
else:
|
||||
show_peak_price = self.should_show_periods(price_info, reverse_sort=True) if all_prices else False
|
||||
min_periods_peak = self._get_option(
|
||||
_const.CONF_MIN_PERIODS_PEAK,
|
||||
"relaxation_and_target_periods",
|
||||
min_periods_peak = self._normalize_int_option(
|
||||
self._get_option(
|
||||
_const.CONF_MIN_PERIODS_PEAK,
|
||||
"relaxation_and_target_periods",
|
||||
_const.DEFAULT_MIN_PERIODS_PEAK,
|
||||
),
|
||||
_const.DEFAULT_MIN_PERIODS_PEAK,
|
||||
option_name=_const.CONF_MIN_PERIODS_PEAK,
|
||||
minimum=1,
|
||||
)
|
||||
relaxation_attempts_peak = self._get_option(
|
||||
_const.CONF_RELAXATION_ATTEMPTS_PEAK,
|
||||
"relaxation_and_target_periods",
|
||||
relaxation_attempts_peak = self._normalize_int_option(
|
||||
self._get_option(
|
||||
_const.CONF_RELAXATION_ATTEMPTS_PEAK,
|
||||
"relaxation_and_target_periods",
|
||||
_const.DEFAULT_RELAXATION_ATTEMPTS_PEAK,
|
||||
),
|
||||
_const.DEFAULT_RELAXATION_ATTEMPTS_PEAK,
|
||||
option_name=_const.CONF_RELAXATION_ATTEMPTS_PEAK,
|
||||
minimum=1,
|
||||
)
|
||||
|
||||
# Calculate peak price periods (or return empty if filtered)
|
||||
|
|
@ -766,10 +1048,15 @@ class TibberPricesPeriodCalculator:
|
|||
"period_settings",
|
||||
_const.DEFAULT_PEAK_PRICE_MIN_LEVEL,
|
||||
)
|
||||
gap_count_peak = self._get_option(
|
||||
_const.CONF_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
"period_settings",
|
||||
gap_count_peak = self._normalize_int_option(
|
||||
self._get_option(
|
||||
_const.CONF_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
"period_settings",
|
||||
_const.DEFAULT_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
),
|
||||
_const.DEFAULT_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
option_name=_const.CONF_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
minimum=0,
|
||||
)
|
||||
peak_period_config = TibberPricesPeriodConfig(
|
||||
reverse_sort=True,
|
||||
|
|
@ -783,6 +1070,11 @@ class TibberPricesPeriodCalculator:
|
|||
threshold_volatility_very_high=threshold_volatility_very_high,
|
||||
level_filter=min_level_peak,
|
||||
gap_count=gap_count_peak,
|
||||
extend_to_extreme=peak_config["extend_to_extreme"],
|
||||
max_extension_intervals=peak_config["max_extension_intervals"],
|
||||
geometric_extra_flex=peak_config["geometric_extra_flex"],
|
||||
segment_forcing=peak_config["segment_forcing"],
|
||||
segment_min_periods=peak_config["segment_min_periods"],
|
||||
)
|
||||
peak_periods = calculate_periods_with_relaxation(
|
||||
all_prices,
|
||||
|
|
@ -797,6 +1089,7 @@ class TibberPricesPeriodCalculator:
|
|||
),
|
||||
time=self.time,
|
||||
config_entry=self.config_entry,
|
||||
day_patterns_by_date=day_patterns_by_date,
|
||||
)
|
||||
else:
|
||||
peak_periods = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -1,33 +1,93 @@
|
|||
{
|
||||
"services": {
|
||||
"get_price": {
|
||||
"service": "mdi:table-search"
|
||||
},
|
||||
"get_chartdata": {
|
||||
"service": "mdi:chart-bar",
|
||||
"sections": {
|
||||
"general": "mdi:identifier",
|
||||
"selection": "mdi:calendar-range",
|
||||
"filters": "mdi:filter-variant",
|
||||
"transformation": "mdi:tune",
|
||||
"format": "mdi:file-table",
|
||||
"arrays_of_objects": "mdi:code-json",
|
||||
"arrays_of_arrays": "mdi:code-brackets"
|
||||
}
|
||||
},
|
||||
"get_apexcharts_yaml": {
|
||||
"service": "mdi:chart-line",
|
||||
"sections": {
|
||||
"entry_id": "mdi:identifier",
|
||||
"day": "mdi:calendar-range",
|
||||
"level_type": "mdi:format-list-bulleted-type",
|
||||
"resolution": "mdi:timer-sand",
|
||||
"highlight_best_price": "mdi:battery-charging-low",
|
||||
"highlight_peak_price": "mdi:battery-alert"
|
||||
}
|
||||
},
|
||||
"refresh_user_data": {
|
||||
"service": "mdi:refresh"
|
||||
}
|
||||
"services": {
|
||||
"get_price": {
|
||||
"service": "mdi:table-search"
|
||||
},
|
||||
"get_chartdata": {
|
||||
"service": "mdi:chart-bar",
|
||||
"sections": {
|
||||
"general": "mdi:identifier",
|
||||
"selection": "mdi:calendar-range",
|
||||
"filters": "mdi:filter-variant",
|
||||
"transformation": "mdi:tune",
|
||||
"format": "mdi:file-table",
|
||||
"arrays_of_objects": "mdi:code-json",
|
||||
"arrays_of_arrays": "mdi:code-brackets"
|
||||
}
|
||||
},
|
||||
"get_apexcharts_yaml": {
|
||||
"service": "mdi:chart-line"
|
||||
},
|
||||
"refresh_user_data": {
|
||||
"service": "mdi:refresh"
|
||||
},
|
||||
"find_cheapest_block": {
|
||||
"service": "mdi:washing-machine",
|
||||
"sections": {
|
||||
"search_range": "mdi:calendar-search",
|
||||
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||
"price_filter": "mdi:filter-variant",
|
||||
"search_tuning": "mdi:cog-outline",
|
||||
"cost_estimation": "mdi:lightning-bolt",
|
||||
"output": "mdi:tune-variant"
|
||||
}
|
||||
},
|
||||
"find_most_expensive_block": {
|
||||
"service": "mdi:lightning-bolt-circle",
|
||||
"sections": {
|
||||
"search_range": "mdi:calendar-search",
|
||||
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||
"price_filter": "mdi:filter-variant",
|
||||
"search_tuning": "mdi:cog-outline",
|
||||
"cost_estimation": "mdi:lightning-bolt",
|
||||
"output": "mdi:tune-variant"
|
||||
}
|
||||
},
|
||||
"find_cheapest_hours": {
|
||||
"service": "mdi:ev-station",
|
||||
"sections": {
|
||||
"search_range": "mdi:calendar-search",
|
||||
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||
"price_filter": "mdi:filter-variant",
|
||||
"search_tuning": "mdi:cog-outline",
|
||||
"cost_estimation": "mdi:lightning-bolt",
|
||||
"output": "mdi:tune-variant"
|
||||
}
|
||||
},
|
||||
"find_most_expensive_hours": {
|
||||
"service": "mdi:flash-alert",
|
||||
"sections": {
|
||||
"search_range": "mdi:calendar-search",
|
||||
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||
"price_filter": "mdi:filter-variant",
|
||||
"search_tuning": "mdi:cog-outline",
|
||||
"cost_estimation": "mdi:lightning-bolt",
|
||||
"output": "mdi:tune-variant"
|
||||
}
|
||||
},
|
||||
"find_cheapest_schedule": {
|
||||
"service": "mdi:calendar-check",
|
||||
"sections": {
|
||||
"search_range": "mdi:calendar-search",
|
||||
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||
"price_filter": "mdi:filter-variant",
|
||||
"search_tuning": "mdi:cog-outline",
|
||||
"output": "mdi:tune-variant"
|
||||
}
|
||||
},
|
||||
"plan_charging": {
|
||||
"service": "mdi:battery-charging",
|
||||
"sections": {
|
||||
"battery": "mdi:battery",
|
||||
"charging": "mdi:ev-station",
|
||||
"search_range": "mdi:calendar-search",
|
||||
"deadline": "mdi:calendar-clock",
|
||||
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||
"price_filter": "mdi:filter-variant",
|
||||
"search_tuning": "mdi:cog-outline",
|
||||
"economics": "mdi:cash-multiple",
|
||||
"output": "mdi:tune-variant"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -93,6 +93,19 @@ class TibberPricesIntervalPoolGarbageCollector:
|
|||
empty_removed,
|
||||
self._home_id,
|
||||
)
|
||||
elif dead_count > 0:
|
||||
# _cleanup_dead_intervals compacted group["intervals"] lists in-place,
|
||||
# shifting the positions of surviving intervals. _remove_empty_groups
|
||||
# only rebuilds the index when it removes completely-empty groups.
|
||||
# If no groups became empty, the index still holds stale interval_index
|
||||
# values that now point past the end of the compacted lists, causing
|
||||
# an IndexError in _get_cached_intervals. Rebuild the index here to
|
||||
# keep it consistent with the compacted groups.
|
||||
self._index.rebuild(fetch_groups)
|
||||
_LOGGER_DETAILS.debug(
|
||||
"GC rebuilt index after dead interval cleanup for home %s",
|
||||
self._home_id,
|
||||
)
|
||||
|
||||
# Phase 2: Count total intervals after cleanup
|
||||
total_intervals = self._cache.count_total_intervals()
|
||||
|
|
|
|||
|
|
@ -4,13 +4,16 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from custom_components.tibber_prices.api.exceptions import TibberPricesApiClientError
|
||||
from homeassistant.util import dt as dt_utils
|
||||
from custom_components.tibber_prices.api.exceptions import (
|
||||
TibberPricesApiClientCommunicationError,
|
||||
TibberPricesApiClientError,
|
||||
)
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .cache import TibberPricesIntervalPoolFetchGroupCache
|
||||
from .fetcher import TibberPricesIntervalPoolFetcher
|
||||
|
|
@ -20,9 +23,7 @@ from .storage import async_save_pool_state
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from custom_components.tibber_prices.api.client import TibberPricesApiClient
|
||||
from custom_components.tibber_prices.coordinator.time_service import (
|
||||
TibberPricesTimeService,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_LOGGER_DETAILS = logging.getLogger(__name__ + ".details")
|
||||
|
|
@ -98,7 +99,7 @@ class TibberPricesIntervalPool:
|
|||
hass: HomeAssistant instance for auto-save (optional).
|
||||
entry_id: Config entry ID for auto-save (optional).
|
||||
time_service: TimeService for time-travel support (optional).
|
||||
If None, uses real time (dt_utils.now()).
|
||||
If None, uses real time (dt_util.now()).
|
||||
|
||||
"""
|
||||
self._home_id = home_id
|
||||
|
|
@ -201,23 +202,41 @@ class TibberPricesIntervalPool:
|
|||
)
|
||||
|
||||
# Fetch missing ranges from API
|
||||
api_fetch_failed = False
|
||||
if missing_ranges:
|
||||
fetch_time_iso = dt_utils.now().isoformat()
|
||||
fetch_time_iso = dt_util.now().isoformat()
|
||||
|
||||
# Fetch with callback for immediate caching
|
||||
await self._fetcher.fetch_missing_ranges(
|
||||
api_client=api_client,
|
||||
user_data=user_data,
|
||||
missing_ranges=missing_ranges,
|
||||
on_intervals_fetched=lambda intervals, _: self._add_intervals(intervals, fetch_time_iso),
|
||||
)
|
||||
try:
|
||||
# Fetch with callback for immediate caching
|
||||
await self._fetcher.fetch_missing_ranges(
|
||||
api_client=api_client,
|
||||
user_data=user_data,
|
||||
missing_ranges=missing_ranges,
|
||||
on_intervals_fetched=lambda intervals, _: self._add_intervals(intervals, fetch_time_iso),
|
||||
)
|
||||
except TibberPricesApiClientCommunicationError as err:
|
||||
if cached_intervals:
|
||||
# Transient API error (e.g. 503) but we have cached data - use it as
|
||||
# fallback so the coordinator can finish initializing. The next regular
|
||||
# update cycle will retry the API automatically.
|
||||
_LOGGER.warning(
|
||||
"API temporarily unavailable for home %s (%s) - using %d cached intervals as fallback",
|
||||
self._home_id,
|
||||
err,
|
||||
len(cached_intervals),
|
||||
)
|
||||
api_fetch_failed = True
|
||||
else:
|
||||
# No cached data at all - re-raise so the caller can decide
|
||||
raise
|
||||
|
||||
# After caching all API responses, read from cache again to get final result
|
||||
# This ensures we return exactly what user requested, filtering out extra intervals
|
||||
final_result = self._get_cached_intervals(start_time_iso, end_time_iso)
|
||||
|
||||
# Track if API was called (True if any missing ranges were fetched)
|
||||
api_called = len(missing_ranges) > 0
|
||||
# Track if API was called (True if any missing ranges were attempted)
|
||||
# If fetch failed but we fell back to cache, treat as "no API call succeeded"
|
||||
api_called = len(missing_ranges) > 0 and not api_fetch_failed
|
||||
|
||||
_LOGGER_DETAILS.debug(
|
||||
"Pool returning %d intervals for home %s (from cache: %d, fetched from API: %d ranges, api_called=%s)",
|
||||
|
|
@ -280,7 +299,7 @@ class TibberPricesIntervalPool:
|
|||
|
||||
# Calculate range in home's timezone
|
||||
tz = ZoneInfo(tz_str) if tz_str else None
|
||||
now = self._time_service.now() if self._time_service else dt_utils.now()
|
||||
now = self._time_service.now() if self._time_service else dt_util.now()
|
||||
now_local = now.astimezone(tz) if tz else now
|
||||
|
||||
# Day before yesterday 00:00 (start) - same for both fetch and return
|
||||
|
|
@ -577,7 +596,7 @@ class TibberPricesIntervalPool:
|
|||
result = []
|
||||
|
||||
# Determine interval step (15 min post-2025-10-01, 60 min pre)
|
||||
resolution_change_naive = datetime(2025, 10, 1) # noqa: DTZ001
|
||||
resolution_change_naive = datetime(2025, 10, 1)
|
||||
interval_minutes = INTERVAL_QUARTER_HOURLY if current_naive >= resolution_change_naive else INTERVAL_HOURLY
|
||||
|
||||
fetch_groups = self._cache.get_fetch_groups()
|
||||
|
|
@ -706,13 +725,20 @@ class TibberPricesIntervalPool:
|
|||
if intervals_to_touch:
|
||||
self._touch_intervals(intervals_to_touch, fetch_time_dt)
|
||||
|
||||
if not new_intervals:
|
||||
if intervals_to_touch:
|
||||
_LOGGER_DETAILS.debug(
|
||||
"All %d intervals already cached for home %s (touched only)",
|
||||
len(intervals),
|
||||
self._home_id,
|
||||
)
|
||||
# Run GC after touch even if no new intervals — touching creates dead
|
||||
# intervals in old fetch groups that should be cleaned up promptly.
|
||||
if intervals_to_touch and not new_intervals:
|
||||
gc_changed_data = self._gc.run_gc()
|
||||
|
||||
_LOGGER_DETAILS.debug(
|
||||
"All %d intervals already cached for home %s (touched only, GC ran: %s)",
|
||||
len(intervals),
|
||||
self._home_id,
|
||||
gc_changed_data,
|
||||
)
|
||||
|
||||
if (intervals_to_touch or gc_changed_data) and self._hass is not None and self._entry_id is not None:
|
||||
self._schedule_debounced_save()
|
||||
return
|
||||
|
||||
# Sort new intervals by startsAt
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ This module handles intelligent routing between different Tibber API endpoints:
|
|||
- PRICE_INFO_RANGE: Historical data (before "day before yesterday midnight")
|
||||
- Automatic splitting and merging when range spans the boundary
|
||||
|
||||
CRITICAL: Uses REAL TIME (dt_utils.now()) for API boundary calculation,
|
||||
CRITICAL: Uses REAL TIME (dt_util.now()) for API boundary calculation,
|
||||
NOT TimeService.now() which may be shifted for internal simulation.
|
||||
"""
|
||||
|
||||
|
|
@ -17,7 +17,7 @@ import logging
|
|||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from custom_components.tibber_prices.api.exceptions import TibberPricesApiClientError
|
||||
from homeassistant.util import dt as dt_utils
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
|
|
@ -43,7 +43,7 @@ async def get_price_intervals_for_range(
|
|||
- PRICE_INFO: For intervals from "day before yesterday midnight" onwards
|
||||
- Both: If range spans across the boundary, splits the request
|
||||
|
||||
CRITICAL: Uses REAL TIME (dt_utils.now()) for API boundary calculation,
|
||||
CRITICAL: Uses REAL TIME (dt_util.now()) for API boundary calculation,
|
||||
NOT TimeService.now() which may be shifted for internal simulation.
|
||||
This ensures predictable API responses.
|
||||
|
||||
|
|
@ -173,7 +173,7 @@ def _parse_timestamp(timestamp_str: str) -> datetime:
|
|||
ValueError: If timestamp string cannot be parsed.
|
||||
|
||||
"""
|
||||
result = dt_utils.parse_datetime(timestamp_str)
|
||||
result = dt_util.parse_datetime(timestamp_str)
|
||||
if result is None:
|
||||
msg = f"Failed to parse timestamp: {timestamp_str}"
|
||||
raise ValueError(msg)
|
||||
|
|
|
|||
|
|
@ -11,5 +11,5 @@
|
|||
"requirements": [
|
||||
"aiofiles>=23.2.1"
|
||||
],
|
||||
"version": "0.30.0"
|
||||
"version": "0.31.0b4"
|
||||
}
|
||||
|
|
|
|||
146
custom_components/tibber_prices/migrations.py
Normal file
146
custom_components/tibber_prices/migrations.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
"""
|
||||
Entity migration checks for Tibber Prices integration.
|
||||
|
||||
Detects obsolete entity keys in the entity registry after upgrades and
|
||||
performs automatic migration where possible. Creates repair issues to
|
||||
notify users about breaking changes that require manual action.
|
||||
|
||||
Separation of concerns:
|
||||
- This module: One-time upgrade migrations (entity renames, breaking changes)
|
||||
- coordinator/repairs.py: Runtime repairs (API issues, missing data)
|
||||
- __init__.py _migrate_config_options(): Config option format changes
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import entity_registry as er, issue_registry as ir
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# ============================================================================
|
||||
# ENTITY KEY RENAMES
|
||||
# Add entries here when renaming sensors in future releases.
|
||||
# old_entity_key -> new_entity_key (auto-migrated, entity_id preserved)
|
||||
# ============================================================================
|
||||
ENTITY_KEY_RENAMES: dict[str, str] = {
|
||||
"trend_change_in_minutes": "next_price_trend_change_in",
|
||||
}
|
||||
|
||||
|
||||
@callback
|
||||
def check_entity_migrations(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
) -> None:
|
||||
"""
|
||||
Check for entity migrations and create repairs if needed.
|
||||
|
||||
Called during async_setup_entry, before platform forwarding.
|
||||
Performs auto-migration of renamed entities and creates
|
||||
informational repairs about breaking changes.
|
||||
"""
|
||||
ent_reg = er.async_get(hass)
|
||||
|
||||
# Auto-migrate renamed entity keys
|
||||
migrated = _auto_migrate_entity_keys(ent_reg, entry)
|
||||
|
||||
# Create persistent repair about breaking changes
|
||||
issue_id = f"entity_migration_{entry.entry_id}"
|
||||
|
||||
if migrated:
|
||||
rename_lines = [f"- `{old_key}` → `{new_key}`" for old_key, new_key, _ in migrated]
|
||||
entity_list = "\n".join(rename_lines)
|
||||
|
||||
_LOGGER.info(
|
||||
"Auto-migrated %d entity key(s) for '%s': %s",
|
||||
len(migrated),
|
||||
entry.title,
|
||||
", ".join(f"{old} → {new}" for old, new, _ in migrated),
|
||||
)
|
||||
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
issue_id,
|
||||
is_fixable=False,
|
||||
is_persistent=True,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="entity_migration",
|
||||
translation_placeholders={
|
||||
"home_name": entry.title,
|
||||
"entity_list": entity_list,
|
||||
"count": str(len(migrated)),
|
||||
},
|
||||
learn_more_url="https://github.com/jpawlowski/hass.tibber_prices/releases",
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def _auto_migrate_entity_keys(
|
||||
ent_reg: er.EntityRegistry,
|
||||
entry: ConfigEntry,
|
||||
) -> list[tuple[str, str, str]]:
|
||||
"""
|
||||
Auto-migrate renamed entity keys in the entity registry.
|
||||
|
||||
Updates unique_ids for renamed entities while preserving entity_id
|
||||
and all user customizations (history, dashboard references, etc.).
|
||||
|
||||
Returns:
|
||||
List of (old_key, new_key, entity_id) tuples for migrated entities
|
||||
|
||||
"""
|
||||
migrated: list[tuple[str, str, str]] = []
|
||||
prefix = f"{entry.entry_id}_"
|
||||
|
||||
# Get all entities for this config entry
|
||||
entry_entities = er.async_entries_for_config_entry(ent_reg, entry.entry_id)
|
||||
|
||||
for entity_entry in entry_entities:
|
||||
if not entity_entry.unique_id.startswith(prefix):
|
||||
continue
|
||||
|
||||
entity_key = entity_entry.unique_id[len(prefix) :]
|
||||
if entity_key not in ENTITY_KEY_RENAMES:
|
||||
continue
|
||||
|
||||
new_key = ENTITY_KEY_RENAMES[entity_key]
|
||||
new_unique_id = f"{prefix}{new_key}"
|
||||
|
||||
# Check if new entity already exists (e.g., from a partial migration)
|
||||
new_entity_id = ent_reg.async_get_entity_id(entity_entry.domain, DOMAIN, new_unique_id)
|
||||
|
||||
if new_entity_id:
|
||||
# New entity already exists — remove the obsolete old one
|
||||
_LOGGER.debug(
|
||||
"Removing obsolete entity '%s' (new entity '%s' already exists)",
|
||||
entity_entry.entity_id,
|
||||
new_entity_id,
|
||||
)
|
||||
ent_reg.async_remove(entity_entry.entity_id)
|
||||
else:
|
||||
# Migrate: update unique_id (preserves entity_id and history)
|
||||
_LOGGER.debug(
|
||||
"Migrating entity '%s': unique_id '%s' → '%s'",
|
||||
entity_entry.entity_id,
|
||||
entity_entry.unique_id,
|
||||
new_unique_id,
|
||||
)
|
||||
ent_reg.async_update_entity(
|
||||
entity_entry.entity_id,
|
||||
new_unique_id=new_unique_id,
|
||||
)
|
||||
|
||||
migrated.append((entity_key, new_key, entity_entry.entity_id))
|
||||
|
||||
return migrated
|
||||
|
|
@ -11,19 +11,13 @@ from __future__ import annotations
|
|||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from custom_components.tibber_prices.const import (
|
||||
DOMAIN,
|
||||
get_home_type_translation,
|
||||
get_translation,
|
||||
)
|
||||
from custom_components.tibber_prices.const import DOMAIN, get_home_type_translation, get_translation
|
||||
from homeassistant.components.number import NumberEntity, RestoreNumber
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from custom_components.tibber_prices.coordinator import (
|
||||
TibberPricesDataUpdateCoordinator,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator
|
||||
|
||||
from .definitions import TibberPricesNumberEntityDescription
|
||||
|
||||
|
|
|
|||
|
|
@ -13,10 +13,7 @@ from __future__ import annotations
|
|||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from homeassistant.components.number import (
|
||||
NumberEntityDescription,
|
||||
NumberMode,
|
||||
)
|
||||
from homeassistant.components.number import NumberEntityDescription, NumberMode
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -17,10 +17,7 @@ from __future__ import annotations
|
|||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from custom_components.tibber_prices.const import (
|
||||
CONF_CURRENCY_DISPLAY_MODE,
|
||||
DISPLAY_MODE_BASE,
|
||||
)
|
||||
from custom_components.tibber_prices.const import CONF_CURRENCY_DISPLAY_MODE, DISPLAY_MODE_BASE
|
||||
|
||||
from .core import TibberPricesSensor
|
||||
from .definitions import ENTITY_DESCRIPTIONS
|
||||
|
|
|
|||
|
|
@ -10,10 +10,7 @@ from __future__ import annotations
|
|||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from custom_components.tibber_prices.entity_utils import (
|
||||
add_description_attributes,
|
||||
add_icon_color_attribute,
|
||||
)
|
||||
from custom_components.tibber_prices.entity_utils import add_description_attributes, add_icon_color_attribute
|
||||
from custom_components.tibber_prices.sensor.types import (
|
||||
DailyStatPriceAttributes,
|
||||
DailyStatRatingAttributes,
|
||||
|
|
@ -32,9 +29,7 @@ from custom_components.tibber_prices.sensor.types import (
|
|||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from custom_components.tibber_prices.coordinator.core import (
|
||||
TibberPricesDataUpdateCoordinator,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator.core import TibberPricesDataUpdateCoordinator
|
||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||
from custom_components.tibber_prices.data import TibberPricesConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
|
@ -44,9 +39,10 @@ from .daily_stat import add_statistics_attributes
|
|||
from .future import add_next_avg_attributes, get_future_prices
|
||||
from .interval import add_current_interval_price_attributes
|
||||
from .lifecycle import build_lifecycle_attributes
|
||||
from .metadata import get_current_price_phase_attributes, get_day_pattern_attributes, get_next_price_phase_attributes
|
||||
from .timing import _is_timing_or_volatility_sensor
|
||||
from .trend import _add_cached_trend_attributes, _add_timing_or_volatility_attributes
|
||||
from .volatility import add_volatility_type_attributes, get_prices_for_volatility
|
||||
from .volatility import add_percentile_rank_attributes, add_volatility_type_attributes, get_prices_for_volatility
|
||||
from .window_24h import add_average_price_attributes
|
||||
|
||||
__all__ = [
|
||||
|
|
@ -64,6 +60,7 @@ __all__ = [
|
|||
"TrendAttributes",
|
||||
"VolatilityAttributes",
|
||||
"Window24hAttributes",
|
||||
"add_percentile_rank_attributes",
|
||||
"add_volatility_type_attributes",
|
||||
"build_extra_state_attributes",
|
||||
"build_sensor_attributes",
|
||||
|
|
@ -189,6 +186,25 @@ def build_sensor_attributes(
|
|||
elif _is_timing_or_volatility_sensor(key):
|
||||
_add_timing_or_volatility_attributes(attributes, key, cached_data, native_value, time=time)
|
||||
|
||||
elif "_price_rank_" in key:
|
||||
add_percentile_rank_attributes(attributes, cached_data, time=time)
|
||||
|
||||
elif key in ("day_pattern_yesterday", "day_pattern_today", "day_pattern_tomorrow"):
|
||||
day = key.removeprefix("day_pattern_")
|
||||
day_attrs = get_day_pattern_attributes(coordinator, day)
|
||||
if day_attrs:
|
||||
attributes.update(day_attrs)
|
||||
|
||||
elif key == "current_price_phase":
|
||||
phase_attrs = get_current_price_phase_attributes(coordinator, time=time)
|
||||
if phase_attrs:
|
||||
attributes.update(phase_attrs)
|
||||
|
||||
elif key == "next_price_phase":
|
||||
next_phase_attrs = get_next_price_phase_attributes(coordinator, time=time)
|
||||
if next_phase_attrs:
|
||||
attributes.update(next_phase_attrs)
|
||||
|
||||
# For current_interval_price_level, add the original level as attribute
|
||||
if key == "current_interval_price_level" and cached_data.get("last_price_level") is not None:
|
||||
attributes["level_id"] = cached_data["last_price_level"]
|
||||
|
|
@ -217,7 +233,7 @@ def build_sensor_attributes(
|
|||
return attributes or None
|
||||
|
||||
|
||||
def build_extra_state_attributes( # noqa: PLR0913
|
||||
def build_extra_state_attributes(
|
||||
entity_key: str,
|
||||
translation_key: str | None,
|
||||
hass: HomeAssistant,
|
||||
|
|
|
|||
|
|
@ -4,13 +4,8 @@ from __future__ import annotations
|
|||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from custom_components.tibber_prices.const import (
|
||||
PRICE_RATING_MAPPING,
|
||||
get_display_unit_factor,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator.helpers import (
|
||||
get_intervals_for_day_offsets,
|
||||
)
|
||||
from custom_components.tibber_prices.const import PRICE_RATING_MAPPING, get_display_precision, get_display_unit_factor
|
||||
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
|
||||
from homeassistant.const import PERCENTAGE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -30,12 +25,13 @@ def _add_energy_tax_from_interval(
|
|||
) -> None:
|
||||
"""Add energy_price and tax from a single interval dict."""
|
||||
factor = get_display_unit_factor(config_entry)
|
||||
precision = get_display_precision(config_entry)
|
||||
energy = interval_data.get("energy")
|
||||
if energy is not None:
|
||||
attributes["energy_price"] = round(float(energy) * factor, 2)
|
||||
attributes["energy_price"] = round(float(energy) * factor, precision)
|
||||
tax = interval_data.get("tax")
|
||||
if tax is not None:
|
||||
attributes["tax"] = round(float(tax) * factor, 2)
|
||||
attributes["tax"] = round(float(tax) * factor, precision)
|
||||
|
||||
|
||||
def _add_energy_tax_averages_from_cache(
|
||||
|
|
@ -49,14 +45,15 @@ def _add_energy_tax_averages_from_cache(
|
|||
"last_energy_tax_averages", (None, None, None, None)
|
||||
)
|
||||
factor = get_display_unit_factor(config_entry)
|
||||
precision = get_display_precision(config_entry)
|
||||
if energy_mean is not None:
|
||||
attributes["energy_price_mean"] = round(float(energy_mean) * factor, 2)
|
||||
attributes["energy_price_mean"] = round(float(energy_mean) * factor, precision)
|
||||
if energy_median is not None:
|
||||
attributes["energy_price_median"] = round(float(energy_median) * factor, 2)
|
||||
attributes["energy_price_median"] = round(float(energy_median) * factor, precision)
|
||||
if tax_mean is not None:
|
||||
attributes["tax_mean"] = round(float(tax_mean) * factor, 2)
|
||||
attributes["tax_mean"] = round(float(tax_mean) * factor, precision)
|
||||
if tax_median is not None:
|
||||
attributes["tax_median"] = round(float(tax_median) * factor, 2)
|
||||
attributes["tax_median"] = round(float(tax_median) * factor, precision)
|
||||
|
||||
|
||||
def _get_day_midnight_timestamp(key: str, *, time: TibberPricesTimeService) -> datetime:
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue