diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 487a767..4e1b964 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,182 +1,270 @@ # Copilot Instructions -This repository contains a **custom component for Home Assistant**, intended to be distributed via the **HACS (Home Assistant Community Store)**. +This is a **Home Assistant custom component** for Tibber electricity price data, distributed via **HACS**. It fetches, caches, and enriches **quarter-hourly** electricity prices with statistical analysis, price levels, and ratings. -## Development Guidelines +## Architecture Overview -- Follow the **official Home Assistant development guidelines** at [developers.home-assistant.io](https://developers.home-assistant.io). -- Ensure compatibility with the **latest Home Assistant release**. -- Use **async functions**, **non-blocking I/O**, and **config flows** where applicable. -- Structure the component using these standard files: +**Core Data Flow:** - - `__init__.py` – setup and teardown - - `manifest.json` – metadata and dependencies - - `config_flow.py` – if the integration supports UI configuration - - `sensor.py`, `switch.py`, etc. – for platforms - - `const.py` – constants (`DOMAIN`, `CONF_*`, etc.) +1. `TibberPricesApiClient` (`api.py`) queries Tibber's GraphQL API with `resolution:QUARTER_HOURLY` for user data and prices (yesterday/today/tomorrow - 192 intervals total) +2. `TibberPricesDataUpdateCoordinator` (`coordinator.py`) orchestrates updates every 15 minutes, manages persistent storage via `Store`, and schedules quarter-hour entity refreshes +3. Price enrichment functions (`price_utils.py`, `average_utils.py`) calculate trailing/leading 24h averages, price differences, and rating levels for each 15-minute interval +4. Entity platforms (`sensor.py`, `binary_sensor.py`) expose enriched data as Home Assistant entities +5. Custom services (`services.py`) provide API endpoints for integrations like ApexCharts -- Use Home Assistant's built-in helpers and utility modules: +**Key Patterns:** - - `homeassistant.helpers.entity`, `device_registry`, `config_validation` - - `homeassistant.util.dt` (`dt_util`) for time/date handling +- **Dual translation system**: Standard HA translations in `/translations/` (config flow, UI strings per HA schema), supplemental in `/custom_translations/` (entity descriptions not supported by HA schema). Both must stay in sync. Use `async_load_translations()` and `async_load_standard_translations()` from `const.py`. When to use which: `/translations/` is bound to official HA schema requirements; anything else goes in `/custom_translations/` (requires manual translation loading). +- **Price data enrichment**: All quarter-hourly price intervals get augmented with `trailing_avg_24h`, `difference`, and `rating_level` fields via `enrich_price_info_with_differences()` in `price_utils.py`. Enriched structure example: + ```python + { + "startsAt": "2025-11-03T14:00:00+01:00", + "total": 0.2534, # Original from API + "level": "NORMAL", # Original from API + "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 + } + ``` +- **Quarter-hour precision**: Entities update on 00/15/30/45-minute boundaries via `_schedule_quarter_hour_refresh()` in coordinator, not just on data fetch intervals. This ensures current price sensors update without waiting for the next API poll. +- **Currency handling**: Multi-currency support with major/minor units (e.g., EUR/ct, NOK/øre) via `get_currency_info()` and `format_price_unit_*()` in `const.py`. +- **Intelligent caching strategy**: Minimizes API calls while ensuring data freshness: + - User data cached for 24h (rarely changes) + - Price data validated against calendar day - cleared on midnight turnover to force fresh fetch + - Cache survives HA restarts via `Store` persistence + - API polling intensifies only when tomorrow's data expected (afternoons) + - Stale cache detection via `_is_cache_valid()` prevents using yesterday's data as today's -- Do not wrap built-in functions (e.g., don’t wrap `dt_util.parse_datetime`) -- Avoid third-party or custom libraries unless absolutely necessary -- Never assume static local file paths — use config options and relative paths +**Component Structure:** -## Coding Style Policy +``` +custom_components/tibber_prices/ +├── __init__.py # Entry setup, platform registration +├── coordinator.py # DataUpdateCoordinator with caching/scheduling +├── api.py # GraphQL client with retry/error handling +├── price_utils.py # Price enrichment, level/rating calculations +├── average_utils.py # Trailing/leading average utilities +├── services.py # Custom services (get_price, ApexCharts, etc.) +├── sensor.py # Price/stats/diagnostic sensors +├── binary_sensor.py # Peak/best hour binary sensors +├── entity.py # Base TibberPricesEntity class +├── data.py # @dataclass TibberPricesData +├── const.py # Constants, translation loaders, currency helpers +├── config_flow.py # UI configuration flow +└── services.yaml # Service definitions +``` -- Follow **PEP8**, enforced by **Black**, **isort**, and **Ruff** -- Use **type hints** on all function and method signatures -- Add **docstrings** for all public classes and methods -- Use **f-strings** for string formatting -- Do not use `print()` — use `_LOGGER` for logging -- YAML examples must be **valid**, **minimal**, and **Home Assistant compliant** +## Development Workflow -## Code Structure and Ordering Policy +**Start dev environment:** -Use the following order inside Python modules: +```bash +./scripts/develop # Starts HA in debug mode with config/ dir, sets PYTHONPATH +``` -1. **Imports** +**Linting (auto-fix):** - - Python standard library imports first - - Third-party imports (e.g., `homeassistant.*`) - - Local imports within this component (`from . import xyz`) - - Enforced automatically by `isort` +```bash +./scripts/lint # Runs ruff format + ruff check --fix +``` -2. **Module-level constants and globals** +**Linting (check-only):** - - Define constants and globals at module-level (e.g., `DOMAIN`, `_LOGGER`, `CONF_*`, `DEFAULT_*`) +```bash +./scripts/lint-check # CI mode, no modifications +``` -3. **Top-level functions** +**Testing:** - - Use only for stateless, reusable logic - - Prefix with `_` if internal only (e.g., `_parse_price()`) - - Do not place Home Assistant lifecycle logic here - - **Sort and group top-level functions for maximum readability:** - - Place public API/entry point functions (e.g., service handlers, async setup) at the top of the function section. - - Direct helpers (called by entry points) immediately after, in the order they are called. - - Pure/stateless utility functions that are not tightly coupled to entry points at the end. - - Where possible, order functions so that a function appears before any function that calls it (call hierarchy order). - - Group related functions by logical purpose or data handled, and use `#region`/`#endregion` folding comments for large files if needed. +```bash +pytest tests/ # Unit tests exist (test_*.py) but no framework enforced +``` -4. **Main classes** +**Key commands:** - - Define main classes (Home Assistant Entity classes, DataUpdateCoordinators, and ConfigFlow handlers) - - Order inside class: - - Special methods (`__init__`, `__repr__`) - - Public methods (no `_`) - - Private methods (`_prefix`) - - All I/O or lifecycle methods must be `async def` +- Dev container includes `hass` CLI for manual HA operations +- Use `uv run --active` prefix for running Python tools in the venv +- `.ruff.toml` enforces max line length 120, complexity ≤25, Python 3.13 target -5. **Helper classes** +## Critical Project-Specific Patterns - - If helper classes become complex, move them to separate modules (e.g., `helpers.py`, `models.py`) - -> ✅ Copilot tip: -> -> - Use top-level functions for pure helpers only. -> - Prefer structured classes where Home Assistant expects them. -> - Sort and group functions for maximum readability and maintainability, following call flow from entry points to helpers to utilities. - -## Code Comments Policy - -- Do **not** add comments in the code to explain automated changes, such as reordering, renaming, or compliance with coding standards or prompts. -- Comments in code should be reserved **only** for documenting the actual logic, purpose, or usage of code elements (e.g., classes, methods, functions, or complex logic blocks). -- If any explanations of automated actions are needed, provide them **outside the code file** (such as in your chat response, PR description, or commit message), not within the Python files themselves. -- Do **not** insert comments like `# moved function`, `# renamed`, `# for compliance`, or similar into code files. - -## Backwards Compatibility Policy - -- Do **not** implement or suggest backward compatibility features, workarounds, deprecated function support, or compatibility layers unless **explicitly requested** in the prompt or project documentation. -- All code should assume a clean, modern codebase and should target the latest stable Home Assistant version only, unless otherwise specified. -- If you believe backward compatibility might be required, **ask for clarification first** before adding any related code. - -## Translations Policy - -- All user-facing strings supported by Home Assistant's translation system **must** be defined in `/translations/en.json` and (if present) in other `/translations/*.json` language files. -- When adding or updating a translation key in `/translations/en.json`, **ensure that all other language files in `/translations/` are updated to match the same set of keys**. Non-English files may use placeholder values if no translation is available, but **must** not miss any keys present in `en.json`. -- Do **not** remove or rename translation keys without updating all language files accordingly. -- Never duplicate translation keys between `/translations/` and `/custom_translations/`. -- The `/custom_translations/` directory contains **supplemental translation files** for UI strings or other content not handled by the standard Home Assistant translation format. - - Only add strings to `/custom_translations/` if they are not supported by the standard Home Assistant translation system. - - Do **not** duplicate any string or translation key that could be handled in `/translations/`. -- When both exist, the standard Home Assistant translation in `/translations/` **always takes priority** over any supplemental entry in `/custom_translations/`. -- All translation files (both standard and custom) **must remain in sync** with the English base file (`en.json`) in their respective directory. - -> ✅ Copilot tip: Whenever adding or changing user-facing strings, update both the main translation files in `/translations/` and the supplemental files in `/custom_translations/`, keeping them in sync and avoiding duplication. - -## Data Structures - -Use `@dataclass` for plain data containers where appropriate: +**1. Translation Loading (Async-First)** +Always load translations at integration setup or before first use: ```python -from dataclasses import dataclass - -@dataclass -class PriceSlot: - start: datetime - end: datetime - price: float +# In __init__.py async_setup_entry: +await async_load_translations(hass, "en") +await async_load_standard_translations(hass, "en") ``` -## Visual File Layout +Access cached translations synchronously later via `get_translation(path, language)`. -Split component logic into multiple Python modules for improved clarity and maintainability: +**2. Price Data Enrichment** +Never use raw API price data directly. Always enrich first: -``` -/custom_components/your_component/ -├── __init__.py -├── manifest.json -├── const.py -├── sensor.py -├── config_flow.py -├── models.py # dataclasses -├── helpers.py # pure utility functions +```python +from .price_utils import enrich_price_info_with_differences + +enriched = enrich_price_info_with_differences( + price_info_data, # Raw API response + thresholds, # User-configured rating thresholds +) ``` -Use `#region` / `#endregion` optionally to improve readability in large files. +This adds `trailing_avg_24h`, `difference`, `rating_level` to each interval. -## Optional Files (Custom Integration via HACS) +**3. Time Handling** +Always use `dt_util.as_local()` when comparing API timestamps (UTC) to local time: -Only create these files if explicitly required by your integration features. Not all files used in Core integrations apply to Custom Integrations: +```python +from homeassistant.util import dt as dt_util -- `services.yaml` – Define custom Home Assistant services -- `translations/*.json` (e.g., `en.json`, `de.json`) – Provide translations for UI elements -- Additional platform files (e.g., `binary_sensor.py`, `switch.py`, `number.py`, `button.py`, `select.py`) – Support for additional entity types -- `websocket_api.py` – Define custom WebSocket API endpoints -- `diagnostics.py` – Provide diagnostic data to users and maintainers -- `repair.py` – Offer built-in repair hints or troubleshooting guidance -- `issue_registry.py` – Communicate integration-specific issues or important changes to users clearly +price_time = dt_util.parse_datetime(price_data["startsAt"]) +price_time = dt_util.as_local(price_time) # Convert to local TZ +``` -> ⚠️ **Copilot tip**: Avoid Core-only files (`device_action.py`, `device_trigger.py`, `device_condition.py`, `strings.json`) for Custom Integrations. These are typically not supported or rarely used. +**4. Coordinator Data Structure** +Access coordinator data like: -## Linting and Code Quality Policy +```python +coordinator.data = { + "user_data": {...}, # Cached user info from viewer query + "priceInfo": { + "yesterday": [...], # List of enriched price dicts + "today": [...], + "tomorrow": [...], + "currency": "EUR", + }, +} +``` -- Enforced by **Ruff**, which runs: +**5. Service Response Pattern** +Services use `SupportsResponse.ONLY` and must return dicts: - - Locally via VS Code devcontainer - - Remotely via GitHub Actions +```python +@callback +def async_setup_services(hass: HomeAssistant) -> None: + hass.services.async_register( + DOMAIN, "get_price", _get_price, + schema=PRICE_SERVICE_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) +``` -Key Ruff linter rules that must be followed: +## Code Quality Rules -- `F401`, `F841` – No unused imports or variables -- `E402`, `E501` – Imports at top, lines ≤88 chars -- `C901`, `PLR0912`, `PLR0915` – Functions must be small and simple -- `PLR0911`, `RET504` – No redundant `else` after `return` -- `B008` – No mutable default arguments -- `T201` – Do not use `print()` -- `SIM102` – Prefer `if x` over `if x == True` +**Ruff config (`.ruff.toml`):** -Also: +- Max line length: **120** chars (not 88 from default Black) +- Max complexity: **25** (McCabe) +- Target: Python 3.13 +- No unused imports/variables (`F401`, `F841`) +- No mutable default args (`B008`) +- Use `_LOGGER` not `print()` (`T201`) -- Use **Black** for formatting -- Use **isort** for import sorting -- See `.ruff.toml` for custom settings -- Prefer **one return statement per function** unless early returns improve clarity +**Import order (enforced by isort):** -## Tests +1. Python stdlib +2. Third-party (`homeassistant.*`, `aiohttp`, etc.) +3. Local (`.api`, `.const`, etc.) -This integration does **not include automated tests** by default. +**Error handling best practices:** -> ⚠️ If Copilot generates tests, keep them minimal and **do not introduce new test frameworks** not already present. +- Keep try blocks minimal - only wrap code that can throw exceptions +- Process data **after** the try/except block, not inside +- Catch specific exceptions, avoid bare `except Exception:` (allowed only in config flows and background tasks) +- Use `ConfigEntryNotReady` for temporary failures (device offline) +- Use `ConfigEntryAuthFailed` for auth issues +- Use `ServiceValidationError` for user input errors in services + +**Logging guidelines:** + +- Use lazy logging: `_LOGGER.debug("Message with %s", variable)` +- No periods at end of log messages +- No integration name in messages (added automatically) +- Debug level for non-user-facing messages + +**Function organization:** +Public entry points → direct helpers (call order) → pure utilities. Prefix private helpers with `_`. + +**No backwards compatibility code** unless explicitly requested. Target latest HA stable only. + +**Translation sync:** When updating `/translations/en.json`, update ALL language files (`de.json`, etc.) with same keys (placeholder values OK). + +## Common Tasks + +**Add a new sensor:** + +1. Define entity description in `sensor.py` (add to `SENSOR_TYPES`) +2. Add translation keys to `/translations/en.json` and `/custom_translations/en.json` +3. Sync all language files +4. Implement `@property` methods in `TibberPricesSensor` class + +**Modify price calculations:** +Edit `price_utils.py` or `average_utils.py`. These are stateless pure functions operating on price lists. + +**Add a new service:** + +1. Define schema in `services.py` (top-level constants) +2. Add service definition to `services.yaml` +3. Implement handler function in `services.py` +4. Register in `async_setup_services()` + +**Change update intervals:** +Edit `UPDATE_INTERVAL` in `coordinator.py` (default: 15 min) or `QUARTER_HOUR_BOUNDARIES` for entity refresh timing. + +**Debug GraphQL queries:** +Check `api.py` → `QueryType` enum and `_build_query()` method. Queries are dynamically constructed based on operation type. + +## Anti-Patterns to Avoid + +**Never do these:** + +```python +# ❌ Blocking operations in event loop +data = requests.get(url) # Use aiohttp with async_get_clientsession(hass) +time.sleep(5) # Use await asyncio.sleep(5) + +# ❌ Processing data inside try block +try: + data = await api.get_data() + processed = data["value"] * 100 # Move outside try + self._attr_native_value = processed +except ApiError: + pass + +# ❌ Hardcoded strings (not translatable) +self._attr_name = "Temperature Sensor" # Use translation_key instead + +# ❌ Accessing hass.data directly in tests +coord = hass.data[DOMAIN][entry.entry_id] # Use proper fixtures + +# ❌ User-configurable polling intervals +vol.Optional("scan_interval"): cv.positive_int # Not allowed, integration determines this +``` + +**Do these instead:** + +```python +# ✅ Async operations +data = await session.get(url) +await asyncio.sleep(5) + +# ✅ Process after exception handling +try: + data = await api.get_data() +except ApiError: + return +processed = data["value"] * 100 # Safe processing after try/except + +# ✅ Translatable entities +_attr_has_entity_name = True +_attr_translation_key = "temperature_sensor" + +# ✅ Proper test setup with fixtures +@pytest.fixture +async def init_integration(hass, mock_config_entry): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + return mock_config_entry +```