mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-07-27 17:26:48 +00:00
Merge 8adefc7a7e into 691e917c0a
This commit is contained in:
commit
8a20f10e73
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`):
|
||||
|
||||
```python
|
||||
MAX_SAFE_FLEX = 0.50 # 50% absolute maximum
|
||||
MAX_OUTLIER_FLEX = 0.25 # 25% for stable outlier detection
|
||||
MAX_SAFE_FLEX = 0.50 # 50% absolute maximum
|
||||
MAX_OUTLIER_FLEX = 0.25 # 25% for stable outlier detection
|
||||
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`):
|
||||
|
|
@ -725,10 +725,10 @@ FLEX_HIGH_THRESHOLD_RELAXATION = 0.30 # WARNING at 30% base flex
|
|||
**Default Configuration Values** (`const.py`):
|
||||
|
||||
```python
|
||||
DEFAULT_BEST_PRICE_FLEX = 15 # 15% base - optimal for relaxation mode
|
||||
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_PEAK = 11 # 11 steps: 20% → 50% (3% increment per step)
|
||||
DEFAULT_BEST_PRICE_FLEX = 15 # 15% base - optimal for relaxation mode
|
||||
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_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.
|
||||
|
|
@ -1297,12 +1297,14 @@ If you see errors from one tool, understand which tool should fix them:
|
|||
def process_data(time: TimeService | None) -> None:
|
||||
result = time.now() # Error: 'None' has no attribute 'now'
|
||||
|
||||
|
||||
# ✅ GOOD - Guard against None
|
||||
def process_data(time: TimeService | None) -> None:
|
||||
if time is None:
|
||||
return
|
||||
result = time.now() # OK - type narrowed to TimeService
|
||||
|
||||
|
||||
# ✅ BETTER - Make it required if always provided
|
||||
def process_data(time: TimeService) -> None:
|
||||
result = time.now() # OK - never None
|
||||
|
|
@ -1319,6 +1321,7 @@ if TYPE_CHECKING:
|
|||
# Custom callback type that accepts TimeService
|
||||
TimeServiceCallback = Callable[[TimeService], None]
|
||||
|
||||
|
||||
# Use in class definition
|
||||
class ListenerManager:
|
||||
def __init__(self) -> None:
|
||||
|
|
@ -1360,10 +1363,12 @@ if (interval_time := time.get_interval_time(price)) is not None:
|
|||
def get_timestamp() -> str:
|
||||
return datetime.now() # Error: incompatible return type
|
||||
|
||||
|
||||
# ✅ GOOD - Match return type to actual return value
|
||||
def get_timestamp() -> datetime:
|
||||
return datetime.now() # OK
|
||||
|
||||
|
||||
# ✅ ALSO GOOD - Convert if signature requires string
|
||||
def get_timestamp() -> 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
|
||||
class _InternalHelper:
|
||||
"""Helper class used only within this module."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# Usage: Only in the same file, never imported elsewhere
|
||||
result = _InternalHelper().process()
|
||||
```
|
||||
|
|
@ -1641,6 +1648,7 @@ result = _InternalHelper().process()
|
|||
# In coordinator/price_data_manager.py
|
||||
class _ApiRetryStateMachine:
|
||||
"""Internal state machine for retry logic. Never used outside this file."""
|
||||
|
||||
def __init__(self, max_retries: int) -> None:
|
||||
self._attempts = 0
|
||||
self._max_retries = max_retries
|
||||
|
|
@ -1733,12 +1741,12 @@ Use consistent indentation to show logical structure (DEBUG level only):
|
|||
|
||||
```python
|
||||
# Define indentation constants at module level
|
||||
INDENT_L0 = "" # Top-level (0 spaces)
|
||||
INDENT_L1 = " " # Level 1 (2 spaces)
|
||||
INDENT_L2 = " " # Level 2 (4 spaces)
|
||||
INDENT_L3 = " " # Level 3 (6 spaces)
|
||||
INDENT_L0 = "" # Top-level (0 spaces)
|
||||
INDENT_L1 = " " # Level 1 (2 spaces)
|
||||
INDENT_L2 = " " # Level 2 (4 spaces)
|
||||
INDENT_L3 = " " # Level 3 (6 spaces)
|
||||
INDENT_L4 = " " # Level 4 (8 spaces)
|
||||
INDENT_L5 = " "# Level 5 (10 spaces)
|
||||
INDENT_L5 = " " # Level 5 (10 spaces)
|
||||
|
||||
# Usage example showing logic hierarchy
|
||||
_LOGGER.debug("%sCalculating periods for day %s", INDENT_L0, day_date)
|
||||
|
|
@ -1769,9 +1777,12 @@ _LOGGER.debug(
|
|||
"%s Base flex: %.1f%%\n"
|
||||
"%s Strategy: 4 flex levels × 4 filter combinations",
|
||||
INDENT_L0,
|
||||
INDENT_L0, "ENABLED" if enable_relaxation else "DISABLED",
|
||||
INDENT_L0, min_periods,
|
||||
INDENT_L0, base_flex,
|
||||
INDENT_L0,
|
||||
"ENABLED" if enable_relaxation else "DISABLED",
|
||||
INDENT_L0,
|
||||
min_periods,
|
||||
INDENT_L0,
|
||||
base_flex,
|
||||
INDENT_L0,
|
||||
)
|
||||
```
|
||||
|
|
@ -1955,7 +1966,7 @@ message = "Hello world"
|
|||
html = '<div class="container">content</div>'
|
||||
|
||||
# ❌ Inconsistent quote usage
|
||||
name = 'tibber_prices' # Ruff will change to double quotes
|
||||
name = "tibber_prices" # Ruff will change to double quotes
|
||||
```
|
||||
|
||||
**Trailing Commas:**
|
||||
|
|
@ -1968,6 +1979,7 @@ SENSOR_TYPES = [
|
|||
"max_price", # ← Trailing comma
|
||||
]
|
||||
|
||||
|
||||
# ✅ Also for function arguments
|
||||
def calculate_average(
|
||||
prices: list[dict],
|
||||
|
|
@ -1976,11 +1988,12 @@ def calculate_average(
|
|||
) -> float:
|
||||
pass
|
||||
|
||||
|
||||
# ❌ Missing trailing comma
|
||||
SENSOR_TYPES = [
|
||||
"current_interval_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 0.25
|
||||
|
||||
|
||||
# ✅ Multi-line docstrings: summary line, blank, details
|
||||
def calculate_average(prices: list[dict]) -> float:
|
||||
"""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)
|
||||
|
||||
|
||||
# ❌ Single quotes or missing docstrings on public functions
|
||||
def get_price() -> float:
|
||||
'''Return price''' # Ruff will change to double quotes
|
||||
"""Return price""" # Ruff will change to double quotes
|
||||
```
|
||||
|
||||
**Line Breaking:**
|
||||
|
|
@ -2020,20 +2035,11 @@ result = some_function(
|
|||
)
|
||||
|
||||
# ✅ Break long conditions
|
||||
if (
|
||||
price > threshold
|
||||
and time_of_day == "peak"
|
||||
and day_of_week in ["Monday", "Friday"]
|
||||
):
|
||||
if price > threshold and time_of_day == "peak" and day_of_week in ["Monday", "Friday"]:
|
||||
do_something()
|
||||
|
||||
# ✅ Chain methods with line breaks
|
||||
df = (
|
||||
data_frame
|
||||
.filter(lambda x: x > 0)
|
||||
.sort_values()
|
||||
.reset_index()
|
||||
)
|
||||
df = data_frame.filter(lambda x: x > 0).sort_values().reset_index()
|
||||
```
|
||||
|
||||
**Type Annotations:**
|
||||
|
|
@ -2044,15 +2050,20 @@ def get_current_interval_price(coordinator: DataUpdateCoordinator) -> float:
|
|||
"""Get current price from coordinator."""
|
||||
return coordinator.data["priceInfo"][0]["total"]
|
||||
|
||||
|
||||
# ✅ Use modern type syntax (Python 3.13)
|
||||
def process_prices(prices: list[dict[str, Any]]) -> dict[str, float]:
|
||||
pass
|
||||
|
||||
|
||||
# ❌ Avoid old-style typing (List, Dict from typing module)
|
||||
from typing import List, Dict
|
||||
|
||||
|
||||
def process_prices(prices: List[Dict[str, Any]]) -> Dict[str, float]: # Use list, dict instead
|
||||
pass
|
||||
|
||||
|
||||
# ✅ Optional parameters
|
||||
def fetch_data(home_id: str, max_retries: int = 3) -> dict | None:
|
||||
pass
|
||||
|
|
@ -2084,11 +2095,7 @@ prices = [interval["total"] for interval in data]
|
|||
price_map = {interval["startsAt"]: interval["total"] for interval in data}
|
||||
|
||||
# ✅ Break long comprehensions
|
||||
prices = [
|
||||
interval["total"]
|
||||
for interval in data
|
||||
if interval["total"] is not None
|
||||
]
|
||||
prices = [interval["total"] for interval in data if interval["total"] is not None]
|
||||
|
||||
# ❌ Don't use comprehensions for complex logic
|
||||
result = [ # Use regular loop instead
|
||||
|
|
@ -2128,40 +2135,34 @@ Attributes should follow a **logical priority order** to make the most important
|
|||
```python
|
||||
attributes = {
|
||||
# 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": ...,
|
||||
"end": ...,
|
||||
"duration_minutes": ...,
|
||||
|
||||
# 2. Core decision attributes (what should I do?)
|
||||
"level": ..., # Price level (VERY_CHEAP, CHEAP, NORMAL, etc.)
|
||||
"rating_level": ..., # Price rating (LOW, NORMAL, HIGH)
|
||||
|
||||
"level": ..., # Price level (VERY_CHEAP, CHEAP, NORMAL, etc.)
|
||||
"rating_level": ..., # Price rating (LOW, NORMAL, HIGH)
|
||||
# 3. Price statistics (how much does it cost?)
|
||||
"price_mean": ...,
|
||||
"price_median": ...,
|
||||
"price_min": ...,
|
||||
"price_max": ...,
|
||||
|
||||
# 4. Price differences (optional - how does it compare?)
|
||||
"price_diff_from_daily_min": ...,
|
||||
"price_diff_from_daily_min_%": ...,
|
||||
|
||||
# 5. Detail information (additional context)
|
||||
"hour": ...,
|
||||
"minute": ...,
|
||||
"time": ...,
|
||||
"period_position": ...,
|
||||
"interval_count": ...,
|
||||
|
||||
# 6. Meta information (technical details)
|
||||
"pricePeriods": [...], # Nested structures last
|
||||
"pricePeriods": [...], # Nested structures last
|
||||
"priceInfo": [...],
|
||||
|
||||
# 7. Extended descriptions (always last)
|
||||
"description": "...", # Short description from custom_translations (always shown)
|
||||
"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)
|
||||
"description": "...", # Short description from custom_translations (always shown)
|
||||
"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)
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -2229,6 +2230,7 @@ def _get_sensor_attributes(self) -> dict | None:
|
|||
# Direct implementation returns dict
|
||||
return build_sensor_attributes(...)
|
||||
|
||||
|
||||
# binary_sensor/core.py
|
||||
def _get_sensor_attributes(self) -> dict | None:
|
||||
"""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_median": 18.3,
|
||||
"interval_count": 4,
|
||||
"intervals": [...]
|
||||
"intervals": [...],
|
||||
}
|
||||
|
||||
# ❌ 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",
|
||||
"start": "2025-11-08T14:00:00+01:00",
|
||||
"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
|
||||
# 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)
|
||||
{
|
||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
||||
"total": 0.2534,
|
||||
"level": "NORMAL",
|
||||
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
||||
"difference": 9.6, # ← Added: % diff from trailing avg
|
||||
"rating_level": "NORMAL" # ← Added: LOW/NORMAL/HIGH based on thresholds
|
||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
||||
"total": 0.2534,
|
||||
"level": "NORMAL",
|
||||
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
||||
"difference": 9.6, # ← Added: % diff from trailing avg
|
||||
"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
|
||||
{
|
||||
"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
|
||||
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(peak_config.items()), # Peak price config
|
||||
best_level_filter, # Level filter overrides
|
||||
peak_level_filter
|
||||
best_level_filter, # Level filter overrides
|
||||
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
|
||||
class _InternalHelper:
|
||||
"""Helper used only within this module."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# ❌ Wrong - no prefix but used across modules
|
||||
class DataFetcher: # Should be TibberPricesDataFetcher
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@ These patterns were analyzed and classified as **not critical**:
|
|||
coordinator.data = {
|
||||
"user_data": {...},
|
||||
"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
|
||||
def test_periods(hass, coordinator):
|
||||
periods = coordinator.data.get('best_price_periods', [])
|
||||
periods = coordinator.data.get("best_price_periods", [])
|
||||
for period in periods:
|
||||
print(f"Period: {period['start']} to {period['end']}")
|
||||
print(f" Intervals: {len(period['intervals'])}")
|
||||
|
|
@ -147,15 +147,14 @@ grep "tibber_prices" config/home-assistant.log
|
|||
|
||||
```python
|
||||
# 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:**
|
||||
|
||||
```python
|
||||
# Add logging in sensor/core.py
|
||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
||||
self.entity_id, self._attr_native_value, new_value)
|
||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s", self.entity_id, self._attr_native_value, new_value)
|
||||
```
|
||||
|
||||
### Period Calculation Wrong
|
||||
|
|
@ -164,8 +163,7 @@ _LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
|||
|
||||
```python
|
||||
# coordinator/period_handlers/period_building.py
|
||||
_LOGGER.debug("Candidate intervals: %s",
|
||||
[(i['startsAt'], i['total']) for i in candidates])
|
||||
_LOGGER.debug("Candidate intervals: %s", [(i["startsAt"], i["total"]) for i in candidates])
|
||||
```
|
||||
|
||||
**Check filter statistics:**
|
||||
|
|
@ -217,6 +215,7 @@ Add to coordinator code:
|
|||
|
||||
```python
|
||||
import debugpy
|
||||
|
||||
debugpy.listen(5678)
|
||||
_LOGGER.info("Waiting for debugger attach on port 5678")
|
||||
debugpy.wait_for_client()
|
||||
|
|
@ -236,6 +235,7 @@ Add breakpoint:
|
|||
|
||||
```python
|
||||
from IPython import embed
|
||||
|
||||
embed() # Drops into interactive shell
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -45,8 +45,7 @@ import tracemalloc
|
|||
tracemalloc.start()
|
||||
# Run your code
|
||||
current, peak = tracemalloc.get_traced_memory()
|
||||
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB",
|
||||
current / 1024**2, peak / 1024**2)
|
||||
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB", current / 1024**2, peak / 1024**2)
|
||||
tracemalloc.stop()
|
||||
```
|
||||
|
||||
|
|
@ -78,6 +77,7 @@ data = await store.async_load()
|
|||
# Already implemented in const.py
|
||||
_TRANSLATION_CACHE: dict[str, dict] = {}
|
||||
|
||||
|
||||
def get_translation(path: str, language: str) -> dict:
|
||||
cache_key = f"{path}_{language}"
|
||||
if cache_key not in _TRANSLATION_CACHE:
|
||||
|
|
@ -139,10 +139,7 @@ user_data = await fetch_user_data()
|
|||
price_data = await fetch_price_data()
|
||||
|
||||
# ✅ Concurrent (fast)
|
||||
user_data, price_data = await asyncio.gather(
|
||||
fetch_user_data(),
|
||||
fetch_price_data()
|
||||
)
|
||||
user_data, price_data = await asyncio.gather(fetch_user_data(), fetch_price_data())
|
||||
```
|
||||
|
||||
**2. Don't block event loop:**
|
||||
|
|
@ -175,6 +172,7 @@ class Coordinator:
|
|||
```python
|
||||
import weakref
|
||||
|
||||
|
||||
class Manager:
|
||||
def __init__(self):
|
||||
self._callbacks: list[weakref.ref] = []
|
||||
|
|
@ -216,8 +214,7 @@ results = (x for x in items if condition(x))
|
|||
**Monitor API usage:**
|
||||
|
||||
```python
|
||||
_LOGGER.debug("API call: %s (cache_age=%s)",
|
||||
endpoint, cache_age)
|
||||
_LOGGER.debug("API call: %s (cache_age=%s)", endpoint, cache_age)
|
||||
```
|
||||
|
||||
### Smart Updates
|
||||
|
|
@ -243,13 +240,13 @@ async def _async_update_data(self) -> dict:
|
|||
|
||||
```python
|
||||
# ❌ 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)
|
||||
state_class=None # Only current state
|
||||
state_class = None # Only current state
|
||||
|
||||
# ✅ TOTAL for counters only
|
||||
state_class=SensorStateClass.TOTAL # For cumulative values
|
||||
state_class = SensorStateClass.TOTAL # For cumulative values
|
||||
```
|
||||
|
||||
### Attribute Size
|
||||
|
|
@ -260,7 +257,7 @@ state_class=SensorStateClass.TOTAL # For cumulative values
|
|||
# ❌ Large nested structures (KB per update)
|
||||
attributes = {
|
||||
"all_intervals": [...], # 384 intervals
|
||||
"full_history": [...], # Days of data
|
||||
"full_history": [...], # Days of data
|
||||
}
|
||||
|
||||
# ✅ Essential data only (bytes per update)
|
||||
|
|
@ -279,6 +276,7 @@ attributes = {
|
|||
import pytest
|
||||
import time
|
||||
|
||||
|
||||
@pytest.mark.benchmark
|
||||
def test_period_calculation_performance(coordinator):
|
||||
"""Period calculation should complete in <100ms."""
|
||||
|
|
|
|||
|
|
@ -1336,8 +1336,8 @@ for price_data in all_prices:
|
|||
ref_date = date_key
|
||||
|
||||
criteria = TibberPricesIntervalCriteria(
|
||||
ref_price=ref_prices[ref_date], # Interval's day
|
||||
avg_price=avg_prices[ref_date], # Interval's day
|
||||
ref_price=ref_prices[ref_date], # Interval's day
|
||||
avg_price=avg_prices[ref_date], # Interval's day
|
||||
flex=flex,
|
||||
min_distance_from_avg=min_distance_from_avg,
|
||||
reverse_sort=reverse_sort,
|
||||
|
|
|
|||
|
|
@ -69,11 +69,7 @@ API requests are being throttled, causing stale data. Updates may be delayed unt
|
|||
|
||||
```python
|
||||
# In error handler
|
||||
is_rate_limit = (
|
||||
"429" in error_str
|
||||
or "rate limit" in error_str
|
||||
or "too many requests" in error_str
|
||||
)
|
||||
is_rate_limit = "429" in error_str or "rate limit" in error_str or "too many requests" in error_str
|
||||
if is_rate_limit:
|
||||
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:
|
||||
await self._clear_new_repair()
|
||||
|
||||
|
||||
async def _create_new_repair(self) -> None:
|
||||
"""Create new repair issue."""
|
||||
_LOGGER.warning("New issue detected - creating repair")
|
||||
|
|
@ -312,6 +309,7 @@ async def _create_new_repair(self) -> None:
|
|||
)
|
||||
self._new_repair_active = True
|
||||
|
||||
|
||||
async def _clear_new_repair(self) -> None:
|
||||
"""Clear new repair issue."""
|
||||
_LOGGER.debug("New issue resolved - clearing repair")
|
||||
|
|
|
|||
|
|
@ -187,9 +187,8 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
|||
class TibberPricesSensor(CoordinatorEntity):
|
||||
async def async_added_to_hass(self):
|
||||
# Register this entity's update callback
|
||||
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(
|
||||
self._handle_coordinator_update
|
||||
)
|
||||
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(self._handle_coordinator_update)
|
||||
|
||||
|
||||
# Coordinator maintains list of listeners
|
||||
class ListenerManager:
|
||||
|
|
@ -390,8 +389,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
|||
|
||||
# Watch for these log messages:
|
||||
"Fetching data from API (reason: tomorrow_check)" # API call
|
||||
"Using cached data (no update needed)" # Fast path
|
||||
"Midnight turnover detected (Timer #1)" # Turnover
|
||||
"Using cached data (no update needed)" # Fast path
|
||||
"Midnight turnover detected (Timer #1)" # Turnover
|
||||
```
|
||||
|
||||
### Check Timer #2 (Quarter-Hour)
|
||||
|
|
@ -399,7 +398,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
|||
```python
|
||||
# Watch coordinator logs:
|
||||
"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)
|
||||
|
|
|
|||
|
|
@ -263,20 +263,16 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
|
|||
|
||||
```python
|
||||
# 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)
|
||||
{
|
||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
||||
"total": 0.2534,
|
||||
"level": "NORMAL",
|
||||
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
||||
"difference": 9.6, # ← Added: % diff from trailing avg
|
||||
"rating_level": "NORMAL" # ← Added: LOW/NORMAL/HIGH based on thresholds
|
||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
||||
"total": 0.2534,
|
||||
"level": "NORMAL",
|
||||
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
||||
"difference": 9.6, # ← Added: % diff from trailing avg
|
||||
"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
|
||||
{
|
||||
"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
|
||||
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(peak_config.items()), # Peak price config
|
||||
best_level_filter, # Level filter overrides
|
||||
peak_level_filter
|
||||
best_level_filter, # Level filter overrides
|
||||
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
|
||||
class _InternalHelper:
|
||||
"""Helper used only within this module."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# ❌ Wrong - no prefix but used across modules
|
||||
class DataFetcher: # Should be TibberPricesDataFetcher
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@ These patterns were analyzed and classified as **not critical**:
|
|||
coordinator.data = {
|
||||
"user_data": {...},
|
||||
"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
|
||||
def test_periods(hass, coordinator):
|
||||
periods = coordinator.data.get('best_price_periods', [])
|
||||
periods = coordinator.data.get("best_price_periods", [])
|
||||
for period in periods:
|
||||
print(f"Period: {period['start']} to {period['end']}")
|
||||
print(f" Intervals: {len(period['intervals'])}")
|
||||
|
|
@ -147,15 +147,14 @@ grep "tibber_prices" config/home-assistant.log
|
|||
|
||||
```python
|
||||
# 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:**
|
||||
|
||||
```python
|
||||
# Add logging in sensor/core.py
|
||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
||||
self.entity_id, self._attr_native_value, new_value)
|
||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s", self.entity_id, self._attr_native_value, new_value)
|
||||
```
|
||||
|
||||
### Period Calculation Wrong
|
||||
|
|
@ -164,8 +163,7 @@ _LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
|||
|
||||
```python
|
||||
# coordinator/period_handlers/period_building.py
|
||||
_LOGGER.debug("Candidate intervals: %s",
|
||||
[(i['startsAt'], i['total']) for i in candidates])
|
||||
_LOGGER.debug("Candidate intervals: %s", [(i["startsAt"], i["total"]) for i in candidates])
|
||||
```
|
||||
|
||||
**Check filter statistics:**
|
||||
|
|
@ -217,6 +215,7 @@ Add to coordinator code:
|
|||
|
||||
```python
|
||||
import debugpy
|
||||
|
||||
debugpy.listen(5678)
|
||||
_LOGGER.info("Waiting for debugger attach on port 5678")
|
||||
debugpy.wait_for_client()
|
||||
|
|
@ -236,6 +235,7 @@ Add breakpoint:
|
|||
|
||||
```python
|
||||
from IPython import embed
|
||||
|
||||
embed() # Drops into interactive shell
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -45,8 +45,7 @@ import tracemalloc
|
|||
tracemalloc.start()
|
||||
# Run your code
|
||||
current, peak = tracemalloc.get_traced_memory()
|
||||
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB",
|
||||
current / 1024**2, peak / 1024**2)
|
||||
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB", current / 1024**2, peak / 1024**2)
|
||||
tracemalloc.stop()
|
||||
```
|
||||
|
||||
|
|
@ -78,6 +77,7 @@ data = await store.async_load()
|
|||
# Already implemented in const.py
|
||||
_TRANSLATION_CACHE: dict[str, dict] = {}
|
||||
|
||||
|
||||
def get_translation(path: str, language: str) -> dict:
|
||||
cache_key = f"{path}_{language}"
|
||||
if cache_key not in _TRANSLATION_CACHE:
|
||||
|
|
@ -139,10 +139,7 @@ user_data = await fetch_user_data()
|
|||
price_data = await fetch_price_data()
|
||||
|
||||
# ✅ Concurrent (fast)
|
||||
user_data, price_data = await asyncio.gather(
|
||||
fetch_user_data(),
|
||||
fetch_price_data()
|
||||
)
|
||||
user_data, price_data = await asyncio.gather(fetch_user_data(), fetch_price_data())
|
||||
```
|
||||
|
||||
**2. Don't block event loop:**
|
||||
|
|
@ -175,6 +172,7 @@ class Coordinator:
|
|||
```python
|
||||
import weakref
|
||||
|
||||
|
||||
class Manager:
|
||||
def __init__(self):
|
||||
self._callbacks: list[weakref.ref] = []
|
||||
|
|
@ -216,8 +214,7 @@ results = (x for x in items if condition(x))
|
|||
**Monitor API usage:**
|
||||
|
||||
```python
|
||||
_LOGGER.debug("API call: %s (cache_age=%s)",
|
||||
endpoint, cache_age)
|
||||
_LOGGER.debug("API call: %s (cache_age=%s)", endpoint, cache_age)
|
||||
```
|
||||
|
||||
### Smart Updates
|
||||
|
|
@ -243,13 +240,13 @@ async def _async_update_data(self) -> dict:
|
|||
|
||||
```python
|
||||
# ❌ 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)
|
||||
state_class=None # Only current state
|
||||
state_class = None # Only current state
|
||||
|
||||
# ✅ TOTAL for counters only
|
||||
state_class=SensorStateClass.TOTAL # For cumulative values
|
||||
state_class = SensorStateClass.TOTAL # For cumulative values
|
||||
```
|
||||
|
||||
### Attribute Size
|
||||
|
|
@ -260,7 +257,7 @@ state_class=SensorStateClass.TOTAL # For cumulative values
|
|||
# ❌ Large nested structures (KB per update)
|
||||
attributes = {
|
||||
"all_intervals": [...], # 384 intervals
|
||||
"full_history": [...], # Days of data
|
||||
"full_history": [...], # Days of data
|
||||
}
|
||||
|
||||
# ✅ Essential data only (bytes per update)
|
||||
|
|
@ -279,6 +276,7 @@ attributes = {
|
|||
import pytest
|
||||
import time
|
||||
|
||||
|
||||
@pytest.mark.benchmark
|
||||
def test_period_calculation_performance(coordinator):
|
||||
"""Period calculation should complete in <100ms."""
|
||||
|
|
|
|||
|
|
@ -1092,8 +1092,8 @@ for price_data in all_prices:
|
|||
ref_date = date_key
|
||||
|
||||
criteria = TibberPricesIntervalCriteria(
|
||||
ref_price=ref_prices[ref_date], # Interval's day
|
||||
avg_price=avg_prices[ref_date], # Interval's day
|
||||
ref_price=ref_prices[ref_date], # Interval's day
|
||||
avg_price=avg_prices[ref_date], # Interval's day
|
||||
flex=flex,
|
||||
min_distance_from_avg=min_distance_from_avg,
|
||||
reverse_sort=reverse_sort,
|
||||
|
|
|
|||
|
|
@ -69,11 +69,7 @@ API requests are being throttled, causing stale data. Updates may be delayed unt
|
|||
|
||||
```python
|
||||
# In error handler
|
||||
is_rate_limit = (
|
||||
"429" in error_str
|
||||
or "rate limit" in error_str
|
||||
or "too many requests" in error_str
|
||||
)
|
||||
is_rate_limit = "429" in error_str or "rate limit" in error_str or "too many requests" in error_str
|
||||
if is_rate_limit:
|
||||
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:
|
||||
await self._clear_new_repair()
|
||||
|
||||
|
||||
async def _create_new_repair(self) -> None:
|
||||
"""Create new repair issue."""
|
||||
_LOGGER.warning("New issue detected - creating repair")
|
||||
|
|
@ -312,6 +309,7 @@ async def _create_new_repair(self) -> None:
|
|||
)
|
||||
self._new_repair_active = True
|
||||
|
||||
|
||||
async def _clear_new_repair(self) -> None:
|
||||
"""Clear new repair issue."""
|
||||
_LOGGER.debug("New issue resolved - clearing repair")
|
||||
|
|
|
|||
|
|
@ -187,9 +187,8 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
|||
class TibberPricesSensor(CoordinatorEntity):
|
||||
async def async_added_to_hass(self):
|
||||
# Register this entity's update callback
|
||||
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(
|
||||
self._handle_coordinator_update
|
||||
)
|
||||
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(self._handle_coordinator_update)
|
||||
|
||||
|
||||
# Coordinator maintains list of listeners
|
||||
class ListenerManager:
|
||||
|
|
@ -390,8 +389,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
|||
|
||||
# Watch for these log messages:
|
||||
"Fetching data from API (reason: tomorrow_check)" # API call
|
||||
"Using cached data (no update needed)" # Fast path
|
||||
"Midnight turnover detected (Timer #1)" # Turnover
|
||||
"Using cached data (no update needed)" # Fast path
|
||||
"Midnight turnover detected (Timer #1)" # Turnover
|
||||
```
|
||||
|
||||
### Check Timer #2 (Quarter-Hour)
|
||||
|
|
@ -399,7 +398,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
|||
```python
|
||||
# Watch coordinator logs:
|
||||
"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)
|
||||
|
|
|
|||
|
|
@ -263,20 +263,16 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
|
|||
|
||||
```python
|
||||
# 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)
|
||||
{
|
||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
||||
"total": 0.2534,
|
||||
"level": "NORMAL",
|
||||
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
||||
"difference": 9.6, # ← Added: % diff from trailing avg
|
||||
"rating_level": "NORMAL" # ← Added: LOW/NORMAL/HIGH based on thresholds
|
||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
||||
"total": 0.2534,
|
||||
"level": "NORMAL",
|
||||
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
||||
"difference": 9.6, # ← Added: % diff from trailing avg
|
||||
"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
|
||||
{
|
||||
"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
|
||||
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(peak_config.items()), # Peak price config
|
||||
best_level_filter, # Level filter overrides
|
||||
peak_level_filter
|
||||
best_level_filter, # Level filter overrides
|
||||
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
|
||||
class _InternalHelper:
|
||||
"""Helper used only within this module."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# ❌ Wrong - no prefix but used across modules
|
||||
class DataFetcher: # Should be TibberPricesDataFetcher
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@ These patterns were analyzed and classified as **not critical**:
|
|||
coordinator.data = {
|
||||
"user_data": {...},
|
||||
"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
|
||||
def test_periods(hass, coordinator):
|
||||
periods = coordinator.data.get('best_price_periods', [])
|
||||
periods = coordinator.data.get("best_price_periods", [])
|
||||
for period in periods:
|
||||
print(f"Period: {period['start']} to {period['end']}")
|
||||
print(f" Intervals: {len(period['intervals'])}")
|
||||
|
|
@ -147,15 +147,14 @@ grep "tibber_prices" config/home-assistant.log
|
|||
|
||||
```python
|
||||
# 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:**
|
||||
|
||||
```python
|
||||
# Add logging in sensor/core.py
|
||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
||||
self.entity_id, self._attr_native_value, new_value)
|
||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s", self.entity_id, self._attr_native_value, new_value)
|
||||
```
|
||||
|
||||
### Period Calculation Wrong
|
||||
|
|
@ -164,8 +163,7 @@ _LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
|||
|
||||
```python
|
||||
# coordinator/period_handlers/period_building.py
|
||||
_LOGGER.debug("Candidate intervals: %s",
|
||||
[(i['startsAt'], i['total']) for i in candidates])
|
||||
_LOGGER.debug("Candidate intervals: %s", [(i["startsAt"], i["total"]) for i in candidates])
|
||||
```
|
||||
|
||||
**Check filter statistics:**
|
||||
|
|
@ -217,6 +215,7 @@ Add to coordinator code:
|
|||
|
||||
```python
|
||||
import debugpy
|
||||
|
||||
debugpy.listen(5678)
|
||||
_LOGGER.info("Waiting for debugger attach on port 5678")
|
||||
debugpy.wait_for_client()
|
||||
|
|
@ -236,6 +235,7 @@ Add breakpoint:
|
|||
|
||||
```python
|
||||
from IPython import embed
|
||||
|
||||
embed() # Drops into interactive shell
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -45,8 +45,7 @@ import tracemalloc
|
|||
tracemalloc.start()
|
||||
# Run your code
|
||||
current, peak = tracemalloc.get_traced_memory()
|
||||
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB",
|
||||
current / 1024**2, peak / 1024**2)
|
||||
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB", current / 1024**2, peak / 1024**2)
|
||||
tracemalloc.stop()
|
||||
```
|
||||
|
||||
|
|
@ -78,6 +77,7 @@ data = await store.async_load()
|
|||
# Already implemented in const.py
|
||||
_TRANSLATION_CACHE: dict[str, dict] = {}
|
||||
|
||||
|
||||
def get_translation(path: str, language: str) -> dict:
|
||||
cache_key = f"{path}_{language}"
|
||||
if cache_key not in _TRANSLATION_CACHE:
|
||||
|
|
@ -139,10 +139,7 @@ user_data = await fetch_user_data()
|
|||
price_data = await fetch_price_data()
|
||||
|
||||
# ✅ Concurrent (fast)
|
||||
user_data, price_data = await asyncio.gather(
|
||||
fetch_user_data(),
|
||||
fetch_price_data()
|
||||
)
|
||||
user_data, price_data = await asyncio.gather(fetch_user_data(), fetch_price_data())
|
||||
```
|
||||
|
||||
**2. Don't block event loop:**
|
||||
|
|
@ -175,6 +172,7 @@ class Coordinator:
|
|||
```python
|
||||
import weakref
|
||||
|
||||
|
||||
class Manager:
|
||||
def __init__(self):
|
||||
self._callbacks: list[weakref.ref] = []
|
||||
|
|
@ -216,8 +214,7 @@ results = (x for x in items if condition(x))
|
|||
**Monitor API usage:**
|
||||
|
||||
```python
|
||||
_LOGGER.debug("API call: %s (cache_age=%s)",
|
||||
endpoint, cache_age)
|
||||
_LOGGER.debug("API call: %s (cache_age=%s)", endpoint, cache_age)
|
||||
```
|
||||
|
||||
### Smart Updates
|
||||
|
|
@ -243,13 +240,13 @@ async def _async_update_data(self) -> dict:
|
|||
|
||||
```python
|
||||
# ❌ 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)
|
||||
state_class=None # Only current state
|
||||
state_class = None # Only current state
|
||||
|
||||
# ✅ TOTAL for counters only
|
||||
state_class=SensorStateClass.TOTAL # For cumulative values
|
||||
state_class = SensorStateClass.TOTAL # For cumulative values
|
||||
```
|
||||
|
||||
### Attribute Size
|
||||
|
|
@ -260,7 +257,7 @@ state_class=SensorStateClass.TOTAL # For cumulative values
|
|||
# ❌ Large nested structures (KB per update)
|
||||
attributes = {
|
||||
"all_intervals": [...], # 384 intervals
|
||||
"full_history": [...], # Days of data
|
||||
"full_history": [...], # Days of data
|
||||
}
|
||||
|
||||
# ✅ Essential data only (bytes per update)
|
||||
|
|
@ -279,6 +276,7 @@ attributes = {
|
|||
import pytest
|
||||
import time
|
||||
|
||||
|
||||
@pytest.mark.benchmark
|
||||
def test_period_calculation_performance(coordinator):
|
||||
"""Period calculation should complete in <100ms."""
|
||||
|
|
|
|||
|
|
@ -1092,8 +1092,8 @@ for price_data in all_prices:
|
|||
ref_date = date_key
|
||||
|
||||
criteria = TibberPricesIntervalCriteria(
|
||||
ref_price=ref_prices[ref_date], # Interval's day
|
||||
avg_price=avg_prices[ref_date], # Interval's day
|
||||
ref_price=ref_prices[ref_date], # Interval's day
|
||||
avg_price=avg_prices[ref_date], # Interval's day
|
||||
flex=flex,
|
||||
min_distance_from_avg=min_distance_from_avg,
|
||||
reverse_sort=reverse_sort,
|
||||
|
|
|
|||
|
|
@ -69,11 +69,7 @@ API requests are being throttled, causing stale data. Updates may be delayed unt
|
|||
|
||||
```python
|
||||
# In error handler
|
||||
is_rate_limit = (
|
||||
"429" in error_str
|
||||
or "rate limit" in error_str
|
||||
or "too many requests" in error_str
|
||||
)
|
||||
is_rate_limit = "429" in error_str or "rate limit" in error_str or "too many requests" in error_str
|
||||
if is_rate_limit:
|
||||
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:
|
||||
await self._clear_new_repair()
|
||||
|
||||
|
||||
async def _create_new_repair(self) -> None:
|
||||
"""Create new repair issue."""
|
||||
_LOGGER.warning("New issue detected - creating repair")
|
||||
|
|
@ -312,6 +309,7 @@ async def _create_new_repair(self) -> None:
|
|||
)
|
||||
self._new_repair_active = True
|
||||
|
||||
|
||||
async def _clear_new_repair(self) -> None:
|
||||
"""Clear new repair issue."""
|
||||
_LOGGER.debug("New issue resolved - clearing repair")
|
||||
|
|
|
|||
|
|
@ -187,9 +187,8 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
|||
class TibberPricesSensor(CoordinatorEntity):
|
||||
async def async_added_to_hass(self):
|
||||
# Register this entity's update callback
|
||||
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(
|
||||
self._handle_coordinator_update
|
||||
)
|
||||
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(self._handle_coordinator_update)
|
||||
|
||||
|
||||
# Coordinator maintains list of listeners
|
||||
class ListenerManager:
|
||||
|
|
@ -390,8 +389,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
|||
|
||||
# Watch for these log messages:
|
||||
"Fetching data from API (reason: tomorrow_check)" # API call
|
||||
"Using cached data (no update needed)" # Fast path
|
||||
"Midnight turnover detected (Timer #1)" # Turnover
|
||||
"Using cached data (no update needed)" # Fast path
|
||||
"Midnight turnover detected (Timer #1)" # Turnover
|
||||
```
|
||||
|
||||
### Check Timer #2 (Quarter-Hour)
|
||||
|
|
@ -399,7 +398,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
|||
```python
|
||||
# Watch coordinator logs:
|
||||
"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)
|
||||
|
|
|
|||
|
|
@ -263,20 +263,16 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
|
|||
|
||||
```python
|
||||
# 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)
|
||||
{
|
||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
||||
"total": 0.2534,
|
||||
"level": "NORMAL",
|
||||
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
||||
"difference": 9.6, # ← Added: % diff from trailing avg
|
||||
"rating_level": "NORMAL" # ← Added: LOW/NORMAL/HIGH based on thresholds
|
||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
||||
"total": 0.2534,
|
||||
"level": "NORMAL",
|
||||
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
||||
"difference": 9.6, # ← Added: % diff from trailing avg
|
||||
"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
|
||||
{
|
||||
"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
|
||||
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(peak_config.items()), # Peak price config
|
||||
best_level_filter, # Level filter overrides
|
||||
peak_level_filter
|
||||
best_level_filter, # Level filter overrides
|
||||
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
|
||||
class _InternalHelper:
|
||||
"""Helper used only within this module."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# ❌ Wrong - no prefix but used across modules
|
||||
class DataFetcher: # Should be TibberPricesDataFetcher
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@ These patterns were analyzed and classified as **not critical**:
|
|||
coordinator.data = {
|
||||
"user_data": {...},
|
||||
"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
|
||||
def test_periods(hass, coordinator):
|
||||
periods = coordinator.data.get('best_price_periods', [])
|
||||
periods = coordinator.data.get("best_price_periods", [])
|
||||
for period in periods:
|
||||
print(f"Period: {period['start']} to {period['end']}")
|
||||
print(f" Intervals: {len(period['intervals'])}")
|
||||
|
|
@ -147,15 +147,14 @@ grep "tibber_prices" config/home-assistant.log
|
|||
|
||||
```python
|
||||
# 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:**
|
||||
|
||||
```python
|
||||
# Add logging in sensor/core.py
|
||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
||||
self.entity_id, self._attr_native_value, new_value)
|
||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s", self.entity_id, self._attr_native_value, new_value)
|
||||
```
|
||||
|
||||
### Period Calculation Wrong
|
||||
|
|
@ -164,8 +163,7 @@ _LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
|||
|
||||
```python
|
||||
# coordinator/period_handlers/period_building.py
|
||||
_LOGGER.debug("Candidate intervals: %s",
|
||||
[(i['startsAt'], i['total']) for i in candidates])
|
||||
_LOGGER.debug("Candidate intervals: %s", [(i["startsAt"], i["total"]) for i in candidates])
|
||||
```
|
||||
|
||||
**Check filter statistics:**
|
||||
|
|
@ -217,6 +215,7 @@ Add to coordinator code:
|
|||
|
||||
```python
|
||||
import debugpy
|
||||
|
||||
debugpy.listen(5678)
|
||||
_LOGGER.info("Waiting for debugger attach on port 5678")
|
||||
debugpy.wait_for_client()
|
||||
|
|
@ -236,6 +235,7 @@ Add breakpoint:
|
|||
|
||||
```python
|
||||
from IPython import embed
|
||||
|
||||
embed() # Drops into interactive shell
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -45,8 +45,7 @@ import tracemalloc
|
|||
tracemalloc.start()
|
||||
# Run your code
|
||||
current, peak = tracemalloc.get_traced_memory()
|
||||
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB",
|
||||
current / 1024**2, peak / 1024**2)
|
||||
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB", current / 1024**2, peak / 1024**2)
|
||||
tracemalloc.stop()
|
||||
```
|
||||
|
||||
|
|
@ -78,6 +77,7 @@ data = await store.async_load()
|
|||
# Already implemented in const.py
|
||||
_TRANSLATION_CACHE: dict[str, dict] = {}
|
||||
|
||||
|
||||
def get_translation(path: str, language: str) -> dict:
|
||||
cache_key = f"{path}_{language}"
|
||||
if cache_key not in _TRANSLATION_CACHE:
|
||||
|
|
@ -139,10 +139,7 @@ user_data = await fetch_user_data()
|
|||
price_data = await fetch_price_data()
|
||||
|
||||
# ✅ Concurrent (fast)
|
||||
user_data, price_data = await asyncio.gather(
|
||||
fetch_user_data(),
|
||||
fetch_price_data()
|
||||
)
|
||||
user_data, price_data = await asyncio.gather(fetch_user_data(), fetch_price_data())
|
||||
```
|
||||
|
||||
**2. Don't block event loop:**
|
||||
|
|
@ -175,6 +172,7 @@ class Coordinator:
|
|||
```python
|
||||
import weakref
|
||||
|
||||
|
||||
class Manager:
|
||||
def __init__(self):
|
||||
self._callbacks: list[weakref.ref] = []
|
||||
|
|
@ -216,8 +214,7 @@ results = (x for x in items if condition(x))
|
|||
**Monitor API usage:**
|
||||
|
||||
```python
|
||||
_LOGGER.debug("API call: %s (cache_age=%s)",
|
||||
endpoint, cache_age)
|
||||
_LOGGER.debug("API call: %s (cache_age=%s)", endpoint, cache_age)
|
||||
```
|
||||
|
||||
### Smart Updates
|
||||
|
|
@ -243,13 +240,13 @@ async def _async_update_data(self) -> dict:
|
|||
|
||||
```python
|
||||
# ❌ 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)
|
||||
state_class=None # Only current state
|
||||
state_class = None # Only current state
|
||||
|
||||
# ✅ TOTAL for counters only
|
||||
state_class=SensorStateClass.TOTAL # For cumulative values
|
||||
state_class = SensorStateClass.TOTAL # For cumulative values
|
||||
```
|
||||
|
||||
### Attribute Size
|
||||
|
|
@ -260,7 +257,7 @@ state_class=SensorStateClass.TOTAL # For cumulative values
|
|||
# ❌ Large nested structures (KB per update)
|
||||
attributes = {
|
||||
"all_intervals": [...], # 384 intervals
|
||||
"full_history": [...], # Days of data
|
||||
"full_history": [...], # Days of data
|
||||
}
|
||||
|
||||
# ✅ Essential data only (bytes per update)
|
||||
|
|
@ -279,6 +276,7 @@ attributes = {
|
|||
import pytest
|
||||
import time
|
||||
|
||||
|
||||
@pytest.mark.benchmark
|
||||
def test_period_calculation_performance(coordinator):
|
||||
"""Period calculation should complete in <100ms."""
|
||||
|
|
|
|||
|
|
@ -1092,8 +1092,8 @@ for price_data in all_prices:
|
|||
ref_date = date_key
|
||||
|
||||
criteria = TibberPricesIntervalCriteria(
|
||||
ref_price=ref_prices[ref_date], # Interval's day
|
||||
avg_price=avg_prices[ref_date], # Interval's day
|
||||
ref_price=ref_prices[ref_date], # Interval's day
|
||||
avg_price=avg_prices[ref_date], # Interval's day
|
||||
flex=flex,
|
||||
min_distance_from_avg=min_distance_from_avg,
|
||||
reverse_sort=reverse_sort,
|
||||
|
|
|
|||
|
|
@ -69,11 +69,7 @@ API requests are being throttled, causing stale data. Updates may be delayed unt
|
|||
|
||||
```python
|
||||
# In error handler
|
||||
is_rate_limit = (
|
||||
"429" in error_str
|
||||
or "rate limit" in error_str
|
||||
or "too many requests" in error_str
|
||||
)
|
||||
is_rate_limit = "429" in error_str or "rate limit" in error_str or "too many requests" in error_str
|
||||
if is_rate_limit:
|
||||
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:
|
||||
await self._clear_new_repair()
|
||||
|
||||
|
||||
async def _create_new_repair(self) -> None:
|
||||
"""Create new repair issue."""
|
||||
_LOGGER.warning("New issue detected - creating repair")
|
||||
|
|
@ -312,6 +309,7 @@ async def _create_new_repair(self) -> None:
|
|||
)
|
||||
self._new_repair_active = True
|
||||
|
||||
|
||||
async def _clear_new_repair(self) -> None:
|
||||
"""Clear new repair issue."""
|
||||
_LOGGER.debug("New issue resolved - clearing repair")
|
||||
|
|
|
|||
|
|
@ -187,9 +187,8 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
|||
class TibberPricesSensor(CoordinatorEntity):
|
||||
async def async_added_to_hass(self):
|
||||
# Register this entity's update callback
|
||||
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(
|
||||
self._handle_coordinator_update
|
||||
)
|
||||
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(self._handle_coordinator_update)
|
||||
|
||||
|
||||
# Coordinator maintains list of listeners
|
||||
class ListenerManager:
|
||||
|
|
@ -390,8 +389,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
|||
|
||||
# Watch for these log messages:
|
||||
"Fetching data from API (reason: tomorrow_check)" # API call
|
||||
"Using cached data (no update needed)" # Fast path
|
||||
"Midnight turnover detected (Timer #1)" # Turnover
|
||||
"Using cached data (no update needed)" # Fast path
|
||||
"Midnight turnover detected (Timer #1)" # Turnover
|
||||
```
|
||||
|
||||
### Check Timer #2 (Quarter-Hour)
|
||||
|
|
@ -399,7 +398,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
|||
```python
|
||||
# Watch coordinator logs:
|
||||
"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)
|
||||
|
|
|
|||
|
|
@ -263,20 +263,16 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
|
|||
|
||||
```python
|
||||
# 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)
|
||||
{
|
||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
||||
"total": 0.2534,
|
||||
"level": "NORMAL",
|
||||
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
||||
"difference": 9.6, # ← Added: % diff from trailing avg
|
||||
"rating_level": "NORMAL" # ← Added: LOW/NORMAL/HIGH based on thresholds
|
||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
||||
"total": 0.2534,
|
||||
"level": "NORMAL",
|
||||
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
||||
"difference": 9.6, # ← Added: % diff from trailing avg
|
||||
"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
|
||||
{
|
||||
"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
|
||||
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(peak_config.items()), # Peak price config
|
||||
best_level_filter, # Level filter overrides
|
||||
peak_level_filter
|
||||
best_level_filter, # Level filter overrides
|
||||
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
|
||||
class _InternalHelper:
|
||||
"""Helper used only within this module."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# ❌ Wrong - no prefix but used across modules
|
||||
class DataFetcher: # Should be TibberPricesDataFetcher
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@ These patterns were analyzed and classified as **not critical**:
|
|||
coordinator.data = {
|
||||
"user_data": {...},
|
||||
"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
|
||||
def test_periods(hass, coordinator):
|
||||
periods = coordinator.data.get('best_price_periods', [])
|
||||
periods = coordinator.data.get("best_price_periods", [])
|
||||
for period in periods:
|
||||
print(f"Period: {period['start']} to {period['end']}")
|
||||
print(f" Intervals: {len(period['intervals'])}")
|
||||
|
|
@ -147,15 +147,14 @@ grep "tibber_prices" config/home-assistant.log
|
|||
|
||||
```python
|
||||
# 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:**
|
||||
|
||||
```python
|
||||
# Add logging in sensor/core.py
|
||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
||||
self.entity_id, self._attr_native_value, new_value)
|
||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s", self.entity_id, self._attr_native_value, new_value)
|
||||
```
|
||||
|
||||
### Period Calculation Wrong
|
||||
|
|
@ -164,8 +163,7 @@ _LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
|||
|
||||
```python
|
||||
# coordinator/period_handlers/period_building.py
|
||||
_LOGGER.debug("Candidate intervals: %s",
|
||||
[(i['startsAt'], i['total']) for i in candidates])
|
||||
_LOGGER.debug("Candidate intervals: %s", [(i["startsAt"], i["total"]) for i in candidates])
|
||||
```
|
||||
|
||||
**Check filter statistics:**
|
||||
|
|
@ -217,6 +215,7 @@ Add to coordinator code:
|
|||
|
||||
```python
|
||||
import debugpy
|
||||
|
||||
debugpy.listen(5678)
|
||||
_LOGGER.info("Waiting for debugger attach on port 5678")
|
||||
debugpy.wait_for_client()
|
||||
|
|
@ -236,6 +235,7 @@ Add breakpoint:
|
|||
|
||||
```python
|
||||
from IPython import embed
|
||||
|
||||
embed() # Drops into interactive shell
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -45,8 +45,7 @@ import tracemalloc
|
|||
tracemalloc.start()
|
||||
# Run your code
|
||||
current, peak = tracemalloc.get_traced_memory()
|
||||
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB",
|
||||
current / 1024**2, peak / 1024**2)
|
||||
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB", current / 1024**2, peak / 1024**2)
|
||||
tracemalloc.stop()
|
||||
```
|
||||
|
||||
|
|
@ -78,6 +77,7 @@ data = await store.async_load()
|
|||
# Already implemented in const.py
|
||||
_TRANSLATION_CACHE: dict[str, dict] = {}
|
||||
|
||||
|
||||
def get_translation(path: str, language: str) -> dict:
|
||||
cache_key = f"{path}_{language}"
|
||||
if cache_key not in _TRANSLATION_CACHE:
|
||||
|
|
@ -139,10 +139,7 @@ user_data = await fetch_user_data()
|
|||
price_data = await fetch_price_data()
|
||||
|
||||
# ✅ Concurrent (fast)
|
||||
user_data, price_data = await asyncio.gather(
|
||||
fetch_user_data(),
|
||||
fetch_price_data()
|
||||
)
|
||||
user_data, price_data = await asyncio.gather(fetch_user_data(), fetch_price_data())
|
||||
```
|
||||
|
||||
**2. Don't block event loop:**
|
||||
|
|
@ -175,6 +172,7 @@ class Coordinator:
|
|||
```python
|
||||
import weakref
|
||||
|
||||
|
||||
class Manager:
|
||||
def __init__(self):
|
||||
self._callbacks: list[weakref.ref] = []
|
||||
|
|
@ -216,8 +214,7 @@ results = (x for x in items if condition(x))
|
|||
**Monitor API usage:**
|
||||
|
||||
```python
|
||||
_LOGGER.debug("API call: %s (cache_age=%s)",
|
||||
endpoint, cache_age)
|
||||
_LOGGER.debug("API call: %s (cache_age=%s)", endpoint, cache_age)
|
||||
```
|
||||
|
||||
### Smart Updates
|
||||
|
|
@ -243,13 +240,13 @@ async def _async_update_data(self) -> dict:
|
|||
|
||||
```python
|
||||
# ❌ 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)
|
||||
state_class=None # Only current state
|
||||
state_class = None # Only current state
|
||||
|
||||
# ✅ TOTAL for counters only
|
||||
state_class=SensorStateClass.TOTAL # For cumulative values
|
||||
state_class = SensorStateClass.TOTAL # For cumulative values
|
||||
```
|
||||
|
||||
### Attribute Size
|
||||
|
|
@ -260,7 +257,7 @@ state_class=SensorStateClass.TOTAL # For cumulative values
|
|||
# ❌ Large nested structures (KB per update)
|
||||
attributes = {
|
||||
"all_intervals": [...], # 384 intervals
|
||||
"full_history": [...], # Days of data
|
||||
"full_history": [...], # Days of data
|
||||
}
|
||||
|
||||
# ✅ Essential data only (bytes per update)
|
||||
|
|
@ -279,6 +276,7 @@ attributes = {
|
|||
import pytest
|
||||
import time
|
||||
|
||||
|
||||
@pytest.mark.benchmark
|
||||
def test_period_calculation_performance(coordinator):
|
||||
"""Period calculation should complete in <100ms."""
|
||||
|
|
|
|||
|
|
@ -1092,8 +1092,8 @@ for price_data in all_prices:
|
|||
ref_date = date_key
|
||||
|
||||
criteria = TibberPricesIntervalCriteria(
|
||||
ref_price=ref_prices[ref_date], # Interval's day
|
||||
avg_price=avg_prices[ref_date], # Interval's day
|
||||
ref_price=ref_prices[ref_date], # Interval's day
|
||||
avg_price=avg_prices[ref_date], # Interval's day
|
||||
flex=flex,
|
||||
min_distance_from_avg=min_distance_from_avg,
|
||||
reverse_sort=reverse_sort,
|
||||
|
|
|
|||
|
|
@ -69,11 +69,7 @@ API requests are being throttled, causing stale data. Updates may be delayed unt
|
|||
|
||||
```python
|
||||
# In error handler
|
||||
is_rate_limit = (
|
||||
"429" in error_str
|
||||
or "rate limit" in error_str
|
||||
or "too many requests" in error_str
|
||||
)
|
||||
is_rate_limit = "429" in error_str or "rate limit" in error_str or "too many requests" in error_str
|
||||
if is_rate_limit:
|
||||
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:
|
||||
await self._clear_new_repair()
|
||||
|
||||
|
||||
async def _create_new_repair(self) -> None:
|
||||
"""Create new repair issue."""
|
||||
_LOGGER.warning("New issue detected - creating repair")
|
||||
|
|
@ -312,6 +309,7 @@ async def _create_new_repair(self) -> None:
|
|||
)
|
||||
self._new_repair_active = True
|
||||
|
||||
|
||||
async def _clear_new_repair(self) -> None:
|
||||
"""Clear new repair issue."""
|
||||
_LOGGER.debug("New issue resolved - clearing repair")
|
||||
|
|
|
|||
|
|
@ -187,9 +187,8 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
|||
class TibberPricesSensor(CoordinatorEntity):
|
||||
async def async_added_to_hass(self):
|
||||
# Register this entity's update callback
|
||||
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(
|
||||
self._handle_coordinator_update
|
||||
)
|
||||
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(self._handle_coordinator_update)
|
||||
|
||||
|
||||
# Coordinator maintains list of listeners
|
||||
class ListenerManager:
|
||||
|
|
@ -390,8 +389,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
|||
|
||||
# Watch for these log messages:
|
||||
"Fetching data from API (reason: tomorrow_check)" # API call
|
||||
"Using cached data (no update needed)" # Fast path
|
||||
"Midnight turnover detected (Timer #1)" # Turnover
|
||||
"Using cached data (no update needed)" # Fast path
|
||||
"Midnight turnover detected (Timer #1)" # Turnover
|
||||
```
|
||||
|
||||
### Check Timer #2 (Quarter-Hour)
|
||||
|
|
@ -399,7 +398,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
|||
```python
|
||||
# Watch coordinator logs:
|
||||
"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)
|
||||
|
|
|
|||
|
|
@ -263,20 +263,16 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
|
|||
|
||||
```python
|
||||
# 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)
|
||||
{
|
||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
||||
"total": 0.2534,
|
||||
"level": "NORMAL",
|
||||
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
||||
"difference": 9.6, # ← Added: % diff from trailing avg
|
||||
"rating_level": "NORMAL" # ← Added: LOW/NORMAL/HIGH based on thresholds
|
||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
||||
"total": 0.2534,
|
||||
"level": "NORMAL",
|
||||
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
||||
"difference": 9.6, # ← Added: % diff from trailing avg
|
||||
"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
|
||||
{
|
||||
"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
|
||||
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(peak_config.items()), # Peak price config
|
||||
best_level_filter, # Level filter overrides
|
||||
peak_level_filter
|
||||
best_level_filter, # Level filter overrides
|
||||
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
|
||||
class _InternalHelper:
|
||||
"""Helper used only within this module."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# ❌ Wrong - no prefix but used across modules
|
||||
class DataFetcher: # Should be TibberPricesDataFetcher
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@ These patterns were analyzed and classified as **not critical**:
|
|||
coordinator.data = {
|
||||
"user_data": {...},
|
||||
"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
|
||||
def test_periods(hass, coordinator):
|
||||
periods = coordinator.data.get('best_price_periods', [])
|
||||
periods = coordinator.data.get("best_price_periods", [])
|
||||
for period in periods:
|
||||
print(f"Period: {period['start']} to {period['end']}")
|
||||
print(f" Intervals: {len(period['intervals'])}")
|
||||
|
|
@ -147,15 +147,14 @@ grep "tibber_prices" config/home-assistant.log
|
|||
|
||||
```python
|
||||
# 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:**
|
||||
|
||||
```python
|
||||
# Add logging in sensor/core.py
|
||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
||||
self.entity_id, self._attr_native_value, new_value)
|
||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s", self.entity_id, self._attr_native_value, new_value)
|
||||
```
|
||||
|
||||
### Period Calculation Wrong
|
||||
|
|
@ -164,8 +163,7 @@ _LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
|||
|
||||
```python
|
||||
# coordinator/period_handlers/period_building.py
|
||||
_LOGGER.debug("Candidate intervals: %s",
|
||||
[(i['startsAt'], i['total']) for i in candidates])
|
||||
_LOGGER.debug("Candidate intervals: %s", [(i["startsAt"], i["total"]) for i in candidates])
|
||||
```
|
||||
|
||||
**Check filter statistics:**
|
||||
|
|
@ -217,6 +215,7 @@ Add to coordinator code:
|
|||
|
||||
```python
|
||||
import debugpy
|
||||
|
||||
debugpy.listen(5678)
|
||||
_LOGGER.info("Waiting for debugger attach on port 5678")
|
||||
debugpy.wait_for_client()
|
||||
|
|
@ -236,6 +235,7 @@ Add breakpoint:
|
|||
|
||||
```python
|
||||
from IPython import embed
|
||||
|
||||
embed() # Drops into interactive shell
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -45,8 +45,7 @@ import tracemalloc
|
|||
tracemalloc.start()
|
||||
# Run your code
|
||||
current, peak = tracemalloc.get_traced_memory()
|
||||
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB",
|
||||
current / 1024**2, peak / 1024**2)
|
||||
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB", current / 1024**2, peak / 1024**2)
|
||||
tracemalloc.stop()
|
||||
```
|
||||
|
||||
|
|
@ -78,6 +77,7 @@ data = await store.async_load()
|
|||
# Already implemented in const.py
|
||||
_TRANSLATION_CACHE: dict[str, dict] = {}
|
||||
|
||||
|
||||
def get_translation(path: str, language: str) -> dict:
|
||||
cache_key = f"{path}_{language}"
|
||||
if cache_key not in _TRANSLATION_CACHE:
|
||||
|
|
@ -139,10 +139,7 @@ user_data = await fetch_user_data()
|
|||
price_data = await fetch_price_data()
|
||||
|
||||
# ✅ Concurrent (fast)
|
||||
user_data, price_data = await asyncio.gather(
|
||||
fetch_user_data(),
|
||||
fetch_price_data()
|
||||
)
|
||||
user_data, price_data = await asyncio.gather(fetch_user_data(), fetch_price_data())
|
||||
```
|
||||
|
||||
**2. Don't block event loop:**
|
||||
|
|
@ -175,6 +172,7 @@ class Coordinator:
|
|||
```python
|
||||
import weakref
|
||||
|
||||
|
||||
class Manager:
|
||||
def __init__(self):
|
||||
self._callbacks: list[weakref.ref] = []
|
||||
|
|
@ -216,8 +214,7 @@ results = (x for x in items if condition(x))
|
|||
**Monitor API usage:**
|
||||
|
||||
```python
|
||||
_LOGGER.debug("API call: %s (cache_age=%s)",
|
||||
endpoint, cache_age)
|
||||
_LOGGER.debug("API call: %s (cache_age=%s)", endpoint, cache_age)
|
||||
```
|
||||
|
||||
### Smart Updates
|
||||
|
|
@ -243,13 +240,13 @@ async def _async_update_data(self) -> dict:
|
|||
|
||||
```python
|
||||
# ❌ 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)
|
||||
state_class=None # Only current state
|
||||
state_class = None # Only current state
|
||||
|
||||
# ✅ TOTAL for counters only
|
||||
state_class=SensorStateClass.TOTAL # For cumulative values
|
||||
state_class = SensorStateClass.TOTAL # For cumulative values
|
||||
```
|
||||
|
||||
### Attribute Size
|
||||
|
|
@ -260,7 +257,7 @@ state_class=SensorStateClass.TOTAL # For cumulative values
|
|||
# ❌ Large nested structures (KB per update)
|
||||
attributes = {
|
||||
"all_intervals": [...], # 384 intervals
|
||||
"full_history": [...], # Days of data
|
||||
"full_history": [...], # Days of data
|
||||
}
|
||||
|
||||
# ✅ Essential data only (bytes per update)
|
||||
|
|
@ -279,6 +276,7 @@ attributes = {
|
|||
import pytest
|
||||
import time
|
||||
|
||||
|
||||
@pytest.mark.benchmark
|
||||
def test_period_calculation_performance(coordinator):
|
||||
"""Period calculation should complete in <100ms."""
|
||||
|
|
|
|||
|
|
@ -1092,8 +1092,8 @@ for price_data in all_prices:
|
|||
ref_date = date_key
|
||||
|
||||
criteria = TibberPricesIntervalCriteria(
|
||||
ref_price=ref_prices[ref_date], # Interval's day
|
||||
avg_price=avg_prices[ref_date], # Interval's day
|
||||
ref_price=ref_prices[ref_date], # Interval's day
|
||||
avg_price=avg_prices[ref_date], # Interval's day
|
||||
flex=flex,
|
||||
min_distance_from_avg=min_distance_from_avg,
|
||||
reverse_sort=reverse_sort,
|
||||
|
|
|
|||
|
|
@ -69,11 +69,7 @@ API requests are being throttled, causing stale data. Updates may be delayed unt
|
|||
|
||||
```python
|
||||
# In error handler
|
||||
is_rate_limit = (
|
||||
"429" in error_str
|
||||
or "rate limit" in error_str
|
||||
or "too many requests" in error_str
|
||||
)
|
||||
is_rate_limit = "429" in error_str or "rate limit" in error_str or "too many requests" in error_str
|
||||
if is_rate_limit:
|
||||
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:
|
||||
await self._clear_new_repair()
|
||||
|
||||
|
||||
async def _create_new_repair(self) -> None:
|
||||
"""Create new repair issue."""
|
||||
_LOGGER.warning("New issue detected - creating repair")
|
||||
|
|
@ -312,6 +309,7 @@ async def _create_new_repair(self) -> None:
|
|||
)
|
||||
self._new_repair_active = True
|
||||
|
||||
|
||||
async def _clear_new_repair(self) -> None:
|
||||
"""Clear new repair issue."""
|
||||
_LOGGER.debug("New issue resolved - clearing repair")
|
||||
|
|
|
|||
|
|
@ -187,9 +187,8 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
|||
class TibberPricesSensor(CoordinatorEntity):
|
||||
async def async_added_to_hass(self):
|
||||
# Register this entity's update callback
|
||||
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(
|
||||
self._handle_coordinator_update
|
||||
)
|
||||
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(self._handle_coordinator_update)
|
||||
|
||||
|
||||
# Coordinator maintains list of listeners
|
||||
class ListenerManager:
|
||||
|
|
@ -390,8 +389,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
|||
|
||||
# Watch for these log messages:
|
||||
"Fetching data from API (reason: tomorrow_check)" # API call
|
||||
"Using cached data (no update needed)" # Fast path
|
||||
"Midnight turnover detected (Timer #1)" # Turnover
|
||||
"Using cached data (no update needed)" # Fast path
|
||||
"Midnight turnover detected (Timer #1)" # Turnover
|
||||
```
|
||||
|
||||
### Check Timer #2 (Quarter-Hour)
|
||||
|
|
@ -399,7 +398,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
|||
```python
|
||||
# Watch coordinator logs:
|
||||
"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)
|
||||
|
|
|
|||
|
|
@ -263,20 +263,16 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
|
|||
|
||||
```python
|
||||
# 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)
|
||||
{
|
||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
||||
"total": 0.2534,
|
||||
"level": "NORMAL",
|
||||
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
||||
"difference": 9.6, # ← Added: % diff from trailing avg
|
||||
"rating_level": "NORMAL" # ← Added: LOW/NORMAL/HIGH based on thresholds
|
||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
||||
"total": 0.2534,
|
||||
"level": "NORMAL",
|
||||
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
||||
"difference": 9.6, # ← Added: % diff from trailing avg
|
||||
"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
|
||||
{
|
||||
"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
|
||||
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(peak_config.items()), # Peak price config
|
||||
best_level_filter, # Level filter overrides
|
||||
peak_level_filter
|
||||
best_level_filter, # Level filter overrides
|
||||
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
|
||||
class _InternalHelper:
|
||||
"""Helper used only within this module."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# ❌ Wrong - no prefix but used across modules
|
||||
class DataFetcher: # Should be TibberPricesDataFetcher
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@ These patterns were analyzed and classified as **not critical**:
|
|||
coordinator.data = {
|
||||
"user_data": {...},
|
||||
"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
|
||||
def test_periods(hass, coordinator):
|
||||
periods = coordinator.data.get('best_price_periods', [])
|
||||
periods = coordinator.data.get("best_price_periods", [])
|
||||
for period in periods:
|
||||
print(f"Period: {period['start']} to {period['end']}")
|
||||
print(f" Intervals: {len(period['intervals'])}")
|
||||
|
|
@ -147,15 +147,14 @@ grep "tibber_prices" config/home-assistant.log
|
|||
|
||||
```python
|
||||
# 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:**
|
||||
|
||||
```python
|
||||
# Add logging in sensor/core.py
|
||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
||||
self.entity_id, self._attr_native_value, new_value)
|
||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s", self.entity_id, self._attr_native_value, new_value)
|
||||
```
|
||||
|
||||
### Period Calculation Wrong
|
||||
|
|
@ -164,8 +163,7 @@ _LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
|||
|
||||
```python
|
||||
# coordinator/period_handlers/period_building.py
|
||||
_LOGGER.debug("Candidate intervals: %s",
|
||||
[(i['startsAt'], i['total']) for i in candidates])
|
||||
_LOGGER.debug("Candidate intervals: %s", [(i["startsAt"], i["total"]) for i in candidates])
|
||||
```
|
||||
|
||||
**Check filter statistics:**
|
||||
|
|
@ -217,6 +215,7 @@ Add to coordinator code:
|
|||
|
||||
```python
|
||||
import debugpy
|
||||
|
||||
debugpy.listen(5678)
|
||||
_LOGGER.info("Waiting for debugger attach on port 5678")
|
||||
debugpy.wait_for_client()
|
||||
|
|
@ -236,6 +235,7 @@ Add breakpoint:
|
|||
|
||||
```python
|
||||
from IPython import embed
|
||||
|
||||
embed() # Drops into interactive shell
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -45,8 +45,7 @@ import tracemalloc
|
|||
tracemalloc.start()
|
||||
# Run your code
|
||||
current, peak = tracemalloc.get_traced_memory()
|
||||
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB",
|
||||
current / 1024**2, peak / 1024**2)
|
||||
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB", current / 1024**2, peak / 1024**2)
|
||||
tracemalloc.stop()
|
||||
```
|
||||
|
||||
|
|
@ -78,6 +77,7 @@ data = await store.async_load()
|
|||
# Already implemented in const.py
|
||||
_TRANSLATION_CACHE: dict[str, dict] = {}
|
||||
|
||||
|
||||
def get_translation(path: str, language: str) -> dict:
|
||||
cache_key = f"{path}_{language}"
|
||||
if cache_key not in _TRANSLATION_CACHE:
|
||||
|
|
@ -139,10 +139,7 @@ user_data = await fetch_user_data()
|
|||
price_data = await fetch_price_data()
|
||||
|
||||
# ✅ Concurrent (fast)
|
||||
user_data, price_data = await asyncio.gather(
|
||||
fetch_user_data(),
|
||||
fetch_price_data()
|
||||
)
|
||||
user_data, price_data = await asyncio.gather(fetch_user_data(), fetch_price_data())
|
||||
```
|
||||
|
||||
**2. Don't block event loop:**
|
||||
|
|
@ -175,6 +172,7 @@ class Coordinator:
|
|||
```python
|
||||
import weakref
|
||||
|
||||
|
||||
class Manager:
|
||||
def __init__(self):
|
||||
self._callbacks: list[weakref.ref] = []
|
||||
|
|
@ -216,8 +214,7 @@ results = (x for x in items if condition(x))
|
|||
**Monitor API usage:**
|
||||
|
||||
```python
|
||||
_LOGGER.debug("API call: %s (cache_age=%s)",
|
||||
endpoint, cache_age)
|
||||
_LOGGER.debug("API call: %s (cache_age=%s)", endpoint, cache_age)
|
||||
```
|
||||
|
||||
### Smart Updates
|
||||
|
|
@ -243,13 +240,13 @@ async def _async_update_data(self) -> dict:
|
|||
|
||||
```python
|
||||
# ❌ 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)
|
||||
state_class=None # Only current state
|
||||
state_class = None # Only current state
|
||||
|
||||
# ✅ TOTAL for counters only
|
||||
state_class=SensorStateClass.TOTAL # For cumulative values
|
||||
state_class = SensorStateClass.TOTAL # For cumulative values
|
||||
```
|
||||
|
||||
### Attribute Size
|
||||
|
|
@ -260,7 +257,7 @@ state_class=SensorStateClass.TOTAL # For cumulative values
|
|||
# ❌ Large nested structures (KB per update)
|
||||
attributes = {
|
||||
"all_intervals": [...], # 384 intervals
|
||||
"full_history": [...], # Days of data
|
||||
"full_history": [...], # Days of data
|
||||
}
|
||||
|
||||
# ✅ Essential data only (bytes per update)
|
||||
|
|
@ -279,6 +276,7 @@ attributes = {
|
|||
import pytest
|
||||
import time
|
||||
|
||||
|
||||
@pytest.mark.benchmark
|
||||
def test_period_calculation_performance(coordinator):
|
||||
"""Period calculation should complete in <100ms."""
|
||||
|
|
|
|||
|
|
@ -1092,8 +1092,8 @@ for price_data in all_prices:
|
|||
ref_date = date_key
|
||||
|
||||
criteria = TibberPricesIntervalCriteria(
|
||||
ref_price=ref_prices[ref_date], # Interval's day
|
||||
avg_price=avg_prices[ref_date], # Interval's day
|
||||
ref_price=ref_prices[ref_date], # Interval's day
|
||||
avg_price=avg_prices[ref_date], # Interval's day
|
||||
flex=flex,
|
||||
min_distance_from_avg=min_distance_from_avg,
|
||||
reverse_sort=reverse_sort,
|
||||
|
|
|
|||
|
|
@ -69,11 +69,7 @@ API requests are being throttled, causing stale data. Updates may be delayed unt
|
|||
|
||||
```python
|
||||
# In error handler
|
||||
is_rate_limit = (
|
||||
"429" in error_str
|
||||
or "rate limit" in error_str
|
||||
or "too many requests" in error_str
|
||||
)
|
||||
is_rate_limit = "429" in error_str or "rate limit" in error_str or "too many requests" in error_str
|
||||
if is_rate_limit:
|
||||
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:
|
||||
await self._clear_new_repair()
|
||||
|
||||
|
||||
async def _create_new_repair(self) -> None:
|
||||
"""Create new repair issue."""
|
||||
_LOGGER.warning("New issue detected - creating repair")
|
||||
|
|
@ -312,6 +309,7 @@ async def _create_new_repair(self) -> None:
|
|||
)
|
||||
self._new_repair_active = True
|
||||
|
||||
|
||||
async def _clear_new_repair(self) -> None:
|
||||
"""Clear new repair issue."""
|
||||
_LOGGER.debug("New issue resolved - clearing repair")
|
||||
|
|
|
|||
|
|
@ -187,9 +187,8 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
|||
class TibberPricesSensor(CoordinatorEntity):
|
||||
async def async_added_to_hass(self):
|
||||
# Register this entity's update callback
|
||||
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(
|
||||
self._handle_coordinator_update
|
||||
)
|
||||
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(self._handle_coordinator_update)
|
||||
|
||||
|
||||
# Coordinator maintains list of listeners
|
||||
class ListenerManager:
|
||||
|
|
@ -390,8 +389,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
|||
|
||||
# Watch for these log messages:
|
||||
"Fetching data from API (reason: tomorrow_check)" # API call
|
||||
"Using cached data (no update needed)" # Fast path
|
||||
"Midnight turnover detected (Timer #1)" # Turnover
|
||||
"Using cached data (no update needed)" # Fast path
|
||||
"Midnight turnover detected (Timer #1)" # Turnover
|
||||
```
|
||||
|
||||
### Check Timer #2 (Quarter-Hour)
|
||||
|
|
@ -399,7 +398,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
|||
```python
|
||||
# Watch coordinator logs:
|
||||
"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)
|
||||
|
|
|
|||
|
|
@ -263,20 +263,16 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
|
|||
|
||||
```python
|
||||
# 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)
|
||||
{
|
||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
||||
"total": 0.2534,
|
||||
"level": "NORMAL",
|
||||
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
||||
"difference": 9.6, # ← Added: % diff from trailing avg
|
||||
"rating_level": "NORMAL" # ← Added: LOW/NORMAL/HIGH based on thresholds
|
||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
||||
"total": 0.2534,
|
||||
"level": "NORMAL",
|
||||
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
||||
"difference": 9.6, # ← Added: % diff from trailing avg
|
||||
"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
|
||||
{
|
||||
"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
|
||||
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(peak_config.items()), # Peak price config
|
||||
best_level_filter, # Level filter overrides
|
||||
peak_level_filter
|
||||
best_level_filter, # Level filter overrides
|
||||
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
|
||||
class _InternalHelper:
|
||||
"""Helper used only within this module."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# ❌ Wrong - no prefix but used across modules
|
||||
class DataFetcher: # Should be TibberPricesDataFetcher
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@ These patterns were analyzed and classified as **not critical**:
|
|||
coordinator.data = {
|
||||
"user_data": {...},
|
||||
"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
|
||||
def test_periods(hass, coordinator):
|
||||
periods = coordinator.data.get('best_price_periods', [])
|
||||
periods = coordinator.data.get("best_price_periods", [])
|
||||
for period in periods:
|
||||
print(f"Period: {period['start']} to {period['end']}")
|
||||
print(f" Intervals: {len(period['intervals'])}")
|
||||
|
|
@ -147,15 +147,14 @@ grep "tibber_prices" config/home-assistant.log
|
|||
|
||||
```python
|
||||
# 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:**
|
||||
|
||||
```python
|
||||
# Add logging in sensor/core.py
|
||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
||||
self.entity_id, self._attr_native_value, new_value)
|
||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s", self.entity_id, self._attr_native_value, new_value)
|
||||
```
|
||||
|
||||
### Period Calculation Wrong
|
||||
|
|
@ -164,8 +163,7 @@ _LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
|||
|
||||
```python
|
||||
# coordinator/period_handlers/period_building.py
|
||||
_LOGGER.debug("Candidate intervals: %s",
|
||||
[(i['startsAt'], i['total']) for i in candidates])
|
||||
_LOGGER.debug("Candidate intervals: %s", [(i["startsAt"], i["total"]) for i in candidates])
|
||||
```
|
||||
|
||||
**Check filter statistics:**
|
||||
|
|
@ -217,6 +215,7 @@ Add to coordinator code:
|
|||
|
||||
```python
|
||||
import debugpy
|
||||
|
||||
debugpy.listen(5678)
|
||||
_LOGGER.info("Waiting for debugger attach on port 5678")
|
||||
debugpy.wait_for_client()
|
||||
|
|
@ -236,6 +235,7 @@ Add breakpoint:
|
|||
|
||||
```python
|
||||
from IPython import embed
|
||||
|
||||
embed() # Drops into interactive shell
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -45,8 +45,7 @@ import tracemalloc
|
|||
tracemalloc.start()
|
||||
# Run your code
|
||||
current, peak = tracemalloc.get_traced_memory()
|
||||
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB",
|
||||
current / 1024**2, peak / 1024**2)
|
||||
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB", current / 1024**2, peak / 1024**2)
|
||||
tracemalloc.stop()
|
||||
```
|
||||
|
||||
|
|
@ -78,6 +77,7 @@ data = await store.async_load()
|
|||
# Already implemented in const.py
|
||||
_TRANSLATION_CACHE: dict[str, dict] = {}
|
||||
|
||||
|
||||
def get_translation(path: str, language: str) -> dict:
|
||||
cache_key = f"{path}_{language}"
|
||||
if cache_key not in _TRANSLATION_CACHE:
|
||||
|
|
@ -139,10 +139,7 @@ user_data = await fetch_user_data()
|
|||
price_data = await fetch_price_data()
|
||||
|
||||
# ✅ Concurrent (fast)
|
||||
user_data, price_data = await asyncio.gather(
|
||||
fetch_user_data(),
|
||||
fetch_price_data()
|
||||
)
|
||||
user_data, price_data = await asyncio.gather(fetch_user_data(), fetch_price_data())
|
||||
```
|
||||
|
||||
**2. Don't block event loop:**
|
||||
|
|
@ -175,6 +172,7 @@ class Coordinator:
|
|||
```python
|
||||
import weakref
|
||||
|
||||
|
||||
class Manager:
|
||||
def __init__(self):
|
||||
self._callbacks: list[weakref.ref] = []
|
||||
|
|
@ -216,8 +214,7 @@ results = (x for x in items if condition(x))
|
|||
**Monitor API usage:**
|
||||
|
||||
```python
|
||||
_LOGGER.debug("API call: %s (cache_age=%s)",
|
||||
endpoint, cache_age)
|
||||
_LOGGER.debug("API call: %s (cache_age=%s)", endpoint, cache_age)
|
||||
```
|
||||
|
||||
### Smart Updates
|
||||
|
|
@ -243,13 +240,13 @@ async def _async_update_data(self) -> dict:
|
|||
|
||||
```python
|
||||
# ❌ 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)
|
||||
state_class=None # Only current state
|
||||
state_class = None # Only current state
|
||||
|
||||
# ✅ TOTAL for counters only
|
||||
state_class=SensorStateClass.TOTAL # For cumulative values
|
||||
state_class = SensorStateClass.TOTAL # For cumulative values
|
||||
```
|
||||
|
||||
### Attribute Size
|
||||
|
|
@ -260,7 +257,7 @@ state_class=SensorStateClass.TOTAL # For cumulative values
|
|||
# ❌ Large nested structures (KB per update)
|
||||
attributes = {
|
||||
"all_intervals": [...], # 384 intervals
|
||||
"full_history": [...], # Days of data
|
||||
"full_history": [...], # Days of data
|
||||
}
|
||||
|
||||
# ✅ Essential data only (bytes per update)
|
||||
|
|
@ -279,6 +276,7 @@ attributes = {
|
|||
import pytest
|
||||
import time
|
||||
|
||||
|
||||
@pytest.mark.benchmark
|
||||
def test_period_calculation_performance(coordinator):
|
||||
"""Period calculation should complete in <100ms."""
|
||||
|
|
|
|||
|
|
@ -1092,8 +1092,8 @@ for price_data in all_prices:
|
|||
ref_date = date_key
|
||||
|
||||
criteria = TibberPricesIntervalCriteria(
|
||||
ref_price=ref_prices[ref_date], # Interval's day
|
||||
avg_price=avg_prices[ref_date], # Interval's day
|
||||
ref_price=ref_prices[ref_date], # Interval's day
|
||||
avg_price=avg_prices[ref_date], # Interval's day
|
||||
flex=flex,
|
||||
min_distance_from_avg=min_distance_from_avg,
|
||||
reverse_sort=reverse_sort,
|
||||
|
|
|
|||
|
|
@ -69,11 +69,7 @@ API requests are being throttled, causing stale data. Updates may be delayed unt
|
|||
|
||||
```python
|
||||
# In error handler
|
||||
is_rate_limit = (
|
||||
"429" in error_str
|
||||
or "rate limit" in error_str
|
||||
or "too many requests" in error_str
|
||||
)
|
||||
is_rate_limit = "429" in error_str or "rate limit" in error_str or "too many requests" in error_str
|
||||
if is_rate_limit:
|
||||
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:
|
||||
await self._clear_new_repair()
|
||||
|
||||
|
||||
async def _create_new_repair(self) -> None:
|
||||
"""Create new repair issue."""
|
||||
_LOGGER.warning("New issue detected - creating repair")
|
||||
|
|
@ -312,6 +309,7 @@ async def _create_new_repair(self) -> None:
|
|||
)
|
||||
self._new_repair_active = True
|
||||
|
||||
|
||||
async def _clear_new_repair(self) -> None:
|
||||
"""Clear new repair issue."""
|
||||
_LOGGER.debug("New issue resolved - clearing repair")
|
||||
|
|
|
|||
|
|
@ -187,9 +187,8 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
|||
class TibberPricesSensor(CoordinatorEntity):
|
||||
async def async_added_to_hass(self):
|
||||
# Register this entity's update callback
|
||||
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(
|
||||
self._handle_coordinator_update
|
||||
)
|
||||
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(self._handle_coordinator_update)
|
||||
|
||||
|
||||
# Coordinator maintains list of listeners
|
||||
class ListenerManager:
|
||||
|
|
@ -390,8 +389,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
|||
|
||||
# Watch for these log messages:
|
||||
"Fetching data from API (reason: tomorrow_check)" # API call
|
||||
"Using cached data (no update needed)" # Fast path
|
||||
"Midnight turnover detected (Timer #1)" # Turnover
|
||||
"Using cached data (no update needed)" # Fast path
|
||||
"Midnight turnover detected (Timer #1)" # Turnover
|
||||
```
|
||||
|
||||
### Check Timer #2 (Quarter-Hour)
|
||||
|
|
@ -399,7 +398,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
|||
```python
|
||||
# Watch coordinator logs:
|
||||
"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)
|
||||
|
|
|
|||
|
|
@ -263,20 +263,16 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
|
|||
|
||||
```python
|
||||
# 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)
|
||||
{
|
||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
||||
"total": 0.2534,
|
||||
"level": "NORMAL",
|
||||
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
||||
"difference": 9.6, # ← Added: % diff from trailing avg
|
||||
"rating_level": "NORMAL" # ← Added: LOW/NORMAL/HIGH based on thresholds
|
||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
||||
"total": 0.2534,
|
||||
"level": "NORMAL",
|
||||
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
||||
"difference": 9.6, # ← Added: % diff from trailing avg
|
||||
"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
|
||||
{
|
||||
"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
|
||||
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(peak_config.items()), # Peak price config
|
||||
best_level_filter, # Level filter overrides
|
||||
peak_level_filter
|
||||
best_level_filter, # Level filter overrides
|
||||
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
|
||||
class _InternalHelper:
|
||||
"""Helper used only within this module."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# ❌ Wrong - no prefix but used across modules
|
||||
class DataFetcher: # Should be TibberPricesDataFetcher
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@ These patterns were analyzed and classified as **not critical**:
|
|||
coordinator.data = {
|
||||
"user_data": {...},
|
||||
"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
|
||||
def test_periods(hass, coordinator):
|
||||
periods = coordinator.data.get('best_price_periods', [])
|
||||
periods = coordinator.data.get("best_price_periods", [])
|
||||
for period in periods:
|
||||
print(f"Period: {period['start']} to {period['end']}")
|
||||
print(f" Intervals: {len(period['intervals'])}")
|
||||
|
|
@ -147,15 +147,14 @@ grep "tibber_prices" config/home-assistant.log
|
|||
|
||||
```python
|
||||
# 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:**
|
||||
|
||||
```python
|
||||
# Add logging in sensor/core.py
|
||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
||||
self.entity_id, self._attr_native_value, new_value)
|
||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s", self.entity_id, self._attr_native_value, new_value)
|
||||
```
|
||||
|
||||
### Period Calculation Wrong
|
||||
|
|
@ -164,8 +163,7 @@ _LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
|||
|
||||
```python
|
||||
# coordinator/period_handlers/period_building.py
|
||||
_LOGGER.debug("Candidate intervals: %s",
|
||||
[(i['startsAt'], i['total']) for i in candidates])
|
||||
_LOGGER.debug("Candidate intervals: %s", [(i["startsAt"], i["total"]) for i in candidates])
|
||||
```
|
||||
|
||||
**Check filter statistics:**
|
||||
|
|
@ -217,6 +215,7 @@ Add to coordinator code:
|
|||
|
||||
```python
|
||||
import debugpy
|
||||
|
||||
debugpy.listen(5678)
|
||||
_LOGGER.info("Waiting for debugger attach on port 5678")
|
||||
debugpy.wait_for_client()
|
||||
|
|
@ -236,6 +235,7 @@ Add breakpoint:
|
|||
|
||||
```python
|
||||
from IPython import embed
|
||||
|
||||
embed() # Drops into interactive shell
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -45,8 +45,7 @@ import tracemalloc
|
|||
tracemalloc.start()
|
||||
# Run your code
|
||||
current, peak = tracemalloc.get_traced_memory()
|
||||
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB",
|
||||
current / 1024**2, peak / 1024**2)
|
||||
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB", current / 1024**2, peak / 1024**2)
|
||||
tracemalloc.stop()
|
||||
```
|
||||
|
||||
|
|
@ -78,6 +77,7 @@ data = await store.async_load()
|
|||
# Already implemented in const.py
|
||||
_TRANSLATION_CACHE: dict[str, dict] = {}
|
||||
|
||||
|
||||
def get_translation(path: str, language: str) -> dict:
|
||||
cache_key = f"{path}_{language}"
|
||||
if cache_key not in _TRANSLATION_CACHE:
|
||||
|
|
@ -139,10 +139,7 @@ user_data = await fetch_user_data()
|
|||
price_data = await fetch_price_data()
|
||||
|
||||
# ✅ Concurrent (fast)
|
||||
user_data, price_data = await asyncio.gather(
|
||||
fetch_user_data(),
|
||||
fetch_price_data()
|
||||
)
|
||||
user_data, price_data = await asyncio.gather(fetch_user_data(), fetch_price_data())
|
||||
```
|
||||
|
||||
**2. Don't block event loop:**
|
||||
|
|
@ -175,6 +172,7 @@ class Coordinator:
|
|||
```python
|
||||
import weakref
|
||||
|
||||
|
||||
class Manager:
|
||||
def __init__(self):
|
||||
self._callbacks: list[weakref.ref] = []
|
||||
|
|
@ -216,8 +214,7 @@ results = (x for x in items if condition(x))
|
|||
**Monitor API usage:**
|
||||
|
||||
```python
|
||||
_LOGGER.debug("API call: %s (cache_age=%s)",
|
||||
endpoint, cache_age)
|
||||
_LOGGER.debug("API call: %s (cache_age=%s)", endpoint, cache_age)
|
||||
```
|
||||
|
||||
### Smart Updates
|
||||
|
|
@ -243,13 +240,13 @@ async def _async_update_data(self) -> dict:
|
|||
|
||||
```python
|
||||
# ❌ 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)
|
||||
state_class=None # Only current state
|
||||
state_class = None # Only current state
|
||||
|
||||
# ✅ TOTAL for counters only
|
||||
state_class=SensorStateClass.TOTAL # For cumulative values
|
||||
state_class = SensorStateClass.TOTAL # For cumulative values
|
||||
```
|
||||
|
||||
### Attribute Size
|
||||
|
|
@ -260,7 +257,7 @@ state_class=SensorStateClass.TOTAL # For cumulative values
|
|||
# ❌ Large nested structures (KB per update)
|
||||
attributes = {
|
||||
"all_intervals": [...], # 384 intervals
|
||||
"full_history": [...], # Days of data
|
||||
"full_history": [...], # Days of data
|
||||
}
|
||||
|
||||
# ✅ Essential data only (bytes per update)
|
||||
|
|
@ -279,6 +276,7 @@ attributes = {
|
|||
import pytest
|
||||
import time
|
||||
|
||||
|
||||
@pytest.mark.benchmark
|
||||
def test_period_calculation_performance(coordinator):
|
||||
"""Period calculation should complete in <100ms."""
|
||||
|
|
|
|||
|
|
@ -1246,8 +1246,8 @@ for price_data in all_prices:
|
|||
ref_date = date_key
|
||||
|
||||
criteria = TibberPricesIntervalCriteria(
|
||||
ref_price=ref_prices[ref_date], # Interval's day
|
||||
avg_price=avg_prices[ref_date], # Interval's day
|
||||
ref_price=ref_prices[ref_date], # Interval's day
|
||||
avg_price=avg_prices[ref_date], # Interval's day
|
||||
flex=flex,
|
||||
min_distance_from_avg=min_distance_from_avg,
|
||||
reverse_sort=reverse_sort,
|
||||
|
|
|
|||
|
|
@ -69,11 +69,7 @@ API requests are being throttled, causing stale data. Updates may be delayed unt
|
|||
|
||||
```python
|
||||
# In error handler
|
||||
is_rate_limit = (
|
||||
"429" in error_str
|
||||
or "rate limit" in error_str
|
||||
or "too many requests" in error_str
|
||||
)
|
||||
is_rate_limit = "429" in error_str or "rate limit" in error_str or "too many requests" in error_str
|
||||
if is_rate_limit:
|
||||
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:
|
||||
await self._clear_new_repair()
|
||||
|
||||
|
||||
async def _create_new_repair(self) -> None:
|
||||
"""Create new repair issue."""
|
||||
_LOGGER.warning("New issue detected - creating repair")
|
||||
|
|
@ -312,6 +309,7 @@ async def _create_new_repair(self) -> None:
|
|||
)
|
||||
self._new_repair_active = True
|
||||
|
||||
|
||||
async def _clear_new_repair(self) -> None:
|
||||
"""Clear new repair issue."""
|
||||
_LOGGER.debug("New issue resolved - clearing repair")
|
||||
|
|
|
|||
|
|
@ -187,9 +187,8 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
|||
class TibberPricesSensor(CoordinatorEntity):
|
||||
async def async_added_to_hass(self):
|
||||
# Register this entity's update callback
|
||||
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(
|
||||
self._handle_coordinator_update
|
||||
)
|
||||
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(self._handle_coordinator_update)
|
||||
|
||||
|
||||
# Coordinator maintains list of listeners
|
||||
class ListenerManager:
|
||||
|
|
@ -390,8 +389,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
|||
|
||||
# Watch for these log messages:
|
||||
"Fetching data from API (reason: tomorrow_check)" # API call
|
||||
"Using cached data (no update needed)" # Fast path
|
||||
"Midnight turnover detected (Timer #1)" # Turnover
|
||||
"Using cached data (no update needed)" # Fast path
|
||||
"Midnight turnover detected (Timer #1)" # Turnover
|
||||
```
|
||||
|
||||
### Check Timer #2 (Quarter-Hour)
|
||||
|
|
@ -399,7 +398,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
|||
```python
|
||||
# Watch coordinator logs:
|
||||
"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)
|
||||
|
|
|
|||
|
|
@ -263,20 +263,16 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
|
|||
|
||||
```python
|
||||
# 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)
|
||||
{
|
||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
||||
"total": 0.2534,
|
||||
"level": "NORMAL",
|
||||
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
||||
"difference": 9.6, # ← Added: % diff from trailing avg
|
||||
"rating_level": "NORMAL" # ← Added: LOW/NORMAL/HIGH based on thresholds
|
||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
||||
"total": 0.2534,
|
||||
"level": "NORMAL",
|
||||
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
||||
"difference": 9.6, # ← Added: % diff from trailing avg
|
||||
"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
|
||||
{
|
||||
"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
|
||||
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(peak_config.items()), # Peak price config
|
||||
best_level_filter, # Level filter overrides
|
||||
peak_level_filter
|
||||
best_level_filter, # Level filter overrides
|
||||
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
|
||||
class _InternalHelper:
|
||||
"""Helper used only within this module."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# ❌ Wrong - no prefix but used across modules
|
||||
class DataFetcher: # Should be TibberPricesDataFetcher
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@ These patterns were analyzed and classified as **not critical**:
|
|||
coordinator.data = {
|
||||
"user_data": {...},
|
||||
"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
|
||||
def test_periods(hass, coordinator):
|
||||
periods = coordinator.data.get('best_price_periods', [])
|
||||
periods = coordinator.data.get("best_price_periods", [])
|
||||
for period in periods:
|
||||
print(f"Period: {period['start']} to {period['end']}")
|
||||
print(f" Intervals: {len(period['intervals'])}")
|
||||
|
|
@ -147,15 +147,14 @@ grep "tibber_prices" config/home-assistant.log
|
|||
|
||||
```python
|
||||
# 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:**
|
||||
|
||||
```python
|
||||
# Add logging in sensor/core.py
|
||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
||||
self.entity_id, self._attr_native_value, new_value)
|
||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s", self.entity_id, self._attr_native_value, new_value)
|
||||
```
|
||||
|
||||
### Period Calculation Wrong
|
||||
|
|
@ -164,8 +163,7 @@ _LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
|||
|
||||
```python
|
||||
# coordinator/period_handlers/period_building.py
|
||||
_LOGGER.debug("Candidate intervals: %s",
|
||||
[(i['startsAt'], i['total']) for i in candidates])
|
||||
_LOGGER.debug("Candidate intervals: %s", [(i["startsAt"], i["total"]) for i in candidates])
|
||||
```
|
||||
|
||||
**Check filter statistics:**
|
||||
|
|
@ -217,6 +215,7 @@ Add to coordinator code:
|
|||
|
||||
```python
|
||||
import debugpy
|
||||
|
||||
debugpy.listen(5678)
|
||||
_LOGGER.info("Waiting for debugger attach on port 5678")
|
||||
debugpy.wait_for_client()
|
||||
|
|
@ -236,6 +235,7 @@ Add breakpoint:
|
|||
|
||||
```python
|
||||
from IPython import embed
|
||||
|
||||
embed() # Drops into interactive shell
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -45,8 +45,7 @@ import tracemalloc
|
|||
tracemalloc.start()
|
||||
# Run your code
|
||||
current, peak = tracemalloc.get_traced_memory()
|
||||
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB",
|
||||
current / 1024**2, peak / 1024**2)
|
||||
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB", current / 1024**2, peak / 1024**2)
|
||||
tracemalloc.stop()
|
||||
```
|
||||
|
||||
|
|
@ -78,6 +77,7 @@ data = await store.async_load()
|
|||
# Already implemented in const.py
|
||||
_TRANSLATION_CACHE: dict[str, dict] = {}
|
||||
|
||||
|
||||
def get_translation(path: str, language: str) -> dict:
|
||||
cache_key = f"{path}_{language}"
|
||||
if cache_key not in _TRANSLATION_CACHE:
|
||||
|
|
@ -139,10 +139,7 @@ user_data = await fetch_user_data()
|
|||
price_data = await fetch_price_data()
|
||||
|
||||
# ✅ Concurrent (fast)
|
||||
user_data, price_data = await asyncio.gather(
|
||||
fetch_user_data(),
|
||||
fetch_price_data()
|
||||
)
|
||||
user_data, price_data = await asyncio.gather(fetch_user_data(), fetch_price_data())
|
||||
```
|
||||
|
||||
**2. Don't block event loop:**
|
||||
|
|
@ -175,6 +172,7 @@ class Coordinator:
|
|||
```python
|
||||
import weakref
|
||||
|
||||
|
||||
class Manager:
|
||||
def __init__(self):
|
||||
self._callbacks: list[weakref.ref] = []
|
||||
|
|
@ -216,8 +214,7 @@ results = (x for x in items if condition(x))
|
|||
**Monitor API usage:**
|
||||
|
||||
```python
|
||||
_LOGGER.debug("API call: %s (cache_age=%s)",
|
||||
endpoint, cache_age)
|
||||
_LOGGER.debug("API call: %s (cache_age=%s)", endpoint, cache_age)
|
||||
```
|
||||
|
||||
### Smart Updates
|
||||
|
|
@ -243,13 +240,13 @@ async def _async_update_data(self) -> dict:
|
|||
|
||||
```python
|
||||
# ❌ 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)
|
||||
state_class=None # Only current state
|
||||
state_class = None # Only current state
|
||||
|
||||
# ✅ TOTAL for counters only
|
||||
state_class=SensorStateClass.TOTAL # For cumulative values
|
||||
state_class = SensorStateClass.TOTAL # For cumulative values
|
||||
```
|
||||
|
||||
### Attribute Size
|
||||
|
|
@ -260,7 +257,7 @@ state_class=SensorStateClass.TOTAL # For cumulative values
|
|||
# ❌ Large nested structures (KB per update)
|
||||
attributes = {
|
||||
"all_intervals": [...], # 384 intervals
|
||||
"full_history": [...], # Days of data
|
||||
"full_history": [...], # Days of data
|
||||
}
|
||||
|
||||
# ✅ Essential data only (bytes per update)
|
||||
|
|
@ -279,6 +276,7 @@ attributes = {
|
|||
import pytest
|
||||
import time
|
||||
|
||||
|
||||
@pytest.mark.benchmark
|
||||
def test_period_calculation_performance(coordinator):
|
||||
"""Period calculation should complete in <100ms."""
|
||||
|
|
|
|||
|
|
@ -1246,8 +1246,8 @@ for price_data in all_prices:
|
|||
ref_date = date_key
|
||||
|
||||
criteria = TibberPricesIntervalCriteria(
|
||||
ref_price=ref_prices[ref_date], # Interval's day
|
||||
avg_price=avg_prices[ref_date], # Interval's day
|
||||
ref_price=ref_prices[ref_date], # Interval's day
|
||||
avg_price=avg_prices[ref_date], # Interval's day
|
||||
flex=flex,
|
||||
min_distance_from_avg=min_distance_from_avg,
|
||||
reverse_sort=reverse_sort,
|
||||
|
|
|
|||
|
|
@ -69,11 +69,7 @@ API requests are being throttled, causing stale data. Updates may be delayed unt
|
|||
|
||||
```python
|
||||
# In error handler
|
||||
is_rate_limit = (
|
||||
"429" in error_str
|
||||
or "rate limit" in error_str
|
||||
or "too many requests" in error_str
|
||||
)
|
||||
is_rate_limit = "429" in error_str or "rate limit" in error_str or "too many requests" in error_str
|
||||
if is_rate_limit:
|
||||
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:
|
||||
await self._clear_new_repair()
|
||||
|
||||
|
||||
async def _create_new_repair(self) -> None:
|
||||
"""Create new repair issue."""
|
||||
_LOGGER.warning("New issue detected - creating repair")
|
||||
|
|
@ -312,6 +309,7 @@ async def _create_new_repair(self) -> None:
|
|||
)
|
||||
self._new_repair_active = True
|
||||
|
||||
|
||||
async def _clear_new_repair(self) -> None:
|
||||
"""Clear new repair issue."""
|
||||
_LOGGER.debug("New issue resolved - clearing repair")
|
||||
|
|
|
|||
|
|
@ -187,9 +187,8 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
|||
class TibberPricesSensor(CoordinatorEntity):
|
||||
async def async_added_to_hass(self):
|
||||
# Register this entity's update callback
|
||||
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(
|
||||
self._handle_coordinator_update
|
||||
)
|
||||
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(self._handle_coordinator_update)
|
||||
|
||||
|
||||
# Coordinator maintains list of listeners
|
||||
class ListenerManager:
|
||||
|
|
@ -390,8 +389,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
|||
|
||||
# Watch for these log messages:
|
||||
"Fetching data from API (reason: tomorrow_check)" # API call
|
||||
"Using cached data (no update needed)" # Fast path
|
||||
"Midnight turnover detected (Timer #1)" # Turnover
|
||||
"Using cached data (no update needed)" # Fast path
|
||||
"Midnight turnover detected (Timer #1)" # Turnover
|
||||
```
|
||||
|
||||
### Check Timer #2 (Quarter-Hour)
|
||||
|
|
@ -399,7 +398,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
|||
```python
|
||||
# Watch coordinator logs:
|
||||
"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)
|
||||
|
|
|
|||
|
|
@ -263,20 +263,16 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
|
|||
|
||||
```python
|
||||
# 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)
|
||||
{
|
||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
||||
"total": 0.2534,
|
||||
"level": "NORMAL",
|
||||
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
||||
"difference": 9.6, # ← Added: % diff from trailing avg
|
||||
"rating_level": "NORMAL" # ← Added: LOW/NORMAL/HIGH based on thresholds
|
||||
"startsAt": "2025-11-03T14:00:00+01:00",
|
||||
"total": 0.2534,
|
||||
"level": "NORMAL",
|
||||
"trailing_avg_24h": 0.2312, # ← Added: 24h trailing average
|
||||
"difference": 9.6, # ← Added: % diff from trailing avg
|
||||
"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
|
||||
{
|
||||
"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
|
||||
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(peak_config.items()), # Peak price config
|
||||
best_level_filter, # Level filter overrides
|
||||
peak_level_filter
|
||||
best_level_filter, # Level filter overrides
|
||||
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
|
||||
class _InternalHelper:
|
||||
"""Helper used only within this module."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# ❌ Wrong - no prefix but used across modules
|
||||
class DataFetcher: # Should be TibberPricesDataFetcher
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@ These patterns were analyzed and classified as **not critical**:
|
|||
coordinator.data = {
|
||||
"user_data": {...},
|
||||
"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
|
||||
def test_periods(hass, coordinator):
|
||||
periods = coordinator.data.get('best_price_periods', [])
|
||||
periods = coordinator.data.get("best_price_periods", [])
|
||||
for period in periods:
|
||||
print(f"Period: {period['start']} to {period['end']}")
|
||||
print(f" Intervals: {len(period['intervals'])}")
|
||||
|
|
@ -147,15 +147,14 @@ grep "tibber_prices" config/home-assistant.log
|
|||
|
||||
```python
|
||||
# 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:**
|
||||
|
||||
```python
|
||||
# Add logging in sensor/core.py
|
||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
||||
self.entity_id, self._attr_native_value, new_value)
|
||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s", self.entity_id, self._attr_native_value, new_value)
|
||||
```
|
||||
|
||||
### Period Calculation Wrong
|
||||
|
|
@ -164,8 +163,7 @@ _LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
|||
|
||||
```python
|
||||
# coordinator/period_handlers/period_building.py
|
||||
_LOGGER.debug("Candidate intervals: %s",
|
||||
[(i['startsAt'], i['total']) for i in candidates])
|
||||
_LOGGER.debug("Candidate intervals: %s", [(i["startsAt"], i["total"]) for i in candidates])
|
||||
```
|
||||
|
||||
**Check filter statistics:**
|
||||
|
|
@ -217,6 +215,7 @@ Add to coordinator code:
|
|||
|
||||
```python
|
||||
import debugpy
|
||||
|
||||
debugpy.listen(5678)
|
||||
_LOGGER.info("Waiting for debugger attach on port 5678")
|
||||
debugpy.wait_for_client()
|
||||
|
|
@ -236,6 +235,7 @@ Add breakpoint:
|
|||
|
||||
```python
|
||||
from IPython import embed
|
||||
|
||||
embed() # Drops into interactive shell
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -45,8 +45,7 @@ import tracemalloc
|
|||
tracemalloc.start()
|
||||
# Run your code
|
||||
current, peak = tracemalloc.get_traced_memory()
|
||||
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB",
|
||||
current / 1024**2, peak / 1024**2)
|
||||
_LOGGER.info("Memory: current=%.2fMB peak=%.2fMB", current / 1024**2, peak / 1024**2)
|
||||
tracemalloc.stop()
|
||||
```
|
||||
|
||||
|
|
@ -78,6 +77,7 @@ data = await store.async_load()
|
|||
# Already implemented in const.py
|
||||
_TRANSLATION_CACHE: dict[str, dict] = {}
|
||||
|
||||
|
||||
def get_translation(path: str, language: str) -> dict:
|
||||
cache_key = f"{path}_{language}"
|
||||
if cache_key not in _TRANSLATION_CACHE:
|
||||
|
|
@ -139,10 +139,7 @@ user_data = await fetch_user_data()
|
|||
price_data = await fetch_price_data()
|
||||
|
||||
# ✅ Concurrent (fast)
|
||||
user_data, price_data = await asyncio.gather(
|
||||
fetch_user_data(),
|
||||
fetch_price_data()
|
||||
)
|
||||
user_data, price_data = await asyncio.gather(fetch_user_data(), fetch_price_data())
|
||||
```
|
||||
|
||||
**2. Don't block event loop:**
|
||||
|
|
@ -175,6 +172,7 @@ class Coordinator:
|
|||
```python
|
||||
import weakref
|
||||
|
||||
|
||||
class Manager:
|
||||
def __init__(self):
|
||||
self._callbacks: list[weakref.ref] = []
|
||||
|
|
@ -216,8 +214,7 @@ results = (x for x in items if condition(x))
|
|||
**Monitor API usage:**
|
||||
|
||||
```python
|
||||
_LOGGER.debug("API call: %s (cache_age=%s)",
|
||||
endpoint, cache_age)
|
||||
_LOGGER.debug("API call: %s (cache_age=%s)", endpoint, cache_age)
|
||||
```
|
||||
|
||||
### Smart Updates
|
||||
|
|
@ -243,13 +240,13 @@ async def _async_update_data(self) -> dict:
|
|||
|
||||
```python
|
||||
# ❌ 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)
|
||||
state_class=None # Only current state
|
||||
state_class = None # Only current state
|
||||
|
||||
# ✅ TOTAL for counters only
|
||||
state_class=SensorStateClass.TOTAL # For cumulative values
|
||||
state_class = SensorStateClass.TOTAL # For cumulative values
|
||||
```
|
||||
|
||||
### Attribute Size
|
||||
|
|
@ -260,7 +257,7 @@ state_class=SensorStateClass.TOTAL # For cumulative values
|
|||
# ❌ Large nested structures (KB per update)
|
||||
attributes = {
|
||||
"all_intervals": [...], # 384 intervals
|
||||
"full_history": [...], # Days of data
|
||||
"full_history": [...], # Days of data
|
||||
}
|
||||
|
||||
# ✅ Essential data only (bytes per update)
|
||||
|
|
@ -279,6 +276,7 @@ attributes = {
|
|||
import pytest
|
||||
import time
|
||||
|
||||
|
||||
@pytest.mark.benchmark
|
||||
def test_period_calculation_performance(coordinator):
|
||||
"""Period calculation should complete in <100ms."""
|
||||
|
|
|
|||
|
|
@ -1246,8 +1246,8 @@ for price_data in all_prices:
|
|||
ref_date = date_key
|
||||
|
||||
criteria = TibberPricesIntervalCriteria(
|
||||
ref_price=ref_prices[ref_date], # Interval's day
|
||||
avg_price=avg_prices[ref_date], # Interval's day
|
||||
ref_price=ref_prices[ref_date], # Interval's day
|
||||
avg_price=avg_prices[ref_date], # Interval's day
|
||||
flex=flex,
|
||||
min_distance_from_avg=min_distance_from_avg,
|
||||
reverse_sort=reverse_sort,
|
||||
|
|
|
|||
|
|
@ -69,11 +69,7 @@ API requests are being throttled, causing stale data. Updates may be delayed unt
|
|||
|
||||
```python
|
||||
# In error handler
|
||||
is_rate_limit = (
|
||||
"429" in error_str
|
||||
or "rate limit" in error_str
|
||||
or "too many requests" in error_str
|
||||
)
|
||||
is_rate_limit = "429" in error_str or "rate limit" in error_str or "too many requests" in error_str
|
||||
if is_rate_limit:
|
||||
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:
|
||||
await self._clear_new_repair()
|
||||
|
||||
|
||||
async def _create_new_repair(self) -> None:
|
||||
"""Create new repair issue."""
|
||||
_LOGGER.warning("New issue detected - creating repair")
|
||||
|
|
@ -312,6 +309,7 @@ async def _create_new_repair(self) -> None:
|
|||
)
|
||||
self._new_repair_active = True
|
||||
|
||||
|
||||
async def _clear_new_repair(self) -> None:
|
||||
"""Clear new repair issue."""
|
||||
_LOGGER.debug("New issue resolved - clearing repair")
|
||||
|
|
|
|||
|
|
@ -187,9 +187,8 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
|||
class TibberPricesSensor(CoordinatorEntity):
|
||||
async def async_added_to_hass(self):
|
||||
# Register this entity's update callback
|
||||
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(
|
||||
self._handle_coordinator_update
|
||||
)
|
||||
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(self._handle_coordinator_update)
|
||||
|
||||
|
||||
# Coordinator maintains list of listeners
|
||||
class ListenerManager:
|
||||
|
|
@ -390,8 +389,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
|||
|
||||
# Watch for these log messages:
|
||||
"Fetching data from API (reason: tomorrow_check)" # API call
|
||||
"Using cached data (no update needed)" # Fast path
|
||||
"Midnight turnover detected (Timer #1)" # Turnover
|
||||
"Using cached data (no update needed)" # Fast path
|
||||
"Midnight turnover detected (Timer #1)" # Turnover
|
||||
```
|
||||
|
||||
### Check Timer #2 (Quarter-Hour)
|
||||
|
|
@ -399,7 +398,8 @@ _LOGGER.setLevel(logging.DEBUG)
|
|||
```python
|
||||
# Watch coordinator logs:
|
||||
"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)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue