hass.tibber_prices/custom_components/tibber_prices/sensor/chart_data.py
Julian Pawlowski a962289682 refactor(sensor): implement Calculator Pattern with specialized modules
Massive refactoring of sensor platform reducing core.py from 2,170 to 909
lines (58% reduction). Extracted business logic into specialized calculators
and attribute builders following separation of concerns principles.

Changes:
- Created sensor/calculators/ package (8 specialized calculators, 1,838 lines):
  * base.py: Abstract BaseCalculator with coordinator access
  * interval.py: Single interval calculations (current/next/previous)
  * rolling_hour.py: 5-interval rolling windows
  * daily_stat.py: Calendar day min/max/avg statistics
  * window_24h.py: Trailing/leading 24h windows
  * volatility.py: Price volatility analysis
  * trend.py: Complex trend analysis with caching (640 lines)
  * timing.py: Best/peak price period timing
  * metadata.py: Home/metering metadata

- Created sensor/attributes/ package (8 specialized modules, 1,209 lines):
  * Modules match calculator types for consistent organization
  * __init__.py: Routing logic + unified builders
  * Handles state presentation separately from business logic

- Created sensor/chart_data.py (144 lines):
  * Extracted chart data export functionality from entity class
  * YAML parsing, service calls, metadata formatting

- Created sensor/value_getters.py (276 lines):
  * Centralized handler mapping for all 80+ sensor types
  * Single source of truth for sensor routing

- Extended sensor/helpers.py (+88 lines):
  * Added aggregate_window_data() unified aggregator
  * Added get_hourly_price_value() for backward compatibility
  * Consolidated sensor-specific helper functions

- Refactored sensor/core.py (909 lines, was 2,170):
  * Instantiates all calculators in __init__
  * Delegates value calculations to appropriate calculator
  * Uses unified handler methods via value_getters mapping
  * Minimal platform-specific logic remains (icon callbacks, entity lifecycle)

- Deleted sensor/attributes.py (1,106 lines):
  * Functionality split into attributes/ package (8 modules)

- Updated AGENTS.md:
  * Documented Calculator Pattern architecture
  * Added guidance for adding new sensors with calculation groups
  * Updated file organization with new package structure

Architecture Benefits:
- Clear separation: Calculators (business logic) vs Attributes (presentation)
- Improved testability: Each calculator independently testable
- Better maintainability: 21 focused modules vs monolithic file
- Easy extensibility: Add sensors by choosing calculation pattern
- Reusable components: Calculators and attribute builders shared across sensors

Impact: Significantly improved code organization and maintainability while
preserving all functionality. All 80+ sensor types continue working with
cleaner, more modular architecture. Developer experience improved with
logical file structure and clear separation of concerns.
2025-11-18 21:25:55 +00:00

144 lines
4.5 KiB
Python

"""Chart data export functionality for Tibber Prices sensors."""
from __future__ import annotations
from typing import TYPE_CHECKING
import yaml
from custom_components.tibber_prices.const import CONF_CHART_DATA_CONFIG, DOMAIN
if TYPE_CHECKING:
from datetime import datetime
from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator
from custom_components.tibber_prices.data import TibberPricesConfigEntry
from homeassistant.core import HomeAssistant
async def call_chartdata_service_async(
hass: HomeAssistant,
coordinator: TibberPricesDataUpdateCoordinator,
config_entry: TibberPricesConfigEntry,
) -> tuple[dict | None, str | None]:
"""
Call get_chartdata service with user-configured YAML (async).
Returns:
Tuple of (response, error_message).
If successful: (response_dict, None)
If failed: (None, error_string)
"""
# Get user-configured YAML
yaml_config = config_entry.options.get(CONF_CHART_DATA_CONFIG, "")
# Parse YAML if provided, otherwise use empty dict (service defaults)
service_params = {}
if yaml_config and yaml_config.strip():
try:
parsed = yaml.safe_load(yaml_config)
# Ensure we have a dict (yaml.safe_load can return str, int, etc.)
if isinstance(parsed, dict):
service_params = parsed
else:
coordinator.logger.warning(
"YAML configuration must be a dictionary, got %s. Using service defaults.",
type(parsed).__name__,
)
service_params = {}
except yaml.YAMLError as err:
coordinator.logger.warning(
"Invalid chart data YAML configuration: %s. Using service defaults.",
err,
)
service_params = {} # Fall back to service defaults
# Add required entry_id parameter
service_params["entry_id"] = config_entry.entry_id
# Call get_chartdata service using official HA service system
try:
response = await hass.services.async_call(
DOMAIN,
"get_chartdata",
service_params,
blocking=True,
return_response=True,
)
except Exception as ex:
coordinator.logger.exception("Chart data service call failed")
return None, str(ex)
else:
return response, None
def get_chart_data_state(
chart_data_response: dict | None,
chart_data_error: str | None,
) -> str | None:
"""
Return state for chart_data_export sensor.
Args:
chart_data_response: Last service response (or None)
chart_data_error: Last error message (or None)
Returns:
"error" if error occurred
"ready" if data available
"pending" if no data yet
"""
if chart_data_error:
return "error"
if chart_data_response:
return "ready"
return "pending"
def build_chart_data_attributes(
chart_data_response: dict | None,
chart_data_last_update: datetime | None,
chart_data_error: str | None,
) -> dict[str, object] | None:
"""
Return chart data from last service call as attributes with metadata.
Attribute order: timestamp, error (if any), service data (at the end).
Args:
chart_data_response: Last service response
chart_data_last_update: Timestamp of last update
chart_data_error: Error message if service call failed
Returns:
Dict with timestamp, optional error, and service response data.
"""
# Build base attributes with metadata FIRST
attributes: dict[str, object] = {
"timestamp": chart_data_last_update.isoformat() if chart_data_last_update else None,
}
# Add error message if service call failed
if chart_data_error:
attributes["error"] = chart_data_error
if not chart_data_response:
# No data - only metadata (timestamp, error)
return attributes
# Service data goes LAST - after metadata
if isinstance(chart_data_response, dict):
if len(chart_data_response) > 1:
# Multiple keys → wrap to prevent collision with metadata
attributes["data"] = chart_data_response
else:
# Single key → safe to merge directly
attributes.update(chart_data_response)
else:
# If response is array/list/primitive, wrap it in "data" key
attributes["data"] = chart_data_response
return attributes