mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-07-27 17:26:48 +00:00
chore(docs): format Python code blocks in markdown files with ruff
Run `ruff format .` to reformat Python code blocks inside markdown files (AGENTS.md and docs/**/*.md). Ruff 0.16.0 formats embedded Python code blocks in .md files, and the CI `lint-check` step runs `ruff format . --check` which was failing on 109 files. Release-Notes: skip User-Impact: none Agent-Logs-Url: https://github.com/jpawlowski/hass.tibber_prices/sessions/05437696-813b-432a-b05e-af698a9fa903 Co-authored-by: jpawlowski <75446+jpawlowski@users.noreply.github.com>
This commit is contained in:
parent
9c8efc73cf
commit
8adefc7a7e
109 changed files with 543 additions and 613 deletions
100
AGENTS.md
100
AGENTS.md
|
|
@ -702,10 +702,10 @@ Example: daily_min=10 ct, daily_avg=20 ct, flex=50%, min_distance=5%
|
||||||
**Key Constants** (defined in `coordinator/period_handlers/core.py`):
|
**Key Constants** (defined in `coordinator/period_handlers/core.py`):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
MAX_SAFE_FLEX = 0.50 # 50% absolute maximum
|
MAX_SAFE_FLEX = 0.50 # 50% absolute maximum
|
||||||
MAX_OUTLIER_FLEX = 0.25 # 25% for stable outlier detection
|
MAX_OUTLIER_FLEX = 0.25 # 25% for stable outlier detection
|
||||||
FLEX_WARNING_THRESHOLD_RELAXATION = 0.25 # INFO warning at 25% base flex
|
FLEX_WARNING_THRESHOLD_RELAXATION = 0.25 # INFO warning at 25% base flex
|
||||||
FLEX_HIGH_THRESHOLD_RELAXATION = 0.30 # WARNING at 30% base flex
|
FLEX_HIGH_THRESHOLD_RELAXATION = 0.30 # WARNING at 30% base flex
|
||||||
```
|
```
|
||||||
|
|
||||||
**Relaxation Strategy** (`coordinator/period_handlers/relaxation.py`):
|
**Relaxation Strategy** (`coordinator/period_handlers/relaxation.py`):
|
||||||
|
|
@ -725,10 +725,10 @@ FLEX_HIGH_THRESHOLD_RELAXATION = 0.30 # WARNING at 30% base flex
|
||||||
**Default Configuration Values** (`const.py`):
|
**Default Configuration Values** (`const.py`):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
DEFAULT_BEST_PRICE_FLEX = 15 # 15% base - optimal for relaxation mode
|
DEFAULT_BEST_PRICE_FLEX = 15 # 15% base - optimal for relaxation mode
|
||||||
DEFAULT_PEAK_PRICE_FLEX = -20 # 20% base (negative for peak detection)
|
DEFAULT_PEAK_PRICE_FLEX = -20 # 20% base (negative for peak detection)
|
||||||
DEFAULT_RELAXATION_ATTEMPTS_BEST = 11 # 11 steps: 15% → 48% (3% increment per step)
|
DEFAULT_RELAXATION_ATTEMPTS_BEST = 11 # 11 steps: 15% → 48% (3% increment per step)
|
||||||
DEFAULT_RELAXATION_ATTEMPTS_PEAK = 11 # 11 steps: 20% → 50% (3% increment per step)
|
DEFAULT_RELAXATION_ATTEMPTS_PEAK = 11 # 11 steps: 20% → 50% (3% increment per step)
|
||||||
```
|
```
|
||||||
|
|
||||||
The relaxation increment is **hard-coded at 3% per step** in `relaxation.py` for reliability and predictability. This prevents configuration issues with high base flex values while still allowing sufficient escalation to the 50% hard maximum.
|
The relaxation increment is **hard-coded at 3% per step** in `relaxation.py` for reliability and predictability. This prevents configuration issues with high base flex values while still allowing sufficient escalation to the 50% hard maximum.
|
||||||
|
|
@ -1297,12 +1297,14 @@ If you see errors from one tool, understand which tool should fix them:
|
||||||
def process_data(time: TimeService | None) -> None:
|
def process_data(time: TimeService | None) -> None:
|
||||||
result = time.now() # Error: 'None' has no attribute 'now'
|
result = time.now() # Error: 'None' has no attribute 'now'
|
||||||
|
|
||||||
|
|
||||||
# ✅ GOOD - Guard against None
|
# ✅ GOOD - Guard against None
|
||||||
def process_data(time: TimeService | None) -> None:
|
def process_data(time: TimeService | None) -> None:
|
||||||
if time is None:
|
if time is None:
|
||||||
return
|
return
|
||||||
result = time.now() # OK - type narrowed to TimeService
|
result = time.now() # OK - type narrowed to TimeService
|
||||||
|
|
||||||
|
|
||||||
# ✅ BETTER - Make it required if always provided
|
# ✅ BETTER - Make it required if always provided
|
||||||
def process_data(time: TimeService) -> None:
|
def process_data(time: TimeService) -> None:
|
||||||
result = time.now() # OK - never None
|
result = time.now() # OK - never None
|
||||||
|
|
@ -1319,6 +1321,7 @@ if TYPE_CHECKING:
|
||||||
# Custom callback type that accepts TimeService
|
# Custom callback type that accepts TimeService
|
||||||
TimeServiceCallback = Callable[[TimeService], None]
|
TimeServiceCallback = Callable[[TimeService], None]
|
||||||
|
|
||||||
|
|
||||||
# Use in class definition
|
# Use in class definition
|
||||||
class ListenerManager:
|
class ListenerManager:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
|
|
@ -1360,10 +1363,12 @@ if (interval_time := time.get_interval_time(price)) is not None:
|
||||||
def get_timestamp() -> str:
|
def get_timestamp() -> str:
|
||||||
return datetime.now() # Error: incompatible return type
|
return datetime.now() # Error: incompatible return type
|
||||||
|
|
||||||
|
|
||||||
# ✅ GOOD - Match return type to actual return value
|
# ✅ GOOD - Match return type to actual return value
|
||||||
def get_timestamp() -> datetime:
|
def get_timestamp() -> datetime:
|
||||||
return datetime.now() # OK
|
return datetime.now() # OK
|
||||||
|
|
||||||
|
|
||||||
# ✅ ALSO GOOD - Convert if signature requires string
|
# ✅ ALSO GOOD - Convert if signature requires string
|
||||||
def get_timestamp() -> str:
|
def get_timestamp() -> str:
|
||||||
return datetime.now().isoformat() # OK - returns str
|
return datetime.now().isoformat() # OK - returns str
|
||||||
|
|
@ -1623,8 +1628,10 @@ If you create a helper class that is ONLY used within a single module file:
|
||||||
# ✅ CORRECT - Private class with underscore prefix
|
# ✅ CORRECT - Private class with underscore prefix
|
||||||
class _InternalHelper:
|
class _InternalHelper:
|
||||||
"""Helper class used only within this module."""
|
"""Helper class used only within this module."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# Usage: Only in the same file, never imported elsewhere
|
# Usage: Only in the same file, never imported elsewhere
|
||||||
result = _InternalHelper().process()
|
result = _InternalHelper().process()
|
||||||
```
|
```
|
||||||
|
|
@ -1641,6 +1648,7 @@ result = _InternalHelper().process()
|
||||||
# In coordinator/price_data_manager.py
|
# In coordinator/price_data_manager.py
|
||||||
class _ApiRetryStateMachine:
|
class _ApiRetryStateMachine:
|
||||||
"""Internal state machine for retry logic. Never used outside this file."""
|
"""Internal state machine for retry logic. Never used outside this file."""
|
||||||
|
|
||||||
def __init__(self, max_retries: int) -> None:
|
def __init__(self, max_retries: int) -> None:
|
||||||
self._attempts = 0
|
self._attempts = 0
|
||||||
self._max_retries = max_retries
|
self._max_retries = max_retries
|
||||||
|
|
@ -1733,12 +1741,12 @@ Use consistent indentation to show logical structure (DEBUG level only):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Define indentation constants at module level
|
# Define indentation constants at module level
|
||||||
INDENT_L0 = "" # Top-level (0 spaces)
|
INDENT_L0 = "" # Top-level (0 spaces)
|
||||||
INDENT_L1 = " " # Level 1 (2 spaces)
|
INDENT_L1 = " " # Level 1 (2 spaces)
|
||||||
INDENT_L2 = " " # Level 2 (4 spaces)
|
INDENT_L2 = " " # Level 2 (4 spaces)
|
||||||
INDENT_L3 = " " # Level 3 (6 spaces)
|
INDENT_L3 = " " # Level 3 (6 spaces)
|
||||||
INDENT_L4 = " " # Level 4 (8 spaces)
|
INDENT_L4 = " " # Level 4 (8 spaces)
|
||||||
INDENT_L5 = " "# Level 5 (10 spaces)
|
INDENT_L5 = " " # Level 5 (10 spaces)
|
||||||
|
|
||||||
# Usage example showing logic hierarchy
|
# Usage example showing logic hierarchy
|
||||||
_LOGGER.debug("%sCalculating periods for day %s", INDENT_L0, day_date)
|
_LOGGER.debug("%sCalculating periods for day %s", INDENT_L0, day_date)
|
||||||
|
|
@ -1769,9 +1777,12 @@ _LOGGER.debug(
|
||||||
"%s Base flex: %.1f%%\n"
|
"%s Base flex: %.1f%%\n"
|
||||||
"%s Strategy: 4 flex levels × 4 filter combinations",
|
"%s Strategy: 4 flex levels × 4 filter combinations",
|
||||||
INDENT_L0,
|
INDENT_L0,
|
||||||
INDENT_L0, "ENABLED" if enable_relaxation else "DISABLED",
|
INDENT_L0,
|
||||||
INDENT_L0, min_periods,
|
"ENABLED" if enable_relaxation else "DISABLED",
|
||||||
INDENT_L0, base_flex,
|
INDENT_L0,
|
||||||
|
min_periods,
|
||||||
|
INDENT_L0,
|
||||||
|
base_flex,
|
||||||
INDENT_L0,
|
INDENT_L0,
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
@ -1955,7 +1966,7 @@ message = "Hello world"
|
||||||
html = '<div class="container">content</div>'
|
html = '<div class="container">content</div>'
|
||||||
|
|
||||||
# ❌ Inconsistent quote usage
|
# ❌ Inconsistent quote usage
|
||||||
name = 'tibber_prices' # Ruff will change to double quotes
|
name = "tibber_prices" # Ruff will change to double quotes
|
||||||
```
|
```
|
||||||
|
|
||||||
**Trailing Commas:**
|
**Trailing Commas:**
|
||||||
|
|
@ -1968,6 +1979,7 @@ SENSOR_TYPES = [
|
||||||
"max_price", # ← Trailing comma
|
"max_price", # ← Trailing comma
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# ✅ Also for function arguments
|
# ✅ Also for function arguments
|
||||||
def calculate_average(
|
def calculate_average(
|
||||||
prices: list[dict],
|
prices: list[dict],
|
||||||
|
|
@ -1976,11 +1988,12 @@ def calculate_average(
|
||||||
) -> float:
|
) -> float:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ❌ Missing trailing comma
|
# ❌ Missing trailing comma
|
||||||
SENSOR_TYPES = [
|
SENSOR_TYPES = [
|
||||||
"current_interval_price",
|
"current_interval_price",
|
||||||
"min_price",
|
"min_price",
|
||||||
"max_price" # Ruff will add trailing comma
|
"max_price", # Ruff will add trailing comma
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -1992,6 +2005,7 @@ def get_price() -> float:
|
||||||
"""Return current electricity price."""
|
"""Return current electricity price."""
|
||||||
return 0.25
|
return 0.25
|
||||||
|
|
||||||
|
|
||||||
# ✅ Multi-line docstrings: summary line, blank, details
|
# ✅ Multi-line docstrings: summary line, blank, details
|
||||||
def calculate_average(prices: list[dict]) -> float:
|
def calculate_average(prices: list[dict]) -> float:
|
||||||
"""Calculate average price from interval list.
|
"""Calculate average price from interval list.
|
||||||
|
|
@ -2004,9 +2018,10 @@ def calculate_average(prices: list[dict]) -> float:
|
||||||
"""
|
"""
|
||||||
return sum(p["total"] for p in prices) / len(prices)
|
return sum(p["total"] for p in prices) / len(prices)
|
||||||
|
|
||||||
|
|
||||||
# ❌ Single quotes or missing docstrings on public functions
|
# ❌ Single quotes or missing docstrings on public functions
|
||||||
def get_price() -> float:
|
def get_price() -> float:
|
||||||
'''Return price''' # Ruff will change to double quotes
|
"""Return price""" # Ruff will change to double quotes
|
||||||
```
|
```
|
||||||
|
|
||||||
**Line Breaking:**
|
**Line Breaking:**
|
||||||
|
|
@ -2020,20 +2035,11 @@ result = some_function(
|
||||||
)
|
)
|
||||||
|
|
||||||
# ✅ Break long conditions
|
# ✅ Break long conditions
|
||||||
if (
|
if price > threshold and time_of_day == "peak" and day_of_week in ["Monday", "Friday"]:
|
||||||
price > threshold
|
|
||||||
and time_of_day == "peak"
|
|
||||||
and day_of_week in ["Monday", "Friday"]
|
|
||||||
):
|
|
||||||
do_something()
|
do_something()
|
||||||
|
|
||||||
# ✅ Chain methods with line breaks
|
# ✅ Chain methods with line breaks
|
||||||
df = (
|
df = data_frame.filter(lambda x: x > 0).sort_values().reset_index()
|
||||||
data_frame
|
|
||||||
.filter(lambda x: x > 0)
|
|
||||||
.sort_values()
|
|
||||||
.reset_index()
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Type Annotations:**
|
**Type Annotations:**
|
||||||
|
|
@ -2044,15 +2050,20 @@ def get_current_interval_price(coordinator: DataUpdateCoordinator) -> float:
|
||||||
"""Get current price from coordinator."""
|
"""Get current price from coordinator."""
|
||||||
return coordinator.data["priceInfo"][0]["total"]
|
return coordinator.data["priceInfo"][0]["total"]
|
||||||
|
|
||||||
|
|
||||||
# ✅ Use modern type syntax (Python 3.13)
|
# ✅ Use modern type syntax (Python 3.13)
|
||||||
def process_prices(prices: list[dict[str, Any]]) -> dict[str, float]:
|
def process_prices(prices: list[dict[str, Any]]) -> dict[str, float]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ❌ Avoid old-style typing (List, Dict from typing module)
|
# ❌ Avoid old-style typing (List, Dict from typing module)
|
||||||
from typing import List, Dict
|
from typing import List, Dict
|
||||||
|
|
||||||
|
|
||||||
def process_prices(prices: List[Dict[str, Any]]) -> Dict[str, float]: # Use list, dict instead
|
def process_prices(prices: List[Dict[str, Any]]) -> Dict[str, float]: # Use list, dict instead
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ✅ Optional parameters
|
# ✅ Optional parameters
|
||||||
def fetch_data(home_id: str, max_retries: int = 3) -> dict | None:
|
def fetch_data(home_id: str, max_retries: int = 3) -> dict | None:
|
||||||
pass
|
pass
|
||||||
|
|
@ -2084,11 +2095,7 @@ prices = [interval["total"] for interval in data]
|
||||||
price_map = {interval["startsAt"]: interval["total"] for interval in data}
|
price_map = {interval["startsAt"]: interval["total"] for interval in data}
|
||||||
|
|
||||||
# ✅ Break long comprehensions
|
# ✅ Break long comprehensions
|
||||||
prices = [
|
prices = [interval["total"] for interval in data if interval["total"] is not None]
|
||||||
interval["total"]
|
|
||||||
for interval in data
|
|
||||||
if interval["total"] is not None
|
|
||||||
]
|
|
||||||
|
|
||||||
# ❌ Don't use comprehensions for complex logic
|
# ❌ Don't use comprehensions for complex logic
|
||||||
result = [ # Use regular loop instead
|
result = [ # Use regular loop instead
|
||||||
|
|
@ -2128,40 +2135,34 @@ Attributes should follow a **logical priority order** to make the most important
|
||||||
```python
|
```python
|
||||||
attributes = {
|
attributes = {
|
||||||
# 1. Time information (when does this apply?)
|
# 1. Time information (when does this apply?)
|
||||||
"timestamp": ..., # ALWAYS FIRST: Reference time for state/attributes validity
|
"timestamp": ..., # ALWAYS FIRST: Reference time for state/attributes validity
|
||||||
"start": ...,
|
"start": ...,
|
||||||
"end": ...,
|
"end": ...,
|
||||||
"duration_minutes": ...,
|
"duration_minutes": ...,
|
||||||
|
|
||||||
# 2. Core decision attributes (what should I do?)
|
# 2. Core decision attributes (what should I do?)
|
||||||
"level": ..., # Price level (VERY_CHEAP, CHEAP, NORMAL, etc.)
|
"level": ..., # Price level (VERY_CHEAP, CHEAP, NORMAL, etc.)
|
||||||
"rating_level": ..., # Price rating (LOW, NORMAL, HIGH)
|
"rating_level": ..., # Price rating (LOW, NORMAL, HIGH)
|
||||||
|
|
||||||
# 3. Price statistics (how much does it cost?)
|
# 3. Price statistics (how much does it cost?)
|
||||||
"price_mean": ...,
|
"price_mean": ...,
|
||||||
"price_median": ...,
|
"price_median": ...,
|
||||||
"price_min": ...,
|
"price_min": ...,
|
||||||
"price_max": ...,
|
"price_max": ...,
|
||||||
|
|
||||||
# 4. Price differences (optional - how does it compare?)
|
# 4. Price differences (optional - how does it compare?)
|
||||||
"price_diff_from_daily_min": ...,
|
"price_diff_from_daily_min": ...,
|
||||||
"price_diff_from_daily_min_%": ...,
|
"price_diff_from_daily_min_%": ...,
|
||||||
|
|
||||||
# 5. Detail information (additional context)
|
# 5. Detail information (additional context)
|
||||||
"hour": ...,
|
"hour": ...,
|
||||||
"minute": ...,
|
"minute": ...,
|
||||||
"time": ...,
|
"time": ...,
|
||||||
"period_position": ...,
|
"period_position": ...,
|
||||||
"interval_count": ...,
|
"interval_count": ...,
|
||||||
|
|
||||||
# 6. Meta information (technical details)
|
# 6. Meta information (technical details)
|
||||||
"pricePeriods": [...], # Nested structures last
|
"pricePeriods": [...], # Nested structures last
|
||||||
"priceInfo": [...],
|
"priceInfo": [...],
|
||||||
|
|
||||||
# 7. Extended descriptions (always last)
|
# 7. Extended descriptions (always last)
|
||||||
"description": "...", # Short description from custom_translations (always shown)
|
"description": "...", # Short description from custom_translations (always shown)
|
||||||
"long_description": "...", # Detailed explanation from custom_translations (shown when CONF_EXTENDED_DESCRIPTIONS enabled)
|
"long_description": "...", # Detailed explanation from custom_translations (shown when CONF_EXTENDED_DESCRIPTIONS enabled)
|
||||||
"usage_tips": "...", # Usage examples from custom_translations (shown when CONF_EXTENDED_DESCRIPTIONS enabled)
|
"usage_tips": "...", # Usage examples from custom_translations (shown when CONF_EXTENDED_DESCRIPTIONS enabled)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -2229,6 +2230,7 @@ def _get_sensor_attributes(self) -> dict | None:
|
||||||
# Direct implementation returns dict
|
# Direct implementation returns dict
|
||||||
return build_sensor_attributes(...)
|
return build_sensor_attributes(...)
|
||||||
|
|
||||||
|
|
||||||
# binary_sensor/core.py
|
# binary_sensor/core.py
|
||||||
def _get_sensor_attributes(self) -> dict | None:
|
def _get_sensor_attributes(self) -> dict | None:
|
||||||
"""Get sensor-specific attributes."""
|
"""Get sensor-specific attributes."""
|
||||||
|
|
@ -2346,7 +2348,7 @@ This ensures timestamp is always the first key in the attribute dict, regardless
|
||||||
"price_mean": 18.5,
|
"price_mean": 18.5,
|
||||||
"price_median": 18.3,
|
"price_median": 18.3,
|
||||||
"interval_count": 4,
|
"interval_count": 4,
|
||||||
"intervals": [...]
|
"intervals": [...],
|
||||||
}
|
}
|
||||||
|
|
||||||
# ❌ Bad: Random order makes it hard to scan
|
# ❌ Bad: Random order makes it hard to scan
|
||||||
|
|
@ -2356,7 +2358,7 @@ This ensures timestamp is always the first key in the attribute dict, regardless
|
||||||
"rating_level": "LOW",
|
"rating_level": "LOW",
|
||||||
"start": "2025-11-08T14:00:00+01:00",
|
"start": "2025-11-08T14:00:00+01:00",
|
||||||
"price_mean": 18.5,
|
"price_mean": 18.5,
|
||||||
"end": "2025-11-08T15:00:00+01:00"
|
"end": "2025-11-08T15:00:00+01:00",
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -263,20 +263,16 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Original from Tibber API
|
# Original from Tibber API
|
||||||
{
|
{"startsAt": "2025-11-03T14:00:00+01:00", "total": 0.2534, "level": "NORMAL"}
|
||||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
|
||||||
"total": 0.2534,
|
|
||||||
"level": "NORMAL"
|
|
||||||
}
|
|
||||||
|
|
||||||
# After enrichment (utils/price.py)
|
# After enrichment (utils/price.py)
|
||||||
{
|
{
|
||||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
"startsAt": "2025-11-03T14:00:00+01:00",
|
||||||
"total": 0.2534,
|
"total": 0.2534,
|
||||||
"level": "NORMAL",
|
"level": "NORMAL",
|
||||||
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
||||||
"difference": 9.6, # ← Added: % diff from trailing avg
|
"difference": 9.6, # ← Added: % diff from trailing avg
|
||||||
"rating_level": "NORMAL" # ← Added: LOW/NORMAL/HIGH based on thresholds
|
"rating_level": "NORMAL", # ← Added: LOW/NORMAL/HIGH based on thresholds
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
||||||
```python
|
```python
|
||||||
{
|
{
|
||||||
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
||||||
"peak": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60}
|
"peak": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -178,11 +178,11 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
||||||
|
|
||||||
```python
|
```python
|
||||||
hash_data = (
|
hash_data = (
|
||||||
today_signature, # (startsAt, rating_level) for each interval
|
today_signature, # (startsAt, rating_level) for each interval
|
||||||
tuple(best_config.items()), # Best price config
|
tuple(best_config.items()), # Best price config
|
||||||
tuple(peak_config.items()), # Peak price config
|
tuple(peak_config.items()), # Peak price config
|
||||||
best_level_filter, # Level filter overrides
|
best_level_filter, # Level filter overrides
|
||||||
peak_level_filter
|
peak_level_filter,
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,8 +61,10 @@ If a helper class is ONLY used within a single module file, prefix it with under
|
||||||
# ✅ Private class - used only in this file
|
# ✅ Private class - used only in this file
|
||||||
class _InternalHelper:
|
class _InternalHelper:
|
||||||
"""Helper used only within this module."""
|
"""Helper used only within this module."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ❌ Wrong - no prefix but used across modules
|
# ❌ Wrong - no prefix but used across modules
|
||||||
class DataFetcher: # Should be TibberPricesDataFetcher
|
class DataFetcher: # Should be TibberPricesDataFetcher
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,7 @@ These patterns were analyzed and classified as **not critical**:
|
||||||
coordinator.data = {
|
coordinator.data = {
|
||||||
"user_data": {...},
|
"user_data": {...},
|
||||||
"priceInfo": [...], # Flat list of all enriched intervals
|
"priceInfo": [...], # Flat list of all enriched intervals
|
||||||
"currency": "EUR" # Top-level for easy access
|
"currency": "EUR", # Top-level for easy access
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ def test_something(coordinator):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def test_periods(hass, coordinator):
|
def test_periods(hass, coordinator):
|
||||||
periods = coordinator.data.get('best_price_periods', [])
|
periods = coordinator.data.get("best_price_periods", [])
|
||||||
for period in periods:
|
for period in periods:
|
||||||
print(f"Period: {period['start']} to {period['end']}")
|
print(f"Period: {period['start']} to {period['end']}")
|
||||||
print(f" Intervals: {len(period['intervals'])}")
|
print(f" Intervals: {len(period['intervals'])}")
|
||||||
|
|
@ -147,15 +147,14 @@ grep "tibber_prices" config/home-assistant.log
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# In Developer Tools > Template
|
# In Developer Tools > Template
|
||||||
{{ states.sensor.tibber_home_current_interval_price.last_updated }}
|
{{states.sensor.tibber_home_current_interval_price.last_updated}}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Debug in code:**
|
**Debug in code:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Add logging in sensor/core.py
|
# Add logging in sensor/core.py
|
||||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
_LOGGER.debug("Updating sensor %s: old=%s new=%s", self.entity_id, self._attr_native_value, new_value)
|
||||||
self.entity_id, self._attr_native_value, new_value)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Period Calculation Wrong
|
### Period Calculation Wrong
|
||||||
|
|
@ -164,8 +163,7 @@ _LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# coordinator/period_handlers/period_building.py
|
# coordinator/period_handlers/period_building.py
|
||||||
_LOGGER.debug("Candidate intervals: %s",
|
_LOGGER.debug("Candidate intervals: %s", [(i["startsAt"], i["total"]) for i in candidates])
|
||||||
[(i['startsAt'], i['total']) for i in candidates])
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Check filter statistics:**
|
**Check filter statistics:**
|
||||||
|
|
@ -217,6 +215,7 @@ Add to coordinator code:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import debugpy
|
import debugpy
|
||||||
|
|
||||||
debugpy.listen(5678)
|
debugpy.listen(5678)
|
||||||
_LOGGER.info("Waiting for debugger attach on port 5678")
|
_LOGGER.info("Waiting for debugger attach on port 5678")
|
||||||
debugpy.wait_for_client()
|
debugpy.wait_for_client()
|
||||||
|
|
@ -236,6 +235,7 @@ Add breakpoint:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from IPython import embed
|
from IPython import embed
|
||||||
|
|
||||||
embed() # Drops into interactive shell
|
embed() # Drops into interactive shell
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,7 @@ import tracemalloc
|
||||||
tracemalloc.start()
|
tracemalloc.start()
|
||||||
# Run your code
|
# Run your code
|
||||||
current, peak = tracemalloc.get_traced_memory()
|
current, peak = tracemalloc.get_traced_memory()
|
||||||
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB",
|
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB", current / 1024**2, peak / 1024**2)
|
||||||
current / 1024**2, peak / 1024**2)
|
|
||||||
tracemalloc.stop()
|
tracemalloc.stop()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -78,6 +77,7 @@ data = await store.async_load()
|
||||||
# Already implemented in const.py
|
# Already implemented in const.py
|
||||||
_TRANSLATION_CACHE: dict[str, dict] = {}
|
_TRANSLATION_CACHE: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
def get_translation(path: str, language: str) -> dict:
|
def get_translation(path: str, language: str) -> dict:
|
||||||
cache_key = f"{path}_{language}"
|
cache_key = f"{path}_{language}"
|
||||||
if cache_key not in _TRANSLATION_CACHE:
|
if cache_key not in _TRANSLATION_CACHE:
|
||||||
|
|
@ -139,10 +139,7 @@ user_data = await fetch_user_data()
|
||||||
price_data = await fetch_price_data()
|
price_data = await fetch_price_data()
|
||||||
|
|
||||||
# ✅ Concurrent (fast)
|
# ✅ Concurrent (fast)
|
||||||
user_data, price_data = await asyncio.gather(
|
user_data, price_data = await asyncio.gather(fetch_user_data(), fetch_price_data())
|
||||||
fetch_user_data(),
|
|
||||||
fetch_price_data()
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**2. Don't block event loop:**
|
**2. Don't block event loop:**
|
||||||
|
|
@ -175,6 +172,7 @@ class Coordinator:
|
||||||
```python
|
```python
|
||||||
import weakref
|
import weakref
|
||||||
|
|
||||||
|
|
||||||
class Manager:
|
class Manager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._callbacks: list[weakref.ref] = []
|
self._callbacks: list[weakref.ref] = []
|
||||||
|
|
@ -216,8 +214,7 @@ results = (x for x in items if condition(x))
|
||||||
**Monitor API usage:**
|
**Monitor API usage:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
_LOGGER.debug("API call: %s (cache_age=%s)",
|
_LOGGER.debug("API call: %s (cache_age=%s)", endpoint, cache_age)
|
||||||
endpoint, cache_age)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Smart Updates
|
### Smart Updates
|
||||||
|
|
@ -243,13 +240,13 @@ async def _async_update_data(self) -> dict:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# ❌ MEASUREMENT for prices (stores every change)
|
# ❌ MEASUREMENT for prices (stores every change)
|
||||||
state_class=SensorStateClass.MEASUREMENT # ~35K records/year
|
state_class = SensorStateClass.MEASUREMENT # ~35K records/year
|
||||||
|
|
||||||
# ✅ None for prices (no long-term stats)
|
# ✅ None for prices (no long-term stats)
|
||||||
state_class=None # Only current state
|
state_class = None # Only current state
|
||||||
|
|
||||||
# ✅ TOTAL for counters only
|
# ✅ TOTAL for counters only
|
||||||
state_class=SensorStateClass.TOTAL # For cumulative values
|
state_class = SensorStateClass.TOTAL # For cumulative values
|
||||||
```
|
```
|
||||||
|
|
||||||
### Attribute Size
|
### Attribute Size
|
||||||
|
|
@ -260,7 +257,7 @@ state_class=SensorStateClass.TOTAL # For cumulative values
|
||||||
# ❌ Large nested structures (KB per update)
|
# ❌ Large nested structures (KB per update)
|
||||||
attributes = {
|
attributes = {
|
||||||
"all_intervals": [...], # 384 intervals
|
"all_intervals": [...], # 384 intervals
|
||||||
"full_history": [...], # Days of data
|
"full_history": [...], # Days of data
|
||||||
}
|
}
|
||||||
|
|
||||||
# ✅ Essential data only (bytes per update)
|
# ✅ Essential data only (bytes per update)
|
||||||
|
|
@ -279,6 +276,7 @@ attributes = {
|
||||||
import pytest
|
import pytest
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.benchmark
|
@pytest.mark.benchmark
|
||||||
def test_period_calculation_performance(coordinator):
|
def test_period_calculation_performance(coordinator):
|
||||||
"""Period calculation should complete in <100ms."""
|
"""Period calculation should complete in <100ms."""
|
||||||
|
|
|
||||||
|
|
@ -1336,8 +1336,8 @@ for price_data in all_prices:
|
||||||
ref_date = date_key
|
ref_date = date_key
|
||||||
|
|
||||||
criteria = TibberPricesIntervalCriteria(
|
criteria = TibberPricesIntervalCriteria(
|
||||||
ref_price=ref_prices[ref_date], # Interval's day
|
ref_price=ref_prices[ref_date], # Interval's day
|
||||||
avg_price=avg_prices[ref_date], # Interval's day
|
avg_price=avg_prices[ref_date], # Interval's day
|
||||||
flex=flex,
|
flex=flex,
|
||||||
min_distance_from_avg=min_distance_from_avg,
|
min_distance_from_avg=min_distance_from_avg,
|
||||||
reverse_sort=reverse_sort,
|
reverse_sort=reverse_sort,
|
||||||
|
|
|
||||||
|
|
@ -69,11 +69,7 @@ API requests are being throttled, causing stale data. Updates may be delayed unt
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# In error handler
|
# In error handler
|
||||||
is_rate_limit = (
|
is_rate_limit = "429" in error_str or "rate limit" in error_str or "too many requests" in error_str
|
||||||
"429" in error_str
|
|
||||||
or "rate limit" in error_str
|
|
||||||
or "too many requests" in error_str
|
|
||||||
)
|
|
||||||
if is_rate_limit:
|
if is_rate_limit:
|
||||||
await self._repair_manager.track_rate_limit_error()
|
await self._repair_manager.track_rate_limit_error()
|
||||||
|
|
||||||
|
|
@ -295,6 +291,7 @@ async def check_new_condition(self, *, param: bool) -> None:
|
||||||
elif not should_warn and self._new_repair_active:
|
elif not should_warn and self._new_repair_active:
|
||||||
await self._clear_new_repair()
|
await self._clear_new_repair()
|
||||||
|
|
||||||
|
|
||||||
async def _create_new_repair(self) -> None:
|
async def _create_new_repair(self) -> None:
|
||||||
"""Create new repair issue."""
|
"""Create new repair issue."""
|
||||||
_LOGGER.warning("New issue detected - creating repair")
|
_LOGGER.warning("New issue detected - creating repair")
|
||||||
|
|
@ -312,6 +309,7 @@ async def _create_new_repair(self) -> None:
|
||||||
)
|
)
|
||||||
self._new_repair_active = True
|
self._new_repair_active = True
|
||||||
|
|
||||||
|
|
||||||
async def _clear_new_repair(self) -> None:
|
async def _clear_new_repair(self) -> None:
|
||||||
"""Clear new repair issue."""
|
"""Clear new repair issue."""
|
||||||
_LOGGER.debug("New issue resolved - clearing repair")
|
_LOGGER.debug("New issue resolved - clearing repair")
|
||||||
|
|
|
||||||
|
|
@ -187,9 +187,8 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
||||||
class TibberPricesSensor(CoordinatorEntity):
|
class TibberPricesSensor(CoordinatorEntity):
|
||||||
async def async_added_to_hass(self):
|
async def async_added_to_hass(self):
|
||||||
# Register this entity's update callback
|
# Register this entity's update callback
|
||||||
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(
|
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(self._handle_coordinator_update)
|
||||||
self._handle_coordinator_update
|
|
||||||
)
|
|
||||||
|
|
||||||
# Coordinator maintains list of listeners
|
# Coordinator maintains list of listeners
|
||||||
class ListenerManager:
|
class ListenerManager:
|
||||||
|
|
@ -390,8 +389,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
# Watch for these log messages:
|
# Watch for these log messages:
|
||||||
"Fetching data from API (reason: tomorrow_check)" # API call
|
"Fetching data from API (reason: tomorrow_check)" # API call
|
||||||
"Using cached data (no update needed)" # Fast path
|
"Using cached data (no update needed)" # Fast path
|
||||||
"Midnight turnover detected (Timer #1)" # Turnover
|
"Midnight turnover detected (Timer #1)" # Turnover
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check Timer #2 (Quarter-Hour)
|
### Check Timer #2 (Quarter-Hour)
|
||||||
|
|
@ -399,7 +398,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
||||||
```python
|
```python
|
||||||
# Watch coordinator logs:
|
# Watch coordinator logs:
|
||||||
"Updated 60 time-sensitive entities at quarter-hour boundary" # Normal
|
"Updated 60 time-sensitive entities at quarter-hour boundary" # Normal
|
||||||
"Midnight turnover detected (Timer #2)" # Turnover
|
|
||||||
|
"Midnight turnover detected (Timer #2)" # Turnover
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check Timer #3 (Minute)
|
### Check Timer #3 (Minute)
|
||||||
|
|
|
||||||
|
|
@ -263,20 +263,16 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Original from Tibber API
|
# Original from Tibber API
|
||||||
{
|
{"startsAt": "2025-11-03T14:00:00+01:00", "total": 0.2534, "level": "NORMAL"}
|
||||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
|
||||||
"total": 0.2534,
|
|
||||||
"level": "NORMAL"
|
|
||||||
}
|
|
||||||
|
|
||||||
# After enrichment (utils/price.py)
|
# After enrichment (utils/price.py)
|
||||||
{
|
{
|
||||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
"startsAt": "2025-11-03T14:00:00+01:00",
|
||||||
"total": 0.2534,
|
"total": 0.2534,
|
||||||
"level": "NORMAL",
|
"level": "NORMAL",
|
||||||
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
||||||
"difference": 9.6, # ← Added: % diff from trailing avg
|
"difference": 9.6, # ← Added: % diff from trailing avg
|
||||||
"rating_level": "NORMAL" # ← Added: LOW/NORMAL/HIGH based on thresholds
|
"rating_level": "NORMAL", # ← Added: LOW/NORMAL/HIGH based on thresholds
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
||||||
```python
|
```python
|
||||||
{
|
{
|
||||||
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
||||||
"peak": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60}
|
"peak": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -178,11 +178,11 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
||||||
|
|
||||||
```python
|
```python
|
||||||
hash_data = (
|
hash_data = (
|
||||||
today_signature, # (startsAt, rating_level) for each interval
|
today_signature, # (startsAt, rating_level) for each interval
|
||||||
tuple(best_config.items()), # Best price config
|
tuple(best_config.items()), # Best price config
|
||||||
tuple(peak_config.items()), # Peak price config
|
tuple(peak_config.items()), # Peak price config
|
||||||
best_level_filter, # Level filter overrides
|
best_level_filter, # Level filter overrides
|
||||||
peak_level_filter
|
peak_level_filter,
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,8 +61,10 @@ If a helper class is ONLY used within a single module file, prefix it with under
|
||||||
# ✅ Private class - used only in this file
|
# ✅ Private class - used only in this file
|
||||||
class _InternalHelper:
|
class _InternalHelper:
|
||||||
"""Helper used only within this module."""
|
"""Helper used only within this module."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ❌ Wrong - no prefix but used across modules
|
# ❌ Wrong - no prefix but used across modules
|
||||||
class DataFetcher: # Should be TibberPricesDataFetcher
|
class DataFetcher: # Should be TibberPricesDataFetcher
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,7 @@ These patterns were analyzed and classified as **not critical**:
|
||||||
coordinator.data = {
|
coordinator.data = {
|
||||||
"user_data": {...},
|
"user_data": {...},
|
||||||
"priceInfo": [...], # Flat list of all enriched intervals
|
"priceInfo": [...], # Flat list of all enriched intervals
|
||||||
"currency": "EUR" # Top-level for easy access
|
"currency": "EUR", # Top-level for easy access
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ def test_something(coordinator):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def test_periods(hass, coordinator):
|
def test_periods(hass, coordinator):
|
||||||
periods = coordinator.data.get('best_price_periods', [])
|
periods = coordinator.data.get("best_price_periods", [])
|
||||||
for period in periods:
|
for period in periods:
|
||||||
print(f"Period: {period['start']} to {period['end']}")
|
print(f"Period: {period['start']} to {period['end']}")
|
||||||
print(f" Intervals: {len(period['intervals'])}")
|
print(f" Intervals: {len(period['intervals'])}")
|
||||||
|
|
@ -147,15 +147,14 @@ grep "tibber_prices" config/home-assistant.log
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# In Developer Tools > Template
|
# In Developer Tools > Template
|
||||||
{{ states.sensor.tibber_home_current_interval_price.last_updated }}
|
{{states.sensor.tibber_home_current_interval_price.last_updated}}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Debug in code:**
|
**Debug in code:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Add logging in sensor/core.py
|
# Add logging in sensor/core.py
|
||||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
_LOGGER.debug("Updating sensor %s: old=%s new=%s", self.entity_id, self._attr_native_value, new_value)
|
||||||
self.entity_id, self._attr_native_value, new_value)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Period Calculation Wrong
|
### Period Calculation Wrong
|
||||||
|
|
@ -164,8 +163,7 @@ _LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# coordinator/period_handlers/period_building.py
|
# coordinator/period_handlers/period_building.py
|
||||||
_LOGGER.debug("Candidate intervals: %s",
|
_LOGGER.debug("Candidate intervals: %s", [(i["startsAt"], i["total"]) for i in candidates])
|
||||||
[(i['startsAt'], i['total']) for i in candidates])
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Check filter statistics:**
|
**Check filter statistics:**
|
||||||
|
|
@ -217,6 +215,7 @@ Add to coordinator code:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import debugpy
|
import debugpy
|
||||||
|
|
||||||
debugpy.listen(5678)
|
debugpy.listen(5678)
|
||||||
_LOGGER.info("Waiting for debugger attach on port 5678")
|
_LOGGER.info("Waiting for debugger attach on port 5678")
|
||||||
debugpy.wait_for_client()
|
debugpy.wait_for_client()
|
||||||
|
|
@ -236,6 +235,7 @@ Add breakpoint:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from IPython import embed
|
from IPython import embed
|
||||||
|
|
||||||
embed() # Drops into interactive shell
|
embed() # Drops into interactive shell
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,7 @@ import tracemalloc
|
||||||
tracemalloc.start()
|
tracemalloc.start()
|
||||||
# Run your code
|
# Run your code
|
||||||
current, peak = tracemalloc.get_traced_memory()
|
current, peak = tracemalloc.get_traced_memory()
|
||||||
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB",
|
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB", current / 1024**2, peak / 1024**2)
|
||||||
current / 1024**2, peak / 1024**2)
|
|
||||||
tracemalloc.stop()
|
tracemalloc.stop()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -78,6 +77,7 @@ data = await store.async_load()
|
||||||
# Already implemented in const.py
|
# Already implemented in const.py
|
||||||
_TRANSLATION_CACHE: dict[str, dict] = {}
|
_TRANSLATION_CACHE: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
def get_translation(path: str, language: str) -> dict:
|
def get_translation(path: str, language: str) -> dict:
|
||||||
cache_key = f"{path}_{language}"
|
cache_key = f"{path}_{language}"
|
||||||
if cache_key not in _TRANSLATION_CACHE:
|
if cache_key not in _TRANSLATION_CACHE:
|
||||||
|
|
@ -139,10 +139,7 @@ user_data = await fetch_user_data()
|
||||||
price_data = await fetch_price_data()
|
price_data = await fetch_price_data()
|
||||||
|
|
||||||
# ✅ Concurrent (fast)
|
# ✅ Concurrent (fast)
|
||||||
user_data, price_data = await asyncio.gather(
|
user_data, price_data = await asyncio.gather(fetch_user_data(), fetch_price_data())
|
||||||
fetch_user_data(),
|
|
||||||
fetch_price_data()
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**2. Don't block event loop:**
|
**2. Don't block event loop:**
|
||||||
|
|
@ -175,6 +172,7 @@ class Coordinator:
|
||||||
```python
|
```python
|
||||||
import weakref
|
import weakref
|
||||||
|
|
||||||
|
|
||||||
class Manager:
|
class Manager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._callbacks: list[weakref.ref] = []
|
self._callbacks: list[weakref.ref] = []
|
||||||
|
|
@ -216,8 +214,7 @@ results = (x for x in items if condition(x))
|
||||||
**Monitor API usage:**
|
**Monitor API usage:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
_LOGGER.debug("API call: %s (cache_age=%s)",
|
_LOGGER.debug("API call: %s (cache_age=%s)", endpoint, cache_age)
|
||||||
endpoint, cache_age)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Smart Updates
|
### Smart Updates
|
||||||
|
|
@ -243,13 +240,13 @@ async def _async_update_data(self) -> dict:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# ❌ MEASUREMENT for prices (stores every change)
|
# ❌ MEASUREMENT for prices (stores every change)
|
||||||
state_class=SensorStateClass.MEASUREMENT # ~35K records/year
|
state_class = SensorStateClass.MEASUREMENT # ~35K records/year
|
||||||
|
|
||||||
# ✅ None for prices (no long-term stats)
|
# ✅ None for prices (no long-term stats)
|
||||||
state_class=None # Only current state
|
state_class = None # Only current state
|
||||||
|
|
||||||
# ✅ TOTAL for counters only
|
# ✅ TOTAL for counters only
|
||||||
state_class=SensorStateClass.TOTAL # For cumulative values
|
state_class = SensorStateClass.TOTAL # For cumulative values
|
||||||
```
|
```
|
||||||
|
|
||||||
### Attribute Size
|
### Attribute Size
|
||||||
|
|
@ -260,7 +257,7 @@ state_class=SensorStateClass.TOTAL # For cumulative values
|
||||||
# ❌ Large nested structures (KB per update)
|
# ❌ Large nested structures (KB per update)
|
||||||
attributes = {
|
attributes = {
|
||||||
"all_intervals": [...], # 384 intervals
|
"all_intervals": [...], # 384 intervals
|
||||||
"full_history": [...], # Days of data
|
"full_history": [...], # Days of data
|
||||||
}
|
}
|
||||||
|
|
||||||
# ✅ Essential data only (bytes per update)
|
# ✅ Essential data only (bytes per update)
|
||||||
|
|
@ -279,6 +276,7 @@ attributes = {
|
||||||
import pytest
|
import pytest
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.benchmark
|
@pytest.mark.benchmark
|
||||||
def test_period_calculation_performance(coordinator):
|
def test_period_calculation_performance(coordinator):
|
||||||
"""Period calculation should complete in <100ms."""
|
"""Period calculation should complete in <100ms."""
|
||||||
|
|
|
||||||
|
|
@ -1092,8 +1092,8 @@ for price_data in all_prices:
|
||||||
ref_date = date_key
|
ref_date = date_key
|
||||||
|
|
||||||
criteria = TibberPricesIntervalCriteria(
|
criteria = TibberPricesIntervalCriteria(
|
||||||
ref_price=ref_prices[ref_date], # Interval's day
|
ref_price=ref_prices[ref_date], # Interval's day
|
||||||
avg_price=avg_prices[ref_date], # Interval's day
|
avg_price=avg_prices[ref_date], # Interval's day
|
||||||
flex=flex,
|
flex=flex,
|
||||||
min_distance_from_avg=min_distance_from_avg,
|
min_distance_from_avg=min_distance_from_avg,
|
||||||
reverse_sort=reverse_sort,
|
reverse_sort=reverse_sort,
|
||||||
|
|
|
||||||
|
|
@ -69,11 +69,7 @@ API requests are being throttled, causing stale data. Updates may be delayed unt
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# In error handler
|
# In error handler
|
||||||
is_rate_limit = (
|
is_rate_limit = "429" in error_str or "rate limit" in error_str or "too many requests" in error_str
|
||||||
"429" in error_str
|
|
||||||
or "rate limit" in error_str
|
|
||||||
or "too many requests" in error_str
|
|
||||||
)
|
|
||||||
if is_rate_limit:
|
if is_rate_limit:
|
||||||
await self._repair_manager.track_rate_limit_error()
|
await self._repair_manager.track_rate_limit_error()
|
||||||
|
|
||||||
|
|
@ -295,6 +291,7 @@ async def check_new_condition(self, *, param: bool) -> None:
|
||||||
elif not should_warn and self._new_repair_active:
|
elif not should_warn and self._new_repair_active:
|
||||||
await self._clear_new_repair()
|
await self._clear_new_repair()
|
||||||
|
|
||||||
|
|
||||||
async def _create_new_repair(self) -> None:
|
async def _create_new_repair(self) -> None:
|
||||||
"""Create new repair issue."""
|
"""Create new repair issue."""
|
||||||
_LOGGER.warning("New issue detected - creating repair")
|
_LOGGER.warning("New issue detected - creating repair")
|
||||||
|
|
@ -312,6 +309,7 @@ async def _create_new_repair(self) -> None:
|
||||||
)
|
)
|
||||||
self._new_repair_active = True
|
self._new_repair_active = True
|
||||||
|
|
||||||
|
|
||||||
async def _clear_new_repair(self) -> None:
|
async def _clear_new_repair(self) -> None:
|
||||||
"""Clear new repair issue."""
|
"""Clear new repair issue."""
|
||||||
_LOGGER.debug("New issue resolved - clearing repair")
|
_LOGGER.debug("New issue resolved - clearing repair")
|
||||||
|
|
|
||||||
|
|
@ -187,9 +187,8 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
||||||
class TibberPricesSensor(CoordinatorEntity):
|
class TibberPricesSensor(CoordinatorEntity):
|
||||||
async def async_added_to_hass(self):
|
async def async_added_to_hass(self):
|
||||||
# Register this entity's update callback
|
# Register this entity's update callback
|
||||||
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(
|
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(self._handle_coordinator_update)
|
||||||
self._handle_coordinator_update
|
|
||||||
)
|
|
||||||
|
|
||||||
# Coordinator maintains list of listeners
|
# Coordinator maintains list of listeners
|
||||||
class ListenerManager:
|
class ListenerManager:
|
||||||
|
|
@ -390,8 +389,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
# Watch for these log messages:
|
# Watch for these log messages:
|
||||||
"Fetching data from API (reason: tomorrow_check)" # API call
|
"Fetching data from API (reason: tomorrow_check)" # API call
|
||||||
"Using cached data (no update needed)" # Fast path
|
"Using cached data (no update needed)" # Fast path
|
||||||
"Midnight turnover detected (Timer #1)" # Turnover
|
"Midnight turnover detected (Timer #1)" # Turnover
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check Timer #2 (Quarter-Hour)
|
### Check Timer #2 (Quarter-Hour)
|
||||||
|
|
@ -399,7 +398,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
||||||
```python
|
```python
|
||||||
# Watch coordinator logs:
|
# Watch coordinator logs:
|
||||||
"Updated 60 time-sensitive entities at quarter-hour boundary" # Normal
|
"Updated 60 time-sensitive entities at quarter-hour boundary" # Normal
|
||||||
"Midnight turnover detected (Timer #2)" # Turnover
|
|
||||||
|
"Midnight turnover detected (Timer #2)" # Turnover
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check Timer #3 (Minute)
|
### Check Timer #3 (Minute)
|
||||||
|
|
|
||||||
|
|
@ -263,20 +263,16 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Original from Tibber API
|
# Original from Tibber API
|
||||||
{
|
{"startsAt": "2025-11-03T14:00:00+01:00", "total": 0.2534, "level": "NORMAL"}
|
||||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
|
||||||
"total": 0.2534,
|
|
||||||
"level": "NORMAL"
|
|
||||||
}
|
|
||||||
|
|
||||||
# After enrichment (utils/price.py)
|
# After enrichment (utils/price.py)
|
||||||
{
|
{
|
||||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
"startsAt": "2025-11-03T14:00:00+01:00",
|
||||||
"total": 0.2534,
|
"total": 0.2534,
|
||||||
"level": "NORMAL",
|
"level": "NORMAL",
|
||||||
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
||||||
"difference": 9.6, # ← Added: % diff from trailing avg
|
"difference": 9.6, # ← Added: % diff from trailing avg
|
||||||
"rating_level": "NORMAL" # ← Added: LOW/NORMAL/HIGH based on thresholds
|
"rating_level": "NORMAL", # ← Added: LOW/NORMAL/HIGH based on thresholds
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
||||||
```python
|
```python
|
||||||
{
|
{
|
||||||
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
||||||
"peak": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60}
|
"peak": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -178,11 +178,11 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
||||||
|
|
||||||
```python
|
```python
|
||||||
hash_data = (
|
hash_data = (
|
||||||
today_signature, # (startsAt, rating_level) for each interval
|
today_signature, # (startsAt, rating_level) for each interval
|
||||||
tuple(best_config.items()), # Best price config
|
tuple(best_config.items()), # Best price config
|
||||||
tuple(peak_config.items()), # Peak price config
|
tuple(peak_config.items()), # Peak price config
|
||||||
best_level_filter, # Level filter overrides
|
best_level_filter, # Level filter overrides
|
||||||
peak_level_filter
|
peak_level_filter,
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,8 +61,10 @@ If a helper class is ONLY used within a single module file, prefix it with under
|
||||||
# ✅ Private class - used only in this file
|
# ✅ Private class - used only in this file
|
||||||
class _InternalHelper:
|
class _InternalHelper:
|
||||||
"""Helper used only within this module."""
|
"""Helper used only within this module."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ❌ Wrong - no prefix but used across modules
|
# ❌ Wrong - no prefix but used across modules
|
||||||
class DataFetcher: # Should be TibberPricesDataFetcher
|
class DataFetcher: # Should be TibberPricesDataFetcher
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,7 @@ These patterns were analyzed and classified as **not critical**:
|
||||||
coordinator.data = {
|
coordinator.data = {
|
||||||
"user_data": {...},
|
"user_data": {...},
|
||||||
"priceInfo": [...], # Flat list of all enriched intervals
|
"priceInfo": [...], # Flat list of all enriched intervals
|
||||||
"currency": "EUR" # Top-level for easy access
|
"currency": "EUR", # Top-level for easy access
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ def test_something(coordinator):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def test_periods(hass, coordinator):
|
def test_periods(hass, coordinator):
|
||||||
periods = coordinator.data.get('best_price_periods', [])
|
periods = coordinator.data.get("best_price_periods", [])
|
||||||
for period in periods:
|
for period in periods:
|
||||||
print(f"Period: {period['start']} to {period['end']}")
|
print(f"Period: {period['start']} to {period['end']}")
|
||||||
print(f" Intervals: {len(period['intervals'])}")
|
print(f" Intervals: {len(period['intervals'])}")
|
||||||
|
|
@ -147,15 +147,14 @@ grep "tibber_prices" config/home-assistant.log
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# In Developer Tools > Template
|
# In Developer Tools > Template
|
||||||
{{ states.sensor.tibber_home_current_interval_price.last_updated }}
|
{{states.sensor.tibber_home_current_interval_price.last_updated}}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Debug in code:**
|
**Debug in code:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Add logging in sensor/core.py
|
# Add logging in sensor/core.py
|
||||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
_LOGGER.debug("Updating sensor %s: old=%s new=%s", self.entity_id, self._attr_native_value, new_value)
|
||||||
self.entity_id, self._attr_native_value, new_value)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Period Calculation Wrong
|
### Period Calculation Wrong
|
||||||
|
|
@ -164,8 +163,7 @@ _LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# coordinator/period_handlers/period_building.py
|
# coordinator/period_handlers/period_building.py
|
||||||
_LOGGER.debug("Candidate intervals: %s",
|
_LOGGER.debug("Candidate intervals: %s", [(i["startsAt"], i["total"]) for i in candidates])
|
||||||
[(i['startsAt'], i['total']) for i in candidates])
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Check filter statistics:**
|
**Check filter statistics:**
|
||||||
|
|
@ -217,6 +215,7 @@ Add to coordinator code:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import debugpy
|
import debugpy
|
||||||
|
|
||||||
debugpy.listen(5678)
|
debugpy.listen(5678)
|
||||||
_LOGGER.info("Waiting for debugger attach on port 5678")
|
_LOGGER.info("Waiting for debugger attach on port 5678")
|
||||||
debugpy.wait_for_client()
|
debugpy.wait_for_client()
|
||||||
|
|
@ -236,6 +235,7 @@ Add breakpoint:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from IPython import embed
|
from IPython import embed
|
||||||
|
|
||||||
embed() # Drops into interactive shell
|
embed() # Drops into interactive shell
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,7 @@ import tracemalloc
|
||||||
tracemalloc.start()
|
tracemalloc.start()
|
||||||
# Run your code
|
# Run your code
|
||||||
current, peak = tracemalloc.get_traced_memory()
|
current, peak = tracemalloc.get_traced_memory()
|
||||||
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB",
|
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB", current / 1024**2, peak / 1024**2)
|
||||||
current / 1024**2, peak / 1024**2)
|
|
||||||
tracemalloc.stop()
|
tracemalloc.stop()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -78,6 +77,7 @@ data = await store.async_load()
|
||||||
# Already implemented in const.py
|
# Already implemented in const.py
|
||||||
_TRANSLATION_CACHE: dict[str, dict] = {}
|
_TRANSLATION_CACHE: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
def get_translation(path: str, language: str) -> dict:
|
def get_translation(path: str, language: str) -> dict:
|
||||||
cache_key = f"{path}_{language}"
|
cache_key = f"{path}_{language}"
|
||||||
if cache_key not in _TRANSLATION_CACHE:
|
if cache_key not in _TRANSLATION_CACHE:
|
||||||
|
|
@ -139,10 +139,7 @@ user_data = await fetch_user_data()
|
||||||
price_data = await fetch_price_data()
|
price_data = await fetch_price_data()
|
||||||
|
|
||||||
# ✅ Concurrent (fast)
|
# ✅ Concurrent (fast)
|
||||||
user_data, price_data = await asyncio.gather(
|
user_data, price_data = await asyncio.gather(fetch_user_data(), fetch_price_data())
|
||||||
fetch_user_data(),
|
|
||||||
fetch_price_data()
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**2. Don't block event loop:**
|
**2. Don't block event loop:**
|
||||||
|
|
@ -175,6 +172,7 @@ class Coordinator:
|
||||||
```python
|
```python
|
||||||
import weakref
|
import weakref
|
||||||
|
|
||||||
|
|
||||||
class Manager:
|
class Manager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._callbacks: list[weakref.ref] = []
|
self._callbacks: list[weakref.ref] = []
|
||||||
|
|
@ -216,8 +214,7 @@ results = (x for x in items if condition(x))
|
||||||
**Monitor API usage:**
|
**Monitor API usage:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
_LOGGER.debug("API call: %s (cache_age=%s)",
|
_LOGGER.debug("API call: %s (cache_age=%s)", endpoint, cache_age)
|
||||||
endpoint, cache_age)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Smart Updates
|
### Smart Updates
|
||||||
|
|
@ -243,13 +240,13 @@ async def _async_update_data(self) -> dict:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# ❌ MEASUREMENT for prices (stores every change)
|
# ❌ MEASUREMENT for prices (stores every change)
|
||||||
state_class=SensorStateClass.MEASUREMENT # ~35K records/year
|
state_class = SensorStateClass.MEASUREMENT # ~35K records/year
|
||||||
|
|
||||||
# ✅ None for prices (no long-term stats)
|
# ✅ None for prices (no long-term stats)
|
||||||
state_class=None # Only current state
|
state_class = None # Only current state
|
||||||
|
|
||||||
# ✅ TOTAL for counters only
|
# ✅ TOTAL for counters only
|
||||||
state_class=SensorStateClass.TOTAL # For cumulative values
|
state_class = SensorStateClass.TOTAL # For cumulative values
|
||||||
```
|
```
|
||||||
|
|
||||||
### Attribute Size
|
### Attribute Size
|
||||||
|
|
@ -260,7 +257,7 @@ state_class=SensorStateClass.TOTAL # For cumulative values
|
||||||
# ❌ Large nested structures (KB per update)
|
# ❌ Large nested structures (KB per update)
|
||||||
attributes = {
|
attributes = {
|
||||||
"all_intervals": [...], # 384 intervals
|
"all_intervals": [...], # 384 intervals
|
||||||
"full_history": [...], # Days of data
|
"full_history": [...], # Days of data
|
||||||
}
|
}
|
||||||
|
|
||||||
# ✅ Essential data only (bytes per update)
|
# ✅ Essential data only (bytes per update)
|
||||||
|
|
@ -279,6 +276,7 @@ attributes = {
|
||||||
import pytest
|
import pytest
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.benchmark
|
@pytest.mark.benchmark
|
||||||
def test_period_calculation_performance(coordinator):
|
def test_period_calculation_performance(coordinator):
|
||||||
"""Period calculation should complete in <100ms."""
|
"""Period calculation should complete in <100ms."""
|
||||||
|
|
|
||||||
|
|
@ -1092,8 +1092,8 @@ for price_data in all_prices:
|
||||||
ref_date = date_key
|
ref_date = date_key
|
||||||
|
|
||||||
criteria = TibberPricesIntervalCriteria(
|
criteria = TibberPricesIntervalCriteria(
|
||||||
ref_price=ref_prices[ref_date], # Interval's day
|
ref_price=ref_prices[ref_date], # Interval's day
|
||||||
avg_price=avg_prices[ref_date], # Interval's day
|
avg_price=avg_prices[ref_date], # Interval's day
|
||||||
flex=flex,
|
flex=flex,
|
||||||
min_distance_from_avg=min_distance_from_avg,
|
min_distance_from_avg=min_distance_from_avg,
|
||||||
reverse_sort=reverse_sort,
|
reverse_sort=reverse_sort,
|
||||||
|
|
|
||||||
|
|
@ -69,11 +69,7 @@ API requests are being throttled, causing stale data. Updates may be delayed unt
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# In error handler
|
# In error handler
|
||||||
is_rate_limit = (
|
is_rate_limit = "429" in error_str or "rate limit" in error_str or "too many requests" in error_str
|
||||||
"429" in error_str
|
|
||||||
or "rate limit" in error_str
|
|
||||||
or "too many requests" in error_str
|
|
||||||
)
|
|
||||||
if is_rate_limit:
|
if is_rate_limit:
|
||||||
await self._repair_manager.track_rate_limit_error()
|
await self._repair_manager.track_rate_limit_error()
|
||||||
|
|
||||||
|
|
@ -295,6 +291,7 @@ async def check_new_condition(self, *, param: bool) -> None:
|
||||||
elif not should_warn and self._new_repair_active:
|
elif not should_warn and self._new_repair_active:
|
||||||
await self._clear_new_repair()
|
await self._clear_new_repair()
|
||||||
|
|
||||||
|
|
||||||
async def _create_new_repair(self) -> None:
|
async def _create_new_repair(self) -> None:
|
||||||
"""Create new repair issue."""
|
"""Create new repair issue."""
|
||||||
_LOGGER.warning("New issue detected - creating repair")
|
_LOGGER.warning("New issue detected - creating repair")
|
||||||
|
|
@ -312,6 +309,7 @@ async def _create_new_repair(self) -> None:
|
||||||
)
|
)
|
||||||
self._new_repair_active = True
|
self._new_repair_active = True
|
||||||
|
|
||||||
|
|
||||||
async def _clear_new_repair(self) -> None:
|
async def _clear_new_repair(self) -> None:
|
||||||
"""Clear new repair issue."""
|
"""Clear new repair issue."""
|
||||||
_LOGGER.debug("New issue resolved - clearing repair")
|
_LOGGER.debug("New issue resolved - clearing repair")
|
||||||
|
|
|
||||||
|
|
@ -187,9 +187,8 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
||||||
class TibberPricesSensor(CoordinatorEntity):
|
class TibberPricesSensor(CoordinatorEntity):
|
||||||
async def async_added_to_hass(self):
|
async def async_added_to_hass(self):
|
||||||
# Register this entity's update callback
|
# Register this entity's update callback
|
||||||
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(
|
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(self._handle_coordinator_update)
|
||||||
self._handle_coordinator_update
|
|
||||||
)
|
|
||||||
|
|
||||||
# Coordinator maintains list of listeners
|
# Coordinator maintains list of listeners
|
||||||
class ListenerManager:
|
class ListenerManager:
|
||||||
|
|
@ -390,8 +389,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
# Watch for these log messages:
|
# Watch for these log messages:
|
||||||
"Fetching data from API (reason: tomorrow_check)" # API call
|
"Fetching data from API (reason: tomorrow_check)" # API call
|
||||||
"Using cached data (no update needed)" # Fast path
|
"Using cached data (no update needed)" # Fast path
|
||||||
"Midnight turnover detected (Timer #1)" # Turnover
|
"Midnight turnover detected (Timer #1)" # Turnover
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check Timer #2 (Quarter-Hour)
|
### Check Timer #2 (Quarter-Hour)
|
||||||
|
|
@ -399,7 +398,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
||||||
```python
|
```python
|
||||||
# Watch coordinator logs:
|
# Watch coordinator logs:
|
||||||
"Updated 60 time-sensitive entities at quarter-hour boundary" # Normal
|
"Updated 60 time-sensitive entities at quarter-hour boundary" # Normal
|
||||||
"Midnight turnover detected (Timer #2)" # Turnover
|
|
||||||
|
"Midnight turnover detected (Timer #2)" # Turnover
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check Timer #3 (Minute)
|
### Check Timer #3 (Minute)
|
||||||
|
|
|
||||||
|
|
@ -263,20 +263,16 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Original from Tibber API
|
# Original from Tibber API
|
||||||
{
|
{"startsAt": "2025-11-03T14:00:00+01:00", "total": 0.2534, "level": "NORMAL"}
|
||||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
|
||||||
"total": 0.2534,
|
|
||||||
"level": "NORMAL"
|
|
||||||
}
|
|
||||||
|
|
||||||
# After enrichment (utils/price.py)
|
# After enrichment (utils/price.py)
|
||||||
{
|
{
|
||||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
"startsAt": "2025-11-03T14:00:00+01:00",
|
||||||
"total": 0.2534,
|
"total": 0.2534,
|
||||||
"level": "NORMAL",
|
"level": "NORMAL",
|
||||||
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
||||||
"difference": 9.6, # ← Added: % diff from trailing avg
|
"difference": 9.6, # ← Added: % diff from trailing avg
|
||||||
"rating_level": "NORMAL" # ← Added: LOW/NORMAL/HIGH based on thresholds
|
"rating_level": "NORMAL", # ← Added: LOW/NORMAL/HIGH based on thresholds
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
||||||
```python
|
```python
|
||||||
{
|
{
|
||||||
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
||||||
"peak": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60}
|
"peak": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -178,11 +178,11 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
||||||
|
|
||||||
```python
|
```python
|
||||||
hash_data = (
|
hash_data = (
|
||||||
today_signature, # (startsAt, rating_level) for each interval
|
today_signature, # (startsAt, rating_level) for each interval
|
||||||
tuple(best_config.items()), # Best price config
|
tuple(best_config.items()), # Best price config
|
||||||
tuple(peak_config.items()), # Peak price config
|
tuple(peak_config.items()), # Peak price config
|
||||||
best_level_filter, # Level filter overrides
|
best_level_filter, # Level filter overrides
|
||||||
peak_level_filter
|
peak_level_filter,
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,8 +61,10 @@ If a helper class is ONLY used within a single module file, prefix it with under
|
||||||
# ✅ Private class - used only in this file
|
# ✅ Private class - used only in this file
|
||||||
class _InternalHelper:
|
class _InternalHelper:
|
||||||
"""Helper used only within this module."""
|
"""Helper used only within this module."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ❌ Wrong - no prefix but used across modules
|
# ❌ Wrong - no prefix but used across modules
|
||||||
class DataFetcher: # Should be TibberPricesDataFetcher
|
class DataFetcher: # Should be TibberPricesDataFetcher
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,7 @@ These patterns were analyzed and classified as **not critical**:
|
||||||
coordinator.data = {
|
coordinator.data = {
|
||||||
"user_data": {...},
|
"user_data": {...},
|
||||||
"priceInfo": [...], # Flat list of all enriched intervals
|
"priceInfo": [...], # Flat list of all enriched intervals
|
||||||
"currency": "EUR" # Top-level for easy access
|
"currency": "EUR", # Top-level for easy access
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ def test_something(coordinator):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def test_periods(hass, coordinator):
|
def test_periods(hass, coordinator):
|
||||||
periods = coordinator.data.get('best_price_periods', [])
|
periods = coordinator.data.get("best_price_periods", [])
|
||||||
for period in periods:
|
for period in periods:
|
||||||
print(f"Period: {period['start']} to {period['end']}")
|
print(f"Period: {period['start']} to {period['end']}")
|
||||||
print(f" Intervals: {len(period['intervals'])}")
|
print(f" Intervals: {len(period['intervals'])}")
|
||||||
|
|
@ -147,15 +147,14 @@ grep "tibber_prices" config/home-assistant.log
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# In Developer Tools > Template
|
# In Developer Tools > Template
|
||||||
{{ states.sensor.tibber_home_current_interval_price.last_updated }}
|
{{states.sensor.tibber_home_current_interval_price.last_updated}}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Debug in code:**
|
**Debug in code:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Add logging in sensor/core.py
|
# Add logging in sensor/core.py
|
||||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
_LOGGER.debug("Updating sensor %s: old=%s new=%s", self.entity_id, self._attr_native_value, new_value)
|
||||||
self.entity_id, self._attr_native_value, new_value)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Period Calculation Wrong
|
### Period Calculation Wrong
|
||||||
|
|
@ -164,8 +163,7 @@ _LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# coordinator/period_handlers/period_building.py
|
# coordinator/period_handlers/period_building.py
|
||||||
_LOGGER.debug("Candidate intervals: %s",
|
_LOGGER.debug("Candidate intervals: %s", [(i["startsAt"], i["total"]) for i in candidates])
|
||||||
[(i['startsAt'], i['total']) for i in candidates])
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Check filter statistics:**
|
**Check filter statistics:**
|
||||||
|
|
@ -217,6 +215,7 @@ Add to coordinator code:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import debugpy
|
import debugpy
|
||||||
|
|
||||||
debugpy.listen(5678)
|
debugpy.listen(5678)
|
||||||
_LOGGER.info("Waiting for debugger attach on port 5678")
|
_LOGGER.info("Waiting for debugger attach on port 5678")
|
||||||
debugpy.wait_for_client()
|
debugpy.wait_for_client()
|
||||||
|
|
@ -236,6 +235,7 @@ Add breakpoint:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from IPython import embed
|
from IPython import embed
|
||||||
|
|
||||||
embed() # Drops into interactive shell
|
embed() # Drops into interactive shell
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,7 @@ import tracemalloc
|
||||||
tracemalloc.start()
|
tracemalloc.start()
|
||||||
# Run your code
|
# Run your code
|
||||||
current, peak = tracemalloc.get_traced_memory()
|
current, peak = tracemalloc.get_traced_memory()
|
||||||
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB",
|
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB", current / 1024**2, peak / 1024**2)
|
||||||
current / 1024**2, peak / 1024**2)
|
|
||||||
tracemalloc.stop()
|
tracemalloc.stop()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -78,6 +77,7 @@ data = await store.async_load()
|
||||||
# Already implemented in const.py
|
# Already implemented in const.py
|
||||||
_TRANSLATION_CACHE: dict[str, dict] = {}
|
_TRANSLATION_CACHE: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
def get_translation(path: str, language: str) -> dict:
|
def get_translation(path: str, language: str) -> dict:
|
||||||
cache_key = f"{path}_{language}"
|
cache_key = f"{path}_{language}"
|
||||||
if cache_key not in _TRANSLATION_CACHE:
|
if cache_key not in _TRANSLATION_CACHE:
|
||||||
|
|
@ -139,10 +139,7 @@ user_data = await fetch_user_data()
|
||||||
price_data = await fetch_price_data()
|
price_data = await fetch_price_data()
|
||||||
|
|
||||||
# ✅ Concurrent (fast)
|
# ✅ Concurrent (fast)
|
||||||
user_data, price_data = await asyncio.gather(
|
user_data, price_data = await asyncio.gather(fetch_user_data(), fetch_price_data())
|
||||||
fetch_user_data(),
|
|
||||||
fetch_price_data()
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**2. Don't block event loop:**
|
**2. Don't block event loop:**
|
||||||
|
|
@ -175,6 +172,7 @@ class Coordinator:
|
||||||
```python
|
```python
|
||||||
import weakref
|
import weakref
|
||||||
|
|
||||||
|
|
||||||
class Manager:
|
class Manager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._callbacks: list[weakref.ref] = []
|
self._callbacks: list[weakref.ref] = []
|
||||||
|
|
@ -216,8 +214,7 @@ results = (x for x in items if condition(x))
|
||||||
**Monitor API usage:**
|
**Monitor API usage:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
_LOGGER.debug("API call: %s (cache_age=%s)",
|
_LOGGER.debug("API call: %s (cache_age=%s)", endpoint, cache_age)
|
||||||
endpoint, cache_age)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Smart Updates
|
### Smart Updates
|
||||||
|
|
@ -243,13 +240,13 @@ async def _async_update_data(self) -> dict:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# ❌ MEASUREMENT for prices (stores every change)
|
# ❌ MEASUREMENT for prices (stores every change)
|
||||||
state_class=SensorStateClass.MEASUREMENT # ~35K records/year
|
state_class = SensorStateClass.MEASUREMENT # ~35K records/year
|
||||||
|
|
||||||
# ✅ None for prices (no long-term stats)
|
# ✅ None for prices (no long-term stats)
|
||||||
state_class=None # Only current state
|
state_class = None # Only current state
|
||||||
|
|
||||||
# ✅ TOTAL for counters only
|
# ✅ TOTAL for counters only
|
||||||
state_class=SensorStateClass.TOTAL # For cumulative values
|
state_class = SensorStateClass.TOTAL # For cumulative values
|
||||||
```
|
```
|
||||||
|
|
||||||
### Attribute Size
|
### Attribute Size
|
||||||
|
|
@ -260,7 +257,7 @@ state_class=SensorStateClass.TOTAL # For cumulative values
|
||||||
# ❌ Large nested structures (KB per update)
|
# ❌ Large nested structures (KB per update)
|
||||||
attributes = {
|
attributes = {
|
||||||
"all_intervals": [...], # 384 intervals
|
"all_intervals": [...], # 384 intervals
|
||||||
"full_history": [...], # Days of data
|
"full_history": [...], # Days of data
|
||||||
}
|
}
|
||||||
|
|
||||||
# ✅ Essential data only (bytes per update)
|
# ✅ Essential data only (bytes per update)
|
||||||
|
|
@ -279,6 +276,7 @@ attributes = {
|
||||||
import pytest
|
import pytest
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.benchmark
|
@pytest.mark.benchmark
|
||||||
def test_period_calculation_performance(coordinator):
|
def test_period_calculation_performance(coordinator):
|
||||||
"""Period calculation should complete in <100ms."""
|
"""Period calculation should complete in <100ms."""
|
||||||
|
|
|
||||||
|
|
@ -1092,8 +1092,8 @@ for price_data in all_prices:
|
||||||
ref_date = date_key
|
ref_date = date_key
|
||||||
|
|
||||||
criteria = TibberPricesIntervalCriteria(
|
criteria = TibberPricesIntervalCriteria(
|
||||||
ref_price=ref_prices[ref_date], # Interval's day
|
ref_price=ref_prices[ref_date], # Interval's day
|
||||||
avg_price=avg_prices[ref_date], # Interval's day
|
avg_price=avg_prices[ref_date], # Interval's day
|
||||||
flex=flex,
|
flex=flex,
|
||||||
min_distance_from_avg=min_distance_from_avg,
|
min_distance_from_avg=min_distance_from_avg,
|
||||||
reverse_sort=reverse_sort,
|
reverse_sort=reverse_sort,
|
||||||
|
|
|
||||||
|
|
@ -69,11 +69,7 @@ API requests are being throttled, causing stale data. Updates may be delayed unt
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# In error handler
|
# In error handler
|
||||||
is_rate_limit = (
|
is_rate_limit = "429" in error_str or "rate limit" in error_str or "too many requests" in error_str
|
||||||
"429" in error_str
|
|
||||||
or "rate limit" in error_str
|
|
||||||
or "too many requests" in error_str
|
|
||||||
)
|
|
||||||
if is_rate_limit:
|
if is_rate_limit:
|
||||||
await self._repair_manager.track_rate_limit_error()
|
await self._repair_manager.track_rate_limit_error()
|
||||||
|
|
||||||
|
|
@ -295,6 +291,7 @@ async def check_new_condition(self, *, param: bool) -> None:
|
||||||
elif not should_warn and self._new_repair_active:
|
elif not should_warn and self._new_repair_active:
|
||||||
await self._clear_new_repair()
|
await self._clear_new_repair()
|
||||||
|
|
||||||
|
|
||||||
async def _create_new_repair(self) -> None:
|
async def _create_new_repair(self) -> None:
|
||||||
"""Create new repair issue."""
|
"""Create new repair issue."""
|
||||||
_LOGGER.warning("New issue detected - creating repair")
|
_LOGGER.warning("New issue detected - creating repair")
|
||||||
|
|
@ -312,6 +309,7 @@ async def _create_new_repair(self) -> None:
|
||||||
)
|
)
|
||||||
self._new_repair_active = True
|
self._new_repair_active = True
|
||||||
|
|
||||||
|
|
||||||
async def _clear_new_repair(self) -> None:
|
async def _clear_new_repair(self) -> None:
|
||||||
"""Clear new repair issue."""
|
"""Clear new repair issue."""
|
||||||
_LOGGER.debug("New issue resolved - clearing repair")
|
_LOGGER.debug("New issue resolved - clearing repair")
|
||||||
|
|
|
||||||
|
|
@ -187,9 +187,8 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
||||||
class TibberPricesSensor(CoordinatorEntity):
|
class TibberPricesSensor(CoordinatorEntity):
|
||||||
async def async_added_to_hass(self):
|
async def async_added_to_hass(self):
|
||||||
# Register this entity's update callback
|
# Register this entity's update callback
|
||||||
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(
|
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(self._handle_coordinator_update)
|
||||||
self._handle_coordinator_update
|
|
||||||
)
|
|
||||||
|
|
||||||
# Coordinator maintains list of listeners
|
# Coordinator maintains list of listeners
|
||||||
class ListenerManager:
|
class ListenerManager:
|
||||||
|
|
@ -390,8 +389,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
# Watch for these log messages:
|
# Watch for these log messages:
|
||||||
"Fetching data from API (reason: tomorrow_check)" # API call
|
"Fetching data from API (reason: tomorrow_check)" # API call
|
||||||
"Using cached data (no update needed)" # Fast path
|
"Using cached data (no update needed)" # Fast path
|
||||||
"Midnight turnover detected (Timer #1)" # Turnover
|
"Midnight turnover detected (Timer #1)" # Turnover
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check Timer #2 (Quarter-Hour)
|
### Check Timer #2 (Quarter-Hour)
|
||||||
|
|
@ -399,7 +398,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
||||||
```python
|
```python
|
||||||
# Watch coordinator logs:
|
# Watch coordinator logs:
|
||||||
"Updated 60 time-sensitive entities at quarter-hour boundary" # Normal
|
"Updated 60 time-sensitive entities at quarter-hour boundary" # Normal
|
||||||
"Midnight turnover detected (Timer #2)" # Turnover
|
|
||||||
|
"Midnight turnover detected (Timer #2)" # Turnover
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check Timer #3 (Minute)
|
### Check Timer #3 (Minute)
|
||||||
|
|
|
||||||
|
|
@ -263,20 +263,16 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Original from Tibber API
|
# Original from Tibber API
|
||||||
{
|
{"startsAt": "2025-11-03T14:00:00+01:00", "total": 0.2534, "level": "NORMAL"}
|
||||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
|
||||||
"total": 0.2534,
|
|
||||||
"level": "NORMAL"
|
|
||||||
}
|
|
||||||
|
|
||||||
# After enrichment (utils/price.py)
|
# After enrichment (utils/price.py)
|
||||||
{
|
{
|
||||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
"startsAt": "2025-11-03T14:00:00+01:00",
|
||||||
"total": 0.2534,
|
"total": 0.2534,
|
||||||
"level": "NORMAL",
|
"level": "NORMAL",
|
||||||
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
||||||
"difference": 9.6, # ← Added: % diff from trailing avg
|
"difference": 9.6, # ← Added: % diff from trailing avg
|
||||||
"rating_level": "NORMAL" # ← Added: LOW/NORMAL/HIGH based on thresholds
|
"rating_level": "NORMAL", # ← Added: LOW/NORMAL/HIGH based on thresholds
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
||||||
```python
|
```python
|
||||||
{
|
{
|
||||||
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
||||||
"peak": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60}
|
"peak": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -178,11 +178,11 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
||||||
|
|
||||||
```python
|
```python
|
||||||
hash_data = (
|
hash_data = (
|
||||||
today_signature, # (startsAt, rating_level) for each interval
|
today_signature, # (startsAt, rating_level) for each interval
|
||||||
tuple(best_config.items()), # Best price config
|
tuple(best_config.items()), # Best price config
|
||||||
tuple(peak_config.items()), # Peak price config
|
tuple(peak_config.items()), # Peak price config
|
||||||
best_level_filter, # Level filter overrides
|
best_level_filter, # Level filter overrides
|
||||||
peak_level_filter
|
peak_level_filter,
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,8 +61,10 @@ If a helper class is ONLY used within a single module file, prefix it with under
|
||||||
# ✅ Private class - used only in this file
|
# ✅ Private class - used only in this file
|
||||||
class _InternalHelper:
|
class _InternalHelper:
|
||||||
"""Helper used only within this module."""
|
"""Helper used only within this module."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ❌ Wrong - no prefix but used across modules
|
# ❌ Wrong - no prefix but used across modules
|
||||||
class DataFetcher: # Should be TibberPricesDataFetcher
|
class DataFetcher: # Should be TibberPricesDataFetcher
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,7 @@ These patterns were analyzed and classified as **not critical**:
|
||||||
coordinator.data = {
|
coordinator.data = {
|
||||||
"user_data": {...},
|
"user_data": {...},
|
||||||
"priceInfo": [...], # Flat list of all enriched intervals
|
"priceInfo": [...], # Flat list of all enriched intervals
|
||||||
"currency": "EUR" # Top-level for easy access
|
"currency": "EUR", # Top-level for easy access
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ def test_something(coordinator):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def test_periods(hass, coordinator):
|
def test_periods(hass, coordinator):
|
||||||
periods = coordinator.data.get('best_price_periods', [])
|
periods = coordinator.data.get("best_price_periods", [])
|
||||||
for period in periods:
|
for period in periods:
|
||||||
print(f"Period: {period['start']} to {period['end']}")
|
print(f"Period: {period['start']} to {period['end']}")
|
||||||
print(f" Intervals: {len(period['intervals'])}")
|
print(f" Intervals: {len(period['intervals'])}")
|
||||||
|
|
@ -147,15 +147,14 @@ grep "tibber_prices" config/home-assistant.log
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# In Developer Tools > Template
|
# In Developer Tools > Template
|
||||||
{{ states.sensor.tibber_home_current_interval_price.last_updated }}
|
{{states.sensor.tibber_home_current_interval_price.last_updated}}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Debug in code:**
|
**Debug in code:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Add logging in sensor/core.py
|
# Add logging in sensor/core.py
|
||||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
_LOGGER.debug("Updating sensor %s: old=%s new=%s", self.entity_id, self._attr_native_value, new_value)
|
||||||
self.entity_id, self._attr_native_value, new_value)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Period Calculation Wrong
|
### Period Calculation Wrong
|
||||||
|
|
@ -164,8 +163,7 @@ _LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# coordinator/period_handlers/period_building.py
|
# coordinator/period_handlers/period_building.py
|
||||||
_LOGGER.debug("Candidate intervals: %s",
|
_LOGGER.debug("Candidate intervals: %s", [(i["startsAt"], i["total"]) for i in candidates])
|
||||||
[(i['startsAt'], i['total']) for i in candidates])
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Check filter statistics:**
|
**Check filter statistics:**
|
||||||
|
|
@ -217,6 +215,7 @@ Add to coordinator code:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import debugpy
|
import debugpy
|
||||||
|
|
||||||
debugpy.listen(5678)
|
debugpy.listen(5678)
|
||||||
_LOGGER.info("Waiting for debugger attach on port 5678")
|
_LOGGER.info("Waiting for debugger attach on port 5678")
|
||||||
debugpy.wait_for_client()
|
debugpy.wait_for_client()
|
||||||
|
|
@ -236,6 +235,7 @@ Add breakpoint:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from IPython import embed
|
from IPython import embed
|
||||||
|
|
||||||
embed() # Drops into interactive shell
|
embed() # Drops into interactive shell
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,7 @@ import tracemalloc
|
||||||
tracemalloc.start()
|
tracemalloc.start()
|
||||||
# Run your code
|
# Run your code
|
||||||
current, peak = tracemalloc.get_traced_memory()
|
current, peak = tracemalloc.get_traced_memory()
|
||||||
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB",
|
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB", current / 1024**2, peak / 1024**2)
|
||||||
current / 1024**2, peak / 1024**2)
|
|
||||||
tracemalloc.stop()
|
tracemalloc.stop()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -78,6 +77,7 @@ data = await store.async_load()
|
||||||
# Already implemented in const.py
|
# Already implemented in const.py
|
||||||
_TRANSLATION_CACHE: dict[str, dict] = {}
|
_TRANSLATION_CACHE: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
def get_translation(path: str, language: str) -> dict:
|
def get_translation(path: str, language: str) -> dict:
|
||||||
cache_key = f"{path}_{language}"
|
cache_key = f"{path}_{language}"
|
||||||
if cache_key not in _TRANSLATION_CACHE:
|
if cache_key not in _TRANSLATION_CACHE:
|
||||||
|
|
@ -139,10 +139,7 @@ user_data = await fetch_user_data()
|
||||||
price_data = await fetch_price_data()
|
price_data = await fetch_price_data()
|
||||||
|
|
||||||
# ✅ Concurrent (fast)
|
# ✅ Concurrent (fast)
|
||||||
user_data, price_data = await asyncio.gather(
|
user_data, price_data = await asyncio.gather(fetch_user_data(), fetch_price_data())
|
||||||
fetch_user_data(),
|
|
||||||
fetch_price_data()
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**2. Don't block event loop:**
|
**2. Don't block event loop:**
|
||||||
|
|
@ -175,6 +172,7 @@ class Coordinator:
|
||||||
```python
|
```python
|
||||||
import weakref
|
import weakref
|
||||||
|
|
||||||
|
|
||||||
class Manager:
|
class Manager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._callbacks: list[weakref.ref] = []
|
self._callbacks: list[weakref.ref] = []
|
||||||
|
|
@ -216,8 +214,7 @@ results = (x for x in items if condition(x))
|
||||||
**Monitor API usage:**
|
**Monitor API usage:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
_LOGGER.debug("API call: %s (cache_age=%s)",
|
_LOGGER.debug("API call: %s (cache_age=%s)", endpoint, cache_age)
|
||||||
endpoint, cache_age)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Smart Updates
|
### Smart Updates
|
||||||
|
|
@ -243,13 +240,13 @@ async def _async_update_data(self) -> dict:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# ❌ MEASUREMENT for prices (stores every change)
|
# ❌ MEASUREMENT for prices (stores every change)
|
||||||
state_class=SensorStateClass.MEASUREMENT # ~35K records/year
|
state_class = SensorStateClass.MEASUREMENT # ~35K records/year
|
||||||
|
|
||||||
# ✅ None for prices (no long-term stats)
|
# ✅ None for prices (no long-term stats)
|
||||||
state_class=None # Only current state
|
state_class = None # Only current state
|
||||||
|
|
||||||
# ✅ TOTAL for counters only
|
# ✅ TOTAL for counters only
|
||||||
state_class=SensorStateClass.TOTAL # For cumulative values
|
state_class = SensorStateClass.TOTAL # For cumulative values
|
||||||
```
|
```
|
||||||
|
|
||||||
### Attribute Size
|
### Attribute Size
|
||||||
|
|
@ -260,7 +257,7 @@ state_class=SensorStateClass.TOTAL # For cumulative values
|
||||||
# ❌ Large nested structures (KB per update)
|
# ❌ Large nested structures (KB per update)
|
||||||
attributes = {
|
attributes = {
|
||||||
"all_intervals": [...], # 384 intervals
|
"all_intervals": [...], # 384 intervals
|
||||||
"full_history": [...], # Days of data
|
"full_history": [...], # Days of data
|
||||||
}
|
}
|
||||||
|
|
||||||
# ✅ Essential data only (bytes per update)
|
# ✅ Essential data only (bytes per update)
|
||||||
|
|
@ -279,6 +276,7 @@ attributes = {
|
||||||
import pytest
|
import pytest
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.benchmark
|
@pytest.mark.benchmark
|
||||||
def test_period_calculation_performance(coordinator):
|
def test_period_calculation_performance(coordinator):
|
||||||
"""Period calculation should complete in <100ms."""
|
"""Period calculation should complete in <100ms."""
|
||||||
|
|
|
||||||
|
|
@ -1092,8 +1092,8 @@ for price_data in all_prices:
|
||||||
ref_date = date_key
|
ref_date = date_key
|
||||||
|
|
||||||
criteria = TibberPricesIntervalCriteria(
|
criteria = TibberPricesIntervalCriteria(
|
||||||
ref_price=ref_prices[ref_date], # Interval's day
|
ref_price=ref_prices[ref_date], # Interval's day
|
||||||
avg_price=avg_prices[ref_date], # Interval's day
|
avg_price=avg_prices[ref_date], # Interval's day
|
||||||
flex=flex,
|
flex=flex,
|
||||||
min_distance_from_avg=min_distance_from_avg,
|
min_distance_from_avg=min_distance_from_avg,
|
||||||
reverse_sort=reverse_sort,
|
reverse_sort=reverse_sort,
|
||||||
|
|
|
||||||
|
|
@ -69,11 +69,7 @@ API requests are being throttled, causing stale data. Updates may be delayed unt
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# In error handler
|
# In error handler
|
||||||
is_rate_limit = (
|
is_rate_limit = "429" in error_str or "rate limit" in error_str or "too many requests" in error_str
|
||||||
"429" in error_str
|
|
||||||
or "rate limit" in error_str
|
|
||||||
or "too many requests" in error_str
|
|
||||||
)
|
|
||||||
if is_rate_limit:
|
if is_rate_limit:
|
||||||
await self._repair_manager.track_rate_limit_error()
|
await self._repair_manager.track_rate_limit_error()
|
||||||
|
|
||||||
|
|
@ -295,6 +291,7 @@ async def check_new_condition(self, *, param: bool) -> None:
|
||||||
elif not should_warn and self._new_repair_active:
|
elif not should_warn and self._new_repair_active:
|
||||||
await self._clear_new_repair()
|
await self._clear_new_repair()
|
||||||
|
|
||||||
|
|
||||||
async def _create_new_repair(self) -> None:
|
async def _create_new_repair(self) -> None:
|
||||||
"""Create new repair issue."""
|
"""Create new repair issue."""
|
||||||
_LOGGER.warning("New issue detected - creating repair")
|
_LOGGER.warning("New issue detected - creating repair")
|
||||||
|
|
@ -312,6 +309,7 @@ async def _create_new_repair(self) -> None:
|
||||||
)
|
)
|
||||||
self._new_repair_active = True
|
self._new_repair_active = True
|
||||||
|
|
||||||
|
|
||||||
async def _clear_new_repair(self) -> None:
|
async def _clear_new_repair(self) -> None:
|
||||||
"""Clear new repair issue."""
|
"""Clear new repair issue."""
|
||||||
_LOGGER.debug("New issue resolved - clearing repair")
|
_LOGGER.debug("New issue resolved - clearing repair")
|
||||||
|
|
|
||||||
|
|
@ -187,9 +187,8 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
||||||
class TibberPricesSensor(CoordinatorEntity):
|
class TibberPricesSensor(CoordinatorEntity):
|
||||||
async def async_added_to_hass(self):
|
async def async_added_to_hass(self):
|
||||||
# Register this entity's update callback
|
# Register this entity's update callback
|
||||||
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(
|
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(self._handle_coordinator_update)
|
||||||
self._handle_coordinator_update
|
|
||||||
)
|
|
||||||
|
|
||||||
# Coordinator maintains list of listeners
|
# Coordinator maintains list of listeners
|
||||||
class ListenerManager:
|
class ListenerManager:
|
||||||
|
|
@ -390,8 +389,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
# Watch for these log messages:
|
# Watch for these log messages:
|
||||||
"Fetching data from API (reason: tomorrow_check)" # API call
|
"Fetching data from API (reason: tomorrow_check)" # API call
|
||||||
"Using cached data (no update needed)" # Fast path
|
"Using cached data (no update needed)" # Fast path
|
||||||
"Midnight turnover detected (Timer #1)" # Turnover
|
"Midnight turnover detected (Timer #1)" # Turnover
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check Timer #2 (Quarter-Hour)
|
### Check Timer #2 (Quarter-Hour)
|
||||||
|
|
@ -399,7 +398,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
||||||
```python
|
```python
|
||||||
# Watch coordinator logs:
|
# Watch coordinator logs:
|
||||||
"Updated 60 time-sensitive entities at quarter-hour boundary" # Normal
|
"Updated 60 time-sensitive entities at quarter-hour boundary" # Normal
|
||||||
"Midnight turnover detected (Timer #2)" # Turnover
|
|
||||||
|
"Midnight turnover detected (Timer #2)" # Turnover
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check Timer #3 (Minute)
|
### Check Timer #3 (Minute)
|
||||||
|
|
|
||||||
|
|
@ -263,20 +263,16 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Original from Tibber API
|
# Original from Tibber API
|
||||||
{
|
{"startsAt": "2025-11-03T14:00:00+01:00", "total": 0.2534, "level": "NORMAL"}
|
||||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
|
||||||
"total": 0.2534,
|
|
||||||
"level": "NORMAL"
|
|
||||||
}
|
|
||||||
|
|
||||||
# After enrichment (utils/price.py)
|
# After enrichment (utils/price.py)
|
||||||
{
|
{
|
||||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
"startsAt": "2025-11-03T14:00:00+01:00",
|
||||||
"total": 0.2534,
|
"total": 0.2534,
|
||||||
"level": "NORMAL",
|
"level": "NORMAL",
|
||||||
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
||||||
"difference": 9.6, # ← Added: % diff from trailing avg
|
"difference": 9.6, # ← Added: % diff from trailing avg
|
||||||
"rating_level": "NORMAL" # ← Added: LOW/NORMAL/HIGH based on thresholds
|
"rating_level": "NORMAL", # ← Added: LOW/NORMAL/HIGH based on thresholds
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
||||||
```python
|
```python
|
||||||
{
|
{
|
||||||
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
||||||
"peak": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60}
|
"peak": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -178,11 +178,11 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
||||||
|
|
||||||
```python
|
```python
|
||||||
hash_data = (
|
hash_data = (
|
||||||
today_signature, # (startsAt, rating_level) for each interval
|
today_signature, # (startsAt, rating_level) for each interval
|
||||||
tuple(best_config.items()), # Best price config
|
tuple(best_config.items()), # Best price config
|
||||||
tuple(peak_config.items()), # Peak price config
|
tuple(peak_config.items()), # Peak price config
|
||||||
best_level_filter, # Level filter overrides
|
best_level_filter, # Level filter overrides
|
||||||
peak_level_filter
|
peak_level_filter,
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,8 +61,10 @@ If a helper class is ONLY used within a single module file, prefix it with under
|
||||||
# ✅ Private class - used only in this file
|
# ✅ Private class - used only in this file
|
||||||
class _InternalHelper:
|
class _InternalHelper:
|
||||||
"""Helper used only within this module."""
|
"""Helper used only within this module."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ❌ Wrong - no prefix but used across modules
|
# ❌ Wrong - no prefix but used across modules
|
||||||
class DataFetcher: # Should be TibberPricesDataFetcher
|
class DataFetcher: # Should be TibberPricesDataFetcher
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,7 @@ These patterns were analyzed and classified as **not critical**:
|
||||||
coordinator.data = {
|
coordinator.data = {
|
||||||
"user_data": {...},
|
"user_data": {...},
|
||||||
"priceInfo": [...], # Flat list of all enriched intervals
|
"priceInfo": [...], # Flat list of all enriched intervals
|
||||||
"currency": "EUR" # Top-level for easy access
|
"currency": "EUR", # Top-level for easy access
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ def test_something(coordinator):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def test_periods(hass, coordinator):
|
def test_periods(hass, coordinator):
|
||||||
periods = coordinator.data.get('best_price_periods', [])
|
periods = coordinator.data.get("best_price_periods", [])
|
||||||
for period in periods:
|
for period in periods:
|
||||||
print(f"Period: {period['start']} to {period['end']}")
|
print(f"Period: {period['start']} to {period['end']}")
|
||||||
print(f" Intervals: {len(period['intervals'])}")
|
print(f" Intervals: {len(period['intervals'])}")
|
||||||
|
|
@ -147,15 +147,14 @@ grep "tibber_prices" config/home-assistant.log
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# In Developer Tools > Template
|
# In Developer Tools > Template
|
||||||
{{ states.sensor.tibber_home_current_interval_price.last_updated }}
|
{{states.sensor.tibber_home_current_interval_price.last_updated}}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Debug in code:**
|
**Debug in code:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Add logging in sensor/core.py
|
# Add logging in sensor/core.py
|
||||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
_LOGGER.debug("Updating sensor %s: old=%s new=%s", self.entity_id, self._attr_native_value, new_value)
|
||||||
self.entity_id, self._attr_native_value, new_value)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Period Calculation Wrong
|
### Period Calculation Wrong
|
||||||
|
|
@ -164,8 +163,7 @@ _LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# coordinator/period_handlers/period_building.py
|
# coordinator/period_handlers/period_building.py
|
||||||
_LOGGER.debug("Candidate intervals: %s",
|
_LOGGER.debug("Candidate intervals: %s", [(i["startsAt"], i["total"]) for i in candidates])
|
||||||
[(i['startsAt'], i['total']) for i in candidates])
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Check filter statistics:**
|
**Check filter statistics:**
|
||||||
|
|
@ -217,6 +215,7 @@ Add to coordinator code:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import debugpy
|
import debugpy
|
||||||
|
|
||||||
debugpy.listen(5678)
|
debugpy.listen(5678)
|
||||||
_LOGGER.info("Waiting for debugger attach on port 5678")
|
_LOGGER.info("Waiting for debugger attach on port 5678")
|
||||||
debugpy.wait_for_client()
|
debugpy.wait_for_client()
|
||||||
|
|
@ -236,6 +235,7 @@ Add breakpoint:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from IPython import embed
|
from IPython import embed
|
||||||
|
|
||||||
embed() # Drops into interactive shell
|
embed() # Drops into interactive shell
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,7 @@ import tracemalloc
|
||||||
tracemalloc.start()
|
tracemalloc.start()
|
||||||
# Run your code
|
# Run your code
|
||||||
current, peak = tracemalloc.get_traced_memory()
|
current, peak = tracemalloc.get_traced_memory()
|
||||||
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB",
|
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB", current / 1024**2, peak / 1024**2)
|
||||||
current / 1024**2, peak / 1024**2)
|
|
||||||
tracemalloc.stop()
|
tracemalloc.stop()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -78,6 +77,7 @@ data = await store.async_load()
|
||||||
# Already implemented in const.py
|
# Already implemented in const.py
|
||||||
_TRANSLATION_CACHE: dict[str, dict] = {}
|
_TRANSLATION_CACHE: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
def get_translation(path: str, language: str) -> dict:
|
def get_translation(path: str, language: str) -> dict:
|
||||||
cache_key = f"{path}_{language}"
|
cache_key = f"{path}_{language}"
|
||||||
if cache_key not in _TRANSLATION_CACHE:
|
if cache_key not in _TRANSLATION_CACHE:
|
||||||
|
|
@ -139,10 +139,7 @@ user_data = await fetch_user_data()
|
||||||
price_data = await fetch_price_data()
|
price_data = await fetch_price_data()
|
||||||
|
|
||||||
# ✅ Concurrent (fast)
|
# ✅ Concurrent (fast)
|
||||||
user_data, price_data = await asyncio.gather(
|
user_data, price_data = await asyncio.gather(fetch_user_data(), fetch_price_data())
|
||||||
fetch_user_data(),
|
|
||||||
fetch_price_data()
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**2. Don't block event loop:**
|
**2. Don't block event loop:**
|
||||||
|
|
@ -175,6 +172,7 @@ class Coordinator:
|
||||||
```python
|
```python
|
||||||
import weakref
|
import weakref
|
||||||
|
|
||||||
|
|
||||||
class Manager:
|
class Manager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._callbacks: list[weakref.ref] = []
|
self._callbacks: list[weakref.ref] = []
|
||||||
|
|
@ -216,8 +214,7 @@ results = (x for x in items if condition(x))
|
||||||
**Monitor API usage:**
|
**Monitor API usage:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
_LOGGER.debug("API call: %s (cache_age=%s)",
|
_LOGGER.debug("API call: %s (cache_age=%s)", endpoint, cache_age)
|
||||||
endpoint, cache_age)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Smart Updates
|
### Smart Updates
|
||||||
|
|
@ -243,13 +240,13 @@ async def _async_update_data(self) -> dict:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# ❌ MEASUREMENT for prices (stores every change)
|
# ❌ MEASUREMENT for prices (stores every change)
|
||||||
state_class=SensorStateClass.MEASUREMENT # ~35K records/year
|
state_class = SensorStateClass.MEASUREMENT # ~35K records/year
|
||||||
|
|
||||||
# ✅ None for prices (no long-term stats)
|
# ✅ None for prices (no long-term stats)
|
||||||
state_class=None # Only current state
|
state_class = None # Only current state
|
||||||
|
|
||||||
# ✅ TOTAL for counters only
|
# ✅ TOTAL for counters only
|
||||||
state_class=SensorStateClass.TOTAL # For cumulative values
|
state_class = SensorStateClass.TOTAL # For cumulative values
|
||||||
```
|
```
|
||||||
|
|
||||||
### Attribute Size
|
### Attribute Size
|
||||||
|
|
@ -260,7 +257,7 @@ state_class=SensorStateClass.TOTAL # For cumulative values
|
||||||
# ❌ Large nested structures (KB per update)
|
# ❌ Large nested structures (KB per update)
|
||||||
attributes = {
|
attributes = {
|
||||||
"all_intervals": [...], # 384 intervals
|
"all_intervals": [...], # 384 intervals
|
||||||
"full_history": [...], # Days of data
|
"full_history": [...], # Days of data
|
||||||
}
|
}
|
||||||
|
|
||||||
# ✅ Essential data only (bytes per update)
|
# ✅ Essential data only (bytes per update)
|
||||||
|
|
@ -279,6 +276,7 @@ attributes = {
|
||||||
import pytest
|
import pytest
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.benchmark
|
@pytest.mark.benchmark
|
||||||
def test_period_calculation_performance(coordinator):
|
def test_period_calculation_performance(coordinator):
|
||||||
"""Period calculation should complete in <100ms."""
|
"""Period calculation should complete in <100ms."""
|
||||||
|
|
|
||||||
|
|
@ -1092,8 +1092,8 @@ for price_data in all_prices:
|
||||||
ref_date = date_key
|
ref_date = date_key
|
||||||
|
|
||||||
criteria = TibberPricesIntervalCriteria(
|
criteria = TibberPricesIntervalCriteria(
|
||||||
ref_price=ref_prices[ref_date], # Interval's day
|
ref_price=ref_prices[ref_date], # Interval's day
|
||||||
avg_price=avg_prices[ref_date], # Interval's day
|
avg_price=avg_prices[ref_date], # Interval's day
|
||||||
flex=flex,
|
flex=flex,
|
||||||
min_distance_from_avg=min_distance_from_avg,
|
min_distance_from_avg=min_distance_from_avg,
|
||||||
reverse_sort=reverse_sort,
|
reverse_sort=reverse_sort,
|
||||||
|
|
|
||||||
|
|
@ -69,11 +69,7 @@ API requests are being throttled, causing stale data. Updates may be delayed unt
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# In error handler
|
# In error handler
|
||||||
is_rate_limit = (
|
is_rate_limit = "429" in error_str or "rate limit" in error_str or "too many requests" in error_str
|
||||||
"429" in error_str
|
|
||||||
or "rate limit" in error_str
|
|
||||||
or "too many requests" in error_str
|
|
||||||
)
|
|
||||||
if is_rate_limit:
|
if is_rate_limit:
|
||||||
await self._repair_manager.track_rate_limit_error()
|
await self._repair_manager.track_rate_limit_error()
|
||||||
|
|
||||||
|
|
@ -295,6 +291,7 @@ async def check_new_condition(self, *, param: bool) -> None:
|
||||||
elif not should_warn and self._new_repair_active:
|
elif not should_warn and self._new_repair_active:
|
||||||
await self._clear_new_repair()
|
await self._clear_new_repair()
|
||||||
|
|
||||||
|
|
||||||
async def _create_new_repair(self) -> None:
|
async def _create_new_repair(self) -> None:
|
||||||
"""Create new repair issue."""
|
"""Create new repair issue."""
|
||||||
_LOGGER.warning("New issue detected - creating repair")
|
_LOGGER.warning("New issue detected - creating repair")
|
||||||
|
|
@ -312,6 +309,7 @@ async def _create_new_repair(self) -> None:
|
||||||
)
|
)
|
||||||
self._new_repair_active = True
|
self._new_repair_active = True
|
||||||
|
|
||||||
|
|
||||||
async def _clear_new_repair(self) -> None:
|
async def _clear_new_repair(self) -> None:
|
||||||
"""Clear new repair issue."""
|
"""Clear new repair issue."""
|
||||||
_LOGGER.debug("New issue resolved - clearing repair")
|
_LOGGER.debug("New issue resolved - clearing repair")
|
||||||
|
|
|
||||||
|
|
@ -187,9 +187,8 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
||||||
class TibberPricesSensor(CoordinatorEntity):
|
class TibberPricesSensor(CoordinatorEntity):
|
||||||
async def async_added_to_hass(self):
|
async def async_added_to_hass(self):
|
||||||
# Register this entity's update callback
|
# Register this entity's update callback
|
||||||
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(
|
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(self._handle_coordinator_update)
|
||||||
self._handle_coordinator_update
|
|
||||||
)
|
|
||||||
|
|
||||||
# Coordinator maintains list of listeners
|
# Coordinator maintains list of listeners
|
||||||
class ListenerManager:
|
class ListenerManager:
|
||||||
|
|
@ -390,8 +389,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
# Watch for these log messages:
|
# Watch for these log messages:
|
||||||
"Fetching data from API (reason: tomorrow_check)" # API call
|
"Fetching data from API (reason: tomorrow_check)" # API call
|
||||||
"Using cached data (no update needed)" # Fast path
|
"Using cached data (no update needed)" # Fast path
|
||||||
"Midnight turnover detected (Timer #1)" # Turnover
|
"Midnight turnover detected (Timer #1)" # Turnover
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check Timer #2 (Quarter-Hour)
|
### Check Timer #2 (Quarter-Hour)
|
||||||
|
|
@ -399,7 +398,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
||||||
```python
|
```python
|
||||||
# Watch coordinator logs:
|
# Watch coordinator logs:
|
||||||
"Updated 60 time-sensitive entities at quarter-hour boundary" # Normal
|
"Updated 60 time-sensitive entities at quarter-hour boundary" # Normal
|
||||||
"Midnight turnover detected (Timer #2)" # Turnover
|
|
||||||
|
"Midnight turnover detected (Timer #2)" # Turnover
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check Timer #3 (Minute)
|
### Check Timer #3 (Minute)
|
||||||
|
|
|
||||||
|
|
@ -263,20 +263,16 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Original from Tibber API
|
# Original from Tibber API
|
||||||
{
|
{"startsAt": "2025-11-03T14:00:00+01:00", "total": 0.2534, "level": "NORMAL"}
|
||||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
|
||||||
"total": 0.2534,
|
|
||||||
"level": "NORMAL"
|
|
||||||
}
|
|
||||||
|
|
||||||
# After enrichment (utils/price.py)
|
# After enrichment (utils/price.py)
|
||||||
{
|
{
|
||||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
"startsAt": "2025-11-03T14:00:00+01:00",
|
||||||
"total": 0.2534,
|
"total": 0.2534,
|
||||||
"level": "NORMAL",
|
"level": "NORMAL",
|
||||||
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
||||||
"difference": 9.6, # ← Added: % diff from trailing avg
|
"difference": 9.6, # ← Added: % diff from trailing avg
|
||||||
"rating_level": "NORMAL" # ← Added: LOW/NORMAL/HIGH based on thresholds
|
"rating_level": "NORMAL", # ← Added: LOW/NORMAL/HIGH based on thresholds
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
||||||
```python
|
```python
|
||||||
{
|
{
|
||||||
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
||||||
"peak": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60}
|
"peak": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -178,11 +178,11 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
||||||
|
|
||||||
```python
|
```python
|
||||||
hash_data = (
|
hash_data = (
|
||||||
today_signature, # (startsAt, rating_level) for each interval
|
today_signature, # (startsAt, rating_level) for each interval
|
||||||
tuple(best_config.items()), # Best price config
|
tuple(best_config.items()), # Best price config
|
||||||
tuple(peak_config.items()), # Peak price config
|
tuple(peak_config.items()), # Peak price config
|
||||||
best_level_filter, # Level filter overrides
|
best_level_filter, # Level filter overrides
|
||||||
peak_level_filter
|
peak_level_filter,
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,8 +61,10 @@ If a helper class is ONLY used within a single module file, prefix it with under
|
||||||
# ✅ Private class - used only in this file
|
# ✅ Private class - used only in this file
|
||||||
class _InternalHelper:
|
class _InternalHelper:
|
||||||
"""Helper used only within this module."""
|
"""Helper used only within this module."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ❌ Wrong - no prefix but used across modules
|
# ❌ Wrong - no prefix but used across modules
|
||||||
class DataFetcher: # Should be TibberPricesDataFetcher
|
class DataFetcher: # Should be TibberPricesDataFetcher
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,7 @@ These patterns were analyzed and classified as **not critical**:
|
||||||
coordinator.data = {
|
coordinator.data = {
|
||||||
"user_data": {...},
|
"user_data": {...},
|
||||||
"priceInfo": [...], # Flat list of all enriched intervals
|
"priceInfo": [...], # Flat list of all enriched intervals
|
||||||
"currency": "EUR" # Top-level for easy access
|
"currency": "EUR", # Top-level for easy access
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ def test_something(coordinator):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def test_periods(hass, coordinator):
|
def test_periods(hass, coordinator):
|
||||||
periods = coordinator.data.get('best_price_periods', [])
|
periods = coordinator.data.get("best_price_periods", [])
|
||||||
for period in periods:
|
for period in periods:
|
||||||
print(f"Period: {period['start']} to {period['end']}")
|
print(f"Period: {period['start']} to {period['end']}")
|
||||||
print(f" Intervals: {len(period['intervals'])}")
|
print(f" Intervals: {len(period['intervals'])}")
|
||||||
|
|
@ -147,15 +147,14 @@ grep "tibber_prices" config/home-assistant.log
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# In Developer Tools > Template
|
# In Developer Tools > Template
|
||||||
{{ states.sensor.tibber_home_current_interval_price.last_updated }}
|
{{states.sensor.tibber_home_current_interval_price.last_updated}}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Debug in code:**
|
**Debug in code:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Add logging in sensor/core.py
|
# Add logging in sensor/core.py
|
||||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
_LOGGER.debug("Updating sensor %s: old=%s new=%s", self.entity_id, self._attr_native_value, new_value)
|
||||||
self.entity_id, self._attr_native_value, new_value)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Period Calculation Wrong
|
### Period Calculation Wrong
|
||||||
|
|
@ -164,8 +163,7 @@ _LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# coordinator/period_handlers/period_building.py
|
# coordinator/period_handlers/period_building.py
|
||||||
_LOGGER.debug("Candidate intervals: %s",
|
_LOGGER.debug("Candidate intervals: %s", [(i["startsAt"], i["total"]) for i in candidates])
|
||||||
[(i['startsAt'], i['total']) for i in candidates])
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Check filter statistics:**
|
**Check filter statistics:**
|
||||||
|
|
@ -217,6 +215,7 @@ Add to coordinator code:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import debugpy
|
import debugpy
|
||||||
|
|
||||||
debugpy.listen(5678)
|
debugpy.listen(5678)
|
||||||
_LOGGER.info("Waiting for debugger attach on port 5678")
|
_LOGGER.info("Waiting for debugger attach on port 5678")
|
||||||
debugpy.wait_for_client()
|
debugpy.wait_for_client()
|
||||||
|
|
@ -236,6 +235,7 @@ Add breakpoint:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from IPython import embed
|
from IPython import embed
|
||||||
|
|
||||||
embed() # Drops into interactive shell
|
embed() # Drops into interactive shell
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,7 @@ import tracemalloc
|
||||||
tracemalloc.start()
|
tracemalloc.start()
|
||||||
# Run your code
|
# Run your code
|
||||||
current, peak = tracemalloc.get_traced_memory()
|
current, peak = tracemalloc.get_traced_memory()
|
||||||
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB",
|
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB", current / 1024**2, peak / 1024**2)
|
||||||
current / 1024**2, peak / 1024**2)
|
|
||||||
tracemalloc.stop()
|
tracemalloc.stop()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -78,6 +77,7 @@ data = await store.async_load()
|
||||||
# Already implemented in const.py
|
# Already implemented in const.py
|
||||||
_TRANSLATION_CACHE: dict[str, dict] = {}
|
_TRANSLATION_CACHE: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
def get_translation(path: str, language: str) -> dict:
|
def get_translation(path: str, language: str) -> dict:
|
||||||
cache_key = f"{path}_{language}"
|
cache_key = f"{path}_{language}"
|
||||||
if cache_key not in _TRANSLATION_CACHE:
|
if cache_key not in _TRANSLATION_CACHE:
|
||||||
|
|
@ -139,10 +139,7 @@ user_data = await fetch_user_data()
|
||||||
price_data = await fetch_price_data()
|
price_data = await fetch_price_data()
|
||||||
|
|
||||||
# ✅ Concurrent (fast)
|
# ✅ Concurrent (fast)
|
||||||
user_data, price_data = await asyncio.gather(
|
user_data, price_data = await asyncio.gather(fetch_user_data(), fetch_price_data())
|
||||||
fetch_user_data(),
|
|
||||||
fetch_price_data()
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**2. Don't block event loop:**
|
**2. Don't block event loop:**
|
||||||
|
|
@ -175,6 +172,7 @@ class Coordinator:
|
||||||
```python
|
```python
|
||||||
import weakref
|
import weakref
|
||||||
|
|
||||||
|
|
||||||
class Manager:
|
class Manager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._callbacks: list[weakref.ref] = []
|
self._callbacks: list[weakref.ref] = []
|
||||||
|
|
@ -216,8 +214,7 @@ results = (x for x in items if condition(x))
|
||||||
**Monitor API usage:**
|
**Monitor API usage:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
_LOGGER.debug("API call: %s (cache_age=%s)",
|
_LOGGER.debug("API call: %s (cache_age=%s)", endpoint, cache_age)
|
||||||
endpoint, cache_age)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Smart Updates
|
### Smart Updates
|
||||||
|
|
@ -243,13 +240,13 @@ async def _async_update_data(self) -> dict:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# ❌ MEASUREMENT for prices (stores every change)
|
# ❌ MEASUREMENT for prices (stores every change)
|
||||||
state_class=SensorStateClass.MEASUREMENT # ~35K records/year
|
state_class = SensorStateClass.MEASUREMENT # ~35K records/year
|
||||||
|
|
||||||
# ✅ None for prices (no long-term stats)
|
# ✅ None for prices (no long-term stats)
|
||||||
state_class=None # Only current state
|
state_class = None # Only current state
|
||||||
|
|
||||||
# ✅ TOTAL for counters only
|
# ✅ TOTAL for counters only
|
||||||
state_class=SensorStateClass.TOTAL # For cumulative values
|
state_class = SensorStateClass.TOTAL # For cumulative values
|
||||||
```
|
```
|
||||||
|
|
||||||
### Attribute Size
|
### Attribute Size
|
||||||
|
|
@ -260,7 +257,7 @@ state_class=SensorStateClass.TOTAL # For cumulative values
|
||||||
# ❌ Large nested structures (KB per update)
|
# ❌ Large nested structures (KB per update)
|
||||||
attributes = {
|
attributes = {
|
||||||
"all_intervals": [...], # 384 intervals
|
"all_intervals": [...], # 384 intervals
|
||||||
"full_history": [...], # Days of data
|
"full_history": [...], # Days of data
|
||||||
}
|
}
|
||||||
|
|
||||||
# ✅ Essential data only (bytes per update)
|
# ✅ Essential data only (bytes per update)
|
||||||
|
|
@ -279,6 +276,7 @@ attributes = {
|
||||||
import pytest
|
import pytest
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.benchmark
|
@pytest.mark.benchmark
|
||||||
def test_period_calculation_performance(coordinator):
|
def test_period_calculation_performance(coordinator):
|
||||||
"""Period calculation should complete in <100ms."""
|
"""Period calculation should complete in <100ms."""
|
||||||
|
|
|
||||||
|
|
@ -1092,8 +1092,8 @@ for price_data in all_prices:
|
||||||
ref_date = date_key
|
ref_date = date_key
|
||||||
|
|
||||||
criteria = TibberPricesIntervalCriteria(
|
criteria = TibberPricesIntervalCriteria(
|
||||||
ref_price=ref_prices[ref_date], # Interval's day
|
ref_price=ref_prices[ref_date], # Interval's day
|
||||||
avg_price=avg_prices[ref_date], # Interval's day
|
avg_price=avg_prices[ref_date], # Interval's day
|
||||||
flex=flex,
|
flex=flex,
|
||||||
min_distance_from_avg=min_distance_from_avg,
|
min_distance_from_avg=min_distance_from_avg,
|
||||||
reverse_sort=reverse_sort,
|
reverse_sort=reverse_sort,
|
||||||
|
|
|
||||||
|
|
@ -69,11 +69,7 @@ API requests are being throttled, causing stale data. Updates may be delayed unt
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# In error handler
|
# In error handler
|
||||||
is_rate_limit = (
|
is_rate_limit = "429" in error_str or "rate limit" in error_str or "too many requests" in error_str
|
||||||
"429" in error_str
|
|
||||||
or "rate limit" in error_str
|
|
||||||
or "too many requests" in error_str
|
|
||||||
)
|
|
||||||
if is_rate_limit:
|
if is_rate_limit:
|
||||||
await self._repair_manager.track_rate_limit_error()
|
await self._repair_manager.track_rate_limit_error()
|
||||||
|
|
||||||
|
|
@ -295,6 +291,7 @@ async def check_new_condition(self, *, param: bool) -> None:
|
||||||
elif not should_warn and self._new_repair_active:
|
elif not should_warn and self._new_repair_active:
|
||||||
await self._clear_new_repair()
|
await self._clear_new_repair()
|
||||||
|
|
||||||
|
|
||||||
async def _create_new_repair(self) -> None:
|
async def _create_new_repair(self) -> None:
|
||||||
"""Create new repair issue."""
|
"""Create new repair issue."""
|
||||||
_LOGGER.warning("New issue detected - creating repair")
|
_LOGGER.warning("New issue detected - creating repair")
|
||||||
|
|
@ -312,6 +309,7 @@ async def _create_new_repair(self) -> None:
|
||||||
)
|
)
|
||||||
self._new_repair_active = True
|
self._new_repair_active = True
|
||||||
|
|
||||||
|
|
||||||
async def _clear_new_repair(self) -> None:
|
async def _clear_new_repair(self) -> None:
|
||||||
"""Clear new repair issue."""
|
"""Clear new repair issue."""
|
||||||
_LOGGER.debug("New issue resolved - clearing repair")
|
_LOGGER.debug("New issue resolved - clearing repair")
|
||||||
|
|
|
||||||
|
|
@ -187,9 +187,8 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
||||||
class TibberPricesSensor(CoordinatorEntity):
|
class TibberPricesSensor(CoordinatorEntity):
|
||||||
async def async_added_to_hass(self):
|
async def async_added_to_hass(self):
|
||||||
# Register this entity's update callback
|
# Register this entity's update callback
|
||||||
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(
|
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(self._handle_coordinator_update)
|
||||||
self._handle_coordinator_update
|
|
||||||
)
|
|
||||||
|
|
||||||
# Coordinator maintains list of listeners
|
# Coordinator maintains list of listeners
|
||||||
class ListenerManager:
|
class ListenerManager:
|
||||||
|
|
@ -390,8 +389,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
# Watch for these log messages:
|
# Watch for these log messages:
|
||||||
"Fetching data from API (reason: tomorrow_check)" # API call
|
"Fetching data from API (reason: tomorrow_check)" # API call
|
||||||
"Using cached data (no update needed)" # Fast path
|
"Using cached data (no update needed)" # Fast path
|
||||||
"Midnight turnover detected (Timer #1)" # Turnover
|
"Midnight turnover detected (Timer #1)" # Turnover
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check Timer #2 (Quarter-Hour)
|
### Check Timer #2 (Quarter-Hour)
|
||||||
|
|
@ -399,7 +398,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
||||||
```python
|
```python
|
||||||
# Watch coordinator logs:
|
# Watch coordinator logs:
|
||||||
"Updated 60 time-sensitive entities at quarter-hour boundary" # Normal
|
"Updated 60 time-sensitive entities at quarter-hour boundary" # Normal
|
||||||
"Midnight turnover detected (Timer #2)" # Turnover
|
|
||||||
|
"Midnight turnover detected (Timer #2)" # Turnover
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check Timer #3 (Minute)
|
### Check Timer #3 (Minute)
|
||||||
|
|
|
||||||
|
|
@ -263,20 +263,16 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Original from Tibber API
|
# Original from Tibber API
|
||||||
{
|
{"startsAt": "2025-11-03T14:00:00+01:00", "total": 0.2534, "level": "NORMAL"}
|
||||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
|
||||||
"total": 0.2534,
|
|
||||||
"level": "NORMAL"
|
|
||||||
}
|
|
||||||
|
|
||||||
# After enrichment (utils/price.py)
|
# After enrichment (utils/price.py)
|
||||||
{
|
{
|
||||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
"startsAt": "2025-11-03T14:00:00+01:00",
|
||||||
"total": 0.2534,
|
"total": 0.2534,
|
||||||
"level": "NORMAL",
|
"level": "NORMAL",
|
||||||
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
||||||
"difference": 9.6, # ← Added: % diff from trailing avg
|
"difference": 9.6, # ← Added: % diff from trailing avg
|
||||||
"rating_level": "NORMAL" # ← Added: LOW/NORMAL/HIGH based on thresholds
|
"rating_level": "NORMAL", # ← Added: LOW/NORMAL/HIGH based on thresholds
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
||||||
```python
|
```python
|
||||||
{
|
{
|
||||||
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
||||||
"peak": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60}
|
"peak": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -178,11 +178,11 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
||||||
|
|
||||||
```python
|
```python
|
||||||
hash_data = (
|
hash_data = (
|
||||||
today_signature, # (startsAt, rating_level) for each interval
|
today_signature, # (startsAt, rating_level) for each interval
|
||||||
tuple(best_config.items()), # Best price config
|
tuple(best_config.items()), # Best price config
|
||||||
tuple(peak_config.items()), # Peak price config
|
tuple(peak_config.items()), # Peak price config
|
||||||
best_level_filter, # Level filter overrides
|
best_level_filter, # Level filter overrides
|
||||||
peak_level_filter
|
peak_level_filter,
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,8 +61,10 @@ If a helper class is ONLY used within a single module file, prefix it with under
|
||||||
# ✅ Private class - used only in this file
|
# ✅ Private class - used only in this file
|
||||||
class _InternalHelper:
|
class _InternalHelper:
|
||||||
"""Helper used only within this module."""
|
"""Helper used only within this module."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ❌ Wrong - no prefix but used across modules
|
# ❌ Wrong - no prefix but used across modules
|
||||||
class DataFetcher: # Should be TibberPricesDataFetcher
|
class DataFetcher: # Should be TibberPricesDataFetcher
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,7 @@ These patterns were analyzed and classified as **not critical**:
|
||||||
coordinator.data = {
|
coordinator.data = {
|
||||||
"user_data": {...},
|
"user_data": {...},
|
||||||
"priceInfo": [...], # Flat list of all enriched intervals
|
"priceInfo": [...], # Flat list of all enriched intervals
|
||||||
"currency": "EUR" # Top-level for easy access
|
"currency": "EUR", # Top-level for easy access
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ def test_something(coordinator):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def test_periods(hass, coordinator):
|
def test_periods(hass, coordinator):
|
||||||
periods = coordinator.data.get('best_price_periods', [])
|
periods = coordinator.data.get("best_price_periods", [])
|
||||||
for period in periods:
|
for period in periods:
|
||||||
print(f"Period: {period['start']} to {period['end']}")
|
print(f"Period: {period['start']} to {period['end']}")
|
||||||
print(f" Intervals: {len(period['intervals'])}")
|
print(f" Intervals: {len(period['intervals'])}")
|
||||||
|
|
@ -147,15 +147,14 @@ grep "tibber_prices" config/home-assistant.log
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# In Developer Tools > Template
|
# In Developer Tools > Template
|
||||||
{{ states.sensor.tibber_home_current_interval_price.last_updated }}
|
{{states.sensor.tibber_home_current_interval_price.last_updated}}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Debug in code:**
|
**Debug in code:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Add logging in sensor/core.py
|
# Add logging in sensor/core.py
|
||||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
_LOGGER.debug("Updating sensor %s: old=%s new=%s", self.entity_id, self._attr_native_value, new_value)
|
||||||
self.entity_id, self._attr_native_value, new_value)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Period Calculation Wrong
|
### Period Calculation Wrong
|
||||||
|
|
@ -164,8 +163,7 @@ _LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# coordinator/period_handlers/period_building.py
|
# coordinator/period_handlers/period_building.py
|
||||||
_LOGGER.debug("Candidate intervals: %s",
|
_LOGGER.debug("Candidate intervals: %s", [(i["startsAt"], i["total"]) for i in candidates])
|
||||||
[(i['startsAt'], i['total']) for i in candidates])
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Check filter statistics:**
|
**Check filter statistics:**
|
||||||
|
|
@ -217,6 +215,7 @@ Add to coordinator code:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import debugpy
|
import debugpy
|
||||||
|
|
||||||
debugpy.listen(5678)
|
debugpy.listen(5678)
|
||||||
_LOGGER.info("Waiting for debugger attach on port 5678")
|
_LOGGER.info("Waiting for debugger attach on port 5678")
|
||||||
debugpy.wait_for_client()
|
debugpy.wait_for_client()
|
||||||
|
|
@ -236,6 +235,7 @@ Add breakpoint:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from IPython import embed
|
from IPython import embed
|
||||||
|
|
||||||
embed() # Drops into interactive shell
|
embed() # Drops into interactive shell
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,7 @@ import tracemalloc
|
||||||
tracemalloc.start()
|
tracemalloc.start()
|
||||||
# Run your code
|
# Run your code
|
||||||
current, peak = tracemalloc.get_traced_memory()
|
current, peak = tracemalloc.get_traced_memory()
|
||||||
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB",
|
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB", current / 1024**2, peak / 1024**2)
|
||||||
current / 1024**2, peak / 1024**2)
|
|
||||||
tracemalloc.stop()
|
tracemalloc.stop()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -78,6 +77,7 @@ data = await store.async_load()
|
||||||
# Already implemented in const.py
|
# Already implemented in const.py
|
||||||
_TRANSLATION_CACHE: dict[str, dict] = {}
|
_TRANSLATION_CACHE: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
def get_translation(path: str, language: str) -> dict:
|
def get_translation(path: str, language: str) -> dict:
|
||||||
cache_key = f"{path}_{language}"
|
cache_key = f"{path}_{language}"
|
||||||
if cache_key not in _TRANSLATION_CACHE:
|
if cache_key not in _TRANSLATION_CACHE:
|
||||||
|
|
@ -139,10 +139,7 @@ user_data = await fetch_user_data()
|
||||||
price_data = await fetch_price_data()
|
price_data = await fetch_price_data()
|
||||||
|
|
||||||
# ✅ Concurrent (fast)
|
# ✅ Concurrent (fast)
|
||||||
user_data, price_data = await asyncio.gather(
|
user_data, price_data = await asyncio.gather(fetch_user_data(), fetch_price_data())
|
||||||
fetch_user_data(),
|
|
||||||
fetch_price_data()
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**2. Don't block event loop:**
|
**2. Don't block event loop:**
|
||||||
|
|
@ -175,6 +172,7 @@ class Coordinator:
|
||||||
```python
|
```python
|
||||||
import weakref
|
import weakref
|
||||||
|
|
||||||
|
|
||||||
class Manager:
|
class Manager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._callbacks: list[weakref.ref] = []
|
self._callbacks: list[weakref.ref] = []
|
||||||
|
|
@ -216,8 +214,7 @@ results = (x for x in items if condition(x))
|
||||||
**Monitor API usage:**
|
**Monitor API usage:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
_LOGGER.debug("API call: %s (cache_age=%s)",
|
_LOGGER.debug("API call: %s (cache_age=%s)", endpoint, cache_age)
|
||||||
endpoint, cache_age)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Smart Updates
|
### Smart Updates
|
||||||
|
|
@ -243,13 +240,13 @@ async def _async_update_data(self) -> dict:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# ❌ MEASUREMENT for prices (stores every change)
|
# ❌ MEASUREMENT for prices (stores every change)
|
||||||
state_class=SensorStateClass.MEASUREMENT # ~35K records/year
|
state_class = SensorStateClass.MEASUREMENT # ~35K records/year
|
||||||
|
|
||||||
# ✅ None for prices (no long-term stats)
|
# ✅ None for prices (no long-term stats)
|
||||||
state_class=None # Only current state
|
state_class = None # Only current state
|
||||||
|
|
||||||
# ✅ TOTAL for counters only
|
# ✅ TOTAL for counters only
|
||||||
state_class=SensorStateClass.TOTAL # For cumulative values
|
state_class = SensorStateClass.TOTAL # For cumulative values
|
||||||
```
|
```
|
||||||
|
|
||||||
### Attribute Size
|
### Attribute Size
|
||||||
|
|
@ -260,7 +257,7 @@ state_class=SensorStateClass.TOTAL # For cumulative values
|
||||||
# ❌ Large nested structures (KB per update)
|
# ❌ Large nested structures (KB per update)
|
||||||
attributes = {
|
attributes = {
|
||||||
"all_intervals": [...], # 384 intervals
|
"all_intervals": [...], # 384 intervals
|
||||||
"full_history": [...], # Days of data
|
"full_history": [...], # Days of data
|
||||||
}
|
}
|
||||||
|
|
||||||
# ✅ Essential data only (bytes per update)
|
# ✅ Essential data only (bytes per update)
|
||||||
|
|
@ -279,6 +276,7 @@ attributes = {
|
||||||
import pytest
|
import pytest
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.benchmark
|
@pytest.mark.benchmark
|
||||||
def test_period_calculation_performance(coordinator):
|
def test_period_calculation_performance(coordinator):
|
||||||
"""Period calculation should complete in <100ms."""
|
"""Period calculation should complete in <100ms."""
|
||||||
|
|
|
||||||
|
|
@ -1092,8 +1092,8 @@ for price_data in all_prices:
|
||||||
ref_date = date_key
|
ref_date = date_key
|
||||||
|
|
||||||
criteria = TibberPricesIntervalCriteria(
|
criteria = TibberPricesIntervalCriteria(
|
||||||
ref_price=ref_prices[ref_date], # Interval's day
|
ref_price=ref_prices[ref_date], # Interval's day
|
||||||
avg_price=avg_prices[ref_date], # Interval's day
|
avg_price=avg_prices[ref_date], # Interval's day
|
||||||
flex=flex,
|
flex=flex,
|
||||||
min_distance_from_avg=min_distance_from_avg,
|
min_distance_from_avg=min_distance_from_avg,
|
||||||
reverse_sort=reverse_sort,
|
reverse_sort=reverse_sort,
|
||||||
|
|
|
||||||
|
|
@ -69,11 +69,7 @@ API requests are being throttled, causing stale data. Updates may be delayed unt
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# In error handler
|
# In error handler
|
||||||
is_rate_limit = (
|
is_rate_limit = "429" in error_str or "rate limit" in error_str or "too many requests" in error_str
|
||||||
"429" in error_str
|
|
||||||
or "rate limit" in error_str
|
|
||||||
or "too many requests" in error_str
|
|
||||||
)
|
|
||||||
if is_rate_limit:
|
if is_rate_limit:
|
||||||
await self._repair_manager.track_rate_limit_error()
|
await self._repair_manager.track_rate_limit_error()
|
||||||
|
|
||||||
|
|
@ -295,6 +291,7 @@ async def check_new_condition(self, *, param: bool) -> None:
|
||||||
elif not should_warn and self._new_repair_active:
|
elif not should_warn and self._new_repair_active:
|
||||||
await self._clear_new_repair()
|
await self._clear_new_repair()
|
||||||
|
|
||||||
|
|
||||||
async def _create_new_repair(self) -> None:
|
async def _create_new_repair(self) -> None:
|
||||||
"""Create new repair issue."""
|
"""Create new repair issue."""
|
||||||
_LOGGER.warning("New issue detected - creating repair")
|
_LOGGER.warning("New issue detected - creating repair")
|
||||||
|
|
@ -312,6 +309,7 @@ async def _create_new_repair(self) -> None:
|
||||||
)
|
)
|
||||||
self._new_repair_active = True
|
self._new_repair_active = True
|
||||||
|
|
||||||
|
|
||||||
async def _clear_new_repair(self) -> None:
|
async def _clear_new_repair(self) -> None:
|
||||||
"""Clear new repair issue."""
|
"""Clear new repair issue."""
|
||||||
_LOGGER.debug("New issue resolved - clearing repair")
|
_LOGGER.debug("New issue resolved - clearing repair")
|
||||||
|
|
|
||||||
|
|
@ -187,9 +187,8 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
||||||
class TibberPricesSensor(CoordinatorEntity):
|
class TibberPricesSensor(CoordinatorEntity):
|
||||||
async def async_added_to_hass(self):
|
async def async_added_to_hass(self):
|
||||||
# Register this entity's update callback
|
# Register this entity's update callback
|
||||||
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(
|
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(self._handle_coordinator_update)
|
||||||
self._handle_coordinator_update
|
|
||||||
)
|
|
||||||
|
|
||||||
# Coordinator maintains list of listeners
|
# Coordinator maintains list of listeners
|
||||||
class ListenerManager:
|
class ListenerManager:
|
||||||
|
|
@ -390,8 +389,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
# Watch for these log messages:
|
# Watch for these log messages:
|
||||||
"Fetching data from API (reason: tomorrow_check)" # API call
|
"Fetching data from API (reason: tomorrow_check)" # API call
|
||||||
"Using cached data (no update needed)" # Fast path
|
"Using cached data (no update needed)" # Fast path
|
||||||
"Midnight turnover detected (Timer #1)" # Turnover
|
"Midnight turnover detected (Timer #1)" # Turnover
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check Timer #2 (Quarter-Hour)
|
### Check Timer #2 (Quarter-Hour)
|
||||||
|
|
@ -399,7 +398,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
||||||
```python
|
```python
|
||||||
# Watch coordinator logs:
|
# Watch coordinator logs:
|
||||||
"Updated 60 time-sensitive entities at quarter-hour boundary" # Normal
|
"Updated 60 time-sensitive entities at quarter-hour boundary" # Normal
|
||||||
"Midnight turnover detected (Timer #2)" # Turnover
|
|
||||||
|
"Midnight turnover detected (Timer #2)" # Turnover
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check Timer #3 (Minute)
|
### Check Timer #3 (Minute)
|
||||||
|
|
|
||||||
|
|
@ -263,20 +263,16 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Original from Tibber API
|
# Original from Tibber API
|
||||||
{
|
{"startsAt": "2025-11-03T14:00:00+01:00", "total": 0.2534, "level": "NORMAL"}
|
||||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
|
||||||
"total": 0.2534,
|
|
||||||
"level": "NORMAL"
|
|
||||||
}
|
|
||||||
|
|
||||||
# After enrichment (utils/price.py)
|
# After enrichment (utils/price.py)
|
||||||
{
|
{
|
||||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
"startsAt": "2025-11-03T14:00:00+01:00",
|
||||||
"total": 0.2534,
|
"total": 0.2534,
|
||||||
"level": "NORMAL",
|
"level": "NORMAL",
|
||||||
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
||||||
"difference": 9.6, # ← Added: % diff from trailing avg
|
"difference": 9.6, # ← Added: % diff from trailing avg
|
||||||
"rating_level": "NORMAL" # ← Added: LOW/NORMAL/HIGH based on thresholds
|
"rating_level": "NORMAL", # ← Added: LOW/NORMAL/HIGH based on thresholds
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
||||||
```python
|
```python
|
||||||
{
|
{
|
||||||
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
||||||
"peak": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60}
|
"peak": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -178,11 +178,11 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
||||||
|
|
||||||
```python
|
```python
|
||||||
hash_data = (
|
hash_data = (
|
||||||
today_signature, # (startsAt, rating_level) for each interval
|
today_signature, # (startsAt, rating_level) for each interval
|
||||||
tuple(best_config.items()), # Best price config
|
tuple(best_config.items()), # Best price config
|
||||||
tuple(peak_config.items()), # Peak price config
|
tuple(peak_config.items()), # Peak price config
|
||||||
best_level_filter, # Level filter overrides
|
best_level_filter, # Level filter overrides
|
||||||
peak_level_filter
|
peak_level_filter,
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,8 +61,10 @@ If a helper class is ONLY used within a single module file, prefix it with under
|
||||||
# ✅ Private class - used only in this file
|
# ✅ Private class - used only in this file
|
||||||
class _InternalHelper:
|
class _InternalHelper:
|
||||||
"""Helper used only within this module."""
|
"""Helper used only within this module."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ❌ Wrong - no prefix but used across modules
|
# ❌ Wrong - no prefix but used across modules
|
||||||
class DataFetcher: # Should be TibberPricesDataFetcher
|
class DataFetcher: # Should be TibberPricesDataFetcher
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,7 @@ These patterns were analyzed and classified as **not critical**:
|
||||||
coordinator.data = {
|
coordinator.data = {
|
||||||
"user_data": {...},
|
"user_data": {...},
|
||||||
"priceInfo": [...], # Flat list of all enriched intervals
|
"priceInfo": [...], # Flat list of all enriched intervals
|
||||||
"currency": "EUR" # Top-level for easy access
|
"currency": "EUR", # Top-level for easy access
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ def test_something(coordinator):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def test_periods(hass, coordinator):
|
def test_periods(hass, coordinator):
|
||||||
periods = coordinator.data.get('best_price_periods', [])
|
periods = coordinator.data.get("best_price_periods", [])
|
||||||
for period in periods:
|
for period in periods:
|
||||||
print(f"Period: {period['start']} to {period['end']}")
|
print(f"Period: {period['start']} to {period['end']}")
|
||||||
print(f" Intervals: {len(period['intervals'])}")
|
print(f" Intervals: {len(period['intervals'])}")
|
||||||
|
|
@ -147,15 +147,14 @@ grep "tibber_prices" config/home-assistant.log
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# In Developer Tools > Template
|
# In Developer Tools > Template
|
||||||
{{ states.sensor.tibber_home_current_interval_price.last_updated }}
|
{{states.sensor.tibber_home_current_interval_price.last_updated}}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Debug in code:**
|
**Debug in code:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Add logging in sensor/core.py
|
# Add logging in sensor/core.py
|
||||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
_LOGGER.debug("Updating sensor %s: old=%s new=%s", self.entity_id, self._attr_native_value, new_value)
|
||||||
self.entity_id, self._attr_native_value, new_value)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Period Calculation Wrong
|
### Period Calculation Wrong
|
||||||
|
|
@ -164,8 +163,7 @@ _LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# coordinator/period_handlers/period_building.py
|
# coordinator/period_handlers/period_building.py
|
||||||
_LOGGER.debug("Candidate intervals: %s",
|
_LOGGER.debug("Candidate intervals: %s", [(i["startsAt"], i["total"]) for i in candidates])
|
||||||
[(i['startsAt'], i['total']) for i in candidates])
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Check filter statistics:**
|
**Check filter statistics:**
|
||||||
|
|
@ -217,6 +215,7 @@ Add to coordinator code:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import debugpy
|
import debugpy
|
||||||
|
|
||||||
debugpy.listen(5678)
|
debugpy.listen(5678)
|
||||||
_LOGGER.info("Waiting for debugger attach on port 5678")
|
_LOGGER.info("Waiting for debugger attach on port 5678")
|
||||||
debugpy.wait_for_client()
|
debugpy.wait_for_client()
|
||||||
|
|
@ -236,6 +235,7 @@ Add breakpoint:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from IPython import embed
|
from IPython import embed
|
||||||
|
|
||||||
embed() # Drops into interactive shell
|
embed() # Drops into interactive shell
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,7 @@ import tracemalloc
|
||||||
tracemalloc.start()
|
tracemalloc.start()
|
||||||
# Run your code
|
# Run your code
|
||||||
current, peak = tracemalloc.get_traced_memory()
|
current, peak = tracemalloc.get_traced_memory()
|
||||||
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB",
|
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB", current / 1024**2, peak / 1024**2)
|
||||||
current / 1024**2, peak / 1024**2)
|
|
||||||
tracemalloc.stop()
|
tracemalloc.stop()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -78,6 +77,7 @@ data = await store.async_load()
|
||||||
# Already implemented in const.py
|
# Already implemented in const.py
|
||||||
_TRANSLATION_CACHE: dict[str, dict] = {}
|
_TRANSLATION_CACHE: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
def get_translation(path: str, language: str) -> dict:
|
def get_translation(path: str, language: str) -> dict:
|
||||||
cache_key = f"{path}_{language}"
|
cache_key = f"{path}_{language}"
|
||||||
if cache_key not in _TRANSLATION_CACHE:
|
if cache_key not in _TRANSLATION_CACHE:
|
||||||
|
|
@ -139,10 +139,7 @@ user_data = await fetch_user_data()
|
||||||
price_data = await fetch_price_data()
|
price_data = await fetch_price_data()
|
||||||
|
|
||||||
# ✅ Concurrent (fast)
|
# ✅ Concurrent (fast)
|
||||||
user_data, price_data = await asyncio.gather(
|
user_data, price_data = await asyncio.gather(fetch_user_data(), fetch_price_data())
|
||||||
fetch_user_data(),
|
|
||||||
fetch_price_data()
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**2. Don't block event loop:**
|
**2. Don't block event loop:**
|
||||||
|
|
@ -175,6 +172,7 @@ class Coordinator:
|
||||||
```python
|
```python
|
||||||
import weakref
|
import weakref
|
||||||
|
|
||||||
|
|
||||||
class Manager:
|
class Manager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._callbacks: list[weakref.ref] = []
|
self._callbacks: list[weakref.ref] = []
|
||||||
|
|
@ -216,8 +214,7 @@ results = (x for x in items if condition(x))
|
||||||
**Monitor API usage:**
|
**Monitor API usage:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
_LOGGER.debug("API call: %s (cache_age=%s)",
|
_LOGGER.debug("API call: %s (cache_age=%s)", endpoint, cache_age)
|
||||||
endpoint, cache_age)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Smart Updates
|
### Smart Updates
|
||||||
|
|
@ -243,13 +240,13 @@ async def _async_update_data(self) -> dict:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# ❌ MEASUREMENT for prices (stores every change)
|
# ❌ MEASUREMENT for prices (stores every change)
|
||||||
state_class=SensorStateClass.MEASUREMENT # ~35K records/year
|
state_class = SensorStateClass.MEASUREMENT # ~35K records/year
|
||||||
|
|
||||||
# ✅ None for prices (no long-term stats)
|
# ✅ None for prices (no long-term stats)
|
||||||
state_class=None # Only current state
|
state_class = None # Only current state
|
||||||
|
|
||||||
# ✅ TOTAL for counters only
|
# ✅ TOTAL for counters only
|
||||||
state_class=SensorStateClass.TOTAL # For cumulative values
|
state_class = SensorStateClass.TOTAL # For cumulative values
|
||||||
```
|
```
|
||||||
|
|
||||||
### Attribute Size
|
### Attribute Size
|
||||||
|
|
@ -260,7 +257,7 @@ state_class=SensorStateClass.TOTAL # For cumulative values
|
||||||
# ❌ Large nested structures (KB per update)
|
# ❌ Large nested structures (KB per update)
|
||||||
attributes = {
|
attributes = {
|
||||||
"all_intervals": [...], # 384 intervals
|
"all_intervals": [...], # 384 intervals
|
||||||
"full_history": [...], # Days of data
|
"full_history": [...], # Days of data
|
||||||
}
|
}
|
||||||
|
|
||||||
# ✅ Essential data only (bytes per update)
|
# ✅ Essential data only (bytes per update)
|
||||||
|
|
@ -279,6 +276,7 @@ attributes = {
|
||||||
import pytest
|
import pytest
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.benchmark
|
@pytest.mark.benchmark
|
||||||
def test_period_calculation_performance(coordinator):
|
def test_period_calculation_performance(coordinator):
|
||||||
"""Period calculation should complete in <100ms."""
|
"""Period calculation should complete in <100ms."""
|
||||||
|
|
|
||||||
|
|
@ -1246,8 +1246,8 @@ for price_data in all_prices:
|
||||||
ref_date = date_key
|
ref_date = date_key
|
||||||
|
|
||||||
criteria = TibberPricesIntervalCriteria(
|
criteria = TibberPricesIntervalCriteria(
|
||||||
ref_price=ref_prices[ref_date], # Interval's day
|
ref_price=ref_prices[ref_date], # Interval's day
|
||||||
avg_price=avg_prices[ref_date], # Interval's day
|
avg_price=avg_prices[ref_date], # Interval's day
|
||||||
flex=flex,
|
flex=flex,
|
||||||
min_distance_from_avg=min_distance_from_avg,
|
min_distance_from_avg=min_distance_from_avg,
|
||||||
reverse_sort=reverse_sort,
|
reverse_sort=reverse_sort,
|
||||||
|
|
|
||||||
|
|
@ -69,11 +69,7 @@ API requests are being throttled, causing stale data. Updates may be delayed unt
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# In error handler
|
# In error handler
|
||||||
is_rate_limit = (
|
is_rate_limit = "429" in error_str or "rate limit" in error_str or "too many requests" in error_str
|
||||||
"429" in error_str
|
|
||||||
or "rate limit" in error_str
|
|
||||||
or "too many requests" in error_str
|
|
||||||
)
|
|
||||||
if is_rate_limit:
|
if is_rate_limit:
|
||||||
await self._repair_manager.track_rate_limit_error()
|
await self._repair_manager.track_rate_limit_error()
|
||||||
|
|
||||||
|
|
@ -295,6 +291,7 @@ async def check_new_condition(self, *, param: bool) -> None:
|
||||||
elif not should_warn and self._new_repair_active:
|
elif not should_warn and self._new_repair_active:
|
||||||
await self._clear_new_repair()
|
await self._clear_new_repair()
|
||||||
|
|
||||||
|
|
||||||
async def _create_new_repair(self) -> None:
|
async def _create_new_repair(self) -> None:
|
||||||
"""Create new repair issue."""
|
"""Create new repair issue."""
|
||||||
_LOGGER.warning("New issue detected - creating repair")
|
_LOGGER.warning("New issue detected - creating repair")
|
||||||
|
|
@ -312,6 +309,7 @@ async def _create_new_repair(self) -> None:
|
||||||
)
|
)
|
||||||
self._new_repair_active = True
|
self._new_repair_active = True
|
||||||
|
|
||||||
|
|
||||||
async def _clear_new_repair(self) -> None:
|
async def _clear_new_repair(self) -> None:
|
||||||
"""Clear new repair issue."""
|
"""Clear new repair issue."""
|
||||||
_LOGGER.debug("New issue resolved - clearing repair")
|
_LOGGER.debug("New issue resolved - clearing repair")
|
||||||
|
|
|
||||||
|
|
@ -187,9 +187,8 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
||||||
class TibberPricesSensor(CoordinatorEntity):
|
class TibberPricesSensor(CoordinatorEntity):
|
||||||
async def async_added_to_hass(self):
|
async def async_added_to_hass(self):
|
||||||
# Register this entity's update callback
|
# Register this entity's update callback
|
||||||
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(
|
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(self._handle_coordinator_update)
|
||||||
self._handle_coordinator_update
|
|
||||||
)
|
|
||||||
|
|
||||||
# Coordinator maintains list of listeners
|
# Coordinator maintains list of listeners
|
||||||
class ListenerManager:
|
class ListenerManager:
|
||||||
|
|
@ -390,8 +389,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
# Watch for these log messages:
|
# Watch for these log messages:
|
||||||
"Fetching data from API (reason: tomorrow_check)" # API call
|
"Fetching data from API (reason: tomorrow_check)" # API call
|
||||||
"Using cached data (no update needed)" # Fast path
|
"Using cached data (no update needed)" # Fast path
|
||||||
"Midnight turnover detected (Timer #1)" # Turnover
|
"Midnight turnover detected (Timer #1)" # Turnover
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check Timer #2 (Quarter-Hour)
|
### Check Timer #2 (Quarter-Hour)
|
||||||
|
|
@ -399,7 +398,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
||||||
```python
|
```python
|
||||||
# Watch coordinator logs:
|
# Watch coordinator logs:
|
||||||
"Updated 60 time-sensitive entities at quarter-hour boundary" # Normal
|
"Updated 60 time-sensitive entities at quarter-hour boundary" # Normal
|
||||||
"Midnight turnover detected (Timer #2)" # Turnover
|
|
||||||
|
"Midnight turnover detected (Timer #2)" # Turnover
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check Timer #3 (Minute)
|
### Check Timer #3 (Minute)
|
||||||
|
|
|
||||||
|
|
@ -263,20 +263,16 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Original from Tibber API
|
# Original from Tibber API
|
||||||
{
|
{"startsAt": "2025-11-03T14:00:00+01:00", "total": 0.2534, "level": "NORMAL"}
|
||||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
|
||||||
"total": 0.2534,
|
|
||||||
"level": "NORMAL"
|
|
||||||
}
|
|
||||||
|
|
||||||
# After enrichment (utils/price.py)
|
# After enrichment (utils/price.py)
|
||||||
{
|
{
|
||||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
"startsAt": "2025-11-03T14:00:00+01:00",
|
||||||
"total": 0.2534,
|
"total": 0.2534,
|
||||||
"level": "NORMAL",
|
"level": "NORMAL",
|
||||||
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
||||||
"difference": 9.6, # ← Added: % diff from trailing avg
|
"difference": 9.6, # ← Added: % diff from trailing avg
|
||||||
"rating_level": "NORMAL" # ← Added: LOW/NORMAL/HIGH based on thresholds
|
"rating_level": "NORMAL", # ← Added: LOW/NORMAL/HIGH based on thresholds
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
||||||
```python
|
```python
|
||||||
{
|
{
|
||||||
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
||||||
"peak": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60}
|
"peak": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -178,11 +178,11 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
||||||
|
|
||||||
```python
|
```python
|
||||||
hash_data = (
|
hash_data = (
|
||||||
today_signature, # (startsAt, rating_level) for each interval
|
today_signature, # (startsAt, rating_level) for each interval
|
||||||
tuple(best_config.items()), # Best price config
|
tuple(best_config.items()), # Best price config
|
||||||
tuple(peak_config.items()), # Peak price config
|
tuple(peak_config.items()), # Peak price config
|
||||||
best_level_filter, # Level filter overrides
|
best_level_filter, # Level filter overrides
|
||||||
peak_level_filter
|
peak_level_filter,
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,8 +61,10 @@ If a helper class is ONLY used within a single module file, prefix it with under
|
||||||
# ✅ Private class - used only in this file
|
# ✅ Private class - used only in this file
|
||||||
class _InternalHelper:
|
class _InternalHelper:
|
||||||
"""Helper used only within this module."""
|
"""Helper used only within this module."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ❌ Wrong - no prefix but used across modules
|
# ❌ Wrong - no prefix but used across modules
|
||||||
class DataFetcher: # Should be TibberPricesDataFetcher
|
class DataFetcher: # Should be TibberPricesDataFetcher
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,7 @@ These patterns were analyzed and classified as **not critical**:
|
||||||
coordinator.data = {
|
coordinator.data = {
|
||||||
"user_data": {...},
|
"user_data": {...},
|
||||||
"priceInfo": [...], # Flat list of all enriched intervals
|
"priceInfo": [...], # Flat list of all enriched intervals
|
||||||
"currency": "EUR" # Top-level for easy access
|
"currency": "EUR", # Top-level for easy access
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ def test_something(coordinator):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def test_periods(hass, coordinator):
|
def test_periods(hass, coordinator):
|
||||||
periods = coordinator.data.get('best_price_periods', [])
|
periods = coordinator.data.get("best_price_periods", [])
|
||||||
for period in periods:
|
for period in periods:
|
||||||
print(f"Period: {period['start']} to {period['end']}")
|
print(f"Period: {period['start']} to {period['end']}")
|
||||||
print(f" Intervals: {len(period['intervals'])}")
|
print(f" Intervals: {len(period['intervals'])}")
|
||||||
|
|
@ -147,15 +147,14 @@ grep "tibber_prices" config/home-assistant.log
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# In Developer Tools > Template
|
# In Developer Tools > Template
|
||||||
{{ states.sensor.tibber_home_current_interval_price.last_updated }}
|
{{states.sensor.tibber_home_current_interval_price.last_updated}}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Debug in code:**
|
**Debug in code:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Add logging in sensor/core.py
|
# Add logging in sensor/core.py
|
||||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
_LOGGER.debug("Updating sensor %s: old=%s new=%s", self.entity_id, self._attr_native_value, new_value)
|
||||||
self.entity_id, self._attr_native_value, new_value)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Period Calculation Wrong
|
### Period Calculation Wrong
|
||||||
|
|
@ -164,8 +163,7 @@ _LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# coordinator/period_handlers/period_building.py
|
# coordinator/period_handlers/period_building.py
|
||||||
_LOGGER.debug("Candidate intervals: %s",
|
_LOGGER.debug("Candidate intervals: %s", [(i["startsAt"], i["total"]) for i in candidates])
|
||||||
[(i['startsAt'], i['total']) for i in candidates])
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Check filter statistics:**
|
**Check filter statistics:**
|
||||||
|
|
@ -217,6 +215,7 @@ Add to coordinator code:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import debugpy
|
import debugpy
|
||||||
|
|
||||||
debugpy.listen(5678)
|
debugpy.listen(5678)
|
||||||
_LOGGER.info("Waiting for debugger attach on port 5678")
|
_LOGGER.info("Waiting for debugger attach on port 5678")
|
||||||
debugpy.wait_for_client()
|
debugpy.wait_for_client()
|
||||||
|
|
@ -236,6 +235,7 @@ Add breakpoint:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from IPython import embed
|
from IPython import embed
|
||||||
|
|
||||||
embed() # Drops into interactive shell
|
embed() # Drops into interactive shell
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,7 @@ import tracemalloc
|
||||||
tracemalloc.start()
|
tracemalloc.start()
|
||||||
# Run your code
|
# Run your code
|
||||||
current, peak = tracemalloc.get_traced_memory()
|
current, peak = tracemalloc.get_traced_memory()
|
||||||
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB",
|
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB", current / 1024**2, peak / 1024**2)
|
||||||
current / 1024**2, peak / 1024**2)
|
|
||||||
tracemalloc.stop()
|
tracemalloc.stop()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -78,6 +77,7 @@ data = await store.async_load()
|
||||||
# Already implemented in const.py
|
# Already implemented in const.py
|
||||||
_TRANSLATION_CACHE: dict[str, dict] = {}
|
_TRANSLATION_CACHE: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
def get_translation(path: str, language: str) -> dict:
|
def get_translation(path: str, language: str) -> dict:
|
||||||
cache_key = f"{path}_{language}"
|
cache_key = f"{path}_{language}"
|
||||||
if cache_key not in _TRANSLATION_CACHE:
|
if cache_key not in _TRANSLATION_CACHE:
|
||||||
|
|
@ -139,10 +139,7 @@ user_data = await fetch_user_data()
|
||||||
price_data = await fetch_price_data()
|
price_data = await fetch_price_data()
|
||||||
|
|
||||||
# ✅ Concurrent (fast)
|
# ✅ Concurrent (fast)
|
||||||
user_data, price_data = await asyncio.gather(
|
user_data, price_data = await asyncio.gather(fetch_user_data(), fetch_price_data())
|
||||||
fetch_user_data(),
|
|
||||||
fetch_price_data()
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**2. Don't block event loop:**
|
**2. Don't block event loop:**
|
||||||
|
|
@ -175,6 +172,7 @@ class Coordinator:
|
||||||
```python
|
```python
|
||||||
import weakref
|
import weakref
|
||||||
|
|
||||||
|
|
||||||
class Manager:
|
class Manager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._callbacks: list[weakref.ref] = []
|
self._callbacks: list[weakref.ref] = []
|
||||||
|
|
@ -216,8 +214,7 @@ results = (x for x in items if condition(x))
|
||||||
**Monitor API usage:**
|
**Monitor API usage:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
_LOGGER.debug("API call: %s (cache_age=%s)",
|
_LOGGER.debug("API call: %s (cache_age=%s)", endpoint, cache_age)
|
||||||
endpoint, cache_age)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Smart Updates
|
### Smart Updates
|
||||||
|
|
@ -243,13 +240,13 @@ async def _async_update_data(self) -> dict:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# ❌ MEASUREMENT for prices (stores every change)
|
# ❌ MEASUREMENT for prices (stores every change)
|
||||||
state_class=SensorStateClass.MEASUREMENT # ~35K records/year
|
state_class = SensorStateClass.MEASUREMENT # ~35K records/year
|
||||||
|
|
||||||
# ✅ None for prices (no long-term stats)
|
# ✅ None for prices (no long-term stats)
|
||||||
state_class=None # Only current state
|
state_class = None # Only current state
|
||||||
|
|
||||||
# ✅ TOTAL for counters only
|
# ✅ TOTAL for counters only
|
||||||
state_class=SensorStateClass.TOTAL # For cumulative values
|
state_class = SensorStateClass.TOTAL # For cumulative values
|
||||||
```
|
```
|
||||||
|
|
||||||
### Attribute Size
|
### Attribute Size
|
||||||
|
|
@ -260,7 +257,7 @@ state_class=SensorStateClass.TOTAL # For cumulative values
|
||||||
# ❌ Large nested structures (KB per update)
|
# ❌ Large nested structures (KB per update)
|
||||||
attributes = {
|
attributes = {
|
||||||
"all_intervals": [...], # 384 intervals
|
"all_intervals": [...], # 384 intervals
|
||||||
"full_history": [...], # Days of data
|
"full_history": [...], # Days of data
|
||||||
}
|
}
|
||||||
|
|
||||||
# ✅ Essential data only (bytes per update)
|
# ✅ Essential data only (bytes per update)
|
||||||
|
|
@ -279,6 +276,7 @@ attributes = {
|
||||||
import pytest
|
import pytest
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.benchmark
|
@pytest.mark.benchmark
|
||||||
def test_period_calculation_performance(coordinator):
|
def test_period_calculation_performance(coordinator):
|
||||||
"""Period calculation should complete in <100ms."""
|
"""Period calculation should complete in <100ms."""
|
||||||
|
|
|
||||||
|
|
@ -1246,8 +1246,8 @@ for price_data in all_prices:
|
||||||
ref_date = date_key
|
ref_date = date_key
|
||||||
|
|
||||||
criteria = TibberPricesIntervalCriteria(
|
criteria = TibberPricesIntervalCriteria(
|
||||||
ref_price=ref_prices[ref_date], # Interval's day
|
ref_price=ref_prices[ref_date], # Interval's day
|
||||||
avg_price=avg_prices[ref_date], # Interval's day
|
avg_price=avg_prices[ref_date], # Interval's day
|
||||||
flex=flex,
|
flex=flex,
|
||||||
min_distance_from_avg=min_distance_from_avg,
|
min_distance_from_avg=min_distance_from_avg,
|
||||||
reverse_sort=reverse_sort,
|
reverse_sort=reverse_sort,
|
||||||
|
|
|
||||||
|
|
@ -69,11 +69,7 @@ API requests are being throttled, causing stale data. Updates may be delayed unt
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# In error handler
|
# In error handler
|
||||||
is_rate_limit = (
|
is_rate_limit = "429" in error_str or "rate limit" in error_str or "too many requests" in error_str
|
||||||
"429" in error_str
|
|
||||||
or "rate limit" in error_str
|
|
||||||
or "too many requests" in error_str
|
|
||||||
)
|
|
||||||
if is_rate_limit:
|
if is_rate_limit:
|
||||||
await self._repair_manager.track_rate_limit_error()
|
await self._repair_manager.track_rate_limit_error()
|
||||||
|
|
||||||
|
|
@ -295,6 +291,7 @@ async def check_new_condition(self, *, param: bool) -> None:
|
||||||
elif not should_warn and self._new_repair_active:
|
elif not should_warn and self._new_repair_active:
|
||||||
await self._clear_new_repair()
|
await self._clear_new_repair()
|
||||||
|
|
||||||
|
|
||||||
async def _create_new_repair(self) -> None:
|
async def _create_new_repair(self) -> None:
|
||||||
"""Create new repair issue."""
|
"""Create new repair issue."""
|
||||||
_LOGGER.warning("New issue detected - creating repair")
|
_LOGGER.warning("New issue detected - creating repair")
|
||||||
|
|
@ -312,6 +309,7 @@ async def _create_new_repair(self) -> None:
|
||||||
)
|
)
|
||||||
self._new_repair_active = True
|
self._new_repair_active = True
|
||||||
|
|
||||||
|
|
||||||
async def _clear_new_repair(self) -> None:
|
async def _clear_new_repair(self) -> None:
|
||||||
"""Clear new repair issue."""
|
"""Clear new repair issue."""
|
||||||
_LOGGER.debug("New issue resolved - clearing repair")
|
_LOGGER.debug("New issue resolved - clearing repair")
|
||||||
|
|
|
||||||
|
|
@ -187,9 +187,8 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
||||||
class TibberPricesSensor(CoordinatorEntity):
|
class TibberPricesSensor(CoordinatorEntity):
|
||||||
async def async_added_to_hass(self):
|
async def async_added_to_hass(self):
|
||||||
# Register this entity's update callback
|
# Register this entity's update callback
|
||||||
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(
|
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(self._handle_coordinator_update)
|
||||||
self._handle_coordinator_update
|
|
||||||
)
|
|
||||||
|
|
||||||
# Coordinator maintains list of listeners
|
# Coordinator maintains list of listeners
|
||||||
class ListenerManager:
|
class ListenerManager:
|
||||||
|
|
@ -390,8 +389,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
# Watch for these log messages:
|
# Watch for these log messages:
|
||||||
"Fetching data from API (reason: tomorrow_check)" # API call
|
"Fetching data from API (reason: tomorrow_check)" # API call
|
||||||
"Using cached data (no update needed)" # Fast path
|
"Using cached data (no update needed)" # Fast path
|
||||||
"Midnight turnover detected (Timer #1)" # Turnover
|
"Midnight turnover detected (Timer #1)" # Turnover
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check Timer #2 (Quarter-Hour)
|
### Check Timer #2 (Quarter-Hour)
|
||||||
|
|
@ -399,7 +398,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
||||||
```python
|
```python
|
||||||
# Watch coordinator logs:
|
# Watch coordinator logs:
|
||||||
"Updated 60 time-sensitive entities at quarter-hour boundary" # Normal
|
"Updated 60 time-sensitive entities at quarter-hour boundary" # Normal
|
||||||
"Midnight turnover detected (Timer #2)" # Turnover
|
|
||||||
|
"Midnight turnover detected (Timer #2)" # Turnover
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check Timer #3 (Minute)
|
### Check Timer #3 (Minute)
|
||||||
|
|
|
||||||
|
|
@ -263,20 +263,16 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Original from Tibber API
|
# Original from Tibber API
|
||||||
{
|
{"startsAt": "2025-11-03T14:00:00+01:00", "total": 0.2534, "level": "NORMAL"}
|
||||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
|
||||||
"total": 0.2534,
|
|
||||||
"level": "NORMAL"
|
|
||||||
}
|
|
||||||
|
|
||||||
# After enrichment (utils/price.py)
|
# After enrichment (utils/price.py)
|
||||||
{
|
{
|
||||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
"startsAt": "2025-11-03T14:00:00+01:00",
|
||||||
"total": 0.2534,
|
"total": 0.2534,
|
||||||
"level": "NORMAL",
|
"level": "NORMAL",
|
||||||
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
||||||
"difference": 9.6, # ← Added: % diff from trailing avg
|
"difference": 9.6, # ← Added: % diff from trailing avg
|
||||||
"rating_level": "NORMAL" # ← Added: LOW/NORMAL/HIGH based on thresholds
|
"rating_level": "NORMAL", # ← Added: LOW/NORMAL/HIGH based on thresholds
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
||||||
```python
|
```python
|
||||||
{
|
{
|
||||||
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
||||||
"peak": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60}
|
"peak": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -178,11 +178,11 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
||||||
|
|
||||||
```python
|
```python
|
||||||
hash_data = (
|
hash_data = (
|
||||||
today_signature, # (startsAt, rating_level) for each interval
|
today_signature, # (startsAt, rating_level) for each interval
|
||||||
tuple(best_config.items()), # Best price config
|
tuple(best_config.items()), # Best price config
|
||||||
tuple(peak_config.items()), # Peak price config
|
tuple(peak_config.items()), # Peak price config
|
||||||
best_level_filter, # Level filter overrides
|
best_level_filter, # Level filter overrides
|
||||||
peak_level_filter
|
peak_level_filter,
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,8 +61,10 @@ If a helper class is ONLY used within a single module file, prefix it with under
|
||||||
# ✅ Private class - used only in this file
|
# ✅ Private class - used only in this file
|
||||||
class _InternalHelper:
|
class _InternalHelper:
|
||||||
"""Helper used only within this module."""
|
"""Helper used only within this module."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ❌ Wrong - no prefix but used across modules
|
# ❌ Wrong - no prefix but used across modules
|
||||||
class DataFetcher: # Should be TibberPricesDataFetcher
|
class DataFetcher: # Should be TibberPricesDataFetcher
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,7 @@ These patterns were analyzed and classified as **not critical**:
|
||||||
coordinator.data = {
|
coordinator.data = {
|
||||||
"user_data": {...},
|
"user_data": {...},
|
||||||
"priceInfo": [...], # Flat list of all enriched intervals
|
"priceInfo": [...], # Flat list of all enriched intervals
|
||||||
"currency": "EUR" # Top-level for easy access
|
"currency": "EUR", # Top-level for easy access
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ def test_something(coordinator):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def test_periods(hass, coordinator):
|
def test_periods(hass, coordinator):
|
||||||
periods = coordinator.data.get('best_price_periods', [])
|
periods = coordinator.data.get("best_price_periods", [])
|
||||||
for period in periods:
|
for period in periods:
|
||||||
print(f"Period: {period['start']} to {period['end']}")
|
print(f"Period: {period['start']} to {period['end']}")
|
||||||
print(f" Intervals: {len(period['intervals'])}")
|
print(f" Intervals: {len(period['intervals'])}")
|
||||||
|
|
@ -147,15 +147,14 @@ grep "tibber_prices" config/home-assistant.log
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# In Developer Tools > Template
|
# In Developer Tools > Template
|
||||||
{{ states.sensor.tibber_home_current_interval_price.last_updated }}
|
{{states.sensor.tibber_home_current_interval_price.last_updated}}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Debug in code:**
|
**Debug in code:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Add logging in sensor/core.py
|
# Add logging in sensor/core.py
|
||||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
_LOGGER.debug("Updating sensor %s: old=%s new=%s", self.entity_id, self._attr_native_value, new_value)
|
||||||
self.entity_id, self._attr_native_value, new_value)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Period Calculation Wrong
|
### Period Calculation Wrong
|
||||||
|
|
@ -164,8 +163,7 @@ _LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# coordinator/period_handlers/period_building.py
|
# coordinator/period_handlers/period_building.py
|
||||||
_LOGGER.debug("Candidate intervals: %s",
|
_LOGGER.debug("Candidate intervals: %s", [(i["startsAt"], i["total"]) for i in candidates])
|
||||||
[(i['startsAt'], i['total']) for i in candidates])
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Check filter statistics:**
|
**Check filter statistics:**
|
||||||
|
|
@ -217,6 +215,7 @@ Add to coordinator code:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import debugpy
|
import debugpy
|
||||||
|
|
||||||
debugpy.listen(5678)
|
debugpy.listen(5678)
|
||||||
_LOGGER.info("Waiting for debugger attach on port 5678")
|
_LOGGER.info("Waiting for debugger attach on port 5678")
|
||||||
debugpy.wait_for_client()
|
debugpy.wait_for_client()
|
||||||
|
|
@ -236,6 +235,7 @@ Add breakpoint:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from IPython import embed
|
from IPython import embed
|
||||||
|
|
||||||
embed() # Drops into interactive shell
|
embed() # Drops into interactive shell
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,7 @@ import tracemalloc
|
||||||
tracemalloc.start()
|
tracemalloc.start()
|
||||||
# Run your code
|
# Run your code
|
||||||
current, peak = tracemalloc.get_traced_memory()
|
current, peak = tracemalloc.get_traced_memory()
|
||||||
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB",
|
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB", current / 1024**2, peak / 1024**2)
|
||||||
current / 1024**2, peak / 1024**2)
|
|
||||||
tracemalloc.stop()
|
tracemalloc.stop()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -78,6 +77,7 @@ data = await store.async_load()
|
||||||
# Already implemented in const.py
|
# Already implemented in const.py
|
||||||
_TRANSLATION_CACHE: dict[str, dict] = {}
|
_TRANSLATION_CACHE: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
def get_translation(path: str, language: str) -> dict:
|
def get_translation(path: str, language: str) -> dict:
|
||||||
cache_key = f"{path}_{language}"
|
cache_key = f"{path}_{language}"
|
||||||
if cache_key not in _TRANSLATION_CACHE:
|
if cache_key not in _TRANSLATION_CACHE:
|
||||||
|
|
@ -139,10 +139,7 @@ user_data = await fetch_user_data()
|
||||||
price_data = await fetch_price_data()
|
price_data = await fetch_price_data()
|
||||||
|
|
||||||
# ✅ Concurrent (fast)
|
# ✅ Concurrent (fast)
|
||||||
user_data, price_data = await asyncio.gather(
|
user_data, price_data = await asyncio.gather(fetch_user_data(), fetch_price_data())
|
||||||
fetch_user_data(),
|
|
||||||
fetch_price_data()
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**2. Don't block event loop:**
|
**2. Don't block event loop:**
|
||||||
|
|
@ -175,6 +172,7 @@ class Coordinator:
|
||||||
```python
|
```python
|
||||||
import weakref
|
import weakref
|
||||||
|
|
||||||
|
|
||||||
class Manager:
|
class Manager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._callbacks: list[weakref.ref] = []
|
self._callbacks: list[weakref.ref] = []
|
||||||
|
|
@ -216,8 +214,7 @@ results = (x for x in items if condition(x))
|
||||||
**Monitor API usage:**
|
**Monitor API usage:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
_LOGGER.debug("API call: %s (cache_age=%s)",
|
_LOGGER.debug("API call: %s (cache_age=%s)", endpoint, cache_age)
|
||||||
endpoint, cache_age)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Smart Updates
|
### Smart Updates
|
||||||
|
|
@ -243,13 +240,13 @@ async def _async_update_data(self) -> dict:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# ❌ MEASUREMENT for prices (stores every change)
|
# ❌ MEASUREMENT for prices (stores every change)
|
||||||
state_class=SensorStateClass.MEASUREMENT # ~35K records/year
|
state_class = SensorStateClass.MEASUREMENT # ~35K records/year
|
||||||
|
|
||||||
# ✅ None for prices (no long-term stats)
|
# ✅ None for prices (no long-term stats)
|
||||||
state_class=None # Only current state
|
state_class = None # Only current state
|
||||||
|
|
||||||
# ✅ TOTAL for counters only
|
# ✅ TOTAL for counters only
|
||||||
state_class=SensorStateClass.TOTAL # For cumulative values
|
state_class = SensorStateClass.TOTAL # For cumulative values
|
||||||
```
|
```
|
||||||
|
|
||||||
### Attribute Size
|
### Attribute Size
|
||||||
|
|
@ -260,7 +257,7 @@ state_class=SensorStateClass.TOTAL # For cumulative values
|
||||||
# ❌ Large nested structures (KB per update)
|
# ❌ Large nested structures (KB per update)
|
||||||
attributes = {
|
attributes = {
|
||||||
"all_intervals": [...], # 384 intervals
|
"all_intervals": [...], # 384 intervals
|
||||||
"full_history": [...], # Days of data
|
"full_history": [...], # Days of data
|
||||||
}
|
}
|
||||||
|
|
||||||
# ✅ Essential data only (bytes per update)
|
# ✅ Essential data only (bytes per update)
|
||||||
|
|
@ -279,6 +276,7 @@ attributes = {
|
||||||
import pytest
|
import pytest
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.benchmark
|
@pytest.mark.benchmark
|
||||||
def test_period_calculation_performance(coordinator):
|
def test_period_calculation_performance(coordinator):
|
||||||
"""Period calculation should complete in <100ms."""
|
"""Period calculation should complete in <100ms."""
|
||||||
|
|
|
||||||
|
|
@ -1246,8 +1246,8 @@ for price_data in all_prices:
|
||||||
ref_date = date_key
|
ref_date = date_key
|
||||||
|
|
||||||
criteria = TibberPricesIntervalCriteria(
|
criteria = TibberPricesIntervalCriteria(
|
||||||
ref_price=ref_prices[ref_date], # Interval's day
|
ref_price=ref_prices[ref_date], # Interval's day
|
||||||
avg_price=avg_prices[ref_date], # Interval's day
|
avg_price=avg_prices[ref_date], # Interval's day
|
||||||
flex=flex,
|
flex=flex,
|
||||||
min_distance_from_avg=min_distance_from_avg,
|
min_distance_from_avg=min_distance_from_avg,
|
||||||
reverse_sort=reverse_sort,
|
reverse_sort=reverse_sort,
|
||||||
|
|
|
||||||
|
|
@ -69,11 +69,7 @@ API requests are being throttled, causing stale data. Updates may be delayed unt
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# In error handler
|
# In error handler
|
||||||
is_rate_limit = (
|
is_rate_limit = "429" in error_str or "rate limit" in error_str or "too many requests" in error_str
|
||||||
"429" in error_str
|
|
||||||
or "rate limit" in error_str
|
|
||||||
or "too many requests" in error_str
|
|
||||||
)
|
|
||||||
if is_rate_limit:
|
if is_rate_limit:
|
||||||
await self._repair_manager.track_rate_limit_error()
|
await self._repair_manager.track_rate_limit_error()
|
||||||
|
|
||||||
|
|
@ -295,6 +291,7 @@ async def check_new_condition(self, *, param: bool) -> None:
|
||||||
elif not should_warn and self._new_repair_active:
|
elif not should_warn and self._new_repair_active:
|
||||||
await self._clear_new_repair()
|
await self._clear_new_repair()
|
||||||
|
|
||||||
|
|
||||||
async def _create_new_repair(self) -> None:
|
async def _create_new_repair(self) -> None:
|
||||||
"""Create new repair issue."""
|
"""Create new repair issue."""
|
||||||
_LOGGER.warning("New issue detected - creating repair")
|
_LOGGER.warning("New issue detected - creating repair")
|
||||||
|
|
@ -312,6 +309,7 @@ async def _create_new_repair(self) -> None:
|
||||||
)
|
)
|
||||||
self._new_repair_active = True
|
self._new_repair_active = True
|
||||||
|
|
||||||
|
|
||||||
async def _clear_new_repair(self) -> None:
|
async def _clear_new_repair(self) -> None:
|
||||||
"""Clear new repair issue."""
|
"""Clear new repair issue."""
|
||||||
_LOGGER.debug("New issue resolved - clearing repair")
|
_LOGGER.debug("New issue resolved - clearing repair")
|
||||||
|
|
|
||||||
|
|
@ -187,9 +187,8 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
||||||
class TibberPricesSensor(CoordinatorEntity):
|
class TibberPricesSensor(CoordinatorEntity):
|
||||||
async def async_added_to_hass(self):
|
async def async_added_to_hass(self):
|
||||||
# Register this entity's update callback
|
# Register this entity's update callback
|
||||||
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(
|
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(self._handle_coordinator_update)
|
||||||
self._handle_coordinator_update
|
|
||||||
)
|
|
||||||
|
|
||||||
# Coordinator maintains list of listeners
|
# Coordinator maintains list of listeners
|
||||||
class ListenerManager:
|
class ListenerManager:
|
||||||
|
|
@ -390,8 +389,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
# Watch for these log messages:
|
# Watch for these log messages:
|
||||||
"Fetching data from API (reason: tomorrow_check)" # API call
|
"Fetching data from API (reason: tomorrow_check)" # API call
|
||||||
"Using cached data (no update needed)" # Fast path
|
"Using cached data (no update needed)" # Fast path
|
||||||
"Midnight turnover detected (Timer #1)" # Turnover
|
"Midnight turnover detected (Timer #1)" # Turnover
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check Timer #2 (Quarter-Hour)
|
### Check Timer #2 (Quarter-Hour)
|
||||||
|
|
@ -399,7 +398,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
||||||
```python
|
```python
|
||||||
# Watch coordinator logs:
|
# Watch coordinator logs:
|
||||||
"Updated 60 time-sensitive entities at quarter-hour boundary" # Normal
|
"Updated 60 time-sensitive entities at quarter-hour boundary" # Normal
|
||||||
"Midnight turnover detected (Timer #2)" # Turnover
|
|
||||||
|
"Midnight turnover detected (Timer #2)" # Turnover
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check Timer #3 (Minute)
|
### Check Timer #3 (Minute)
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue